@takosjp/yurucommu-core 3.4.3 → 3.4.4

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.
Files changed (112) hide show
  1. package/migrations/0023_delivery_resolution_outbox.sql +33 -0
  2. package/migrations/0024_delivery_fanout_outbox.sql +35 -0
  3. package/migrations/0025_delivery_endpoint_terminal_retention.sql +6 -0
  4. package/migrations/0026_remote_actor_fetch_failures.sql +29 -0
  5. package/migrations/0027_remote_actor_tombstones.sql +12 -0
  6. package/migrations/0028_remote_actor_delivery_fence.sql +30 -0
  7. package/migrations/0029_delivery_endpoint_recipients.sql +47 -0
  8. package/package.json +2 -1
  9. package/packages/api/package.json +1 -1
  10. package/packages/api/src/lib/api/communities.ts +2 -0
  11. package/packages/api/src/lib/api/fetch.ts +69 -13
  12. package/packages/api/src/lib/api/normalize.ts +32 -10
  13. package/packages/api/src/lib/api/notifications.ts +4 -1
  14. package/packages/api/src/lib/rtc-client.ts +127 -29
  15. package/src/backend/federation-helpers.ts +1 -0
  16. package/src/backend/index.ts +16 -3
  17. package/src/backend/lib/account-migration.ts +340 -0
  18. package/src/backend/lib/activity-delete-cascade.ts +193 -0
  19. package/src/backend/lib/activitypub-actor-cache.ts +615 -48
  20. package/src/backend/lib/activitypub-actor-identity-sql.ts +179 -0
  21. package/src/backend/lib/activitypub-actor-identity.ts +39 -0
  22. package/src/backend/lib/activitypub-validators.ts +62 -4
  23. package/src/backend/lib/ap-ids.ts +36 -6
  24. package/src/backend/lib/ap-verify.ts +7 -17
  25. package/src/backend/lib/blocklist-purge.ts +676 -42
  26. package/src/backend/lib/blocklist.ts +278 -39
  27. package/src/backend/lib/community-visibility.ts +47 -33
  28. package/src/backend/lib/delivery/fanout-outbox.ts +336 -0
  29. package/src/backend/lib/delivery/planner.ts +16 -12
  30. package/src/backend/lib/delivery/queue-batching.ts +389 -145
  31. package/src/backend/lib/delivery/queue-delivery.ts +47 -25
  32. package/src/backend/lib/delivery/queue.ts +479 -73
  33. package/src/backend/lib/delivery/resolution-outbox.ts +456 -0
  34. package/src/backend/lib/delivery/types.ts +31 -7
  35. package/src/backend/lib/feed-exclude.ts +40 -28
  36. package/src/backend/lib/follow-edge-mutations.ts +217 -0
  37. package/src/backend/lib/notification-eligibility.ts +15 -9
  38. package/src/backend/lib/notification-push.ts +2 -2
  39. package/src/backend/lib/oidc-id-token.ts +66 -0
  40. package/src/backend/lib/personal-actor-moderation.ts +239 -0
  41. package/src/backend/lib/post-visibility.ts +79 -16
  42. package/src/backend/lib/remote-activity-id.ts +61 -0
  43. package/src/backend/lib/unread-counts.ts +3 -0
  44. package/src/backend/retention.ts +38 -1
  45. package/src/backend/routes/account-teardown.ts +422 -156
  46. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +104 -105
  47. package/src/backend/routes/activitypub/handlers/inbound-community-scope.ts +121 -0
  48. package/src/backend/routes/activitypub/handlers/inbound-object-identity.ts +54 -0
  49. package/src/backend/routes/activitypub/handlers/inbound-reply-target.ts +34 -0
  50. package/src/backend/routes/activitypub/handlers/inbound-story-projection.ts +438 -0
  51. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1121 -806
  52. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +118 -153
  53. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +185 -168
  54. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +220 -32
  55. package/src/backend/routes/activitypub/inbound-activity-identity.ts +16 -0
  56. package/src/backend/routes/activitypub/inbound-activity-reference.ts +116 -0
  57. package/src/backend/routes/activitypub/inbound-addressing.ts +87 -0
  58. package/src/backend/routes/activitypub/inbox-addressing.ts +22 -19
  59. package/src/backend/routes/activitypub/inbox-types.ts +14 -2
  60. package/src/backend/routes/activitypub/inbox.ts +81 -105
  61. package/src/backend/routes/activitypub/outbox.ts +0 -0
  62. package/src/backend/routes/activitypub.ts +6 -5
  63. package/src/backend/routes/actors-helpers.ts +34 -8
  64. package/src/backend/routes/actors.ts +328 -164
  65. package/src/backend/routes/auth-helpers.ts +57 -9
  66. package/src/backend/routes/auth.ts +10 -2
  67. package/src/backend/routes/communities/membership-invites.ts +4 -1
  68. package/src/backend/routes/communities/membership-members.ts +201 -73
  69. package/src/backend/routes/communities/membership-requests.ts +141 -76
  70. package/src/backend/routes/communities/membership-shared.ts +228 -21
  71. package/src/backend/routes/communities/messages.ts +9 -2
  72. package/src/backend/routes/communities/routes.ts +48 -13
  73. package/src/backend/routes/dm/contacts.ts +29 -41
  74. package/src/backend/routes/dm/conversations-helpers.ts +9 -1
  75. package/src/backend/routes/dm/messages.ts +36 -42
  76. package/src/backend/routes/dm/read-archive.ts +4 -2
  77. package/src/backend/routes/dm/requests.ts +62 -54
  78. package/src/backend/routes/follow-helpers.ts +200 -60
  79. package/src/backend/routes/follow.ts +146 -140
  80. package/src/backend/routes/media.ts +20 -94
  81. package/src/backend/routes/moderation.ts +72 -4
  82. package/src/backend/routes/notes.ts +3 -4
  83. package/src/backend/routes/notifications.ts +209 -108
  84. package/src/backend/routes/posts/delete-cascade.ts +253 -89
  85. package/src/backend/routes/posts/federation.ts +373 -0
  86. package/src/backend/routes/posts/interactions.ts +160 -184
  87. package/src/backend/routes/posts/like-mutation.ts +240 -0
  88. package/src/backend/routes/posts/post-helpers.ts +112 -162
  89. package/src/backend/routes/posts/queries.ts +150 -54
  90. package/src/backend/routes/posts/routes.ts +169 -329
  91. package/src/backend/routes/posts/transformers.ts +61 -6
  92. package/src/backend/routes/recommendations.ts +37 -44
  93. package/src/backend/routes/rtc/index.ts +26 -6
  94. package/src/backend/routes/search.ts +39 -55
  95. package/src/backend/routes/stories/interactions.ts +22 -6
  96. package/src/backend/routes/stories/query-helpers.ts +28 -41
  97. package/src/backend/routes/stories/routes.ts +125 -108
  98. package/src/backend/routes/takos-tools/dm.ts +17 -17
  99. package/src/backend/routes/takos-tools/posts.ts +189 -106
  100. package/src/backend/routes/takos-tools/search.ts +13 -4
  101. package/src/backend/routes/takos-tools/timeline.ts +35 -27
  102. package/src/backend/routes/takos-tools-response.ts +6 -2
  103. package/src/backend/routes/timeline.ts +5 -5
  104. package/src/backend/runtime/call-signaling-do.ts +35 -4
  105. package/src/backend/runtime/one-time-ticket.ts +116 -0
  106. package/src/backend/runtime/realtime-stream-do.ts +5 -61
  107. package/src/backend/runtime/signaling-hub.ts +36 -3
  108. package/src/backend/server.ts +95 -80
  109. package/src/db/d1-write.ts +67 -43
  110. package/src/db/schema/federation.ts +42 -1
  111. package/src/db/schema/messaging.ts +110 -2
  112. package/src/db/schema.ts +1 -1
