@takosjp/yurucommu-core 3.3.0 → 3.4.1

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 (52) 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/index.ts +1 -0
  5. package/packages/api/src/lib/api/notifications.ts +1 -0
  6. package/packages/api/src/lib/api/posts.ts +1 -0
  7. package/packages/api/src/lib/rtc-client.ts +1 -3
  8. package/packages/api/src/types/call.ts +2 -10
  9. package/packages/api/src/types/index.ts +3 -0
  10. package/packages/api/src/types/realtime.ts +139 -0
  11. package/src/backend/index.ts +54 -12
  12. package/src/backend/lib/delivery/queue-batching.ts +53 -36
  13. package/src/backend/lib/delivery/queue-delivery.ts +3 -3
  14. package/src/backend/lib/delivery/queue.ts +17 -9
  15. package/src/backend/lib/delivery/types.ts +12 -0
  16. package/src/backend/lib/notification-push.ts +2 -2
  17. package/src/backend/lib/oauth-providers.ts +9 -0
  18. package/src/backend/lib/strip-image-metadata.ts +50 -30
  19. package/src/backend/lib/unread-counts.ts +63 -2
  20. package/src/backend/middleware/bearer-auth.ts +24 -9
  21. package/src/backend/public.ts +25 -1
  22. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +4 -3
  23. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +204 -5
  24. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +78 -53
  25. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +66 -2
  26. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +35 -12
  27. package/src/backend/routes/activitypub/inbox-addressing.ts +236 -0
  28. package/src/backend/routes/activitypub/inbox-types.ts +8 -0
  29. package/src/backend/routes/activitypub/inbox.ts +410 -205
  30. package/src/backend/routes/activitypub/outbox.ts +0 -0
  31. package/src/backend/routes/auth.ts +2 -1
  32. package/src/backend/routes/communities/messages.ts +53 -17
  33. package/src/backend/routes/dm/messages.ts +47 -7
  34. package/src/backend/routes/dm/read-archive.ts +27 -0
  35. package/src/backend/routes/dm/typing.ts +16 -0
  36. package/src/backend/routes/notifications.ts +25 -0
  37. package/src/backend/routes/posts/post-helpers.ts +42 -23
  38. package/src/backend/routes/realtime/index.ts +67 -0
  39. package/src/backend/routes/rtc/index.ts +5 -1
  40. package/src/backend/runtime/call-hub-core.ts +13 -3
  41. package/src/backend/runtime/cloudflare.ts +63 -2
  42. package/src/backend/runtime/managed-relational.ts +197 -0
  43. package/src/backend/runtime/managed-runtime.ts +631 -0
  44. package/src/backend/runtime/queue.ts +40 -0
  45. package/src/backend/runtime/realtime-hub.ts +257 -0
  46. package/src/backend/runtime/realtime-stream-do.ts +323 -0
  47. package/src/backend/server.ts +15 -18
  48. package/src/backend/types.ts +13 -2
  49. package/src/db/d1-write.ts +270 -0
  50. package/src/db/index.ts +17 -0
  51. package/src/db/schema/federation.ts +19 -0
  52. package/src/db/schema/index.ts +1 -0
@@ -1,5 +1,15 @@
1
1
  import type { Database } from "../../../../db/index.ts";
2
- import { and, count, eq, gt, inArray, isNotNull, or, sql } from "drizzle-orm";
2
+ import {
3
+ and,
4
+ count,
5
+ eq,
6
+ gt,
7
+ inArray,
8
+ isNotNull,
9
+ isNull,
10
+ or,
11
+ sql,
12
+ } from "drizzle-orm";
3
13
  import type { BatchItem } from "drizzle-orm/batch";
