@takosjp/yurucommu-core 3.4.0 → 3.4.3

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 (39) hide show
  1. package/migrations/0022_inbound_dispatch_claims.sql +17 -0
  2. package/package.json +3 -2
  3. package/packages/api/package.json +1 -1
  4. package/packages/api/src/lib/api/notifications.ts +1 -0
  5. package/packages/api/src/lib/api/posts.ts +1 -0
  6. package/src/backend/index.ts +62 -12
  7. package/src/backend/lib/delivery/queue-batching.ts +53 -36
  8. package/src/backend/lib/delivery/queue-delivery.ts +3 -3
  9. package/src/backend/lib/delivery/queue.ts +13 -9
  10. package/src/backend/lib/delivery/types.ts +12 -0
  11. package/src/backend/lib/notification-push.ts +2 -2
  12. package/src/backend/lib/oauth-providers.ts +9 -0
  13. package/src/backend/lib/strip-image-metadata.ts +50 -30
  14. package/src/backend/middleware/bearer-auth.ts +24 -9
  15. package/src/backend/public.ts +38 -1
  16. package/src/backend/retention.ts +78 -0
  17. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +4 -3
  18. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +204 -5
  19. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +78 -53
  20. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +66 -2
  21. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +35 -12
  22. package/src/backend/routes/activitypub/inbox-addressing.ts +236 -0
  23. package/src/backend/routes/activitypub/inbox-types.ts +8 -0
  24. package/src/backend/routes/activitypub/inbox.ts +410 -205
  25. package/src/backend/routes/activitypub/outbox.ts +0 -0
  26. package/src/backend/routes/actors.ts +5 -5
  27. package/src/backend/routes/auth.ts +2 -1
  28. package/src/backend/routes/posts/post-helpers.ts +42 -23
  29. package/src/backend/routes/stories/routes.ts +5 -7
  30. package/src/backend/runtime/cloudflare.ts +63 -2
  31. package/src/backend/runtime/managed-relational.ts +197 -0
  32. package/src/backend/runtime/managed-runtime.ts +631 -0
  33. package/src/backend/runtime/queue.ts +40 -0
  34. package/src/backend/server.ts +15 -18
  35. package/src/backend/types.ts +9 -2
  36. package/src/db/d1-write.ts +270 -0
  37. package/src/db/index.ts +17 -0
  38. package/src/db/schema/federation.ts +19 -0
  39. package/src/db/schema/index.ts +1 -0
@@ -1,12 +1,18 @@
1
1
  import { Hono } from "hono";
2
2
  import type { Context } from "hono";
3
3
  import type { Env, Variables } from "../../types.ts";
4
- import { and, eq, inArray } from "drizzle-orm";
5
- import { activities, actorCache, actors, follows } from "../../../db/index.ts";
6
- import { chunkForInClause } from "../../lib/chunk.ts";
4
+ import { and, eq, isNull, lte, or, sql } from "drizzle-orm";
5
+ import {
6
+ activities,
7
+ actorCache,
8
+ actors,
9
+ affectedRowCount,
10
+ inboundActivityClaims,
11
+ } from "../../../db/index.ts";
7
12
  import {
8
13
  activityApId,
9
14
  actorApId,
15
+ generateId,
10
16
  isLocal,
11
17
  isSafeRemoteUrl,
12
18
  } from "../../federation-helpers.ts";
@@ -20,6 +26,12 @@ import {
20
26
  typeIncludes,
21
27
  } from "./inbox-types.ts";
22
28
  import { findFollowByActivityId } from "./handlers/inbox-shared-helpers.ts";
