@rmdes/indiekit-endpoint-activitypub 3.8.3 → 3.8.5

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
@@ -5,6 +5,11 @@ import { createMastodonRouter } from "./lib/mastodon/router.js";
5
5
  import { setLocalIdentity } from "./lib/mastodon/entities/status.js";
6
6
  import { initRedisCache } from "./lib/redis-cache.js";
7
7
  import { lookupWithSecurity } from "./lib/lookup-helpers.js";
8
+ import {
9
+ needsDirectFollow,
10
+ sendDirectFollow,
11
+ sendDirectUnfollow,
12
+ } from "./lib/direct-follow.js";
8
13
  import {
9
14
  createFedifyMiddleware,
10
15
  } from "./lib/federation-bridge.js";
@@ -231,13 +236,6 @@ export default class ActivityPubEndpoint {
231
236
  // authenticated `routes` getter, not the federation layer.
232
237
  if (req.path.startsWith("/admin")) return next();
233
238
 
234
- // Diagnostic: log inbox POSTs to detect federation stalls
235
- if (req.method === "POST" && req.path.includes("inbox")) {
236
- const ua = req.get("user-agent") || "unknown";
237
- const bodyParsed = req.body !== undefined && Object.keys(req.body || {}).length > 0;
238
- console.info(`[federation-diag] POST ${req.path} from=${ua.slice(0, 60)} bodyParsed=${bodyParsed} readable=${req.readable}`);
239
- }
240
-
241
239
  // Fedify's acceptsJsonLd() treats Accept: */* as NOT accepting JSON-LD
242
240
  // (it only returns true for explicit application/activity+json etc.).
243
241
  // Remote servers fetching actor URLs for HTTP Signature verification
@@ -729,6 +727,32 @@ export default class ActivityPubEndpoint {
729
727
  * @param {string} [actorInfo.photo] - Actor avatar URL
730
728
  * @returns {Promise<{ok: boolean, error?: string}>}
731
729
  */
730
+ /**
731
+ * Load the RSA private key from ap_keys for direct HTTP Signature signing.
732
+ * @returns {Promise<CryptoKey|null>}
733
+ */
734
+ async _loadRsaPrivateKey() {
735
+ try {
736
+ const keyDoc = await this._collections.ap_keys.findOne({
737
+ privateKeyPem: { $exists: true },
738
+ });
739
+ if (!keyDoc?.privateKeyPem) return null;
740
+ const pemBody = keyDoc.privateKeyPem
741
+ .replace(/-----[^-]+-----/g, "")
742
+ .replace(/\s/g, "");
743
+ return await crypto.subtle.importKey(
744
+ "pkcs8",
745
+ Buffer.from(pemBody, "base64"),
746
+ { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
747
+ true,
748
+ ["sign"],
749
+ );
750
+ } catch (error) {
751
+ console.error("[ActivityPub] Failed to load RSA key:", error.message);
752
+ return null;
753
+ }
754
+ }
755
+
732
756
  async followActor(actorUrl, actorInfo = {}) {
733
757
  if (!this._federation) {
734
758
  return { ok: false, error: "Federation not initialized" };
@@ -755,14 +779,33 @@ export default class ActivityPubEndpoint {
755
779
  }
756
780
 
757
781
  // Send Follow activity
758
- const follow = new Follow({
759
- actor: ctx.getActorUri(handle),
760
- object: new URL(actorUrl),
761
- });
762
-
763
- await ctx.sendActivity({ identifier: handle }, remoteActor, follow, {
764
- orderingKey: actorUrl,
765
- });
782
+ if (needsDirectFollow(actorUrl)) {
783
+ // tags.pub rejects Fedify's LD Signature context (identity/v1).
784
+ // Send a minimal signed Follow directly, bypassing the outbox pipeline.
785
+ // See: https://github.com/social-web-foundation/tags.pub/issues/10
786
+ const rsaKey = await this._loadRsaPrivateKey();
787
+ if (!rsaKey) {
788
+ return { ok: false, error: "No RSA key available for direct follow" };
789
+ }
790
+ const result = await sendDirectFollow({
791
+ actorUri: ctx.getActorUri(handle).href,
792
+ targetActorUrl: actorUrl,
793
+ inboxUrl: remoteActor.inboxId?.href,
794
+ keyId: `${ctx.getActorUri(handle).href}#main-key`,
795
+ privateKey: rsaKey,
796
+ });
797
+ if (!result.ok) {
798
+ return { ok: false, error: result.error };
799
+ }
800
+ } else {
801
+ const follow = new Follow({
802
+ actor: ctx.getActorUri(handle),
803
+ object: new URL(actorUrl),
804
+ });
805
+ await ctx.sendActivity({ identifier: handle }, remoteActor, follow, {
806
+ orderingKey: actorUrl,
807
+ });
808
+ }
766
809
 
767
810
  // Store in ap_following
768
811
  const name =
@@ -866,19 +909,35 @@ export default class ActivityPubEndpoint {
866
909
  return { ok: true };
867
910
  }
868
911
 
869
- const follow = new Follow({
870
- actor: ctx.getActorUri(handle),
871
- object: new URL(actorUrl),
872
- });
873
-
874
- const undo = new Undo({
875
- actor: ctx.getActorUri(handle),
876
- object: follow,
877
- });
878
-
879
- await ctx.sendActivity({ identifier: handle }, remoteActor, undo, {
880
- orderingKey: actorUrl,
881
- });
912
+ if (needsDirectFollow(actorUrl)) {
913
+ // tags.pub rejects Fedify's LD Signature context (identity/v1).
914
+ // See: https://github.com/social-web-foundation/tags.pub/issues/10
915
+ const rsaKey = await this._loadRsaPrivateKey();
916
+ if (rsaKey) {
917
+ const result = await sendDirectUnfollow({
918
+ actorUri: ctx.getActorUri(handle).href,
919
+ targetActorUrl: actorUrl,
920
+ inboxUrl: remoteActor.inboxId?.href,
921
+ keyId: `${ctx.getActorUri(handle).href}#main-key`,
922
+ privateKey: rsaKey,
923
+ });
924
+ if (!result.ok) {
925
+ console.warn(`[ActivityPub] Direct unfollow failed for ${actorUrl}: ${result.error}`);
926
+ }
927
+ }
928
+ } else {
929
+ const follow = new Follow({
930
+ actor: ctx.getActorUri(handle),
931
+ object: new URL(actorUrl),
932
+ });
933
+ const undo = new Undo({
934
+ actor: ctx.getActorUri(handle),
935
+ object: follow,
936
+ });
937
+ await ctx.sendActivity({ identifier: handle }, remoteActor, undo, {
938
+ orderingKey: actorUrl,
939
+ });
940
+ }
882
941
  await this._collections.ap_following.deleteOne({ actorUrl });
883
942
 
884
943
  console.info(`[ActivityPub] Sent Undo(Follow) to ${actorUrl}`);
@@ -1530,6 +1589,7 @@ export default class ActivityPubEndpoint {
1530
1589
  federation: this._federation,
1531
1590
  followActor: (url, info) => pluginRef.followActor(url, info),
1532
1591
  unfollowActor: (url) => pluginRef.unfollowActor(url),
1592
+ loadRsaKey: () => pluginRef._loadRsaPrivateKey(),
1533
1593
  },
1534
1594
  });
1535
1595
  Indiekit.addEndpoint({
@@ -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) {
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Direct Follow/Undo(Follow) for servers that reject Fedify's LD Signatures.
3
+ *
4
+ * tags.pub's activitypub-bot uses the `activitystrea.ms` AS2 parser, which
5
+ * rejects the `https://w3id.org/identity/v1` JSON-LD context that Fedify 2.0
6
+ * adds for RsaSignature2017. This module sends Follow/Undo(Follow) activities
7
+ * with a minimal body (no LD Sig, no Data Integrity Proof) signed with
8
+ * draft-cavage HTTP Signatures.
9
+ *
10
+ * Upstream issue: https://github.com/social-web-foundation/tags.pub/issues/10
11
+ *
12
+ * @module direct-follow
13
+ */
14
+
15
+ import crypto from "node:crypto";
16
+
17
+ /** Hostnames that need direct follow (bypass Fedify outbox pipeline) */
18
+ const DIRECT_FOLLOW_HOSTS = new Set(["tags.pub"]);
19
+
20
+ /**
21
+ * Check if an actor URL requires direct follow delivery.
22
+ * @param {string} actorUrl
23
+ * @returns {boolean}
24
+ */
25
+ export function needsDirectFollow(actorUrl) {
26
+ try {
27
+ return DIRECT_FOLLOW_HOSTS.has(new URL(actorUrl).hostname);
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Send a Follow activity directly with draft-cavage HTTP Signatures.
35
+ * @param {object} options
36
+ * @param {string} options.actorUri - Our actor URI
37
+ * @param {string} options.targetActorUrl - Remote actor URL to follow
38
+ * @param {string} options.inboxUrl - Remote actor's inbox URL
39
+ * @param {string} options.keyId - Our key ID (e.g. ...#main-key)
40
+ * @param {CryptoKey} options.privateKey - RSA private key for signing
41
+ * @returns {Promise<{ok: boolean, status?: number, error?: string}>}
42
+ */
43
+ export async function sendDirectFollow({
44
+ actorUri,
45
+ targetActorUrl,
46
+ inboxUrl,
47
+ keyId,
48
+ privateKey,
49
+ }) {
50
+ const body = JSON.stringify({
51
+ "@context": "https://www.w3.org/ns/activitystreams",
52
+ type: "Follow",
53
+ actor: actorUri,
54
+ object: targetActorUrl,
55
+ id: `${actorUri.replace(/\/$/, "")}/#Follow/${crypto.randomUUID()}`,
56
+ });
57
+
58
+ return _signAndSend(inboxUrl, body, keyId, privateKey);
59
+ }
60
+
61
+ /**
62
+ * Send an Undo(Follow) activity directly with draft-cavage HTTP Signatures.
63
+ * @param {object} options
64
+ * @param {string} options.actorUri - Our actor URI
65
+ * @param {string} options.targetActorUrl - Remote actor URL to unfollow
66
+ * @param {string} options.inboxUrl - Remote actor's inbox URL
67
+ * @param {string} options.keyId - Our key ID (e.g. ...#main-key)
68
+ * @param {CryptoKey} options.privateKey - RSA private key for signing
69
+ * @returns {Promise<{ok: boolean, status?: number, error?: string}>}
70
+ */
71
+ export async function sendDirectUnfollow({
72
+ actorUri,
73
+ targetActorUrl,
74
+ inboxUrl,
75
+ keyId,
76
+ privateKey,
77
+ }) {
78
+ const body = JSON.stringify({
79
+ "@context": "https://www.w3.org/ns/activitystreams",
80
+ type: "Undo",
81
+ actor: actorUri,
82
+ object: {
83
+ type: "Follow",
84
+ actor: actorUri,
85
+ object: targetActorUrl,
86
+ },
87
+ id: `${actorUri.replace(/\/$/, "")}/#Undo/${crypto.randomUUID()}`,
88
+ });
89
+
90
+ return _signAndSend(inboxUrl, body, keyId, privateKey);
91
+ }
92
+
93
+ /**
94
+ * Sign a POST request with draft-cavage HTTP Signatures and send it.
95
+ * @private
96
+ */
97
+ async function _signAndSend(inboxUrl, body, keyId, privateKey) {
98
+ const url = new URL(inboxUrl);
99
+ const date = new Date().toUTCString();
100
+
101
+ // Compute SHA-256 digest of the body
102
+ const digestRaw = await crypto.subtle.digest(
103
+ "SHA-256",
104
+ new TextEncoder().encode(body),
105
+ );
106
+ const digest = "SHA-256=" + Buffer.from(digestRaw).toString("base64");
107
+
108
+ // Build draft-cavage signing string
109
+ const signingString = [
110
+ `(request-target): post ${url.pathname}`,
111
+ `host: ${url.host}`,
112
+ `date: ${date}`,
113
+ `digest: ${digest}`,
114
+ ].join("\n");
115
+
116
+ // Sign with RSA-SHA256
117
+ const signature = await crypto.subtle.sign(
118
+ "RSASSA-PKCS1-v1_5",
119
+ privateKey,
120
+ new TextEncoder().encode(signingString),
121
+ );
122
+ const signatureB64 = Buffer.from(signature).toString("base64");
123
+
124
+ const signatureHeader = [
125
+ `keyId="${keyId}"`,
126
+ `algorithm="rsa-sha256"`,
127
+ `headers="(request-target) host date digest"`,
128
+ `signature="${signatureB64}"`,
129
+ ].join(",");
130
+
131
+ try {
132
+ const response = await fetch(inboxUrl, {
133
+ method: "POST",
134
+ headers: {
135
+ "Content-Type": "application/activity+json",
136
+ Date: date,
137
+ Digest: digest,
138
+ Host: url.host,
139
+ Signature: signatureHeader,
140
+ },
141
+ body,
142
+ });
143
+
144
+ if (response.ok) {
145
+ return { ok: true, status: response.status };
146
+ }
147
+
148
+ const errorBody = await response.text().catch(() => "");
149
+ let detail = errorBody;
150
+ try {
151
+ detail = JSON.parse(errorBody).detail || errorBody;
152
+ } catch {
153
+ // not JSON
154
+ }
155
+ return {
156
+ ok: false,
157
+ status: response.status,
158
+ error: `${response.status} ${response.statusText}: ${detail}`,
159
+ };
160
+ } catch (error) {
161
+ return { ok: false, error: error.message };
162
+ }
163
+ }
@@ -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 recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections);
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 recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections);
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 recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections);
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, {
@@ -614,6 +614,7 @@ function getFederationOpts(req) {
614
614
  handle: pluginOptions.handle || "user",
615
615
  publicationUrl: pluginOptions.publicationUrl,
616
616
  collections: req.app.locals.mastodonCollections,
617
+ loadRsaKey: pluginOptions.loadRsaKey,
617
618
  };
618
619
  }
619
620
 
@@ -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",
3
+ "version": "3.8.5",
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",