@takosjp/yurucommu-core 3.4.3 → 3.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/migrations/0023_delivery_resolution_outbox.sql +33 -0
  2. package/migrations/0024_delivery_fanout_outbox.sql +35 -0
  3. package/migrations/0025_delivery_endpoint_terminal_retention.sql +6 -0
  4. package/migrations/0026_remote_actor_fetch_failures.sql +29 -0
  5. package/migrations/0027_remote_actor_tombstones.sql +12 -0
  6. package/migrations/0028_remote_actor_delivery_fence.sql +30 -0
  7. package/migrations/0029_delivery_endpoint_recipients.sql +47 -0
  8. package/package.json +2 -1
  9. package/packages/api/package.json +1 -1
  10. package/packages/api/src/lib/api/communities.ts +2 -0
  11. package/packages/api/src/lib/api/fetch.ts +69 -13
  12. package/packages/api/src/lib/api/normalize.ts +32 -10
  13. package/packages/api/src/lib/api/notifications.ts +4 -1
  14. package/packages/api/src/lib/rtc-client.ts +127 -29
  15. package/src/backend/federation-helpers.ts +1 -0
  16. package/src/backend/index.ts +16 -3
  17. package/src/backend/lib/account-migration.ts +340 -0
  18. package/src/backend/lib/activity-delete-cascade.ts +193 -0
  19. package/src/backend/lib/activitypub-actor-cache.ts +615 -48
  20. package/src/backend/lib/activitypub-actor-identity-sql.ts +179 -0
  21. package/src/backend/lib/activitypub-actor-identity.ts +39 -0
  22. package/src/backend/lib/activitypub-validators.ts +62 -4
  23. package/src/backend/lib/ap-ids.ts +36 -6
  24. package/src/backend/lib/ap-verify.ts +7 -17
  25. package/src/backend/lib/blocklist-purge.ts +676 -42
  26. package/src/backend/lib/blocklist.ts +278 -39
  27. package/src/backend/lib/community-visibility.ts +47 -33
  28. package/src/backend/lib/delivery/fanout-outbox.ts +336 -0
  29. package/src/backend/lib/delivery/planner.ts +16 -12
  30. package/src/backend/lib/delivery/queue-batching.ts +389 -145
  31. package/src/backend/lib/delivery/queue-delivery.ts +47 -25
  32. package/src/backend/lib/delivery/queue.ts +479 -73
  33. package/src/backend/lib/delivery/resolution-outbox.ts +456 -0
  34. package/src/backend/lib/delivery/types.ts +31 -7
  35. package/src/backend/lib/feed-exclude.ts +40 -28
  36. package/src/backend/lib/follow-edge-mutations.ts +217 -0
  37. package/src/backend/lib/notification-eligibility.ts +15 -9
  38. package/src/backend/lib/notification-push.ts +2 -2
  39. package/src/backend/lib/oidc-id-token.ts +66 -0
  40. package/src/backend/lib/personal-actor-moderation.ts +239 -0
  41. package/src/backend/lib/post-visibility.ts +79 -16
  42. package/src/backend/lib/remote-activity-id.ts +61 -0
  43. package/src/backend/lib/unread-counts.ts +3 -0
  44. package/src/backend/retention.ts +38 -1
  45. package/src/backend/routes/account-teardown.ts +422 -156
  46. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +104 -105
  47. package/src/backend/routes/activitypub/handlers/inbound-community-scope.ts +121 -0
  48. package/src/backend/routes/activitypub/handlers/inbound-object-identity.ts +54 -0
  49. package/src/backend/routes/activitypub/handlers/inbound-reply-target.ts +34 -0
  50. package/src/backend/routes/activitypub/handlers/inbound-story-projection.ts +438 -0
  51. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1121 -806
  52. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +118 -153
  53. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +185 -168
  54. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +220 -32
  55. package/src/backend/routes/activitypub/inbound-activity-identity.ts +16 -0
  56. package/src/backend/routes/activitypub/inbound-activity-reference.ts +116 -0
  57. package/src/backend/routes/activitypub/inbound-addressing.ts +87 -0
  58. package/src/backend/routes/activitypub/inbox-addressing.ts +22 -19
  59. package/src/backend/routes/activitypub/inbox-types.ts +14 -2
  60. package/src/backend/routes/activitypub/inbox.ts +81 -105
  61. package/src/backend/routes/activitypub/outbox.ts +0 -0
  62. package/src/backend/routes/activitypub.ts +6 -5
  63. package/src/backend/routes/actors-helpers.ts +34 -8
  64. package/src/backend/routes/actors.ts +328 -164
  65. package/src/backend/routes/auth-helpers.ts +57 -9
  66. package/src/backend/routes/auth.ts +10 -2
  67. package/src/backend/routes/communities/membership-invites.ts +4 -1
  68. package/src/backend/routes/communities/membership-members.ts +201 -73
  69. package/src/backend/routes/communities/membership-requests.ts +141 -76
  70. package/src/backend/routes/communities/membership-shared.ts +228 -21
  71. package/src/backend/routes/communities/messages.ts +9 -2
  72. package/src/backend/routes/communities/routes.ts +48 -13
  73. package/src/backend/routes/dm/contacts.ts +29 -41
  74. package/src/backend/routes/dm/conversations-helpers.ts +9 -1
  75. package/src/backend/routes/dm/messages.ts +36 -42
  76. package/src/backend/routes/dm/read-archive.ts +4 -2
  77. package/src/backend/routes/dm/requests.ts +62 -54
  78. package/src/backend/routes/follow-helpers.ts +200 -60
  79. package/src/backend/routes/follow.ts +146 -140
  80. package/src/backend/routes/media.ts +20 -94
  81. package/src/backend/routes/moderation.ts +72 -4
  82. package/src/backend/routes/notes.ts +3 -4
  83. package/src/backend/routes/notifications.ts +209 -108
  84. package/src/backend/routes/posts/delete-cascade.ts +253 -89
  85. package/src/backend/routes/posts/federation.ts +373 -0
  86. package/src/backend/routes/posts/interactions.ts +160 -184
  87. package/src/backend/routes/posts/like-mutation.ts +240 -0
  88. package/src/backend/routes/posts/post-helpers.ts +112 -162
  89. package/src/backend/routes/posts/queries.ts +150 -54
  90. package/src/backend/routes/posts/routes.ts +169 -329
  91. package/src/backend/routes/posts/transformers.ts +61 -6
  92. package/src/backend/routes/recommendations.ts +37 -44
  93. package/src/backend/routes/rtc/index.ts +26 -6
  94. package/src/backend/routes/search.ts +39 -55
  95. package/src/backend/routes/stories/interactions.ts +22 -6
  96. package/src/backend/routes/stories/query-helpers.ts +28 -41
  97. package/src/backend/routes/stories/routes.ts +125 -108
  98. package/src/backend/routes/takos-tools/dm.ts +17 -17
  99. package/src/backend/routes/takos-tools/posts.ts +189 -106
  100. package/src/backend/routes/takos-tools/search.ts +13 -4
  101. package/src/backend/routes/takos-tools/timeline.ts +35 -27
  102. package/src/backend/routes/takos-tools-response.ts +6 -2
  103. package/src/backend/routes/timeline.ts +5 -5
  104. package/src/backend/runtime/call-signaling-do.ts +35 -4
  105. package/src/backend/runtime/one-time-ticket.ts +116 -0
  106. package/src/backend/runtime/realtime-stream-do.ts +5 -61
  107. package/src/backend/runtime/signaling-hub.ts +36 -3
  108. package/src/backend/server.ts +95 -80
  109. package/src/db/d1-write.ts +67 -43
  110. package/src/db/schema/federation.ts +42 -1
  111. package/src/db/schema/messaging.ts +110 -2
  112. package/src/db/schema.ts +1 -1