29
+ import {
30
+ ACTIVITY_ADDRESSING,
31
+ isHandledActivityType,
32
+ resolveAddressedRecipients,
33
+ type HandledActivityType,
34
+ } from "./inbox-addressing.ts";
23
35
  import {
24
36
  ActivityPubContractError,
25
37
  parseActivity,
@@ -62,6 +74,8 @@ const log = logger.child({ component: "activitypub.inbox" });
62
74
  type HonoContext = Context<{ Bindings: Env; Variables: Variables }>;
63
75
 
64
76
  const MAX_PAYLOAD_BYTES = 512 * 1024;
77
+ const MAX_REMOTE_ACTIVITY_ID_LENGTH = 2_048;
78
+ const INBOUND_DISPATCH_LEASE_MS = 2 * 60 * 1000;
65
79
  const TEXT_DECODER = new TextDecoder("utf-8", { fatal: true });
66
80
 
67
81
  type RequestBodyResult =
@@ -183,12 +197,37 @@ export function isActorMismatch(
183
197
 
184
198
  type ParsedActivity = {
185
199
  activity: Activity;
200
+ /** Origin-bound, fixed-size internal ledger identifier. */
186
201
  activityId: string;
202
+ /** Bounded protocol identifier to echo in Accept/Reject objects. */
203
+ sourceActivityId: string;
204
+ /** Original, request-size-bounded envelope retained for audit/debugging. */
205
+ rawActivityJson: string;
187
206
  actor: string;
188
207
  activityType: string;
189
208
  activityObjectId: string | null;
190
209
  };
191
210
 
211
+ function isBoundedProtocolActivityId(value: string): boolean {
212
+ return (
213
+ value.length > 0 &&
214
+ value.length <= MAX_REMOTE_ACTIVITY_ID_LENGTH &&
215
+ !/[\u0000-\u001f\u007f]/u.test(value)
216
+ );
217
+ }
218
+
219
+ async function internalInboundActivityId(
220
+ baseUrl: string,
221
+ actor: string,
222
+ source: string,
223
+ ): Promise<string> {
224
+ const actorIdentity = normalizeActorUrl(actor) ?? actor;
225
+ return activityApId(
226
+ baseUrl,
227
+ `inbound-${await sha256Hex(`${actorIdentity}\0${source}`)}`,
228
+ );
229
+ }
230
+
192
231
  /**
193
232
  * Shared pipeline for both inbox endpoints: size check, signature verification,
194
233
  * JSON parse, field extraction, and actor-mismatch check. Returns either a
@@ -253,50 +292,41 @@ async function verifyAndParseInbox(
253
292
  return c.json({ error: "Invalid activity" }, 400);
254
293
  }
255
294
 
256
- // The activity envelope `id` becomes the dedup ledger key (activities.apId).
257
- // Only trust a remote-supplied id when it shares the (signature-bound) actor's
258
- // origin and is not a local id; otherwise a remote could set `id` to ANOTHER
259
- // instance's namespace and pre-occupy that row (processed=1), silently
260
- // black-holing that instance's later legitimate redelivery of the same id
261
- // (denial of federation / dedup-ledger poisoning). Mirrors the object-id
262
- // origin guard (isObjectIdOriginMismatch) but for the envelope used by dedup.
263
- // An untrustworthy id does NOT drop the (validly-signed) activity — it just
264
- // gets deduped under a local deterministic synthetic id instead.
295
+ // A peer controls Activity.id. Never use that unbounded string as our primary
296
+ // key, queue key, or structured-log identifier. First validate the protocol
297
+ // id against the signature-bound actor origin, then derive a fixed-size local
298
+ // id from actor + source. The original envelope remains in rawJson (bounded by
299
+ // MAX_PAYLOAD_BYTES) for protocol evidence.
265
300
  const rawActivityId = typeof activity.id === "string" ? activity.id : null;
266
301
  let activityIdTrusted = false;
267
- if (rawActivityId !== null && !isLocal(rawActivityId, baseUrl)) {
302
+ if (
303
+ rawActivityId !== null &&
304
+ isBoundedProtocolActivityId(rawActivityId) &&
305
+ !isLocal(rawActivityId, baseUrl)
306
+ ) {
268
307
  try {
269
308
  activityIdTrusted = getDomain(rawActivityId) === getDomain(actor);
270
309
  } catch {
271
310
  activityIdTrusted = false;
272
311
  }
273
312
  }
274
- const activityId =
313
+ const sourceActivityId =
275
314
  rawActivityId !== null && activityIdTrusted
276
315
  ? rawActivityId
277
- : activityApId(
278
- baseUrl,
279
- // Deterministic synthetic id (local namespace) for an id-less OR
280
- // untrusted-origin activity: a redelivery of the SAME logical action
281
- // then dedups via the activities table instead of minting a fresh
282
- // RANDOM id each time (which re-processed it on every retry).
283
- // Defense-in-depth — the side-effect handlers are also independently
284
- // idempotent.
285
- `synthetic-${await sha256Hex(
286
- `${actor}|${activityType}|${getActivityObjectId(activity) ?? ""}`,
287
- )}`,
288
- );
316
+ : `synthetic:${await sha256Hex(
317
+ `${actor}\0${activityType}\0${getActivityObjectId(activity) ?? ""}\0${body}`,
318
+ )}`;
319
+ const activityId = await internalInboundActivityId(
320
+ baseUrl,
321
+ actor,
322
+ sourceActivityId,
323
+ );
289
324
 
290
- // Id-less activity (#8): stamp the deterministic synthetic id onto the envelope
291
- // so the per-type handlers which derive their notification / activities id
292
- // from `activity.id` (`activity.id || activityApId(generateId())`) converge
293
- // on the SAME id across a concurrent dual-endpoint delivery or an in-flight
294
- // re-claim, instead of each run minting a fresh RANDOM id and inserting
295
- // DUPLICATE inbox/notification rows. A present (trusted OR untrusted) id is a
296
- // stable string that already dedups, so only the absent-id case needs this.
297
- if (rawActivityId === null) {
298
- activity.id = activityId;
299
- }
325
+ // Handlers persist activity ids on interaction/follow edges. Stamp the
326
+ // canonical internal id before dispatch so no remote string escapes into
327
+ // those internal keys. Undo references are normalized through the same
328
+ // source-id lookup in the Undo helpers and therefore still resolve.
329
+ activity.id = activityId;
300
330
 
301
331
  const signingActor = signingActorFromKeyId(signatureResult.keyId);
302
332
  if (isActorMismatch(signingActor, actor)) {
@@ -323,6 +353,8 @@ async function verifyAndParseInbox(
323
353
  return {
324
354
  activity,
325
355
  activityId,
356
+ sourceActivityId,
357
+ rawActivityJson: body,
326
358
  actor,
327
359
  activityType,
328
360
  activityObjectId: getActivityObjectId(activity),
@@ -375,8 +407,18 @@ async function isActivityBlocked(
375
407
  // 0 = stored, dispatch not yet committed (newly inserted, or a prior dispatch
376
408
  // threw — such a row is RE-DISPATCHABLE so a peer retry completes it)
377
409
  // 1 = dispatch effects committed successfully (terminal; suppresses re-dispatch)
410
+ // 2 = UNDELIVERABLE: the activity named no local recipient we could resolve.
411
+ // Terminal for dedup purposes, but distinguished from 1 so this class of
412
+ // failure is countable (`SELECT count(*) FROM activities WHERE
413
+ // processed = 2`) instead of being indistinguishable from a successful
414
+ // no-op — which is exactly how "every DM from a non-follower is dropped"
415
+ // stayed invisible. The route answers 422 for these so the peer learns
416
+ // the delivery failed rather than reading a 202.
417
+ // The column is INTEGER with no CHECK constraint and no value-keyed index, so
418
+ // adding the third value needs no migration.
378
419
  const PROCESSED_UNPROCESSED = 0;
379
420
  const PROCESSED_DONE = 1;
421
+ const PROCESSED_UNDELIVERABLE = 2;
380
422
 
381
423
  /**
382
424
  * A request that owns dispatch for an inbound activity. After running the
@@ -390,6 +432,7 @@ type ActivityDispatchClaim = {
390
432
  activityId: string;
391
433
  activityType: string;
392
434
  actor: string;
435
+ processingToken: string;
393
436
  };
394
437
 
395
438
  /**
@@ -422,61 +465,77 @@ async function claimActivityForDispatch(
422
465
  activityType,
423
466
  actor,
424
467
  activityObjectId,
425
- activity,
468
+ rawActivityJson,
426
469
  }: ParsedActivity,
427
470
  ): Promise<Response | ActivityDispatchClaim> {
428
471
  const db = c.get("db");
429
- const rawJson = JSON.stringify(activity);
430
472
 
431
- // Atomic insert-or-skip. A non-null returned row means THIS request created
432
- // the dedup row and owns dispatch; a null row means a concurrent or prior
433
- // delivery already stored it.
434
- const inserted = await db
473
+ // Persist the dedup ledger and ensure its claim row exists. A crash between
474
+ // these idempotent inserts is harmless: the next delivery creates whichever
475
+ // row is absent before attempting the fenced claim.
476
+ await db
435
477
  .insert(activities)
436
478
  .values({
437
479
  apId: activityId,
438
480
  type: activityType,
439
481
  actorApId: actor,
440
482
  objectApId: activityObjectId,
441
- rawJson,
483
+ rawJson: rawActivityJson,
442
484
  direction: "inbound",
443
485
  processed: PROCESSED_UNPROCESSED,
444
486
  })
445
- .onConflictDoNothing()
446
- .returning()
447
- .get();
448
-
449
- if (inserted) {
450
- return { activityId, activityType, actor };
451
- }
487
+ .onConflictDoNothing();
488
+ await db
489
+ .insert(inboundActivityClaims)
490
+ .values({ activityApId: activityId })
491
+ .onConflictDoNothing();
452
492
 
453
- // Lost the insert: a row already exists. Suppress ONLY if it was already
454
- // dispatched to completion (`processed = 1`). An existing `processed = 0` row
455
- // means a prior dispatch threw without committing, so this redelivery must
456
- // re-dispatch to finish it — otherwise the dedup row would permanently
457
- // suppress re-dispatch of a half-applied activity (bug #9).
458
493
  const existing = await db.query.activities.findFirst({
459
494
  where: eq(activities.apId, activityId),
460
495
  columns: { processed: true },
461
496
  });
462
497
 
463
- if (existing && existing.processed === PROCESSED_UNPROCESSED) {
464
- log.info("Re-dispatching previously-uncommitted activity", {
465
- event: "ap.activity.redispatch_uncommitted",
498
+ if (!existing || existing.processed !== PROCESSED_UNPROCESSED) {
499
+ log.info("Duplicate activity skipped", {
500
+ event: "ap.activity.duplicate_skipped",
466
501
  activityId,
467
502
  activityType,
468
503
  actor,
469
504
  });
470
- return { activityId, activityType, actor };
505
+ return c.body(null, 202);
471
506
  }
472
507
 
473
- log.info("Duplicate activity skipped", {
474
- event: "ap.activity.duplicate_skipped",
475
- activityId,
476
- activityType,
477
- actor,
478
- });
479
- return c.body(null, 202);
508
+ const now = new Date();
509
+ const nowIso = now.toISOString();
510
+ const processingToken = generateId();
511
+ const leaseExpiresAt = new Date(
512
+ now.getTime() + INBOUND_DISPATCH_LEASE_MS,
513
+ ).toISOString();
514
+ const claimed = await db
515
+ .update(inboundActivityClaims)
516
+ .set({
517
+ processingToken,
518
+ leaseExpiresAt,
519
+ updatedAt: nowIso,
520
+ })
521
+ .where(
522
+ and(
523
+ eq(inboundActivityClaims.activityApId, activityId),
524
+ or(
525
+ isNull(inboundActivityClaims.processingToken),
526
+ lte(inboundActivityClaims.leaseExpiresAt, nowIso),
527
+ ),
528
+ ),
529
+ );
530
+
531
+ if (affectedRowCount(claimed) === 0) {
532
+ // Another delivery is actively dispatching this activity. ACK the duplicate
533
+ // rather than running the handler concurrently; the owner returns 5xx and
534
+ // releases the claim if its dispatch fails.
535
+ return c.body(null, 202);
536
+ }
537
+
538
+ return { activityId, activityType, actor, processingToken };
480
539
  }
481
540
 
482
541
  /**
@@ -486,13 +545,69 @@ async function claimActivityForDispatch(
486
545
  */
487
546
  async function commitActivityDispatch(
488
547
  c: HonoContext,
489
- activityId: string,
548
+ claim: ActivityDispatchClaim,
549
+ state:
550
+ typeof PROCESSED_DONE | typeof PROCESSED_UNDELIVERABLE = PROCESSED_DONE,
490
551
  ): Promise<void> {
491
552
  const db = c.get("db");
492
- await db
553
+ const committed = await db
493
554
  .update(activities)
494
- .set({ processed: PROCESSED_DONE })
495
- .where(eq(activities.apId, activityId));
555
+ .set({ processed: state })
556
+ .where(
557
+ and(
558
+ eq(activities.apId, claim.activityId),
559
+ sql`EXISTS (
560
+ SELECT 1 FROM ${inboundActivityClaims}
561
+ WHERE ${inboundActivityClaims.activityApId} = ${claim.activityId}
562
+ AND ${inboundActivityClaims.processingToken} = ${claim.processingToken}
563
+ )`,
564
+ ),
565
+ );
566
+ if (affectedRowCount(committed) === 0) {
567
+ throw new Error("Inbound activity dispatch lease was lost before commit");
568
+ }
569
+ await db
570
+ .update(inboundActivityClaims)
571
+ .set({
572
+ processingToken: null,
573
+ leaseExpiresAt: null,
574
+ updatedAt: new Date().toISOString(),
575
+ })
576
+ .where(
577
+ and(
578
+ eq(inboundActivityClaims.activityApId, claim.activityId),
579
+ eq(inboundActivityClaims.processingToken, claim.processingToken),
580
+ ),
581
+ );
582
+ }
583
+
584
+ async function releaseActivityDispatch(
585
+ c: HonoContext,
586
+ claim: ActivityDispatchClaim,
587
+ ): Promise<void> {
588
+ await c
589
+ .get("db")
590
+ .update(inboundActivityClaims)
591
+ .set({
592
+ processingToken: null,
593
+ leaseExpiresAt: null,
594
+ updatedAt: new Date().toISOString(),
595
+ })
596
+ .where(
597
+ and(
598
+ eq(inboundActivityClaims.activityApId, claim.activityId),
599
+ eq(inboundActivityClaims.processingToken, claim.processingToken),
600
+ ),
601
+ );
602
+ }
603
+
604
+ async function retryableDispatchFailure(
605
+ c: HonoContext,
606
+ claim: ActivityDispatchClaim,
607
+ ): Promise<Response> {
608
+ await releaseActivityDispatch(c, claim);
609
+ c.header("Retry-After", "30");
610
+ return c.json({ error: "Activity dispatch temporarily failed" }, 503);
496
611
  }
497
612
 
498
613
  // ---------------------------------------------------------------------------
@@ -577,17 +692,25 @@ type UserInboxHandler = {
577
692
  recipient: ActorRow;
578
693
  actor: string;
579
694
  baseUrl: string;
695
+ sourceActivityId: string;
580
696
  };
581
697
 
582
698
  async function dispatchUserActivity(
583
699
  c: HonoContext,
584
700
  activityType: string,
585
701
  activity: Activity,
586
- { recipient, actor, baseUrl }: UserInboxHandler,
702
+ { recipient, actor, baseUrl, sourceActivityId }: UserInboxHandler,
587
703
  ): Promise<void> {
588
704
  switch (activityType) {
589
705
  case "Follow":
590
- await handleFollow(c, activity, recipient, actor, baseUrl);
706
+ await handleFollow(
707
+ c,
708
+ activity,
709
+ recipient,
710
+ actor,
711
+ baseUrl,
712
+ sourceActivityId,
713
+ );
591
714
  break;
592
715
  case "Accept":
593
716
  await handleAccept(c, activity, actor);
@@ -596,7 +719,7 @@ async function dispatchUserActivity(
596
719
  await handleUndo(c, activity, recipient, actor, baseUrl);
597
720
  break;
598
721
  case "Like":
599
- await handleLike(c, activity, recipient, actor, baseUrl);
722
+ await handleLike(c, activity, actor, baseUrl);
600
723
  break;
601
724
  case "Create":
602
725
  await handleCreate(c, activity, recipient, actor, baseUrl);
@@ -605,7 +728,7 @@ async function dispatchUserActivity(
605
728
  await handleDelete(c, activity);
606
729
  break;
607
730
  case "Announce":
608
- await handleAnnounce(c, activity, recipient, actor, baseUrl);
731
+ await handleAnnounce(c, activity, actor, baseUrl);
609
732
  break;
610
733
  case "Update":
611
734
  await handleUpdate(c, activity, actor);
@@ -637,6 +760,62 @@ async function dispatchUserActivity(
637
760
  }
638
761
  }
639
762
 
763
+ /**
764
+ * Dispatch an activity whose handler resolves its own target — no delivery
765
+ * recipient is involved. This is the ONLY path for the `instance` addressing
766
+ * class (see inbox-addressing.ts) and it takes no `ActorRow`, so the synthetic
767
+ * `{ apId: actor } as ActorRow` the shared inbox used to fabricate cannot be
768
+ * constructed: a handler that genuinely needs a recipient will not typecheck
769
+ * here.
770
+ */
771
+ async function dispatchInstanceActivity(
772
+ c: HonoContext,
773
+ activityType: HandledActivityType,
774
+ activity: Activity,
775
+ actor: string,
776
+ baseUrl: string,
777
+ ): Promise<void> {
778
+ switch (activityType) {
779
+ case "Accept":
780
+ await handleAccept(c, activity, actor);
781
+ break;
782
+ case "Delete":
783
+ await handleDelete(c, activity);
784
+ break;
785
+ case "Update":
786
+ await handleUpdate(c, activity, actor);
787
+ break;
788
+ case "Reject":
789
+ await handleReject(c, activity, actor);
790
+ break;
791
+ case "Flag":
792
+ await handleFlag(c, activity, actor);
793
+ break;
794
+ case "Move":
795
+ await handleMove(c, activity, actor);
796
+ break;
797
+ case "Like":
798
+ await handleLike(c, activity, actor, baseUrl);
799
+ break;
800
+ case "Announce":
801
+ await handleAnnounce(c, activity, actor, baseUrl);
802
+ break;
803
+ case "Undo":
804
+ // Only Undo(Like|Announce) reaches this path: Undo(Follow|Block) is
805
+ // object-actor-scoped and is dispatched with its resolved target.
806
+ // `null` is the honest recipient here, and handleUndo refuses the
807
+ // follow branch without one.
808
+ await handleUndo(c, activity, null, actor, baseUrl);
809
+ break;
810
+ default:
811
+ log.error("Instance dispatch reached a non-instance activity type", {
812
+ event: "ap.activity.instance_dispatch_misroute",
813
+ activityType,
814
+ actor,
815
+ });
816
+ }
817
+ }
818
+
640
819
  // ---------------------------------------------------------------------------
641
820
  // Per-domain inbox throttling
642
821
  // ---------------------------------------------------------------------------
@@ -720,9 +899,8 @@ ap.post("/ap/actor/inbox", async (c) => {
720
899
  // handler is isolated and logged WITHOUT committing, so the row stays
721
900
  // `processed = 0` and a peer retry re-dispatches to complete the effect rather
722
901
  // than being permanently suppressed by the dedup row (#9). A successful
723
- // dispatch commits (processed = 1) so retries are skipped. Either way we ACK
724
- // 202 — a 5xx would make the remote retry, and a committed-too-early dedup row
725
- // would black-hole the half-applied activity.
902
+ // dispatch commits (processed = 1) so retries are skipped; failure releases
903
+ // the fenced claim and returns retryable 503.
726
904
  try {
727
905
  switch (activityType) {
728
906
  case "Follow":
@@ -733,6 +911,7 @@ ap.post("/ap/actor/inbox", async (c) => {
733
911
  actor,
734
912
  baseUrl,
735
913
  result.activityId,
914
+ result.sourceActivityId,
736
915
  );
737
916
  break;
738
917
  case "Undo":
@@ -742,7 +921,7 @@ ap.post("/ap/actor/inbox", async (c) => {
742
921
  await handleGroupCreate(c, activity, instActor, actor, baseUrl);
743
922
  break;
744
923
  }
745
- await commitActivityDispatch(c, claim.activityId);
924
+ await commitActivityDispatch(c, claim);
746
925
  } catch (e) {
747
926
  log.error("Actor-inbox dispatch failed", {
748
927
  event: "ap.actor_inbox.dispatch_error",
@@ -750,6 +929,7 @@ ap.post("/ap/actor/inbox", async (c) => {
750
929
  actor,
751
930
  error: e,
752
931
  });
932
+ return retryableDispatchFailure(c, claim);
753
933
  }
754
934
 
755
935
  return c.body(null, 202);
@@ -791,13 +971,14 @@ ap.post("/ap/groups/:name/inbox", async (c) => {
791
971
  actor,
792
972
  baseUrl,
793
973
  result.activityId,
974
+ result.sourceActivityId,
794
975
  );
795
976
  break;
796
977
  case "Undo":
797
978
  await handleGroupUndo(c, activity, community, actor);
798
979
  break;
799
980
  }
800
- await commitActivityDispatch(c, claim.activityId);
981
+ await commitActivityDispatch(c, claim);
801
982
  } catch (e) {
802
983
  log.error("Community-inbox dispatch failed", {
803
984
  event: "ap.community_inbox.dispatch_error",
@@ -806,6 +987,7 @@ ap.post("/ap/groups/:name/inbox", async (c) => {
806
987
  community: community.apId,
807
988
  error: e,
808
989
  });
990
+ return retryableDispatchFailure(c, claim);
809
991
  }
810
992
 
811
993
  return c.body(null, 202);
@@ -831,22 +1013,22 @@ ap.post("/ap/users/:username/inbox", async (c) => {
831
1013
  const claim = await claimActivityForDispatch(c, result);
832
1014
  if (claim instanceof Response) return claim;
833
1015
 
834
- const { activity, activityType, actor } = result;
835
-
836
- await cacheRemoteActor(c, actor, baseUrl);
1016
+ const { activity, activityType, actor, sourceActivityId } = result;
837
1017
 
838
1018
  // The activity row is stored (processed = 0) before dispatch. If a handler
839
1019
  // throws we leave it uncommitted so a peer retry re-dispatches and completes
840
1020
  // the effect, instead of the dedup row permanently suppressing it (#9); on
841
- // success we commit (processed = 1) so retries are skipped. We ACK 202
842
- // regardless so a retry is not provoked into a 5xx loop.
1021
+ // success we commit (processed = 1) so retries are skipped. Failure releases
1022
+ // the fenced claim and returns retryable 503 so the peer can complete it.
843
1023
  try {
1024
+ await cacheRemoteActor(c, actor, baseUrl);
844
1025
  await dispatchUserActivity(c, activityType, activity, {
845
1026
  recipient,
846
1027
  actor,
847
1028
  baseUrl,
1029
+ sourceActivityId,
848
1030
  });
849
- await commitActivityDispatch(c, claim.activityId);
1031
+ await commitActivityDispatch(c, claim);
850
1032
  } catch (e) {
851
1033
  log.error("User-inbox dispatch failed", {
852
1034
  event: "ap.user_inbox.dispatch_error",
@@ -855,6 +1037,7 @@ ap.post("/ap/users/:username/inbox", async (c) => {
855
1037
  recipient: recipient.apId,
856
1038
  error: e,
857
1039
  });
1040
+ return retryableDispatchFailure(c, claim);
858
1041
  }
859
1042
 
860
1043
  return c.body(null, 202);
@@ -873,12 +1056,12 @@ ap.post("/ap/users/:username/inbox", async (c) => {
873
1056
  // the appropriate local recipients, instead of black-holing it with a bare
874
1057
  // 202.
875
1058
  //
876
- // Recipient resolution: the parsed activity envelope does not carry
877
- // `to`/`cc`/`audience`, so for recipient-scoped activity types we fan out to
878
- // every LOCAL actor that follows the activity actor (the standard sharedInbox
879
- // semantic the sending server delivers once and the receiving server
880
- // distributes to its own followers). Recipient-independent types (Accept,
881
- // Delete, Update, Reject, Flag, Move) are dispatched exactly once.
1059
+ // Recipient resolution is a total function over the handled activity types:
1060
+ // every type DECLARES its addressing class in inbox-addressing.ts and the
1061
+ // route dispatches accordingly. There is no "everything else" fallback the
1062
+ // old one fanned out to the sender's local followers, which resolved to ZERO
1063
+ // recipients for a DM or a Like from a non-follower and then committed
1064
+ // `processed = 1`, silently and permanently dropping it behind a 202.
882
1065
 
883
1066
  // Bound on the number of local followers fanned out per shared-inbox activity,
884
1067
  // so a single delivery cannot trigger an unbounded number of handler runs in
@@ -886,58 +1069,6 @@ ap.post("/ap/users/:username/inbox", async (c) => {
886
1069
  // community app), so this ceiling is generous.
887
1070
  const MAX_SHARED_INBOX_FANOUT = 1000;
888
1071
 
889
- // Activity types whose handlers do not depend on the recipient actor; these
890
- // are dispatched once rather than per local follower.
891
- const RECIPIENT_INDEPENDENT_TYPES = new Set([
892
- "Accept",
893
- "Delete",
894
- "Update",
895
- "Reject",
896
- "Flag",
897
- "Move",
898
- ]);
899
-
900
- /**
901
- * Resolve the local actor rows that follow `actorApIdValue` (an accepted
902
- * follow), capped at MAX_SHARED_INBOX_FANOUT. Used to fan a shared-inbox
903
- * activity out to the local recipients that subscribed to the sending actor.
904
- */
905
- async function resolveLocalFollowerRecipients(
906
- c: HonoContext,
907
- actorApIdValue: string,
908
- baseUrl: string,
909
- ): Promise<ActorRow[]> {
910
- const db = c.get("db");
911
-
912
- const followerRows = await db
913
- .select({
914
- followerApId: follows.followerApId,
915
- })
916
- .from(follows)
917
- .where(
918
- and(
919
- eq(follows.followingApId, actorApIdValue),
920
- eq(follows.status, "accepted"),
921
- ),
922
- )
923
- .limit(MAX_SHARED_INBOX_FANOUT);
924
-
925
- const localFollowerApIds = followerRows
926
- .map((row) => row.followerApId)
927
- .filter((apId) => isLocal(apId, baseUrl));
928
- if (localFollowerApIds.length === 0) return [];
929
-
930
- // Chunk the IN(...) lookup: the fan-out is capped at MAX_SHARED_INBOX_FANOUT
931
- // (1000) and D1 allows at most 100 bound parameters per query. The id slices
932
- // are disjoint, so flattening the per-chunk actor rows is collision-free.
933
- const chunks = await Promise.all(
934
- chunkForInClause(localFollowerApIds).map((ids) =>
935
- db.query.actors.findMany({ where: inArray(actors.apId, ids) }),
936
- ),
937
- );
938
- return chunks.flat();
939
- }
940
-
941
1072
  /**
942
1073
  * Resolve the LOCAL actor named by `activity.object` (an actor IRI). Used for
943
1074
  * object-actor-scoped activities (e.g. `Follow`) delivered to the SHARED inbox:
@@ -981,16 +1112,28 @@ async function findLocalActorByApId(
981
1112
  * the recipient must be the followed actor. Resolve it from the wrapped
982
1113
  * activity's object (typed inner) or by looking up the referenced follow edge
983
1114
  * (bare-string inner). Undo(Like|Announce) is actor-keyed + idempotent, so it
984
- * is NOT actor-scoped and keeps the follower fan-out (`scoped: false`).
1115
+ * is NOT actor-scoped (`scoped: false`) and is dispatched ONCE as an
1116
+ * instance activity.
985
1117
  * `scoped: true` with `target: null` = an actor-scoped activity that names no
986
1118
  * known LOCAL actor → an honest no-op (do not fan out to the sender's followers).
987
1119
  */
1120
+ type ObjectActorTarget = {
1121
+ scoped: boolean;
1122
+ target: ActorRow | null;
1123
+ /**
1124
+ * A scoped activity with no target that is nonetheless COMPLETE — e.g. an
1125
+ * Undo(Follow) whose edge is already gone. Distinguished from "named a local
1126
+ * actor we do not host", which is undeliverable.
1127
+ */
1128
+ noop?: boolean;
1129
+ };
1130
+
988
1131
  async function resolveObjectActorTarget(
989
1132
  c: HonoContext,
990
1133
  activityType: string,
991
1134
  activity: Activity,
992
1135
  baseUrl: string,
993
- ): Promise<{ scoped: boolean; target: ActorRow | null }> {
1136
+ ): Promise<ObjectActorTarget> {
994
1137
  if (activityType === "Follow" || activityType === "Block") {
995
1138
  return {
996
1139
  scoped: true,
@@ -1026,8 +1169,9 @@ async function resolveObjectActorTarget(
1026
1169
  // id, OR a typeless object inner — all mirror the per-user inbox's
1027
1170
  // findFollowByActivityId path). An inner WITHOUT an explicit "Follow" type
1028
1171
  // (bare-string or typeless object) is treated as a POSSIBLE Undo(Follow); if
1029
- // it resolves no local follow edge it is left to the fan-out, because it may
1030
- // be an Undo(Like|Announce) by id whose actor-keyed handler must still run.
1172
+ // it resolves no local follow edge it is left UNSCOPED, because it may be
1173
+ // an Undo(Like|Announce) by id whose actor-keyed handler must still run
1174
+ // as a SINGLE instance dispatch, not a per-follower fan-out.
1031
1175
  if (
1032
1176
  typeIncludes(inner?.type, "Follow") ||
1033
1177
  inner == null ||
@@ -1054,15 +1198,19 @@ async function resolveObjectActorTarget(
1054
1198
  }
1055
1199
  }
1056
1200
  // A typed Follow inner is object-scoped even with an unresolvable edge
1057
- // (commit a no-op; do NOT fan out to the sender's followers). An inner with
1058
- // no explicit Follow type that resolved no follow edge keeps the fan-out
1059
- // it may be an Undo(Like|Announce) whose decrement the handler must apply.
1201
+ // (do NOT dispatch it against some other actor). An inner with no
1202
+ // explicit Follow type that resolved no follow edge stays unscoped it
1203
+ // may be an Undo(Like|Announce) whose decrement the handler must apply.
1204
+ // A typed Follow whose edge is already gone is a DUPLICATE Undo, not a
1205
+ // misdirected delivery: nothing is left to undo, so it is a no-op, not
1206
+ // an undeliverable (answering 422 to an idempotent retry would be a
1207
+ // lie).
1060
1208
  return inner?.type === "Follow"
1061
- ? { scoped: true, target: null }
1209
+ ? { scoped: true, target: null, noop: true }
1062
1210
  : { scoped: false, target: null };
1063
1211
  }
1064
1212
 
1065
- // Undo(Like|Announce|…) — actor-keyed + idempotent; keep the follower fan-out.
1213
+ // Undo(Like|Announce|…) — actor-keyed + idempotent; instance-dispatched.
1066
1214
  return { scoped: false, target: null };
1067
1215
  }
1068
1216
  return { scoped: false, target: null };
@@ -1070,6 +1218,7 @@ async function resolveObjectActorTarget(
1070
1218
 
1071
1219
  ap.post("/ap/inbox", async (c) => {
1072
1220
  const baseUrl = c.env.APP_URL;
1221
+ const db = c.get("db");
1073
1222
 
1074
1223
  const result = await verifyAndParseInbox(c, baseUrl);
1075
1224
  if (result instanceof Response) return result;
@@ -1080,79 +1229,132 @@ ap.post("/ap/inbox", async (c) => {
1080
1229
  const claim = await claimActivityForDispatch(c, result);
1081
1230
  if (claim instanceof Response) return claim;
1082
1231
 
1083
- const { activity, activityType, actor } = result;
1232
+ const { activity, activityType, actor, sourceActivityId } = result;
1084
1233
 
1085
- // The fan-out below may throw before any dispatch runs (e.g. actor cache or
1086
- // follower resolution faults). On such a failure we leave the row uncommitted
1087
- // (processed = 0) so a peer retry re-dispatches and completes delivery rather
1088
- // than being suppressed by the dedup row (#9); we commit it once the fan-out
1089
- // has run so retries are skipped.
1234
+ // The dispatch below may throw before any handler runs (e.g. actor cache or
1235
+ // recipient resolution faults). On such a failure we leave the row
1236
+ // uncommitted (processed = 0) so a peer retry re-dispatches and completes
1237
+ // delivery rather than being suppressed by the dedup row (#9); we commit it
1238
+ // once dispatch has run so retries are skipped.
1090
1239
  try {
1091
1240
  await cacheRemoteActor(c, actor, baseUrl);
1092
1241
 
1093
- if (RECIPIENT_INDEPENDENT_TYPES.has(activityType)) {
1094
- // These handlers ignore the recipient; dispatch once. We pass a synthetic
1095
- // recipient context derived from the activity actor so the handler
1096
- // signature is satisfied without implying a specific local target.
1097
- await dispatchUserActivity(c, activityType, activity, {
1098
- recipient: { apId: actor } as ActorRow,
1242
+ if (!isHandledActivityType(activityType)) {
1243
+ log.warn("Unhandled activity type", {
1244
+ event: "ap.activity.unhandled_type",
1245
+ activityType,
1099
1246
  actor,
1100
- baseUrl,
1101
1247
  });
1102
- await commitActivityDispatch(c, claim.activityId);
1248
+ await commitActivityDispatch(c, claim, PROCESSED_DONE);
1103
1249
  return c.body(null, 202);
1104
1250
  }
1105
1251
 
1106
- // Object-actor-scoped activities (Follow / Block / Undo(Follow|Block)) are
1107
- // addressed to the actor NAMED by the activity, NOT to followers of the
1108
- // sender. Routing them through the follower fan-out below would make the
1109
- // handler key off the wrong actor (bogus edge / Accept from the wrong actor
1110
- // / followerCount drift on the wrong actor) or when the sender has no
1111
- // local followers silently DROP the request entirely. Resolve the target
1112
- // and dispatch once (mirrors the per-user inbox). Correctly-addressed peers
1113
- // hit /ap/users/:username/inbox; this guards peers that point them here.
1114
- const objectScoped = await resolveObjectActorTarget(
1115
- c,
1116
- activityType,
1117
- activity,
1118
- baseUrl,
1119
- );
1120
- if (objectScoped.scoped) {
1121
- if (objectScoped.target) {
1252
+ const declared = ACTIVITY_ADDRESSING[activityType];
1253
+
1254
+ // ---- object-actor: the recipient is the actor NAMED by the activity ----
1255
+ // Follow / Block / Undo(Follow|Block). Routing these through a follower
1256
+ // fan-out would key the handler off the wrong actor (bogus edge / Accept
1257
+ // from the wrong actor / followerCount drift) or drop the request when the
1258
+ // sender has no local followers.
1259
+ if (declared === "object-actor") {
1260
+ const objectScoped = await resolveObjectActorTarget(
1261
+ c,
1262
+ activityType,
1263
+ activity,
1264
+ baseUrl,
1265
+ );
1266
+ if (objectScoped.scoped) {
1267
+ if (!objectScoped.target && objectScoped.noop) {
1268
+ // Idempotent no-op (duplicate Undo of an edge that is already gone).
1269
+ await commitActivityDispatch(c, claim);
1270
+ return c.body(null, 202);
1271
+ }
1272
+ if (!objectScoped.target) {
1273
+ // Named a local actor we do not have (or named none at all). This is
1274
+ // not a completed delivery: mark it undeliverable so it is countable
1275
+ // and answer 422 instead of a 202 the peer would read as success.
1276
+ log.info("Shared-inbox object-actor activity names no local target", {
1277
+ event: "ap.shared_inbox.object_actor_no_target",
1278
+ activityType,
1279
+ actor,
1280
+ object: getActivityObjectId(activity),
1281
+ });
1282
+ await commitActivityDispatch(c, claim, PROCESSED_UNDELIVERABLE);
1283
+ return c.json({ error: "No local recipient for this activity" }, 422);
1284
+ }
1122
1285
  await dispatchUserActivity(c, activityType, activity, {
1123
1286
  recipient: objectScoped.target,
1124
1287
  actor,
1125
1288
  baseUrl,
1289
+ sourceActivityId,
1126
1290
  });
1127
- } else {
1128
- log.info("Shared-inbox object-actor activity names no local target", {
1129
- event: "ap.shared_inbox.object_actor_no_target",
1130
- activityType,
1131
- actor,
1132
- object: getActivityObjectId(activity),
1133
- });
1291
+ await commitActivityDispatch(c, claim);
1292
+ return c.body(null, 202);
1134
1293
  }
1135
- await commitActivityDispatch(c, claim.activityId);
1294
+ // Undo(Like|Announce): actor-keyed and idempotent, with no local actor
1295
+ // target — dispatch ONCE like any other instance-scoped activity. It used
1296
+ // to fall through to the follower fan-out, which ran the handler (and its
1297
+ // whole-table counter recompute) once per local follower.
1298
+ await dispatchInstanceActivity(c, activityType, activity, actor, baseUrl);
1299
+ await commitActivityDispatch(c, claim);
1136
1300
  return c.body(null, 202);
1137
1301
  }
1138
1302
 
1139
- // Recipient-scoped: fan out to every local follower of the sending actor.
1140
- const recipients = await resolveLocalFollowerRecipients(c, actor, baseUrl);
1141
- if (recipients.length === 0) {
1142
- // No local subscribers for this actor — an honest no-op delivery. Commit
1143
- // the claim so the no-op is not retried indefinitely.
1144
- await commitActivityDispatch(c, claim.activityId);
1145
- log.info("Shared-inbox activity had no local recipients", {
1146
- event: "ap.shared_inbox.no_recipients",
1303
+ // ---- instance: the handler resolves its own target ----
1304
+ // Accept / Delete / Update / Reject / Flag / Move, plus Like / Announce
1305
+ // (whose handlers take `_recipient` and key off the object's attributedTo).
1306
+ if (declared === "instance") {
1307
+ await dispatchInstanceActivity(c, activityType, activity, actor, baseUrl);
1308
+ await commitActivityDispatch(c, claim);
1309
+ return c.body(null, 202);
1310
+ }
1311
+
1312
+ // ---- addressed: read the activity's own addressing ----
1313
+ const resolution = await resolveAddressedRecipients(
1314
+ db,
1315
+ activity,
1316
+ actor,
1317
+ baseUrl,
1318
+ MAX_SHARED_INBOX_FANOUT,
1319
+ );
1320
+
1321
+ if (resolution.recipients.length === 0) {
1322
+ if (resolution.cls === "audience") {
1323
+ // The activity addressed a COLLECTION (Public / followers) and nobody
1324
+ // here subscribes to the sender. That is a genuine no-op: the peer did
1325
+ // not name us, so there is nothing that failed to arrive. Commit it
1326
+ // done and answer 202.
1327
+ log.info("Shared-inbox audience activity has no local subscribers", {
1328
+ event: "ap.shared_inbox.no_subscribers",
1329
+ activityType,
1330
+ actor,
1331
+ });
1332
+ await commitActivityDispatch(c, claim);
1333
+ return c.body(null, 202);
1334
+ }
1335
+ // The activity named SPECIFIC recipients (or named nobody at all) and
1336
+ // none of them resolved to a local actor. Undeliverable, NOT a no-op:
1337
+ // the previous code committed `processed = 1` here, which made a DM or a
1338
+ // Like from someone the addressee does not follow permanently
1339
+ // unrecoverable — every retry was suppressed by the dedup row behind a
1340
+ // 202. `processed = 2` keeps the two outcomes distinguishable
1341
+ // (`SELECT count(*) FROM activities WHERE processed = 2` is the meter for
1342
+ // this defect class) and 422 tells the peer the delivery failed.
1343
+ log.warn("Shared-inbox activity resolved no local recipients", {
1344
+ event: "ap.shared_inbox.undeliverable",
1147
1345
  activityType,
1148
1346
  actor,
1347
+ addressing: resolution.cls,
1348
+ addresses: resolution.addresses,
1149
1349
  });
1150
- return c.body(null, 202);
1350
+ await commitActivityDispatch(c, claim, PROCESSED_UNDELIVERABLE);
1351
+ return c.json({ error: "No local recipient for this activity" }, 422);
1151
1352
  }
1152
1353
 
1153
- for (const recipient of recipients) {
1354
+ let dispatchFailed = false;
1355
+ for (const recipient of resolution.recipients) {
1154
1356
  // Isolate per-recipient failures: a single local recipient whose handler
1155
- // throws must not abort fan-out to the others or turn the whole shared
1357
+ // throws must not abort delivery to the others or turn the whole shared
1156
1358
  // delivery into a 5xx (which would make the sending peer retry and
1157
1359
  // redeliver to every recipient).
1158
1360
  try {
@@ -1160,8 +1362,10 @@ ap.post("/ap/inbox", async (c) => {
1160
1362
  recipient,
1161
1363
  actor,
1162
1364
  baseUrl,
1365
+ sourceActivityId,
1163
1366
  });
1164
1367
  } catch (e) {
1368
+ dispatchFailed = true;
1165
1369
  log.error("Shared-inbox dispatch failed for one recipient", {
1166
1370
  event: "ap.shared_inbox.dispatch_error",
1167
1371
  activityType,
@@ -1171,11 +1375,11 @@ ap.post("/ap/inbox", async (c) => {
1171
1375
  });
1172
1376
  }
1173
1377
  }
1378
+ if (dispatchFailed) {
1379
+ throw new Error("One or more shared-inbox recipient dispatches failed");
1380
+ }
1174
1381
 
1175
- // Fan-out attempted for every resolved recipient (per-recipient failures
1176
- // are isolated above). Commit the claim so a peer retry does not redeliver
1177
- // to every local follower.
1178
- await commitActivityDispatch(c, claim.activityId);
1382
+ await commitActivityDispatch(c, claim);
1179
1383
  } catch (e) {
1180
1384
  log.error("Shared-inbox dispatch failed", {
1181
1385
  event: "ap.shared_inbox.dispatch_error",
@@ -1183,6 +1387,7 @@ ap.post("/ap/inbox", async (c) => {
1183
1387
  actor,
1184
1388
  error: e,
1185
1389
  });
1390
+ return retryableDispatchFailure(c, claim);
1186
1391
  }
1187
1392
 
1188
1393
  return c.body(null, 202);