@takosjp/yurucommu-core 3.0.0

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 (185) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +82 -0
  3. package/migrations/0001_init.sql +495 -0
  4. package/migrations/0002_social_remote_actor_edges.sql +92 -0
  5. package/migrations/0003_activity_remote_object_edges.sql +68 -0
  6. package/migrations/0004_blocklist.sql +26 -0
  7. package/migrations/0005_story_community_scope.sql +13 -0
  8. package/migrations/0006_dm_community_read_status.sql +19 -0
  9. package/migrations/0007_moderation_reports.sql +22 -0
  10. package/migrations/0008_actor_fields_aka.sql +18 -0
  11. package/migrations/0009_object_tags.sql +13 -0
  12. package/migrations/0010_object_recipients_drop_actor_fk.sql +34 -0
  13. package/migrations/0011_drop_remote_actor_fks.sql +205 -0
  14. package/migrations/0012_objects_content_fts.sql +39 -0
  15. package/migrations/0013_efficiency_indexes.sql +13 -0
  16. package/migrations/0014_inbox_actor_created_idx.sql +15 -0
  17. package/migrations/0015_community_bans.sql +16 -0
  18. package/migrations/0016_namespace_takos_oidc_subject.sql +19 -0
  19. package/migrations/0017_mobile_push_registrations.sql +22 -0
  20. package/migrations/README.md +122 -0
  21. package/package.json +75 -0
  22. package/packages/api/LICENSE +16 -0
  23. package/packages/api/package.json +30 -0
  24. package/packages/api/src/index.ts +4 -0
  25. package/packages/api/src/lib/api/account.ts +20 -0
  26. package/packages/api/src/lib/api/actors.ts +149 -0
  27. package/packages/api/src/lib/api/auth.ts +46 -0
  28. package/packages/api/src/lib/api/communities.ts +329 -0
  29. package/packages/api/src/lib/api/dm.test.ts +67 -0
  30. package/packages/api/src/lib/api/dm.ts +236 -0
  31. package/packages/api/src/lib/api/fetch.ts +111 -0
  32. package/packages/api/src/lib/api/follow.ts +30 -0
  33. package/packages/api/src/lib/api/media.ts +100 -0
  34. package/packages/api/src/lib/api/moderation.ts +98 -0
  35. package/packages/api/src/lib/api/normalize.ts +71 -0
  36. package/packages/api/src/lib/api/notifications.test.ts +63 -0
  37. package/packages/api/src/lib/api/notifications.ts +61 -0
  38. package/packages/api/src/lib/api/posts.test.ts +110 -0
  39. package/packages/api/src/lib/api/posts.ts +181 -0
  40. package/packages/api/src/lib/api/recommendations.ts +22 -0
  41. package/packages/api/src/lib/api/search.ts +88 -0
  42. package/packages/api/src/lib/api/stories.ts +80 -0
  43. package/packages/api/src/lib/api.ts +15 -0
  44. package/packages/api/src/lib/fetch-with-timeout.ts +42 -0
  45. package/packages/api/src/lib/transport.ts +40 -0
  46. package/packages/api/src/social-server.ts +47 -0
  47. package/packages/api/src/types/index.ts +185 -0
  48. package/scripts/apply-takosumi-migrations.ts +621 -0
  49. package/src/backend/federation-helpers.ts +36 -0
  50. package/src/backend/index.ts +872 -0
  51. package/src/backend/lib/account-migration.ts +106 -0
  52. package/src/backend/lib/activitypub-actor-cache.ts +238 -0
  53. package/src/backend/lib/activitypub-helpers.ts +131 -0
  54. package/src/backend/lib/activitypub-validators.ts +323 -0
  55. package/src/backend/lib/ap-context.ts +16 -0
  56. package/src/backend/lib/ap-ids.ts +101 -0
  57. package/src/backend/lib/ap-response.ts +30 -0
  58. package/src/backend/lib/ap-signing.ts +87 -0
  59. package/src/backend/lib/ap-verify.ts +670 -0
  60. package/src/backend/lib/auth-lockout.ts +230 -0
  61. package/src/backend/lib/backend-paths.ts +34 -0
  62. package/src/backend/lib/base64.ts +30 -0
  63. package/src/backend/lib/blocklist-purge.ts +109 -0
  64. package/src/backend/lib/blocklist.ts +279 -0
  65. package/src/backend/lib/chunk.ts +33 -0
  66. package/src/backend/lib/client-ip.ts +169 -0
  67. package/src/backend/lib/community-visibility.ts +230 -0
  68. package/src/backend/lib/crypto.ts +424 -0
  69. package/src/backend/lib/delivery/circuit.ts +265 -0
  70. package/src/backend/lib/delivery/metrics.ts +30 -0
  71. package/src/backend/lib/delivery/planner.ts +190 -0
  72. package/src/backend/lib/delivery/queue-batching.ts +626 -0
  73. package/src/backend/lib/delivery/queue-delivery.ts +641 -0
  74. package/src/backend/lib/delivery/queue.ts +576 -0
  75. package/src/backend/lib/delivery/transformers.ts +56 -0
  76. package/src/backend/lib/delivery/types.ts +139 -0
  77. package/src/backend/lib/errors.ts +114 -0
  78. package/src/backend/lib/federation-fetch.ts +296 -0
  79. package/src/backend/lib/feed-cursor.ts +57 -0
  80. package/src/backend/lib/feed-exclude.ts +48 -0
  81. package/src/backend/lib/hex.ts +8 -0
  82. package/src/backend/lib/log-mask.ts +213 -0
  83. package/src/backend/lib/logger.ts +285 -0
  84. package/src/backend/lib/mobile-contract.ts +137 -0
  85. package/src/backend/lib/oauth-providers.ts +324 -0
  86. package/src/backend/lib/oauth-utils.ts +148 -0
  87. package/src/backend/lib/oidc-id-token.ts +151 -0
  88. package/src/backend/lib/parse-helpers.ts +31 -0
  89. package/src/backend/lib/post-visibility.ts +190 -0
  90. package/src/backend/lib/session-actor.ts +61 -0
  91. package/src/backend/lib/ssrf.ts +428 -0
  92. package/src/backend/lib/strip-image-metadata.ts +191 -0
  93. package/src/backend/middleware/bearer-auth.ts +70 -0
  94. package/src/backend/middleware/body-limit.ts +212 -0
  95. package/src/backend/middleware/cache.ts +429 -0
  96. package/src/backend/middleware/csrf.ts +130 -0
  97. package/src/backend/middleware/error-handler.ts +77 -0
  98. package/src/backend/middleware/rate-limit.ts +308 -0
  99. package/src/backend/public.ts +21 -0
  100. package/src/backend/routes/account-teardown.ts +430 -0
  101. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +354 -0
  102. package/src/backend/routes/activitypub/handlers/inbound-timestamp.ts +29 -0
  103. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1634 -0
  104. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +547 -0
  105. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +497 -0
  106. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +262 -0
  107. package/src/backend/routes/activitypub/handlers/user-inbox-handlers.ts +35 -0
  108. package/src/backend/routes/activitypub/inbox-types.ts +74 -0
  109. package/src/backend/routes/activitypub/inbox.ts +1191 -0
  110. package/src/backend/routes/activitypub/outbox.ts +0 -0
  111. package/src/backend/routes/activitypub/query-helpers.ts +227 -0
  112. package/src/backend/routes/activitypub.ts +616 -0
  113. package/src/backend/routes/actors-helpers.ts +487 -0
  114. package/src/backend/routes/actors.ts +1311 -0
  115. package/src/backend/routes/apps.ts +313 -0
  116. package/src/backend/routes/auth-helpers.ts +566 -0
  117. package/src/backend/routes/auth.ts +615 -0
  118. package/src/backend/routes/communities/membership-invites.ts +208 -0
  119. package/src/backend/routes/communities/membership-join.ts +335 -0
  120. package/src/backend/routes/communities/membership-members.ts +539 -0
  121. package/src/backend/routes/communities/membership-requests.ts +296 -0
  122. package/src/backend/routes/communities/membership-shared.ts +364 -0
  123. package/src/backend/routes/communities/messages.ts +479 -0
  124. package/src/backend/routes/communities/routes.ts +624 -0
  125. package/src/backend/routes/communities.ts +21 -0
  126. package/src/backend/routes/dm/contacts.ts +525 -0
  127. package/src/backend/routes/dm/conversations-helpers.ts +197 -0
  128. package/src/backend/routes/dm/conversations.ts +25 -0
  129. package/src/backend/routes/dm/messages.ts +658 -0
  130. package/src/backend/routes/dm/query-helpers.ts +85 -0
  131. package/src/backend/routes/dm/read-archive.ts +228 -0
  132. package/src/backend/routes/dm/requests.ts +222 -0
  133. package/src/backend/routes/dm/typing.ts +81 -0
  134. package/src/backend/routes/dm.ts +15 -0
  135. package/src/backend/routes/follow-helpers.ts +370 -0
  136. package/src/backend/routes/follow.ts +588 -0
  137. package/src/backend/routes/media.ts +692 -0
  138. package/src/backend/routes/mobile.ts +159 -0
  139. package/src/backend/routes/moderation.ts +373 -0
  140. package/src/backend/routes/notifications.ts +757 -0
  141. package/src/backend/routes/posts/delete-cascade.ts +330 -0
  142. package/src/backend/routes/posts/interactions.ts +795 -0
  143. package/src/backend/routes/posts/post-helpers.ts +847 -0
  144. package/src/backend/routes/posts/queries.ts +537 -0
  145. package/src/backend/routes/posts/routes.ts +865 -0
  146. package/src/backend/routes/posts/transformers.ts +161 -0
  147. package/src/backend/routes/posts.ts +17 -0
  148. package/src/backend/routes/recommendations.ts +88 -0
  149. package/src/backend/routes/search.ts +730 -0
  150. package/src/backend/routes/stories/interactions.ts +576 -0
  151. package/src/backend/routes/stories/query-helpers.ts +482 -0
  152. package/src/backend/routes/stories/routes.ts +906 -0
  153. package/src/backend/routes/stories.ts +13 -0
  154. package/src/backend/routes/takos-tools/dm.ts +249 -0
  155. package/src/backend/routes/takos-tools/follows.ts +225 -0
  156. package/src/backend/routes/takos-tools/posts.ts +292 -0
  157. package/src/backend/routes/takos-tools/search.ts +228 -0
  158. package/src/backend/routes/takos-tools/timeline.ts +132 -0
  159. package/src/backend/routes/takos-tools/types.ts +10 -0
  160. package/src/backend/routes/takos-tools-response.ts +178 -0
  161. package/src/backend/routes/takos-tools.ts +153 -0
  162. package/src/backend/routes/timeline.ts +755 -0
  163. package/src/backend/runtime/bun.ts +620 -0
  164. package/src/backend/runtime/cloudflare.ts +202 -0
  165. package/src/backend/runtime/compat-bun/types.ts +44 -0
  166. package/src/backend/runtime/memory-kv.ts +104 -0
  167. package/src/backend/runtime/shared.ts +142 -0
  168. package/src/backend/runtime/types.ts +205 -0
  169. package/src/backend/server.ts +636 -0
  170. package/src/backend/types.ts +143 -0
  171. package/src/db/index.ts +97 -0
  172. package/src/db/schema/actors.ts +129 -0
  173. package/src/db/schema/communities.ts +133 -0
  174. package/src/db/schema/date-utils.ts +17 -0
  175. package/src/db/schema/index.ts +17 -0
  176. package/src/db/schema/messaging.ts +241 -0
  177. package/src/db/schema/mobile.ts +37 -0
  178. package/src/db/schema/posts.ts +150 -0
  179. package/src/db/schema/relations.ts +266 -0
  180. package/src/db/schema/reports.ts +33 -0
  181. package/src/db/schema/social.ts +106 -0
  182. package/src/db/schema/stories.ts +70 -0
  183. package/src/db/schema.ts +15 -0
  184. package/src/plugin/public.ts +7 -0
  185. package/src/runtime/site-worker.ts +10 -0
