@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,641 @@
1
+ /**
2
+ * Delivery endpoint processing - handles the actual HTTP delivery of ActivityPub activities.
3
+ * Includes signing, circuit breaker checks, retry logic, and dead-letter handling.
4
+ */
5
+
6
+ import type { Message } from "@cloudflare/workers-types";
7
+ import type { Env } from "../../types.ts";
8
+ import type { Database } from "../../../db/index.ts";
9
+ import { and, eq, lt, notInArray, or, sql } from "drizzle-orm";
10
+ import {
11
+ activities,
12
+ actorCache,
13
+ actors,
14
+ communities,
15
+ deliveryQueue,
16
+ instanceActor,
17
+ } from "../../../db/index.ts";
18
+ import {
19
+ fetchWithTimeout,
20
+ isSafeRemoteUrl,
21
+ signRequest,
22
+ } from "../../federation-helpers.ts";
23
+ import { emitMetric } from "./metrics.ts";
24
+ import { logger } from "../logger.ts";
25
+ import {
26
+ checkCircuit,
27
+ recordCircuitFailure,
28
+ recordCircuitSuccess,
29
+ } from "./circuit.ts";
30
+
31
+ const log = logger.child({ component: "delivery.queue" });
32
+ import {
33
+ type DeliveryDeliverEndpointMessageV1,
34
+ type DeliveryQueueMessageV1,
35
+ } from "./types.ts";
36
+ import {
37
+ computeRetryDelaySeconds,
38
+ DELIVERY_MAX_ATTEMPTS,
39
+ safeEndpointHost,
40
+ safeParseIsoTimeMs,
41
+ } from "./transformers.ts";
42
+ import {
43
+ buildDeliverEndpointMessage,
44
+ type Bulkhead,
45
+ enqueueResolveForEndpointActors,
46
+ nowIso,
47
+ type QueueEnv,
48
+ requireQueue,
49
+ sendDlqMessage,
50
+ sendQueueMessage,
51
+ } from "./queue.ts";
52
+
53
+ const DELIVERY_HTTP_TIMEOUT_MS = 8000;
54
+ const STALE_PROCESSING_MS = 2 * 60 * 1000;
55
+ const EPOCH_ISO = "1970-01-01T00:00:00.000Z";
56
+
57
+ // 4xx statuses that are RETRYABLE rather than permanent (see isPermanentDeliveryFailure):
58
+ // 429 Too Many Requests, 408 Request Timeout, 425 Too Early.
59
+ // 401 Unauthorized: in secure-mode / authorized-fetch this is the remote FAILING
60
+ // to verify our HTTP signature, which is frequently TRANSIENT — it recovers
61
+ // once the remote (re-)fetches our actor #main-key (our origin was briefly
62
+ // down, mid key-rotation, the remote's key cache raced) or a brief Date/clock
63
+ // skew passes. A job is keyed by the SHARED INBOX endpoint, so treating this
64
+ // as permanent black-holed the activity for EVERY co-tenant recipient.
65
+ // 404 Not Found: a shared inbox returning 404 is usually a transient blip
66
+ // (deploy/restart/misroute). It also triggers endpoint re-resolution, and
67
+ // failing permanently here left the re-resolved job colliding on the same
68
+ // terminal jobId (silently dropped). Retrying lets the endpoint recover.
69
+ // 410 Gone (endpoint genuinely removed) and 400/403/422 (a remote per-activity /
70
+ // deliberate-relationship verdict, not endpoint health) stay PERMANENT.
71
+ export const TRANSIENT_DELIVERY_4XX: ReadonlySet<number> = new Set([
72
+ 401, 404, 408, 425, 429,
73
+ ]);
74
+
75
+ /**
76
+ * A delivery response status that should be PERMANENTLY failed (no retry): a 4xx
77
+ * that is not in the transient set. 5xx and transient 4xx are retried; a null
78
+ * status (network error) is not classified here.
79
+ */
80
+ export function isPermanentDeliveryFailure(status: number | null): boolean {
81
+ return (
82
+ status !== null &&
83
+ status >= 400 &&
84
+ status < 500 &&
85
+ !TRANSIENT_DELIVERY_4XX.has(status)
86
+ );
87
+ }
88
+
89
+ type TimedFetchResult = {
90
+ response: Response | null;
91
+ error: unknown;
92
+ latencyMs: number;
93
+ };
94
+
95
+ async function timedFetch(
96
+ url: string,
97
+ init: RequestInit & { timeout: number },
98
+ ): Promise<TimedFetchResult> {
99
+ const startedAt = Date.now();
100
+ let response: Response | null = null;
101
+ let error: unknown = null;
102
+ try {
103
+ response = await fetchWithTimeout(url, init);
104
+ } catch (e) {
105
+ error = e;
106
+ }
107
+ return { response, error, latencyMs: Date.now() - startedAt };
108
+ }
109
+
110
+ function buildErrorMessage(response: Response | null, error: unknown): string {
111
+ if (response) return `HTTP ${response.status}`;
112
+ if (error instanceof Error) return error.message;
113
+ return "delivery_error";
114
+ }
115
+
116
+ function parseCommaSeparated(value: string | undefined): string[] {
117
+ if (!value) return [];
118
+ return value
119
+ .split(",")
120
+ .map((v) => v.trim())
121
+ .filter(Boolean);
122
+ }
123
+
124
+ function parseSampleRate(value: string | undefined): number {
125
+ if (!value) return 1.0;
126
+ const n = Number(value);
127
+ if (!Number.isFinite(n)) return 1.0;
128
+ return Math.max(0, Math.min(1, n));
129
+ }
130
+
131
+ async function resolveSigningActor(
132
+ db: Database,
133
+ actorApId: string,
134
+ ): Promise<{ apId: string; privateKeyPem: string } | null> {
135
+ // Check actors table. This MUST resolve tombstoned (soft-deleted) actors too:
136
+ // when an account is deleted, the outbound Delete(actor) deliver_endpoint jobs
137
+ // are snapshotted before teardown but drain afterwards, and the actor row is
138
+ // tombstoned (deletedAt set) rather than hard-deleted precisely so its private
139
+ // key survives for signing here. So this lookup deliberately does NOT filter
140
+ // on `deletedAt`.
141
+ const actorRow = await db
142
+ .select({
143
+ apId: actors.apId,
144
+ privateKeyPem: actors.privateKeyPem,
145
+ })
146
+ .from(actors)
147
+ .where(eq(actors.apId, actorApId))
148
+ .get();
149
+ if (actorRow?.privateKeyPem) {
150
+ return { apId: actorRow.apId, privateKeyPem: actorRow.privateKeyPem };
151
+ }
152
+
153
+ // Check communities table
154
+ const communityRow = await db
155
+ .select({
156
+ apId: communities.apId,
157
+ privateKeyPem: communities.privateKeyPem,
158
+ })
159
+ .from(communities)
160
+ .where(eq(communities.apId, actorApId))
161
+ .get();
162
+ if (communityRow?.privateKeyPem) {
163
+ return {
164
+ apId: communityRow.apId,
165
+ privateKeyPem: communityRow.privateKeyPem,
166
+ };
167
+ }
168
+
169
+ // Check instanceActor table
170
+ const instanceRow = await db
171
+ .select({
172
+ apId: instanceActor.apId,
173
+ privateKeyPem: instanceActor.privateKeyPem,
174
+ })
175
+ .from(instanceActor)
176
+ .where(eq(instanceActor.apId, actorApId))
177
+ .get();
178
+ if (instanceRow?.privateKeyPem) {
179
+ return { apId: instanceRow.apId, privateKeyPem: instanceRow.privateKeyPem };
180
+ }
181
+
182
+ return null;
183
+ }
184
+
185
+ async function failJob(
186
+ db: Database,
187
+ jobId: string,
188
+ error: string,
189
+ message: Message<DeliveryQueueMessageV1>,
190
+ ): Promise<void> {
191
+ await db
192
+ .update(deliveryQueue)
193
+ .set({
194
+ status: "failed",
195
+ error,
196
+ lastAttemptAt: nowIso(),
197
+ processingStartedAt: null,
198
+ })
199
+ .where(eq(deliveryQueue.id, jobId));
200
+ message.ack();
201
+ }
202
+
203
+ async function incrementDeliveryAttempts(
204
+ db: Database,
205
+ jobId: string,
206
+ ): Promise<number> {
207
+ await db
208
+ .update(deliveryQueue)
209
+ .set({ attempts: sql`${deliveryQueue.attempts} + 1` })
210
+ .where(eq(deliveryQueue.id, jobId));
211
+ const updated = await db
212
+ .select({ attempts: deliveryQueue.attempts })
213
+ .from(deliveryQueue)
214
+ .where(eq(deliveryQueue.id, jobId))
215
+ .get();
216
+ return updated?.attempts ?? 0;
217
+ }
218
+
219
+ async function maybeShadowProbeInbox(
220
+ db: Database,
221
+ env: Env,
222
+ params: {
223
+ activityId: string;
224
+ sharedInboxEndpoint: string;
225
+ endpointHost: string;
226
+ sender: { apId: string; privateKeyPem: string };
227
+ body: string;
228
+ },
229
+ ): Promise<void> {
230
+ const allowedHosts = parseCommaSeparated(env.DELIVERY_SHADOW_PROBE_HOSTS);
231
+ if (allowedHosts.length === 0) return;
232
+ if (!allowedHosts.includes(params.endpointHost)) return;
233
+
234
+ const sampleRate = parseSampleRate(env.DELIVERY_SHADOW_PROBE_SAMPLE_RATE);
235
+ if (sampleRate <= 0) return;
236
+ if (sampleRate < 1 && Math.random() > sampleRate) return;
237
+
238
+ const rep = await db
239
+ .select({
240
+ apId: actorCache.apId,
241
+ inbox: actorCache.inbox,
242
+ })
243
+ .from(actorCache)
244
+ .where(eq(actorCache.sharedInbox, params.sharedInboxEndpoint))
245
+ .limit(1)
246
+ .get();
247
+ const inbox = rep?.inbox;
248
+ if (!inbox || !isSafeRemoteUrl(inbox)) return;
249
+
250
+ const inboxHost = safeEndpointHost(inbox);
251
+ const keyId = `${params.sender.apId}#main-key`;
252
+ const headers = await signRequest(
253
+ params.sender.privateKeyPem,
254
+ keyId,
255
+ "POST",
256
+ inbox,
257
+ params.body,
258
+ );
259
+
260
+ const { response, error, latencyMs } = await timedFetch(inbox, {
261
+ method: "POST",
262
+ headers: {
263
+ ...headers,
264
+ "Content-Type": "application/activity+json",
265
+ "X-Yurucommu-Shadow-Probe": "1",
266
+ },
267
+ body: params.body,
268
+ timeout: DELIVERY_HTTP_TIMEOUT_MS,
269
+ });
270
+
271
+ emitMetric("delivery_shadow_probe_inbox_latency_ms", latencyMs, {
272
+ endpoint_host: params.endpointHost,
273
+ inbox_host: inboxHost ?? "unknown",
274
+ ok: response?.ok ?? false,
275
+ status: response?.status ?? null,
276
+ });
277
+ emitMetric("delivery_shadow_probe_inbox_ok", response?.ok ? 1 : 0, {
278
+ endpoint_host: params.endpointHost,
279
+ inbox_host: inboxHost ?? "unknown",
280
+ status: response?.status ?? null,
281
+ error: error instanceof Error ? error.message : null,
282
+ });
283
+ }
284
+
285
+ export async function processDeliverEndpoint(
286
+ db: Database,
287
+ env: Env,
288
+ msg: DeliveryDeliverEndpointMessageV1,
289
+ message: Message<DeliveryQueueMessageV1>,
290
+ bulkhead: Bulkhead,
291
+ ): Promise<void> {
292
+ if (!requireQueue(env, "deliver_endpoint", message)) return;
293
+
294
+ const job = await db
295
+ .select({
296
+ id: deliveryQueue.id,
297
+ activityApId: deliveryQueue.activityApId,
298
+ inboxUrl: deliveryQueue.inboxUrl,
299
+ attempts: deliveryQueue.attempts,
300
+ status: deliveryQueue.status,
301
+ nextAttemptAt: deliveryQueue.nextAttemptAt,
302
+ processingStartedAt: deliveryQueue.processingStartedAt,
303
+ })
304
+ .from(deliveryQueue)
305
+ .where(eq(deliveryQueue.id, msg.jobId))
306
+ .get();
307
+
308
+ const TERMINAL_JOB_STATUSES: readonly string[] = [
309
+ "delivered",
310
+ "dead_letter",
311
+ "failed",
312
+ ];
313
+
314
+ if (!job || TERMINAL_JOB_STATUSES.includes(job.status)) {
315
+ message.ack();
316
+ return;
317
+ }
318
+
319
+ // Queue lag metric
320
+ const scheduledMs = safeParseIsoTimeMs(msg.scheduledAt);
321
+ if (scheduledMs !== null) {
322
+ emitMetric(
323
+ "delivery_queue_lag_seconds",
324
+ Math.max(0, (Date.now() - scheduledMs) / 1000),
325
+ {
326
+ endpoint_host: safeEndpointHost(job.inboxUrl) ?? "unknown",
327
+ },
328
+ );
329
+ }
330
+
331
+ // Respect job scheduling stored in DB
332
+ const nextAttemptMs = safeParseIsoTimeMs(job.nextAttemptAt);
333
+ if (nextAttemptMs !== null && Date.now() < nextAttemptMs) {
334
+ const deferSeconds = Math.max(
335
+ 1,
336
+ Math.ceil((nextAttemptMs - Date.now()) / 1000),
337
+ );
338
+ await sendQueueMessage(
339
+ env,
340
+ // Preserve the reconcile-cycle count across the re-enqueue — dropping it
341
+ // resets the budget to 0 every cycle, so a permanently-dead endpoint
342
+ // would reconcile forever (#6).
343
+ buildDeliverEndpointMessage(job.id, msg.reconcileAttempt ?? 0),
344
+ deferSeconds,
345
+ );
346
+ message.ack();
347
+ return;
348
+ }
349
+
350
+ if (job.status === "processing") {
351
+ const startedMs = safeParseIsoTimeMs(job.processingStartedAt);
352
+ if (startedMs !== null && Date.now() - startedMs < STALE_PROCESSING_MS) {
353
+ await sendQueueMessage(
354
+ env,
355
+ buildDeliverEndpointMessage(job.id, msg.reconcileAttempt ?? 0),
356
+ 30,
357
+ );
358
+ message.ack();
359
+ return;
360
+ }
361
+ }
362
+
363
+ const endpoint = job.inboxUrl;
364
+ const host = safeEndpointHost(endpoint);
365
+ if (!host) {
366
+ await failJob(db, job.id, "invalid_endpoint", message);
367
+ return;
368
+ }
369
+
370
+ // Circuit breaker
371
+ const circuit = await checkCircuit(db, endpoint);
372
+ if (!circuit.allow) {
373
+ emitMetric("delivery_circuit_open_count", 1, { endpoint_host: host });
374
+ await sendQueueMessage(
375
+ env,
376
+ buildDeliverEndpointMessage(job.id, msg.reconcileAttempt ?? 0),
377
+ circuit.deferSeconds,
378
+ );
379
+ message.ack();
380
+ return;
381
+ }
382
+
383
+ await bulkhead.acquire(host);
384
+ try {
385
+ // Mark processing — VERIFIED CAS. Cloudflare Queues is at-least-once and the
386
+ // SELECT above ran BEFORE bulkhead.acquire (the bulkhead is keyed by host,
387
+ // not jobId), so two runners can hold the same jobId concurrently and both
388
+ // reach here. Claim only if the row is still claimable: a non-processing,
389
+ // non-terminal status, OR a STALE 'processing' row (a crashed runner — the
390
+ // same condition the pre-acquire stale check at :316 lets fall through). If
391
+ // our UPDATE changes 0 rows another live runner already owns the job → ack
392
+ // without re-enqueue so the signed activity is POSTed exactly once.
393
+ const staleThresholdIso = new Date(
394
+ Date.now() - STALE_PROCESSING_MS,
395
+ ).toISOString();
396
+ const claim = await db
397
+ .update(deliveryQueue)
398
+ .set({
399
+ status: "processing",
400
+ processingStartedAt: nowIso(),
401
+ })
402
+ .where(
403
+ and(
404
+ eq(deliveryQueue.id, job.id),
405
+ or(
406
+ notInArray(deliveryQueue.status, [
407
+ "processing",
408
+ ...TERMINAL_JOB_STATUSES,
409
+ ]),
410
+ and(
411
+ eq(deliveryQueue.status, "processing"),
412
+ lt(deliveryQueue.processingStartedAt, staleThresholdIso),
413
+ ),
414
+ ),
415
+ ),
416
+ );
417
+ if (((claim as { meta?: { changes?: number } }).meta?.changes ?? 0) === 0) {
418
+ message.ack();
419
+ return;
420
+ }
421
+
422
+ const activity = await db
423
+ .select({
424
+ rawJson: activities.rawJson,
425
+ actorApId: activities.actorApId,
426
+ })
427
+ .from(activities)
428
+ .where(eq(activities.apId, job.activityApId))
429
+ .get();
430
+ if (!activity) {
431
+ await failJob(db, job.id, "activity_not_found", message);
432
+ return;
433
+ }
434
+
435
+ const sender = await resolveSigningActor(db, activity.actorApId);
436
+ if (!sender) {
437
+ await failJob(db, job.id, "signing_actor_not_found", message);
438
+ return;
439
+ }
440
+
441
+ const body = activity.rawJson;
442
+ const keyId = `${sender.apId}#main-key`;
443
+ const headers = await signRequest(
444
+ sender.privateKeyPem,
445
+ keyId,
446
+ "POST",
447
+ endpoint,
448
+ body,
449
+ );
450
+
451
+ const { response, error, latencyMs } = await timedFetch(endpoint, {
452
+ method: "POST",
453
+ headers: { ...headers, "Content-Type": "application/activity+json" },
454
+ body,
455
+ timeout: DELIVERY_HTTP_TIMEOUT_MS,
456
+ });
457
+
458
+ emitMetric("delivery_latency_ms", latencyMs, {
459
+ endpoint_host: host,
460
+ ok: response?.ok ?? false,
461
+ status: response?.status ?? null,
462
+ });
463
+
464
+ if (response?.ok) {
465
+ // The remote POST already succeeded. From here on the message MUST be
466
+ // acked even if the bookkeeping DB writes fail: re-running the batch
467
+ // would re-POST the same activity and produce a duplicate delivery. A
468
+ // stale 'processing' row left behind by a failed update is harmless
469
+ // (it gets reaped/retried only via reconcile, which re-checks state).
470
+ const now = nowIso();
471
+ try {
472
+ await db
473
+ .update(deliveryQueue)
474
+ .set({
475
+ status: "delivered",
476
+ deliveredAt: now,
477
+ error: null,
478
+ lastAttemptAt: now,
479
+ processingStartedAt: null,
480
+ })
481
+ .where(eq(deliveryQueue.id, job.id));
482
+ await recordCircuitSuccess(db, endpoint);
483
+ } catch (e) {
484
+ log.error("Post-delivery bookkeeping failed; acking to avoid re-POST", {
485
+ event: "delivery.deliver.post_success_update_failed",
486
+ jobId: job.id,
487
+ activityId: job.activityApId,
488
+ endpoint,
489
+ endpointHost: host,
490
+ error: e,
491
+ });
492
+ emitMetric("delivery_success", 1, {
493
+ endpoint_host: host,
494
+ bookkeeping_failed: true,
495
+ });
496
+ message.ack();
497
+ return;
498
+ }
499
+ emitMetric("delivery_success", 1, { endpoint_host: host });
500
+
501
+ // Shadow probe (staging-only)
502
+ if (job.attempts === 0) {
503
+ try {
504
+ await maybeShadowProbeInbox(db, env, {
505
+ activityId: job.activityApId,
506
+ sharedInboxEndpoint: endpoint,
507
+ endpointHost: host,
508
+ sender,
509
+ body,
510
+ });
511
+ } catch (e) {
512
+ log.warn("Shadow probe failed", {
513
+ event: "delivery.shadow_probe.failed",
514
+ activityId: job.activityApId,
515
+ endpoint,
516
+ endpointHost: host,
517
+ error: e,
518
+ });
519
+ }
520
+ }
521
+
522
+ message.ack();
523
+ return;
524
+ }
525
+
526
+ const status = response?.status ?? null;
527
+ const errorMessage = buildErrorMessage(response, error);
528
+
529
+ // 404/410: expire endpoint cache immediately
530
+ if (status === 404 || status === 410) {
531
+ try {
532
+ await enqueueResolveForEndpointActors(
533
+ db,
534
+ env,
535
+ job.activityApId,
536
+ endpoint,
537
+ );
538
+ await db
539
+ .update(actorCache)
540
+ .set({ sharedInbox: null, lastFetchedAt: EPOCH_ISO })
541
+ .where(eq(actorCache.sharedInbox, endpoint));
542
+ await db
543
+ .update(actorCache)
544
+ .set({ lastFetchedAt: EPOCH_ISO })
545
+ .where(eq(actorCache.inbox, endpoint));
546
+ } catch (e) {
547
+ log.warn("Endpoint invalidation failed", {
548
+ event: "delivery.endpoint.invalidation_failed",
549
+ endpoint,
550
+ endpointHost: host,
551
+ status,
552
+ error: e,
553
+ });
554
+ }
555
+ }
556
+
557
+ // Permanent 4xx => fail; transient 4xx (TRANSIENT_DELIVERY_4XX) and 5xx are
558
+ // retried. See isPermanentDeliveryFailure for the classification rationale.
559
+ const nonRetryable = isPermanentDeliveryFailure(status);
560
+ if (nonRetryable) {
561
+ await failJob(db, job.id, errorMessage, message);
562
+ emitMetric("delivery_success", 0, {
563
+ endpoint_host: host,
564
+ non_retryable: true,
565
+ status,
566
+ });
567
+ // Do NOT trip the circuit on a permanent 4xx: a 400/403/410/422 is the
568
+ // remote's per-ACTIVITY verdict (malformed/forbidden/gone for THIS post),
569
+ // not a signal the endpoint is unhealthy. The circuit is keyed by the
570
+ // shared inbox, so counting per-activity rejections would let one bad
571
+ // activity (or one over-strict remote) open the breaker and block delivery
572
+ // of unrelated, valid activities to every co-located recipient. Only
573
+ // transient failures (below) reflect endpoint health.
574
+ return;
575
+ }
576
+
577
+ // Retryable failure — this DOES reflect endpoint health, so feed the circuit.
578
+ const nextAttempts = await incrementDeliveryAttempts(db, job.id);
579
+ await recordCircuitFailure(db, endpoint);
580
+
581
+ if (nextAttempts >= DELIVERY_MAX_ATTEMPTS) {
582
+ const now = nowIso();
583
+ await db
584
+ .update(deliveryQueue)
585
+ .set({
586
+ status: "dead_letter",
587
+ error: errorMessage,
588
+ lastAttemptAt: now,
589
+ processingStartedAt: null,
590
+ })
591
+ .where(eq(deliveryQueue.id, job.id));
592
+ emitMetric("delivery_dead_letter", 1, { endpoint_host: host });
593
+
594
+ await sendDlqMessage(env, {
595
+ version: 1,
596
+ type: "dlq",
597
+ jobId: job.id,
598
+ activityId: job.activityApId,
599
+ endpoint,
600
+ attempts: nextAttempts,
601
+ lastError: errorMessage,
602
+ // Carry the job's reconcile-cycle count so the DLQ consumer can advance
603
+ // (and ultimately terminate) the reconciliation budget.
604
+ reconcileAttempt: msg.reconcileAttempt ?? 0,
605
+ deadLetteredAt: now,
606
+ });
607
+
608
+ message.ack();
609
+ return;
610
+ }
611
+
612
+ const delaySeconds = computeRetryDelaySeconds(nextAttempts);
613
+ const nextAttemptAtStr = new Date(
614
+ Date.now() + delaySeconds * 1000,
615
+ ).toISOString();
616
+
617
+ await db
618
+ .update(deliveryQueue)
619
+ .set({
620
+ status: "retry_wait",
621
+ error: errorMessage,
622
+ lastAttemptAt: nowIso(),
623
+ processingStartedAt: null,
624
+ nextAttemptAt: nextAttemptAtStr,
625
+ })
626
+ .where(eq(deliveryQueue.id, job.id));
627
+
628
+ emitMetric("delivery_success", 0, {
629
+ endpoint_host: host,
630
+ status: status ?? null,
631
+ });
632
+ await sendQueueMessage(
633
+ env,
634
+ buildDeliverEndpointMessage(job.id, msg.reconcileAttempt ?? 0),
635
+ delaySeconds,
636
+ );
637
+ message.ack();
638
+ } finally {
639
+ bulkhead.release(host);
640
+ }
641
+ }