@takosjp/yurucommu-core 3.4.0 → 3.4.3

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 (39) hide show
  1. package/migrations/0022_inbound_dispatch_claims.sql +17 -0
  2. package/package.json +3 -2
  3. package/packages/api/package.json +1 -1
  4. package/packages/api/src/lib/api/notifications.ts +1 -0
  5. package/packages/api/src/lib/api/posts.ts +1 -0
  6. package/src/backend/index.ts +62 -12
  7. package/src/backend/lib/delivery/queue-batching.ts +53 -36
  8. package/src/backend/lib/delivery/queue-delivery.ts +3 -3
  9. package/src/backend/lib/delivery/queue.ts +13 -9
  10. package/src/backend/lib/delivery/types.ts +12 -0
  11. package/src/backend/lib/notification-push.ts +2 -2
  12. package/src/backend/lib/oauth-providers.ts +9 -0
  13. package/src/backend/lib/strip-image-metadata.ts +50 -30
  14. package/src/backend/middleware/bearer-auth.ts +24 -9
  15. package/src/backend/public.ts +38 -1
  16. package/src/backend/retention.ts +78 -0
  17. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +4 -3
  18. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +204 -5
  19. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +78 -53
  20. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +66 -2
  21. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +35 -12
  22. package/src/backend/routes/activitypub/inbox-addressing.ts +236 -0
  23. package/src/backend/routes/activitypub/inbox-types.ts +8 -0
  24. package/src/backend/routes/activitypub/inbox.ts +410 -205
  25. package/src/backend/routes/activitypub/outbox.ts +0 -0
  26. package/src/backend/routes/actors.ts +5 -5
  27. package/src/backend/routes/auth.ts +2 -1
  28. package/src/backend/routes/posts/post-helpers.ts +42 -23
  29. package/src/backend/routes/stories/routes.ts +5 -7
  30. package/src/backend/runtime/cloudflare.ts +63 -2
  31. package/src/backend/runtime/managed-relational.ts +197 -0
  32. package/src/backend/runtime/managed-runtime.ts +631 -0
  33. package/src/backend/runtime/queue.ts +40 -0
  34. package/src/backend/server.ts +15 -18
  35. package/src/backend/types.ts +9 -2
  36. package/src/db/d1-write.ts +270 -0
  37. package/src/db/index.ts +17 -0
  38. package/src/db/schema/federation.ts +19 -0
  39. package/src/db/schema/index.ts +1 -0
@@ -1,12 +1,6 @@
1
1
  import type { Database } from "../../../../db/index.ts";
