fix: broadcastActorUpdate was silently failing due to Fedify API mismatch

ctx.getActor() only exists on RequestContext (inside HTTP handlers), not
on the base Context returned by createContext(). Extracted actor-building
logic into shared buildPersonActor() helper used by both the dispatcher
and broadcastActorUpdate(). Profile link attachments now propagate to
remote instances via Update(Person) activity.
This commit is contained in:
Ricardo
2026-02-22 11:27:35 +01:00
parent 0fa446ceb2
commit c648606525
4 changed files with 98 additions and 75 deletions
+10 -4
View File
@@ -1,6 +1,6 @@
import express from "express"; import express from "express";
import { setupFederation } from "./lib/federation-setup.js"; import { setupFederation, buildPersonActor } from "./lib/federation-setup.js";
import { import {
createFedifyMiddleware, createFedifyMiddleware,
} from "./lib/federation-bridge.js"; } from "./lib/federation-bridge.js";
@@ -690,9 +690,15 @@ export default class ActivityPubEndpoint {
{ handle, publicationUrl: this._publicationUrl }, { handle, publicationUrl: this._publicationUrl },
); );
// Retrieve the full actor from the dispatcher (same object remote // Build the full actor object (same as what the dispatcher serves).
// servers will get when they re-fetch the actor URL) // Note: ctx.getActor() only exists on RequestContext, not the base
const actor = await ctx.getActor(handle); // Context returned by createContext(), so we use the shared helper.
const actor = await buildPersonActor(
ctx,
handle,
this._collections,
this.options.actorType,
);
if (!actor) { if (!actor) {
console.warn("[ActivityPub] broadcastActorUpdate: could not build actor"); console.warn("[ActivityPub] broadcastActorUpdate: could not build actor");
return; return;
+85 -68
View File
@@ -142,74 +142,7 @@ export function setupFederation(options) {
if (identifier !== handle) return null; if (identifier !== handle) return null;
const profile = await getProfile(collections); return buildPersonActor(ctx, identifier, collections, actorType);
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;
personOptions.assertionMethods = keyPairs.map((k) => k.multikey);
}
if (profile.attachments?.length > 0) {
personOptions.attachments = profile.attachments.map(
(att) =>
new PropertyValue({
name: att.name,
value: formatAttachmentValue(att.value),
}),
);
}
if (profile.alsoKnownAs?.length > 0) {
personOptions.alsoKnownAs = profile.alsoKnownAs.map(
(u) => new URL(u),
);
}
if (profile.createdAt) {
personOptions.published = Temporal.Instant.from(profile.createdAt);
}
// Actor type from profile overrides config default
const profileActorType = profile.actorType || actorType;
const ResolvedActorClass = actorTypeMap[profileActorType] || ActorClass;
return new ResolvedActorClass(personOptions);
}, },
) )
.mapHandle((_ctx, username) => { .mapHandle((_ctx, username) => {
@@ -678,6 +611,90 @@ async function getProfile(collections) {
return doc || {}; 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;
personOptions.assertionMethods = keyPairs.map((k) => k.multikey);
}
if (profile.attachments?.length > 0) {
personOptions.attachments = profile.attachments.map(
(att) =>
new PropertyValue({
name: att.name,
value: formatAttachmentValue(att.value),
}),
);
}
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 a PKCS#8 PEM private key using Web Crypto API. * 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. * Fedify's importPem only handles PKCS#1, but Node.js crypto generates PKCS#8.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@rmdes/indiekit-endpoint-activitypub", "name": "@rmdes/indiekit-endpoint-activitypub",
"version": "1.1.13", "version": "1.1.17",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@rmdes/indiekit-endpoint-activitypub", "name": "@rmdes/indiekit-endpoint-activitypub",
"version": "1.1.13", "version": "1.1.17",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fedify/express": "^1.10.3", "@fedify/express": "^1.10.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@rmdes/indiekit-endpoint-activitypub", "name": "@rmdes/indiekit-endpoint-activitypub",
"version": "1.1.17", "version": "1.1.18",
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
"keywords": [ "keywords": [
"indiekit", "indiekit",