@@ -0,0 +1,576 @@
1
+ /**
2
+ * Core queue management - message builders, public enqueue entry points,
3
+ * and the batch handler that dispatches to sub-modules.
4
+ */
5
+
6
+ import type { Message, MessageBatch, Queue } from "@cloudflare/workers-types";
7
+ import type { Env } from "../../types.ts";
8
+ import type { Database } from "../../../db/index.ts";
9
+ import { and, eq, notInArray, or, sql } from "drizzle-orm";
10
+ import { actorCache, deliveryQueue } from "../../../db/index.ts";
11
+ import { isSafeRemoteUrl } from "../../federation-helpers.ts";
12
+ import {
13
+ DELIVERY_QUEUE_MESSAGE_VERSION,
14
+ type DeliveryDeliverEndpointMessageV1,
15
+ type DeliveryDlqMessageV1,
16
+ type DeliveryQueueMessageV1,
17
+ isDeliveryDlqMessageV1,
18
+ isDeliveryQueueMessageV1,
19
+ } from "./types.ts";
20
+ import { computeDeliveryJobId, safeEndpointHost } from "./transformers.ts";
21
+ import { emitMetric } from "./metrics.ts";
22
+ import { logger } from "../logger.ts";
23
+ import { filterBlockedActorApIds, isActorBlocked } from "../blocklist.ts";
24
+
25
+ const log = logger.child({ component: "delivery.queue" });
26
+
27
+ // Without DELIVERY_QUEUE/DELIVERY_DLQ bindings, enqueued activities persist in
28
+ // the DB but never federate. That used to be a silent no-op; surface it as a
29
+ // structured error/metric on first occurrence so it is observable. Reset only
30
+ // once a successful enqueue happens again (so a later misconfiguration re-fires).
31
+ let producerUnavailableReported = false;
32
+
33
+ function reportProducerUnavailable(op: string): void {
34
+ if (producerUnavailableReported) return;
35
+ producerUnavailableReported = true;
36
+ log.error("Delivery queue producer unavailable; activity will not federate", {
37
+ event: "delivery.queue.producer_unavailable",
38
+ op,
39
+ });
40
+ emitMetric("delivery.queue.producer_unavailable", 1, { op });
41
+ }
42
+
43
+ function assertNever(x: never): never {
44
+ throw new Error(
45
+ `Unhandled delivery queue message type: ${JSON.stringify(x)}`,
46
+ );
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Concurrency primitives
51
+ // ---------------------------------------------------------------------------
52
+
53
+ const BULKHEAD_PER_DOMAIN = 3;
54
+ const BULKHEAD_GLOBAL_CONCURRENCY = 10;
55
+
56
+ class Semaphore {
57
+ private available: number;
58
+ private waiters: Array<() => void> = [];
59
+
60
+ constructor(limit: number) {
61
+ this.available = limit;
62
+ }
63
+
64
+ async acquire(): Promise<void> {
65
+ if (this.available > 0) {
66
+ this.available -= 1;
67
+ return;
68
+ }
69
+ await new Promise<void>((resolve) => this.waiters.push(resolve));
70
+ this.available -= 1;
71
+ }
72
+
73
+ release(): void {
74
+ this.available += 1;
75
+ const next = this.waiters.shift();
76
+ if (next) next();
77
+ }
78
+ }
79
+
80
+ export class Bulkhead {
81
+ private global: Semaphore;
82
+ private perHost = new Map<string, Semaphore>();
83
+
84
+ constructor(globalLimit: number, perHostLimit: number) {
85
+ this.global = new Semaphore(globalLimit);
86
+ this.perHostLimit = perHostLimit;
87
+ }
88
+
89
+ private perHostLimit: number;
90
+
91
+ async acquire(host: string): Promise<void> {
92
+ await this.global.acquire();
93
+ let sem = this.perHost.get(host);
94
+ if (!sem) {
95
+ sem = new Semaphore(this.perHostLimit);
96
+ this.perHost.set(host, sem);
97
+ }
98
+ await sem.acquire();
99
+ }
100
+
101
+ release(host: string): void {
102
+ const sem = this.perHost.get(host);
103
+ if (sem) sem.release();
104
+ this.global.release();
105
+ }
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Helpers
110
+ // ---------------------------------------------------------------------------
111
+
112
+ export function nowIso(): string {
113
+ return new Date().toISOString();
114
+ }
115
+
116
+ export type QueueEnv = Env & {
117
+ DELIVERY_QUEUE: Queue<DeliveryQueueMessageV1>;
118
+ DELIVERY_DLQ: Queue<DeliveryDlqMessageV1>;
119
+ };
120
+
121
+ function queueAvailable(env: Env): env is QueueEnv {
122
+ return Boolean(env.DELIVERY_QUEUE) && Boolean(env.DELIVERY_DLQ);
123
+ }
124
+
125
+ export function requireQueue(
126
+ env: Env,
127
+ label: string,
128
+ message: Message<DeliveryQueueMessageV1>,
129
+ ): env is QueueEnv {
130
+ if (queueAvailable(env)) return true;
131
+ log.warn("Missing DELIVERY_QUEUE/DELIVERY_DLQ bindings; dropping job", {
132
+ event: "delivery.queue.bindings_missing",
133
+ label,
134
+ });
135
+ message.ack();
136
+ return false;
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Queue message builders & senders
141
+ // ---------------------------------------------------------------------------
142
+
143
+ export async function sendQueueMessage(
144
+ env: Env,
145
+ body: DeliveryQueueMessageV1,
146
+ delaySeconds?: number,
147
+ ): Promise<void> {
148
+ if (!queueAvailable(env)) {
149
+ reportProducerUnavailable("sendQueueMessage");
150
+ return;
151
+ }
152
+ producerUnavailableReported = false;
153
+ await env.DELIVERY_QUEUE.send(
154
+ body,
155
+ delaySeconds ? { delaySeconds } : undefined,
156
+ );
157
+ }
158
+
159
+ export async function sendDlqMessage(
160
+ env: Env,
161
+ payload: DeliveryDlqMessageV1,
162
+ ): Promise<void> {
163
+ if (!queueAvailable(env)) {
164
+ reportProducerUnavailable("sendDlqMessage");
165
+ return;
166
+ }
167
+ producerUnavailableReported = false;
168
+ await env.DELIVERY_DLQ.send(payload);
169
+ }
170
+
171
+ // Maximum reconcile cycles before a dead-lettered job is left terminally
172
+ // dead_letter. Each cycle revives the job (6h apart) for one more full retry
173
+ // series, so this bounds a permanently-dead endpoint instead of churning the
174
+ // queue forever. Exported so the DLQ consumer (handleDeliveryDlqBatch) and the
175
+ // reconcile worker (processReconcileJob) share one source of truth.
176
+ export const MAX_RECONCILE_ATTEMPTS = 5;
177
+
178
+ export function buildDeliverEndpointMessage(
179
+ jobId: string,
180
+ reconcileAttempt = 0,
181
+ ): DeliveryQueueMessageV1 {
182
+ return {
183
+ version: DELIVERY_QUEUE_MESSAGE_VERSION,
184
+ type: "deliver_endpoint",
185
+ jobId,
186
+ // Only stamp the field once a reconcile cycle has begun, so the initial
187
+ // delivery message stays byte-identical to before (treated as 0 on read).
188
+ ...(reconcileAttempt > 0 ? { reconcileAttempt } : {}),
189
+ scheduledAt: nowIso(),
190
+ };
191
+ }
192
+
193
+ export function buildResolveActorMessage(
194
+ activityId: string,
195
+ recipientActorApId: string,
196
+ attempts = 0,
197
+ ): DeliveryQueueMessageV1 {
198
+ return {
199
+ version: DELIVERY_QUEUE_MESSAGE_VERSION,
200
+ type: "resolve_actor",
201
+ activityId,
202
+ recipientActorApId,
203
+ ...(attempts > 0 ? { attempts } : {}),
204
+ scheduledAt: nowIso(),
205
+ };
206
+ }
207
+
208
+ export function buildReconcileJobMessage(
209
+ jobId: string,
210
+ reconcileAttempt: number,
211
+ ): DeliveryQueueMessageV1 {
212
+ return {
213
+ version: DELIVERY_QUEUE_MESSAGE_VERSION,
214
+ type: "reconcile_job",
215
+ jobId,
216
+ reconcileAttempt,
217
+ scheduledAt: nowIso(),
218
+ };
219
+ }
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // Job management
223
+ // ---------------------------------------------------------------------------
224
+
225
+ export async function upsertDeliveryJob(
226
+ db: Database,
227
+ jobId: string,
228
+ activityId: string,
229
+ endpoint: string,
230
+ ): Promise<void> {
231
+ await db
232
+ .insert(deliveryQueue)
233
+ .values({
234
+ id: jobId,
235
+ inboxUrl: endpoint,
236
+ activityApId: activityId,
237
+ attempts: 0,
238
+ nextAttemptAt: nowIso(),
239
+ status: "pending",
240
+ })
241
+ .onConflictDoNothing();
242
+
243
+ // Guard against overwriting in-flight or completed jobs.
244
+ await db
245
+ .update(deliveryQueue)
246
+ .set({
247
+ inboxUrl: endpoint,
248
+ activityApId: activityId,
249
+ })
250
+ .where(
251
+ and(
252
+ eq(deliveryQueue.id, jobId),
253
+ notInArray(deliveryQueue.status, ["processing", "delivered"]),
254
+ ),
255
+ );
256
+ }
257
+
258
+ export async function enqueueResolveForEndpointActors(
259
+ db: Database,
260
+ env: Env,
261
+ activityId: string,
262
+ endpoint: string,
263
+ ): Promise<number> {
264
+ if (!queueAvailable(env)) return 0;
265
+
266
+ const PAGE_SIZE = 200;
267
+ const SEND_BATCH_SIZE = 100;
268
+ const MAX_ACTORS = 2000;
269
+
270
+ let cursor: string | null = null;
271
+ let enqueued = 0;
272
+
273
+ while (enqueued < MAX_ACTORS) {
274
+ let query = db
275
+ .select({ apId: actorCache.apId })
276
+ .from(actorCache)
277
+ .where(
278
+ or(
279
+ eq(actorCache.sharedInbox, endpoint),
280
+ eq(actorCache.inbox, endpoint),
281
+ ),
282
+ )
283
+ .orderBy(actorCache.apId)
284
+ .limit(PAGE_SIZE);
285
+
286
+ if (cursor) {
287
+ query = db
288
+ .select({ apId: actorCache.apId })
289
+ .from(actorCache)
290
+ .where(
291
+ and(
292
+ or(
293
+ eq(actorCache.sharedInbox, endpoint),
294
+ eq(actorCache.inbox, endpoint),
295
+ ),
296
+ sql`${actorCache.apId} > ${cursor}`,
297
+ ),
298
+ )
299
+ .orderBy(actorCache.apId)
300
+ .limit(PAGE_SIZE);
301
+ }
302
+
303
+ const page = await query;
304
+
305
+ if (page.length === 0) break;
306
+
307
+ for (
308
+ let i = 0;
309
+ i < page.length && enqueued < MAX_ACTORS;
310
+ i += SEND_BATCH_SIZE
311
+ ) {
312
+ const candidates = page
313
+ .slice(i, i + SEND_BATCH_SIZE)
314
+ .map((r) => r.apId)
315
+ .filter((apId) => isSafeRemoteUrl(apId));
316
+
317
+ // Drop recipients the operator has defederated before re-enqueueing
318
+ // resolve_actor jobs (outbound blocklist enforcement). Batched (2 queries)
319
+ // rather than a serial isActorBlocked per candidate.
320
+ const blockedSet = await filterBlockedActorApIds(db, candidates);
321
+ const slice = candidates.filter((apId) => !blockedSet.has(apId));
322
+
323
+ if (slice.length === 0) continue;
324
+
325
+ const requests = slice.map((recipientApId) => ({
326
+ body: buildResolveActorMessage(activityId, recipientApId),
327
+ }));
328
+
329
+ await env.DELIVERY_QUEUE.sendBatch(requests);
330
+ enqueued += slice.length;
331
+ }
332
+
333
+ cursor = page[page.length - 1]?.apId ?? null;
334
+ if (page.length < PAGE_SIZE) break;
335
+ }
336
+
337
+ if (enqueued >= MAX_ACTORS) {
338
+ log.warn(
339
+ "Endpoint invalidation affected many actors; capped re-resolution enqueue",
340
+ {
341
+ event: "delivery.queue.reresolution_capped",
342
+ endpoint,
343
+ activityId,
344
+ enqueued,
345
+ max: MAX_ACTORS,
346
+ },
347
+ );
348
+ }
349
+
350
+ return enqueued;
351
+ }
352
+
353
+ // ---------------------------------------------------------------------------
354
+ // Public enqueue entry points
355
+ // ---------------------------------------------------------------------------
356
+
357
+ export async function enqueueFanoutToFollowers(
358
+ env: Env,
359
+ activityId: string,
360
+ followeeApId: string,
361
+ ): Promise<void> {
362
+ await sendQueueMessage(env, {
363
+ version: DELIVERY_QUEUE_MESSAGE_VERSION,
364
+ type: "fanout_followers",
365
+ activityId,
366
+ followeeApId,
367
+ scheduledAt: nowIso(),
368
+ });
369
+ }
370
+
371
+ export async function enqueueDeliveryToActor(
372
+ env: Env,
373
+ activityId: string,
374
+ recipientActorApId: string,
375
+ ): Promise<void> {
376
+ // Enforce the operator blocklist on the OUTBOUND single-actor path (DMs,
377
+ // Accept/Follow responses, targeted post/story interactions). A defederated
378
+ // domain/actor must never receive our activities, mirroring the inbound
379
+ // enforcement in the inbox handler. Best-effort: if the db read fails,
380
+ // isActorBlocked returns false (never black-hole on a transient error).
381
+ const db = (env as Partial<Env>).DB_INSTANCE;
382
+ if (db && (await isActorBlocked(db, recipientActorApId))) {
383
+ log.info("Skipping outbound delivery to blocked actor", {
384
+ event: "delivery.blocklist.actor_skip",
385
+ actor: recipientActorApId,
386
+ activityId,
387
+ });
388
+ emitMetric("delivery.blocklist.actor_skip", 1, {});
389
+ return;
390
+ }
391
+
392
+ await sendQueueMessage(
393
+ env,
394
+ buildResolveActorMessage(activityId, recipientActorApId),
395
+ );
396
+ }
397
+
398
+ /**
399
+ * Fan an activity out to a community's audience (members + community
400
+ * followers) instead of the author's personal follower graph. Used for
401
+ * community-scoped posts so reach == community.
402
+ */
403
+ export async function enqueueFanoutToCommunity(
404
+ env: Env,
405
+ activityId: string,
406
+ communityApId: string,
407
+ announceActivityId?: string,
408
+ ): Promise<void> {
409
+ await sendQueueMessage(env, {
410
+ version: DELIVERY_QUEUE_MESSAGE_VERSION,
411
+ type: "fanout_community",
412
+ activityId,
413
+ communityApId,
414
+ ...(announceActivityId ? { announceActivityId } : {}),
415
+ scheduledAt: nowIso(),
416
+ });
417
+ }
418
+
419
+ // ---------------------------------------------------------------------------
420
+ // Batch handlers (top-level entry points for queue consumers)
421
+ // ---------------------------------------------------------------------------
422
+
423
+ export async function handleDeliveryQueueBatch(
424
+ batch: MessageBatch<DeliveryQueueMessageV1>,
425
+ env: Env,
426
+ ): Promise<void> {
427
+ const db = env.DB_INSTANCE;
428
+ const bulkhead = new Bulkhead(
429
+ BULKHEAD_GLOBAL_CONCURRENCY,
430
+ BULKHEAD_PER_DOMAIN,
431
+ );
432
+
433
+ // Lazy import sub-modules to avoid circular dependencies at module level
434
+ const {
435
+ processFanoutFollowers,
436
+ processFanoutCommunity,
437
+ processResolveActor,
438
+ processReconcileJob,
439
+ runWithConcurrency,
440
+ } = await import("./queue-batching.ts");
441
+ const { processDeliverEndpoint } = await import("./queue-delivery.ts");
442
+
443
+ // Process non-delivery messages first (planning/resolution).
444
+ for (const message of batch.messages) {
445
+ const body = message.body;
446
+ if (!isDeliveryQueueMessageV1(body)) {
447
+ log.warn("Invalid delivery message format, skipping", {
448
+ event: "delivery.queue.invalid_message",
449
+ bodyPreview: JSON.stringify(body).slice(0, 200),
450
+ });
451
+ message.ack();
452
+ continue;
453
+ }
454
+
455
+ if (body.type === "deliver_endpoint") {
456
+ // handled later with concurrency
457
+ continue;
458
+ }
459
+
460
+ try {
461
+ switch (body.type) {
462
+ case "fanout_followers":
463
+ await processFanoutFollowers(db, env, body, message);
464
+ break;
465
+ case "fanout_community":
466
+ await processFanoutCommunity(db, env, body, message);
467
+ break;
468
+ case "resolve_actor":
469
+ await processResolveActor(db, env, body, message);
470
+ break;
471
+ case "reconcile_job":
472
+ await processReconcileJob(db, env, body, message);
473
+ break;
474
+ default:
475
+ assertNever(body);
476
+ }
477
+ } catch (e) {
478
+ log.error("Non-delivery message failed", {
479
+ event: "delivery.queue.non_delivery_failed",
480
+ messageType: body.type,
481
+ error: e,
482
+ });
483
+ message.retry({ delaySeconds: 60 });
484
+ }
485
+ }
486
+
487
+ // Deliver endpoint messages with bulkhead+concurrency.
488
+ const deliveryMessages = batch.messages.filter(
489
+ (m: Message<DeliveryQueueMessageV1>) =>
490
+ isDeliveryQueueMessageV1(m.body) && m.body.type === "deliver_endpoint",
491
+ ) as Array<Message<DeliveryQueueMessageV1>>;
492
+ await runWithConcurrency(
493
+ deliveryMessages,
494
+ BULKHEAD_GLOBAL_CONCURRENCY,
495
+ async (m: Message<DeliveryQueueMessageV1>) => {
496
+ try {
497
+ await processDeliverEndpoint(
498
+ db,
499
+ env,
500
+ m.body as DeliveryDeliverEndpointMessageV1,
501
+ m,
502
+ bulkhead,
503
+ );
504
+ } catch (e) {
505
+ const body = m.body as DeliveryDeliverEndpointMessageV1;
506
+ log.error("deliver_endpoint failed", {
507
+ event: "delivery.queue.deliver_endpoint_failed",
508
+ jobId: body?.jobId,
509
+ error: e,
510
+ });
511
+ m.retry({ delaySeconds: 60 });
512
+ }
513
+ },
514
+ );
515
+ }
516
+
517
+ export async function handleDeliveryDlqBatch(
518
+ batch: MessageBatch<DeliveryDlqMessageV1>,
519
+ env: Env,
520
+ ): Promise<void> {
521
+ for (const message of batch.messages) {
522
+ const body = message.body;
523
+ if (!isDeliveryDlqMessageV1(body)) {
524
+ log.warn("Invalid DLQ message format, skipping", {
525
+ event: "delivery.dlq.invalid_message",
526
+ bodyPreview: JSON.stringify(body).slice(0, 200),
527
+ });
528
+ message.ack();
529
+ continue;
530
+ }
531
+
532
+ // Structured log for alerting/monitoring.
533
+ log.error("Delivery job dead-lettered", {
534
+ event: "delivery.dlq.job_dead_lettered",
535
+ jobId: body.jobId,
536
+ activityId: body.activityId,
537
+ endpoint: body.endpoint,
538
+ attempts: body.attempts,
539
+ lastError: body.lastError,
540
+ deadLetteredAt: body.deadLetteredAt,
541
+ });
542
+
543
+ // Phase 3: periodic reconciliation (best-effort), BOUNDED. The job's
544
+ // reconcile-cycle count is carried in-band on the dead-lettered
545
+ // deliver_endpoint message (default 0 for the first dead-letter). Once it
546
+ // reaches the cap, stop reconciling and leave the job terminally dead_letter
547
+ // — otherwise a permanently-dead endpoint loops dead_letter -> reconcile ->
548
+ // dead_letter forever. The next cycle carries count+1.
549
+ const reconcileAttempt = body.reconcileAttempt ?? 0;
550
+ if (reconcileAttempt >= MAX_RECONCILE_ATTEMPTS) {
551
+ log.warn("Delivery job exhausted reconciliation budget; giving up", {
552
+ event: "delivery.dlq.reconciliation_exhausted",
553
+ jobId: body.jobId,
554
+ endpoint: body.endpoint,
555
+ reconcileAttempt,
556
+ });
557
+ message.ack();
558
+ continue;
559
+ }
560
+ try {
561
+ await sendQueueMessage(
562
+ env,
563
+ buildReconcileJobMessage(body.jobId, reconcileAttempt + 1),
564
+ 6 * 60 * 60,
565
+ );
566
+ } catch (e) {
567
+ log.warn("Failed to schedule DLQ reconciliation", {
568
+ event: "delivery.dlq.reconciliation_schedule_failed",
569
+ jobId: body.jobId,
570
+ error: e,
571
+ });
572
+ }
573
+
574
+ message.ack();
575
+ }
576
+ }
@@ -0,0 +1,56 @@
1
+ import { isSafeRemoteUrl } from "../../federation-helpers.ts";
2
+ import { bytesToHex } from "../hex.ts";
3
+
4
+ export const DELIVERY_ENDPOINT_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
5
+
6
+ // 1m, 2m, 4m, 8m, 16m, 32m, 64m, 128m (contract)
7
+ const BACKOFF_SERIES_SECONDS = [
8
+ 60, 120, 240, 480, 960, 1920, 3840, 7680,
9
+ ] as const;
10
+
11
+ export const DELIVERY_MAX_ATTEMPTS = BACKOFF_SERIES_SECONDS.length;
12
+
13
+ export async function sha256Hex(input: string): Promise<string> {
14
+ const data = new TextEncoder().encode(input);
15
+ const digest = await crypto.subtle.digest("SHA-256", data);
16
+ return bytesToHex(new Uint8Array(digest));
17
+ }
18
+
19
+ export async function computeDeliveryJobId(
20
+ activityId: string,
21
+ endpoint: string,
22
+ ): Promise<string> {
23
+ // Deterministic idempotency key: activityId + endpoint (+ attemptGroup if needed).
24
+ return sha256Hex(`${activityId}|${endpoint}`);
25
+ }
26
+
27
+ export function computeRetryDelaySeconds(nextAttempt: number): number {
28
+ // nextAttempt is 1-based: first retry => 1.
29
+ const idx = Math.max(
30
+ 0,
31
+ Math.min(nextAttempt - 1, BACKOFF_SERIES_SECONDS.length - 1),
32
+ );
33
+ const base = BACKOFF_SERIES_SECONDS[idx];
34
+ // jitter: +/- 20%
35
+ const jitterFactor = 0.8 + Math.random() * 0.4;
36
+ return Math.max(1, Math.round(base * jitterFactor));
37
+ }
38
+
39
+ export function safeParseIsoTimeMs(
40
+ value: string | null | undefined,
41
+ ): number | null {
42
+ if (!value) return null;
43
+ const ms = Date.parse(value);
44
+ return Number.isFinite(ms) ? ms : null;
45
+ }
46
+
47
+ export function safeEndpointHost(endpoint: string): string | null {
48
+ try {
49
+ const url = new URL(endpoint);
50
+ if (url.protocol !== "https:" && url.protocol !== "http:") return null;
51
+ if (!isSafeRemoteUrl(endpoint)) return null;
52
+ return url.host;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }