@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.
- package/migrations/0022_inbound_dispatch_claims.sql +17 -0
- package/package.json +3 -2
- package/packages/api/package.json +1 -1
- package/packages/api/src/lib/api/notifications.ts +1 -0
- package/packages/api/src/lib/api/posts.ts +1 -0
- package/src/backend/index.ts +62 -12
- package/src/backend/lib/delivery/queue-batching.ts +53 -36
- package/src/backend/lib/delivery/queue-delivery.ts +3 -3
- package/src/backend/lib/delivery/queue.ts +13 -9
- package/src/backend/lib/delivery/types.ts +12 -0
- package/src/backend/lib/notification-push.ts +2 -2
- package/src/backend/lib/oauth-providers.ts +9 -0
- package/src/backend/lib/strip-image-metadata.ts +50 -30
- package/src/backend/middleware/bearer-auth.ts +24 -9
- package/src/backend/public.ts +38 -1
- package/src/backend/retention.ts +78 -0
- package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +4 -3
- package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +204 -5
- package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +78 -53
- package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +66 -2
- package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +35 -12
- package/src/backend/routes/activitypub/inbox-addressing.ts +236 -0
- package/src/backend/routes/activitypub/inbox-types.ts +8 -0
- package/src/backend/routes/activitypub/inbox.ts +410 -205
- package/src/backend/routes/activitypub/outbox.ts +0 -0
- package/src/backend/routes/actors.ts +5 -5
- package/src/backend/routes/auth.ts +2 -1
- package/src/backend/routes/posts/post-helpers.ts +42 -23
- package/src/backend/routes/stories/routes.ts +5 -7
- package/src/backend/runtime/cloudflare.ts +63 -2
- package/src/backend/runtime/managed-relational.ts +197 -0
- package/src/backend/runtime/managed-runtime.ts +631 -0
- package/src/backend/runtime/queue.ts +40 -0
- package/src/backend/server.ts +15 -18
- package/src/backend/types.ts +9 -2
- package/src/db/d1-write.ts +270 -0
- package/src/db/index.ts +17 -0
- package/src/db/schema/federation.ts +19 -0
- 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,
|
|
5
|
-
import {
|
|
6
|
-
|
|
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
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
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 (
|
|
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
|
|
313
|
+
const sourceActivityId =
|
|
275
314
|
rawActivityId !== null && activityIdTrusted
|
|
276
315
|
? rawActivityId
|
|
277
|
-
:
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
//
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
|
|
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
|
-
|
|
447
|
-
.
|
|
448
|
-
|
|
449
|
-
|
|
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
|
|
464
|
-
log.info("
|
|
465
|
-
event: "ap.activity.
|
|
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
|
|
505
|
+
return c.body(null, 202);
|
|
471
506
|
}
|
|
472
507
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
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:
|
|
495
|
-
.where(
|
|
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(
|
|
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,
|
|
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,
|
|
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
|
|
724
|
-
//
|
|
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
|
|
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
|
|
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.
|
|
842
|
-
//
|
|
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
|
|
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
|
|
877
|
-
//
|
|
878
|
-
//
|
|
879
|
-
//
|
|
880
|
-
//
|
|
881
|
-
//
|
|
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
|
|
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<
|
|
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
|
|
1030
|
-
//
|
|
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
|
-
// (
|
|
1058
|
-
//
|
|
1059
|
-
//
|
|
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;
|
|
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
|
|
1086
|
-
//
|
|
1087
|
-
// (processed = 0) so a peer retry re-dispatches and completes
|
|
1088
|
-
// than being suppressed by the dedup row (#9); we commit it
|
|
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 (
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
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
|
|
1248
|
+
await commitActivityDispatch(c, claim, PROCESSED_DONE);
|
|
1103
1249
|
return c.body(null, 202);
|
|
1104
1250
|
}
|
|
1105
1251
|
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
//
|
|
1109
|
-
//
|
|
1110
|
-
//
|
|
1111
|
-
//
|
|
1112
|
-
//
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
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
|
-
|
|
1128
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
await commitActivityDispatch(c, claim
|
|
1145
|
-
|
|
1146
|
-
|
|
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
|
-
|
|
1350
|
+
await commitActivityDispatch(c, claim, PROCESSED_UNDELIVERABLE);
|
|
1351
|
+
return c.json({ error: "No local recipient for this activity" }, 422);
|
|
1151
1352
|
}
|
|
1152
1353
|
|
|
1153
|
-
|
|
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
|
|
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
|
-
|
|
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);
|