@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,626 @@
1
+ /**
2
+ * Queue batch processing - handles fanout, actor resolution, reconciliation,
3
+ * and batch dispatch of delivery messages.
4
+ */
5
+
6
+ import type { Message, MessageBatch } from "@cloudflare/workers-types";
7
+ import type { Env } from "../../types.ts";
8
+ import type { Database } from "../../../db/index.ts";
9
+ import { and, eq, or, sql } from "drizzle-orm";
10
+ import {
11
+ activities,
12
+ actorCache,
13
+ communityMembers,
14
+ deliveryQueue,
15
+ follows,
16
+ inbox as inboxTable,
17
+ } from "../../../db/index.ts";
18
+ import { isLocal, isSafeRemoteUrl } from "../../federation-helpers.ts";
19
+ import { isActorBlocked } from "../blocklist.ts";
20
+ import { planEndpointsFromActorCache } from "./planner.ts";
21
+ import {
22
+ fetchAndUpsertActorCache,
23
+ getInstanceFetchSignerByDb,
24
+ } from "../activitypub-actor-cache.ts";
25
+ import {
26
+ DELIVERY_QUEUE_MESSAGE_VERSION,
27
+ type DeliveryFanoutCommunityMessageV1,
28
+ type DeliveryFanoutFollowersMessageV1,
29
+ type DeliveryQueueMessageV1,
30
+ type DeliveryReconcileJobMessageV1,
31
+ type DeliveryResolveActorMessageV1,
32
+ } from "./types.ts";
33
+ import {
34
+ computeDeliveryJobId,
35
+ DELIVERY_ENDPOINT_CACHE_TTL_MS,
36
+ safeParseIsoTimeMs,
37
+ } from "./transformers.ts";
38
+ import {
39
+ buildDeliverEndpointMessage,
40
+ buildResolveActorMessage,
41
+ MAX_RECONCILE_ATTEMPTS,
42
+ nowIso,
43
+ type QueueEnv,
44
+ requireQueue,
45
+ sendQueueMessage,
46
+ upsertDeliveryJob,
47
+ } from "./queue.ts";
48
+ import { logger } from "../logger.ts";
49
+
50
+ const DELIVERY_HTTP_TIMEOUT_MS = 8000;
51
+ // Cap on resolve_actor self-requeues (each ~60s apart) before giving up on a
52
+ // permanently-unresolvable recipient — otherwise a dead host churns one queue
53
+ // message every 60s forever.
54
+ const MAX_RESOLVE_ATTEMPTS = 8;
55
+
56
+ const log = logger.child({ component: "delivery.batching" });
57
+
58
+ async function fetchAndCacheRemoteActor(
59
+ db: Database,
60
+ actorApId: string,
61
+ ): Promise<void> {
62
+ await fetchAndUpsertActorCache(db, actorApId, {
63
+ timeout: DELIVERY_HTTP_TIMEOUT_MS,
64
+ mode: "upsert",
65
+ // Sign as the instance actor so resolving a delivery target on a
66
+ // secure-mode instance doesn't 401 (unsigned otherwise).
67
+ signer: (await getInstanceFetchSignerByDb(db)) ?? undefined,
68
+ });
69
+ }
70
+
71
+ function resolvePreferredEndpoint(
72
+ row: { inbox: string | null; sharedInbox: string | null } | null,
73
+ ): string | null {
74
+ if (row?.sharedInbox && isSafeRemoteUrl(row.sharedInbox)) {
75
+ return row.sharedInbox;
76
+ }
77
+ if (row?.inbox && isSafeRemoteUrl(row.inbox)) return row.inbox;
78
+ return null;
79
+ }
80
+
81
+ // Fan-out is paginated and chunked so a single popular-actor delivery cannot
82
+ // (a) materialize an unbounded follower set in one Worker invocation, nor
83
+ // (b) exceed Cloudflare Queues' 100-messages-per-`sendBatch` limit (which
84
+ // would throw before `message.ack()` and retry-loop forever). Mirrors the
85
+ // page/chunk/cap pattern in `enqueueResolveForEndpointActors`.
86
+ const FANOUT_FOLLOWER_PAGE_SIZE = 200;
87
+ const FANOUT_SEND_BATCH_SIZE = 100;
88
+ const FANOUT_MAX_FOLLOWERS = 20_000;
89
+
90
+ async function sendQueueBatchChunked(
91
+ queue: QueueEnv["DELIVERY_QUEUE"],
92
+ requests: Array<{ body: DeliveryQueueMessageV1 }>,
93
+ ): Promise<void> {
94
+ for (let i = 0; i < requests.length; i += FANOUT_SEND_BATCH_SIZE) {
95
+ await queue.sendBatch(requests.slice(i, i + FANOUT_SEND_BATCH_SIZE));
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Page through the accepted-follower graph of `followeeApId`, plan each page's
101
+ * remote recipients against the actor cache, and enqueue `deliver_endpoint`
102
+ * (known endpoints) + `resolve_actor` (unknown recipients) jobs directly.
103
+ *
104
+ * This is the shared core of follower fan-out. It is deliberately decoupled
105
+ * from the queue message so it can also be driven SYNCHRONOUSLY by callers
106
+ * that must capture a follower snapshot before the `follows` rows are deleted
107
+ * (e.g. account deletion teardown in routes/actors.ts): the async
108
+ * `fanout_followers` consumer would otherwise read an already-emptied graph
109
+ * and deliver the Delete(actor) to zero followers.
110
+ *
111
+ * Returns the number of follower rows scanned and whether the per-invocation
112
+ * cap was hit (so callers can log the same capped warning).
113
+ */
114
+ export async function enqueueFollowerEndpointDeliveries(
115
+ db: Database,
116
+ queue: QueueEnv["DELIVERY_QUEUE"],
117
+ baseUrl: string,
118
+ activityId: string,
119
+ followeeApId: string,
120
+ ): Promise<{ processed: number; capped: boolean }> {
121
+ // Page through accepted followers with a keyset cursor instead of loading
122
+ // every row into memory at once. Each page is planned and dispatched in
123
+ // ≤100-message chunks before the next page is read, bounding both memory
124
+ // and per-call batch size.
125
+ let cursor: string | null = null;
126
+ let processed = 0;
127
+ let capped = false;
128
+
129
+ while (processed < FANOUT_MAX_FOLLOWERS) {
130
+ const conditions = [
131
+ eq(follows.followingApId, followeeApId),
132
+ eq(follows.status, "accepted"),
133
+ ];
134
+ if (cursor !== null) {
135
+ conditions.push(sql`${follows.followerApId} > ${cursor}`);
136
+ }
137
+
138
+ const page = await db
139
+ .select({ followerApId: follows.followerApId })
140
+ .from(follows)
141
+ .where(and(...conditions))
142
+ .orderBy(follows.followerApId)
143
+ .limit(FANOUT_FOLLOWER_PAGE_SIZE);
144
+
145
+ if (page.length === 0) break;
146
+
147
+ cursor = page[page.length - 1].followerApId;
148
+
149
+ // Deduplicate within the page and drop local recipients (no remote
150
+ // delivery needed for local followers).
151
+ const recipientApIds = [...new Set(page.map((f) => f.followerApId))].filter(
152
+ (apId) => !isLocal(apId, baseUrl),
153
+ );
154
+
155
+ if (recipientApIds.length > 0) {
156
+ const planned = await planEndpointsFromActorCache(db, recipientApIds, {
157
+ metricTags: {
158
+ followee: followeeApId,
159
+ activity: activityId,
160
+ },
161
+ });
162
+
163
+ const deliverRequests: Array<{ body: DeliveryQueueMessageV1 }> = [];
164
+ for (const group of planned.groups) {
165
+ const jobId = await computeDeliveryJobId(activityId, group.endpoint);
166
+ await upsertDeliveryJob(db, jobId, activityId, group.endpoint);
167
+ deliverRequests.push({ body: buildDeliverEndpointMessage(jobId) });
168
+ }
169
+
170
+ const resolveRequests = planned.unknownRecipients.map((apId) => ({
171
+ body: buildResolveActorMessage(activityId, apId),
172
+ }));
173
+
174
+ await sendQueueBatchChunked(queue, deliverRequests);
175
+ await sendQueueBatchChunked(queue, resolveRequests);
176
+ }
177
+
178
+ processed += page.length;
179
+
180
+ if (page.length < FANOUT_FOLLOWER_PAGE_SIZE) break;
181
+ if (processed >= FANOUT_MAX_FOLLOWERS) {
182
+ capped = true;
183
+ break;
184
+ }
185
+ }
186
+
187
+ return { processed, capped };
188
+ }
189
+
190
+ /**
191
+ * Synchronously snapshot an actor's follower inboxes and enqueue per-endpoint
192
+ * delivery jobs for `activityId`, BEFORE the caller deletes the `follows`
193
+ * rows. Use this from teardown paths (account deletion) where the async
194
+ * `fanout_followers` consumer would otherwise run after the follower graph is
195
+ * gone and reach zero remote followers.
196
+ *
197
+ * Best-effort and queue-aware: if the delivery queue bindings are missing it
198
+ * is a no-op (mirrors enqueueFanoutToFollowers' silent producer-unavailable
199
+ * behavior). Never throws into the caller's teardown transaction — the caller
200
+ * still wraps it so federation can never block local deletion.
201
+ */
202
+ export async function snapshotAndEnqueueFollowerDeliveries(
203
+ db: Database,
204
+ env: Env,
205
+ activityId: string,
206
+ followeeApId: string,
207
+ ): Promise<void> {
208
+ const queue = (env as Partial<QueueEnv>).DELIVERY_QUEUE;
209
+ if (!queue) {
210
+ log.warn("Delivery queue unavailable; follower snapshot delivery skipped", {
211
+ event: "delivery.fanout.snapshot_queue_unavailable",
212
+ followee: followeeApId,
213
+ activityId,
214
+ });
215
+ return;
216
+ }
217
+
218
+ const { processed, capped } = await enqueueFollowerEndpointDeliveries(
219
+ db,
220
+ queue,
221
+ env.APP_URL,
222
+ activityId,
223
+ followeeApId,
224
+ );
225
+
226
+ if (capped) {
227
+ log.warn("Follower snapshot delivery capped at max followers", {
228
+ event: "delivery.fanout.snapshot_capped",
229
+ followee: followeeApId,
230
+ activityId,
231
+ processed,
232
+ max: FANOUT_MAX_FOLLOWERS,
233
+ });
234
+ }
235
+ }
236
+
237
+ export async function processFanoutFollowers(
238
+ db: Database,
239
+ env: Env,
240
+ msg: DeliveryFanoutFollowersMessageV1,
241
+ message: Message<DeliveryQueueMessageV1>,
242
+ ): Promise<void> {
243
+ if (!requireQueue(env, "fanout", message)) return;
244
+ const queueEnv = env as QueueEnv;
245
+
246
+ const { processed, capped } = await enqueueFollowerEndpointDeliveries(
247
+ db,
248
+ queueEnv.DELIVERY_QUEUE,
249
+ env.APP_URL,
250
+ msg.activityId,
251
+ msg.followeeApId,
252
+ );
253
+
254
+ if (capped) {
255
+ // Extremely large follower sets are capped per invocation to keep the
256
+ // Worker within CPU/time limits. Endpoint-deduped delivery jobs are
257
+ // idempotent (computeDeliveryJobId + upsertDeliveryJob), and the planner
258
+ // re-enqueues any still-unknown recipients on the next fanout, so capped
259
+ // followers are re-planned on the actor's next delivery rather than lost
260
+ // silently.
261
+ log.warn("Fanout capped at max followers for one invocation", {
262
+ event: "delivery.fanout.capped",
263
+ followee: msg.followeeApId,
264
+ activityId: msg.activityId,
265
+ processed,
266
+ max: FANOUT_MAX_FOLLOWERS,
267
+ });
268
+ }
269
+
270
+ message.ack();
271
+ }
272
+
273
+ /**
274
+ * Fan an activity out to a community's audience instead of the author's
275
+ * personal follower graph. The community is a Group-style actor:
276
+ *
277
+ * - LOCAL recipients (accepted `communityMembers` hosted on this server,
278
+ * excluding the author) receive an inbox entry directly, so local members
279
+ * see the post even though it never touched the author's follower set.
280
+ * - REMOTE recipients (remote `communityMembers` plus accepted followers of
281
+ * the community actor in `follows`) are planned to their inbox/sharedInbox
282
+ * endpoints and delivered like a normal remote fan-out.
283
+ *
284
+ * This keeps reach == community: a community post is delivered to community
285
+ * members, never to the author's plain followers.
286
+ */
287
+ export async function processFanoutCommunity(
288
+ db: Database,
289
+ env: Env,
290
+ msg: DeliveryFanoutCommunityMessageV1,
291
+ message: Message<DeliveryQueueMessageV1>,
292
+ ): Promise<void> {
293
+ const baseUrl = env.APP_URL;
294
+
295
+ if (!requireQueue(env, "fanout_community", message)) return;
296
+ const queueEnv = env as QueueEnv;
297
+
298
+ // Resolve the activity's author so we never echo the post back into the
299
+ // author's own inbox.
300
+ const activityRow = await db
301
+ .select({ actorApId: activities.actorApId })
302
+ .from(activities)
303
+ .where(eq(activities.apId, msg.activityId))
304
+ .get();
305
+ const authorApId = activityRow?.actorApId ?? null;
306
+
307
+ // ----- 1. Local members: deliver to their inbox directly. ----------------
308
+ let memberCursor: string | null = null;
309
+ while (true) {
310
+ const conditions = [eq(communityMembers.communityApId, msg.communityApId)];
311
+ if (memberCursor !== null) {
312
+ conditions.push(sql`${communityMembers.actorApId} > ${memberCursor}`);
313
+ }
314
+ const page = await db
315
+ .select({ actorApId: communityMembers.actorApId })
316
+ .from(communityMembers)
317
+ .where(and(...conditions))
318
+ .orderBy(communityMembers.actorApId)
319
+ .limit(FANOUT_FOLLOWER_PAGE_SIZE);
320
+
321
+ if (page.length === 0) break;
322
+ memberCursor = page[page.length - 1].actorApId;
323
+
324
+ const localRecipients = page
325
+ .map((m) => m.actorApId)
326
+ .filter((apId) => isLocal(apId, baseUrl) && apId !== authorApId);
327
+
328
+ if (localRecipients.length > 0) {
329
+ const now = nowIso();
330
+ await db
331
+ .insert(inboxTable)
332
+ .values(
333
+ localRecipients.map((actorApId) => ({
334
+ actorApId,
335
+ activityApId: msg.activityId,
336
+ read: 0,
337
+ createdAt: now,
338
+ })),
339
+ )
340
+ .onConflictDoNothing();
341
+ }
342
+
343
+ if (page.length < FANOUT_FOLLOWER_PAGE_SIZE) break;
344
+ }
345
+
346
+ // ----- 2. Remote recipients: plan endpoints and deliver. -----------------
347
+ // Remote members of the community plus accepted followers of the community
348
+ // actor. A community member set is typically modest; followers of the
349
+ // community actor capture remote servers that follow the Group to receive
350
+ // its activities. Both are deduped before planning.
351
+ const remoteRecipients = new Set<string>();
352
+
353
+ // Remote community members.
354
+ {
355
+ let cursor: string | null = null;
356
+ let processed = 0;
357
+ while (processed < FANOUT_MAX_FOLLOWERS) {
358
+ const conditions = [
359
+ eq(communityMembers.communityApId, msg.communityApId),
360
+ ];
361
+ if (cursor !== null) {
362
+ conditions.push(sql`${communityMembers.actorApId} > ${cursor}`);
363
+ }
364
+ const page = await db
365
+ .select({ actorApId: communityMembers.actorApId })
366
+ .from(communityMembers)
367
+ .where(and(...conditions))
368
+ .orderBy(communityMembers.actorApId)
369
+ .limit(FANOUT_FOLLOWER_PAGE_SIZE);
370
+ if (page.length === 0) break;
371
+ cursor = page[page.length - 1].actorApId;
372
+ for (const m of page) {
373
+ if (!isLocal(m.actorApId, baseUrl) && m.actorApId !== authorApId) {
374
+ remoteRecipients.add(m.actorApId);
375
+ }
376
+ }
377
+ processed += page.length;
378
+ if (page.length < FANOUT_FOLLOWER_PAGE_SIZE) break;
379
+ }
380
+ }
381
+
382
+ // Accepted followers of the community actor.
383
+ {
384
+ let cursor: string | null = null;
385
+ let processed = 0;
386
+ while (processed < FANOUT_MAX_FOLLOWERS) {
387
+ const conditions = [
388
+ eq(follows.followingApId, msg.communityApId),
389
+ eq(follows.status, "accepted"),
390
+ ];
391
+ if (cursor !== null) {
392
+ conditions.push(sql`${follows.followerApId} > ${cursor}`);
393
+ }
394
+ const page = await db
395
+ .select({ followerApId: follows.followerApId })
396
+ .from(follows)
397
+ .where(and(...conditions))
398
+ .orderBy(follows.followerApId)
399
+ .limit(FANOUT_FOLLOWER_PAGE_SIZE);
400
+ if (page.length === 0) break;
401
+ cursor = page[page.length - 1].followerApId;
402
+ for (const f of page) {
403
+ if (
404
+ !isLocal(f.followerApId, baseUrl) &&
405
+ f.followerApId !== authorApId
406
+ ) {
407
+ remoteRecipients.add(f.followerApId);
408
+ }
409
+ }
410
+ processed += page.length;
411
+ if (page.length < FANOUT_FOLLOWER_PAGE_SIZE) break;
412
+ }
413
+ }
414
+
415
+ const remoteList = [...remoteRecipients];
416
+ if (remoteList.length > 0) {
417
+ // Announce-relay: remote followers receive the GROUP's Announce of the post
418
+ // (when present) rather than the raw author activity, so it is attributed
419
+ // to the community. Local members above kept the raw `activityId`.
420
+ const remoteActivityId = msg.announceActivityId ?? msg.activityId;
421
+ const planned = await planEndpointsFromActorCache(db, remoteList, {
422
+ metricTags: {
423
+ community: msg.communityApId,
424
+ activity: remoteActivityId,
425
+ },
426
+ });
427
+
428
+ const deliverRequests: Array<{ body: DeliveryQueueMessageV1 }> = [];
429
+ for (const group of planned.groups) {
430
+ const jobId = await computeDeliveryJobId(
431
+ remoteActivityId,
432
+ group.endpoint,
433
+ );
434
+ await upsertDeliveryJob(db, jobId, remoteActivityId, group.endpoint);
435
+ deliverRequests.push({ body: buildDeliverEndpointMessage(jobId) });
436
+ }
437
+
438
+ const resolveRequests = planned.unknownRecipients.map((apId) => ({
439
+ body: buildResolveActorMessage(remoteActivityId, apId),
440
+ }));
441
+
442
+ await sendQueueBatchChunked(queueEnv.DELIVERY_QUEUE, deliverRequests);
443
+ await sendQueueBatchChunked(queueEnv.DELIVERY_QUEUE, resolveRequests);
444
+ }
445
+
446
+ // TODO(remote-inbox-optimization): when the community has a large remote
447
+ // footprint, prefer delivering once to each remote server's shared inbox via
448
+ // the community's own followers collection rather than expanding the full
449
+ // member/follower set here. Local delivery and community audience/addressing
450
+ // are already correct; this is purely a remote fan-out efficiency follow-up.
451
+
452
+ message.ack();
453
+ }
454
+
455
+ export async function processResolveActor(
456
+ db: Database,
457
+ env: Env,
458
+ msg: DeliveryResolveActorMessageV1,
459
+ message: Message<DeliveryQueueMessageV1>,
460
+ ): Promise<void> {
461
+ if (!requireQueue(env, "resolve_actor", message)) return;
462
+
463
+ // Defense-in-depth: the fanout/enqueue side already drops blocked recipients
464
+ // via planEndpointsFromActorCache, but enforce the operator blocklist at the
465
+ // resolve seam too so a re-resolved actor (or a domain blocked after enqueue)
466
+ // never gets a delivery job. ACK silently — same posture as the inbox handler.
467
+ if (await isActorBlocked(db, msg.recipientActorApId)) {
468
+ message.ack();
469
+ return;
470
+ }
471
+
472
+ const cached = await db
473
+ .select({
474
+ apId: actorCache.apId,
475
+ inbox: actorCache.inbox,
476
+ sharedInbox: actorCache.sharedInbox,
477
+ lastFetchedAt: actorCache.lastFetchedAt,
478
+ })
479
+ .from(actorCache)
480
+ .where(eq(actorCache.apId, msg.recipientActorApId))
481
+ .get();
482
+ const lastFetchedMs = safeParseIsoTimeMs(cached?.lastFetchedAt ?? null);
483
+ const stale =
484
+ lastFetchedMs === null ||
485
+ Date.now() - lastFetchedMs > DELIVERY_ENDPOINT_CACHE_TTL_MS;
486
+ if (!cached || stale) {
487
+ try {
488
+ await fetchAndCacheRemoteActor(db, msg.recipientActorApId);
489
+ } catch (e) {
490
+ // Bound the retry: a permanently-unresolvable recipient (dead host,
491
+ // persistent 5xx, SSRF-blocked) must NOT re-enqueue a fresh resolve_actor
492
+ // every 60s forever. Give up after MAX_RESOLVE_ATTEMPTS so the activity is
493
+ // dropped for that recipient instead of churning the queue indefinitely.
494
+ const nextAttempt = (msg.attempts ?? 0) + 1;
495
+ if (nextAttempt > MAX_RESOLVE_ATTEMPTS) {
496
+ log.warn("resolve_actor giving up after max attempts", {
497
+ event: "delivery.resolve_actor.exhausted",
498
+ actor: msg.recipientActorApId,
499
+ activityId: msg.activityId,
500
+ attempts: nextAttempt,
501
+ error: e,
502
+ });
503
+ message.ack();
504
+ return;
505
+ }
506
+ log.warn("resolve_actor fetch failed", {
507
+ event: "delivery.resolve_actor.failed",
508
+ actor: msg.recipientActorApId,
509
+ activityId: msg.activityId,
510
+ attempt: nextAttempt,
511
+ error: e,
512
+ });
513
+ await sendQueueMessage(
514
+ env,
515
+ buildResolveActorMessage(
516
+ msg.activityId,
517
+ msg.recipientActorApId,
518
+ nextAttempt,
519
+ ),
520
+ 60,
521
+ );
522
+ message.ack();
523
+ return;
524
+ }
525
+ }
526
+
527
+ const row = await db
528
+ .select({
529
+ inbox: actorCache.inbox,
530
+ sharedInbox: actorCache.sharedInbox,
531
+ })
532
+ .from(actorCache)
533
+ .where(eq(actorCache.apId, msg.recipientActorApId))
534
+ .get();
535
+ const endpoint = resolvePreferredEndpoint(row ?? null);
536
+
537
+ if (!endpoint) {
538
+ log.warn("Could not resolve endpoint for actor", {
539
+ event: "delivery.endpoint.unresolved",
540
+ actor: msg.recipientActorApId,
541
+ activityId: msg.activityId,
542
+ });
543
+ message.ack();
544
+ return;
545
+ }
546
+
547
+ const jobId = await computeDeliveryJobId(msg.activityId, endpoint);
548
+ await upsertDeliveryJob(db, jobId, msg.activityId, endpoint);
549
+ await sendQueueMessage(env, buildDeliverEndpointMessage(jobId));
550
+ message.ack();
551
+ }
552
+
553
+ export async function processReconcileJob(
554
+ db: Database,
555
+ env: Env,
556
+ msg: DeliveryReconcileJobMessageV1,
557
+ message: Message<DeliveryQueueMessageV1>,
558
+ ): Promise<void> {
559
+ if (!requireQueue(env, "reconcile", message)) return;
560
+
561
+ if (msg.reconcileAttempt > MAX_RECONCILE_ATTEMPTS) {
562
+ message.ack();
563
+ return;
564
+ }
565
+
566
+ const job = await db
567
+ .select({
568
+ id: deliveryQueue.id,
569
+ status: deliveryQueue.status,
570
+ })
571
+ .from(deliveryQueue)
572
+ .where(eq(deliveryQueue.id, msg.jobId))
573
+ .get();
574
+
575
+ if (!job || job.status === "delivered") {
576
+ message.ack();
577
+ return;
578
+ }
579
+
580
+ await db
581
+ .update(deliveryQueue)
582
+ .set({
583
+ status: "pending",
584
+ // Reset the attempt budget: a reconciled (dead-lettered) job still carries
585
+ // attempts at its max, so without this its first retryable failure would
586
+ // immediately re-dead-letter it with no real retry budget.
587
+ attempts: 0,
588
+ error: null,
589
+ lastAttemptAt: null,
590
+ processingStartedAt: null,
591
+ nextAttemptAt: nowIso(),
592
+ })
593
+ .where(eq(deliveryQueue.id, msg.jobId));
594
+
595
+ // Carry the reconcile-cycle count forward on the revived delivery so that, if
596
+ // it dead-letters again, the next DLQ message advances the budget (and the
597
+ // loop terminates after MAX_RECONCILE_ATTEMPTS) instead of resetting to 1.
598
+ await sendQueueMessage(
599
+ env,
600
+ buildDeliverEndpointMessage(msg.jobId, msg.reconcileAttempt),
601
+ );
602
+ message.ack();
603
+ }
604
+
605
+ export async function runWithConcurrency<T>(
606
+ items: T[],
607
+ concurrency: number,
608
+ fn: (item: T) => Promise<void>,
609
+ ): Promise<void> {
610
+ const queue = items.slice();
611
+ const workers: Promise<void>[] = [];
612
+
613
+ for (let i = 0; i < concurrency; i++) {
614
+ workers.push(
615
+ (async () => {
616
+ while (queue.length > 0) {
617
+ const item = queue.shift();
618
+ if (!item) break;
619
+ await fn(item);
620
+ }
621
+ })(),
622
+ );
623
+ }
624
+
625
+ await Promise.all(workers);
626
+ }