@rmdes/indiekit-endpoint-activitypub 3.8.4 → 3.8.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js
CHANGED
|
@@ -387,38 +387,6 @@ export default class ActivityPubEndpoint {
|
|
|
387
387
|
const router = express.Router(); // eslint-disable-line new-cap
|
|
388
388
|
const self = this;
|
|
389
389
|
|
|
390
|
-
// Intercept Micropub delete actions to broadcast Delete to fediverse.
|
|
391
|
-
// Wraps res.json to detect successful delete responses, then fires
|
|
392
|
-
// broadcastDelete asynchronously so remote servers remove the post.
|
|
393
|
-
router.use((req, res, next) => {
|
|
394
|
-
if (req.method !== "POST") return next();
|
|
395
|
-
if (!req.path.endsWith("/micropub")) return next();
|
|
396
|
-
|
|
397
|
-
const action = req.query?.action || req.body?.action;
|
|
398
|
-
if (action !== "delete") return next();
|
|
399
|
-
|
|
400
|
-
const postUrl = req.query?.url || req.body?.url;
|
|
401
|
-
if (!postUrl) return next();
|
|
402
|
-
|
|
403
|
-
const originalJson = res.json.bind(res);
|
|
404
|
-
res.json = function (body) {
|
|
405
|
-
// Fire broadcastDelete after successful delete (status 200)
|
|
406
|
-
if (res.statusCode === 200 && body?.success === "delete") {
|
|
407
|
-
console.info(
|
|
408
|
-
`[ActivityPub] Micropub delete detected for ${postUrl}, broadcasting Delete to followers`,
|
|
409
|
-
);
|
|
410
|
-
self.broadcastDelete(postUrl).catch((error) => {
|
|
411
|
-
console.warn(
|
|
412
|
-
`[ActivityPub] broadcastDelete after Micropub delete failed: ${error.message}`,
|
|
413
|
-
);
|
|
414
|
-
});
|
|
415
|
-
}
|
|
416
|
-
return originalJson(body);
|
|
417
|
-
};
|
|
418
|
-
|
|
419
|
-
return next();
|
|
420
|
-
});
|
|
421
|
-
|
|
422
390
|
// Let Fedify handle NodeInfo data (/nodeinfo/2.1)
|
|
423
391
|
// Only pass GET/HEAD requests — POST/PUT/DELETE must not go through
|
|
424
392
|
// Fedify here, because fromExpressRequest() consumes the body stream,
|
|
@@ -715,6 +683,9 @@ export default class ActivityPubEndpoint {
|
|
|
715
683
|
return undefined;
|
|
716
684
|
}
|
|
717
685
|
},
|
|
686
|
+
|
|
687
|
+
delete: async (url) => this.delete(url),
|
|
688
|
+
update: async (properties) => this.update(properties),
|
|
718
689
|
};
|
|
719
690
|
}
|
|
720
691
|
|
|
@@ -744,7 +715,7 @@ export default class ActivityPubEndpoint {
|
|
|
744
715
|
"pkcs8",
|
|
745
716
|
Buffer.from(pemBody, "base64"),
|
|
746
717
|
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
747
|
-
|
|
718
|
+
true,
|
|
748
719
|
["sign"],
|
|
749
720
|
);
|
|
750
721
|
} catch (error) {
|
|
@@ -1181,6 +1152,138 @@ export default class ActivityPubEndpoint {
|
|
|
1181
1152
|
}
|
|
1182
1153
|
}
|
|
1183
1154
|
|
|
1155
|
+
/**
|
|
1156
|
+
* Called by post-content.js when a Micropub delete succeeds.
|
|
1157
|
+
* Broadcasts an ActivityPub Delete activity to all followers.
|
|
1158
|
+
* @param {string} url - Full URL of the deleted post
|
|
1159
|
+
*/
|
|
1160
|
+
async delete(url) {
|
|
1161
|
+
await this.broadcastDelete(url).catch((err) =>
|
|
1162
|
+
console.warn(`[ActivityPub] broadcastDelete failed for ${url}: ${err.message}`)
|
|
1163
|
+
);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* Called by post-content.js when a Micropub update succeeds.
|
|
1168
|
+
* Broadcasts an ActivityPub Update activity for the post to all followers.
|
|
1169
|
+
* @param {object} properties - JF2 post properties (must include url)
|
|
1170
|
+
*/
|
|
1171
|
+
async update(properties) {
|
|
1172
|
+
await this.broadcastPostUpdate(properties).catch((err) =>
|
|
1173
|
+
console.warn(`[ActivityPub] broadcastPostUpdate failed for ${properties?.url}: ${err.message}`)
|
|
1174
|
+
);
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/**
|
|
1178
|
+
* Send an Update activity to all followers for a modified post.
|
|
1179
|
+
* Mirrors broadcastDelete() pattern: batch delivery with shared inbox dedup.
|
|
1180
|
+
* @param {object} properties - JF2 post properties
|
|
1181
|
+
*/
|
|
1182
|
+
async broadcastPostUpdate(properties) {
|
|
1183
|
+
if (!this._federation) return;
|
|
1184
|
+
|
|
1185
|
+
try {
|
|
1186
|
+
const { Update } = await import("@fedify/fedify/vocab");
|
|
1187
|
+
const actorUrl = this._getActorUrl();
|
|
1188
|
+
const handle = this.options.actor.handle;
|
|
1189
|
+
const ctx = this._federation.createContext(
|
|
1190
|
+
new URL(this._publicationUrl),
|
|
1191
|
+
{ handle, publicationUrl: this._publicationUrl },
|
|
1192
|
+
);
|
|
1193
|
+
|
|
1194
|
+
// Build the Note/Article object by calling jf2ToAS2Activity() and
|
|
1195
|
+
// extracting the wrapped object from the returned Create activity.
|
|
1196
|
+
// For post edits, Fediverse servers expect an Update activity wrapping
|
|
1197
|
+
// the updated object — NOT a second Create activity.
|
|
1198
|
+
const createActivity = jf2ToAS2Activity(
|
|
1199
|
+
properties,
|
|
1200
|
+
actorUrl,
|
|
1201
|
+
this._publicationUrl,
|
|
1202
|
+
{ visibility: this.options.defaultVisibility },
|
|
1203
|
+
);
|
|
1204
|
+
|
|
1205
|
+
if (!createActivity) {
|
|
1206
|
+
console.warn(`[ActivityPub] broadcastPostUpdate: could not convert post to AS2 for ${properties?.url}`);
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// Extract the Note/Article object from the Create wrapper, then build
|
|
1211
|
+
// an Update activity around it — matching broadcastActorUpdate() pattern.
|
|
1212
|
+
const noteObject = await createActivity.getObject();
|
|
1213
|
+
const activity = new Update({
|
|
1214
|
+
actor: ctx.getActorUri(handle),
|
|
1215
|
+
object: noteObject,
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
const followers = await this._collections.ap_followers
|
|
1219
|
+
.find({})
|
|
1220
|
+
.project({ actorUrl: 1, inbox: 1, sharedInbox: 1 })
|
|
1221
|
+
.toArray();
|
|
1222
|
+
|
|
1223
|
+
const inboxMap = new Map();
|
|
1224
|
+
for (const f of followers) {
|
|
1225
|
+
const key = f.sharedInbox || f.inbox;
|
|
1226
|
+
if (key && !inboxMap.has(key)) {
|
|
1227
|
+
inboxMap.set(key, f);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
const uniqueRecipients = [...inboxMap.values()];
|
|
1232
|
+
const BATCH_SIZE = 25;
|
|
1233
|
+
const BATCH_DELAY_MS = 5000;
|
|
1234
|
+
let delivered = 0;
|
|
1235
|
+
let failed = 0;
|
|
1236
|
+
|
|
1237
|
+
console.info(
|
|
1238
|
+
`[ActivityPub] Broadcasting Update for ${properties.url} to ${uniqueRecipients.length} unique inboxes`,
|
|
1239
|
+
);
|
|
1240
|
+
|
|
1241
|
+
for (let i = 0; i < uniqueRecipients.length; i += BATCH_SIZE) {
|
|
1242
|
+
const batch = uniqueRecipients.slice(i, i + BATCH_SIZE);
|
|
1243
|
+
const recipients = batch.map((f) => ({
|
|
1244
|
+
id: new URL(f.actorUrl),
|
|
1245
|
+
inboxId: new URL(f.inbox || f.sharedInbox),
|
|
1246
|
+
endpoints: f.sharedInbox
|
|
1247
|
+
? { sharedInbox: new URL(f.sharedInbox) }
|
|
1248
|
+
: undefined,
|
|
1249
|
+
}));
|
|
1250
|
+
|
|
1251
|
+
try {
|
|
1252
|
+
await ctx.sendActivity(
|
|
1253
|
+
{ identifier: handle },
|
|
1254
|
+
recipients,
|
|
1255
|
+
activity,
|
|
1256
|
+
{ preferSharedInbox: true },
|
|
1257
|
+
);
|
|
1258
|
+
delivered += batch.length;
|
|
1259
|
+
} catch (error) {
|
|
1260
|
+
failed += batch.length;
|
|
1261
|
+
console.warn(
|
|
1262
|
+
`[ActivityPub] Update batch ${Math.floor(i / BATCH_SIZE) + 1} failed: ${error.message}`,
|
|
1263
|
+
);
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
if (i + BATCH_SIZE < uniqueRecipients.length) {
|
|
1267
|
+
await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY_MS));
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
console.info(
|
|
1272
|
+
`[ActivityPub] Update broadcast complete for ${properties.url}: ${delivered} delivered, ${failed} failed`,
|
|
1273
|
+
);
|
|
1274
|
+
|
|
1275
|
+
await logActivity(this._collections.ap_activities, {
|
|
1276
|
+
direction: "outbound",
|
|
1277
|
+
type: "Update",
|
|
1278
|
+
actorUrl: this._publicationUrl,
|
|
1279
|
+
objectUrl: properties.url,
|
|
1280
|
+
summary: `Sent Update for ${properties.url} to ${delivered} inboxes`,
|
|
1281
|
+
}).catch(() => {});
|
|
1282
|
+
} catch (error) {
|
|
1283
|
+
console.warn("[ActivityPub] broadcastPostUpdate failed:", error.message);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1184
1287
|
/**
|
|
1185
1288
|
* Build the full actor URL from config.
|
|
1186
1289
|
* @returns {string}
|
|
@@ -1589,6 +1692,7 @@ export default class ActivityPubEndpoint {
|
|
|
1589
1692
|
federation: this._federation,
|
|
1590
1693
|
followActor: (url, info) => pluginRef.followActor(url, info),
|
|
1591
1694
|
unfollowActor: (url) => pluginRef.unfollowActor(url),
|
|
1695
|
+
loadRsaKey: () => pluginRef._loadRsaPrivateKey(),
|
|
1592
1696
|
},
|
|
1593
1697
|
});
|
|
1594
1698
|
Indiekit.addEndpoint({
|
|
@@ -1614,6 +1718,7 @@ export default class ActivityPubEndpoint {
|
|
|
1614
1718
|
}, 10_000);
|
|
1615
1719
|
|
|
1616
1720
|
// Run one-time migrations (idempotent — safe to run on every startup)
|
|
1721
|
+
console.info("[ActivityPub] Init: starting post-refollow setup");
|
|
1617
1722
|
runSeparateMentionsMigration(this._collections).then(({ skipped, updated }) => {
|
|
1618
1723
|
if (!skipped) {
|
|
1619
1724
|
console.log(`[ActivityPub] Migration separate-mentions: updated ${updated} timeline items`);
|
|
@@ -1660,6 +1765,7 @@ export default class ActivityPubEndpoint {
|
|
|
1660
1765
|
});
|
|
1661
1766
|
|
|
1662
1767
|
// Start async inbox queue processor (processes one item every 3s)
|
|
1768
|
+
console.info("[ActivityPub] Init: starting inbox queue processor");
|
|
1663
1769
|
this._inboxProcessorInterval = startInboxProcessor(
|
|
1664
1770
|
this._collections,
|
|
1665
1771
|
() => this._federation?.createContext(new URL(this._publicationUrl), {
|
|
@@ -72,11 +72,16 @@ export function boostController(mountPath, plugin) {
|
|
|
72
72
|
identifier: handle,
|
|
73
73
|
});
|
|
74
74
|
const { application } = request.app.locals;
|
|
75
|
+
const rsaKey = await plugin._loadRsaPrivateKey();
|
|
75
76
|
const recipient = await resolveAuthor(
|
|
76
77
|
url,
|
|
77
78
|
ctx,
|
|
78
79
|
documentLoader,
|
|
79
80
|
application?.collections,
|
|
81
|
+
{
|
|
82
|
+
privateKey: rsaKey,
|
|
83
|
+
keyId: `${ctx.getActorUri(handle).href}#main-key`,
|
|
84
|
+
},
|
|
80
85
|
);
|
|
81
86
|
|
|
82
87
|
if (recipient) {
|
|
@@ -49,11 +49,16 @@ export function likeController(mountPath, plugin) {
|
|
|
49
49
|
});
|
|
50
50
|
|
|
51
51
|
const { application } = request.app.locals;
|
|
52
|
+
const rsaKey = await plugin._loadRsaPrivateKey();
|
|
52
53
|
const recipient = await resolveAuthor(
|
|
53
54
|
url,
|
|
54
55
|
ctx,
|
|
55
56
|
documentLoader,
|
|
56
57
|
application?.collections,
|
|
58
|
+
{
|
|
59
|
+
privateKey: rsaKey,
|
|
60
|
+
keyId: `${ctx.getActorUri(handle).href}#main-key`,
|
|
61
|
+
},
|
|
57
62
|
);
|
|
58
63
|
|
|
59
64
|
if (!recipient) {
|
|
@@ -170,11 +175,16 @@ export function unlikeController(mountPath, plugin) {
|
|
|
170
175
|
identifier: handle,
|
|
171
176
|
});
|
|
172
177
|
|
|
178
|
+
const rsaKey2 = await plugin._loadRsaPrivateKey();
|
|
173
179
|
const recipient = await resolveAuthor(
|
|
174
180
|
url,
|
|
175
181
|
ctx,
|
|
176
182
|
documentLoader,
|
|
177
183
|
application?.collections,
|
|
184
|
+
{
|
|
185
|
+
privateKey: rsaKey2,
|
|
186
|
+
keyId: `${ctx.getActorUri(handle).href}#main-key`,
|
|
187
|
+
},
|
|
178
188
|
);
|
|
179
189
|
|
|
180
190
|
if (!recipient) {
|
|
@@ -22,7 +22,7 @@ import { resolveAuthor } from "../../resolve-author.js";
|
|
|
22
22
|
* @param {object} params.interactions - ap_interactions collection
|
|
23
23
|
* @returns {Promise<{ activityId: string }>}
|
|
24
24
|
*/
|
|
25
|
-
export async function likePost({ targetUrl, federation, handle, publicationUrl, collections, interactions }) {
|
|
25
|
+
export async function likePost({ targetUrl, federation, handle, publicationUrl, collections, interactions, loadRsaKey }) {
|
|
26
26
|
const { Like } = await import("@fedify/fedify/vocab");
|
|
27
27
|
const ctx = federation.createContext(
|
|
28
28
|
new URL(publicationUrl),
|
|
@@ -30,7 +30,11 @@ export async function likePost({ targetUrl, federation, handle, publicationUrl,
|
|
|
30
30
|
);
|
|
31
31
|
|
|
32
32
|
const documentLoader = await ctx.getDocumentLoader({ identifier: handle });
|
|
33
|
-
const
|
|
33
|
+
const rsaKey = loadRsaKey ? await loadRsaKey() : null;
|
|
34
|
+
const recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections, {
|
|
35
|
+
privateKey: rsaKey,
|
|
36
|
+
keyId: `${ctx.getActorUri(handle).href}#main-key`,
|
|
37
|
+
});
|
|
34
38
|
|
|
35
39
|
const uuid = crypto.randomUUID();
|
|
36
40
|
const baseUrl = publicationUrl.replace(/\/$/, "");
|
|
@@ -79,7 +83,7 @@ export async function likePost({ targetUrl, federation, handle, publicationUrl,
|
|
|
79
83
|
* @param {object} params.interactions - ap_interactions collection
|
|
80
84
|
* @returns {Promise<void>}
|
|
81
85
|
*/
|
|
82
|
-
export async function unlikePost({ targetUrl, federation, handle, publicationUrl, collections, interactions }) {
|
|
86
|
+
export async function unlikePost({ targetUrl, federation, handle, publicationUrl, collections, interactions, loadRsaKey }) {
|
|
83
87
|
const existing = interactions
|
|
84
88
|
? await interactions.findOne({ objectUrl: targetUrl, type: "like" })
|
|
85
89
|
: null;
|
|
@@ -95,7 +99,11 @@ export async function unlikePost({ targetUrl, federation, handle, publicationUrl
|
|
|
95
99
|
);
|
|
96
100
|
|
|
97
101
|
const documentLoader = await ctx.getDocumentLoader({ identifier: handle });
|
|
98
|
-
const
|
|
102
|
+
const rsaKey = loadRsaKey ? await loadRsaKey() : null;
|
|
103
|
+
const recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections, {
|
|
104
|
+
privateKey: rsaKey,
|
|
105
|
+
keyId: `${ctx.getActorUri(handle).href}#main-key`,
|
|
106
|
+
});
|
|
99
107
|
|
|
100
108
|
if (recipient) {
|
|
101
109
|
const like = new Like({
|
|
@@ -131,7 +139,7 @@ export async function unlikePost({ targetUrl, federation, handle, publicationUrl
|
|
|
131
139
|
* @param {object} params.interactions - ap_interactions collection
|
|
132
140
|
* @returns {Promise<{ activityId: string }>}
|
|
133
141
|
*/
|
|
134
|
-
export async function boostPost({ targetUrl, federation, handle, publicationUrl, collections, interactions }) {
|
|
142
|
+
export async function boostPost({ targetUrl, federation, handle, publicationUrl, collections, interactions, loadRsaKey }) {
|
|
135
143
|
const { Announce } = await import("@fedify/fedify/vocab");
|
|
136
144
|
const ctx = federation.createContext(
|
|
137
145
|
new URL(publicationUrl),
|
|
@@ -162,7 +170,11 @@ export async function boostPost({ targetUrl, federation, handle, publicationUrl,
|
|
|
162
170
|
|
|
163
171
|
// Also send directly to the original post author
|
|
164
172
|
const documentLoader = await ctx.getDocumentLoader({ identifier: handle });
|
|
165
|
-
const
|
|
173
|
+
const rsaKey = loadRsaKey ? await loadRsaKey() : null;
|
|
174
|
+
const recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections, {
|
|
175
|
+
privateKey: rsaKey,
|
|
176
|
+
keyId: `${ctx.getActorUri(handle).href}#main-key`,
|
|
177
|
+
});
|
|
166
178
|
if (recipient) {
|
|
167
179
|
try {
|
|
168
180
|
await ctx.sendActivity({ identifier: handle }, recipient, announce, {
|
package/lib/resolve-author.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Strategies (tried in order):
|
|
8
8
|
* 1. lookupObject on post URL → getAttributedTo
|
|
9
|
+
* 1b. Raw signed fetch fallback (for servers like wafrn that return
|
|
10
|
+
* non-standard JSON-LD that Fedify can't parse)
|
|
9
11
|
* 2. Timeline/notification DB lookup → lookupObject on stored author URL
|
|
10
12
|
* 3. Extract author URL from post URL pattern → lookupObject
|
|
11
13
|
*/
|
|
@@ -60,6 +62,9 @@ export function extractAuthorUrl(postUrl) {
|
|
|
60
62
|
* @param {object} ctx - Fedify context
|
|
61
63
|
* @param {object} documentLoader - Authenticated document loader
|
|
62
64
|
* @param {object} [collections] - Optional MongoDB collections map (application.collections)
|
|
65
|
+
* @param {object} [options] - Additional options
|
|
66
|
+
* @param {CryptoKey} [options.privateKey] - RSA private key for raw signed fetch fallback
|
|
67
|
+
* @param {string} [options.keyId] - Key ID for HTTP Signature (e.g. "...#main-key")
|
|
63
68
|
* @returns {Promise<object|null>} - Fedify Actor object or null
|
|
64
69
|
*/
|
|
65
70
|
export async function resolveAuthor(
|
|
@@ -67,6 +72,7 @@ export async function resolveAuthor(
|
|
|
67
72
|
ctx,
|
|
68
73
|
documentLoader,
|
|
69
74
|
collections,
|
|
75
|
+
options = {},
|
|
70
76
|
) {
|
|
71
77
|
// Strategy 1: Look up remote post via Fedify (signed request)
|
|
72
78
|
try {
|
|
@@ -90,6 +96,46 @@ export async function resolveAuthor(
|
|
|
90
96
|
);
|
|
91
97
|
}
|
|
92
98
|
|
|
99
|
+
// Strategy 1b: Raw signed fetch fallback
|
|
100
|
+
// Some servers (e.g. wafrn) return AP JSON without @context, which Fedify's
|
|
101
|
+
// JSON-LD processor rejects. A raw fetch can still extract attributedTo/actor.
|
|
102
|
+
if (options.privateKey && options.keyId) {
|
|
103
|
+
try {
|
|
104
|
+
const { signRequest } = await import("@fedify/fedify/sig");
|
|
105
|
+
const request = new Request(postUrl, {
|
|
106
|
+
method: "GET",
|
|
107
|
+
headers: { Accept: "application/activity+json" },
|
|
108
|
+
});
|
|
109
|
+
const signed = await signRequest(request, options.privateKey, new URL(options.keyId), {
|
|
110
|
+
spec: "draft-cavage-http-signatures-12",
|
|
111
|
+
});
|
|
112
|
+
const res = await fetch(signed, { redirect: "follow" });
|
|
113
|
+
if (res.ok) {
|
|
114
|
+
const contentType = res.headers.get("content-type") || "";
|
|
115
|
+
if (contentType.includes("json")) {
|
|
116
|
+
const json = await res.json();
|
|
117
|
+
const authorUrl = json.attributedTo || json.actor;
|
|
118
|
+
if (authorUrl && typeof authorUrl === "string") {
|
|
119
|
+
const actor = await lookupWithSecurity(ctx, new URL(authorUrl), {
|
|
120
|
+
documentLoader,
|
|
121
|
+
});
|
|
122
|
+
if (actor) {
|
|
123
|
+
console.info(
|
|
124
|
+
`[ActivityPub] Resolved author via raw fetch for ${postUrl} → ${authorUrl}`,
|
|
125
|
+
);
|
|
126
|
+
return actor;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
} catch (error) {
|
|
132
|
+
console.warn(
|
|
133
|
+
`[ActivityPub] Raw fetch fallback failed for ${postUrl}:`,
|
|
134
|
+
error.message,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
93
139
|
// Strategy 2: Use author URL from timeline or notifications
|
|
94
140
|
if (collections) {
|
|
95
141
|
const ap_timeline = collections.get("ap_timeline");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmdes/indiekit-endpoint-activitypub",
|
|
3
|
-
"version": "3.8.
|
|
3
|
+
"version": "3.8.6",
|
|
4
4
|
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"indiekit",
|