@takosjp/yurucommu-core 3.4.3 → 3.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/migrations/0023_delivery_resolution_outbox.sql +33 -0
  2. package/migrations/0024_delivery_fanout_outbox.sql +35 -0
  3. package/migrations/0025_delivery_endpoint_terminal_retention.sql +6 -0
  4. package/migrations/0026_remote_actor_fetch_failures.sql +29 -0
  5. package/migrations/0027_remote_actor_tombstones.sql +12 -0
  6. package/migrations/0028_remote_actor_delivery_fence.sql +30 -0
  7. package/migrations/0029_delivery_endpoint_recipients.sql +47 -0
  8. package/package.json +2 -1
  9. package/packages/api/package.json +1 -1
  10. package/packages/api/src/lib/api/communities.ts +2 -0
  11. package/packages/api/src/lib/api/fetch.ts +69 -13
  12. package/packages/api/src/lib/api/normalize.ts +32 -10
  13. package/packages/api/src/lib/api/notifications.ts +4 -1
  14. package/packages/api/src/lib/rtc-client.ts +127 -29
  15. package/src/backend/federation-helpers.ts +1 -0
  16. package/src/backend/index.ts +16 -3
  17. package/src/backend/lib/account-migration.ts +340 -0
  18. package/src/backend/lib/activity-delete-cascade.ts +193 -0
  19. package/src/backend/lib/activitypub-actor-cache.ts +615 -48
  20. package/src/backend/lib/activitypub-actor-identity-sql.ts +179 -0
  21. package/src/backend/lib/activitypub-actor-identity.ts +39 -0
  22. package/src/backend/lib/activitypub-validators.ts +62 -4
  23. package/src/backend/lib/ap-ids.ts +36 -6
  24. package/src/backend/lib/ap-verify.ts +7 -17
  25. package/src/backend/lib/blocklist-purge.ts +676 -42
  26. package/src/backend/lib/blocklist.ts +278 -39
  27. package/src/backend/lib/community-visibility.ts +47 -33
  28. package/src/backend/lib/delivery/fanout-outbox.ts +336 -0
  29. package/src/backend/lib/delivery/planner.ts +16 -12
  30. package/src/backend/lib/delivery/queue-batching.ts +389 -145
  31. package/src/backend/lib/delivery/queue-delivery.ts +47 -25
  32. package/src/backend/lib/delivery/queue.ts +479 -73
  33. package/src/backend/lib/delivery/resolution-outbox.ts +456 -0
  34. package/src/backend/lib/delivery/types.ts +31 -7
  35. package/src/backend/lib/feed-exclude.ts +40 -28
  36. package/src/backend/lib/follow-edge-mutations.ts +217 -0
  37. package/src/backend/lib/notification-eligibility.ts +15 -9
  38. package/src/backend/lib/notification-push.ts +2 -2
  39. package/src/backend/lib/oidc-id-token.ts +66 -0
  40. package/src/backend/lib/personal-actor-moderation.ts +239 -0
  41. package/src/backend/lib/post-visibility.ts +79 -16
  42. package/src/backend/lib/remote-activity-id.ts +61 -0
  43. package/src/backend/lib/unread-counts.ts +3 -0
  44. package/src/backend/retention.ts +38 -1
  45. package/src/backend/routes/account-teardown.ts +422 -156
  46. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +104 -105
  47. package/src/backend/routes/activitypub/handlers/inbound-community-scope.ts +121 -0
  48. package/src/backend/routes/activitypub/handlers/inbound-object-identity.ts +54 -0
  49. package/src/backend/routes/activitypub/handlers/inbound-reply-target.ts +34 -0
  50. package/src/backend/routes/activitypub/handlers/inbound-story-projection.ts +438 -0
  51. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1121 -806
  52. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +118 -153
  53. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +185 -168
  54. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +220 -32
  55. package/src/backend/routes/activitypub/inbound-activity-identity.ts +16 -0
  56. package/src/backend/routes/activitypub/inbound-activity-reference.ts +116 -0
  57. package/src/backend/routes/activitypub/inbound-addressing.ts +87 -0
  58. package/src/backend/routes/activitypub/inbox-addressing.ts +22 -19
  59. package/src/backend/routes/activitypub/inbox-types.ts +14 -2
  60. package/src/backend/routes/activitypub/inbox.ts +81 -105
  61. package/src/backend/routes/activitypub/outbox.ts +0 -0
  62. package/src/backend/routes/activitypub.ts +6 -5
  63. package/src/backend/routes/actors-helpers.ts +34 -8
  64. package/src/backend/routes/actors.ts +328 -164
  65. package/src/backend/routes/auth-helpers.ts +57 -9
  66. package/src/backend/routes/auth.ts +10 -2
  67. package/src/backend/routes/communities/membership-invites.ts +4 -1
  68. package/src/backend/routes/communities/membership-members.ts +201 -73
  69. package/src/backend/routes/communities/membership-requests.ts +141 -76
  70. package/src/backend/routes/communities/membership-shared.ts +228 -21
  71. package/src/backend/routes/communities/messages.ts +9 -2
  72. package/src/backend/routes/communities/routes.ts +48 -13
  73. package/src/backend/routes/dm/contacts.ts +29 -41
  74. package/src/backend/routes/dm/conversations-helpers.ts +9 -1
  75. package/src/backend/routes/dm/messages.ts +36 -42
  76. package/src/backend/routes/dm/read-archive.ts +4 -2
  77. package/src/backend/routes/dm/requests.ts +62 -54
  78. package/src/backend/routes/follow-helpers.ts +200 -60
  79. package/src/backend/routes/follow.ts +146 -140
  80. package/src/backend/routes/media.ts +20 -94
  81. package/src/backend/routes/moderation.ts +72 -4
  82. package/src/backend/routes/notes.ts +3 -4
  83. package/src/backend/routes/notifications.ts +209 -108
  84. package/src/backend/routes/posts/delete-cascade.ts +253 -89
  85. package/src/backend/routes/posts/federation.ts +373 -0
  86. package/src/backend/routes/posts/interactions.ts +160 -184
  87. package/src/backend/routes/posts/like-mutation.ts +240 -0
  88. package/src/backend/routes/posts/post-helpers.ts +112 -162
  89. package/src/backend/routes/posts/queries.ts +150 -54
  90. package/src/backend/routes/posts/routes.ts +169 -329
  91. package/src/backend/routes/posts/transformers.ts +61 -6
  92. package/src/backend/routes/recommendations.ts +37 -44
  93. package/src/backend/routes/rtc/index.ts +26 -6
  94. package/src/backend/routes/search.ts +39 -55
  95. package/src/backend/routes/stories/interactions.ts +22 -6
  96. package/src/backend/routes/stories/query-helpers.ts +28 -41
  97. package/src/backend/routes/stories/routes.ts +125 -108
  98. package/src/backend/routes/takos-tools/dm.ts +17 -17
  99. package/src/backend/routes/takos-tools/posts.ts +189 -106
  100. package/src/backend/routes/takos-tools/search.ts +13 -4
  101. package/src/backend/routes/takos-tools/timeline.ts +35 -27
  102. package/src/backend/routes/takos-tools-response.ts +6 -2
  103. package/src/backend/routes/timeline.ts +5 -5
  104. package/src/backend/runtime/call-signaling-do.ts +35 -4
  105. package/src/backend/runtime/one-time-ticket.ts +116 -0
  106. package/src/backend/runtime/realtime-stream-do.ts +5 -61
  107. package/src/backend/runtime/signaling-hub.ts +36 -3
  108. package/src/backend/server.ts +95 -80
  109. package/src/db/d1-write.ts +67 -43
  110. package/src/db/schema/federation.ts +42 -1
  111. package/src/db/schema/messaging.ts +110 -2
  112. package/src/db/schema.ts +1 -1