4
14
  import {
5
15
  activities,
@@ -8,6 +18,7 @@ import {
8
18
  announces,
9
19
  blocks,
10
20
  bookmarks,
21
+ communities,
11
22
  follows,
12
23
  inbox as inboxTable,
13
24
  likes,
@@ -45,6 +56,8 @@ import {
45
56
  fetchAndUpsertActorCache,
46
57
  getInstanceFetchSignerByDb,
47
58
  } from "../../../lib/activitypub-actor-cache.ts";
59
+ import { fetchWithTimeout } from "../../../lib/federation-fetch.ts";
60
+ import { signRequest } from "../../../lib/ap-signing.ts";
48
61
  import { enqueueDeliveryToActor } from "../../../lib/delivery/queue.ts";
49
62
  import { destinationDeclaresAlias } from "../../../lib/account-migration.ts";
50
63
  import { chunkForInClause } from "../../../lib/chunk.ts";
@@ -132,13 +145,13 @@ function isActorTypeUpdate(type: string | string[] | undefined): boolean {
132
145
 
133
146
  // The ActivityStreams public-collection magic value, including the legacy
134
147
  // short forms some implementations still emit.
135
- const PUBLIC_COLLECTION = new Set([
148
+ export const PUBLIC_COLLECTION = new Set([
136
149
  "https://www.w3.org/ns/activitystreams#Public",
137
150
  "as:Public",
138
151
  "Public",
139
152
  ]);
140
153
 
141
- function addressesPublic(addresses: string[]): boolean {
154
+ export function addressesPublic(addresses: string[]): boolean {
142
155
  return addresses.some((a) => PUBLIC_COLLECTION.has(a));
143
156
  }
144
157
 
@@ -146,7 +159,7 @@ function addressesPublic(addresses: string[]): boolean {
146
159
  // and NOT to Public is a followers-only post. We match any `/followers`
147
160
  // collection by suffix (mirrors isDirectNote), which covers the author's
148
161
  // collection without needing to resolve it.
149
- function addressesFollowers(addresses: string[]): boolean {
162
+ export function addressesFollowers(addresses: string[]): boolean {
150
163
  return addresses.some((a) => a.endsWith("/followers"));
151
164
  }
152
165
 
@@ -194,7 +207,7 @@ function isDirectShapedNote(object: { to?: string[]; cc?: string[] }): boolean {
194
207
  // Cap persisted addressing arrays so a remote cannot bloat a row with a huge
195
208
  // to/cc list; 64 entries is far beyond any real audience and keeps the explicit-
196
209
  // recipient (mention) gate working.
197
- const MAX_ADDRESS_ENTRIES = 64;
210
+ export const MAX_ADDRESS_ENTRIES = 64;
198
211
  function boundAddressJson(addresses: string[] | undefined): string {
199
212
  if (!Array.isArray(addresses) || addresses.length === 0) return "[]";
200
213
  return JSON.stringify(
@@ -204,6 +217,21 @@ function boundAddressJson(addresses: string[] | undefined): string {
204
217
  );
205
218
  }
206
219
 
220
+ /**
221
+ * Normalize an AS2 addressing field (`to` / `cc` / `audience`) to a bounded list
222
+ * of strings. The field may be absent, a bare string, or an array mixing
223
+ * strings and embedded objects; only the string forms are usable as a
224
+ * collection id, and the same 64-entry cap applies so a remote cannot force a
225
+ * huge `IN (...)` lookup.
226
+ */
227
+ function addressList(value: unknown): string[] {
228
+ if (typeof value === "string") return [value];
229
+ if (!Array.isArray(value)) return [];
230
+ return value
231
+ .filter((a): a is string => typeof a === "string")
232
+ .slice(0, MAX_ADDRESS_ENTRIES);
233
+ }
234
+
207
235
  /**
208
236
  * Reject an inbound object whose `object.id` is asserted under a host the
209
237
  * delivering actor does not control (object-ID squatting / cross-origin
@@ -888,6 +916,35 @@ export async function handleCreateStory(
888
916
  storyDataJson = JSON.stringify({ ...attachmentData, overlays: undefined });
889
917
  }
890
918
 
919
+ // Carry the community scope across the federation boundary. A story that
920
+ // arrived through community fanout is addressed to the community's followers
921
+ // collection, not the author's; storing it with no scope made the local read
922
+ // gate treat it as a personal story and serve it to every local follower of
923
+ // the author, member or not. Resolve the addressed collection against the
924
+ // communities this instance actually knows and only then mark the scope —
925
+ // an unresolvable audience is left unscoped rather than trusted, and the
926
+ // membership gate is still evaluated locally against `community_members`.
927
+ const addressedCollections = [
928
+ ...addressList((activity as { audience?: unknown }).audience),
929
+ ...addressList(object.to),
930
+ ...addressList((object as { audience?: unknown }).audience),
931
+ ];
932
+ const community = addressedCollections.length
933
+ ? await db
934
+ .select({ apId: communities.apId })
935
+ .from(communities)
936
+ .where(
937
+ and(
938
+ or(
939
+ inArray(communities.followersUrl, addressedCollections),
940
+ inArray(communities.apId, addressedCollections),
941
+ ),
942
+ isNull(communities.deletedAt),
943
+ ),
944
+ )
945
+ .get()
946
+ : undefined;
947
+
891
948
  const inserted = await db
892
949
  .insert(objects)
893
950
  .values({
@@ -896,6 +953,12 @@ export async function handleCreateStory(
896
953
  attributedTo: actor,
897
954
  content: "",
898
955
  attachmentsJson: storyDataJson,
956
+ ...(community
957
+ ? {
958
+ communityApId: community.apId,
959
+ audienceJson: JSON.stringify([community.apId]),
960
+ }
961
+ : {}),
899
962
  endTime,
900
963
  published: publishedAt,
901
964
  isLocal: 0,
@@ -907,6 +970,142 @@ export async function handleCreateStory(
907
970
  if (!inserted) return; // duplicate
908
971
  }
909
972
 
973
+ // ---------------------------------------------------------------------------
974
+ // Announce target resolution (fetch-and-store a boosted remote Note)
975
+ // ---------------------------------------------------------------------------
976
+
977
+ // Upper bound on the fetch of a boosted remote object. Mirrors the actor-cache
978
+ // fetch timeout; the body size is already capped by fetchWithTimeout's wrapper.
979
+ const ANNOUNCED_OBJECT_FETCH_TIMEOUT_MS = 15_000;
980
+
981
+ /**
982
+ * Resolve an inbound Announce whose target this instance has never seen: fetch
983
+ * the boosted object from its origin, validate it, and persist it as a remote
984
+ * Note so the announce edge recorded by handleAnnounce surfaces in feeds
985
+ * ("reposted by X") instead of dangling on an unknown ap_id.
986
+ *
987
+ * Reuses the SSRF-guarded federation fetch discipline end to end:
988
+ * `fetchWithTimeout` (resolver-pinned DNS validation, no redirects, capped +
989
+ * time-bounded body) with the GET signed as the instance actor so a
990
+ * secure-mode remote serves the document (mirrors fetchAndUpsertActorCache).
991
+ *
992
+ * Validation mirrors the inbound Create(Note) gates:
993
+ * - the document's `id` must equal the fetched URL (no id squatting);
994
+ * - `attributedTo` must be same-origin with the object id (a remote author
995
+ * may only own objects under its own host — mirrors
996
+ * isObjectIdOriginMismatch, and a local-origin object is never fetched);
997
+ * - type must include "Note"; direct/DM-shaped addressing is refused;
998
+ * - only world-readable classifications (public / unlisted) are persisted —
999
+ * a boost must never widen a followers-only/direct object's audience, and
1000
+ * this instance cannot verify a remote author's follower audience.
1001
+ *
1002
+ * Depth cap: the object's `inReplyTo` is stored verbatim but NEVER resolved —
1003
+ * a single Announce triggers at most one object fetch (plus a best-effort
1004
+ * author-profile cache fill), not a thread walk.
1005
+ *
1006
+ * Best-effort by contract: every failure returns false and the Announce is
1007
+ * dropped exactly as it was before this path existed.
1008
+ */
1009
+ export async function fetchAndPersistAnnouncedNote(
1010
+ db: Database,
1011
+ objectId: string,
1012
+ baseUrl: string,
1013
+ ): Promise<boolean> {
1014
+ // Never fetch a local id (a local object that does not exist is just gone)
1015
+ // and never fetch an unsafe URL (non-http(s), credentials, blocked host…).
1016
+ if (isLocal(objectId, baseUrl) || !isSafeRemoteUrl(objectId)) return false;
1017
+
1018
+ let note: ActivityObject & { attributedTo?: unknown };
1019
+ try {
1020
+ const headers: Record<string, string> = {
1021
+ Accept: "application/activity+json, application/ld+json",
1022
+ };
1023
+ const signer = await getInstanceFetchSignerByDb(db);
1024
+ if (signer) {
1025
+ Object.assign(
1026
+ headers,
1027
+ await signRequest(signer.privateKeyPem, signer.keyId, "GET", objectId),
1028
+ );
1029
+ }
1030
+ const res = await fetchWithTimeout(objectId, {
1031
+ headers,
1032
+ timeout: ANNOUNCED_OBJECT_FETCH_TIMEOUT_MS,
1033
+ });
1034
+ if (!res.ok) return false;
1035
+ const raw: unknown = await res.json();
1036
+ if (!raw || typeof raw !== "object") return false;
1037
+ note = raw as ActivityObject & { attributedTo?: unknown };
1038
+ } catch {
1039
+ // Unresolvable / oversized / timed-out fetch: drop silently (the caller
1040
+ // logs at debug level), matching the pre-existing unknown-object behavior.
1041
+ return false;
1042
+ }
1043
+
1044
+ if (note.id !== objectId) return false;
1045
+ if (!typeIncludes(note.type, "Note")) return false;
1046
+
1047
+ const attributedTo =
1048
+ typeof note.attributedTo === "string" ? note.attributedTo : null;
1049
+ if (!attributedTo || !isSafeRemoteUrl(attributedTo)) return false;
1050
+ try {
1051
+ if (getDomain(attributedTo) !== getDomain(objectId)) return false;
1052
+ } catch {
1053
+ return false;
1054
+ }
1055
+
1056
+ // Addressing gates: a DM-shaped object must never be stored world-readable,
1057
+ // and a non-public classification is refused outright (see doc comment).
1058
+ if (isDirectShapedNote(note)) return false;
1059
+ const visibility = classifyInboundNoteVisibility(note);
1060
+ if (visibility !== "public" && visibility !== "unlisted") return false;
1061
+
1062
+ // Best-effort author profile fill so the surfaced boost renders with the
1063
+ // author's name/icon. Cache-when-absent; a failure never blocks the persist.
1064
+ const cachedAuthor = await db
1065
+ .select({ apId: actorCache.apId })
1066
+ .from(actorCache)
1067
+ .where(eq(actorCache.apId, attributedTo))
1068
+ .get();
1069
+ if (!cachedAuthor) {
1070
+ try {
1071
+ await fetchAndUpsertActorCache(db, attributedTo, {
1072
+ timeout: ANNOUNCED_OBJECT_FETCH_TIMEOUT_MS,
1073
+ mode: "insert",
1074
+ signer: (await getInstanceFetchSignerByDb(db)) ?? undefined,
1075
+ });
1076
+ } catch {
1077
+ /* best-effort */
1078
+ }
1079
+ }
1080
+
1081
+ const attachments = note.attachment ? JSON.stringify(note.attachment) : "[]";
1082
+ await db
1083
+ .insert(objects)
1084
+ .values({
1085
+ apId: objectId,
1086
+ type: "Note",
1087
+ attributedTo,
1088
+ content: boundInboundContent(note.content),
1089
+ summary: boundInboundSummary(note.summary),
1090
+ attachmentsJson: boundAttachmentsJson(attachments),
1091
+ // Stored verbatim, never resolved (depth cap): a boosted reply keeps its
1092
+ // honest thread link even though the parent may stay unknown here.
1093
+ inReplyTo: note.inReplyTo || null,
1094
+ visibility,
1095
+ toJson: boundAddressJson(note.to),
1096
+ ccJson: boundAddressJson(note.cc),
1097
+ communityApId: null,
1098
+ published: normalizeInboundTimestamp(
1099
+ note.published,
1100
+ new Date().toISOString(),
1101
+ ),
1102
+ isLocal: 0,
1103
+ })
1104
+ .onConflictDoNothing();
1105
+
1106
+ return true;
1107
+ }
1108
+
910
1109
  // ---------------------------------------------------------------------------
911
1110
  // Delete handler
912
1111
  // ---------------------------------------------------------------------------
@@ -1,12 +1,6 @@
1
1
  import type { Database } from "../../../../db/index.ts";
2
- import { and, eq, gt, inArray, sql } from "drizzle-orm";
3
- import {
4
- activities,
5
- actors,
6
- follows,
7
- likes,
8
- objects,
9
- } from "../../../../db/index.ts";
2
+ import { and, eq, gt, or, sql } from "drizzle-orm";
3
+ import { activities, actors, follows } from "../../../../db/index.ts";
10
4
  import {
11
5
  activityApId,
12
6
  generateId,
@@ -43,6 +37,7 @@ export async function handleFollow(
43
37
  recipient: ActorRow,
44
38
  actor: string,
45
39
  baseUrl: string,
40
+ sourceActivityId: string = activity.id || "",
46
41
  ) {
47
42
  const db = c.get("db");
48
43
 
@@ -152,7 +147,9 @@ export async function handleFollow(
152
147
  id: acceptId,
153
148
  type: "Accept",
154
149
  actor: recipient.apId,
155
- object: activityId,
150
+ // Echo the peer's bounded protocol id, not our origin-bound internal
151
+ // ledger id. Remote servers match Accept.object to their Follow id.
152
+ object: sourceActivityId || activityId,
156
153
  };
157
154
 
158
155
  // Store accept activity before enqueue.
@@ -160,7 +157,7 @@ export async function handleFollow(
160
157
  apId: acceptId,
161
158
  type: "Accept",
162
159
  actorApId: recipient.apId,
163
- objectApId: activityId,
160
+ objectApId: sourceActivityId || activityId,
164
161
  rawJson: JSON.stringify(acceptActivity),
165
162
  direction: "outbound",
166
163
  });
@@ -294,10 +291,20 @@ export async function handleReject(
294
291
  // Undo handler
295
292
  // ---------------------------------------------------------------------------
296
293
 
294
+ /**
295
+ * `recipient` is null when the Undo arrived at the shared inbox as an
296
+ * INSTANCE-scoped activity — i.e. Undo(Like|Announce), which is actor-keyed
297
+ * and idempotent and names no local actor. Undo(Follow|Block) is object-actor
298
+ * scoped and always dispatched with its resolved target, so a null recipient
299
+ * on a follow branch means the target could not be resolved; that is logged
300
+ * and refused rather than guessed at. The previous code guessed: it fell back
301
+ * to deleting the signer's likes across EVERY object the "recipient" had
302
+ * authored and rewriting every one of that actor's `objects` rows.
303
+ */
297
304
  export async function handleUndo(
298
305
  c: ActivityContext,
299
306
  activity: Activity,
300
- recipient: ActorRow,
307
+ recipient: ActorRow | null,
301
308
  actor: string,
302
309
  _baseUrl: string,
303
310
  ) {
@@ -318,9 +325,17 @@ export async function handleUndo(
318
325
  }
319
326
 
320
327
  if (typeIncludes(objectType, "Follow")) {
328
+ if (!recipient) {
329
+ log.warn("Undo(Follow) without a resolved local target", {
330
+ event: "ap.undo.follow.no_recipient",
331
+ actor,
332
+ activityId: activity.id,
333
+ });
334
+ return;
335
+ }
321
336
  await undoFollow(db, objectId, actor, recipient);
322
337
  } else if (typeIncludes(objectType, "Like")) {
323
- await undoLike(db, objectId, activityObject, actor, recipient);
338
+ await undoLike(db, objectId, activityObject, actor);
324
339
  } else if (typeIncludes(objectType, "Announce")) {
325
340
  await undoAnnounce(db, objectId, activityObject, actor);
326
341
  }
@@ -339,16 +354,25 @@ async function resolveUndoByActivityId(
339
354
  db: Database,
340
355
  objectId: string,
341
356
  actor: string,
342
- recipient: ActorRow,
357
+ recipient: ActorRow | null,
343
358
  ): Promise<boolean> {
344
359
  const originalActivity = await db
345
360
  .select({
361
+ apId: activities.apId,
346
362
  type: activities.type,
347
363
  objectApId: activities.objectApId,
348
364
  actorApId: activities.actorApId,
349
365
  })
350
366
  .from(activities)
351
- .where(eq(activities.apId, objectId))
367
+ .where(
368
+ or(
369
+ eq(activities.apId, objectId),
370
+ and(
371
+ eq(activities.direction, "inbound"),
372
+ sql`json_extract(${activities.rawJson}, '$.id') = ${objectId}`,
373
+ ),
374
+ ),
375
+ )
352
376
  .get();
353
377
  if (!originalActivity) return false;
354
378
 
@@ -363,7 +387,17 @@ async function resolveUndoByActivityId(
363
387
  }
364
388
 
365
389
  if (originalActivity.type === "Follow") {
366
- const follow = await findFollowByActivityId(db, objectId);
390
+ // The followerCount decrement is keyed on the followed actor, so an
391
+ // instance-dispatched Undo (no resolved local target) must not run it.
392
+ if (!recipient) {
393
+ log.warn("Undo(Follow) by activity id without a resolved local target", {
394
+ event: "ap.undo.follow.no_recipient",
395
+ actor,
396
+ activityId: objectId,
397
+ });
398
+ return true;
399
+ }
400
+ const follow = await findFollowByActivityId(db, originalActivity.apId);
367
401
  if (follow) {
368
402
  await undoFollowEdge(
369
403
  db,
@@ -391,7 +425,14 @@ async function resolveUndoByActivityId(
391
425
  // A crash-then-retry converges (the recompute is idempotent against the true
392
426
  // edge set) instead of permanently over-counting on the retry's no-op
393
427
  // delete. The actor-mismatch guard above still constrains who may undo.
394
- await undoInteraction(db, kind, countField, undefined, objectId, actor);
428
+ await undoInteraction(
429
+ db,
430
+ kind,
431
+ countField,
432
+ undefined,
433
+ originalActivity.apId,
434
+ actor,
435
+ );
395
436
  return true;
396
437
  }
397
438
 
@@ -476,12 +517,26 @@ async function undoFollow(
476
517
  await undoFollowEdge(db, followerApId, followingApId, recipient.apId);
477
518
  }
478
519
 
520
+ /**
521
+ * Undo(Like).
522
+ *
523
+ * If the like edge cannot be identified — from the inner object's `object`, or
524
+ * from the referenced Like activity id — there is NOTHING to undo and we say
525
+ * so. There used to be a "last resort" here that deleted every like the signer
526
+ * held on ANY object authored by the delivery recipient and then recomputed
527
+ * `likeCount` for EVERY one of that actor's `objects` rows. It was reachable
528
+ * from a malformed `{type:'Undo', object:{type:'Like', id:...}}` with no inner
529
+ * `object` (and from the actor-mismatch branch), and under the shared inbox it
530
+ * ran once per local follower, so a single non-conformant peer could both wipe
531
+ * a user's likes across an author's whole timeline and amplify a full-table
532
+ * UPDATE by the follower count. Nothing about the activity authorised that
533
+ * scope: the only thing it identified was the signer.
534
+ */
479
535
  async function undoLike(
480
536
  db: Database,
481
537
  objectId: string | null,
482
538
  activityObject: ReturnType<typeof getActivityObject>,
483
539
  actor: string,
484
- recipient: ActorRow,
485
540
  ): Promise<void> {
486
541
  const handled = await undoInteraction(
487
542
  db,
@@ -493,43 +548,13 @@ async function undoLike(
493
548
  );
494
549
  if (handled) return;
495
550
 
496
- // Last resort: delete any like from this actor for the recipient's objects.
497
- // #COUNTER-SYM: co-commit the edge delete and a COUNT(*) recompute of likeCount
498
- // in one atomic batch (mirrors the primary undoInteraction path). Previously
499
- // the delete committed first and the count was decremented (`- 1`) in a
500
- // SEPARATE statement; a crash between them, then a peer retry whose delete
501
- // matches 0 rows, permanently SKIPPED the decrement → a like OVER-count.
502
- // Deriving each affected object's likeCount from COUNT(*) of the remaining like
503
- // rows makes the fallback idempotent so a crash-then-retry CONVERGES instead of
504
- // drifting.
505
- // The recipient (local user) can have authored thousands of objects; splicing
506
- // every id into an `IN (...)` would blow D1's 100-bound-parameter ceiling.
507
- // Express the affected set as a SUBQUERY (delete) and a direct predicate
508
- // (update) so no per-object parameter is bound. An empty set is a harmless
509
- // no-op batch, so the previous early-return is unnecessary.
510
- const recipientObjectsSubquery = db
511
- .select({ apId: objects.apId })
512
- .from(objects)
513
- .where(eq(objects.attributedTo, recipient.apId));
514
-
515
- await runBatch(db, [
516
- db
517
- .delete(likes)
518
- .where(
519
- and(
520
- eq(likes.actorApId, actor),
521
- inArray(likes.objectApId, recipientObjectsSubquery),
522
- ),
523
- ),
524
- db
525
- .update(objects)
526
- .set({
527
- likeCount: sql`(SELECT COUNT(*) FROM ${likes} WHERE ${likes.objectApId} = ${objects.apId})`,
528
- })
529
- .where(eq(objects.attributedTo, recipient.apId)),
530
- ]);
551
+ log.warn("Undo(Like) names no resolvable like edge; ignoring", {
552
+ event: "ap.undo.like.unresolved",
553
+ actor,
554
+ activityId: objectId,
555
+ object: activityObject?.object,
556
+ });
531
557
  }
532
-
533
558
  async function undoAnnounce(
534
559
  db: Database,
535
560
  objectId: string | null,
@@ -20,7 +20,9 @@ import {
20
20
  getActivityObjectId,
21
21
  } from "../inbox-types.ts";
22
22
  import { notifyLocalObjectOwner } from "./inbox-shared-helpers.ts";
23
+ import { fetchAndPersistAnnouncedNote } from "./inbox-content-handlers.ts";
23
24
  import { isLocal } from "../../../lib/ap-ids.ts";
25
+ import { notDeleted } from "../../../../db/index.ts";
24
26
  import {
25
27
  actorIsBlockedBy,
26
28
  canViewerReadObjectFull,
@@ -174,10 +176,15 @@ async function handleInteraction(
174
176
  // Like handler
175
177
  // ---------------------------------------------------------------------------
176
178
 
179
+ /**
180
+ * A Like is INSTANCE-scoped: the affected object (and the local owner to
181
+ * notify) is resolved from `activity.object`, never from a delivery recipient.
182
+ * The recipient argument was already ignored; removing it is what stops the
183
+ * shared inbox from being able to run this once per local follower.
184
+ */
177
185
  export async function handleLike(
178
186
  c: ActivityContext,
179
187
  activity: Activity,
180
- _recipient: ActorRow,
181
188
  actor: string,
182
189
  baseUrl: string,
183
190
  ) {
@@ -188,13 +195,70 @@ export async function handleLike(
188
195
  // Announce handler (repost/boost)
189
196
  // ---------------------------------------------------------------------------
190
197
 
198
+ /**
199
+ * Does at least one (non-tombstoned) LOCAL actor have an accepted follow of
200
+ * `actorApId`? The fetch-and-store path below is gated on this so an arbitrary
201
+ * remote cannot use our inbox as an open relay: only a boost from someone a
202
+ * local user chose to follow may trigger an outbound object fetch.
203
+ */
204
+ async function actorHasLocalFollowers(
205
+ db: Database,
206
+ actorApId: string,
207
+ ): Promise<boolean> {
208
+ const row = await db
209
+ .select({ followerApId: follows.followerApId })
210
+ .from(follows)
211
+ .innerJoin(
212
+ actors,
213
+ and(eq(actors.apId, follows.followerApId), notDeleted(actors)),
214
+ )
215
+ .where(
216
+ and(eq(follows.followingApId, actorApId), eq(follows.status, "accepted")),
217
+ )
218
+ .limit(1)
219
+ .get();
220
+ return Boolean(row);
221
+ }
222
+
223
+ /** Instance-scoped, exactly like {@link handleLike}. */
191
224
  export async function handleAnnounce(
192
225
  c: ActivityContext,
193
226
  activity: Activity,
194
- _recipient: ActorRow,
195
227
  actor: string,
196
228
  baseUrl: string,
197
229
  ) {
230
+ // Fetch-and-store an UNKNOWN boosted remote object before recording the
231
+ // announce, so a followed remote's boost of a post this instance never saw
232
+ // still surfaces in feeds (previously the Announce only left a dangling
233
+ // announce edge + a counter no-op). Strictly gated: the target must be
234
+ // remote and unknown, and the booster must have at least one local follower
235
+ // (no open-relay amplification). On any fetch/validation failure the
236
+ // Announce degrades to exactly the old behavior — handleInteraction records
237
+ // the edge idempotently and the counter recompute no-ops on the absent row,
238
+ // so a later Create of the same object retroactively completes the boost.
239
+ const db = c.get("db");
240
+ const objectId = getActivityObjectId(activity);
241
+ if (objectId && !isLocal(objectId, baseUrl)) {
242
+ const known = await db
243
+ .select({ apId: objects.apId })
244
+ .from(objects)
245
+ .where(eq(objects.apId, objectId))
246
+ .get();
247
+ if (!known && (await actorHasLocalFollowers(db, actor))) {
248
+ const persisted = await fetchAndPersistAnnouncedNote(
249
+ db,
250
+ objectId,
251
+ baseUrl,
252
+ );
253
+ if (!persisted) {
254
+ log.debug("Announce target fetch skipped or failed", {
255
+ event: "ap.announce.object_fetch_failed",
256
+ actor,
257
+ objectId,
258
+ });
259
+ }
260
+ }
261
+ }
198
262
  await handleInteraction("announce", c, activity, actor, baseUrl);
199
263
  }
200
264
 
@@ -23,18 +23,13 @@ import type { Activity } from "../inbox-types.ts";
23
23
  // ---------------------------------------------------------------------------
24
24
 
25
25
  type BatchStatement = BatchItem<"sqlite">;
26
- interface BatchableDb {
27
- batch(
28
- statements: readonly [BatchStatement, ...BatchStatement[]],
29
- ): Promise<unknown>;
30
- }
31
26
 
32
- export async function runBatch(
33
- db: Database,
34
- statements: readonly [BatchStatement, ...BatchStatement[]],
35
- ): Promise<void> {
36
- await (db as unknown as BatchableDb).batch(statements);
37
- }
27
+ // The batch primitive lives with the other D1 write primitives (src/db/
28
+ // d1-write.ts) so the atomicity rule and the parameter-budget rule cannot
29
+ // drift apart. Re-exported here because the inbox handlers are its heaviest
30
+ // caller and this is the import path they already use.
31
+ export { runBatch } from "../../../../db/d1-write.ts";
32
+ import { runBatch } from "../../../../db/d1-write.ts";
38
33
 
39
34
  // ---------------------------------------------------------------------------
40
35
  // Shared helpers used by multiple inbox handler files
@@ -97,7 +92,7 @@ export async function findFollowByActivityId(
97
92
  followingApId: string;
98
93
  status: string;
99
94
  } | null> {
100
- const row = await db
95
+ let row = await db
101
96
  .select({
102
97
  followerApId: follows.followerApId,
103
98
  followingApId: follows.followingApId,
@@ -106,6 +101,34 @@ export async function findFollowByActivityId(
106
101
  .from(follows)
107
102
  .where(eq(follows.activityApId, activityApIdValue))
108
103
  .get();
104
+ if (!row) {
105
+ // Inbound envelopes use an origin-bound internal activities.apId. Undo
106
+ // carries the peer's protocol id, so resolve that bounded raw-json id back
107
+ // to the internal row before looking up the follow edge. The direct lookup
108
+ // above preserves compatibility with legacy rows.
109
+ const source = await db
110
+ .select({ apId: activities.apId })
111
+ .from(activities)
112
+ .where(
113
+ and(
114
+ eq(activities.direction, "inbound"),
115
+ sql`json_extract(${activities.rawJson}, '$.id') = ${activityApIdValue}`,
116
+ ),
117
+ )
118
+ .limit(2);
119
+ for (const candidate of source) {
120
+ row = await db
121
+ .select({
122
+ followerApId: follows.followerApId,
123
+ followingApId: follows.followingApId,
124
+ status: follows.status,
125
+ })
126
+ .from(follows)
127
+ .where(eq(follows.activityApId, candidate.apId))
128
+ .get();
129
+ if (row) break;
130
+ }
131
+ }
109
132
  return row ?? null;
110
133
  }
111
134