@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
@@ -9,9 +9,29 @@ import type {
9
9
  IQueueMessage,
10
10
  IQueueProducer,
11
11
  } from "../../runtime/queue.ts";
12
- import type { Database } from "../../../db/index.ts";
13
- import { and, eq, notInArray, or, sql } from "drizzle-orm";
14
- import { actorCache, deliveryQueue } from "../../../db/index.ts";
12
+ import {
13
+ and,
14
+ asc,
15
+ eq,
16
+ exists,
17
+ inArray,
18
+ lt,
19
+ lte,
20
+ notInArray,
21
+ notExists,
22
+ or,
23
+ sql,
24
+ } from "drizzle-orm";
25
+ import {
26
+ actorCache,
27
+ affectedRowCount,
28
+ deliveryEndpointRecipients,
29
+ deliveryQueue,
30
+ insertMany,
31
+ runBatch,
32
+ type D1Statement,
33
+ type Database,
34
+ } from "../../../db/index.ts";
15
35
  import { isSafeRemoteUrl } from "../../federation-helpers.ts";
16
36
  import {
17
37
  DELIVERY_QUEUE_MESSAGE_VERSION,
@@ -24,12 +44,29 @@ import {
24
44
  import { computeDeliveryJobId, safeEndpointHost } from "./transformers.ts";
25
45
  import { emitMetric } from "./metrics.ts";
26
46
  import { logger } from "../logger.ts";
27
- import { filterBlockedActorApIds, isActorBlocked } from "../blocklist.ts";
47
+ import {
48
+ filterBlockedActorApIdsStrict,
49
+ isActorBlockedStrict,
50
+ } from "../blocklist.ts";
28
51
  import {
29
52
  enqueuePendingNotificationPushJobs,
30
53
  processNotificationPushJob,
31
54
  recoverDeadLetteredNotificationPushJob,
32
55
  } from "../notification-push.ts";
56
+ import {
57
+ buildResolveActorMessage,
58
+ enqueueDeliveryResolutionJobs,
59
+ enqueuePendingDeliveryResolutionJobs,
60
+ persistDeliveryResolutionJobs,
61
+ } from "./resolution-outbox.ts";
62
+ import {
63
+ deliveryFanoutIntentFromMessage,
64
+ enqueueDeliveryFanoutJob,
65
+ enqueuePendingDeliveryFanoutJobs,
66
+ failDeliveryFanoutJob,
67
+ } from "./fanout-outbox.ts";
68
+
69
+ export { buildResolveActorMessage } from "./resolution-outbox.ts";
33
70
 
34
71
  const log = logger.child({ component: "delivery.queue" });
35
72
 
@@ -165,6 +202,24 @@ export async function sendQueueMessage(
165
202
  );
166
203
  }
167
204
 
205
+ /**
206
+ * DLQ recovery cannot use the ordinary best-effort producer contract: a
207
+ * missing binding must keep the DLQ message retryable rather than look like a
208
+ * successful redrive and get ACKed. Runtime send failures already throw; make
209
+ * binding drift do the same while preserving the shared alert/metric.
210
+ */
211
+ async function sendQueueMessageRequired(
212
+ env: Env,
213
+ body: DeliveryQueueMessageV1,
214
+ delaySeconds?: number,
215
+ ): Promise<void> {
216
+ if (!queueAvailable(env)) {
217
+ reportProducerUnavailable("sendQueueMessageRequired");
218
+ throw new Error("DELIVERY_QUEUE and DELIVERY_DLQ bindings are required");
219
+ }
220
+ await sendQueueMessage(env, body, delaySeconds);
221
+ }
222
+
168
223
  export async function sendDlqMessage(
169
224
  env: Env,
170
225
  payload: DeliveryDlqMessageV1,
@@ -184,6 +239,12 @@ export async function sendDlqMessage(
184
239
  // reconcile worker (processReconcileJob) share one source of truth.
185
240
  export const MAX_RECONCILE_ATTEMPTS = 5;
186
241
 
242
+ // Cloudflare puts the raw MAIN-queue body onto the DLQ after transport/runtime
243
+ // retries are exhausted. Redrive idempotent delivery work a few times so one
244
+ // infrastructure incident does not become permanent data loss, while keeping
245
+ // a poison message or persistent code failure from cycling MAIN -> DLQ forever.
246
+ export const MAX_AUTO_DLQ_REDRIVES = 3;
247
+
187
248
  export function buildDeliverEndpointMessage(
188
249
  jobId: string,
189
250
  reconcileAttempt = 0,
@@ -199,21 +260,6 @@ export function buildDeliverEndpointMessage(
199
260
  };
200
261
  }
201
262
 
202
- export function buildResolveActorMessage(
203
- activityId: string,
204
- recipientActorApId: string,
205
- attempts = 0,
206
- ): DeliveryQueueMessageV1 {
207
- return {
208
- version: DELIVERY_QUEUE_MESSAGE_VERSION,
209
- type: "resolve_actor",
210
- activityId,
211
- recipientActorApId,
212
- ...(attempts > 0 ? { attempts } : {}),
213
- scheduledAt: nowIso(),
214
- };
215
- }
216
-
217
263
  export function buildReconcileJobMessage(
218
264
  jobId: string,
219
265
  reconcileAttempt: number,
@@ -236,32 +282,188 @@ export async function upsertDeliveryJob(
236
282
  jobId: string,
237
283
  activityId: string,
238
284
  endpoint: string,
239
- ): Promise<void> {
240
- await db
241
- .insert(deliveryQueue)
242
- .values({
243
- id: jobId,
244
- inboxUrl: endpoint,
245
- activityApId: activityId,
246
- attempts: 0,
247
- nextAttemptAt: nowIso(),
248
- status: "pending",
249
- })
250
- .onConflictDoNothing();
251
-
252
- // Guard against overwriting in-flight or completed jobs.
253
- await db
254
- .update(deliveryQueue)
255
- .set({
256
- inboxUrl: endpoint,
257
- activityApId: activityId,
258
- })
259
- .where(
285
+ recipientActorApIds: readonly string[],
286
+ ): Promise<boolean> {
287
+ const uniqueRecipientActorApIds = [...new Set(recipientActorApIds)];
288
+ if (uniqueRecipientActorApIds.length === 0) return false;
289
+
290
+ const recipientInserts = insertMany(
291
+ db,
292
+ deliveryEndpointRecipients,
293
+ uniqueRecipientActorApIds.map((recipientActorApId) => ({
294
+ deliveryJobId: jobId,
295
+ recipientActorApId,
296
+ })),
297
+ { conflict: "ignore" },
298
+ );
299
+ const firstRecipientInsert = recipientInserts[0];
300
+ if (!firstRecipientInsert) return false;
301
+ const hasRetainedRecipient = () =>
302
+ exists(
303
+ db
304
+ .select({ deliveryJobId: deliveryEndpointRecipients.deliveryJobId })
305
+ .from(deliveryEndpointRecipients)
306
+ .where(eq(deliveryEndpointRecipients.deliveryJobId, jobId)),
307
+ );
308
+ const now = nowIso();
309
+
310
+ // One deep materialization unit owns endpoint aggregation, recipient
311
+ // attribution, tombstone fencing, and compatibility with legacy jobs. The
312
+ // recipient trigger drops a deleted actor before the final cleanup runs.
313
+ // A newly-created job is marked attributed; a pre-0029 conflict retains its
314
+ // default 0 because its full historical recipient set cannot be recovered.
315
+ await runBatch(db, [
316
+ firstRecipientInsert,
317
+ ...recipientInserts.slice(1),
318
+ db
319
+ .insert(deliveryQueue)
320
+ .values({
321
+ id: jobId,
322
+ inboxUrl: endpoint,
323
+ activityApId: activityId,
324
+ attempts: 0,
325
+ nextAttemptAt: now,
326
+ status: "pending",
327
+ recipientAttributionComplete: 1,
328
+ })
329
+ .onConflictDoNothing() as unknown as D1Statement,
330
+ // Guard against overwriting in-flight or completed jobs, and do not update
331
+ // a new all-tombstoned candidate that retained no recipient mapping.
332
+ db
333
+ .update(deliveryQueue)
334
+ .set({ inboxUrl: endpoint, activityApId: activityId })
335
+ .where(
336
+ and(
337
+ eq(deliveryQueue.id, jobId),
338
+ notInArray(deliveryQueue.status, ["processing", "delivered"]),
339
+ hasRetainedRecipient(),
340
+ ),
341
+ ) as unknown as D1Statement,
342
+ // Only jobs born under the recipient-aware implementation are safe to
343
+ // delete when every supplied recipient was already tombstoned. Legacy jobs
344
+ // remain conservative because they may represent unknown co-recipients.
345
+ db.delete(deliveryQueue).where(
260
346
  and(
261
347
  eq(deliveryQueue.id, jobId),
262
- notInArray(deliveryQueue.status, ["processing", "delivered"]),
348
+ eq(deliveryQueue.recipientAttributionComplete, 1),
349
+ notExists(
350
+ db
351
+ .select({
352
+ deliveryJobId: deliveryEndpointRecipients.deliveryJobId,
353
+ })
354
+ .from(deliveryEndpointRecipients)
355
+ .where(eq(deliveryEndpointRecipients.deliveryJobId, jobId)),
356
+ ),
263
357
  ),
358
+ ) as unknown as D1Statement,
359
+ ]);
360
+
361
+ return Boolean(
362
+ await db
363
+ .select({ id: deliveryQueue.id })
364
+ .from(deliveryQueue)
365
+ .where(eq(deliveryQueue.id, jobId))
366
+ .get(),
367
+ );
368
+ }
369
+
370
+ const PENDING_DELIVERY_SCAN_LIMIT = 500;
371
+ const STALE_DELIVERY_PROCESSING_MS = 2 * 60 * 1000;
372
+ const TERMINAL_DELIVERY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
373
+ const TERMINAL_DELIVERY_PURGE_LIMIT = 50;
374
+ const TERMINAL_DELIVERY_STATUSES = [
375
+ "delivered",
376
+ "dead_letter",
377
+ "failed",
378
+ ] as const;
379
+
380
+ /**
381
+ * Bound terminal endpoint-ledger growth while retaining a 30-day idempotency
382
+ * and diagnostic window. Activity deletion still owns immediate projection
383
+ * cleanup; this path handles Activities that legitimately remain in history.
384
+ */
385
+ export async function purgeTerminalDeliveryEndpointJobs(
386
+ db: Database,
387
+ nowDate = new Date(),
388
+ ): Promise<number> {
389
+ const cutoff = new Date(
390
+ nowDate.getTime() - TERMINAL_DELIVERY_RETENTION_MS,
391
+ ).toISOString();
392
+ const rows = await db
393
+ .select({ id: deliveryQueue.id })
394
+ .from(deliveryQueue)
395
+ .where(
396
+ and(
397
+ inArray(deliveryQueue.status, [...TERMINAL_DELIVERY_STATUSES]),
398
+ lte(deliveryQueue.createdAt, cutoff),
399
+ ),
400
+ )
401
+ .orderBy(asc(deliveryQueue.createdAt))
402
+ .limit(TERMINAL_DELIVERY_PURGE_LIMIT);
403
+ if (rows.length === 0) return 0;
404
+ const result = await db.delete(deliveryQueue).where(
405
+ and(
406
+ inArray(
407
+ deliveryQueue.id,
408
+ rows.map((row) => row.id),
409
+ ),
410
+ inArray(deliveryQueue.status, [...TERMINAL_DELIVERY_STATUSES]),
411
+ lte(deliveryQueue.createdAt, cutoff),
412
+ ),
413
+ );
414
+ return affectedRowCount(result);
415
+ }
416
+
417
+ /**
418
+ * Re-publish durable endpoint jobs whose first Queue RPC was lost, whose
419
+ * backoff has elapsed, or whose processing worker disappeared. Queue messages
420
+ * are wakeups; delivery_queue remains the retry authority.
421
+ */
422
+ export async function enqueuePendingDeliveryEndpointJobs(
423
+ env: Env,
424
+ nowDate = new Date(),
425
+ options: {
426
+ readonly includeDeferred?: boolean;
427
+ readonly includeFreshPending?: boolean;
428
+ } = {},
429
+ ): Promise<number> {
430
+ await purgeTerminalDeliveryEndpointJobs(env.DB_INSTANCE, nowDate);
431
+ if (!env.DELIVERY_QUEUE) return 0;
432
+ const now = nowDate.toISOString();
433
+ const staleBefore = new Date(
434
+ nowDate.getTime() - STALE_DELIVERY_PROCESSING_MS,
435
+ ).toISOString();
436
+ const rows = await env.DB_INSTANCE.select({ id: deliveryQueue.id })
437
+ .from(deliveryQueue)
438
+ .where(
439
+ or(
440
+ options.includeFreshPending
441
+ ? eq(deliveryQueue.status, "pending")
442
+ : and(
443
+ eq(deliveryQueue.status, "pending"),
444
+ lt(deliveryQueue.createdAt, staleBefore),
445
+ ),
446
+ options.includeDeferred
447
+ ? eq(deliveryQueue.status, "retry_wait")
448
+ : and(
449
+ eq(deliveryQueue.status, "retry_wait"),
450
+ lte(deliveryQueue.nextAttemptAt, now),
451
+ ),
452
+ and(
453
+ eq(deliveryQueue.status, "processing"),
454
+ lt(deliveryQueue.processingStartedAt, staleBefore),
455
+ ),
456
+ ),
457
+ )
458
+ .limit(PENDING_DELIVERY_SCAN_LIMIT);
459
+ for (let offset = 0; offset < rows.length; offset += 100) {
460
+ await env.DELIVERY_QUEUE.sendBatch(
461
+ rows.slice(offset, offset + 100).map((row) => ({
462
+ body: buildDeliverEndpointMessage(row.id),
463
+ })),
264
464
  );
465
+ }
466
+ return rows.length;
265
467
  }
266
468
 
267
469
  export async function enqueueResolveForEndpointActors(
@@ -326,16 +528,35 @@ export async function enqueueResolveForEndpointActors(
326
528
  // Drop recipients the operator has defederated before re-enqueueing
327
529
  // resolve_actor jobs (outbound blocklist enforcement). Batched (2 queries)
328
530
  // rather than a serial isActorBlocked per candidate.
329
- const blockedSet = await filterBlockedActorApIds(db, candidates);
531
+ let blockedSet: Set<string>;
532
+ try {
533
+ blockedSet = await filterBlockedActorApIdsStrict(db, candidates);
534
+ } catch (error) {
535
+ // Preserve the resolution authority before surfacing a transient local
536
+ // moderation outage. The resolver itself repeats the strict check, so
537
+ // recovery cannot deliver to a blocked actor once the DB is readable.
538
+ await persistDeliveryResolutionJobs(
539
+ db,
540
+ candidates.map((recipientActorApId) => ({
541
+ activityId,
542
+ recipientActorApId,
543
+ })),
544
+ );
545
+ throw error;
546
+ }
330
547
  const slice = candidates.filter((apId) => !blockedSet.has(apId));
331
548
 
332
549
  if (slice.length === 0) continue;
333
550
 
334
- const requests = slice.map((recipientApId) => ({
335
- body: buildResolveActorMessage(activityId, recipientApId),
336
- }));
337
-
338
- await env.DELIVERY_QUEUE.sendBatch(requests);
551
+ await enqueueDeliveryResolutionJobs(
552
+ db,
553
+ env.DELIVERY_QUEUE,
554
+ slice.map((recipientActorApId) => ({
555
+ activityId,
556
+ recipientActorApId,
557
+ })),
558
+ { reopenTerminal: true },
559
+ );
339
560
  enqueued += slice.length;
340
561
  }
341
562
 
@@ -368,13 +589,26 @@ export async function enqueueFanoutToFollowers(
368
589
  activityId: string,
369
590
  followeeApId: string,
370
591
  ): Promise<void> {
371
- await sendQueueMessage(env, {
592
+ const body = {
372
593
  version: DELIVERY_QUEUE_MESSAGE_VERSION,
373
- type: "fanout_followers",
594
+ type: "fanout_followers" as const,
374
595
  activityId,
375
596
  followeeApId,
376
597
  scheduledAt: nowIso(),
377
- });
598
+ };
599
+ const db = (env as Partial<Env>).DB_INSTANCE;
600
+ if (db) {
601
+ const queue = queueAvailable(env) ? env.DELIVERY_QUEUE : undefined;
602
+ await enqueueDeliveryFanoutJob(
603
+ db,
604
+ queue,
605
+ deliveryFanoutIntentFromMessage(body),
606
+ );
607
+ if (!queue) reportProducerUnavailable("enqueueFanoutToFollowers");
608
+ else producerUnavailableReported = false;
609
+ return;
610
+ }
611
+ await sendQueueMessage(env, body);
378
612
  }
379
613
 
380
614
  export async function enqueueDeliveryToActor(
@@ -385,16 +619,35 @@ export async function enqueueDeliveryToActor(
385
619
  // Enforce the operator blocklist on the OUTBOUND single-actor path (DMs,
386
620
  // Accept/Follow responses, targeted post/story interactions). A defederated
387
621
  // domain/actor must never receive our activities, mirroring the inbound
388
- // enforcement in the inbox handler. Best-effort: if the db read fails,
389
- // isActorBlocked returns false (never black-hole on a transient error).
622
+ // enforcement in the inbox handler. A DB read failure retains the delivery
623
+ // intent and propagates so unavailable authority never becomes a bypass.
390
624
  const db = (env as Partial<Env>).DB_INSTANCE;
391
- if (db && (await isActorBlocked(db, recipientActorApId))) {
392
- log.info("Skipping outbound delivery to blocked actor", {
393
- event: "delivery.blocklist.actor_skip",
394
- actor: recipientActorApId,
395
- activityId,
396
- });
397
- emitMetric("delivery.blocklist.actor_skip", 1, {});
625
+ if (db) {
626
+ try {
627
+ if (await isActorBlockedStrict(db, recipientActorApId)) {
628
+ log.info("Skipping outbound delivery to blocked actor", {
629
+ event: "delivery.blocklist.actor_skip",
630
+ actor: recipientActorApId,
631
+ activityId,
632
+ });
633
+ emitMetric("delivery.blocklist.actor_skip", 1, {});
634
+ return;
635
+ }
636
+ } catch (error) {
637
+ // The Activity may already be committed and some callers deliberately
638
+ // treat queue publication as best-effort. Persist the semantic intent so
639
+ // a later outbox sweep can resume after moderation authority recovers.
640
+ await persistDeliveryResolutionJobs(db, [
641
+ { activityId, recipientActorApId },
642
+ ]);
643
+ throw error;
644
+ }
645
+ const queue = queueAvailable(env) ? env.DELIVERY_QUEUE : undefined;
646
+ await enqueueDeliveryResolutionJobs(db, queue, [
647
+ { activityId, recipientActorApId },
648
+ ]);
649
+ if (!queue) reportProducerUnavailable("enqueueDeliveryToActor");
650
+ else producerUnavailableReported = false;
398
651
  return;
399
652
  }
400
653
 
@@ -404,6 +657,75 @@ export async function enqueueDeliveryToActor(
404
657
  );
405
658
  }
406
659
 
660
+ /**
661
+ * Enqueue several distinct activities to the same actor without one Queue RPC
662
+ * per activity. Cloudflare Queues accepts at most 100 messages per sendBatch;
663
+ * keep the provider-neutral port bounded to that same size. Replaying a batch
664
+ * is safe: endpoint resolution later derives the durable delivery-job id from
665
+ * activity id + endpoint and upserts it idempotently.
666
+ */
667
+ export async function enqueueDeliveriesToActor(
668
+ env: Env,
669
+ activityIds: readonly string[],
670
+ recipientActorApId: string,
671
+ ): Promise<void> {
672
+ if (activityIds.length === 0) return;
673
+ const db = (env as Partial<Env>).DB_INSTANCE;
674
+ const intents = activityIds.map((activityId) => ({
675
+ activityId,
676
+ recipientActorApId,
677
+ }));
678
+ if (db) {
679
+ try {
680
+ if (await isActorBlockedStrict(db, recipientActorApId)) {
681
+ log.info("Skipping outbound deliveries to blocked actor", {
682
+ event: "delivery.blocklist.actor_batch_skip",
683
+ actor: recipientActorApId,
684
+ activityCount: activityIds.length,
685
+ });
686
+ emitMetric(
687
+ "delivery.blocklist.actor_batch_skip",
688
+ activityIds.length,
689
+ {},
690
+ );
691
+ return;
692
+ }
693
+ } catch (error) {
694
+ await persistDeliveryResolutionJobs(db, intents);
695
+ throw error;
696
+ }
697
+ }
698
+
699
+ if (db) await persistDeliveryResolutionJobs(db, intents);
700
+ if (!queueAvailable(env)) {
701
+ reportProducerUnavailable("enqueueDeliveriesToActor");
702
+ // This batched path is used by account Move after its pending edges and
703
+ // outbound activities have already committed. A silent no-op would make
704
+ // those local users follow a destination that never learned about them,
705
+ // with no remaining old edges from which to reconstruct delivery. Throw so
706
+ // the inbound dispatch stays retryable and can resume the durable activity
707
+ // namespace after the operator restores the producer bindings.
708
+ throw new Error(
709
+ "DELIVERY_QUEUE and DELIVERY_DLQ bindings are required for batched delivery",
710
+ );
711
+ }
712
+ producerUnavailableReported = false;
713
+
714
+ if (db) {
715
+ await enqueueDeliveryResolutionJobs(db, env.DELIVERY_QUEUE, intents);
716
+ return;
717
+ }
718
+
719
+ const SEND_BATCH_SIZE = 100;
720
+ for (let offset = 0; offset < activityIds.length; offset += SEND_BATCH_SIZE) {
721
+ await env.DELIVERY_QUEUE.sendBatch(
722
+ activityIds.slice(offset, offset + SEND_BATCH_SIZE).map((activityId) => ({
723
+ body: buildResolveActorMessage(activityId, recipientActorApId),
724
+ })),
725
+ );
726
+ }
727
+ }
728
+
407
729
  /**
408
730
  * Fan an activity out to a community's audience (members + community
409
731
  * followers) instead of the author's personal follower graph. Used for
@@ -415,14 +737,27 @@ export async function enqueueFanoutToCommunity(
415
737
  communityApId: string,
416
738
  announceActivityId?: string,
417
739
  ): Promise<void> {
418
- await sendQueueMessage(env, {
740
+ const body = {
419
741
  version: DELIVERY_QUEUE_MESSAGE_VERSION,
420
- type: "fanout_community",
742
+ type: "fanout_community" as const,
421
743
  activityId,
422
744
  communityApId,
423
745
  ...(announceActivityId ? { announceActivityId } : {}),
424
746
  scheduledAt: nowIso(),
425
- });
747
+ };
748
+ const db = (env as Partial<Env>).DB_INSTANCE;
749
+ if (db) {
750
+ const queue = queueAvailable(env) ? env.DELIVERY_QUEUE : undefined;
751
+ await enqueueDeliveryFanoutJob(
752
+ db,
753
+ queue,
754
+ deliveryFanoutIntentFromMessage(body),
755
+ );
756
+ if (!queue) reportProducerUnavailable("enqueueFanoutToCommunity");
757
+ else producerUnavailableReported = false;
758
+ return;
759
+ }
760
+ await sendQueueMessage(env, body);
426
761
  }
427
762
 
428
763
  // ---------------------------------------------------------------------------
@@ -527,6 +862,16 @@ export async function handleDeliveryQueueBatch(
527
862
 
528
863
  // Community fanout can create local inbox rows inside this consumer rather
529
864
  // than an HTTP request. Flush the same DB-triggered outbox choke point here.
865
+ try {
866
+ await enqueuePendingDeliveryFanoutJobs(env);
867
+ await enqueuePendingDeliveryEndpointJobs(env);
868
+ await enqueuePendingDeliveryResolutionJobs(env);
869
+ } catch (error) {
870
+ log.error("Failed to enqueue durable federation outbox", {
871
+ event: "delivery.outbox.enqueue_failed",
872
+ error,
873
+ });
874
+ }
530
875
  try {
531
876
  await enqueuePendingNotificationPushJobs(env);
532
877
  } catch (error) {
@@ -546,7 +891,11 @@ export async function handleDeliveryDlqBatch(
546
891
  env: Env,
547
892
  ): Promise<void> {
548
893
  for (const message of batch.messages) {
549
- const body = message.body;
894
+ // The configured DLQ receives both app-built DeliveryDlqMessageV1 bodies
895
+ // and Cloudflare's raw original MAIN-queue bodies. The batch generic can
896
+ // express only one of those at a time, so narrow the runtime union from
897
+ // unknown through the two wire validators below.
898
+ const body: unknown = message.body;
550
899
  if (!isDeliveryDlqMessageV1(body)) {
551
900
  // Not an app-built `dlq` message. Cloudflare Queues also delivers here
552
901
  // the RAW original body of any MAIN-queue message that exhausted its
@@ -554,8 +903,22 @@ export async function handleDeliveryDlqBatch(
554
903
  // "invalid": a lost fanout/resolve drops local notifications or delivery
555
904
  // planning, and a lost notification_push strands its durable outbox row.
556
905
  if (isDeliveryQueueMessageV1(body)) {
557
- await handleAutoDeadLetteredMessage(env, body);
558
- message.ack();
906
+ try {
907
+ await handleAutoDeadLetteredMessage(env, body);
908
+ message.ack();
909
+ } catch (error) {
910
+ log.warn("Failed to recover auto-dead-lettered message", {
911
+ event: "delivery.dlq.auto_recovery_failed",
912
+ messageType: body.type,
913
+ error,
914
+ });
915
+ emitMetric("delivery.dlq.auto_recovery_failed", 1, {
916
+ message_type: body.type,
917
+ });
918
+ // Keep the raw DLQ message alive until its durable repair or bounded
919
+ // MAIN-queue redrive succeeds.
920
+ message.retry({ delaySeconds: 60 });
921
+ }
559
922
  continue;
560
923
  }
561
924
  log.warn("Invalid DLQ message format, skipping", {
@@ -596,7 +959,7 @@ export async function handleDeliveryDlqBatch(
596
959
  continue;
597
960
  }
598
961
  try {
599
- await sendQueueMessage(
962
+ await sendQueueMessageRequired(
600
963
  env,
601
964
  buildReconcileJobMessage(body.jobId, reconcileAttempt + 1),
602
965
  6 * 60 * 60,
@@ -607,6 +970,14 @@ export async function handleDeliveryDlqBatch(
607
970
  jobId: body.jobId,
608
971
  error: e,
609
972
  });
973
+ // The dead-letter row is already the durable evidence that delivery did
974
+ // not complete. ACKing its DLQ message here used to discard the only
975
+ // trigger that can revive it after a transient main-queue outage, leaving
976
+ // the ActivityPub delivery permanently dead_letter. Ask Cloudflare
977
+ // Queues to redeliver this exact DLQ message instead; the successful path
978
+ // below remains the sole ACK point.
979
+ message.retry({ delaySeconds: 60 });
980
+ continue;
610
981
  }
611
982
 
612
983
  message.ack();
@@ -617,7 +988,7 @@ export async function handleDeliveryDlqBatch(
617
988
  * Recover / account for a MAIN-queue message that Cloudflare auto-dead-lettered
618
989
  * (retries exhausted with the raw body). `notification_push` rows are durable,
619
990
  * so reset the job to retry through the outbox instead of stranding it;
620
- * everything else is logged with an alerting metric rather than swallowed.
991
+ * idempotent federation work is sent back to MAIN with a bounded generation.
621
992
  */
622
993
  async function handleAutoDeadLetteredMessage(
623
994
  env: Env,
@@ -642,16 +1013,51 @@ async function handleAutoDeadLetteredMessage(
642
1013
  error,
643
1014
  });
644
1015
  emitMetric("delivery.dlq.notification_push_recover_failed", 1, {});
1016
+ throw error;
1017
+ }
1018
+ return;
1019
+ }
1020
+
1021
+ const autoDlqAttempt = body.autoDlqAttempt ?? 0;
1022
+ if (autoDlqAttempt >= MAX_AUTO_DLQ_REDRIVES) {
1023
+ if (body.type === "fanout_followers" || body.type === "fanout_community") {
1024
+ const db = (env as Partial<Env>).DB_INSTANCE;
1025
+ if (db) {
1026
+ await failDeliveryFanoutJob(
1027
+ db,
1028
+ deliveryFanoutIntentFromMessage(body),
1029
+ `Queue delivery exhausted after ${autoDlqAttempt} automatic redrives`,
1030
+ );
1031
+ }
645
1032
  }
1033
+ log.error("Delivery message exhausted automatic DLQ redrives; dropping", {
1034
+ event: "delivery.dlq.auto_redrive_exhausted",
1035
+ messageType: body.type,
1036
+ autoDlqAttempt,
1037
+ });
1038
+ emitMetric("delivery.dlq.auto_redrive_exhausted", 1, {
1039
+ message_type: body.type,
1040
+ });
646
1041
  return;
647
1042
  }
648
1043
 
649
- // fanout_*/resolve_actor/deliver_endpoint/reconcile_job: no durable ledger to
650
- // rewind here, but make the loss explicit (alertable) rather than a silent
651
- // "invalid message" ack.
652
- log.error("Delivery message auto-dead-lettered; dropping", {
653
- event: "delivery.dlq.auto_dead_lettered",
1044
+ // Fanout pages preserve their cursor/stage, resolve preserves its own fetch
1045
+ // attempt, and deliver/reconcile preserve the durable job generation. Their
1046
+ // handlers are idempotent (stable inbox/job keys plus CAS), so replay the
1047
+ // exact semantic position with only the transport-redrive generation and
1048
+ // schedule timestamp changed.
1049
+ const redriven = {
1050
+ ...body,
1051
+ autoDlqAttempt: autoDlqAttempt + 1,
1052
+ scheduledAt: nowIso(),
1053
+ } as DeliveryQueueMessageV1;
1054
+ await sendQueueMessageRequired(env, redriven, 60);
1055
+ log.error("Delivery message auto-dead-lettered; bounded redrive scheduled", {
1056
+ event: "delivery.dlq.auto_redrive_scheduled",
654
1057
  messageType: body.type,
1058
+ autoDlqAttempt: autoDlqAttempt + 1,
1059
+ });
1060
+ emitMetric("delivery.dlq.auto_redrive_scheduled", 1, {
1061
+ message_type: body.type,
655
1062
  });
656
- emitMetric("delivery.dlq.auto_dead_lettered", 1, { message_type: body.type });
657
1063
  }