@@ -14,8 +14,27 @@
14
14
  * one fetch/guard/upsert flow, so every cached actor row is now populated
15
15
  * identically regardless of entry path.
16
16
  */
17
- import { eq } from "drizzle-orm";
18
- import { actorCache } from "../../db/index.ts";
17
+ import {
18
+ and,
19
+ asc,
20
+ eq,
21
+ inArray,
22
+ isNull,
23
+ lt,
24
+ lte,
25
+ ne,
26
+ notExists,
27
+ or,
28
+ sql,
29
+ } from "drizzle-orm";
30
+ import {
31
+ actorCache,
32
+ affectedRowCount,
33
+ type D1Statement,
34
+ remoteActorFetchFailures,
35
+ remoteActorTombstones,
36
+ runBatch,
37
+ } from "../../db/index.ts";
19
38
  import type { Database } from "../../db/index.ts";
20
39
  import {
21
40
  fetchWithTimeout,
@@ -26,6 +45,7 @@ import {
26
45
  tryParseRemoteActor,
27
46
  type RemoteActorDocument,
28
47
  } from "./activitypub-validators.ts";
48
+ import { normalizeActivityPubActorId } from "./activitypub-actor-identity.ts";
29
49
 
30
50
  /**
31
51
  * The signing identity used to HTTP-sign an outbound actor GET so instances
@@ -115,11 +135,459 @@ export type ActorCacheFailureReason =
115
135
  | "invalid_document" // body did not parse as a remote actor
116
136
  | "id_mismatch" // returned `id` did not match the requested URL
117
137
  | "missing_inbox" // no inbox, or inbox/id failed the SSRF safety check
118
- | "missing_public_key"; // required public key absent (mode === "require-key")
138
+ | "missing_public_key" // required public key absent (mode === "require-key")
139
+ | "actor_tombstoned"; // verified Delete(Actor) is durable authority
119
140
 
120
141
  export type ActorCacheResult =
121
142
  | { ok: true; data: RemoteActorDocument; row: typeof actorCache.$inferSelect }
122
- | { ok: false; reason: ActorCacheFailureReason };
143
+ | {
144
+ ok: false;
145
+ reason: "fetch_not_ok";
146
+ status: number;
147
+ retryAfterSeconds: number | null;
148
+ }
149
+ | {
150
+ ok: false;
151
+ reason: Exclude<ActorCacheFailureReason, "fetch_not_ok">;
152
+ };
153
+
154
+ export type ActorCacheFailureResult = Extract<ActorCacheResult, { ok: false }>;
155
+
156
+ function canonicalRemoteActorTombstoneId(actorApId: string): string {
157
+ return normalizeActivityPubActorId(actorApId) ?? actorApId;
158
+ }
159
+
160
+ /** Return the durable self-Delete authority for one actor identity, if any. */
161
+ export async function getRemoteActorTombstone(
162
+ db: Database,
163
+ actorApId: string,
164
+ ): Promise<typeof remoteActorTombstones.$inferSelect | null> {
165
+ return (
166
+ (await db
167
+ .select()
168
+ .from(remoteActorTombstones)
169
+ .where(
170
+ eq(
171
+ remoteActorTombstones.actorApId,
172
+ canonicalRemoteActorTombstoneId(actorApId),
173
+ ),
174
+ )
175
+ .get()) ?? null
176
+ );
177
+ }
178
+
179
+ export async function isRemoteActorTombstoned(
180
+ db: Database,
181
+ actorApId: string,
182
+ ): Promise<boolean> {
183
+ return (await getRemoteActorTombstone(db, actorApId)) !== null;
184
+ }
185
+
186
+ export type RemoteActorFetchFailureKind = "gone" | "unavailable" | "invalid";
187
+
188
+ export interface RemoteActorFetchFailureState {
189
+ readonly actorApId: string;
190
+ readonly kind: RemoteActorFetchFailureKind;
191
+ readonly reason: ActorCacheFailureReason;
192
+ readonly httpStatus: number | null;
193
+ readonly failureCount: number;
194
+ readonly retryAt: string | null;
195
+ readonly retryAfterSeconds: number | null;
196
+ }
197
+
198
+ const TRANSIENT_BACKOFF_BASE_SECONDS = 30;
199
+ const INVALID_BACKOFF_BASE_SECONDS = 5 * 60;
200
+ const MAX_FETCH_BACKOFF_SECONDS = 60 * 60;
201
+ const MAX_REMOTE_RETRY_AFTER_SECONDS = 24 * 60 * 60;
202
+ const FETCH_FAILURE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
203
+ const FETCH_FAILURE_REAP_LIMIT = 50;
204
+ // The default remote GET timeout is 15s. Keep the claim alive for twice that
205
+ // window so request scheduling / D1 latency cannot let a peer steal it while
206
+ // the owner is still completing the bounded network call.
207
+ const REMOTE_ACTOR_FETCH_LEASE_MS = 30 * 1000;
208
+
209
+ function clampSeconds(seconds: number, max: number): number {
210
+ return Math.max(1, Math.min(max, Math.ceil(seconds)));
211
+ }
212
+
213
+ function parseRetryAfterSeconds(
214
+ value: string | null,
215
+ nowMs: number,
216
+ ): number | null {
217
+ if (!value) return null;
218
+ const numeric = Number(value);
219
+ if (Number.isFinite(numeric) && numeric >= 0) {
220
+ return clampSeconds(numeric, MAX_REMOTE_RETRY_AFTER_SECONDS);
221
+ }
222
+ const dateMs = Date.parse(value);
223
+ if (!Number.isFinite(dateMs) || dateMs <= nowMs) return null;
224
+ return clampSeconds((dateMs - nowMs) / 1000, MAX_REMOTE_RETRY_AFTER_SECONDS);
225
+ }
226
+
227
+ function classifyFetchFailure(
228
+ failure: ActorCacheFailureResult,
229
+ ): RemoteActorFetchFailureKind {
230
+ if (failure.reason === "fetch_not_ok" && failure.status === 410) {
231
+ return "gone";
232
+ }
233
+ if (failure.reason === "actor_tombstoned") return "gone";
234
+ if (
235
+ failure.reason === "invalid_document" ||
236
+ failure.reason === "id_mismatch" ||
237
+ failure.reason === "missing_inbox" ||
238
+ failure.reason === "missing_public_key"
239
+ ) {
240
+ return "invalid";
241
+ }
242
+ return "unavailable";
243
+ }
244
+
245
+ function failureBackoffSeconds(
246
+ kind: RemoteActorFetchFailureKind,
247
+ failureCount: number,
248
+ remoteRetryAfterSeconds: number | null,
249
+ ): number | null {
250
+ if (kind === "gone") return null;
251
+ const base =
252
+ kind === "invalid"
253
+ ? INVALID_BACKOFF_BASE_SECONDS
254
+ : TRANSIENT_BACKOFF_BASE_SECONDS;
255
+ const exponent = Math.min(Math.max(0, failureCount - 1), 8);
256
+ const exponential = Math.min(MAX_FETCH_BACKOFF_SECONDS, base * 2 ** exponent);
257
+ return Math.max(exponential, remoteRetryAfterSeconds ?? 0);
258
+ }
259
+
260
+ function stateFromRow(
261
+ row: typeof remoteActorFetchFailures.$inferSelect,
262
+ nowMs: number,
263
+ ): RemoteActorFetchFailureState {
264
+ const retryAtMs = row.retryAt ? Date.parse(row.retryAt) : Number.NaN;
265
+ const retryAfterSeconds =
266
+ row.kind === "gone" || !Number.isFinite(retryAtMs)
267
+ ? null
268
+ : clampSeconds(
269
+ (retryAtMs - nowMs) / 1000,
270
+ MAX_REMOTE_RETRY_AFTER_SECONDS,
271
+ );
272
+ return {
273
+ actorApId: row.actorApId,
274
+ kind: row.kind as RemoteActorFetchFailureKind,
275
+ reason: row.reason as ActorCacheFailureReason,
276
+ httpStatus: row.httpStatus,
277
+ failureCount: row.failureCount,
278
+ retryAt: row.retryAt,
279
+ retryAfterSeconds,
280
+ };
281
+ }
282
+
283
+ /**
284
+ * Return an active terminal/cooldown decision. An expired retry window returns
285
+ * null so the caller may make one new network attempt and update the ledger.
286
+ */
287
+ export async function getRemoteActorFetchFailure(
288
+ db: Database,
289
+ actorApId: string,
290
+ now: Date = new Date(),
291
+ ): Promise<RemoteActorFetchFailureState | null> {
292
+ const row = await db
293
+ .select()
294
+ .from(remoteActorFetchFailures)
295
+ .where(eq(remoteActorFetchFailures.actorApId, actorApId))
296
+ .get();
297
+ if (!row) return null;
298
+ if (row.kind === "gone") return stateFromRow(row, now.getTime());
299
+ const retryAtMs = row.retryAt ? Date.parse(row.retryAt) : Number.NaN;
300
+ if (!Number.isFinite(retryAtMs) || retryAtMs <= now.getTime()) return null;
301
+ return stateFromRow(row, now.getTime());
302
+ }
303
+
304
+ export type RemoteActorFetchClaim =
305
+ | { readonly owned: true; readonly token: string }
306
+ | {
307
+ readonly owned: false;
308
+ readonly failure: RemoteActorFetchFailureState;
309
+ };
310
+
311
+ function activeLeaseFailure(
312
+ row: typeof remoteActorFetchFailures.$inferSelect,
313
+ nowMs: number,
314
+ ): RemoteActorFetchFailureState {
315
+ const leaseExpiresAtMs = row.leaseExpiresAt
316
+ ? Date.parse(row.leaseExpiresAt)
317
+ : Number.NaN;
318
+ const retryAfterSeconds = Number.isFinite(leaseExpiresAtMs)
319
+ ? clampSeconds(
320
+ (leaseExpiresAtMs - nowMs) / 1000,
321
+ MAX_REMOTE_RETRY_AFTER_SECONDS,
322
+ )
323
+ : TRANSIENT_BACKOFF_BASE_SECONDS;
324
+ return {
325
+ actorApId: row.actorApId,
326
+ kind: "unavailable",
327
+ reason: "fetch_failed",
328
+ httpStatus: null,
329
+ failureCount: row.failureCount,
330
+ retryAt: Number.isFinite(leaseExpiresAtMs) ? row.leaseExpiresAt : null,
331
+ retryAfterSeconds,
332
+ };
333
+ }
334
+
335
+ function currentFailureOrLease(
336
+ row: typeof remoteActorFetchFailures.$inferSelect,
337
+ nowMs: number,
338
+ ): RemoteActorFetchFailureState {
339
+ if (row.kind === "gone") return stateFromRow(row, nowMs);
340
+ const retryAtMs = row.retryAt ? Date.parse(row.retryAt) : Number.NaN;
341
+ if (Number.isFinite(retryAtMs) && retryAtMs > nowMs) {
342
+ return stateFromRow(row, nowMs);
343
+ }
344
+ return activeLeaseFailure(row, nowMs);
345
+ }
346
+
347
+ /**
348
+ * Claim the one remote GET allowed for an actor whose cache row is missing.
349
+ *
350
+ * The token fences completion: if a slow owner outlives this bounded lease, it
351
+ * cannot overwrite the failure recorded by a newer owner or clear a cache
352
+ * decision after that newer fetch succeeds.
353
+ */
354
+ export async function claimRemoteActorFetch(
355
+ db: Database,
356
+ actorApId: string,
357
+ now: Date = new Date(),
358
+ ): Promise<RemoteActorFetchClaim> {
359
+ const nowIso = now.toISOString();
360
+ const token = crypto.randomUUID();
361
+ const leaseExpiresAt = new Date(
362
+ now.getTime() + REMOTE_ACTOR_FETCH_LEASE_MS,
363
+ ).toISOString();
364
+
365
+ const inserted = await db
366
+ .insert(remoteActorFetchFailures)
367
+ .values({
368
+ actorApId,
369
+ kind: "unavailable",
370
+ reason: "fetch_failed",
371
+ httpStatus: null,
372
+ failureCount: 0,
373
+ retryAt: null,
374
+ processingToken: token,
375
+ leaseExpiresAt,
376
+ createdAt: nowIso,
377
+ updatedAt: nowIso,
378
+ })
379
+ .onConflictDoNothing();
380
+ if (affectedRowCount(inserted) > 0) return { owned: true, token };
381
+
382
+ const claimed = await db
383
+ .update(remoteActorFetchFailures)
384
+ .set({ processingToken: token, leaseExpiresAt, updatedAt: nowIso })
385
+ .where(
386
+ and(
387
+ eq(remoteActorFetchFailures.actorApId, actorApId),
388
+ ne(remoteActorFetchFailures.kind, "gone"),
389
+ or(
390
+ isNull(remoteActorFetchFailures.retryAt),
391
+ lte(remoteActorFetchFailures.retryAt, nowIso),
392
+ ),
393
+ or(
394
+ isNull(remoteActorFetchFailures.processingToken),
395
+ isNull(remoteActorFetchFailures.leaseExpiresAt),
396
+ lte(remoteActorFetchFailures.leaseExpiresAt, nowIso),
397
+ ),
398
+ ),
399
+ );
400
+ if (affectedRowCount(claimed) > 0) return { owned: true, token };
401
+
402
+ const current = await db
403
+ .select()
404
+ .from(remoteActorFetchFailures)
405
+ .where(eq(remoteActorFetchFailures.actorApId, actorApId))
406
+ .get();
407
+ if (!current) {
408
+ // A successful owner may have deleted the ledger between our failed claim
409
+ // and this read. The current request did not own a GET, so fail closed and
410
+ // let a later profile load observe the newly cached actor or retry.
411
+ return {
412
+ owned: false,
413
+ failure: {
414
+ actorApId,
415
+ kind: "unavailable",
416
+ reason: "fetch_failed",
417
+ httpStatus: null,
418
+ failureCount: 0,
419
+ retryAt: null,
420
+ retryAfterSeconds: TRANSIENT_BACKOFF_BASE_SECONDS,
421
+ },
422
+ };
423
+ }
424
+ return {
425
+ owned: false,
426
+ failure: currentFailureOrLease(current, now.getTime()),
427
+ };
428
+ }
429
+
430
+ /** Persist one failed fetch and return the exact response/backoff authority. */
431
+ export async function recordRemoteActorFetchFailure(
432
+ db: Database,
433
+ actorApId: string,
434
+ failure: ActorCacheFailureResult,
435
+ now: Date = new Date(),
436
+ claimToken?: string,
437
+ ): Promise<RemoteActorFetchFailureState> {
438
+ const prior = await db
439
+ .select({ failureCount: remoteActorFetchFailures.failureCount })
440
+ .from(remoteActorFetchFailures)
441
+ .where(eq(remoteActorFetchFailures.actorApId, actorApId))
442
+ .get();
443
+ const failureCount = Math.min((prior?.failureCount ?? 0) + 1, 1_000_000);
444
+ const kind = classifyFetchFailure(failure);
445
+ const remoteRetryAfterSeconds =
446
+ failure.reason === "fetch_not_ok" ? failure.retryAfterSeconds : null;
447
+ const backoffSeconds = failureBackoffSeconds(
448
+ kind,
449
+ failureCount,
450
+ remoteRetryAfterSeconds,
451
+ );
452
+ const nowIso = now.toISOString();
453
+ const retryAt =
454
+ backoffSeconds === null
455
+ ? null
456
+ : new Date(now.getTime() + backoffSeconds * 1000).toISOString();
457
+ const values = {
458
+ actorApId,
459
+ kind,
460
+ reason: failure.reason,
461
+ httpStatus: failure.reason === "fetch_not_ok" ? failure.status : null,
462
+ failureCount,
463
+ retryAt,
464
+ processingToken: null,
465
+ leaseExpiresAt: null,
466
+ updatedAt: nowIso,
467
+ };
468
+
469
+ if (claimToken) {
470
+ const recorded = await db
471
+ .update(remoteActorFetchFailures)
472
+ .set(values)
473
+ .where(
474
+ and(
475
+ eq(remoteActorFetchFailures.actorApId, actorApId),
476
+ eq(remoteActorFetchFailures.processingToken, claimToken),
477
+ notExists(
478
+ db
479
+ .select({ apId: actorCache.apId })
480
+ .from(actorCache)
481
+ .where(eq(actorCache.apId, actorApId)),
482
+ ),
483
+ ),
484
+ );
485
+ if (affectedRowCount(recorded) === 0) {
486
+ const cached = await db
487
+ .select({ apId: actorCache.apId })
488
+ .from(actorCache)
489
+ .where(eq(actorCache.apId, actorApId))
490
+ .get();
491
+ if (cached) {
492
+ // A successful writer won the race. Remove only this claimant's stale
493
+ // lease (never a newer owner's) so cache eviction cannot resurrect the
494
+ // losing request's failure decision later.
495
+ await db
496
+ .delete(remoteActorFetchFailures)
497
+ .where(
498
+ and(
499
+ eq(remoteActorFetchFailures.actorApId, actorApId),
500
+ eq(remoteActorFetchFailures.processingToken, claimToken),
501
+ ),
502
+ );
503
+ }
504
+ const current = await db
505
+ .select()
506
+ .from(remoteActorFetchFailures)
507
+ .where(eq(remoteActorFetchFailures.actorApId, actorApId))
508
+ .get();
509
+ if (current) return currentFailureOrLease(current, now.getTime());
510
+ return {
511
+ actorApId,
512
+ kind: "unavailable",
513
+ reason: "fetch_failed",
514
+ httpStatus: null,
515
+ failureCount: 0,
516
+ retryAt: null,
517
+ retryAfterSeconds: TRANSIENT_BACKOFF_BASE_SECONDS,
518
+ };
519
+ }
520
+ } else {
521
+ await db
522
+ .insert(remoteActorFetchFailures)
523
+ .values({ ...values, createdAt: nowIso })
524
+ .onConflictDoUpdate({
525
+ target: remoteActorFetchFailures.actorApId,
526
+ set: values,
527
+ });
528
+ }
529
+
530
+ return {
531
+ ...values,
532
+ retryAfterSeconds: backoffSeconds,
533
+ };
534
+ }
535
+
536
+ export async function clearRemoteActorFetchFailure(
537
+ db: Database,
538
+ actorApId: string,
539
+ claimToken?: string,
540
+ ): Promise<void> {
541
+ await db
542
+ .delete(remoteActorFetchFailures)
543
+ .where(
544
+ claimToken
545
+ ? and(
546
+ eq(remoteActorFetchFailures.actorApId, actorApId),
547
+ eq(remoteActorFetchFailures.processingToken, claimToken),
548
+ )
549
+ : eq(remoteActorFetchFailures.actorApId, actorApId),
550
+ );
551
+ }
552
+
553
+ /** Remove one D1-safe batch of stale negative-cache/backoff rows. */
554
+ export async function reapRemoteActorFetchFailures(
555
+ db: Database,
556
+ now: Date = new Date(),
557
+ ): Promise<number> {
558
+ const cutoff = new Date(
559
+ now.getTime() - FETCH_FAILURE_RETENTION_MS,
560
+ ).toISOString();
561
+ const candidates = await db
562
+ .select({ actorApId: remoteActorFetchFailures.actorApId })
563
+ .from(remoteActorFetchFailures)
564
+ .where(
565
+ and(
566
+ lt(remoteActorFetchFailures.updatedAt, cutoff),
567
+ or(
568
+ isNull(remoteActorFetchFailures.leaseExpiresAt),
569
+ lte(remoteActorFetchFailures.leaseExpiresAt, now.toISOString()),
570
+ ),
571
+ ),
572
+ )
573
+ .orderBy(asc(remoteActorFetchFailures.updatedAt))
574
+ .limit(FETCH_FAILURE_REAP_LIMIT);
575
+ if (candidates.length === 0) return 0;
576
+ const result = await db.delete(remoteActorFetchFailures).where(
577
+ and(
578
+ inArray(
579
+ remoteActorFetchFailures.actorApId,
580
+ candidates.map((row) => row.actorApId),
581
+ ),
582
+ lt(remoteActorFetchFailures.updatedAt, cutoff),
583
+ or(
584
+ isNull(remoteActorFetchFailures.leaseExpiresAt),
585
+ lte(remoteActorFetchFailures.leaseExpiresAt, now.toISOString()),
586
+ ),
587
+ ),
588
+ );
589
+ return affectedRowCount(result);
590
+ }
123
591
 
124
592
  export interface FetchAndUpsertActorCacheOptions {
125
593
  /** Fetch timeout in ms. Defaults to 15s. */
@@ -145,6 +613,132 @@ export interface FetchAndUpsertActorCacheOptions {
145
613
  signer?: RemoteFetchSigner;
146
614
  }
147
615
 
616
+ export type CacheRemoteActorDocumentOptions = Pick<
617
+ FetchAndUpsertActorCacheOptions,
618
+ "mode" | "publicKey"
619
+ >;
620
+
621
+ /**
622
+ * Validate and persist an already-fetched remote actor document.
623
+ *
624
+ * This is the single successful-write authority for `actor_cache`: the cache
625
+ * mutation and deletion of any older terminal/cooldown/lease row are committed
626
+ * atomically. Indexed fields come from the bounded parsed shape while
627
+ * `rawJson` preserves the original document, including extension fields such
628
+ * as `attachment`, `alsoKnownAs`, and `endpoints.rtcSignal`.
629
+ */
630
+ export async function cacheRemoteActorDocument(
631
+ db: Database,
632
+ expectedActorApId: string,
633
+ rawDocument: unknown,
634
+ options: CacheRemoteActorDocumentOptions = {},
635
+ ): Promise<ActorCacheResult> {
636
+ const { mode = "upsert", publicKey = "allow-keyless" } = options;
637
+
638
+ if (!isSafeRemoteUrl(expectedActorApId)) {
639
+ return { ok: false, reason: "missing_inbox" };
640
+ }
641
+
642
+ const data = tryParseRemoteActor(rawDocument);
643
+ if (!data) return { ok: false, reason: "invalid_document" };
644
+ if (data.id !== expectedActorApId) {
645
+ return { ok: false, reason: "id_mismatch" };
646
+ }
647
+ if (
648
+ !data.inbox ||
649
+ !isSafeRemoteUrl(data.id) ||
650
+ !isSafeRemoteUrl(data.inbox)
651
+ ) {
652
+ return { ok: false, reason: "missing_inbox" };
653
+ }
654
+ if (publicKey === "require-key" && !data.publicKey?.publicKeyPem) {
655
+ return { ok: false, reason: "missing_public_key" };
656
+ }
657
+
658
+ const fields = {
659
+ ...buildActorCacheFields(data),
660
+ rawJson: JSON.stringify(rawDocument),
661
+ };
662
+ const tombstoneActorApId = canonicalRemoteActorTombstoneId(data.id);
663
+ const tombstoneAbsent = () =>
664
+ notExists(
665
+ db
666
+ .select({ actorApId: remoteActorTombstones.actorApId })
667
+ .from(remoteActorTombstones)
668
+ .where(eq(remoteActorTombstones.actorApId, tombstoneActorApId)),
669
+ );
670
+ // Keep the tombstone predicate inside the INSERT statement. A preflight
671
+ // read would leave a check/write gap in which Delete(Actor) could commit and
672
+ // then be undone by this stale network response.
673
+ const candidate = db
674
+ .select({
675
+ apId: sql<string>`${data.id}`.as("ap_id"),
676
+ type: sql<string>`${fields.type}`.as("type"),
677
+ preferredUsername: sql<string | null>`${fields.preferredUsername}`.as(
678
+ "preferred_username",
679
+ ),
680
+ name: sql<string | null>`${fields.name}`.as("name"),
681
+ summary: sql<string | null>`${fields.summary}`.as("summary"),
682
+ iconUrl: sql<string | null>`${fields.iconUrl}`.as("icon_url"),
683
+ inbox: sql<string>`${fields.inbox}`.as("inbox"),
684
+ outbox: sql<string | null>`${fields.outbox}`.as("outbox"),
685
+ followersUrl: sql<string | null>`${fields.followersUrl}`.as(
686
+ "followers_url",
687
+ ),
688
+ followingUrl: sql<string | null>`${fields.followingUrl}`.as(
689
+ "following_url",
690
+ ),
691
+ sharedInbox: sql<string | null>`${fields.sharedInbox}`.as("shared_inbox"),
692
+ publicKeyId: sql<string | null>`${fields.publicKeyId}`.as(
693
+ "public_key_id",
694
+ ),
695
+ publicKeyPem: sql<string | null>`${fields.publicKeyPem}`.as(
696
+ "public_key_pem",
697
+ ),
698
+ rawJson: sql<string>`${fields.rawJson}`.as("raw_json"),
699
+ lastFetchedAt: sql<string>`${fields.lastFetchedAt}`.as("last_fetched_at"),
700
+ createdAt: sql<string>`datetime('now')`.as("created_at"),
701
+ })
702
+ .from(sql`(SELECT 1) AS actor_cache_candidate`)
703
+ .where(tombstoneAbsent());
704
+ const write =
705
+ mode === "insert"
706
+ ? db.insert(actorCache).select(candidate).onConflictDoNothing()
707
+ : db
708
+ .insert(actorCache)
709
+ .select(candidate)
710
+ .onConflictDoUpdate({ target: actorCache.apId, set: fields });
711
+ const clearFailure = db
712
+ .delete(remoteActorFetchFailures)
713
+ .where(
714
+ and(eq(remoteActorFetchFailures.actorApId, data.id), tombstoneAbsent()),
715
+ );
716
+
717
+ await runBatch(db, [
718
+ write as unknown as D1Statement,
719
+ clearFailure as unknown as D1Statement,
720
+ ]);
721
+
722
+ if (await isRemoteActorTombstoned(db, data.id)) {
723
+ return { ok: false, reason: "actor_tombstoned" };
724
+ }
725
+ const row = await db
726
+ .select()
727
+ .from(actorCache)
728
+ .where(eq(actorCache.apId, data.id))
729
+ .get();
730
+ if (!row) {
731
+ // Delete may have committed after the first tombstone read and removed the
732
+ // row. Recheck so the caller observes terminal deletion, not availability.
733
+ if (await isRemoteActorTombstoned(db, data.id)) {
734
+ return { ok: false, reason: "actor_tombstoned" };
735
+ }
736
+ return { ok: false, reason: "fetch_failed" };
737
+ }
738
+
739
+ return { ok: true, data, row };
740
+ }
741
+
148
742
  /**
149
743
  * Fetch a remote actor document, validate it, and upsert it into
150
744
  * `actor_cache` using the single canonical column set. Returns a discriminated
@@ -171,7 +765,7 @@ export async function fetchAndUpsertActorCache(
171
765
  return { ok: false, reason: "missing_inbox" };
172
766
  }
173
767
 
174
- let data: RemoteActorDocument | null;
768
+ let raw: unknown;
175
769
  try {
176
770
  const headers: Record<string, string> = {
177
771
  Accept: "application/activity+json, application/ld+json",
@@ -189,50 +783,23 @@ export async function fetchAndUpsertActorCache(
189
783
  headers,
190
784
  timeout,
191
785
  });
192
- if (!res.ok) return { ok: false, reason: "fetch_not_ok" };
193
- const raw: unknown = await res.json();
194
- data = tryParseRemoteActor(raw);
786
+ if (!res.ok) {
787
+ return {
788
+ ok: false,
789
+ reason: "fetch_not_ok",
790
+ status: res.status,
791
+ retryAfterSeconds: parseRetryAfterSeconds(
792
+ res.headers.get("retry-after"),
793
+ Date.now(),
794
+ ),
795
+ };
796
+ }
797
+ raw = await res.json();
195
798
  } catch {
196
799
  return { ok: false, reason: "fetch_failed" };
197
800
  }
198
-
199
- if (!data) return { ok: false, reason: "invalid_document" };
200
- if (data.id !== actorApId) return { ok: false, reason: "id_mismatch" };
201
- if (
202
- !data.inbox ||
203
- !isSafeRemoteUrl(data.id) ||
204
- !isSafeRemoteUrl(data.inbox)
205
- ) {
206
- return { ok: false, reason: "missing_inbox" };
207
- }
208
- if (publicKey === "require-key" && !data.publicKey?.publicKeyPem) {
209
- return { ok: false, reason: "missing_public_key" };
210
- }
211
-
212
- const fields = buildActorCacheFields(data);
213
-
214
- if (mode === "insert") {
215
- // Cache-when-absent: leave an existing row untouched. The early-existence
216
- // check at the call site is best-effort, so two isolates racing the same
217
- // cold actor can both reach this insert; `onConflictDoNothing` keeps that
218
- // race-safe instead of throwing a primary-key violation.
219
- await db
220
- .insert(actorCache)
221
- .values({ apId: data.id, ...fields })
222
- .onConflictDoNothing();
223
- } else {
224
- await db
225
- .insert(actorCache)
226
- .values({ apId: data.id, ...fields })
227
- .onConflictDoUpdate({ target: actorCache.apId, set: fields });
228
- }
229
-
230
- const row = await db
231
- .select()
232
- .from(actorCache)
233
- .where(eq(actorCache.apId, data.id))
234
- .get();
235
- if (!row) return { ok: false, reason: "fetch_failed" };
236
-
237
- return { ok: true, data, row };
801
+ return await cacheRemoteActorDocument(db, actorApId, raw, {
802
+ mode,
803
+ publicKey,
804
+ });
238
805
  }