@rmdes/indiekit-endpoint-activitypub 0.1.10 → 1.0.0

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
@@ -1,15 +1,26 @@
1
1
  import express from "express";
2
2
 
3
- import { handleWebFinger } from "./lib/webfinger.js";
4
- import { buildActorDocument } from "./lib/actor.js";
5
- import { getOrCreateKeyPair } from "./lib/keys.js";
6
- import { jf2ToActivityStreams, resolvePostUrl } from "./lib/jf2-to-as2.js";
7
- import { createFederationHandler } from "./lib/federation.js";
3
+ import { setupFederation } from "./lib/federation-setup.js";
4
+ import {
5
+ createFedifyMiddleware,
6
+ } from "./lib/federation-bridge.js";
7
+ import {
8
+ jf2ToActivityStreams,
9
+ jf2ToAS2Activity,
10
+ } from "./lib/jf2-to-as2.js";
8
11
  import { dashboardController } from "./lib/controllers/dashboard.js";
9
12
  import { followersController } from "./lib/controllers/followers.js";
10
13
  import { followingController } from "./lib/controllers/following.js";
11
14
  import { activitiesController } from "./lib/controllers/activities.js";
12
- import { migrateGetController, migratePostController, migrateImportController } from "./lib/controllers/migrate.js";
15
+ import {
16
+ migrateGetController,
17
+ migratePostController,
18
+ migrateImportController,
19
+ } from "./lib/controllers/migrate.js";
20
+ import {
21
+ profileGetController,
22
+ profilePostController,
23
+ } from "./lib/controllers/profile.js";
13
24
 
14
25
  const defaults = {
15
26
  mountPath: "/activitypub",
@@ -21,8 +32,8 @@ const defaults = {
21
32
  },
22
33
  checked: true,
23
34
  alsoKnownAs: "",
24
- activityRetentionDays: 90, // Auto-delete activities older than this (0 = keep forever)
25
- storeRawActivities: false, // Store full incoming JSON (enables debugging, costs storage)
35
+ activityRetentionDays: 90,
36
+ storeRawActivities: false,
26
37
  };
27
38
 
