Files
indiekit-endpoint-activitypub/lib/federation-setup.js
Ricardo 4495667ed9 fix: remove RSA Multikey from assertionMethod to fix tags.pub signature verification
The Fedify 2.0 migration added assertionMethods = keyPairs.map(k => k.multikey),
which places the RSA Multikey (id: #main-key) into assertionMethod alongside the
Ed25519 Multikey (id: #key-2).

This creates a keyId collision: the RSA CryptographicKey in publicKey and the RSA
Multikey in assertionMethod both use #main-key. Servers that traverse JSON-LD
properties alphabetically (assertionMethod before publicKey) find the Multikey
first — which lacks publicKeyPem — and return "public key not found".

Fix: filter assertionMethods to only Ed25519 keys (Object Integrity Proofs).
RSA keys already have their correct representation in publicKey (HTTP Signatures).
This matches Mastodon's behavior and is semantically correct per the two key systems.
2026-03-22 15:00:14 +01:00

891 lines
30 KiB
JavaScript

/**
* 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,
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: [
{
// All Fedify logs — federation, vocab, delivery, HTTP 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,
});
// --- 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 = 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 notDeleted = { "properties.deleted": { $exists: false } };
const total = await postsCollection.countDocuments(notDeleted);
const posts = await postsCollection
.find(notDeleted)
.sort({ "properties.published": -1 })
.skip(skip)
.limit(pageSize)
.toArray();
const { jf2ToAS2Activity } = await import("./jf2-to-as2.js");
const items = posts
.map((post) => {
try {
return 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.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}`;
const post = await collections.posts.findOne({ "properties.url": postUrl });
if (!post) return null;
// Soft-deleted posts should not be dereferenceable
if (post.properties?.deleted) return null;
const actorUrl = ctx.getActorUri(handle).href;
const activity = 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;
},
);
}
// --- 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<import("@fedify/fedify").Actor|null>}
*/
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: `<a href="${actorUrl}" rel="me">${actorUrl}</a>`,
})
: 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
return `<a href="${escaped}" rel="me">${escaped}</a>`;
}
// 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";
}