2
- import { and, eq, gt, inArray, sql } from "drizzle-orm";
3
- import {
4
- activities,
5
- actors,
6
- follows,
7
- likes,
8
- objects,
9
- } from "../../../../db/index.ts";
2
+ import { and, eq, gt, or, sql } from "drizzle-orm";
3
+ import { activities, actors, follows } from "../../../../db/index.ts";
10
4
  import {
11
5
  activityApId,
12
6
  generateId,
@@ -43,6 +37,7 @@ export async function handleFollow(
43
37
  recipient: ActorRow,
44
38
  actor: string,
45
39
  baseUrl: string,
40
+ sourceActivityId: string = activity.id || "",
46
41
  ) {
47
42
  const db = c.get("db");
48
43
 
@@ -152,7 +147,9 @@ export async function handleFollow(
152
147
  id: acceptId,
153
148
  type: "Accept",
154
149
  actor: recipient.apId,
155
- object: activityId,
150
+ // Echo the peer's bounded protocol id, not our origin-bound internal
151
+ // ledger id. Remote servers match Accept.object to their Follow id.
152
+ object: sourceActivityId || activityId,
156
153
  };
157
154
 
158
155
  // Store accept activity before enqueue.
@@ -160,7 +157,7 @@ export async function handleFollow(
160
157
  apId: acceptId,
161
158
  type: "Accept",
162
159
  actorApId: recipient.apId,
163
- objectApId: activityId,
160
+ objectApId: sourceActivityId || activityId,
164
161
  rawJson: JSON.stringify(acceptActivity),
165
162
  direction: "outbound",
166
163
  });
@@ -294,10 +291,20 @@ export async function handleReject(
294
291
  // Undo handler
295
292
  // ---------------------------------------------------------------------------
296
293
 
294
+ /**
295
+ * `recipient` is null when the Undo arrived at the shared inbox as an
296
+ * INSTANCE-scoped activity — i.e. Undo(Like|Announce), which is actor-keyed
297
+ * and idempotent and names no local actor. Undo(Follow|Block) is object-actor
298
+ * scoped and always dispatched with its resolved target, so a null recipient
299
+ * on a follow branch means the target could not be resolved; that is logged
300
+ * and refused rather than guessed at. The previous code guessed: it fell back
301
+ * to deleting the signer's likes across EVERY object the "recipient" had
302
+ * authored and rewriting every one of that actor's `objects` rows.
303
+ */
297
304
  export async function handleUndo(
298
305
  c: ActivityContext,
299
306
  activity: Activity,
300
- recipient: ActorRow,
307
+ recipient: ActorRow | null,
301
308
  actor: string,
302
309
  _baseUrl: string,
303
310
  ) {
@@ -318,9 +325,17 @@ export async function handleUndo(
318
325
  }
319
326
 
320
327
  if (typeIncludes(objectType, "Follow")) {
328
+ if (!recipient) {
329
+ log.warn("Undo(Follow) without a resolved local target", {
330
+ event: "ap.undo.follow.no_recipient",
331
+ actor,
332
+ activityId: activity.id,
333
+ });
334
+ return;
335
+ }
321
336
  await undoFollow(db, objectId, actor, recipient);
322
337
  } else if (typeIncludes(objectType, "Like")) {
323
- await undoLike(db, objectId, activityObject, actor, recipient);
338
+ await undoLike(db, objectId, activityObject, actor);
324
339
  } else if (typeIncludes(objectType, "Announce")) {
325
340
  await undoAnnounce(db, objectId, activityObject, actor);
326
341
  }
@@ -339,16 +354,25 @@ async function resolveUndoByActivityId(
339
354
  db: Database,
340
355
  objectId: string,
341
356
  actor: string,
342
- recipient: ActorRow,
357
+ recipient: ActorRow | null,
343
358
  ): Promise<boolean> {
344
359
  const originalActivity = await db
345
360
  .select({
361
+ apId: activities.apId,
346
362
  type: activities.type,
347
363
  objectApId: activities.objectApId,
348
364
  actorApId: activities.actorApId,
349
365
  })
350
366
  .from(activities)
351
- .where(eq(activities.apId, objectId))
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
+ )
352
376
  .get();
353
377
  if (!originalActivity) return false;
354
378
 
@@ -363,7 +387,17 @@ async function resolveUndoByActivityId(
363
387
  }
364
388
 
365
389
  if (originalActivity.type === "Follow") {
366
- const follow = await findFollowByActivityId(db, objectId);
390
+ // The followerCount decrement is keyed on the followed actor, so an
391
+ // instance-dispatched Undo (no resolved local target) must not run it.
392
+ if (!recipient) {
393
+ log.warn("Undo(Follow) by activity id without a resolved local target", {
394
+ event: "ap.undo.follow.no_recipient",
395
+ actor,
396
+ activityId: objectId,
397
+ });
398
+ return true;
399
+ }
400
+ const follow = await findFollowByActivityId(db, originalActivity.apId);
367
401
  if (follow) {
368
402
  await undoFollowEdge(
369
403
  db,
@@ -391,7 +425,14 @@ async function resolveUndoByActivityId(
391
425
  // A crash-then-retry converges (the recompute is idempotent against the true
392
426
  // edge set) instead of permanently over-counting on the retry's no-op
393
427
  // delete. The actor-mismatch guard above still constrains who may undo.
394
- await undoInteraction(db, kind, countField, undefined, objectId, actor);
428
+ await undoInteraction(
429
+ db,
430
+ kind,
431
+ countField,
432
+ undefined,
433
+ originalActivity.apId,
434
+ actor,
435
+ );
395
436
  return true;
396
437
  }
397
438
 
@@ -476,12 +517,26 @@ async function undoFollow(
476
517
  await undoFollowEdge(db, followerApId, followingApId, recipient.apId);
477
518
  }
478
519
 
520
+ /**
521
+ * Undo(Like).
522
+ *
523
+ * If the like edge cannot be identified — from the inner object's `object`, or
524
+ * from the referenced Like activity id — there is NOTHING to undo and we say
525
+ * so. There used to be a "last resort" here that deleted every like the signer
526
+ * held on ANY object authored by the delivery recipient and then recomputed
527
+ * `likeCount` for EVERY one of that actor's `objects` rows. It was reachable
528
+ * from a malformed `{type:'Undo', object:{type:'Like', id:...}}` with no inner
529
+ * `object` (and from the actor-mismatch branch), and under the shared inbox it
530
+ * ran once per local follower, so a single non-conformant peer could both wipe
531
+ * a user's likes across an author's whole timeline and amplify a full-table
532
+ * UPDATE by the follower count. Nothing about the activity authorised that
533
+ * scope: the only thing it identified was the signer.
534
+ */
479
535
  async function undoLike(
480
536
  db: Database,
481
537
  objectId: string | null,
482
538
  activityObject: ReturnType<typeof getActivityObject>,
483
539
  actor: string,
484
- recipient: ActorRow,
485
540
  ): Promise<void> {
486
541
  const handled = await undoInteraction(
487
542
  db,
@@ -493,43 +548,13 @@ async function undoLike(
493
548
  );
494
549
  if (handled) return;
495
550
 
496
- // Last resort: delete any like from this actor for the recipient's objects.
497
- // #COUNTER-SYM: co-commit the edge delete and a COUNT(*) recompute of likeCount
498
- // in one atomic batch (mirrors the primary undoInteraction path). Previously
499
- // the delete committed first and the count was decremented (`- 1`) in a
500
- // SEPARATE statement; a crash between them, then a peer retry whose delete
501
- // matches 0 rows, permanently SKIPPED the decrement → a like OVER-count.
502
- // Deriving each affected object's likeCount from COUNT(*) of the remaining like
503
- // rows makes the fallback idempotent so a crash-then-retry CONVERGES instead of
504
- // drifting.
505
- // The recipient (local user) can have authored thousands of objects; splicing
506
- // every id into an `IN (...)` would blow D1's 100-bound-parameter ceiling.
507
- // Express the affected set as a SUBQUERY (delete) and a direct predicate
508
- // (update) so no per-object parameter is bound. An empty set is a harmless
509
- // no-op batch, so the previous early-return is unnecessary.
510
- const recipientObjectsSubquery = db
511
- .select({ apId: objects.apId })
512
- .from(objects)
513
- .where(eq(objects.attributedTo, recipient.apId));
514
-
515
- await runBatch(db, [
516
- db
517
- .delete(likes)
518
- .where(
519
- and(
520
- eq(likes.actorApId, actor),
521
- inArray(likes.objectApId, recipientObjectsSubquery),
522
- ),
523
- ),
524
- db
525
- .update(objects)
526
- .set({
527
- likeCount: sql`(SELECT COUNT(*) FROM ${likes} WHERE ${likes.objectApId} = ${objects.apId})`,
528
- })
529
- .where(eq(objects.attributedTo, recipient.apId)),
530
- ]);
551
+ log.warn("Undo(Like) names no resolvable like edge; ignoring", {
552
+ event: "ap.undo.like.unresolved",
553
+ actor,
554
+ activityId: objectId,
555
+ object: activityObject?.object,
556
+ });
531
557
  }
532
-
533
558
  async function undoAnnounce(
534
559
  db: Database,
535
560
  objectId: string | null,
@@ -20,7 +20,9 @@ import {
20
20
  getActivityObjectId,
21
21
  } from "../inbox-types.ts";
22
22
  import { notifyLocalObjectOwner } from "./inbox-shared-helpers.ts";
23
+ import { fetchAndPersistAnnouncedNote } from "./inbox-content-handlers.ts";
23
24
  import { isLocal } from "../../../lib/ap-ids.ts";
25
+ import { notDeleted } from "../../../../db/index.ts";
24
26
  import {
25
27
  actorIsBlockedBy,
26
28
  canViewerReadObjectFull,
@@ -174,10 +176,15 @@ async function handleInteraction(
174
176
  // Like handler
175
177
  // ---------------------------------------------------------------------------
176
178
 
179
+ /**
180
+ * A Like is INSTANCE-scoped: the affected object (and the local owner to
181
+ * notify) is resolved from `activity.object`, never from a delivery recipient.
182
+ * The recipient argument was already ignored; removing it is what stops the
183
+ * shared inbox from being able to run this once per local follower.
184
+ */
177
185
  export async function handleLike(
178
186
  c: ActivityContext,
179
187
  activity: Activity,
180
- _recipient: ActorRow,
181
188
  actor: string,
182
189
  baseUrl: string,
183
190
  ) {
@@ -188,13 +195,70 @@ export async function handleLike(
188
195
  // Announce handler (repost/boost)
189
196
  // ---------------------------------------------------------------------------
190
197
 
198
+ /**
199
+ * Does at least one (non-tombstoned) LOCAL actor have an accepted follow of
200
+ * `actorApId`? The fetch-and-store path below is gated on this so an arbitrary
201
+ * remote cannot use our inbox as an open relay: only a boost from someone a
202
+ * local user chose to follow may trigger an outbound object fetch.
203
+ */
204
+ async function actorHasLocalFollowers(
205
+ db: Database,
206
+ actorApId: string,
207
+ ): Promise<boolean> {
208
+ const row = await db
209
+ .select({ followerApId: follows.followerApId })
210
+ .from(follows)
211
+ .innerJoin(
212
+ actors,
213
+ and(eq(actors.apId, follows.followerApId), notDeleted(actors)),
214
+ )
215
+ .where(
216
+ and(eq(follows.followingApId, actorApId), eq(follows.status, "accepted")),
217
+ )
218
+ .limit(1)
219
+ .get();
220
+ return Boolean(row);
221
+ }
222
+
223
+ /** Instance-scoped, exactly like {@link handleLike}. */
191
224
  export async function handleAnnounce(
192
225
  c: ActivityContext,
193
226
  activity: Activity,
194
- _recipient: ActorRow,
195
227
  actor: string,
196
228
  baseUrl: string,
197
229
  ) {
230
+ // Fetch-and-store an UNKNOWN boosted remote object before recording the
231
+ // announce, so a followed remote's boost of a post this instance never saw
232
+ // still surfaces in feeds (previously the Announce only left a dangling
233
+ // announce edge + a counter no-op). Strictly gated: the target must be
234
+ // 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.
239
+ const db = c.get("db");
240
+ const objectId = getActivityObjectId(activity);
241
+ if (objectId && !isLocal(objectId, baseUrl)) {
242
+ const known = await db
243
+ .select({ apId: objects.apId })
244
+ .from(objects)
245
+ .where(eq(objects.apId, objectId))
246
+ .get();
247
+ if (!known && (await actorHasLocalFollowers(db, actor))) {
248
+ const persisted = await fetchAndPersistAnnouncedNote(
249
+ db,
250
+ objectId,
251
+ baseUrl,
252
+ );
253
+ if (!persisted) {
254
+ log.debug("Announce target fetch skipped or failed", {
255
+ event: "ap.announce.object_fetch_failed",
256
+ actor,
257
+ objectId,
258
+ });
259
+ }
260
+ }
261
+ }
198
262
  await handleInteraction("announce", c, activity, actor, baseUrl);
199
263
  }
200
264
 
@@ -23,18 +23,13 @@ import type { Activity } from "../inbox-types.ts";
23
23
  // ---------------------------------------------------------------------------
24
24
 
25
25
  type BatchStatement = BatchItem<"sqlite">;
26
- interface BatchableDb {
27
- batch(
28
- statements: readonly [BatchStatement, ...BatchStatement[]],
29
- ): Promise<unknown>;
30
- }
31
26
 
32
- export async function runBatch(
33
- db: Database,
34
- statements: readonly [BatchStatement, ...BatchStatement[]],
35
- ): Promise<void> {
36
- await (db as unknown as BatchableDb).batch(statements);
37
- }
27
+ // The batch primitive lives with the other D1 write primitives (src/db/
28
+ // d1-write.ts) so the atomicity rule and the parameter-budget rule cannot
29
+ // drift apart. Re-exported here because the inbox handlers are its heaviest
30
+ // caller and this is the import path they already use.
31
+ export { runBatch } from "../../../../db/d1-write.ts";
32
+ import { runBatch } from "../../../../db/d1-write.ts";
38
33
 
39
34
  // ---------------------------------------------------------------------------
40
35
  // Shared helpers used by multiple inbox handler files
@@ -97,7 +92,7 @@ export async function findFollowByActivityId(
97
92
  followingApId: string;
98
93
  status: string;
99
94
  } | null> {
100
- const row = await db
95
+ let row = await db
101
96
  .select({
102
97
  followerApId: follows.followerApId,
103
98
  followingApId: follows.followingApId,
@@ -106,6 +101,34 @@ export async function findFollowByActivityId(
106
101
  .from(follows)
107
102
  .where(eq(follows.activityApId, activityApIdValue))
108
103
  .get();
104
+ if (!row) {
105
+ // Inbound envelopes use an origin-bound internal activities.apId. Undo
106
+ // carries the peer's protocol id, so resolve that bounded raw-json id back
107
+ // to the internal row before looking up the follow edge. The direct lookup
108
+ // above preserves compatibility with legacy rows.
109
+ const source = await db
110
+ .select({ apId: activities.apId })
111
+ .from(activities)
112
+ .where(
113
+ and(
114
+ eq(activities.direction, "inbound"),
115
+ sql`json_extract(${activities.rawJson}, '$.id') = ${activityApIdValue}`,
116
+ ),
117
+ )
118
+ .limit(2);
119
+ for (const candidate of source) {
120
+ row = await db
121
+ .select({
122
+ followerApId: follows.followerApId,
123
+ followingApId: follows.followingApId,
124
+ status: follows.status,
125
+ })
126
+ .from(follows)
127
+ .where(eq(follows.activityApId, candidate.apId))
128
+ .get();
129
+ if (row) break;
130
+ }
131
+ }
109
132
  return row ?? null;
110
133
  }
111
134
 
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Shared-inbox addressing: which LOCAL actors is an inbound activity for?
3
+ *
4
+ * The old model was an exception list. Six types were "recipient independent"
5
+ * and dispatched once; everything else fell through to "fan out to every local
6
+ * actor that follows the SENDER". That fallback is a guess, and it is wrong in
7
+ * both directions:
8
+ *
9
+ * - A DM or a Like from someone the recipient does not follow resolved to
10
+ * ZERO recipients. The route then committed `processed = 1`, so the
11
+ * activity was permanently un-redeliverable — a silent drop with a 202.
12
+ * Our own delivery planner always prefers `endpoints.sharedInbox`, and so
13
+ * does Mastodon, so this is the NORMAL inbound path, not an edge case.
14
+ * - `Like` / `Announce` were fanned out once PER LOCAL FOLLOWER even though
15
+ * their handlers take `_recipient` and resolve the target from the object's
16
+ * `attributedTo`. Every extra follower was one more redundant whole-row
17
+ * counter recompute.
18
+ *
19
+ * The replacement is a total function over the handled activity types. Every
20
+ * type declares its addressing class, `satisfies` makes the map exhaustive, and
21
+ * a new handler therefore cannot be added without declaring where it is
22
+ * addressed — the enforcement is `bunx tsc --noEmit`, not a lint script.
23
+ */
24
+
25
+ import { and, eq, inArray } from "drizzle-orm";
26
+ import type { Database } from "../../../db/index.ts";
27
+ import { actors, follows, inChunks } from "../../../db/index.ts";
28
+ import { isLocal } from "../../federation-helpers.ts";
29
+ import type { Activity } from "./inbox-types.ts";
30
+ import {
31
+ addressesPublic,
32
+ MAX_ADDRESS_ENTRIES,
33
+ } from "./handlers/inbox-content-handlers.ts";
34
+
35
+ export type ActorRow = typeof actors.$inferSelect;
36
+
37
+ /**
38
+ * How an inbound activity names its recipients.
39
+ *
40
+ * `audience` is never DECLARED — it is only ever the RESULT of resolving an
41
+ * `addressed` activity whose addressing actually contains a Public or
42
+ * followers-collection marker. That distinction is the whole point: the old
43
+ * code performed a follower fan-out because it had found no other recipient,
44
+ * rather than because the activity asked for one.
45
+ */
46
+ export type AddressingClass =
47
+ "instance" | "object-actor" | "addressed" | "audience";
48
+
49
+ export type DeclaredAddressingClass = Exclude<AddressingClass, "audience">;
50
+
51
+ /** Activity types `dispatchUserActivity` handles. */
52
+ export const HANDLED_ACTIVITY_TYPES = [
53
+ "Follow",
54
+ "Accept",
55
+ "Undo",
56
+ "Like",
57
+ "Create",
58
+ "Delete",
59
+ "Announce",
60
+ "Update",
61
+ "Reject",
62
+ "Add",
63
+ "Remove",
64
+ "Block",
65
+ "Flag",
66
+ "Move",
67
+ ] as const;
68
+
69
+ export type HandledActivityType = (typeof HANDLED_ACTIVITY_TYPES)[number];
70
+
71
+ /**
72
+ * The addressing declaration. `satisfies` keeps it total: adding a handler to
73
+ * {@link HANDLED_ACTIVITY_TYPES} without a class here is a type error.
74
+ *
75
+ * `Like` and `Announce` are `instance`: their handlers already ignore the
76
+ * recipient argument and resolve the affected object (and its owner) from
77
+ * `activity.object`, so dispatching them once is not a behaviour change — it
78
+ * only removes the per-follower duplication.
79
+ *
80
+ * `Undo` is declared `object-actor` because Undo(Follow|Block) must reach the
81
+ * followed/blocked actor. Undo(Like|Announce) carries no local actor target and
82
+ * is actor-keyed + idempotent, so the resolver downgrades it to `instance`.
83
+ */
84
+ export const ACTIVITY_ADDRESSING = {
85
+ Accept: "instance",
86
+ Delete: "instance",
87
+ Update: "instance",
88
+ Reject: "instance",
89
+ Flag: "instance",
90
+ Move: "instance",
91
+ Like: "instance",
92
+ Announce: "instance",
93
+
94
+ Follow: "object-actor",
95
+ Block: "object-actor",
96
+ Undo: "object-actor",
97
+
98
+ Create: "addressed",
99
+ Add: "addressed",
100
+ Remove: "addressed",
101
+ } satisfies Record<HandledActivityType, DeclaredAddressingClass>;
102
+
103
+ export function isHandledActivityType(
104
+ type: string,
105
+ ): type is HandledActivityType {
106
+ return type in ACTIVITY_ADDRESSING;
107
+ }
108
+
109
+ /**
110
+ * Every IRI the activity uses to name an audience, from the ENVELOPE and from
111
+ * an embedded object. The envelope fields used to be discarded by
112
+ * `parseActivity`, which is why a DM's addressing never reached routing.
113
+ *
114
+ * `bto`/`bcc` are included: a peer that strips them before delivery simply
115
+ * contributes nothing, and one that does not must still be honoured.
116
+ */
117
+ export function collectAddresses(activity: Activity): string[] {
118
+ const object = typeof activity.object === "string" ? null : activity.object;
119
+ const all = [
120
+ ...(activity.to ?? []),
121
+ ...(activity.cc ?? []),
122
+ ...(activity.bto ?? []),
123
+ ...(activity.bcc ?? []),
124
+ ...(activity.audience ?? []),
125
+ ...(object?.to ?? []),
126
+ ...(object?.cc ?? []),
127
+ ];
128
+ // Same bound the persisted addressing arrays use (policy defined once, in
129
+ // inbox-content-handlers): a remote must not be able to make one delivery
130
+ // cost an unbounded number of actor lookups.
131
+ return [...new Set(all)].slice(0, MAX_ADDRESS_ENTRIES);
132
+ }
133
+
134
+ /**
135
+ * A followers-collection IRI. Matched by suffix, exactly like the visibility
136
+ * classifier in inbox-content-handlers — resolving the sender's real
137
+ * `followers` URL would need a network fetch for a remote actor.
138
+ */
139
+ function addressesFollowersCollection(addresses: readonly string[]): boolean {
140
+ return addresses.some((a) => a.endsWith("/followers"));
141
+ }
142
+
143
+ export type AddressingResolution = {
144
+ /** The class actually applied (may be `audience`, which is never declared). */
145
+ readonly cls: AddressingClass;
146
+ readonly recipients: readonly ActorRow[];
147
+ /** Addressing IRIs read off the activity, for logging. */
148
+ readonly addresses: readonly string[];
149
+ };
150
+
151
+ /**
152
+ * Local actors explicitly named in the addressing.
153
+ */
154
+ async function resolveAddressedLocalActors(
155
+ db: Database,
156
+ addresses: readonly string[],
157
+ baseUrl: string,
158
+ ): Promise<ActorRow[]> {
159
+ const localIris = addresses.filter(
160
+ (a) => isLocal(a, baseUrl) && !addressesPublic([a]),
161
+ );
162
+ if (localIris.length === 0) return [];
163
+ // Bounded by MAX_ADDRESS_ENTRIES, but routed through inChunks anyway so the
164
+ // D1 parameter rule holds if that bound is ever raised.
165
+ return await inChunks(localIris, (chunk) =>
166
+ db.query.actors.findMany({ where: inArray(actors.apId, [...chunk]) }),
167
+ );
168
+ }
169
+
170
+ /**
171
+ * Local actors with an accepted follow of `senderApId`.
172
+ */
173
+ async function resolveLocalFollowers(
174
+ db: Database,
175
+ senderApId: string,
176
+ baseUrl: string,
177
+ limit: number,
178
+ ): Promise<ActorRow[]> {
179
+ const rows = await db
180
+ .select({ followerApId: follows.followerApId })
181
+ .from(follows)
182
+ .where(
183
+ and(
184
+ eq(follows.followingApId, senderApId),
185
+ eq(follows.status, "accepted"),
186
+ ),
187
+ )
188
+ .limit(limit);
189
+
190
+ const localFollowerApIds = rows
191
+ .map((row) => row.followerApId)
192
+ .filter((apId) => isLocal(apId, baseUrl));
193
+ if (localFollowerApIds.length === 0) return [];
194
+
195
+ return await inChunks(localFollowerApIds, (chunk) =>
196
+ db.query.actors.findMany({ where: inArray(actors.apId, [...chunk]) }),
197
+ );
198
+ }
199
+
200
+ /**
201
+ * Resolve an `addressed` activity's local recipients.
202
+ *
203
+ * A follower fan-out happens ONLY when the activity actually addresses Public
204
+ * or a followers collection (`audience`). An activity that names specific
205
+ * actors resolves to exactly those (`addressed`). An activity that names
206
+ * neither resolves to nothing, and the caller must treat that as UNDELIVERABLE
207
+ * rather than as a completed no-op — the old code's `processed = 1` on that
208
+ * branch is what made non-follower DMs unrecoverable.
209
+ */
210
+ export async function resolveAddressedRecipients(
211
+ db: Database,
212
+ activity: Activity,
213
+ senderApId: string,
214
+ baseUrl: string,
215
+ followerFanoutLimit: number,
216
+ ): Promise<AddressingResolution> {
217
+ const addresses = collectAddresses(activity);
218
+ const addressed = await resolveAddressedLocalActors(db, addresses, baseUrl);
219
+
220
+ const wantsAudience =
221
+ addressesPublic([...addresses]) || addressesFollowersCollection(addresses);
222
+
223
+ if (!wantsAudience) {
224
+ return { cls: "addressed", recipients: addressed, addresses };
225
+ }
226
+
227
+ const followers = await resolveLocalFollowers(
228
+ db,
229
+ senderApId,
230
+ baseUrl,
231
+ followerFanoutLimit,
232
+ );
233
+ const byApId = new Map<string, ActorRow>();
234
+ for (const row of [...addressed, ...followers]) byApId.set(row.apId, row);
235
+ return { cls: "audience", recipients: [...byApId.values()], addresses };
236
+ }
@@ -29,6 +29,14 @@ export type Activity = {
29
29
  object?: string | ActivityObject;
30
30
  target?: string | ActivityObject;
31
31
  room?: string;
32
+ // Envelope addressing, preserved by parseActivity. Shared-inbox routing is
33
+ // derived from these (inbox-addressing.ts); before they were parsed the
34
+ // route had to guess the recipients from the sender's follower graph.
35
+ to?: string[];
36
+ cc?: string[];
37
+ bto?: string[];
38
+ bcc?: string[];
39
+ audience?: string[];
32
40
  };
33
41
 
34
42
  export type RemoteActor = {