/** * Fedify Federation setup — configures the Federation instance with all * dispatchers, inbox listeners, and collection handlers. * * This replaces the hand-rolled federation.js, actor.js, keys.js, webfinger.js, * and inbox.js with Fedify's battle-tested implementations. */ import { AsyncLocalStorage } from "node:async_hooks"; import { createRequire } from "node:module"; import { Temporal } from "@js-temporal/polyfill"; import { createFederation, InProcessMessageQueue, ParallelMessageQueue, } from "@fedify/fedify"; import { exportJwk, generateCryptoKeyPair, importJwk, } from "@fedify/fedify/sig"; import { Application, Article, Create, Endpoints, Group, Hashtag, Image, Like, Note, Organization, Person, PropertyValue, Service, } from "@fedify/fedify/vocab"; import { configure, getConsoleSink } from "@logtape/logtape"; import { RedisMessageQueue, RedisKvStore } from "@fedify/redis"; import { createFederationDebugger } from "@fedify/debugger"; import Redis from "ioredis"; import { MongoKvStore } from "./kv-store.js"; import { registerInboxListeners } from "./inbox-listeners.js"; import { jf2ToAS2Activity, resolvePostUrl } from "./jf2-to-as2.js"; import { cachedQuery } from "./redis-cache.js"; import { onOutboxPermanentFailure } from "./outbox-failure.js"; const COLLECTION_CACHE_TTL = 300; // 5 minutes /** * Create and configure a Fedify Federation instance. * * @param {object} options * @param {object} options.collections - MongoDB collections * @param {string} options.mountPath - Plugin mount path (e.g. "/activitypub") * @param {string} options.handle - Actor handle (e.g. "rick") * @param {boolean} options.storeRawActivities - Whether to store full raw JSON * @returns {{ federation: import("@fedify/fedify").Federation }} */ // Track whether LogTape has been configured (can only call configure() once) let _logtapeConfigured = false; export function setupFederation(options) { const { collections, mountPath, handle, storeRawActivities = false, redisUrl = "", publicationUrl = "", parallelWorkers = 5, actorType = "Person", logLevel = "warning", debugDashboard = false, debugPassword = "", } = options; // Map config string to Fedify actor class const actorTypeMap = { Person, Service, Application, Organization, Group }; const ActorClass = actorTypeMap[actorType] || Person; // Configure LogTape for Fedify delivery logging (once per process). // When the debug dashboard is enabled, skip this — the debugger // auto-configures LogTape with per-trace log collection + OpenTelemetry. // Valid levels: "debug" | "info" | "warning" | "error" | "fatal" const validLevels = ["debug", "info", "warning", "error", "fatal"]; const resolvedLevel = validLevels.includes(logLevel) ? logLevel : "warning"; if (!debugDashboard && !_logtapeConfigured) { _logtapeConfigured = true; configure({ contextLocalStorage: new AsyncLocalStorage(), sinks: { console: getConsoleSink(), }, loggers: [ { // Noise guard: remote deleted actors often return 404/410 on fetch. // Keep only fatal events for the docloader category. category: ["fedify", "runtime", "docloader"], sinks: ["console"], lowestLevel: "fatal", }, { // All remaining Fedify logs - federation, vocab, delivery, signatures. category: ["fedify"], sinks: ["console"], lowestLevel: resolvedLevel, }, ], }).catch((error) => { console.warn("[ActivityPub] LogTape configure failed:", error.message); }); } let queue; let kv; if (redisUrl) { const redisQueue = new RedisMessageQueue(() => new Redis(redisUrl)); if (parallelWorkers > 1) { queue = new ParallelMessageQueue(redisQueue, parallelWorkers); console.info( `[ActivityPub] Using Redis message queue with ${parallelWorkers} parallel workers`, ); } else { queue = redisQueue; console.info("[ActivityPub] Using Redis message queue (single worker)"); } // Use Redis for Fedify KV store — idempotence records, public key cache, // remote document cache. Redis handles TTL natively so entries auto-expire // instead of growing unbounded in MongoDB. kv = new RedisKvStore(new Redis(redisUrl)); console.info("[ActivityPub] Using Redis KV store for Fedify"); } else { queue = new InProcessMessageQueue(); kv = new MongoKvStore(collections.ap_kv); console.warn( "[ActivityPub] Using in-process message queue + MongoDB KV store (not recommended for production)", ); } const federation = createFederation({ kv, queue, // Accept signatures up to 12 h old. // Mastodon retries failed deliveries with the original signature, which // can be hours old by the time the delivery succeeds. signatureTimeWindow: { hours: 12 }, // Allow fetching own-site URLs that resolve to private IPs (e.g. when // the blog hostname resolves to a RFC-1918 address on the local LAN). // Without this, Fedify's SSRF guard blocks lookupObject() and WebFinger // calls for own-site posts, producing errors in the activity log. allowPrivateAddress: true, }); // --- Actor dispatcher --- federation .setActorDispatcher( `${mountPath}/users/{identifier}`, async (ctx, identifier) => { // Instance actor: Application-type actor for the domain itself // Required for authorized fetch to avoid infinite loops const hostname = ctx.url?.hostname || ""; if (identifier === hostname) { const keyPairs = await ctx.getActorKeyPairs(identifier); const appOptions = { id: ctx.getActorUri(identifier), preferredUsername: hostname, name: hostname, inbox: ctx.getInboxUri(identifier), outbox: ctx.getOutboxUri(identifier), endpoints: new Endpoints({ sharedInbox: ctx.getInboxUri() }), }; if (keyPairs.length > 0) { appOptions.publicKey = keyPairs[0].cryptographicKey; // Only include Ed25519 keys in assertionMethod (Object Integrity Proofs). // RSA keys belong only in publicKey (HTTP Signatures). Putting the RSA // Multikey in assertionMethod with the same #main-key id as the // CryptographicKey in publicKey causes id collisions — servers that // traverse JSON-LD properties alphabetically (assertionMethod before // publicKey) find the Multikey first, which has no publicKeyPem, // and fail signature verification. appOptions.assertionMethods = keyPairs .filter((k) => k.privateKey.algorithm.name !== "RSASSA-PKCS1-v1_5") .map((k) => k.multikey); } return new Application(appOptions); } if (identifier !== handle) return null; return buildPersonActor(ctx, identifier, collections, actorType); }, ) .mapHandle((_ctx, username) => { if (username === handle) return handle; // Accept hostname as valid identifier for instance actor if (publicationUrl) { try { const hostname = new URL(publicationUrl).hostname; if (username === hostname) return hostname; } catch { /* ignore */ } } return null; }) .mapAlias((_ctx, alias) => { // Resolve profile URL and /@handle patterns via WebFinger. // Must return { identifier } or { username }, not a bare string. if (!publicationUrl) return null; try { const pub = new URL(publicationUrl); if (alias.hostname !== pub.hostname) return null; const path = alias.pathname.replace(/\/$/, ""); if (path === "" || path === `/@${handle}`) return { identifier: handle }; } catch { /* ignore */ } return null; }) .setKeyPairsDispatcher(async (ctx, identifier) => { // Allow key pairs for both the main actor and instance actor const hostname = ctx.url?.hostname || ""; if (identifier !== handle && identifier !== hostname) return []; const keyPairs = []; // --- Legacy RSA key pair (HTTP Signatures) --- const legacyKey = await collections.ap_keys.findOne({ type: "rsa" }); // Fall back to old schema (no type field) for backward compat const rsaDoc = legacyKey || (await collections.ap_keys.findOne({ publicKeyPem: { $exists: true }, })); if (rsaDoc?.publicKeyPem && rsaDoc?.privateKeyPem) { try { const publicKey = await importSpkiPem(rsaDoc.publicKeyPem); const privateKey = await importPkcs8Pem(rsaDoc.privateKeyPem); keyPairs.push({ publicKey, privateKey }); } catch { console.warn("[ActivityPub] Could not import legacy RSA keys"); } } // --- Ed25519 key pair (Object Integrity Proofs) --- // Load from DB or generate + persist on first use let ed25519Doc = await collections.ap_keys.findOne({ type: "ed25519", }); if (ed25519Doc?.publicKeyJwk && ed25519Doc?.privateKeyJwk) { try { const publicKey = await importJwk( ed25519Doc.publicKeyJwk, "public", ); const privateKey = await importJwk( ed25519Doc.privateKeyJwk, "private", ); keyPairs.push({ publicKey, privateKey }); } catch (error) { console.warn( "[ActivityPub] Could not import Ed25519 keys, regenerating:", error.message, ); ed25519Doc = null; // Force regeneration below } } if (!ed25519Doc) { try { const ed25519 = await generateCryptoKeyPair("Ed25519"); await collections.ap_keys.insertOne({ type: "ed25519", publicKeyJwk: await exportJwk(ed25519.publicKey), privateKeyJwk: await exportJwk(ed25519.privateKey), createdAt: new Date().toISOString(), }); keyPairs.push(ed25519); console.info( "[ActivityPub] Generated and persisted Ed25519 key pair", ); } catch (error) { console.warn( "[ActivityPub] Could not generate Ed25519 key pair:", error.message, ); } } return keyPairs; }); // NOTE: .authorize() is intentionally NOT chained here. // Fedify's authorize predicate triggers HTTP Signature verification on // every GET to the actor endpoint. When a remote server that requires // authorized fetch (e.g. kobolds.online, void.ello.tech) requests our // actor, Fedify tries to fetch THEIR public key to verify the signature. // Those instances return 401, causing a FetchError that Fedify doesn't // catch — resulting in 500s for those servers and error log spam. // Authorized fetch requires authenticated document loading (using the // instance actor's keys for outgoing fetches), which Fedify doesn't yet // support out of the box. Re-enable once Fedify adds this capability. // --- WebFinger custom links --- // Add OStatus subscribe template so remote servers (WordPress AP, Misskey, etc.) // can redirect users to our authorize_interaction page for remote follow. federation.setWebFingerLinksDispatcher((_ctx, _resource) => { return [ { rel: "http://ostatus.org/schema/1.0/subscribe", template: `${publicationUrl}${mountPath.replace(/^\//, "")}/authorize_interaction?uri={uri}`, }, ]; }); // --- Inbox listeners --- const inboxChain = federation.setInboxListeners( `${mountPath}/users/{identifier}/inbox`, `${mountPath}/inbox`, ); registerInboxListeners(inboxChain, { collections, handle, storeRawActivities, }); // Enable authenticated fetches for the shared inbox. // Without this, Fedify can't verify incoming HTTP Signatures from servers // that require authorized fetch (e.g. hachyderm.io returns 401 on unsigned GETs). // This tells Fedify to use our actor's key pair when fetching remote actor // documents during signature verification on the shared inbox. inboxChain.setSharedKeyDispatcher((_ctx) => ({ identifier: handle })); // --- Collection dispatchers --- setupFollowers(federation, mountPath, handle, collections); setupFollowing(federation, mountPath, handle, collections); setupOutbox(federation, mountPath, handle, collections); setupLiked(federation, mountPath, handle, collections); setupFeatured(federation, mountPath, handle, collections, publicationUrl); setupFeaturedTags(federation, mountPath, handle, collections, publicationUrl); // --- Object dispatchers (make posts dereferenceable) --- setupObjectDispatchers(federation, mountPath, handle, collections, publicationUrl); // --- NodeInfo --- // Fedify 2.0: software.version is now a plain string (was SemVer object) let softwareVersion = "1.0.0"; try { const require = createRequire(import.meta.url); const pkg = require("@indiekit/indiekit/package.json"); if (pkg.version) softwareVersion = pkg.version; } catch { /* fallback to "1.0.0" */ } federation.setNodeInfoDispatcher("/nodeinfo/2.1", async () => { const postsCount = collections.posts ? await collections.posts.countDocuments() : 0; return { software: { name: "indiekit", version: softwareVersion, }, protocols: ["activitypub"], usage: { users: { total: 1, activeMonth: 1, activeHalfyear: 1 }, localPosts: postsCount, localComments: 0, }, }; }); // Handle permanent delivery failures (Fedify 2.0). // Fires when a remote inbox returns 404/410. // 410: immediate full cleanup. 404: strike system (3 strikes over 7 days). federation.setOutboxPermanentFailureHandler(async (_ctx, values) => { await onOutboxPermanentFailure( values.statusCode, values.actorIds, values.inbox, collections, ); }); // Wrap with debug dashboard if enabled. The debugger proxies the // Federation object and intercepts requests at {mountPath}/__debug__/, // serving a live dashboard showing traces, activities, signature // verification, and correlated logs. It auto-configures OpenTelemetry // tracing and LogTape per-trace log collection. let activeFederation = federation; if (debugDashboard) { const debugOptions = { path: `${mountPath}/__debug__`, }; if (debugPassword) { debugOptions.auth = { type: "password", password: debugPassword }; } activeFederation = createFederationDebugger(federation, debugOptions); console.info( `[ActivityPub] Debug dashboard enabled at ${mountPath}/__debug__/` + (debugPassword ? " (password-protected)" : " (WARNING: no password set)"), ); } // Start the message queue for outbound activity delivery. // Without this, ctx.sendActivity() enqueues delivery tasks but the // InProcessMessageQueue never processes them — activities are never // actually POSTed to follower inboxes. activeFederation.startQueue().catch((error) => { console.error("[ActivityPub] Failed to start delivery queue:", error.message); }); return { federation: activeFederation }; } // --- Collection setup helpers --- function setupFollowers(federation, mountPath, handle, collections) { federation .setFollowersDispatcher( `${mountPath}/users/{identifier}/followers`, async (ctx, identifier, cursor) => { if (identifier !== handle) return null; // One-shot collection: when cursor is null, return ALL followers // as Recipient objects so sendActivity("followers") can deliver. // See: https://fedify.dev/manual/collections#one-shot-followers-collection-for-gathering-recipients if (cursor == null) { const docs = await cachedQuery("col:followers:recipients", COLLECTION_CACHE_TTL, async () => { return await collections.ap_followers .find() .sort({ followedAt: -1 }) .toArray(); }); return { items: docs.map((f) => ({ id: new URL(f.actorUrl), inboxId: f.inbox ? new URL(f.inbox) : null, endpoints: f.sharedInbox ? { sharedInbox: new URL(f.sharedInbox) } : null, })), }; } // Paginated collection: for remote browsing of /followers endpoint const pageSize = 20; const skip = Number.parseInt(cursor, 10); const [docs, total] = await cachedQuery(`col:followers:page:${cursor}`, COLLECTION_CACHE_TTL, async () => { const d = await collections.ap_followers .find() .sort({ followedAt: -1 }) .skip(skip) .limit(pageSize) .toArray(); const t = await collections.ap_followers.countDocuments(); return [d, t]; }); return { items: docs.map((f) => new URL(f.actorUrl)), nextCursor: skip + pageSize < total ? String(skip + pageSize) : null, }; }, ) .setCounter(async (ctx, identifier) => { if (identifier !== handle) return 0; return await cachedQuery("col:followers:count", COLLECTION_CACHE_TTL, async () => { return await collections.ap_followers.countDocuments(); }); }) .setFirstCursor(async () => "0"); } function setupFollowing(federation, mountPath, handle, collections) { federation .setFollowingDispatcher( `${mountPath}/users/{identifier}/following`, async (ctx, identifier, cursor) => { if (identifier !== handle) return null; const pageSize = 20; const skip = cursor ? Number.parseInt(cursor, 10) : 0; const [docs, total] = await cachedQuery(`col:following:page:${cursor}`, COLLECTION_CACHE_TTL, async () => { const d = await collections.ap_following .find() .sort({ followedAt: -1 }) .skip(skip) .limit(pageSize) .toArray(); const t = await collections.ap_following.countDocuments(); return [d, t]; }); return { items: docs.map((f) => new URL(f.actorUrl)), nextCursor: skip + pageSize < total ? String(skip + pageSize) : null, }; }, ) .setCounter(async (ctx, identifier) => { if (identifier !== handle) return 0; return await cachedQuery("col:following:count", COLLECTION_CACHE_TTL, async () => { return await collections.ap_following.countDocuments(); }); }) .setFirstCursor(async () => "0"); } function setupLiked(federation, mountPath, handle, collections) { federation .setLikedDispatcher( `${mountPath}/users/{identifier}/liked`, async (ctx, identifier, cursor) => { if (identifier !== handle) return null; if (!collections.posts) return { items: [] }; const pageSize = 20; const skip = cursor ? Number.parseInt(cursor, 10) : 0; const query = { "properties.post-type": "like" }; const [docs, total] = await cachedQuery(`col:liked:page:${cursor}`, COLLECTION_CACHE_TTL, async () => { const d = await collections.posts .find(query) .sort({ "properties.published": -1 }) .skip(skip) .limit(pageSize) .toArray(); const t = await collections.posts.countDocuments(query); return [d, t]; }); const items = docs .map((d) => { const likeOf = d.properties?.["like-of"]; return likeOf ? new URL(likeOf) : null; }) .filter(Boolean); return { items, nextCursor: skip + pageSize < total ? String(skip + pageSize) : null, }; }, ) .setCounter(async (ctx, identifier) => { if (identifier !== handle) return 0; if (!collections.posts) return 0; return await cachedQuery("col:liked:count", COLLECTION_CACHE_TTL, async () => { return await collections.posts.countDocuments({ "properties.post-type": "like", }); }); }) .setFirstCursor(async () => "0"); } function setupFeatured(federation, mountPath, handle, collections, publicationUrl) { federation.setFeaturedDispatcher( `${mountPath}/users/{identifier}/featured`, async (ctx, identifier) => { if (identifier !== handle) return null; if (!collections.ap_featured) return { items: [] }; const docs = await collections.ap_featured .find() .sort({ pinnedAt: -1 }) .toArray(); // Convert pinned post URLs to Fedify Note/Article objects const items = []; for (const doc of docs) { if (!collections.posts) continue; const post = await collections.posts.findOne({ "properties.url": doc.postUrl, }); if (!post) continue; const actorUrl = ctx.getActorUri(identifier).href; const activity = await jf2ToAS2Activity( post.properties, actorUrl, publicationUrl, ); if (activity instanceof Create) { const obj = await activity.getObject(); if (obj) items.push(obj); } } return { items }; }, ); } function setupFeaturedTags(federation, mountPath, handle, collections, publicationUrl) { federation.setFeaturedTagsDispatcher( `${mountPath}/users/{identifier}/tags`, async (ctx, identifier) => { if (identifier !== handle) return null; if (!collections.ap_featured_tags) return { items: [] }; const docs = await collections.ap_featured_tags .find() .sort({ addedAt: -1 }) .toArray(); const baseUrl = publicationUrl ? publicationUrl.replace(/\/$/, "") : ctx.url.origin; const items = docs.map( (doc) => new Hashtag({ name: `#${doc.tag}`, href: new URL( `/categories/${encodeURIComponent(doc.tag)}`, baseUrl, ), }), ); return { items }; }, ); } function setupOutbox(federation, mountPath, handle, collections) { federation .setOutboxDispatcher( `${mountPath}/users/{identifier}/outbox`, async (ctx, identifier, cursor) => { if (identifier !== handle) return null; const postsCollection = collections.posts; if (!postsCollection) return { items: [] }; const pageSize = 20; const skip = cursor ? Number.parseInt(cursor, 10) : 0; const federationVisibilityQuery = { "properties.post-status": { $ne: "draft" }, "properties.visibility": { $ne: "unlisted" }, "properties.deleted": { $exists: false }, }; const total = await postsCollection.countDocuments( federationVisibilityQuery, ); const posts = await postsCollection .find(federationVisibilityQuery) .sort({ "properties.published": -1 }) .skip(skip) .limit(pageSize) .toArray(); const { jf2ToAS2Activity } = await import("./jf2-to-as2.js"); const items = ( await Promise.all( posts.map(async (post) => { try { return await jf2ToAS2Activity( post.properties, ctx.getActorUri(identifier).href, collections._publicationUrl, ); } catch { return null; } }), ) ).filter(Boolean); return { items, nextCursor: skip + pageSize < total ? String(skip + pageSize) : null, }; }, ) .setCounter(async (ctx, identifier) => { if (identifier !== handle) return 0; const postsCollection = collections.posts; if (!postsCollection) return 0; return await postsCollection.countDocuments({ "properties.post-status": { $ne: "draft" }, "properties.visibility": { $ne: "unlisted" }, "properties.deleted": { $exists: false }, }); }) .setFirstCursor(async () => "0"); } function setupObjectDispatchers(federation, mountPath, handle, collections, publicationUrl) { // Shared lookup: find post by URL path, convert to Fedify Note/Article async function resolvePost(ctx, id) { if (!collections.posts || !publicationUrl) return null; const postUrl = `${publicationUrl.replace(/\/$/, "")}/${id}`; // Match with or without trailing slash — AP object URLs omit the slash // but posts are stored with one, so an exact match would fail. const post = await collections.posts.findOne({ "properties.url": { $in: [postUrl, postUrl + "/"] }, }); if (!post) return null; if (post?.properties?.["post-status"] === "draft") return null; if (post?.properties?.visibility === "unlisted") return null; // Soft-deleted posts should not be dereferenceable if (post.properties?.deleted) return null; const actorUrl = ctx.getActorUri(handle).href; const activity = await jf2ToAS2Activity(post.properties, actorUrl, publicationUrl); // Only Create activities wrap Note/Article objects if (!(activity instanceof Create)) return null; return await activity.getObject(); } // Note dispatcher — handles note, reply, bookmark, jam, rsvp, checkin federation.setObjectDispatcher( Note, `${mountPath}/objects/note/{+id}`, async (ctx, { id }) => { const obj = await resolvePost(ctx, id); return obj instanceof Note ? obj : null; }, ); // Article dispatcher federation.setObjectDispatcher( Article, `${mountPath}/objects/article/{+id}`, async (ctx, { id }) => { const obj = await resolvePost(ctx, id); return obj instanceof Article ? obj : null; }, ); // Like activity dispatcher — makes AP-like activities dereferenceable. // Per ActivityPub §3.1, objects with an `id` MUST be fetchable at that URI. // Like activities produced by jf2ToAS2Activity carry a canonical id at // /activitypub/activities/like/{post-path}; this dispatcher serves them. federation.setObjectDispatcher( Like, `${mountPath}/activities/like/{+id}`, async (ctx, { id }) => { if (!collections.posts || !publicationUrl) return null; const postUrl = `${publicationUrl.replace(/\/$/, "")}/${id}`; const post = await collections.posts.findOne({ "properties.url": { $in: [postUrl, postUrl + "/"] }, }); if (!post) return null; if (post?.properties?.["post-status"] === "draft") return null; if (post?.properties?.visibility === "unlisted") return null; if (post.properties?.deleted) return null; const actorUrl = ctx.getActorUri(handle).href; const activity = await jf2ToAS2Activity(post.properties, actorUrl, publicationUrl); return activity instanceof Like ? activity : null; }, ); } // --- Helpers --- async function getProfile(collections) { const doc = await collections.ap_profile.findOne({}); return doc || {}; } /** * Build the Person/Service/Organization actor object from the stored profile. * Used by both the actor dispatcher (for serving the actor to federation * requests) and broadcastActorUpdate() (for sending Update activities). * * @param {object} ctx - Fedify context (base Context or RequestContext) * @param {string} identifier - Actor handle (e.g. "rick") * @param {object} collections - MongoDB collections * @param {string} [defaultActorType="Person"] - Fallback actor type * @returns {Promise} */ export async function buildPersonActor( ctx, identifier, collections, defaultActorType = "Person", ) { const actorTypeMap = { Person, Service, Application, Organization, Group }; const profile = await getProfile(collections); const keyPairs = await ctx.getActorKeyPairs(identifier); const personOptions = { id: ctx.getActorUri(identifier), preferredUsername: identifier, name: profile.name || identifier, url: profile.url ? new URL(profile.url) : null, inbox: ctx.getInboxUri(identifier), outbox: ctx.getOutboxUri(identifier), followers: ctx.getFollowersUri(identifier), following: ctx.getFollowingUri(identifier), liked: ctx.getLikedUri(identifier), featured: ctx.getFeaturedUri(identifier), featuredTags: ctx.getFeaturedTagsUri(identifier), endpoints: new Endpoints({ sharedInbox: ctx.getInboxUri() }), manuallyApprovesFollowers: profile.manuallyApprovesFollowers || false, }; if (profile.summary) { personOptions.summary = profile.summary; } if (profile.icon) { personOptions.icon = new Image({ url: new URL(profile.icon), mediaType: guessImageMediaType(profile.icon), }); } if (profile.image) { personOptions.image = new Image({ url: new URL(profile.image), mediaType: guessImageMediaType(profile.image), }); } if (keyPairs.length > 0) { personOptions.publicKey = keyPairs[0].cryptographicKey; // Only include Ed25519 keys in assertionMethod (Object Integrity Proofs). // RSA keys belong only in publicKey (HTTP Signatures). See instance actor // above for the full explanation of why this filter is necessary. personOptions.assertionMethods = keyPairs .filter((k) => k.privateKey.algorithm.name !== "RSASSA-PKCS1-v1_5") .map((k) => k.multikey); } // Build profile field attachments (PropertyValue). // Always include a "Fediverse" field with the actor's handle — this serves // two purposes: (1) shows the canonical fediverse address on the profile, // and (2) ensures 2+ attachments when combined with user-defined fields, // preventing Fedify's JSON-LD compaction from collapsing single-element // arrays to plain objects (which Mastodon's update_account_fields rejects). const actorUrl = ctx.getActorUri(identifier)?.href; const fediverseField = actorUrl ? new PropertyValue({ name: "Fediverse", value: `${actorUrl}`, }) : null; if (profile.attachments?.length > 0) { personOptions.attachments = profile.attachments.map( (att) => new PropertyValue({ name: att.name, value: formatAttachmentValue(att.value), }), ); // Append fediverse field if not already present in user-defined fields if (fediverseField && !profile.attachments.some((a) => a.name === "Fediverse")) { personOptions.attachments.push(fediverseField); } } else if (fediverseField) { personOptions.attachments = [fediverseField]; } if (profile.alsoKnownAs?.length > 0) { personOptions.alsoKnownAs = profile.alsoKnownAs.map((u) => new URL(u)); } if (profile.createdAt) { personOptions.published = Temporal.Instant.from(profile.createdAt); } const profileActorType = profile.actorType || defaultActorType; const ResolvedActorClass = actorTypeMap[profileActorType] || Person; return new ResolvedActorClass(personOptions); } /** * Import an SPKI PEM public key using Web Crypto API. * Replaces Fedify 1.x's importSpki() which was removed in 2.0. */ async function importSpkiPem(pem) { const lines = pem .replace("-----BEGIN PUBLIC KEY-----", "") .replace("-----END PUBLIC KEY-----", "") .replace(/\s/g, ""); const der = Uint8Array.from(atob(lines), (c) => c.charCodeAt(0)); return crypto.subtle.importKey( "spki", der, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, true, ["verify"], ); } /** * Import a PKCS#8 PEM private key using Web Crypto API. * Fedify's importPem only handles PKCS#1, but Node.js crypto generates PKCS#8. */ async function importPkcs8Pem(pem) { const lines = pem .replace("-----BEGIN PRIVATE KEY-----", "") .replace("-----END PRIVATE KEY-----", "") .replace(/\s/g, ""); const der = Uint8Array.from(atob(lines), (c) => c.charCodeAt(0)); return crypto.subtle.importKey( "pkcs8", der, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, true, ["sign"], ); } /** * Format an attachment value for ActivityPub PropertyValue. * If the value looks like a URL, wrap it in an HTML anchor tag with rel="me" * so Mastodon can verify profile link ownership. Plain text values pass through. */ function formatAttachmentValue(value) { if (!value) return ""; const trimmed = value.trim(); // Already contains HTML — pass through if (trimmed.startsWith("<")) return trimmed; // URL — wrap in anchor with rel="me" if (/^https?:\/\//i.test(trimmed)) { const escaped = trimmed .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); return `${escaped}`; } // Plain text (e.g. pronouns) — return as-is return trimmed; } function guessImageMediaType(url) { const ext = url.split(".").pop()?.toLowerCase(); const types = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp", svg: "image/svg+xml", avif: "image/avif", }; return types[ext] || "image/jpeg"; }