@rmdes/indiekit-endpoint-activitypub 3.9.4 → 3.10.1

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @rmdes/indiekit-endpoint-activitypub
2
2
 
3
- ActivityPub federation endpoint for [Indiekit](https://getindiekit.com), built on [Fedify](https://fedify.dev) 2.0. Makes your IndieWeb site a full fediverse actor — discoverable, followable, and interactive from Mastodon, Misskey, Pixelfed, and any ActivityPub-compatible platform. Includes a Mastodon-compatible Client API so you can use Phanpy, Elk, Moshidon, Fedilab, and other Mastodon clients with your own AP instance.
3
+ ActivityPub federation endpoint for [Indiekit](https://getindiekit.com), built on [Fedify](https://fedify.dev) 2.1. Makes your IndieWeb site a full fediverse actor — discoverable, followable, and interactive from Mastodon, Misskey, Pixelfed, and any ActivityPub-compatible platform. Includes a Mastodon-compatible Client API so you can use Phanpy, Elk, Moshidon, Fedilab, and other Mastodon clients with your own AP instance.
4
4
 
5
5
  ## Features
6
6
 
@@ -109,10 +109,39 @@ ActivityPub federation endpoint for [Indiekit](https://getindiekit.com), built o
109
109
  - Follower and following lists with source tracking
110
110
  - Federation management page with moderation overview (blocked servers, blocked accounts, muted)
111
111
 
112
+ **Standards Compliance**
113
+
114
+ Core protocols and Fediverse Enhancement Proposals (FEPs) supported:
115
+
116
+ | Standard | Name | Status | Provider |
117
+ |----------|------|--------|----------|
118
+ | [ActivityPub](https://www.w3.org/TR/activitypub/) | W3C ActivityPub | Full (server-to-server) | Fedify 2.1 |
119
+ | [ActivityStreams 2.0](https://www.w3.org/TR/activitystreams-core/) | W3C Activity Streams | Full | Fedify 2.1 |
120
+ | [HTTP Signatures](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures) | draft-cavage HTTP Signatures | Full | Fedify 2.1 |
121
+ | [RFC 9421](https://www.rfc-editor.org/rfc/rfc9421) | HTTP Message Signatures | Full (with Accept-Signature negotiation) | Fedify 2.1 |
122
+ | [WebFinger](https://www.rfc-editor.org/rfc/rfc7033) | RFC 7033 WebFinger | Full | Fedify 2.1 |
123
+ | [NodeInfo 2.1](https://nodeinfo.diaspora.software/) | Server metadata discovery | Full (enriched) | Plugin |
124
+ | [FEP-8b32](https://w3id.org/fep/8b32) | Object Integrity Proofs (Ed25519) | Full | Fedify 2.1 |
125
+ | [FEP-521a](https://w3id.org/fep/521a) | Multiple key pairs (Multikey) | Full | Fedify 2.1 |
126
+ | [FEP-fe34](https://w3id.org/fep/fe34) | Origin-based security model | Full | Fedify 2.1 + Plugin |
127
+ | [FEP-8fcf](https://w3id.org/fep/8fcf) | Followers collection synchronization | Outbound only | Fedify 2.1 |
128
+ | [FEP-5feb](https://w3id.org/fep/5feb) | Search indexing consent | Full (`indexable`, `discoverable`) | Plugin |
129
+ | [FEP-f1d5](https://w3id.org/fep/f1d5) | Enhanced NodeInfo 2.1 | Full (metadata, staff accounts) | Plugin |
130
+ | [FEP-4f05](https://w3id.org/fep/4f05) | Soft delete with Tombstone | Full (410 + Tombstone JSON-LD) | Plugin |
131
+ | [FEP-3b86](https://w3id.org/fep/3b86) | Activity Intents | Full (Follow, Create, Like, Announce) | Plugin |
132
+ | [FEP-044f](https://w3id.org/fep/044f) | Quote posts | Full (Mastodon, Misskey, Fedibird formats) | Fedify 2.1 + Plugin |
133
+ | [FEP-c0e0](https://w3id.org/fep/c0e0) | Emoji reactions (EmojiReact) | Vocab support (no UI) | Fedify 2.1 |
134
+ | [FEP-5711](https://w3id.org/fep/5711) | Conversation threads | Vocab support | Fedify 2.1 |
135
+ | [Linked Data Signatures](https://w3c-dvcg.github.io/ld-signatures/) | RsaSignature2017 (legacy) | Full (outbound signing) | Fedify 2.1 |
136
+
137
+ **Status key:** *Full* = complete implementation, *Outbound only* = sending side only, *Vocab support* = types available but no dedicated UI/logic.
138
+
139
+ **Provider key:** *Fedify 2.1* = handled by the Fedify framework, *Plugin* = implemented in this plugin, *Fedify 2.1 + Plugin* = framework provides primitives, plugin wires them together.
140
+
112
141
  ## Requirements
113
142
 
114
143
  - [Indiekit](https://getindiekit.com) v1.0.0-beta.25+
115
- - [Fedify](https://fedify.dev) 2.0+ (bundled as dependency)
144
+ - [Fedify](https://fedify.dev) 2.1+ (bundled as dependency)
116
145
  - Node.js >= 22
117
146
  - MongoDB (used by Indiekit)
118
147
  - Redis (recommended for production delivery queue; in-process queue available for development)
@@ -322,6 +351,7 @@ The plugin creates these collections automatically:
322
351
  | `ap_blocked_servers` | Blocked server domains (instance-level blocks) |
323
352
  | `ap_key_freshness` | Tracks when remote actor keys were last verified |
324
353
  | `ap_inbox_queue` | Persistent async inbox processing queue |
354
+ | `ap_tombstones` | Tombstone records for soft-deleted posts (FEP-4f05) |
325
355
  | `ap_oauth_apps` | Mastodon API client app registrations |
326
356
  | `ap_oauth_tokens` | OAuth2 authorization codes and access tokens |
327
357
  | `ap_markers` | Read position markers for Mastodon API clients |
@@ -342,7 +372,7 @@ Categories are converted to `Hashtag` tags. Bookmarks include a bookmark emoji a
342
372
 
343
373
  ## Fedify Workarounds and Implementation Notes
344
374
 
345
- This plugin uses [Fedify](https://fedify.dev) 2.0 but carries several workarounds for issues in Fedify or its Express integration. These are documented here so they can be revisited when Fedify upgrades.
375
+ This plugin uses [Fedify](https://fedify.dev) 2.1 but carries several workarounds for issues in Fedify or its Express integration. These are documented here so they can be revisited when Fedify upgrades.
346
376
 
347
377
  ### Custom Express Bridge (instead of `@fedify/express`)
348
378
 
@@ -364,14 +394,11 @@ Mastodon's `update_account_fields` checks `attachment.is_a?(Array)` and silently
364
394
 
365
395
  **Revisit when:** Fedify adds an option to preserve arrays during JSON-LD serialization, or Mastodon fixes their array check.
366
396
 
367
- ### Endpoints `as:Endpoints` Type Stripping
397
+ ### Endpoints `as:Endpoints` Type Stripping — REMOVED
368
398
 
369
- **File:** `lib/federation-bridge.js` (in `sendFedifyResponse()`)
370
399
  **Upstream issue:** [fedify#576](https://github.com/fedify-dev/fedify/issues/576) — FIXED in Fedify 2.1.0
371
400
 
372
- Fedify serializes the `endpoints` object with `"type": "as:Endpoints"`, which is not a valid ActivityStreams type. browser.pub rejects this. The bridge strips the `type` field from the `endpoints` object before sending.
373
-
374
- **Remove when:** Upgrading to Fedify ≥ 2.1.0.
401
+ This workaround has been removed. Fedify 2.1.0 now omits the invalid `"type": "as:Endpoints"` from serialized actor JSON.
375
402
 
376
403
  ### PropertyValue Attachment Type (Known Issue)
377
404
 
package/index.js CHANGED
@@ -435,6 +435,20 @@ export default class ActivityPubEndpoint {
435
435
  });
436
436
 
437
437
  if (!post || post.properties?.deleted) {
438
+ // FEP-4f05: Serve Tombstone for deleted posts
439
+ const { getTombstone } = await import("./lib/storage/tombstones.js");
440
+ const tombstone = await getTombstone(self._collections, requestUrl);
441
+ if (tombstone) {
442
+ res.status(410).set("Content-Type", "application/activity+json").json({
443
+ "@context": "https://www.w3.org/ns/activitystreams",
444
+ type: "Tombstone",
445
+ id: requestUrl,
446
+ formerType: tombstone.formerType,
447
+ published: tombstone.published || undefined,
448
+ deleted: tombstone.deleted,
449
+ });
450
+ return;
451
+ }
438
452
  return next();
439
453
  }
440
454
 
@@ -811,6 +825,21 @@ export default class ActivityPubEndpoint {
811
825
  * @param {string} url - Full URL of the deleted post
812
826
  */
813
827
  async delete(url) {
828
+ // Record tombstone for FEP-4f05
829
+ try {
830
+ const { addTombstone } = await import("./lib/storage/tombstones.js");
831
+ const postsCol = this._collections.posts;
832
+ const post = postsCol ? await postsCol.findOne({ "properties.url": url }) : null;
833
+ await addTombstone(this._collections, {
834
+ url,
835
+ formerType: post?.properties?.["post-type"] === "article" ? "Article" : "Note",
836
+ published: post?.properties?.published || null,
837
+ deleted: new Date().toISOString(),
838
+ });
839
+ } catch (error) {
840
+ console.warn(`[ActivityPub] Tombstone creation failed for ${url}: ${error.message}`);
841
+ }
842
+
814
843
  await this.broadcastDelete(url).catch((err) =>
815
844
  console.warn(`[ActivityPub] broadcastDelete failed for ${url}: ${err.message}`)
816
845
  );
@@ -927,6 +956,8 @@ export default class ActivityPubEndpoint {
927
956
  Indiekit.addCollection("ap_oauth_apps");
928
957
  Indiekit.addCollection("ap_oauth_tokens");
929
958
  Indiekit.addCollection("ap_markers");
959
+ // Tombstones for soft-deleted posts (FEP-4f05)
960
+ Indiekit.addCollection("ap_tombstones");
930
961
 
931
962
  // Store collection references (posts resolved lazily)
932
963
  const indiekitCollections = Indiekit.collections;
@@ -964,6 +995,7 @@ export default class ActivityPubEndpoint {
964
995
  ap_oauth_apps: indiekitCollections.get("ap_oauth_apps"),
965
996
  ap_oauth_tokens: indiekitCollections.get("ap_oauth_tokens"),
966
997
  ap_markers: indiekitCollections.get("ap_markers"),
998
+ ap_tombstones: indiekitCollections.get("ap_tombstones"),
967
999
  get posts() {
968
1000
  return indiekitCollections.get("posts");
969
1001
  },
@@ -2,18 +2,21 @@
2
2
  * Authorize Interaction controller — handles the remote follow / authorize
3
3
  * interaction flow for ActivityPub federation.
4
4
  *
5
- * When a remote server (WordPress AP, Misskey, etc.) discovers our WebFinger
6
- * subscribe template, it redirects the user here with ?uri={actorOrPostUrl}.
5
+ * Supports:
6
+ * - OStatus subscribe template (legacy remote follow via ?uri=...)
7
+ * - FEP-3b86 Activity Intents (via ?uri=...&intent=follow|create|like|announce)
7
8
  *
8
9
  * Flow:
9
10
  * 1. Missing uri → render error page
10
11
  * 2. Unauthenticated → redirect to login, then back here
11
- * 3. Authenticated → redirect to the reader's remote profile page
12
+ * 3. Authenticated → route to appropriate page based on intent
12
13
  */
13
14
 
14
15
  export function authorizeInteractionController(plugin) {
15
16
  return async (req, res) => {
16
17
  const uri = req.query.uri || req.query.acct;
18
+ const intent = req.query.intent || "";
19
+
17
20
  if (!uri) {
18
21
  return res.status(400).render("activitypub-authorize-interaction", {
19
22
  title: "Authorize Interaction",
@@ -29,17 +32,28 @@ export function authorizeInteractionController(plugin) {
29
32
  // then back to this page after auth
30
33
  const session = req.session;
31
34
  if (!session?.access_token) {
32
- const returnUrl = `${plugin.options.mountPath}/authorize_interaction?uri=${encodeURIComponent(uri)}`;
35
+ const params = `uri=${encodeURIComponent(uri)}${intent ? `&intent=${intent}` : ""}`;
36
+ const returnUrl = `${plugin.options.mountPath}/authorize_interaction?${params}`;
33
37
  return res.redirect(
34
38
  `/session/login?redirect=${encodeURIComponent(returnUrl)}`,
35
39
  );
36
40
  }
37
41
 
38
- // Authenticated redirect to the remote profile viewer in our reader
39
- // which already has follow/unfollow/like/boost functionality
42
+ const mp = plugin.options.mountPath;
40
43
  const encodedUrl = encodeURIComponent(resource);
41
- return res.redirect(
42
- `${plugin.options.mountPath}/admin/reader/profile?url=${encodedUrl}`,
43
- );
44
+
45
+ // Route based on intent (FEP-3b86)
46
+ switch (intent) {
47
+ case "follow":
48
+ return res.redirect(`${mp}/admin/reader/profile?url=${encodedUrl}`);
49
+ case "create":
50
+ return res.redirect(`${mp}/admin/reader/compose?replyTo=${encodedUrl}`);
51
+ case "like":
52
+ case "announce":
53
+ return res.redirect(`${mp}/admin/reader/post?url=${encodedUrl}`);
54
+ default:
55
+ // Default: resolve to remote profile page
56
+ return res.redirect(`${mp}/admin/reader/profile?url=${encodedUrl}`);
57
+ }
44
58
  };
45
59
  }
@@ -89,12 +89,6 @@ async function sendFedifyResponse(res, response, request) {
89
89
  if (json.attachment && !Array.isArray(json.attachment)) {
90
90
  json.attachment = [json.attachment];
91
91
  }
92
- // WORKAROUND: Fedify serializes endpoints with "type": "as:Endpoints"
93
- // which is not a valid AS2 type. The endpoints object should be a plain
94
- // object with just sharedInbox/proxyUrl etc. Strip the invalid type.
95
- if (json.endpoints && json.endpoints.type) {
96
- delete json.endpoints.type;
97
- }
98
92
  const patched = JSON.stringify(json);
99
93
  res.setHeader("content-length", Buffer.byteLength(patched));
100
94
  res.end(patched);
@@ -286,10 +286,48 @@ export function setupFederation(options) {
286
286
  // Add OStatus subscribe template so remote servers (WordPress AP, Misskey, etc.)
287
287
  // can redirect users to our authorize_interaction page for remote follow.
288
288
  federation.setWebFingerLinksDispatcher((_ctx, _resource) => {
289
+ const interactionBase = `${publicationUrl}${mountPath.replace(/^\//, "")}/authorize_interaction`;
289
290
  return [
291
+ // OStatus subscribe template (legacy remote follow)
290
292
  {
291
293
  rel: "http://ostatus.org/schema/1.0/subscribe",
292
- template: `${publicationUrl}${mountPath.replace(/^\//, "")}/authorize_interaction?uri={uri}`,
294
+ template: `${interactionBase}?uri={uri}`,
295
+ },
296
+ // FEP-3b86 Activity Intents — Follow
297
+ {
298
+ rel: "https://w3id.org/fep/3b86",
299
+ template: `${interactionBase}?uri={uri}&intent=follow`,
300
+ properties: {
301
+ "https://w3id.org/fep/3b86#intent":
302
+ "https://www.w3.org/ns/activitystreams#Follow",
303
+ },
304
+ },
305
+ // FEP-3b86 Activity Intents — Create (reply)
306
+ {
307
+ rel: "https://w3id.org/fep/3b86",
308
+ template: `${interactionBase}?uri={uri}&intent=create`,
309
+ properties: {
310
+ "https://w3id.org/fep/3b86#intent":
311
+ "https://www.w3.org/ns/activitystreams#Create",
312
+ },
313
+ },
314
+ // FEP-3b86 Activity Intents — Like
315
+ {
316
+ rel: "https://w3id.org/fep/3b86",
317
+ template: `${interactionBase}?uri={uri}&intent=like`,
318
+ properties: {
319
+ "https://w3id.org/fep/3b86#intent":
320
+ "https://www.w3.org/ns/activitystreams#Like",
321
+ },
322
+ },
323
+ // FEP-3b86 Activity Intents — Announce (boost)
324
+ {
325
+ rel: "https://w3id.org/fep/3b86",
326
+ template: `${interactionBase}?uri={uri}&intent=announce`,
327
+ properties: {
328
+ "https://w3id.org/fep/3b86#intent":
329
+ "https://www.w3.org/ns/activitystreams#Announce",
330
+ },
293
331
  },
294
332
  ];
295
333
  });
@@ -305,6 +343,43 @@ export function setupFederation(options) {
305
343
  storeRawActivities,
306
344
  });
307
345
 
346
+ // Handle Delete activities from actors whose signing keys are gone.
347
+ // When an account is deleted, the remote server sends Delete but the
348
+ // actor's key endpoint returns 404/410, so signature verification fails.
349
+ // Fedify 2.1.0 lets us inspect these instead of auto-rejecting.
350
+ inboxChain
351
+ .onUnverifiedActivity(async (_ctx, activity, reason) => {
352
+ // Handle Delete activities from actors whose signing keys are gone.
353
+ // When an account is deleted, the remote server sends Delete but the
354
+ // actor's key endpoint returns 404/410, so signature verification fails.
355
+ // Fedify 2.1.0 lets us inspect these instead of auto-rejecting.
356
+ if (reason.type === "keyFetchError") {
357
+ const status = reason.result?.status;
358
+ if (status === 404 || status === 410) {
359
+ const actorId = activity.actorId?.href;
360
+ if (actorId) {
361
+ const activityType = activity.constructor?.name || "";
362
+ if (activityType === "Delete") {
363
+ console.info(
364
+ `[ActivityPub] Processing unverified Delete from ${actorId} (key ${status})`,
365
+ );
366
+ try {
367
+ await collections.ap_followers.deleteOne({ actorUrl: actorId });
368
+ await collections.ap_timeline.deleteMany({ "author.url": actorId });
369
+ await collections.ap_notifications.deleteMany({ actorUrl: actorId });
370
+ console.info(`[ActivityPub] Cleaned up data for deleted actor ${actorId}`);
371
+ } catch (error) {
372
+ console.warn(`[ActivityPub] Cleanup for ${actorId} failed: ${error.message}`);
373
+ }
374
+ return new Response(null, { status: 202 });
375
+ }
376
+ }
377
+ }
378
+ }
379
+ // All other unverified activities: return null for default 401
380
+ return null;
381
+ });
382
+
308
383
  // Enable authenticated fetches for the shared inbox.
309
384
  // Without this, Fedify can't verify incoming HTTP Signatures from servers
310
385
  // that require authorized fetch (e.g. hachyderm.io returns 401 on unsigned GETs).
@@ -337,17 +412,33 @@ export function setupFederation(options) {
337
412
  ? await collections.posts.countDocuments()
338
413
  : 0;
339
414
 
415
+ const profile = await getProfile(collections);
416
+
340
417
  return {
341
418
  software: {
342
419
  name: "indiekit",
343
420
  version: softwareVersion,
421
+ repository: new URL("https://github.com/getindiekit/indiekit"),
422
+ homepage: new URL("https://getindiekit.com"),
344
423
  },
345
424
  protocols: ["activitypub"],
425
+ services: {
426
+ inbound: [],
427
+ outbound: [],
428
+ },
429
+ openRegistrations: false,
346
430
  usage: {
347
431
  users: { total: 1, activeMonth: 1, activeHalfyear: 1 },
348
432
  localPosts: postsCount,
349
433
  localComments: 0,
350
434
  },
435
+ metadata: {
436
+ nodeName: profile.name || handle,
437
+ nodeDescription: profile.summary || "",
438
+ staffAccounts: [
439
+ `${publicationUrl}${mountPath.replace(/^\//, "")}/users/${handle}`,
440
+ ],
441
+ },
351
442
  };
352
443
  });
353
444
 
@@ -740,6 +831,8 @@ export async function buildPersonActor(
740
831
  featuredTags: ctx.getFeaturedTagsUri(identifier),
741
832
  endpoints: new Endpoints({ sharedInbox: ctx.getInboxUri() }),
742
833
  manuallyApprovesFollowers: profile.manuallyApprovesFollowers || false,
834
+ indexable: true,
835
+ discoverable: true,
743
836
  };
744
837
 
745
838
  if (profile.summary) {
@@ -244,6 +244,12 @@ export function createIndexes(collections, options) {
244
244
  { userId: 1, timeline: 1 },
245
245
  { unique: true, background: true },
246
246
  );
247
+
248
+ // Tombstone indexes (FEP-4f05)
249
+ collections.ap_tombstones?.createIndex(
250
+ { url: 1 },
251
+ { unique: true, background: true },
252
+ );
247
253
  } catch {
248
254
  // Index creation failed — collections not yet available.
249
255
  // Indexes already exist from previous startups; non-fatal.
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Tombstone storage for soft-deleted posts (FEP-4f05).
3
+ * When a post is deleted, a tombstone record is created so remote servers
4
+ * fetching the URL get a proper Tombstone response instead of 404.
5
+ * @module storage/tombstones
6
+ */
7
+
8
+ /**
9
+ * Record a tombstone for a deleted post.
10
+ * @param {object} collections - MongoDB collections
11
+ * @param {object} data - { url, formerType, published, deleted }
12
+ */
13
+ export async function addTombstone(collections, { url, formerType, published, deleted }) {
14
+ const { ap_tombstones } = collections;
15
+ if (!ap_tombstones) return;
16
+
17
+ await ap_tombstones.updateOne(
18
+ { url },
19
+ {
20
+ $set: {
21
+ url,
22
+ formerType: formerType || "Note",
23
+ published: published || null,
24
+ deleted: deleted || new Date().toISOString(),
25
+ },
26
+ },
27
+ { upsert: true },
28
+ );
29
+ }
30
+
31
+ /**
32
+ * Remove a tombstone (post re-published).
33
+ * @param {object} collections - MongoDB collections
34
+ * @param {string} url - Post URL
35
+ */
36
+ export async function removeTombstone(collections, url) {
37
+ const { ap_tombstones } = collections;
38
+ if (!ap_tombstones) return;
39
+ await ap_tombstones.deleteOne({ url });
40
+ }
41
+
42
+ /**
43
+ * Look up a tombstone by URL.
44
+ * @param {object} collections - MongoDB collections
45
+ * @param {string} url - Post URL
46
+ * @returns {Promise<object|null>} Tombstone record or null
47
+ */
48
+ export async function getTombstone(collections, url) {
49
+ const { ap_tombstones } = collections;
50
+ if (!ap_tombstones) return null;
51
+ return ap_tombstones.findOne({ url });
52
+ }
package/lib/syndicator.js CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  } from "./jf2-to-as2.js";
9
9
  import { lookupWithSecurity } from "./lookup-helpers.js";
10
10
  import { logActivity } from "./activity-log.js";
11
+ import { addTimelineItem } from "./storage/timeline.js";
11
12
 
12
13
  /**
13
14
  * Create the ActivityPub syndicator object.
@@ -219,6 +220,54 @@ export function createSyndicator(plugin) {
219
220
  `[ActivityPub] Syndication queued: ${typeName} for ${properties.url}${replyNote}`,
220
221
  );
221
222
 
223
+ // Add own post to ap_timeline so it appears in Mastodon Client API
224
+ // timelines (Phanpy/Moshidon). Uses $setOnInsert — idempotent.
225
+ try {
226
+ const profile = await plugin._collections.ap_profile?.findOne({});
227
+ const content = normalizeContent(properties.content);
228
+ const timelineItem = {
229
+ uid: properties.url,
230
+ url: properties.url,
231
+ type: mapPostType(properties["post-type"]),
232
+ content,
233
+ author: {
234
+ name: profile?.name || handle,
235
+ url: profile?.url || plugin._publicationUrl,
236
+ photo: profile?.icon || "",
237
+ handle: `@${handle}`,
238
+ emojis: [],
239
+ bot: false,
240
+ },
241
+ published: properties.published || new Date().toISOString(),
242
+ createdAt: new Date().toISOString(),
243
+ visibility: properties.visibility || "public",
244
+ sensitive: properties.sensitive === "true",
245
+ category: Array.isArray(properties.category)
246
+ ? properties.category
247
+ : properties.category ? [properties.category] : [],
248
+ photo: normalizeMedia(properties.photo, plugin._publicationUrl),
249
+ video: normalizeMedia(properties.video, plugin._publicationUrl),
250
+ audio: normalizeMedia(properties.audio, plugin._publicationUrl),
251
+ counts: { replies: 0, boosts: 0, likes: 0 },
252
+ };
253
+ if (properties.name) timelineItem.name = properties.name;
254
+ if (properties.summary) timelineItem.summary = properties.summary;
255
+ if (properties["content-warning"]) {
256
+ timelineItem.summary = properties["content-warning"];
257
+ timelineItem.sensitive = true;
258
+ }
259
+ if (properties["in-reply-to"]) {
260
+ timelineItem.inReplyTo = Array.isArray(properties["in-reply-to"])
261
+ ? properties["in-reply-to"][0]
262
+ : properties["in-reply-to"];
263
+ }
264
+ await addTimelineItem(plugin._collections, timelineItem);
265
+ } catch (tlError) {
266
+ console.warn(
267
+ `[ActivityPub] Failed to add own post to timeline: ${tlError.message}`,
268
+ );
269
+ }
270
+
222
271
  return properties.url || undefined;
223
272
  } catch (error) {
224
273
  console.error("[ActivityPub] Syndication failed:", error.message);
@@ -237,3 +286,35 @@ export function createSyndicator(plugin) {
237
286
  update: async (properties) => plugin.update(properties),
238
287
  };
239
288
  }
289
+
290
+ // ─── Timeline helpers ───────────────────────────────────────────────────────
291
+
292
+ function normalizeContent(content) {
293
+ if (!content) return { text: "", html: "" };
294
+ if (typeof content === "string") return { text: content, html: `<p>${content}</p>` };
295
+ return {
296
+ text: content.text || content.value || "",
297
+ html: content.html || content.text || content.value || "",
298
+ };
299
+ }
300
+
301
+ function mapPostType(postType) {
302
+ if (postType === "article") return "article";
303
+ if (postType === "repost") return "boost";
304
+ return "note";
305
+ }
306
+
307
+ function normalizeMedia(value, siteUrl) {
308
+ if (!value) return [];
309
+ const base = siteUrl?.replace(/\/$/, "") || "";
310
+ const arr = Array.isArray(value) ? value : [value];
311
+ return arr.map((item) => {
312
+ if (typeof item === "string") {
313
+ return item.startsWith("http") ? item : `${base}/${item.replace(/^\//, "")}`;
314
+ }
315
+ if (item?.url && !item.url.startsWith("http")) {
316
+ return { ...item, url: `${base}/${item.url.replace(/^\//, "")}` };
317
+ }
318
+ return item;
319
+ }).filter(Boolean);
320
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "3.9.4",
3
+ "version": "3.10.1",
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",
@@ -37,9 +37,9 @@
37
37
  "url": "https://github.com/rmdes/indiekit-endpoint-activitypub/issues"
38
38
  },
39
39
  "dependencies": {
40
- "@fedify/debugger": "^2.0.0",
41
- "@fedify/fedify": "^2.0.0",
42
- "@fedify/redis": "^2.0.0",
40
+ "@fedify/debugger": "^2.1.0",
41
+ "@fedify/fedify": "^2.1.0",
42
+ "@fedify/redis": "^2.1.0",
43
43
  "@js-temporal/polyfill": "^0.5.0",
44
44
  "express": "^5.0.0",
45
45
  "express-rate-limit": "^7.5.1",