@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,8 +1,9 @@
1
- import { and, eq, gt, sql } from "drizzle-orm";
1
+ import { and, eq, inArray, or, sql } from "drizzle-orm";
2
2
  import type { BatchItem } from "drizzle-orm/batch";
3
3
  import {
4
4
  actors,
5
5
  announces,
6
+ communities,
6
7
  follows,
7
8
  likes,
8
9
  objects,
@@ -19,15 +20,25 @@ import {
19
20
  type ActivityContext,
20
21
  getActivityObjectId,
21
22
  } from "../inbox-types.ts";
22
- import { notifyLocalObjectOwner } from "./inbox-shared-helpers.ts";
23
+ import {
24
+ findFollowByFollowingIdentity,
25
+ notifyLocalObjectOwner,
26
+ } from "./inbox-shared-helpers.ts";
23
27
  import { fetchAndPersistAnnouncedNote } from "./inbox-content-handlers.ts";
24
28
  import { isLocal } from "../../../lib/ap-ids.ts";
25
29
  import { notDeleted } from "../../../../db/index.ts";
26
30
  import {
27
- actorIsBlockedBy,
31
+ actorSuppressesInteractionFrom,
28
32
  canViewerReadObjectFull,
29
33
  } from "../../../lib/post-visibility.ts";
30
34
  import { logger } from "../../../lib/logger.ts";
35
+ import { isSameActivityPubActor } from "../../../lib/activitypub-actor-identity.ts";
36
+ import { MAX_ACTIVITY_OBJECT_IDS } from "../../../lib/activitypub-validators.ts";
37
+ import {
38
+ acceptRetainedFollowDirection,
39
+ severFollowDirection,
40
+ severFollowPair,
41
+ } from "../../../lib/follow-edge-mutations.ts";
31
42
 
32
43
  type ActorRow = typeof actors.$inferSelect;
33
44
 
@@ -91,15 +102,16 @@ async function handleInteraction(
91
102
  const objectId = getActivityObjectId(activity);
92
103
  if (!objectId) return;
93
104
 
94
- // Block + read gate for a LOCAL target. Every LOCAL interaction path (like /
95
- // repost / reply / story like-vote-share) refuses an actor the owner has
96
- // blocked or who cannot read the object; the inbound federated Like/Announce
97
- // path did neither, so a personally-blocked remote could still bump the
98
- // owner's like/boost counter AND deliver a "X liked your post" notification
99
- // (defeating the block as a harassment remedy), and a remote that cannot read a
100
- // restricted post could still interact with it. Mirror the local guards.
105
+ // Apply the canonical read gate to EVERY object retained by this instance,
106
+ // including a remote-authored object delivered to a local recipient. Limiting
107
+ // this check to locally-authored targets let an unrelated signer Like/Announce
108
+ // a cached remote DM or followers-only Note, mutating its edge/counter state
109
+ // despite having no read access. Personal blocks/mutes remain local-owner
110
+ // state, so that extra write-suppression guard only applies when the retained
111
+ // object's author is local.
101
112
  const target = await db
102
113
  .select({
114
+ apId: objects.apId,
103
115
  attributedTo: objects.attributedTo,
104
116
  visibility: objects.visibility,
105
117
  toJson: objects.toJson,
@@ -110,12 +122,26 @@ async function handleInteraction(
110
122
  endTime: objects.endTime,
111
123
  })
112
124
  .from(objects)
113
- .where(eq(objects.apId, objectId))
125
+ .where(and(eq(objects.apId, objectId), notDeleted(objects)))
114
126
  .get();
115
- if (target && isLocal(target.attributedTo, baseUrl)) {
116
- if (await actorIsBlockedBy(db, target.attributedTo, actor)) return;
117
- if (!(await canViewerReadObjectFull(db, target, actor))) return;
127
+ // Never retain a relationship to an object this instance does not currently
128
+ // retain as live. With no D1 FK (remote identities/relationships are
129
+ // intentionally app-managed), the old path inserted one permanent edge for
130
+ // every attacker-chosen object id even though the counter update matched no
131
+ // row. A signer could therefore grow likes/announces without bound, and a
132
+ // soft-deleted object could accumulate fresh interactions. An unknown
133
+ // Announce from a followed actor still gets its one bounded, validated fetch
134
+ // in handleAnnounce BEFORE reaching this guard; only a successfully persisted
135
+ // public/unlisted Note proceeds.
136
+ if (!target) return;
137
+
138
+ if (
139
+ isLocal(target.attributedTo, baseUrl) &&
140
+ (await actorSuppressesInteractionFrom(db, target.attributedTo, actor))
141
+ ) {
142
+ return;
118
143
  }
144
+ if (!(await canViewerReadObjectFull(db, target, actor))) return;
119
145
 
120
146
  const { table, countField, activityType } = INTERACTION_CONFIG[kind];
121
147
  const activityId = activity.id || activityApId(baseUrl, generateId());
@@ -232,10 +258,9 @@ export async function handleAnnounce(
232
258
  // still surfaces in feeds (previously the Announce only left a dangling
233
259
  // announce edge + a counter no-op). Strictly gated: the target must be
234
260
  // remote and unknown, and the booster must have at least one local follower
235
- // (no open-relay amplification). On any fetch/validation failure the
236
- // Announce degrades to exactly the old behavior handleInteraction records
237
- // the edge idempotently and the counter recompute no-ops on the absent row,
238
- // so a later Create of the same object retroactively completes the boost.
261
+ // (no open-relay amplification). On any fetch/validation failure,
262
+ // handleInteraction drops the Announce because there is no live retained
263
+ // target; it must not create a dangling edge for an attacker-chosen id.
239
264
  const db = c.get("db");
240
265
  const objectId = getActivityObjectId(activity);
241
266
  if (objectId && !isLocal(objectId, baseUrl)) {
@@ -277,6 +302,18 @@ export async function handleAdd(
277
302
 
278
303
  const db = c.get("db");
279
304
  const now = new Date().toISOString();
305
+ const retainedEdge = await findFollowByFollowingIdentity(
306
+ db,
307
+ recipient.apId,
308
+ followingApId,
309
+ );
310
+ if (
311
+ !retainedEdge ||
312
+ (retainedEdge.status !== "pending" && retainedEdge.status !== "accepted")
313
+ ) {
314
+ return;
315
+ }
316
+ const retainedFollowingApId = retainedEdge.followingApId;
280
317
 
281
318
  // SECURITY (consent — federated follow-graph forgery): an `Add <local user>
282
319
  // to <remote>/followers` is the remote CONFIRMING the local user's OWN Follow
@@ -289,32 +326,16 @@ export async function handleAdd(
289
326
  // inflating the victim's followingCount and routing the sender's posts into
290
327
  // the victim's home feed — all without the victim ever following anyone.
291
328
  //
292
- // #COUNTER-SYM: like handleAccept, the two +1s run BEFORE the flip, each
293
- // guarded by a correlated `EXISTS(... status='pending')` subquery, and the
294
- // flip's own `status='pending'` predicate makes a duplicate/already-accepted
295
- // (or absent) edge a total no-op so counters can neither double-bump,
296
- // under-count on retry, nor bump for an edge that was never pending.
297
- const pendingEdgeExists = sql`EXISTS (SELECT 1 FROM ${follows} WHERE ${follows.followerApId} = ${recipient.apId} AND ${follows.followingApId} = ${followingApId} AND ${follows.status} = 'pending')`;
298
- await runBatch(db, [
299
- db
300
- .update(actors)
301
- .set({ followingCount: sql`${actors.followingCount} + 1` })
302
- .where(and(eq(actors.apId, recipient.apId), pendingEdgeExists)),
303
- db
304
- .update(actors)
305
- .set({ followerCount: sql`${actors.followerCount} + 1` })
306
- .where(and(eq(actors.apId, followingApId), pendingEdgeExists)),
307
- db
308
- .update(follows)
309
- .set({ status: "accepted", acceptedAt: now })
310
- .where(
311
- and(
312
- eq(follows.followerApId, recipient.apId),
313
- eq(follows.followingApId, followingApId),
314
- eq(follows.status, "pending"),
315
- ),
316
- ),
317
- ]);
329
+ // Like Accept, collapse cosmetic duplicates and derive the counter delta from
330
+ // the retained accepted-row set. This preserves the consent requirement (an
331
+ // existing pending/accepted edge is mandatory) while making retries and
332
+ // legacy duplicate rows converge to one logical relation.
333
+ await acceptRetainedFollowDirection(
334
+ db,
335
+ recipient.apId,
336
+ retainedFollowingApId,
337
+ now,
338
+ );
318
339
  }
319
340
 
320
341
  // ---------------------------------------------------------------------------
@@ -331,49 +352,19 @@ export async function handleRemove(
331
352
  if (!followingApId) return;
332
353
 
333
354
  const db = c.get("db");
334
-
335
- // #COUNTER-SYM (crash-retry convergence): the edge delete and both -1s MUST
336
- // commit together. Previously the delete committed first and the decrements
337
- // were SEPARATE statements; a crash between them left the edge gone but the
338
- // counts un-decremented, and the peer's retry matched 0 rows so the decrements
339
- // were SKIPPED → a permanent OVER-count. Co-commit them in one atomic batch.
340
- //
341
- // The two decrements run BEFORE the delete and are each guarded by a
342
- // correlated `EXISTS(... status='accepted')` subquery (so a pending /
343
- // never-counted edge, a duplicate Remove, or an unknown edge does not drift
344
- // the counts) plus a `count > 0` underflow guard (mirrors the local API delete
345
- // paths in posts/interactions.ts which batch + guard both sides).
346
- const acceptedEdgeExists = sql`EXISTS (SELECT 1 FROM ${follows} WHERE ${follows.followerApId} = ${recipient.apId} AND ${follows.followingApId} = ${followingApId} AND ${follows.status} = 'accepted')`;
347
- await runBatch(db, [
348
- db
349
- .update(actors)
350
- .set({ followingCount: sql`${actors.followingCount} - 1` })
351
- .where(
352
- and(
353
- eq(actors.apId, recipient.apId),
354
- gt(actors.followingCount, 0),
355
- acceptedEdgeExists,
356
- ),
357
- ),
358
- db
359
- .update(actors)
360
- .set({ followerCount: sql`${actors.followerCount} - 1` })
361
- .where(
362
- and(
363
- eq(actors.apId, followingApId),
364
- gt(actors.followerCount, 0),
365
- acceptedEdgeExists,
366
- ),
367
- ),
368
- db
369
- .delete(follows)
370
- .where(
371
- and(
372
- eq(follows.followerApId, recipient.apId),
373
- eq(follows.followingApId, followingApId),
374
- ),
375
- ),
376
- ]);
355
+ const retainedEdge = await findFollowByFollowingIdentity(
356
+ db,
357
+ recipient.apId,
358
+ followingApId,
359
+ );
360
+ if (!retainedEdge) return;
361
+ const retainedFollowingApId = retainedEdge.followingApId;
362
+
363
+ // Remove every retained spelling of this logical edge. The shared mutation
364
+ // co-commits deletion with count reconciliation and remains bounded to the
365
+ // exact local follower, so a cosmetic duplicate cannot keep feed-delivery
366
+ // authority after the remote collection removes the member.
367
+ await severFollowDirection(db, recipient.apId, retainedFollowingApId);
377
368
  }
378
369
 
379
370
  // ---------------------------------------------------------------------------
@@ -393,61 +384,9 @@ export async function handleBlock(
393
384
  // Only act when the recipient is being blocked.
394
385
  if (blockedId !== recipient.apId) return;
395
386
 
396
- // Best-effort: sever follow relations in both directions. #COUNTER-SYM
397
- // (crash-retry convergence): handle each direction as its own atomic
398
- // edge-delete + counter reconcile so a crash between the delete and the
399
- // decrements cannot leave a counter permanently over-counted (the peer's
400
- // retry would otherwise match 0 rows and skip the decrement).
401
- await severFollowEdge(db, recipient.apId, actor); // recipient follows actor
402
- await severFollowEdge(db, actor, recipient.apId); // actor follows recipient
403
- }
404
-
405
- /**
406
- * Atomically delete a (followerApId -> followingApId) follow edge and reconcile
407
- * both denormalized counters in a single batch.
408
- *
409
- * #COUNTER-SYM: the decrements run BEFORE the delete, each guarded by a
410
- * correlated `EXISTS(... status='accepted')` subquery (pending edges were never
411
- * counted) plus a `count > 0` underflow guard, so a pending edge, a duplicate
412
- * Block, a never-existing edge, or a crash-then-retry is a clean no-op rather
413
- * than permanently over-counting.
414
- */
415
- export async function severFollowEdge(
416
- db: Database,
417
- followerApId: string,
418
- followingApId: string,
419
- ): Promise<void> {
420
- const acceptedEdgeExists = sql`EXISTS (SELECT 1 FROM ${follows} WHERE ${follows.followerApId} = ${followerApId} AND ${follows.followingApId} = ${followingApId} AND ${follows.status} = 'accepted')`;
421
- await runBatch(db, [
422
- db
423
- .update(actors)
424
- .set({ followingCount: sql`${actors.followingCount} - 1` })
425
- .where(
426
- and(
427
- eq(actors.apId, followerApId),
428
- gt(actors.followingCount, 0),
429
- acceptedEdgeExists,
430
- ),
431
- ),
432
- db
433
- .update(actors)
434
- .set({ followerCount: sql`${actors.followerCount} - 1` })
435
- .where(
436
- and(
437
- eq(actors.apId, followingApId),
438
- gt(actors.followerCount, 0),
439
- acceptedEdgeExists,
440
- ),
441
- ),
442
- db
443
- .delete(follows)
444
- .where(
445
- and(
446
- eq(follows.followerApId, followerApId),
447
- eq(follows.followingApId, followingApId),
448
- ),
449
- ),
450
- ]);
387
+ // Commit both directions together. The inbox dispatch retries failures, but
388
+ // there must be no interval where only one direction has been severed.
389
+ await severFollowPair(db, recipient.apId, actor);
451
390
  }
452
391
 
453
392
  // ---------------------------------------------------------------------------
@@ -461,17 +400,25 @@ export async function handleFlag(
461
400
  ) {
462
401
  const objectId = getActivityObjectId(activity);
463
402
  const targetId = getActivityTargetId(activity);
464
- // Flag activities carry a free-text reason in `content` (not part of the
465
- // narrowed Activity type, so read it defensively).
466
- const rawContent = (activity as { content?: unknown }).content;
403
+ // Flag activities carry a free-text reason at envelope-level `content`.
404
+ const rawContent = activity.content;
467
405
  // Cap the inbound reason length at ingest. The Flag `content` is fully
468
406
  // attacker-controlled free text, so without a bound the reports table grows
469
407
  // unbounded under report spam. 2000 chars is ample for a moderation reason.
470
408
  const content =
471
409
  typeof rawContent === "string" ? rawContent.slice(0, 2000) : null;
472
410
 
473
- // The report target is the flagged object (preferred) or the activity target.
474
- const reportTarget = objectId ?? targetId ?? null;
411
+ // Standard AS2 / Mastodon Flag uses an object ARRAY (usually the reported
412
+ // post followed by its actor). The parser projects those references into a
413
+ // bounded list; retain the scalar fallback for older callers/fixtures and the
414
+ // target fallback for peers that use it instead of object.
415
+ const reportedIds = [
416
+ ...(activity.objectIds ?? []),
417
+ ...(objectId ? [objectId] : []),
418
+ ...(targetId ? [targetId] : []),
419
+ ]
420
+ .filter((value, index, all) => all.indexOf(value) === index)
421
+ .slice(0, MAX_ACTIVITY_OBJECT_IDS);
475
422
 
476
423
  let instance: string | null = null;
477
424
  try {
@@ -480,18 +427,90 @@ export async function handleFlag(
480
427
  instance = null;
481
428
  }
482
429
 
483
- // Persist the report so operators can triage it via the moderation API.
484
- // Best-effort: never let a storage error 5xx the inbox (which would make
485
- // the sender retry on a backoff). Log the failure for the operator.
430
+ // Persist only a target this instance can actually moderate:
431
+ // - an object authored by a local actor;
432
+ // - an object scoped to a live local community; or
433
+ // - a local actor/community itself.
434
+ //
435
+ // Before this authority check, every signed Flag — including one containing
436
+ // no usable object at all — appended a permanent report row for an arbitrary
437
+ // attacker-chosen id. Besides filling the queue with unactionable noise, that
438
+ // made the append-only table a storage-amplification surface. Each IN list is
439
+ // capped by MAX_ACTIVITY_OBJECT_IDS, below D1's 100-bind ceiling.
440
+ //
441
+ // A storage failure MUST escape to the inbox route. The shared dispatch
442
+ // contract releases its fenced claim and returns retryable 503, leaving the
443
+ // activity ledger uncommitted so the peer can complete the report later.
444
+ // Swallowing this error would ACK 202 and mark the activity processed even
445
+ // though no moderation row exists, permanently losing a valid abuse report.
486
446
  try {
487
447
  const db = c.get("db");
488
- await db.insert(reports).values({
489
- id: generateId(),
490
- reporterApId: actor,
491
- targetApId: reportTarget,
492
- content,
493
- instance,
494
- });
448
+ if (reportedIds.length === 0) {
449
+ log.info("Dropped inbound Flag with no reportable target", {
450
+ event: "ap.flag.no_target",
451
+ actor,
452
+ });
453
+ return;
454
+ }
455
+
456
+ const objectTargets = await db
457
+ .select({ apId: objects.apId })
458
+ .from(objects)
459
+ .where(
460
+ and(
461
+ inArray(objects.apId, reportedIds),
462
+ or(
463
+ sql`EXISTS (SELECT 1 FROM ${actors} WHERE ${actors.apId} = ${objects.attributedTo})`,
464
+ sql`EXISTS (
465
+ SELECT 1 FROM ${communities}
466
+ WHERE ${communities.apId} = ${objects.communityApId}
467
+ AND ${communities.deletedAt} IS NULL
468
+ )`,
469
+ ),
470
+ ),
471
+ );
472
+ const actorTargets = await db
473
+ .select({ apId: actors.apId })
474
+ .from(actors)
475
+ .where(inArray(actors.apId, reportedIds));
476
+ const communityTargets = await db
477
+ .select({ apId: communities.apId })
478
+ .from(communities)
479
+ .where(
480
+ and(inArray(communities.apId, reportedIds), notDeleted(communities)),
481
+ );
482
+
483
+ // Prefer a specific actionable object over its actor when both are present
484
+ // in a standard `[post, actor]` Flag array.
485
+ const actionableObjects = new Set(objectTargets.map((row) => row.apId));
486
+ const actionableEntities = new Set([
487
+ ...actorTargets.map((row) => row.apId),
488
+ ...communityTargets.map((row) => row.apId),
489
+ ]);
490
+ const reportTarget =
491
+ reportedIds.find((id) => actionableObjects.has(id)) ??
492
+ reportedIds.find((id) => actionableEntities.has(id));
493
+ if (!reportTarget) {
494
+ log.info("Dropped inbound Flag outside local moderation authority", {
495
+ event: "ap.flag.unowned_target",
496
+ actor,
497
+ });
498
+ return;
499
+ }
500
+
501
+ await db
502
+ .insert(reports)
503
+ .values({
504
+ // verifyAndParseInbox stamps an origin-bound internal activity id.
505
+ // Reusing it makes a dispatch retry idempotent instead of appending a
506
+ // second random report after a commit/lease failure.
507
+ id: activity.id || generateId(),
508
+ reporterApId: actor,
509
+ targetApId: reportTarget,
510
+ content,
511
+ instance,
512
+ })
513
+ .onConflictDoNothing();
495
514
  } catch (err) {
496
515
  log.warn("Failed to persist Flag report", {
497
516
  event: "ap.flag.persist_failed",
@@ -500,6 +519,7 @@ export async function handleFlag(
500
519
  target: targetId,
501
520
  error: err,
502
521
  });
522
+ throw err;
503
523
  }
504
524
 
505
525
  log.warn("Flag received", {
@@ -546,16 +566,13 @@ function resolveCollectionTarget(
546
566
  const followingApId = normalizeCollectionTarget(targetId || actor) || null;
547
567
  if (!followingApId) return null;
548
568
 
549
- // SECURITY (federated follow-graph forgery): `activity.target` is
550
- // attacker-controlled and only the signing actor is authenticated, NOT the
551
- // target. Without this check a signed Add/Remove could forge or delete a local
552
- // user's follow edge to an ARBITRARY third party (followingApId on any host).
553
- // Constrain the resolved target to the signing actor's own origin, so an
554
- // Add/Remove can only affect a relationship involving the sending actor.
555
- try {
556
- if (getDomain(followingApId) !== getDomain(actor)) return null;
557
- } catch {
558
- return null;
559
- }
569
+ // SECURITY (same-host actor authority): `activity.target` is
570
+ // attacker-controlled and only the exact signing actor is authenticated.
571
+ // Same-origin is insufficient: one account on a multi-user remote host must
572
+ // not Accept or Remove a local user's follow edge to a sibling account. Once
573
+ // an optional `/followers` collection suffix is normalized, the target actor
574
+ // id must therefore identify the verified signer under the same narrow
575
+ // cosmetic equivalence used at the HTTP-signature boundary.
576
+ if (!isSameActivityPubActor(followingApId, actor)) return null;
560
577
  return followingApId;
561
578
  }