@@ -1,5 +1,5 @@
1
1
  import type { Database } from "../../../../db/index.ts";
2
- import { and, eq, gt, or, sql } from "drizzle-orm";
2
+ import { and, eq, sql } from "drizzle-orm";
3
3
  import { activities, actors, follows } from "../../../../db/index.ts";
4
4
  import {
5
5
  activityApId,
@@ -7,8 +7,14 @@ import {
7
7
  isLocal,
8
8
  } from "../../../federation-helpers.ts";
9
9
  import { enqueueDeliveryToActor } from "../../../lib/delivery/queue.ts";
10
- import { actorIsBlockedBy } from "../../../lib/post-visibility.ts";
10
+ import { isSameActivityPubActor } from "../../../lib/activitypub-actor-identity.ts";
11
+ import { actorSuppressesInteractionFrom } from "../../../lib/post-visibility.ts";
11
12
  import { logger } from "../../../lib/logger.ts";
13
+ import { resolveInboundActivityReference } from "../inbound-activity-reference.ts";
14
+ import {
15
+ acceptRetainedFollowDirection,
16
+ severFollowDirection,
17
+ } from "../../../lib/follow-edge-mutations.ts";
12
18
  import {
13
19
  type Activity,
14
20
  type ActivityContext,
@@ -18,15 +24,49 @@ import {
18
24
  } from "../inbox-types.ts";
19
25
  import {
20
26
  findFollowByActivityId,
27
+ findFollowByFollowerIdentity,
21
28
  runBatch,
22
29
  undoInteraction,
23
30
  upsertActivityAndNotify,
24
31
  } from "./inbox-shared-helpers.ts";
25
32
 
26
33
  type ActorRow = typeof actors.$inferSelect;
34
+ type FollowReference = {
35
+ followerApId: string;
36
+ followingApId: string;
37
+ status: string;
38
+ };
27
39
 
28
40
  const log = logger.child({ component: "activitypub.inbox.follow" });
29
41
 
42
+ function authorizesFollowUndo(
43
+ follow: FollowReference,
44
+ actor: string,
45
+ recipientApId: string,
46
+ activityId: string | null,
47
+ ): boolean {
48
+ if (!isSameActivityPubActor(follow.followerApId, actor)) {
49
+ log.warn("Undo(Follow) actor mismatch", {
50
+ event: "ap.undo.follow.actor_mismatch",
51
+ actor,
52
+ followOwner: follow.followerApId,
53
+ activityId,
54
+ });
55
+ return false;
56
+ }
57
+ if (follow.followingApId !== recipientApId) {
58
+ log.warn("Undo(Follow) recipient mismatch", {
59
+ event: "ap.undo.follow.recipient_mismatch",
60
+ actor,
61
+ recipient: recipientApId,
62
+ followedActor: follow.followingApId,
63
+ activityId,
64
+ });
65
+ return false;
66
+ }
67
+ return true;
68
+ }
69
+
30
70
  // ---------------------------------------------------------------------------
31
71
  // Follow handler
32
72
  // ---------------------------------------------------------------------------
@@ -41,15 +81,15 @@ export async function handleFollow(
41
81
  ) {
42
82
  const db = c.get("db");
43
83
 
44
- // A Follow from an actor the recipient has personally BLOCKED must be dropped —
45
- // mirror the local follow guard (handleLocalFollow's actorIsBlockedBy 404).
46
- // Without this, a blocked remote could re-follow (auto-accepting on a public
47
- // recipient → followerCount++ + a follow notification + an Accept, so the
48
- // blocked actor resumes receiving fan-out), or surface in the owner's
84
+ // A Follow from an actor the recipient has personally blocked OR muted must be
85
+ // dropped at write time. Without this, a suppressed remote could re-follow
86
+ // (auto-accepting on a public recipient followerCount++ + a follow
87
+ // notification + an Accept, so the remote actor resumes receiving fan-out),
88
+ // or surface in the owner's
49
89
  // follow-request list. `actor` is the HTTP-signature-verified signer. The inbox
50
90
  // already ACKs, so dropping here causes no retry storm and does not leak the
51
- // block to the sender.
52
- if (await actorIsBlockedBy(db, recipient.apId, actor)) {
91
+ // recipient's moderation choice to the sender.
92
+ if (await actorSuppressesInteractionFrom(db, recipient.apId, actor)) {
53
93
  return;
54
94
  }
55
95
 
@@ -59,20 +99,17 @@ export async function handleFollow(
59
99
  const status = recipient.isPrivate ? "pending" : "accepted";
60
100
  const now = new Date().toISOString();
61
101
 
62
- // Was the edge already present BEFORE this dispatch? This gates the one-shot
63
- // owner notification / Accept reply below so a duplicate (re)delivery does not
64
- // spam them it does NOT gate the counter, which is reconciled atomically in
65
- // the batch below against the edge table's pre-insert state.
66
- const existingEdge = await db
67
- .select({ followerApId: follows.followerApId })
68
- .from(follows)
69
- .where(
70
- and(
71
- eq(follows.followerApId, actor),
72
- eq(follows.followingApId, recipient.apId),
73
- ),
74
- )
75
- .get();
102
+ // Was the edge already present BEFORE this dispatch? Exact keys are the
103
+ // steady-state path; the helper also recognizes a pre-invariant cosmetic
104
+ // spelling so a retry cannot create a second logical edge. The original
105
+ // insert + counter transition committed atomically, so a retained edge means
106
+ // there is nothing left to reconcile or notify.
107
+ const existingEdge = await findFollowByFollowerIdentity(
108
+ db,
109
+ actor,
110
+ recipient.apId,
111
+ );
112
+ if (existingEdge) return;
76
113
 
77
114
  // #COUNTER-SYM (crash-retry convergence): the followers-edge insert and the
78
115
  // recipient.followerCount +1 MUST commit together. Previously the edge was
@@ -122,11 +159,6 @@ export async function handleFollow(
122
159
  .onConflictDoNothing();
123
160
  }
124
161
 
125
- // If the edge already existed, this is a duplicate (re)delivery: the counter is
126
- // already correct (the batch's no-op insert kept the NOT-EXISTS guard false),
127
- // so do not re-notify the owner or re-send an Accept.
128
- if (existingEdge) return;
129
-
130
162
  // Store activity and add to inbox (AP Native notification)
131
163
  await upsertActivityAndNotify(
132
164
  db,
@@ -181,58 +213,30 @@ export async function handleAccept(
181
213
  if (!followId) return;
182
214
 
183
215
  const follow = await findFollowByActivityId(db, followId);
184
- if (!follow || follow.status === "accepted") return;
216
+ if (
217
+ !follow ||
218
+ (follow.status !== "pending" && follow.status !== "accepted")
219
+ ) {
220
+ return;
221
+ }
185
222
 
186
223
  // Only the followed party may Accept the follow. The signing actor is bound to
187
224
  // its domain upstream, so without this a different-domain actor that learned
188
225
  // the follow activity id could flip someone else's pending follow to accepted.
189
- if (!actor || follow.followingApId !== actor) return;
226
+ if (!isSameActivityPubActor(follow.followingApId, actor)) return;
190
227
 
191
228
  const now = new Date().toISOString();
192
229
 
193
- try {
194
- // #COUNTER-SYM (crash-retry convergence): the pending->accepted flip and
195
- // both +1s MUST commit together. Previously the flip committed first and the
196
- // increments were SEPARATE statements; a crash between them left the edge
197
- // 'accepted' while the counts were un-bumped, and the peer's retry saw an
198
- // already-accepted edge (the early-return above) so the increments were
199
- // SKIPPED → a permanent UNDER-count. Co-commit the flip and the increments
200
- // in one atomic batch so the whole transition is all-or-nothing.
201
- //
202
- // The two increments run BEFORE the flip and are each guarded by a
203
- // correlated `EXISTS(... status='pending')` subquery, so they fire only when
204
- // THIS batch is the one performing the transition (the still-pending edge is
205
- // observed in pre-flip state). A concurrent duplicate Accept, or a retry
206
- // after the flip already committed, sees a non-pending edge → both guards
207
- // are false and the flip's `status='pending'` predicate is a no-op, so the
208
- // counts can neither double-bump nor (on retry) under-count.
209
- const pendingEdgeExists = sql`EXISTS (SELECT 1 FROM ${follows} WHERE ${follows.followerApId} = ${follow.followerApId} AND ${follows.followingApId} = ${follow.followingApId} AND ${follows.status} = 'pending')`;
210
- await runBatch(db, [
211
- db
212
- .update(actors)
213
- .set({ followingCount: sql`${actors.followingCount} + 1` })
214
- .where(and(eq(actors.apId, follow.followerApId), pendingEdgeExists)),
215
- db
216
- .update(actors)
217
- .set({ followerCount: sql`${actors.followerCount} + 1` })
218
- .where(and(eq(actors.apId, follow.followingApId), pendingEdgeExists)),
219
- db
220
- .update(follows)
221
- .set({ status: "accepted", acceptedAt: now })
222
- .where(
223
- and(
224
- eq(follows.followerApId, follow.followerApId),
225
- eq(follows.followingApId, follow.followingApId),
226
- eq(follows.status, "pending"),
227
- ),
228
- ),
229
- ]);
230
- } catch (e) {
231
- log.error("Error in handleAccept", {
232
- event: "ap.accept.handler_error",
233
- error: e,
234
- });
235
- }
230
+ // Converge legacy cosmetic duplicates to one logical accepted relation. The
231
+ // shared mutation derives the counter delta from the accepted rows that
232
+ // actually exist, so pending->accepted adds one, a retry adds zero, and two
233
+ // accepted spellings repair back to one in the same atomic batch.
234
+ await acceptRetainedFollowDirection(
235
+ db,
236
+ follow.followerApId,
237
+ follow.followingApId,
238
+ now,
239
+ );
236
240
  }
237
241
 
238
242
  // ---------------------------------------------------------------------------
@@ -252,39 +256,15 @@ export async function handleReject(
252
256
  if (!follow) return;
253
257
 
254
258
  // Only the followed party may Reject the follow (see handleAccept).
255
- if (!actor || follow.followingApId !== actor) return;
259
+ if (!isSameActivityPubActor(follow.followingApId, actor)) return;
256
260
 
257
261
  // A remote followee can Reject an ALREADY-ACCEPTED follow to terminate it
258
- // (Mastodon does this on lock + remove-follower). handleAccept incremented the
259
- // local follower's followingCount when the edge became accepted, so a Reject of
260
- // an accepted edge MUST decrement it otherwise the follower's following_count
261
- // stays permanently +1 over its true value (the edge IS correctly deleted, only
262
- // the denormalized count drifts). Co-commit the accepted-gated decrement with
263
- // the delete, mirroring undoFollowEdge: a pending (never-counted) reject and a
264
- // duplicate are no-ops via the EXISTS(... status='accepted') + gt(>0) guards.
265
- // (followingApId — the rejecting followee — is the remote signer with no local
266
- // actors row, so only the local follower's followingCount is reconciled.)
267
- const acceptedEdgeExists = sql`EXISTS (SELECT 1 FROM ${follows} WHERE ${follows.followerApId} = ${follow.followerApId} AND ${follows.followingApId} = ${follow.followingApId} AND ${follows.status} = 'accepted')`;
268
- await runBatch(db, [
269
- db
270
- .update(actors)
271
- .set({ followingCount: sql`${actors.followingCount} - 1` })
272
- .where(
273
- and(
274
- eq(actors.apId, follow.followerApId),
275
- gt(actors.followingCount, 0),
276
- acceptedEdgeExists,
277
- ),
278
- ),
279
- db
280
- .delete(follows)
281
- .where(
282
- and(
283
- eq(follows.followerApId, follow.followerApId),
284
- eq(follows.followingApId, follow.followingApId),
285
- ),
286
- ),
287
- ]);
262
+ // (Mastodon does this on lock + remove-follower). Remove every cosmetically
263
+ // equivalent retained edge: deleting only the first activity-id match left a
264
+ // second row authorized to receive posts. The shared mutation subtracts the
265
+ // number of accepted rows and deletes them atomically; pending and duplicate
266
+ // Reject deliveries remain counter-safe no-ops.
267
+ await severFollowDirection(db, follow.followerApId, follow.followingApId);
288
268
  }
289
269
 
290
270
  // ---------------------------------------------------------------------------
@@ -306,7 +286,7 @@ export async function handleUndo(
306
286
  activity: Activity,
307
287
  recipient: ActorRow | null,
308
288
  actor: string,
309
- _baseUrl: string,
289
+ baseUrl: string,
310
290
  ) {
311
291
  const db = c.get("db");
312
292
  const activityObject = getActivityObject(activity);
@@ -320,6 +300,7 @@ export async function handleUndo(
320
300
  objectId,
321
301
  actor,
322
302
  recipient,
303
+ baseUrl,
323
304
  );
324
305
  if (resolved) return;
325
306
  }
@@ -333,7 +314,7 @@ export async function handleUndo(
333
314
  });
334
315
  return;
335
316
  }
336
- await undoFollow(db, objectId, actor, recipient);
317
+ await undoFollow(db, objectId, actor, recipient, baseUrl);
337
318
  } else if (typeIncludes(objectType, "Like")) {
338
319
  await undoLike(db, objectId, activityObject, actor);
339
320
  } else if (typeIncludes(objectType, "Announce")) {
@@ -355,7 +336,16 @@ async function resolveUndoByActivityId(
355
336
  objectId: string,
356
337
  actor: string,
357
338
  recipient: ActorRow | null,
339
+ baseUrl: string,
358
340
  ): Promise<boolean> {
341
+ const resolvedActivityId = await resolveInboundActivityReference(
342
+ db,
343
+ objectId,
344
+ actor,
345
+ baseUrl,
346
+ );
347
+ if (!resolvedActivityId) return false;
348
+
359
349
  const originalActivity = await db
360
350
  .select({
361
351
  apId: activities.apId,
@@ -364,19 +354,14 @@ async function resolveUndoByActivityId(
364
354
  actorApId: activities.actorApId,
365
355
  })
366
356
  .from(activities)
367
- .where(
368
- or(
369
- eq(activities.apId, objectId),
370
- and(
371
- eq(activities.direction, "inbound"),
372
- sql`json_extract(${activities.rawJson}, '$.id') = ${objectId}`,
373
- ),
374
- ),
375
- )
357
+ .where(eq(activities.apId, resolvedActivityId))
376
358
  .get();
377
359
  if (!originalActivity) return false;
378
360
 
379
- if (originalActivity.actorApId && originalActivity.actorApId !== actor) {
361
+ if (
362
+ originalActivity.actorApId &&
363
+ !isSameActivityPubActor(originalActivity.actorApId, actor)
364
+ ) {
380
365
  log.warn("Undo actor mismatch", {
381
366
  event: "ap.undo.actor_mismatch",
382
367
  actor,
@@ -399,12 +384,10 @@ async function resolveUndoByActivityId(
399
384
  }
400
385
  const follow = await findFollowByActivityId(db, originalActivity.apId);
401
386
  if (follow) {
402
- await undoFollowEdge(
403
- db,
404
- follow.followerApId,
405
- follow.followingApId,
406
- recipient.apId,
407
- );
387
+ if (!authorizesFollowUndo(follow, actor, recipient.apId, objectId)) {
388
+ return true;
389
+ }
390
+ await undoFollowEdge(db, follow.followerApId, follow.followingApId);
408
391
  }
409
392
  return true;
410
393
  }
@@ -457,29 +440,8 @@ async function undoFollowEdge(
457
440
  db: Database,
458
441
  followerApId: string,
459
442
  followingApId: string,
460
- recipientApId: string,
461
443
  ): Promise<void> {
462
- const acceptedEdgeExists = sql`EXISTS (SELECT 1 FROM ${follows} WHERE ${follows.followerApId} = ${followerApId} AND ${follows.followingApId} = ${followingApId} AND ${follows.status} = 'accepted')`;
463
- await runBatch(db, [
464
- db
465
- .update(actors)
466
- .set({ followerCount: sql`${actors.followerCount} - 1` })
467
- .where(
468
- and(
469
- eq(actors.apId, recipientApId),
470
- gt(actors.followerCount, 0),
471
- acceptedEdgeExists,
472
- ),
473
- ),
474
- db
475
- .delete(follows)
476
- .where(
477
- and(
478
- eq(follows.followerApId, followerApId),
479
- eq(follows.followingApId, followingApId),
480
- ),
481
- ),
482
- ]);
444
+ await severFollowDirection(db, followerApId, followingApId);
483
445
  }
484
446
 
485
447
  async function undoFollow(
@@ -487,8 +449,14 @@ async function undoFollow(
487
449
  objectId: string | null,
488
450
  actor: string,
489
451
  recipient: ActorRow,
452
+ baseUrl: string,
490
453
  ): Promise<void> {
491
- const follow = objectId ? await findFollowByActivityId(db, objectId) : null;
454
+ const follow = objectId
455
+ ? await findFollowByActivityId(db, objectId, {
456
+ actorApId: actor,
457
+ localBaseUrl: baseUrl,
458
+ })
459
+ : null;
492
460
 
493
461
  // Bind the undo to the VERIFIED signer: an Undo(Follow) may only remove the
494
462
  // SIGNER's own follow edge. The activity id is public (it appears in the
@@ -496,13 +464,10 @@ async function undoFollow(
496
464
  // cross-actor forgery — an attacker severing a victim's follow + decrementing
497
465
  // their followerCount. The bare-string Undo path already enforces this via
498
466
  // resolveUndoByActivityId; the typed-object path must too.
499
- if (follow && follow.followerApId !== actor) {
500
- log.warn("Undo(Follow) actor mismatch", {
501
- event: "ap.undo.follow.actor_mismatch",
502
- actor,
503
- followOwner: follow.followerApId,
504
- activityId: objectId,
505
- });
467
+ if (
468
+ follow &&
469
+ !authorizesFollowUndo(follow, actor, recipient.apId, objectId)
470
+ ) {
506
471
  return;
507
472
  }
508
473
 
@@ -514,7 +479,7 @@ async function undoFollow(
514
479
  // of permanently over-counting.
515
480
  const followerApId = follow ? follow.followerApId : actor;
516
481
  const followingApId = follow ? follow.followingApId : recipient.apId;
517
- await undoFollowEdge(db, followerApId, followingApId, recipient.apId);
482
+ await undoFollowEdge(db, followerApId, followingApId);
518
483
  }
519
484
 
520
485
  /**