@@ -10,6 +10,10 @@ import {
10
10
  objects,
11
11
  } from "../../../../db/index.ts";
12
12
  import { isLocal } from "../../../federation-helpers.ts";
13
+ import { activityPubActorIdentityMatchesSql } from "../../../lib/activitypub-actor-identity-sql.ts";
14
+ import { isSameActivityPubActor } from "../../../lib/activitypub-actor-identity.ts";
15
+ import { isBoundedHttpActivityId } from "../../../lib/remote-activity-id.ts";
16
+ import { resolveInboundActivityReference } from "../inbound-activity-reference.ts";
13
17
  import type { Activity } from "../inbox-types.ts";
14
18
 
15
19
  // ---------------------------------------------------------------------------
@@ -83,50 +87,190 @@ const INTERACTION_TABLES: Record<"like" | "announce", InteractionTable> = {
83
87
  announce: announces,
84
88
  };
85
89
 
86
- /** Find a follow by activityApId and return it (or null). */
87
- export async function findFollowByActivityId(
90
+ /**
91
+ * Resolve a follow edge owned by the verified follower identity. Exact DB keys
92
+ * are the steady-state path; the fallback keeps every pre-invariant row
93
+ * reachable without treating a same-origin sibling as the same actor.
94
+ */
95
+ export async function findFollowByFollowerIdentity(
88
96
  db: Database,
89
- activityApIdValue: string,
90
- ): Promise<{
97
+ followerApId: string,
98
+ followingApId: string,
99
+ ) {
100
+ const exact = await db
101
+ .select()
102
+ .from(follows)
103
+ .where(
104
+ and(
105
+ eq(follows.followerApId, followerApId),
106
+ eq(follows.followingApId, followingApId),
107
+ ),
108
+ )
109
+ .get();
110
+ if (exact) return exact;
111
+
112
+ const retainedFollowers = activityPubActorIdentityMatchesSql(
113
+ sql`
114
+ SELECT ${follows.followerApId}
115
+ FROM ${follows}
116
+ WHERE ${follows.followingApId} = ${followingApId}
117
+ `,
118
+ followerApId,
119
+ );
120
+ return await db
121
+ .select()
122
+ .from(follows)
123
+ .where(
124
+ and(
125
+ eq(follows.followingApId, followingApId),
126
+ sql`${follows.followerApId} IN (${retainedFollowers})`,
127
+ ),
128
+ )
129
+ .limit(1)
130
+ .get();
131
+ }
132
+
133
+ /** Same compatibility lookup when the verified actor owns the followee side. */
134
+ export async function findFollowByFollowingIdentity(
135
+ db: Database,
136
+ followerApId: string,
137
+ followingApId: string,
138
+ ) {
139
+ const exact = await db
140
+ .select()
141
+ .from(follows)
142
+ .where(
143
+ and(
144
+ eq(follows.followerApId, followerApId),
145
+ eq(follows.followingApId, followingApId),
146
+ ),
147
+ )
148
+ .get();
149
+ if (exact) return exact;
150
+
151
+ const retainedFollowing = activityPubActorIdentityMatchesSql(
152
+ sql`
153
+ SELECT ${follows.followingApId}
154
+ FROM ${follows}
155
+ WHERE ${follows.followerApId} = ${followerApId}
156
+ `,
157
+ followingApId,
158
+ );
159
+ return await db
160
+ .select()
161
+ .from(follows)
162
+ .where(
163
+ and(
164
+ eq(follows.followerApId, followerApId),
165
+ sql`${follows.followingApId} IN (${retainedFollowing})`,
166
+ ),
167
+ )
168
+ .limit(1)
169
+ .get();
170
+ }
171
+
172
+ type FollowLookupRow = {
91
173
  followerApId: string;
92
174
  followingApId: string;
93
175
  status: string;
94
- } | null> {
95
- let row = await db
96
- .select({
97
- followerApId: follows.followerApId,
98
- followingApId: follows.followingApId,
99
- status: follows.status,
100
- })
176
+ };
177
+
178
+ async function findActorOwnedFollowByActivityId(
179
+ db: Database,
180
+ activityApIdValue: string,
181
+ actorApId: string,
182
+ ): Promise<FollowLookupRow | null> {
183
+ const columns = {
184
+ followerApId: follows.followerApId,
185
+ followingApId: follows.followingApId,
186
+ status: follows.status,
187
+ };
188
+ const exact = await db
189
+ .select(columns)
101
190
  .from(follows)
102
- .where(eq(follows.activityApId, activityApIdValue))
191
+ .where(
192
+ and(
193
+ eq(follows.activityApId, activityApIdValue),
194
+ eq(follows.followerApId, actorApId),
195
+ ),
196
+ )
103
197
  .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)
198
+ if (exact) return exact;
199
+
200
+ // Current ingress stores the verified key-owner spelling, so this only
201
+ // serves rows created before that invariant. Match the complete retained set:
202
+ // a public activity id may be repeated and must not make an older legitimate
203
+ // edge unreachable or authorize a same-origin sibling.
204
+ const retainedFollowers = activityPubActorIdentityMatchesSql(
205
+ sql`
206
+ SELECT ${follows.followerApId}
207
+ FROM ${follows}
208
+ WHERE ${follows.activityApId} = ${activityApIdValue}
209
+ `,
210
+ actorApId,
211
+ );
212
+ return (
213
+ (await db
214
+ .select(columns)
215
+ .from(follows)
112
216
  .where(
113
217
  and(
114
- eq(activities.direction, "inbound"),
115
- sql`json_extract(${activities.rawJson}, '$.id') = ${activityApIdValue}`,
218
+ eq(follows.activityApId, activityApIdValue),
219
+ sql`${follows.followerApId} IN (${retainedFollowers})`,
116
220
  ),
117
221
  )
118
- .limit(2);
119
- for (const candidate of source) {
120
- row = await db
222
+ .limit(1)
223
+ .get()) ?? null
224
+ );
225
+ }
226
+
227
+ /**
228
+ * Find a follow by its retained activity IRI. When `source` is present, a peer
229
+ * wire ID may also resolve through that verified actor's inbound ledger row.
230
+ */
231
+ export async function findFollowByActivityId(
232
+ db: Database,
233
+ activityApIdValue: string,
234
+ source?: { actorApId: string; localBaseUrl: string },
235
+ ): Promise<{
236
+ followerApId: string;
237
+ followingApId: string;
238
+ status: string;
239
+ } | null> {
240
+ if (!isBoundedHttpActivityId(activityApIdValue)) return null;
241
+
242
+ let row = source
243
+ ? await findActorOwnedFollowByActivityId(
244
+ db,
245
+ activityApIdValue,
246
+ source.actorApId,
247
+ )
248
+ : await db
121
249
  .select({
122
250
  followerApId: follows.followerApId,
123
251
  followingApId: follows.followingApId,
124
252
  status: follows.status,
125
253
  })
126
254
  .from(follows)
127
- .where(eq(follows.activityApId, candidate.apId))
255
+ .where(eq(follows.activityApId, activityApIdValue))
128
256
  .get();
129
- if (row) break;
257
+ if (!row && source) {
258
+ // Inbound envelopes use an origin-bound internal activities.apId. Undo
259
+ // carries the peer's protocol id, so resolve that bounded raw-json id back
260
+ // to the internal row before looking up the follow edge. The direct lookup
261
+ // above preserves compatibility with legacy rows.
262
+ const internalActivityApId = await resolveInboundActivityReference(
263
+ db,
264
+ activityApIdValue,
265
+ source.actorApId,
266
+ source.localBaseUrl,
267
+ );
268
+ if (internalActivityApId) {
269
+ row = await findActorOwnedFollowByActivityId(
270
+ db,
271
+ internalActivityApId,
272
+ source.actorApId,
273
+ );
130
274
  }
131
275
  }
132
276
  return row ?? null;
@@ -224,6 +368,15 @@ export async function undoInteraction(
224
368
  const cf = countField ?? COUNT_FIELDS[kind];
225
369
 
226
370
  if (directObjectId) {
371
+ const retainedActors = activityPubActorIdentityMatchesSql(
372
+ sql`
373
+ SELECT ${table.actorApId}
374
+ FROM ${table}
375
+ WHERE ${table.objectApId} = ${directObjectId}
376
+ `,
377
+ actor,
378
+ );
379
+
227
380
  // Delete the edge and recompute the counter from the remaining edge rows
228
381
  // atomically. A duplicate Undo (or an Undo of an interaction we never
229
382
  // recorded) deletes 0 rows and the recompute is a no-op against the
@@ -232,7 +385,10 @@ export async function undoInteraction(
232
385
  db
233
386
  .delete(table)
234
387
  .where(
235
- and(eq(table.actorApId, actor), eq(table.objectApId, directObjectId)),
388
+ and(
389
+ eq(table.objectApId, directObjectId),
390
+ sql`${table.actorApId} IN (${retainedActors})`,
391
+ ),
236
392
  ),
237
393
  db
238
394
  .update(objects)
@@ -248,27 +404,59 @@ export async function undoInteraction(
248
404
 
249
405
  // Resolve the edge by its activity ID, then commit the delete + recompute in
250
406
  // one batch keyed on the resolved objectApId.
251
- const record = await db
407
+ let record = await db
252
408
  .select({
253
409
  actorApId: table.actorApId,
254
410
  objectApId: table.objectApId,
255
411
  })
256
412
  .from(table)
257
- .where(eq(table.activityApId, activityId))
413
+ .where(and(eq(table.activityApId, activityId), eq(table.actorApId, actor)))
258
414
  .get();
415
+ if (!record) {
416
+ const retainedActors = activityPubActorIdentityMatchesSql(
417
+ sql`
418
+ SELECT ${table.actorApId}
419
+ FROM ${table}
420
+ WHERE ${table.activityApId} = ${activityId}
421
+ `,
422
+ actor,
423
+ );
424
+ record = await db
425
+ .select({
426
+ actorApId: table.actorApId,
427
+ objectApId: table.objectApId,
428
+ })
429
+ .from(table)
430
+ .where(
431
+ and(
432
+ eq(table.activityApId, activityId),
433
+ sql`${table.actorApId} IN (${retainedActors})`,
434
+ ),
435
+ )
436
+ .limit(1)
437
+ .get();
438
+ }
259
439
  if (record) {
260
440
  // Bind the undo to the VERIFIED signer. The activity id is public, so a
261
441
  // resolved edge whose owner != the signing actor is a cross-actor forgery
262
442
  // (a remote attacker undoing someone else's like/announce by id). The
263
443
  // directObjectId branch above already keys its delete on `actor`; mirror it.
264
- if (record.actorApId !== actor) return false;
444
+ if (!isSameActivityPubActor(record.actorApId, actor)) return false;
445
+ const retainedActors = activityPubActorIdentityMatchesSql(
446
+ sql`
447
+ SELECT ${table.actorApId}
448
+ FROM ${table}
449
+ WHERE ${table.objectApId} = ${record.objectApId}
450
+ `,
451
+ actor,
452
+ );
265
453
  await runBatch(db, [
266
454
  db
267
455
  .delete(table)
268
456
  .where(
269
457
  and(
270
- eq(table.actorApId, record.actorApId),
271
458
  eq(table.objectApId, record.objectApId),
459
+ sql`${table.actorApId} IN (${retainedActors})`,
272
460
  ),
273
461
  ),
274
462
  db
@@ -0,0 +1,16 @@
1
+ import { activityApId } from "../../federation-helpers.ts";
2
+ import { normalizeActivityPubActorId } from "../../lib/activitypub-actor-identity.ts";
3
+ import { sha256Hex } from "../../lib/delivery/transformers.ts";
4
+
5
+ /** Build the fixed-size, actor-scoped local ledger IRI for an inbound source. */
6
+ export async function internalInboundActivityId(
7
+ baseUrl: string,
8
+ actor: string,
9
+ source: string,
10
+ ): Promise<string> {
11
+ const actorIdentity = normalizeActivityPubActorId(actor) ?? actor;
12
+ return activityApId(
13
+ baseUrl,
14
+ `inbound-${await sha256Hex(`${actorIdentity}\0${source}`)}`,
15
+ );
16
+ }
@@ -0,0 +1,116 @@
1
+ import { and, eq, sql } from "drizzle-orm";
2
+
3
+ import type { Database } from "../../../db/index.ts";
4
+ import { activities } from "../../../db/index.ts";
5
+ import { isSameActivityPubActor } from "../../lib/activitypub-actor-identity.ts";
6
+ import { activityPubActorIdentityMatchesSql } from "../../lib/activitypub-actor-identity-sql.ts";
7
+ import {
8
+ isBoundedHttpActivityId,
9
+ isTrustedRemoteActivityId,
10
+ } from "../../lib/remote-activity-id.ts";
11
+ import { internalInboundActivityId } from "./inbound-activity-identity.ts";
12
+
13
+ /**
14
+ * Resolve an Activity reference controlled by a verified remote actor to the
15
+ * canonical local ledger IRI.
16
+ *
17
+ * The direct path covers the local `inbound-<hash>` IRI we may have exposed as
18
+ * a fallback. The raw-envelope path covers a normal peer wire ID, but only
19
+ * within the signing actor and only when that actor owns the ID's exact origin.
20
+ * Actor scoping is essential because Activity IDs are origin-owned: sibling
21
+ * actors on one server may otherwise collide and make a legitimate Undo bind
22
+ * to whichever row SQLite happens to return first.
23
+ */
24
+ export async function resolveInboundActivityReference(
25
+ db: Database,
26
+ reference: string,
27
+ actorApId: string,
28
+ localBaseUrl: string,
29
+ ): Promise<string | null> {
30
+ if (!isBoundedHttpActivityId(reference)) return null;
31
+
32
+ const direct = await db
33
+ .select({ apId: activities.apId, actorApId: activities.actorApId })
34
+ .from(activities)
35
+ .where(eq(activities.apId, reference))
36
+ .get();
37
+ if (direct && isSameActivityPubActor(direct.actorApId, actorApId)) {
38
+ return direct.apId;
39
+ }
40
+
41
+ if (!isTrustedRemoteActivityId(reference, actorApId, localBaseUrl)) {
42
+ return null;
43
+ }
44
+
45
+ // Current inbound rows use this deterministic actor+source key. Resolve it
46
+ // first so a sibling actor reusing the same public wire id can neither poison
47
+ // the lookup nor force a scan of retained envelopes.
48
+ const expectedInternalId = await internalInboundActivityId(
49
+ localBaseUrl,
50
+ actorApId,
51
+ reference,
52
+ );
53
+ const retainedCurrent = await db
54
+ .select({ apId: activities.apId, actorApId: activities.actorApId })
55
+ .from(activities)
56
+ .where(
57
+ and(
58
+ eq(activities.apId, expectedInternalId),
59
+ eq(activities.direction, "inbound"),
60
+ ),
61
+ )
62
+ .get();
63
+ if (
64
+ retainedCurrent &&
65
+ isSameActivityPubActor(retainedCurrent.actorApId, actorApId)
66
+ ) {
67
+ return retainedCurrent.apId;
68
+ }
69
+
70
+ // Preserve legacy rows whose primary key predates the deterministic key.
71
+ // Keep the common exact-spelling lookup indexed by actor first.
72
+ const retainedExact = await db
73
+ .select({ apId: activities.apId })
74
+ .from(activities)
75
+ .where(
76
+ and(
77
+ eq(activities.direction, "inbound"),
78
+ eq(activities.actorApId, actorApId),
79
+ // A corrupted/legacy raw_json row must not make the whole lookup throw.
80
+ sql`CASE WHEN json_valid(${activities.rawJson}) THEN json_extract(${activities.rawJson}, '$.id') ELSE NULL END = ${reference}`,
81
+ ),
82
+ )
83
+ .get();
84
+ if (retainedExact) return retainedExact.apId;
85
+
86
+ // Older rows may retain the same key owner under an accepted cosmetic actor
87
+ // spelling. Search the complete candidate set in SQL: a public wire id can
88
+ // collide across arbitrarily many same-origin sibling actors, so a fixed JS
89
+ // prefix made the legitimate signer-dependent row unreachable. The shared
90
+ // identity matcher keeps path case and sibling actor paths distinct while
91
+ // using a constant parameter count.
92
+ const retainedLegacyActors = activityPubActorIdentityMatchesSql(
93
+ sql`
94
+ SELECT ${activities.actorApId}
95
+ FROM ${activities}
96
+ WHERE ${activities.direction} = 'inbound'
97
+ AND CASE WHEN json_valid(${activities.rawJson})
98
+ THEN json_extract(${activities.rawJson}, '$.id')
99
+ ELSE NULL END = ${reference}
100
+ `,
101
+ actorApId,
102
+ );
103
+ const retainedLegacy = await db
104
+ .select({ apId: activities.apId, actorApId: activities.actorApId })
105
+ .from(activities)
106
+ .where(
107
+ and(
108
+ eq(activities.direction, "inbound"),
109
+ sql`CASE WHEN json_valid(${activities.rawJson}) THEN json_extract(${activities.rawJson}, '$.id') ELSE NULL END = ${reference}`,
110
+ sql`${activities.actorApId} IN (${retainedLegacyActors})`,
111
+ ),
112
+ )
113
+ .limit(1)
114
+ .get();
115
+ return retainedLegacy?.apId ?? null;
116
+ }
@@ -0,0 +1,87 @@
1
+ import type { Activity, ActivityObject } from "./inbox-types.ts";
2
+
3
+ export const MAX_INBOUND_ADDRESS_ENTRIES = 64;
4
+ export const MAX_INBOUND_ADDRESS_LENGTH = 2048;
5
+
6
+ const ADDRESS_FIELDS = ["to", "cc", "bto", "bcc", "audience"] as const;
7
+ type AddressField = (typeof ADDRESS_FIELDS)[number];
8
+ type AddressSource = Pick<
9
+ Activity | ActivityObject,
10
+ "to" | "cc" | "bto" | "bcc" | "audience"
11
+ >;
12
+
13
+ export type InboundAddressingFailureReason = "too_many" | "too_long";
14
+
15
+ export type InboundAddressingResult =
16
+ | { ok: true; addresses: string[] }
17
+ | {
18
+ ok: false;
19
+ reason: InboundAddressingFailureReason;
20
+ addresses: string[];
21
+ };
22
+
23
+ // The ActivityStreams public-collection magic value, including legacy short
24
+ // forms some implementations still emit.
25
+ const PUBLIC_COLLECTION = new Set([
26
+ "https://www.w3.org/ns/activitystreams#Public",
27
+ "as:Public",
28
+ "Public",
29
+ ]);
30
+
31
+ export function addressesPublic(addresses: readonly string[]): boolean {
32
+ return addresses.some((address) => PUBLIC_COLLECTION.has(address));
33
+ }
34
+
35
+ /**
36
+ * Validate one complete inbound addressing projection without truncation.
37
+ *
38
+ * A field/value pair is counted once across the envelope and embedded object,
39
+ * so peers that repeat the same projection in both places remain compatible.
40
+ * Field placement remains significant (`Public` in `to` and `cc` are distinct
41
+ * reach declarations), while duplicate entries inside one field do not create
42
+ * storage or lookup amplification. The returned flat list is de-duplicated for
43
+ * shared-inbox routing; callers retain their field-specific arrays separately.
44
+ */
45
+ export function collectBoundedInboundAddresses(
46
+ sources: readonly AddressSource[],
47
+ ): InboundAddressingResult {
48
+ const seenByField = new Map<AddressField, Set<string>>();
49
+ const addresses = new Set<string>();
50
+ let entryCount = 0;
51
+
52
+ for (const source of sources) {
53
+ for (const field of ADDRESS_FIELDS) {
54
+ const raw = source[field] as unknown;
55
+ const candidates =
56
+ typeof raw === "string" ? [raw] : Array.isArray(raw) ? raw : [];
57
+ let seen = seenByField.get(field);
58
+ if (!seen) {
59
+ seen = new Set<string>();
60
+ seenByField.set(field, seen);
61
+ }
62
+ for (const candidate of candidates) {
63
+ if (typeof candidate !== "string" || candidate.length === 0) continue;
64
+ if (candidate.length > MAX_INBOUND_ADDRESS_LENGTH) {
65
+ return {
66
+ ok: false,
67
+ reason: "too_long",
68
+ addresses: [...addresses],
69
+ };
70
+ }
71
+ if (seen.has(candidate)) continue;
72
+ if (entryCount >= MAX_INBOUND_ADDRESS_ENTRIES) {
73
+ return {
74
+ ok: false,
75
+ reason: "too_many",
76
+ addresses: [...addresses],
77
+ };
78
+ }
79
+ seen.add(candidate);
80
+ addresses.add(candidate);
81
+ entryCount += 1;
82
+ }
83
+ }
84
+ }
85
+
86
+ return { ok: true, addresses: [...addresses] };
87
+ }
@@ -29,8 +29,10 @@ import { isLocal } from "../../federation-helpers.ts";
29
29
  import type { Activity } from "./inbox-types.ts";
30
30
  import {
31
31
  addressesPublic,
32
- MAX_ADDRESS_ENTRIES,
33
- } from "./handlers/inbox-content-handlers.ts";
32
+ collectBoundedInboundAddresses,
33
+ type InboundAddressingFailureReason,
34
+ type InboundAddressingResult,
35
+ } from "./inbound-addressing.ts";
34
36
 
35
37
  export type ActorRow = typeof actors.$inferSelect;
36
38
 
@@ -114,21 +116,11 @@ export function isHandledActivityType(
114
116
  * `bto`/`bcc` are included: a peer that strips them before delivery simply
115
117
  * contributes nothing, and one that does not must still be honoured.
116
118
  */
117
- export function collectAddresses(activity: Activity): string[] {
119
+ export function collectAddresses(activity: Activity): InboundAddressingResult {
118
120
  const object = typeof activity.object === "string" ? null : activity.object;
119
- const all = [
120
- ...(activity.to ?? []),
121
- ...(activity.cc ?? []),
122
- ...(activity.bto ?? []),
123
- ...(activity.bcc ?? []),
124
- ...(activity.audience ?? []),
125
- ...(object?.to ?? []),
126
- ...(object?.cc ?? []),
127
- ];
128
- // Same bound the persisted addressing arrays use (policy defined once, in
129
- // inbox-content-handlers): a remote must not be able to make one delivery
130
- // cost an unbounded number of actor lookups.
131
- return [...new Set(all)].slice(0, MAX_ADDRESS_ENTRIES);
121
+ return collectBoundedInboundAddresses(
122
+ object ? [activity, object] : [activity],
123
+ );
132
124
  }
133
125
 
134
126
  /**
@@ -146,6 +138,8 @@ export type AddressingResolution = {
146
138
  readonly recipients: readonly ActorRow[];
147
139
  /** Addressing IRIs read off the activity, for logging. */
148
140
  readonly addresses: readonly string[];
141
+ /** Invalid projections are undeliverable and must never be truncated. */
142
+ readonly invalidReason?: InboundAddressingFailureReason;
149
143
  };
150
144
 
151
145
  /**
@@ -160,8 +154,8 @@ async function resolveAddressedLocalActors(
160
154
  (a) => isLocal(a, baseUrl) && !addressesPublic([a]),
161
155
  );
162
156
  if (localIris.length === 0) return [];
163
- // Bounded by MAX_ADDRESS_ENTRIES, but routed through inChunks anyway so the
164
- // D1 parameter rule holds if that bound is ever raised.
157
+ // Bounded by the shared 64-entry contract, but routed through inChunks so
158
+ // the D1 parameter rule holds if that bound is ever raised.
165
159
  return await inChunks(localIris, (chunk) =>
166
160
  db.query.actors.findMany({ where: inArray(actors.apId, [...chunk]) }),
167
161
  );
@@ -214,7 +208,16 @@ export async function resolveAddressedRecipients(
214
208
  baseUrl: string,
215
209
  followerFanoutLimit: number,
216
210
  ): Promise<AddressingResolution> {
217
- const addresses = collectAddresses(activity);
211
+ const collected = collectAddresses(activity);
212
+ if (!collected.ok) {
213
+ return {
214
+ cls: "addressed",
215
+ recipients: [],
216
+ addresses: collected.addresses,
217
+ invalidReason: collected.reason,
218
+ };
219
+ }
220
+ const addresses = collected.addresses;
218
221
  const addressed = await resolveAddressedLocalActors(db, addresses, baseUrl);
219
222
 
220
223
  const wantsAudience =
@@ -7,9 +7,13 @@ export type ActivityObject = {
7
7
  id?: string;
8
8
  type?: string | string[];
9
9
  object?: string;
10
- inReplyTo?: string;
10
+ /** Raw ingress value; Note handlers must validate before SQL or storage. */
11
+ inReplyTo?: unknown;
11
12
  to?: string[];
12
13
  cc?: string[];
14
+ bto?: string[];
15
+ bcc?: string[];
16
+ audience?: string[];
13
17
  conversation?: string;
14
18
  content?: string;
15
19
  summary?: string | null;
@@ -27,7 +31,15 @@ export type Activity = {
27
31
  type?: string;
28
32
  actor?: string;
29
33
  object?: string | ActivityObject;
34
+ /**
35
+ * Bounded object references extracted from scalar, embedded, or array-valued
36
+ * AS2 `object`. Standard Flag activities use an array; keeping the normalized
37
+ * ids separately lets ordinary handlers retain their scalar/object model.
38
+ */
39
+ objectIds?: string[];
30
40
  target?: string | ActivityObject;
41
+ /** Envelope-level content, used by Flag as the moderation reason. */
42
+ content?: string;
31
43
  room?: string;
32
44
  // Envelope addressing, preserved by parseActivity. Shared-inbox routing is
33
45
  // derived from these (inbox-addressing.ts); before they were parsed the
@@ -76,7 +88,7 @@ export function getActivityObject(activity: Activity): ActivityObject | null {
76
88
  }
77
89
 
78
90
  export function getActivityObjectId(activity: Activity): string | null {
79
- if (!activity.object) return null;
91
+ if (!activity.object) return activity.objectIds?.[0] ?? null;
80
92
  if (typeof activity.object === "string") return activity.object;
81
93
  return activity.object.id || null;
82
94
  }