28
39
  export default class ActivityPubEndpoint {
@@ -33,11 +44,10 @@ export default class ActivityPubEndpoint {
33
44
  this.options.actor = { ...defaults.actor, ...options.actor };
34
45
  this.mountPath = this.options.mountPath;
35
46
 
36
- // Set at init time when we have access to Indiekit
37
47
  this._publicationUrl = "";
38
- this._actorUrl = "";
39
48
  this._collections = {};
40
- this._federationHandler = null;
49
+ this._federation = null;
50
+ this._fedifyMiddleware = null;
41
51
  }
42
52
 
43
53
  get navigationItems() {
@@ -48,127 +58,40 @@ export default class ActivityPubEndpoint {
48
58
  };
49
59
  }
50
60
 
51
- // filePath is set by Indiekit's plugin loader via require.resolve()
52
-
53
61
  /**
54
- * WebFinger routes — mounted at /.well-known/
62
+ * WebFinger + NodeInfo discovery — mounted at /.well-known/
63
+ * Fedify handles these automatically via federation.fetch().
55
64
  */
56
65
  get routesWellKnown() {
57
66
  const router = express.Router(); // eslint-disable-line new-cap
58
- const options = this.options;
59
67
  const self = this;
60
68
 
61
- router.get("/webfinger", (request, response) => {
62
- const resource = request.query.resource;
63
- if (!resource) {
64
- return response.status(400).json({ error: "Missing resource parameter" });
65
- }
66
-
67
- const result = handleWebFinger(resource, {
68
- handle: options.actor.handle,
69
- hostname: new URL(self._publicationUrl).hostname,
70
- actorUrl: self._actorUrl,
71
- });
72
-
73
- if (!result) {
74
- return response.status(404).json({ error: "Resource not found" });
75
- }
76
-
77
- response.set("Content-Type", "application/jrd+json");
78
- return response.json(result);
69
+ router.use((req, res, next) => {
70
+ if (!self._fedifyMiddleware) return next();
71
+ return self._fedifyMiddleware(req, res, next);
79
72
  });
80
73
 
81
74
  return router;
82
75
  }
83
76
 
84
77
  /**
85
- * Public federation routes — mounted at mountPath, unauthenticated
78
+ * Public federation routes — mounted at mountPath.
79
+ * Fedify handles actor, inbox, outbox, followers, following.
86
80
  */
87
81
  get routesPublic() {
88
82
  const router = express.Router(); // eslint-disable-line new-cap
89
83
  const self = this;
90
84
 
91
- // Actor document (fallback primary is content negotiation on /)
92
- router.get("/actor", async (request, response) => {
93
- const actor = await self._getActorDocument();
94
- if (!actor) {
95
- return response.status(500).json({ error: "Actor not configured" });
96
- }
97
- response.set("Content-Type", "application/activity+json");
98
- return response.json(actor);
99
- });
100
-
101
- // Inbox — receive incoming activities
102
- router.post("/inbox", express.raw({ type: ["application/activity+json", "application/ld+json", "application/json"] }), async (request, response, next) => {
103
- try {
104
- if (self._federationHandler) {
105
- return await self._federationHandler.handleInbox(request, response);
106
- }
107
- return response.status(202).json({ status: "accepted" });
108
- } catch (error) {
109
- next(error);
110
- }
111
- });
112
-
113
- // Outbox — serve published posts as ActivityStreams
114
- router.get("/outbox", async (request, response, next) => {
115
- try {
116
- if (self._federationHandler) {
117
- return await self._federationHandler.handleOutbox(request, response);
118
- }
119
- response.set("Content-Type", "application/activity+json");
120
- return response.json({
121
- "@context": "https://www.w3.org/ns/activitystreams",
122
- type: "OrderedCollection",
123
- totalItems: 0,
124
- orderedItems: [],
125
- });
126
- } catch (error) {
127
- next(error);
128
- }
129
- });
130
-
131
- // Followers collection
132
- router.get("/followers", async (request, response, next) => {
133
- try {
134
- if (self._federationHandler) {
135
- return await self._federationHandler.handleFollowers(request, response);
136
- }
137
- response.set("Content-Type", "application/activity+json");
138
- return response.json({
139
- "@context": "https://www.w3.org/ns/activitystreams",
140
- type: "OrderedCollection",
141
- totalItems: 0,
142
- orderedItems: [],
143
- });
144
- } catch (error) {
145
- next(error);
146
- }
147
- });
148
-
149
- // Following collection
150
- router.get("/following", async (request, response, next) => {
151
- try {
152
- if (self._federationHandler) {
153
- return await self._federationHandler.handleFollowing(request, response);
154
- }
155
- response.set("Content-Type", "application/activity+json");
156
- return response.json({
157
- "@context": "https://www.w3.org/ns/activitystreams",
158
- type: "OrderedCollection",
159
- totalItems: 0,
160
- orderedItems: [],
161
- });
162
- } catch (error) {
163
- next(error);
164
- }
85
+ router.use((req, res, next) => {
86
+ if (!self._fedifyMiddleware) return next();
87
+ return self._fedifyMiddleware(req, res, next);
165
88
  });
166
89
 
167
90
  return router;
168
91
  }
169
92
 
170
93
  /**
171
- * Authenticated admin routes — mounted at mountPath, behind IndieAuth
94
+ * Authenticated admin routes — mounted at mountPath, behind IndieAuth.
172
95
  */
173
96
  get routes() {
174
97
  const router = express.Router(); // eslint-disable-line new-cap
@@ -178,23 +101,36 @@ export default class ActivityPubEndpoint {
178
101
  router.get("/admin/followers", followersController(mp));
179
102
  router.get("/admin/following", followingController(mp));
180
103
  router.get("/admin/activities", activitiesController(mp));
104
+ router.get("/admin/profile", profileGetController(mp));
105
+ router.post("/admin/profile", profilePostController(mp));
181
106
  router.get("/admin/migrate", migrateGetController(mp, this.options));
182
107
  router.post("/admin/migrate", migratePostController(mp, this.options));
183
- router.post("/admin/migrate/import", migrateImportController(mp, this.options));
108
+ router.post(
109
+ "/admin/migrate/import",
110
+ migrateImportController(mp, this.options),
111
+ );
184
112
 
185
113
  return router;
186
114
  }
187
115
 
188
116
  /**
189
- * Content negotiation handler — serves AS2 JSON for ActivityPub clients
190
- * Registered as a separate endpoint with mountPath "/"
117
+ * Content negotiation — serves AS2 JSON for ActivityPub clients
118
+ * requesting individual post URLs. Also handles NodeInfo data
119
+ * at /nodeinfo/2.1 (delegated to Fedify).
191
120
  */
192
121
  get contentNegotiationRoutes() {
193
122
  const router = express.Router(); // eslint-disable-line new-cap
194
123
  const self = this;
195
124
 
196
- router.get("{*path}", async (request, response, next) => {
197
- const accept = request.headers.accept || "";
125
+ // Let Fedify handle NodeInfo data (/nodeinfo/2.1)
126
+ router.use((req, res, next) => {
127
+ if (!self._fedifyMiddleware) return next();
128
+ return self._fedifyMiddleware(req, res, next);
129
+ });
130
+
131
+ // Content negotiation for AP clients on regular URLs
132
+ router.get("{*path}", async (req, res, next) => {
133
+ const accept = req.headers.accept || "";
198
134
  const isActivityPub =
199
135
  accept.includes("application/activity+json") ||
200
136
  accept.includes("application/ld+json");
@@ -204,25 +140,20 @@ export default class ActivityPubEndpoint {
204
140
  }
205
141
 
206
142
  try {
207
- // Root URL — serve actor document
208
- if (request.path === "/") {
209
- const actor = await self._getActorDocument();
210
- if (!actor) {
211
- return next();
212
- }
213
- response.set("Content-Type", "application/activity+json");
214
- return response.json(actor);
143
+ // Root URL — redirect to Fedify actor
144
+ if (req.path === "/") {
145
+ const actorPath = `${self.options.mountPath}/users/${self.options.actor.handle}`;
146
+ return res.redirect(actorPath);
215
147
  }
216
148
 
217
149
  // Post URLs — look up in database and convert to AS2
218
- const { application } = request.app.locals;
150
+ const { application } = req.app.locals;
219
151
  const postsCollection = application?.collections?.get("posts");
220
152
  if (!postsCollection) {
221
153
  return next();
222
154
  }
223
155
 
224
- // Try to find a post matching this URL path
225
- const requestUrl = `${self._publicationUrl}${request.path.slice(1)}`;
156
+ const requestUrl = `${self._publicationUrl}${req.path.slice(1)}`;
226
157
  const post = await postsCollection.findOne({
227
158
  "properties.url": requestUrl,
228
159
  });
@@ -231,16 +162,16 @@ export default class ActivityPubEndpoint {
231
162
  return next();
232
163
  }
233
164
 
165
+ const actorUrl = self._getActorUrl();
234
166
  const activity = jf2ToActivityStreams(
235
167
  post.properties,
236
- self._actorUrl,
168
+ actorUrl,
237
169
  self._publicationUrl,
238
170
  );
239
171
 
240
- // Return the object, not the wrapping Create activity
241
172
  const object = activity.object || activity;
242
- response.set("Content-Type", "application/activity+json");
243
- return response.json({
173
+ res.set("Content-Type", "application/activity+json");
174
+ return res.json({
244
175
  "@context": [
245
176
  "https://www.w3.org/ns/activitystreams",
246
177
  "https://w3id.org/security/v1",
@@ -256,30 +187,7 @@ export default class ActivityPubEndpoint {
256
187
  }
257
188
 
258
189
  /**
259
- * Build and cache the actor document
260
- */
261
- async _getActorDocument() {
262
- const keysCollection = this._collections.ap_keys;
263
- if (!keysCollection) {
264
- return null;
265
- }
266
-
267
- const keyPair = await getOrCreateKeyPair(keysCollection, this._actorUrl);
268
- return buildActorDocument({
269
- actorUrl: this._actorUrl,
270
- publicationUrl: this._publicationUrl,
271
- mountPath: this.options.mountPath,
272
- handle: this.options.actor.handle,
273
- name: this.options.actor.name,
274
- summary: this.options.actor.summary,
275
- icon: this.options.actor.icon,
276
- alsoKnownAs: this.options.alsoKnownAs,
277
- publicKeyPem: keyPair.publicKeyPem,
278
- });
279
- }
280
-
281
- /**
282
- * Syndicator — delivers posts to ActivityPub followers
190
+ * Syndicator delivers posts to ActivityPub followers via Fedify.
283
191
  */
284
192
  get syndicator() {
285
193
  const self = this;
@@ -303,15 +211,35 @@ export default class ActivityPubEndpoint {
303
211
  };
304
212
  },
305
213
 
306
- async syndicate(properties, publication) {
307
- if (!self._federationHandler) {
214
+ async syndicate(properties) {
215
+ if (!self._federation) {
308
216
  return undefined;
309
217
  }
218
+
310
219
  try {
311
- return await self._federationHandler.deliverToFollowers(
220
+ const actorUrl = self._getActorUrl();
221
+ const activity = jf2ToAS2Activity(
312
222
  properties,
313
- publication,
223
+ actorUrl,
224
+ self._publicationUrl,
225
+ );
226
+
227
+ if (!activity) {
228
+ return undefined;
229
+ }
230
+
231
+ const ctx = self._federation.createContext(
232
+ new URL(self._publicationUrl),
233
+ {},
314
234
  );
235
+
236
+ await ctx.sendActivity(
237
+ { identifier: self.options.actor.handle },
238
+ "followers",
239
+ activity,
240
+ );
241
+
242
+ return properties.url || undefined;
315
243
  } catch (error) {
316
244
  console.error("[ActivityPub] Syndication failed:", error.message);
317
245
  return undefined;
@@ -320,6 +248,15 @@ export default class ActivityPubEndpoint {
320
248
  };
321
249
  }
322
250
 
251
+ /**
252
+ * Build the full actor URL from config.
253
+ * @returns {string}
254
+ */
255
+ _getActorUrl() {
256
+ const base = this._publicationUrl.replace(/\/$/, "");
257
+ return `${base}${this.options.mountPath}/users/${this.options.actor.handle}`;
258
+ }
259
+
323
260
  init(Indiekit) {
324
261
  // Store publication URL for later use
325
262
  this._publicationUrl = Indiekit.publication?.me
@@ -327,23 +264,31 @@ export default class ActivityPubEndpoint {
327
264
  ? Indiekit.publication.me
328
265
  : `${Indiekit.publication.me}/`
329
266
  : "";
330
- this._actorUrl = this._publicationUrl;
331
267
 
332
268
  // Register MongoDB collections
333
269
  Indiekit.addCollection("ap_followers");
334
270
  Indiekit.addCollection("ap_following");
335
271
  Indiekit.addCollection("ap_activities");
336
272
  Indiekit.addCollection("ap_keys");
273
+ Indiekit.addCollection("ap_kv");
274
+ Indiekit.addCollection("ap_profile");
337
275
 
338
- // Store collection references for later use
276
+ // Store collection references (posts resolved lazily)
277
+ const indiekitCollections = Indiekit.collections;
339
278
  this._collections = {
340
- ap_followers: Indiekit.collections.get("ap_followers"),
341
- ap_following: Indiekit.collections.get("ap_following"),
342
- ap_activities: Indiekit.collections.get("ap_activities"),
343
- ap_keys: Indiekit.collections.get("ap_keys"),
279
+ ap_followers: indiekitCollections.get("ap_followers"),
280
+ ap_following: indiekitCollections.get("ap_following"),
281
+ ap_activities: indiekitCollections.get("ap_activities"),
282
+ ap_keys: indiekitCollections.get("ap_keys"),
283
+ ap_kv: indiekitCollections.get("ap_kv"),
284
+ ap_profile: indiekitCollections.get("ap_profile"),
285
+ get posts() {
286
+ return indiekitCollections.get("posts");
287
+ },
288
+ _publicationUrl: this._publicationUrl,
344
289
  };
345
290
 
346
- // Set up TTL index so ap_activities self-cleans (MongoDB handles expiry)
291
+ // TTL index for activity cleanup (MongoDB handles expiry automatically)
347
292
  const retentionDays = this.options.activityRetentionDays;
348
293
  if (retentionDays > 0) {
349
294
  this._collections.ap_activities.createIndex(
@@ -352,28 +297,64 @@ export default class ActivityPubEndpoint {
352
297
  );
353
298
  }
354
299
 
355
- // Initialize federation handler
356
- this._federationHandler = createFederationHandler({
357
- actorUrl: this._actorUrl,
358
- publicationUrl: this._publicationUrl,
359
- mountPath: this.options.mountPath,
360
- actorConfig: this.options.actor,
361
- alsoKnownAs: this.options.alsoKnownAs,
300
+ // Seed actor profile from config on first run
301
+ this._seedProfile().catch((error) => {
302
+ console.warn("[ActivityPub] Profile seed failed:", error.message);
303
+ });
304
+
305
+ // Set up Fedify Federation instance
306
+ const { federation } = setupFederation({
362
307
  collections: this._collections,
308
+ mountPath: this.options.mountPath,
309
+ handle: this.options.actor.handle,
363
310
  storeRawActivities: this.options.storeRawActivities,
364
311
  });
365
312
 
366
- // Register as endpoint (adds routes)
313
+ this._federation = federation;
314
+ this._fedifyMiddleware = createFedifyMiddleware(federation, () => ({}));
315
+
316
+ // Register as endpoint (mounts routesPublic, routesWellKnown, routes)
367
317
  Indiekit.addEndpoint(this);
368
318
 
369
- // Register content negotiation handler as a virtual endpoint
319
+ // Content negotiation + NodeInfo virtual endpoint at root
370
320
  Indiekit.addEndpoint({
371
321
  name: "ActivityPub content negotiation",
372
322
  mountPath: "/",
373
323
  routesPublic: this.contentNegotiationRoutes,
374
324
  });
375
325
 
376
- // Register as syndicator (appears in post UI)
326
+ // Register syndicator (appears in post editing UI)
377
327
  Indiekit.addSyndicator(this.syndicator);
378
328
  }
329
+
330
+ /**
331
+ * Seed the ap_profile collection from config options on first run.
332
+ * Only creates a profile if none exists — preserves UI edits.
333
+ */
334
+ async _seedProfile() {
335
+ const { ap_profile } = this._collections;
336
+ const existing = await ap_profile.findOne({});
337
+
338
+ if (existing) {
339
+ return;
340
+ }
341
+
342
+ const profile = {
343
+ name: this.options.actor.name || this.options.actor.handle,
344
+ summary: this.options.actor.summary || "",
345
+ url: this._publicationUrl,
346
+ icon: this.options.actor.icon || "",
347
+ manuallyApprovesFollowers: false,
348
+ createdAt: new Date().toISOString(),
349
+ };
350
+
351
+ // Only include alsoKnownAs if explicitly configured
352
+ if (this.options.alsoKnownAs) {
353
+ profile.alsoKnownAs = Array.isArray(this.options.alsoKnownAs)
354
+ ? this.options.alsoKnownAs
355
+ : [this.options.alsoKnownAs];
356
+ }
357
+
358
+ await ap_profile.insertOne(profile);
359
+ }
379
360
  }
@@ -14,10 +14,18 @@ import {
14
14
  export function migrateGetController(mountPath, pluginOptions) {
15
15
  return async (request, response, next) => {
16
16
  try {
17
+ const { application } = request.app.locals;
18
+ const profileCollection = application?.collections?.get("ap_profile");
19
+ const profile = profileCollection
20
+ ? (await profileCollection.findOne({})) || {}
21
+ : {};
22
+
23
+ const currentAlias = profile.alsoKnownAs?.[0] || "";
24
+
17
25
  response.render("activitypub-migrate", {
18
26
  title: response.locals.__("activitypub.migrate.title"),
19
27
  mountPath,
20
- currentAlias: pluginOptions.alsoKnownAs || "",
28
+ currentAlias,
21
29
  result: null,
22
30
  });
23
31
  } catch (error) {
@@ -29,22 +37,32 @@ export function migrateGetController(mountPath, pluginOptions) {
29
37
  export function migratePostController(mountPath, pluginOptions) {
30
38
  return async (request, response, next) => {
31
39
  try {
40
+ const { application } = request.app.locals;
41
+ const profileCollection = application?.collections?.get("ap_profile");
32
42
  let result = null;
33
43
 
34
- // Only handles alias updates (small payload, regular form POST)
35
44
  const aliasUrl = request.body.aliasUrl?.trim();
36
- if (aliasUrl) {
37
- pluginOptions.alsoKnownAs = aliasUrl;
45
+ if (aliasUrl && profileCollection) {
46
+ await profileCollection.updateOne(
47
+ {},
48
+ { $set: { alsoKnownAs: [aliasUrl] } },
49
+ { upsert: true },
50
+ );
38
51
  result = {
39
52
  type: "success",
40
53
  text: response.locals.__("activitypub.migrate.aliasSuccess"),
41
54
  };
42
55
  }
43
56
 
57
+ const profile = profileCollection
58
+ ? (await profileCollection.findOne({})) || {}
59
+ : {};
60
+ const currentAlias = profile.alsoKnownAs?.[0] || "";
61
+
44
62
  response.render("activitypub-migrate", {
45
63
  title: response.locals.__("activitypub.migrate.title"),
46
64
  mountPath,
47
- currentAlias: pluginOptions.alsoKnownAs || "",
65
+ currentAlias,
48
66
  result,
49
67
  });
50
68
  } catch (error) {
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Profile controller — edit the ActivityPub actor profile.
3
+ *
4
+ * GET: loads profile from ap_profile collection, renders form
5
+ * POST: saves updated profile fields back to ap_profile
6
+ */
7
+
8
+ export function profileGetController(mountPath) {
9
+ return async (request, response, next) => {
10
+ try {
11
+ const { application } = request.app.locals;
12
+ const profileCollection = application?.collections?.get("ap_profile");
13
+ const profile = profileCollection
14
+ ? (await profileCollection.findOne({})) || {}
15
+ : {};
16
+
17
+ response.render("activitypub-profile", {
18
+ title: response.locals.__("activitypub.profile.title"),
19
+ mountPath,
20
+ profile,
21
+ result: null,
22
+ });
23
+ } catch (error) {
24
+ next(error);
25
+ }
26
+ };
27
+ }
28
+
29
+ export function profilePostController(mountPath) {
30
+ return async (request, response, next) => {
31
+ try {
32
+ const { application } = request.app.locals;
33
+ const profileCollection = application?.collections?.get("ap_profile");
34
+
35
+ if (!profileCollection) {
36
+ return next(new Error("ap_profile collection not available"));
37
+ }
38
+
39
+ const { name, summary, url, icon, image, manuallyApprovesFollowers } =
40
+ request.body;
41
+
42
+ const update = {
43
+ $set: {
44
+ name: name?.trim() || "",
45
+ summary: summary?.trim() || "",
46
+ url: url?.trim() || "",
47
+ icon: icon?.trim() || "",
48
+ image: image?.trim() || "",
49
+ manuallyApprovesFollowers: manuallyApprovesFollowers === "true",
50
+ updatedAt: new Date().toISOString(),
51
+ },
52
+ };
53
+
54
+ await profileCollection.updateOne({}, update, { upsert: true });
55
+
56
+ const profile = await profileCollection.findOne({});
57
+
58
+ response.render("activitypub-profile", {
59
+ title: response.locals.__("activitypub.profile.title"),
60
+ mountPath,
61
+ profile,
62
+ result: {
63
+ type: "success",
64
+ text: response.locals.__("activitypub.profile.saved"),
65
+ },
66
+ });
67
+ } catch (error) {
68
+ next(error);
69
+ }
70
+ };
71
+ }