@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
@@ -1,4 +1,5 @@
1
1
  import type { Database } from "../../../../db/index.ts";
2
+ import type { SQLiteColumn } from "drizzle-orm/sqlite-core";
2
3
  import {
3
4
  and,
4
5
  count,
@@ -7,72 +8,100 @@ import {
7
8
  inArray,
8
9
  isNotNull,
9
10
  isNull,
11
+ notExists,
10
12
  or,
11
13
  sql,
12
14
  } from "drizzle-orm";
13
- import type { BatchItem } from "drizzle-orm/batch";
14
15
  import {
15
16
  activities,
16
17
  actorCache,
17
18
  actors,
18
19
  announces,
19
- blocks,
20
20
  bookmarks,
21
21
  communities,
22
+ deliveryEndpointRecipients,
23
+ deliveryQueue,
24
+ deliveryResolutions,
22
25
  follows,
23
26
  inbox as inboxTable,
24
27
  likes,
25
28
  objectRecipients,
26
29
  objects,
30
+ remoteActorFetchFailures,
31
+ remoteActorTombstones,
27
32
  storyShares,
28
33
  storyViews,
29
34
  storyVotes,
30
35
  } from "../../../../db/index.ts";
36
+ import { insertMany, runBatch } from "../../../../db/d1-write.ts";
37
+ import {
38
+ addressesPublic,
39
+ collectBoundedInboundAddresses,
40
+ } from "../inbound-addressing.ts";
31
41
  import { upsertActivityAndNotify } from "./inbox-shared-helpers.ts";
32
42
  import { normalizeInboundTimestamp } from "./inbound-timestamp.ts";
43
+ import { resolveInboundCommunityScope } from "./inbound-community-scope.ts";
44
+ import {
45
+ MAX_INBOUND_OBJECT_ID_LENGTH,
46
+ validateInboundObjectIdentity,
47
+ } from "./inbound-object-identity.ts";
48
+ import { validateInboundReplyTarget } from "./inbound-reply-target.ts";
49
+ import {
50
+ buildInboundStoryCreateProjection,
51
+ buildInboundStoryUpdateProjection,
52
+ declaresStoryAddressing,
53
+ hasStoryProjectionUpdate,
54
+ normalizeInboundStoryCreateEndTime,
55
+ normalizeInboundStoryUpdateEndTime,
56
+ storyAddressedCollections,
57
+ } from "./inbound-story-projection.ts";
33
58
  import {
34
59
  deleteObjectCascade,
35
60
  purgeMediaBlobs,
36
61
  } from "../../posts/delete-cascade.ts";
37
62
  import {
38
- boundAttachmentsJson,
39
63
  boundInboundContent,
64
+ boundInboundNoteAttachmentsJson,
40
65
  boundInboundSummary,
41
- MAX_ATTACHMENTS_JSON_LENGTH,
42
- MAX_POST_CONTENT_LENGTH,
43
- MAX_POST_SUMMARY_LENGTH,
44
- truncate,
66
+ boundInboundTagsJson,
45
67
  } from "../../posts/transformers.ts";
46
68
  import {
47
69
  activityApId,
48
70
  generateId,
49
- getDomain,
50
71
  isLocal,
51
72
  isSafeRemoteUrl,
52
- objectApId,
53
73
  } from "../../../federation-helpers.ts";
54
74
  import { getConversationId } from "../../dm/query-helpers.ts";
55
75
  import {
56
76
  fetchAndUpsertActorCache,
57
77
  getInstanceFetchSignerByDb,
58
78
  } from "../../../lib/activitypub-actor-cache.ts";
79
+ import {
80
+ isSameActivityPubActor,
81
+ normalizeActivityPubActorId,
82
+ } from "../../../lib/activitypub-actor-identity.ts";
83
+ import { activityPubActorIdentityMatchesSql } from "../../../lib/activitypub-actor-identity-sql.ts";
59
84
  import { fetchWithTimeout } from "../../../lib/federation-fetch.ts";
60
85
  import { signRequest } from "../../../lib/ap-signing.ts";
61
- import { enqueueDeliveryToActor } from "../../../lib/delivery/queue.ts";
62
- import { destinationDeclaresAlias } from "../../../lib/account-migration.ts";
86
+ import {
87
+ destinationDeclaresAlias,
88
+ enqueuePersistedMoveRefollows,
89
+ moveRefollowPrefix,
90
+ rewriteMovedFollowGraph,
91
+ } from "../../../lib/account-migration.ts";
63
92
  import { chunkForInClause } from "../../../lib/chunk.ts";
64
93
  import {
65
- actorIsBlockedBy,
94
+ actorSuppressesInteractionFrom,
66
95
  canViewerReadObjectFull,
67
96
  } from "../../../lib/post-visibility.ts";
68
97
  import { logger } from "../../../lib/logger.ts";
98
+ import { anyOwnerSuppressesInboundActor } from "../../../lib/personal-actor-moderation.ts";
69
99
  import {
70
100
  type Activity,
71
101
  type ActivityContext,
72
102
  type ActivityObject,
73
103
  getActivityObject,
74
104
  getActivityObjectId,
75
- type StoryOverlay,
76
105
  typeIncludes,
77
106
  } from "../inbox-types.ts";
78
107
 
@@ -83,29 +112,6 @@ type ActorRow = typeof actors.$inferSelect;
83
112
  // normalizeInboundTimestamp now lives in ./inbound-timestamp.ts (shared with the
84
113
  // federated group-chat path) — imported at the top of this file.
85
114
 
86
- // ---------------------------------------------------------------------------
87
- // Atomic multi-statement commit (mirrors posts/interactions.ts `runBatch` and
88
- // the inbox-interaction / inbox-shared helper). D1 has no interactive
89
- // transactions, but both the D1 and libsql drivers expose `db.batch([...])`,
90
- // which commits a list of prepared statements atomically. The shared
91
- // `Database` union aliases the abstract `BaseSQLiteDatabase` base (which does
92
- // not surface `batch`), so we narrow to the concrete batch surface here.
93
- // ---------------------------------------------------------------------------
94
-
95
- type BatchStatement = BatchItem<"sqlite">;
96
- interface BatchableDb {
97
- batch(
98
- statements: readonly [BatchStatement, ...BatchStatement[]],
99
- ): Promise<unknown>;
100
- }
101
-
102
- async function runBatch(
103
- db: Database,
104
- statements: readonly [BatchStatement, ...BatchStatement[]],
105
- ): Promise<void> {
106
- await (db as unknown as BatchableDb).batch(statements);
107
- }
108
-
109
115
  // Federation blocklist enforcement lives centrally in
110
116
  // `verifyAndParseInbox` (routes/activitypub/inbox.ts): every inbound
111
117
  // activity is gated once there before any handler runs, so the per-handler
@@ -120,6 +126,14 @@ function isStoryType(type: string | string[] | undefined): boolean {
120
126
  return Array.isArray(type) ? type.includes("Story") : type === "Story";
121
127
  }
122
128
 
129
+ /** Single-user instance policy: any local owner block/mute suppresses content writes. */
130
+ async function ownerSuppressesInboundActor(
131
+ db: Database,
132
+ actorApId: string,
133
+ ): Promise<boolean> {
134
+ return anyOwnerSuppressesInboundActor(db, actorApId);
135
+ }
136
+
123
137
  // The actor object types whose inbound Update represents a remote
124
138
  // profile / avatar / public-key change that should refresh the actor cache.
125
139
  const ACTOR_OBJECT_TYPES = new Set([
@@ -143,18 +157,6 @@ function isActorTypeUpdate(type: string | string[] | undefined): boolean {
143
157
  : ACTOR_OBJECT_TYPES.has(type);
144
158
  }
145
159
 
146
- // The ActivityStreams public-collection magic value, including the legacy
147
- // short forms some implementations still emit.
148
- export const PUBLIC_COLLECTION = new Set([
149
- "https://www.w3.org/ns/activitystreams#Public",
150
- "as:Public",
151
- "Public",
152
- ]);
153
-
154
- export function addressesPublic(addresses: string[]): boolean {
155
- return addresses.some((a) => PUBLIC_COLLECTION.has(a));
156
- }
157
-
158
160
  // A note addressed to a followers collection (the author's `<actor>/followers`)
159
161
  // and NOT to Public is a followers-only post. We match any `/followers`
160
162
  // collection by suffix (mirrors isDirectNote), which covers the author's
@@ -163,29 +165,115 @@ export function addressesFollowers(addresses: string[]): boolean {
163
165
  return addresses.some((a) => a.endsWith("/followers"));
164
166
  }
165
167
 
168
+ type NoteAddressing = {
169
+ readonly to: string[];
170
+ readonly cc: string[];
171
+ readonly bto: string[];
172
+ readonly bcc: string[];
173
+ };
174
+
175
+ type AddressingSource = Pick<Activity, "to" | "cc" | "bto" | "bcc">;
176
+
177
+ function declaresAddressing(source: AddressingSource): boolean {
178
+ return (
179
+ source.to !== undefined ||
180
+ source.cc !== undefined ||
181
+ source.bto !== undefined ||
182
+ source.bcc !== undefined
183
+ );
184
+ }
185
+
186
+ function noteAddressing(source: AddressingSource): NoteAddressing {
187
+ return {
188
+ to: addressList(source.to),
189
+ cc: addressList(source.cc),
190
+ bto: addressList(source.bto),
191
+ bcc: addressList(source.bcc),
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Resolve the Note reach carried by a Create. The embedded object is the
197
+ * durable object projection when it declares any addressing field (including
198
+ * an explicit empty array); peers that put all addressing on the Create
199
+ * envelope remain compatible through the fallback.
200
+ */
201
+ function createNoteAddressing(
202
+ activity: Activity,
203
+ object: ActivityObject,
204
+ ): NoteAddressing {
205
+ return noteAddressing(declaresAddressing(object) ? object : activity);
206
+ }
207
+
208
+ function allNoteAddresses(addressing: NoteAddressing): string[] {
209
+ return [
210
+ ...addressing.to,
211
+ ...addressing.cc,
212
+ ...addressing.bto,
213
+ ...addressing.bcc,
214
+ ];
215
+ }
216
+
217
+ /**
218
+ * Bounded actor/specific-object recipients, excluding collection reach. These
219
+ * become indexed object_recipients authority. `bto`/`bcc` values never enter
220
+ * the public to_json/cc_json projections.
221
+ */
222
+ function specificRecipientAddresses(addressing: NoteAddressing): string[] {
223
+ return [...new Set(allNoteAddresses(addressing))].filter(
224
+ (address) => !addressesPublic([address]) && !address.endsWith("/followers"),
225
+ );
226
+ }
227
+
228
+ function hiddenRecipientAddresses(addressing: NoteAddressing): string[] {
229
+ return [...new Set([...addressing.bto, ...addressing.bcc])].filter(
230
+ (address) => !addressesPublic([address]) && !address.endsWith("/followers"),
231
+ );
232
+ }
233
+
234
+ /**
235
+ * Derive an object's reply counter from the indexed child edge set.
236
+ *
237
+ * Federation delivery is unordered: a child can commit while its parent is
238
+ * still unknown, making the child's immediate parent UPDATE a legitimate
239
+ * zero-row no-op. Every later parent insert or duplicate delivery must run this
240
+ * statement after the insert attempt so both arrival orders — and old stale
241
+ * rows — converge without depending on a child retry.
242
+ */
243
+ function recomputeObjectReplyCount(db: Database, objectId: string) {
244
+ return db
245
+ .update(objects)
246
+ .set({
247
+ replyCount: sql`(SELECT COUNT(*) FROM ${objects} WHERE ${objects.inReplyTo} = ${objectId})`,
248
+ })
249
+ .where(eq(objects.apId, objectId));
250
+ }
251
+
166
252
  /**
167
253
  * Recipient-INDEPENDENT visibility classification for an inbound generic Note,
168
254
  * mirroring the local outbound addressing contract. CRITICAL invariant: a
169
255
  * non-public Note is NEVER classified as "unlisted" (world-readable). Direct
170
- * (addressed-to-specific-actors-only) Notes are diverted BEFORE this is reached
171
- * (insertDirectNote / the direct-shaped skip), so the residual here is:
256
+ * addressed Notes are normally diverted before the generic Create insert, but
257
+ * the classifier itself remains total so every caller fails closed:
172
258
  * - "public" — the Public collection is in `to`;
173
- * - "unlisted" — Public is only in `cc` (Mastodon-style unlisted), or the
174
- * note carries no usable addressing at all;
259
+ * - "unlisted" — Public is only in `cc` (Mastodon-style unlisted);
175
260
  * - "followers" — a followers collection is addressed and Public is absent.
261
+ * - "direct" — only actor IRIs, or no usable addressing at all. The empty
262
+ * case is intentionally unreadable rather than world-readable.
176
263
  * Previously this was derived solely from `to.includes(Public)`, so a remote
177
264
  * followers-only post (Public absent) was silently downgraded to "unlisted" and
178
265
  * became world-readable. */
179
- function classifyInboundNoteVisibility(object: {
180
- to?: string[];
181
- cc?: string[];
182
- }): "public" | "unlisted" | "followers" {
183
- const to = object.to ?? [];
184
- const cc = object.cc ?? [];
266
+ function classifyInboundNoteVisibility(
267
+ addressing: NoteAddressing,
268
+ ): "public" | "unlisted" | "followers" | "direct" {
269
+ const { to, cc, bto, bcc } = addressing;
185
270
  if (addressesPublic(to)) return "public";
186
271
  if (addressesPublic(cc)) return "unlisted";
187
- if (addressesFollowers([...to, ...cc])) return "followers";
188
- return "unlisted";
272
+ // Hidden fields are still audience authority. They are considered for reach
273
+ // classification but are never copied into visible addressing projections.
274
+ if (addressesPublic([...bto, ...bcc])) return "public";
275
+ if (addressesFollowers([...to, ...cc, ...bto, ...bcc])) return "followers";
276
+ return "direct";
189
277
  }
190
278
 
191
279
  /**
@@ -196,63 +284,42 @@ function classifyInboundNoteVisibility(object: {
196
284
  * insertDirectNote. Recipient-independent (keyed on the activity's own
197
285
  * addressing), unlike isDirectNote.
198
286
  */
199
- function isDirectShapedNote(object: { to?: string[]; cc?: string[] }): boolean {
200
- const all = [...(object.to ?? []), ...(object.cc ?? [])];
287
+ function isDirectShapedNote(addressing: NoteAddressing): boolean {
288
+ const all = allNoteAddresses(addressing);
201
289
  if (all.length === 0) return false;
202
290
  if (addressesPublic(all)) return false;
203
291
  if (addressesFollowers(all)) return false;
204
292
  return true;
205
293
  }
206
294
 
207
- // Cap persisted addressing arrays so a remote cannot bloat a row with a huge
208
- // to/cc list; 64 entries is far beyond any real audience and keeps the explicit-
209
- // recipient (mention) gate working.
210
- export const MAX_ADDRESS_ENTRIES = 64;
211
- function boundAddressJson(addresses: string[] | undefined): string {
212
- if (!Array.isArray(addresses) || addresses.length === 0) return "[]";
213
- return JSON.stringify(
214
- addresses
215
- .filter((a) => typeof a === "string")
216
- .slice(0, MAX_ADDRESS_ENTRIES),
217
- );
295
+ function boundAddressJson(value: unknown): string {
296
+ return JSON.stringify(addressList(value));
218
297
  }
219
298
 
220
299
  /**
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.
300
+ * Normalize one already-validated AS2 addressing field (`to` / `cc` /
301
+ * `audience`). The field may be absent, a bare string, or an array mixing
302
+ * strings and embedded objects. Validation happens once for the complete
303
+ * envelope/object projection before any caller derives reach from these lists.
226
304
  */
227
305
  function addressList(value: unknown): string[] {
228
306
  if (typeof value === "string") return [value];
229
307
  if (!Array.isArray(value)) return [];
230
- return value
231
- .filter((a): a is string => typeof a === "string")
232
- .slice(0, MAX_ADDRESS_ENTRIES);
308
+ return [...new Set(value.filter((a): a is string => typeof a === "string"))];
233
309
  }
234
310
 
235
311
  /**
236
- * Reject an inbound object whose `object.id` is asserted under a host the
237
- * delivering actor does not control (object-ID squatting / cross-origin
238
- * injection). A remote actor may only Create objects under its own origin, and
239
- * never under the local domain. Returns true when the object id must be
240
- * rejected. Mirrors the ownership checks already enforced for Delete/Update.
312
+ * The embedded object's audience is the persisted object projection. Peers
313
+ * that put audience only on the activity envelope remain compatible, while an
314
+ * explicit object-level [] wins and can clear the scope on Update.
241
315
  */
242
- function isObjectIdOriginMismatch(
243
- objectId: string | undefined,
244
- actor: string,
245
- baseUrl: string,
246
- ): boolean {
247
- if (!objectId) return false;
248
- // A remote actor must never assert a local-domain object id.
249
- if (isLocal(objectId, baseUrl)) return true;
250
- try {
251
- return getDomain(objectId) !== getDomain(actor);
252
- } catch {
253
- // Unparseable object id: treat as a mismatch (reject) rather than insert.
254
- return true;
255
- }
316
+ function normalizedObjectAudience(
317
+ activity: Activity,
318
+ object: ActivityObject,
319
+ ): string[] {
320
+ const source =
321
+ object.audience !== undefined ? object.audience : activity.audience;
322
+ return [...new Set(addressList(source))];
256
323
  }
257
324
 
258
325
  /**
@@ -286,19 +353,18 @@ function extractMentionHrefs(tag: unknown): string[] {
286
353
  }
287
354
 
288
355
  /**
289
- * Detect an inbound direct (DM) Note: it is addressed (in `to`/`cc`) to one or
290
- * more recipients but NOT to the Public collection and NOT to a followers
291
- * collection. The local addressed recipient is the inbox owner (`recipient`),
292
- * who is necessarily a known local actor row. Mirrors the outbound DM contract
293
- * in dm/messages.ts (visibility="direct", to=[recipient]).
356
+ * Detect an inbound direct (DM) Note: it is addressed (in
357
+ * `to`/`cc`/`bto`/`bcc`) to one or more recipients but NOT to the Public
358
+ * collection and NOT to a followers collection. The local addressed recipient
359
+ * is the inbox owner (`recipient`), who is necessarily a known local actor row.
360
+ * Mirrors the outbound DM contract in dm/messages.ts (visibility="direct",
361
+ * to=[recipient]).
294
362
  */
295
363
  function isDirectNote(
296
- object: { to?: string[]; cc?: string[] },
364
+ addressing: NoteAddressing,
297
365
  recipient: ActorRow,
298
366
  ): boolean {
299
- const to = object.to ?? [];
300
- const cc = object.cc ?? [];
301
- const all = [...to, ...cc];
367
+ const all = allNoteAddresses(addressing);
302
368
  if (all.length === 0) return false;
303
369
  // Direct notes are never addressed to the Public collection...
304
370
  if (addressesPublic(all)) return false;
@@ -307,9 +373,10 @@ function isDirectNote(
307
373
  if (recipient.followersUrl && all.includes(recipient.followersUrl)) {
308
374
  return false;
309
375
  }
310
- // The inbox owner must be explicitly addressed in `to` (the recipient set
311
- // that defines a DM); a mere `cc` mention is not treated as a DM.
312
- return to.includes(recipient.apId);
376
+ // Every AS2 audience field names recipients. `bto`/`bcc` are private, not
377
+ // non-authoritative; dropping them here makes a correctly routed hidden DM
378
+ // persist without recipient authority and therefore disappear from the UX.
379
+ return all.includes(recipient.apId);
313
380
  }
314
381
 
315
382
  /**
@@ -318,10 +385,10 @@ function isDirectNote(
318
385
  * dm/messages.ts: a direct-visibility Note row, an objectRecipients row, a
319
386
  * stored inbound Create activity, and an inbox row so it surfaces.
320
387
  *
321
- * Scope: a single local recipient (the inbox owner). The outbound DM model is
322
- * strictly 1:1 (to=[otherApId]) and `objects.conversation` is a single column,
323
- * so multi-recipient / group direct Notes are intentionally out of scope and
324
- * fall back to the generic Note insert.
388
+ * Each invocation records delivery for one local inbox owner. The outbound DM
389
+ * UX remains strictly 1:1 (to=[otherApId]), so only single-recipient direct
390
+ * Notes receive an `objects.conversation` id; multi-recipient delivery keeps
391
+ * recipient authority without pretending that it belongs to a 1:1 thread.
325
392
  */
326
393
  async function insertDirectNote(
327
394
  db: Database,
@@ -331,41 +398,35 @@ async function insertDirectNote(
331
398
  actor: string,
332
399
  recipient: ActorRow,
333
400
  baseUrl: string,
401
+ addressing: NoteAddressing,
402
+ parentId: string | null,
334
403
  ): Promise<void> {
335
404
  // Derive the conversation. Honour a sender-supplied `object.conversation`
336
405
  // only when it matches the value yurucommu itself would compute for this
337
406
  // (sender, localRecipient) pair — otherwise a remote actor could force a
338
407
  // message into an arbitrary thread (spoof a reply context). Fall back to the
339
408
  // computed id for foreign-origin DMs that carry no/invalid conversation.
340
- const computedConversation = getConversationId(
341
- baseUrl,
342
- actor,
343
- recipient.apId,
344
- );
409
+ const directRecipients = specificRecipientAddresses(addressing);
410
+ const computedConversation =
411
+ directRecipients.length === 1
412
+ ? getConversationId(baseUrl, actor, directRecipients[0])
413
+ : null;
345
414
  const conversationId =
346
- object.conversation === computedConversation
415
+ computedConversation && object.conversation === computedConversation
347
416
  ? object.conversation
348
417
  : computedConversation;
349
418
 
350
- const attachments = object.attachment
351
- ? JSON.stringify(object.attachment)
352
- : "[]";
353
419
  const publishedAt = normalizeInboundTimestamp(
354
420
  object.published,
355
421
  new Date().toISOString(),
356
422
  );
357
- const toJson = JSON.stringify([recipient.apId]);
358
-
359
- // Was the object already present BEFORE this dispatch? This decides whether
360
- // this delivery is the one that creates the row (and therefore the one that
361
- // owns the postCount +1 and the inbox surfacing). It is read once here and
362
- // used only to gate the post-commit side effects; the counter itself is made
363
- // crash-/retry-safe by the in-batch NOT-EXISTS guard below.
364
- const existingObject = await db
365
- .select({ apId: objects.apId })
366
- .from(objects)
367
- .where(eq(objects.apId, objectId))
368
- .get();
423
+
424
+ const replyCountStatements = [
425
+ ...(parentId ? [recomputeObjectReplyCount(db, parentId)] : []),
426
+ // A direct Note can itself be a late-arriving parent. Recompute even when
427
+ // the insert conflicts so a peer retry repairs a stale legacy counter.
428
+ recomputeObjectReplyCount(db, objectId),
429
+ ];
369
430
 
370
431
  // #3 (atomicity + idempotency): the object insert and the author postCount
371
432
  // bump MUST commit together. Previously the row was inserted
@@ -392,10 +453,15 @@ async function insertDirectNote(
392
453
  attributedTo: actor,
393
454
  content: boundInboundContent(object.content),
394
455
  summary: boundInboundSummary(object.summary),
395
- attachmentsJson: boundAttachmentsJson(attachments),
396
- inReplyTo: object.inReplyTo || null,
456
+ attachmentsJson: boundInboundNoteAttachmentsJson(object.attachment),
457
+ tagsJson: boundInboundTagsJson(object.tag),
458
+ inReplyTo: parentId,
397
459
  visibility: "direct",
398
- toJson,
460
+ // Only visible audience fields are serialized. Hidden `bto` / `bcc`
461
+ // recipients live exclusively in object_recipients and must never be
462
+ // disclosed through object JSON or the post API.
463
+ toJson: boundAddressJson(addressing.to),
464
+ ccJson: boundAddressJson(addressing.cc),
399
465
  conversation: conversationId,
400
466
  communityApId: null,
401
467
  published: publishedAt,
@@ -420,12 +486,13 @@ async function insertDirectNote(
420
486
  type: "to",
421
487
  })
422
488
  .onConflictDoNothing(),
489
+ ...replyCountStatements,
423
490
  ]);
424
491
 
425
- if (existingObject) return; // duplicate: no inbox surfacing, no double count
426
-
427
492
  // Store the inbound Create and surface it in the recipient's inbox so the DM
428
- // appears in the conversation / message-requests view.
493
+ // appears in the conversation / message-requests view. Both inserts are
494
+ // idempotent, so every local recipient in a shared-inbox fan-out gets its own
495
+ // inbox row and a retry repairs a missing side effect safely.
429
496
  const activityId = activity.id || activityApId(baseUrl, generateId());
430
497
  await upsertActivityAndNotify(
431
498
  db,
@@ -462,53 +529,94 @@ export async function handleCreate(
462
529
  // Handle Note type (a remote may send `type` as a string or an array)
463
530
  if (!typeIncludes(object.type, "Note")) return;
464
531
 
465
- // Same-origin guard: a remote actor may only Create objects under its own
466
- // origin, never under another host or the local domain. This closes the
467
- // object-ID squatting / cross-origin injection vector and mirrors the
468
- // ownership checks already enforced for Delete/Update.
469
- if (isObjectIdOriginMismatch(object.id, actor, baseUrl)) {
470
- log.warn("Create rejected: object id origin does not match actor", {
471
- event: "ap.create.object_origin_mismatch",
532
+ const identity = validateInboundObjectIdentity(object.id, actor, baseUrl);
533
+ if (!identity.ok) {
534
+ log.warn("Create rejected: invalid remote object identity", {
535
+ event: "ap.create.object_identity_invalid",
472
536
  actor,
473
537
  objectId: object.id,
538
+ reason: identity.reason,
539
+ });
540
+ return;
541
+ }
542
+
543
+ const addressingContract = collectBoundedInboundAddresses([activity, object]);
544
+ if (!addressingContract.ok) {
545
+ log.warn("Create(Note) rejected: invalid addressing projection", {
546
+ event: "ap.create.note_addressing_invalid",
547
+ actor,
548
+ objectId: identity.objectId,
549
+ reason: addressingContract.reason,
550
+ });
551
+ return;
552
+ }
553
+
554
+ const replyTarget = validateInboundReplyTarget(object.inReplyTo);
555
+ if (!replyTarget.ok) {
556
+ log.warn("Create(Note) rejected: invalid reply target", {
557
+ event: "ap.create.note_reply_target_invalid",
558
+ actor,
559
+ objectId: identity.objectId,
560
+ reason: replyTarget.reason,
474
561
  });
475
562
  return;
476
563
  }
564
+ const parentId = replyTarget.parentId ?? null;
477
565
 
478
566
  // Direct (DM) Note routing: a Note addressed to the local inbox owner that
479
567
  // is neither public nor follower-only belongs in the recipient's DM inbox /
480
568
  // message-request flow rather than the generic public Note insert.
481
- if (object.id && isDirectNote(object, recipient)) {
482
- // A DM from an actor the recipient has personally BLOCKED must be dropped —
483
- // mirror the local DM send guard (dm/messages.ts) so the reject+block remedy
484
- // actually stops federated DM harassment (the operator-scoped federation
485
- // blocklist checked in verifyAndParseInbox is a SEPARATE mechanism). `actor`
486
- // is the HTTP-signature-verified signer, so blockedApId=actor is not
487
- // spoofable. The inbox already ACKs, so dropping here causes no retry storm.
488
- const blockedBySigner = await db
489
- .select({ b: blocks.blockerApId })
490
- .from(blocks)
491
- .where(
492
- and(
493
- eq(blocks.blockerApId, recipient.apId),
494
- eq(blocks.blockedApId, actor),
495
- ),
496
- )
497
- .get();
498
- if (blockedBySigner) {
499
- log.info("Dropped inbound DM from a blocked actor", {
500
- event: "ap.create.direct_note_blocked",
501
- actor,
502
- recipient: recipient.apId,
503
- });
569
+ const addressing = createNoteAddressing(activity, object);
570
+
571
+ const parentObj = parentId
572
+ ? await db
573
+ .select({
574
+ apId: objects.apId,
575
+ attributedTo: objects.attributedTo,
576
+ visibility: objects.visibility,
577
+ toJson: objects.toJson,
578
+ ccJson: objects.ccJson,
579
+ audienceJson: objects.audienceJson,
580
+ communityApId: objects.communityApId,
581
+ type: objects.type,
582
+ endTime: objects.endTime,
583
+ })
584
+ .from(objects)
585
+ .where(eq(objects.apId, parentId))
586
+ .get()
587
+ : null;
588
+
589
+ // Inbound replies must pass the canonical read gate for EVERY parent retained
590
+ // by this instance, including direct Notes and remote-authored objects
591
+ // delivered to a local recipient. Keeping this check before the direct/generic
592
+ // routing split prevents either storage path from becoming a restricted-thread
593
+ // injection bypass. Personal block/mute state remains local-owner state, so
594
+ // that extra guard applies only when the parent author is local.
595
+ if (parentId && parentObj) {
596
+ if (!(await canViewerReadObjectFull(db, parentObj, actor))) return;
597
+ if (
598
+ isLocal(parentObj.attributedTo, baseUrl) &&
599
+ (await actorSuppressesInteractionFrom(db, parentObj.attributedTo, actor))
600
+ ) {
504
601
  return;
505
602
  }
506
- const existing = await db
507
- .select({ apId: objects.apId })
508
- .from(objects)
509
- .where(eq(objects.apId, object.id))
510
- .get();
511
- if (existing) return;
603
+ }
604
+
605
+ // Root/public/followers/community Notes are still writes into this local
606
+ // recipient's feed. Apply the same block/mute policy as DM, reply, mention,
607
+ // Follow, Like, Announce, and Story before any durable projection is created.
608
+ // The shared inbox invokes this handler once per resolved local recipient, so
609
+ // the decision remains recipient-specific on that path.
610
+ if (await actorSuppressesInteractionFrom(db, recipient.apId, actor)) {
611
+ log.info("Dropped inbound Note from a blocked or muted actor", {
612
+ event: "ap.create.note_suppressed",
613
+ actor,
614
+ recipient: recipient.apId,
615
+ });
616
+ return;
617
+ }
618
+
619
+ if (object.id && isDirectNote(addressing, recipient)) {
512
620
  await insertDirectNote(
513
621
  db,
514
622
  activity,
@@ -517,6 +625,8 @@ export async function handleCreate(
517
625
  actor,
518
626
  recipient,
519
627
  baseUrl,
628
+ addressing,
629
+ parentId,
520
630
  );
521
631
  return;
522
632
  }
@@ -527,7 +637,7 @@ export async function handleCreate(
527
637
  // addressed to actor A is also dispatched for an unrelated follower B. We must
528
638
  // NOT store it as a world-readable generic Note for B — the addressed actor's
529
639
  // own delivery handles it via insertDirectNote above. Skip it here.
530
- if (isDirectShapedNote(object)) {
640
+ if (isDirectShapedNote(addressing)) {
531
641
  log.warn("Skipping direct Note not addressed to this recipient", {
532
642
  event: "ap.create.direct_note_not_addressed",
533
643
  actor,
@@ -537,7 +647,7 @@ export async function handleCreate(
537
647
  return;
538
648
  }
539
649
 
540
- const objectId = object.id || objectApId(baseUrl, generateId());
650
+ const objectId = identity.objectId;
541
651
 
542
652
  // Was the object already present BEFORE this dispatch? This is read ONCE and
543
653
  // used only to gate the one-shot side effects (parent notification) below; it
@@ -551,49 +661,18 @@ export async function handleCreate(
551
661
  .where(eq(objects.apId, objectId))
552
662
  .get();
553
663
 
554
- const attachments = object.attachment
555
- ? JSON.stringify(object.attachment)
556
- : "[]";
664
+ const audience = normalizedObjectAudience(activity, object);
665
+ const communityScope = await resolveInboundCommunityScope(
666
+ db,
667
+ actor,
668
+ audience,
669
+ );
670
+ if (!communityScope.allowed) return;
671
+
557
672
  const publishedAt = normalizeInboundTimestamp(
558
673
  object.published,
559
674
  new Date().toISOString(),
560
675
  );
561
- const parentObj = object.inReplyTo
562
- ? await db
563
- .select({
564
- attributedTo: objects.attributedTo,
565
- visibility: objects.visibility,
566
- toJson: objects.toJson,
567
- ccJson: objects.ccJson,
568
- audienceJson: objects.audienceJson,
569
- communityApId: objects.communityApId,
570
- type: objects.type,
571
- endTime: objects.endTime,
572
- })
573
- .from(objects)
574
- .where(eq(objects.apId, object.inReplyTo))
575
- .get()
576
- : null;
577
-
578
- // Inbound reply to a LOCAL parent the sending actor cannot read (or that has
579
- // blocked them) is REFUSED — mirroring the local reply 404 gate
580
- // (routes/posts/routes.ts). Without this, a remote who merely learns a
581
- // followers-only / direct(DM) / private-community post's apId could inflate its
582
- // replyCount, deliver a reply notification to the owner (bypassing a personal
583
- // block), and — because the stored reply's in_reply_to discloses the parent —
584
- // build an existence oracle for the restricted post. A legitimate follower /
585
- // addressed recipient still passes canViewerReadObjectFull, so their reply is
586
- // ingested normally. (Only gated for LOCAL parents: a remote parent's audience
587
- // is the remote instance's concern, and we hold no counter/notification for it.)
588
- if (
589
- object.inReplyTo &&
590
- parentObj &&
591
- isLocal(parentObj.attributedTo, baseUrl) &&
592
- (!(await canViewerReadObjectFull(db, parentObj, actor)) ||
593
- (await actorIsBlockedBy(db, parentObj.attributedTo, actor)))
594
- ) {
595
- return;
596
- }
597
676
 
598
677
  const shouldNotifyParent = !!(
599
678
  parentObj && isLocal(parentObj.attributedTo, baseUrl)
@@ -626,17 +705,19 @@ export async function handleCreate(
626
705
  attributedTo: actor,
627
706
  content: boundInboundContent(object.content),
628
707
  summary: boundInboundSummary(object.summary),
629
- attachmentsJson: boundAttachmentsJson(attachments),
630
- inReplyTo: object.inReplyTo || null,
708
+ attachmentsJson: boundInboundNoteAttachmentsJson(object.attachment),
709
+ tagsJson: boundInboundTagsJson(object.tag),
710
+ inReplyTo: parentId,
631
711
  // Recipient-independent classification: a non-public Note is never stored
632
712
  // as world-readable "unlisted". A followers-only post → "followers" (gated
633
713
  // by the accepted-follow edge), preserving the remote author's audience.
634
- visibility: classifyInboundNoteVisibility(object),
714
+ visibility: classifyInboundNoteVisibility(addressing),
635
715
  // Persist the addressing so the explicit-recipient (mention) gate in
636
716
  // canViewerReadObjectFull / the post-detail route can evaluate.
637
- toJson: boundAddressJson(object.to),
638
- ccJson: boundAddressJson(object.cc),
639
- communityApId: null,
717
+ toJson: boundAddressJson(addressing.to),
718
+ ccJson: boundAddressJson(addressing.cc),
719
+ audienceJson: JSON.stringify(audience),
720
+ communityApId: communityScope.communityApId,
640
721
  published: publishedAt,
641
722
  isLocal: 0,
642
723
  })
@@ -647,20 +728,37 @@ export async function handleCreate(
647
728
  .set({ postCount: sql`${actors.postCount} + 1` })
648
729
  .where(and(eq(actors.apId, actor), objectAbsent));
649
730
 
650
- if (object.inReplyTo) {
651
- const parentId = object.inReplyTo;
731
+ // Persist hidden bto/bcc recipients as indexed authority projections on the
732
+ // generic path. Visible to/cc recipients already remain authoritative in the
733
+ // JSON projection; duplicating them here is unnecessary and would turn an
734
+ // ordinary remote @mention into a recipient-table dependency. The canonical
735
+ // read gate combines both representations without revealing hidden reach.
736
+ const recipientProjectionStatements = insertMany(
737
+ db,
738
+ objectRecipients,
739
+ hiddenRecipientAddresses(addressing).map((recipientApId) => ({
740
+ objectApId: objectId,
741
+ recipientApId,
742
+ type: "to",
743
+ })),
744
+ { conflict: "ignore" },
745
+ );
746
+
747
+ if (parentId) {
652
748
  await runBatch(db, [
653
749
  bumpPostCount,
654
750
  insertObject,
655
- db
656
- .update(objects)
657
- .set({
658
- replyCount: sql`(SELECT COUNT(*) FROM ${objects} WHERE ${objects.inReplyTo} = ${parentId})`,
659
- })
660
- .where(eq(objects.apId, parentId)),
751
+ ...recipientProjectionStatements,
752
+ recomputeObjectReplyCount(db, parentId),
753
+ recomputeObjectReplyCount(db, objectId),
661
754
  ]);
662
755
  } else {
663
- await runBatch(db, [bumpPostCount, insertObject]);
756
+ await runBatch(db, [
757
+ bumpPostCount,
758
+ insertObject,
759
+ ...recipientProjectionStatements,
760
+ recomputeObjectReplyCount(db, objectId),
761
+ ]);
664
762
  }
665
763
 
666
764
  if (existingBeforeInsert) return; // duplicate: no double notification
@@ -683,7 +781,8 @@ export async function handleCreate(
683
781
  // that only ever fired for local-origin posts). Runs once (the duplicate
684
782
  // delivery short-circuits at `existingBeforeInsert` above). Skips the post
685
783
  // author and the parent author (already notified by the reply branch) and
686
- // honors the mentioned actor's block of the sender, mirroring the reply gate.
784
+ // honors the mentioned actor's block/mute of the sender, mirroring the reply
785
+ // gate.
687
786
  const mentionedLocalApIds = new Set<string>();
688
787
  for (const href of extractMentionHrefs(object.tag)) {
689
788
  if (!isLocal(href, baseUrl)) continue;
@@ -714,7 +813,8 @@ export async function handleCreate(
714
813
  ).flat();
715
814
 
716
815
  for (const { apId: mentionedApId } of existingLocalApIds) {
717
- if (await actorIsBlockedBy(db, mentionedApId, actor)) continue;
816
+ if (await actorSuppressesInteractionFrom(db, mentionedApId, actor))
817
+ continue;
718
818
  await upsertActivityAndNotify(
719
819
  db,
720
820
  activityApId(baseUrl, generateId()),
@@ -741,18 +841,18 @@ export async function handleCreateStory(
741
841
  const object = getActivityObject(activity);
742
842
  if (!object) return;
743
843
 
744
- // Same-origin guard: reject a story whose object id is squatted under another
745
- // host or the local domain (see handleCreate for rationale).
746
- if (isObjectIdOriginMismatch(object.id, actor, baseUrl)) {
747
- log.warn("Create(Story) rejected: object id origin does not match actor", {
748
- event: "ap.story.object_origin_mismatch",
844
+ const identity = validateInboundObjectIdentity(object.id, actor, baseUrl);
845
+ if (!identity.ok) {
846
+ log.warn("Create(Story) rejected: invalid remote object identity", {
847
+ event: "ap.story.object_identity_invalid",
749
848
  actor,
750
849
  objectId: object.id,
850
+ reason: identity.reason,
751
851
  });
752
852
  return;
753
853
  }
754
854
 
755
- const objectId = object.id || objectApId(baseUrl, generateId());
855
+ const objectId = identity.objectId;
756
856
 
757
857
  // Per-user block: drop a Story from an actor the local owner has blocked,
758
858
  // mirroring the inbound DM blockedBySigner drop + the inbound Like/Announce/
@@ -761,12 +861,7 @@ export async function handleCreateStory(
761
861
  // stories were still stored (consuming the per-author cap + retrievable via
762
862
  // GET /api/posts/:id). Single-user instance: any blocks row blocking this actor
763
863
  // is the owner's block.
764
- const blockedByOwner = await db
765
- .select({ b: blocks.blockerApId })
766
- .from(blocks)
767
- .where(eq(blocks.blockedApId, actor))
768
- .get();
769
- if (blockedByOwner) return;
864
+ if (await ownerSuppressesInboundActor(db, actor)) return;
770
865
 
771
866
  // Check if story already exists
772
867
  const existing = await db
@@ -774,7 +869,12 @@ export async function handleCreateStory(
774
869
  .from(objects)
775
870
  .where(eq(objects.apId, objectId))
776
871
  .get();
777
- if (existing) return;
872
+ if (existing) {
873
+ // Duplicate Story delivery is a bounded repair opportunity for a parent
874
+ // inserted by an older version before one or more child replies arrived.
875
+ await recomputeObjectReplyCount(db, objectId);
876
+ return;
877
+ }
778
878
 
779
879
  // Per-author flood cap. A hostile remote could Create() an unbounded number of
780
880
  // Stories to bloat our feed/storage (each carries an attachment blob + caption).
@@ -803,119 +903,42 @@ export async function handleCreateStory(
803
903
  return;
804
904
  }
805
905
 
806
- // attachment validation (required)
807
- if (!object.attachment) {
808
- log.error("Remote story has no attachment", {
809
- event: "ap.story.missing_attachment",
906
+ // Story metadata has a product-specific storage shape; never feed it through
907
+ // the generic Note attachment projection. Normalize every remote field at
908
+ // this boundary so malformed URLs/overlays cannot become durable UI data.
909
+ const storyProjection = buildInboundStoryCreateProjection(object);
910
+ if (!storyProjection) {
911
+ log.warn("Create(Story) rejected: invalid story projection", {
912
+ event: "ap.story.invalid_projection",
913
+ actor,
810
914
  objectId,
811
915
  });
812
916
  return;
813
917
  }
814
918
 
815
- // Normalize attachment (handle array or single object)
816
- const attachmentArray = Array.isArray(object.attachment)
817
- ? object.attachment
818
- : [object.attachment];
819
- const attachment = attachmentArray[0] as {
820
- url?: string;
821
- mediaType?: string;
822
- width?: number;
823
- height?: number;
824
- };
825
-
826
- if (!attachment || !attachment.url) {
827
- log.error("Remote story attachment has no URL", {
828
- event: "ap.story.attachment_missing_url",
919
+ // Clamp+normalize `published` first and anchor the expiry to that durable
920
+ // value. Already-expired objects are discarded, closing the live-cap bypass
921
+ // where a sender could churn unlimited rows with a past endTime.
922
+ const now = new Date().toISOString();
923
+ const publishedAt = normalizeInboundTimestamp(object.published, now);
924
+ const endTime = normalizeInboundStoryCreateEndTime(
925
+ publishedAt,
926
+ object.endTime,
927
+ now,
928
+ );
929
+ if (!endTime) {
930
+ log.debug("Create(Story) dropped: already expired", {
931
+ event: "ap.story.expired_at_ingress",
932
+ actor,
829
933
  objectId,
830
934
  });
831
935
  return;
832
936
  }
833
937
 
834
- // overlays validation (optional, validate if present). Cap the COUNT — the
835
- // local create path bounds overlays via validateOverlays (MAX_OVERLAYS=20),
836
- // and a hostile remote must not pad an unbounded array into attachments_json.
837
- const MAX_INBOUND_OVERLAYS = 20;
838
- let overlays: StoryOverlay[] | undefined;
839
- if (Array.isArray(object.overlays)) {
840
- const filtered = (object.overlays as StoryOverlay[])
841
- .filter(
842
- (o: StoryOverlay) =>
843
- o &&
844
- o.position &&
845
- typeof o.position.x === "number" &&
846
- typeof o.position.y === "number",
847
- )
848
- .slice(0, MAX_INBOUND_OVERLAYS);
849
- // Keep at most ONE Question (poll) overlay — votes are keyed only by
850
- // (storyApId, actorApId) and tallied by optionIndex with no question
851
- // dimension, so a second poll would conflate tallies. Mirror validateOverlays.
852
- let seenQuestion = false;
853
- const capped = filtered.filter((o: StoryOverlay) => {
854
- if (o.type === "Question") {
855
- if (seenQuestion) return false;
856
- seenQuestion = true;
857
- }
858
- return true;
859
- });
860
- if (capped.length > 0) overlays = capped;
861
- }
862
-
863
- // Build attachments_json
864
- const attachmentData = {
865
- attachment: {
866
- r2_key: "", // Remote stories don't have local R2 key
867
- content_type: attachment.mediaType || "image/jpeg",
868
- url: attachment.url,
869
- width: attachment.width || 1080,
870
- height: attachment.height || 1920,
871
- },
872
- displayDuration:
873
- (object as { displayDuration?: string }).displayDuration || "PT5S",
874
- // The remote caption arrives as the AS2 Note `content`; persist it (bounded
875
- // to the same local content cap as every other inbound Note path) so the
876
- // local renderer shows the same caption as the originating instance.
877
- caption:
878
- typeof object.content === "string" && object.content.trim().length > 0
879
- ? boundInboundContent(object.content)
880
- : undefined,
881
- overlays,
882
- };
883
-
884
- const now = new Date().toISOString();
885
- // Clamp the attacker-controlled `endTime`: a story must expire. A non-ISO or
886
- // far-future value stored verbatim would never satisfy the expiry filter
887
- // (`lt(endTime, now)`, a lexical compare), so a malicious remote could create
888
- // never-expiring stories that accumulate forever. Bound it to published + ~25h
889
- // (the ~24h story lifetime + slack) and normalize to ISO so the compare holds.
890
- const STORY_MAX_LIFETIME_MS = 25 * 60 * 60 * 1000;
891
- // Clamp+normalize the inbound `published` FIRST and anchor the endTime bound to
892
- // THAT, not the raw value: a far-future `published` ("9999-…") would otherwise
893
- // push maxEndMs far into the future too and defeat this very expiry clamp.
894
- const publishedAt = normalizeInboundTimestamp(object.published, now);
895
- const publishedMs = Date.parse(publishedAt);
896
- const maxEndMs =
897
- (Number.isNaN(publishedMs) ? Date.now() : publishedMs) +
898
- STORY_MAX_LIFETIME_MS;
899
- const requestedEndMs = object.endTime ? Date.parse(object.endTime) : NaN;
900
- const endTime = new Date(
901
- Number.isNaN(requestedEndMs)
902
- ? maxEndMs
903
- : Math.min(requestedEndMs, maxEndMs),
904
- ).toISOString();
905
-
906
938
  // The early existence check above is best-effort (TOCTOU): two isolates
907
939
  // racing the same cold story can both pass it. `onConflictDoNothing` keeps
908
940
  // that race insert-safe, and gating follow-on side effects on the returned
909
941
  // row mirrors the duplicate guard in handleCreate.
910
- // Bound the serialized story data. Caption is capped and overlays are
911
- // count-limited above, but per-overlay padding could still inflate it; if the
912
- // blob exceeds the attachments cap, drop the (decorative) overlays so the core
913
- // attachment + caption still persist within bounds.
914
- let storyDataJson = JSON.stringify(attachmentData);
915
- if (storyDataJson.length > MAX_ATTACHMENTS_JSON_LENGTH) {
916
- storyDataJson = JSON.stringify({ ...attachmentData, overlays: undefined });
917
- }
918
-
919
942
  // Carry the community scope across the federation boundary. A story that
920
943
  // arrived through community fanout is addressed to the community's followers
921
944
  // collection, not the author's; storing it with no scope made the local read
@@ -924,26 +947,24 @@ export async function handleCreateStory(
924
947
  // communities this instance actually knows and only then mark the scope —
925
948
  // an unresolvable audience is left unscoped rather than trusted, and the
926
949
  // 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;
950
+ const storyAddressing = storyAddressedCollections(activity, object);
951
+ if (storyAddressing.overflow) {
952
+ log.warn("Create(Story) rejected: too many addressing entries", {
953
+ event: "ap.story.addressing_overflow",
954
+ actor,
955
+ objectId,
956
+ });
957
+ return;
958
+ }
959
+ const communityScope = await resolveInboundCommunityScope(
960
+ db,
961
+ actor,
962
+ storyAddressing.addresses,
963
+ );
964
+ if (!communityScope.allowed) return;
965
+ const storyAudience = communityScope.communityApId
966
+ ? [communityScope.communityApId]
967
+ : normalizedObjectAudience(activity, object);
947
968
 
948
969
  const inserted = await db
949
970
  .insert(objects)
@@ -952,14 +973,19 @@ export async function handleCreateStory(
952
973
  type: "Story",
953
974
  attributedTo: actor,
954
975
  content: "",
955
- attachmentsJson: storyDataJson,
956
- ...(community
976
+ attachmentsJson: storyProjection.json,
977
+ ...(communityScope.communityApId
957
978
  ? {
958
- communityApId: community.apId,
959
- audienceJson: JSON.stringify([community.apId]),
979
+ communityApId: communityScope.communityApId,
980
+ audienceJson: JSON.stringify(storyAudience),
960
981
  }
961
- : {}),
982
+ : storyAudience.length > 0
983
+ ? { audienceJson: JSON.stringify(storyAudience) }
984
+ : {}),
962
985
  endTime,
986
+ // Handles the child-before-parent order without a second transaction.
987
+ // The child Create path repairs the opposite concurrent order.
988
+ replyCount: sql`(SELECT COUNT(*) FROM ${objects} WHERE ${objects.inReplyTo} = ${objectId})`,
963
989
  published: publishedAt,
964
990
  isLocal: 0,
965
991
  })
@@ -999,9 +1025,10 @@ const ANNOUNCED_OBJECT_FETCH_TIMEOUT_MS = 15_000;
999
1025
  * a boost must never widen a followers-only/direct object's audience, and
1000
1026
  * this instance cannot verify a remote author's follower audience.
1001
1027
  *
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.
1028
+ * Depth cap: the object's `inReplyTo` is stored verbatim and a retained parent
1029
+ * is authority-checked locally, but an unknown parent is NEVER fetched. A
1030
+ * single Announce therefore triggers at most one object fetch (plus a
1031
+ * best-effort author-profile cache fill), not a thread walk.
1005
1032
  *
1006
1033
  * Best-effort by contract: every failure returns false and the Announce is
1007
1034
  * dropped exactly as it was before this path existed.
@@ -1013,7 +1040,13 @@ export async function fetchAndPersistAnnouncedNote(
1013
1040
  ): Promise<boolean> {
1014
1041
  // Never fetch a local id (a local object that does not exist is just gone)
1015
1042
  // and never fetch an unsafe URL (non-http(s), credentials, blocked host…).
1016
- if (isLocal(objectId, baseUrl) || !isSafeRemoteUrl(objectId)) return false;
1043
+ if (
1044
+ objectId.length > MAX_INBOUND_OBJECT_ID_LENGTH ||
1045
+ isLocal(objectId, baseUrl) ||
1046
+ !isSafeRemoteUrl(objectId)
1047
+ ) {
1048
+ return false;
1049
+ }
1017
1050
 
1018
1051
  let note: ActivityObject & { attributedTo?: unknown };
1019
1052
  try {
@@ -1047,18 +1080,72 @@ export async function fetchAndPersistAnnouncedNote(
1047
1080
  const attributedTo =
1048
1081
  typeof note.attributedTo === "string" ? note.attributedTo : null;
1049
1082
  if (!attributedTo || !isSafeRemoteUrl(attributedTo)) return false;
1050
- try {
1051
- if (getDomain(attributedTo) !== getDomain(objectId)) return false;
1052
- } catch {
1083
+ if (!validateInboundObjectIdentity(objectId, attributedTo, baseUrl).ok) {
1053
1084
  return false;
1054
1085
  }
1055
1086
 
1087
+ const addressingContract = collectBoundedInboundAddresses([note]);
1088
+ if (!addressingContract.ok) return false;
1089
+
1090
+ const replyTarget = validateInboundReplyTarget(note.inReplyTo);
1091
+ if (!replyTarget.ok) return false;
1092
+ const parentId = replyTarget.parentId ?? null;
1093
+
1056
1094
  // Addressing gates: a DM-shaped object must never be stored world-readable,
1057
1095
  // and a non-public classification is refused outright (see doc comment).
1058
- if (isDirectShapedNote(note)) return false;
1059
- const visibility = classifyInboundNoteVisibility(note);
1096
+ const addressing = noteAddressing(note);
1097
+ if (isDirectShapedNote(addressing)) return false;
1098
+ const visibility = classifyInboundNoteVisibility(addressing);
1060
1099
  if (visibility !== "public" && visibility !== "unlisted") return false;
1061
1100
 
1101
+ const audience = addressList(note.audience);
1102
+ const communityScope = await resolveInboundCommunityScope(
1103
+ db,
1104
+ attributedTo,
1105
+ audience,
1106
+ );
1107
+ if (!communityScope.allowed) return false;
1108
+
1109
+ // A fetched boost target can itself be a reply. If its parent is already
1110
+ // retained, apply the exact same read/suppression authority as ordinary
1111
+ // inbound Create before storing the child. Without this check an Announce of
1112
+ // an otherwise-public Note could inject a reply beneath a local direct or
1113
+ // followers-only parent that its author cannot read. An unknown parent stays
1114
+ // unresolved (the one-fetch depth cap below); later parent arrival repairs
1115
+ // its derived counter through recomputeObjectReplyCount.
1116
+ const parentObj = parentId
1117
+ ? await db
1118
+ .select({
1119
+ apId: objects.apId,
1120
+ attributedTo: objects.attributedTo,
1121
+ visibility: objects.visibility,
1122
+ toJson: objects.toJson,
1123
+ ccJson: objects.ccJson,
1124
+ audienceJson: objects.audienceJson,
1125
+ communityApId: objects.communityApId,
1126
+ type: objects.type,
1127
+ endTime: objects.endTime,
1128
+ })
1129
+ .from(objects)
1130
+ .where(eq(objects.apId, parentId))
1131
+ .get()
1132
+ : null;
1133
+ if (parentId && parentObj) {
1134
+ if (!(await canViewerReadObjectFull(db, parentObj, attributedTo))) {
1135
+ return false;
1136
+ }
1137
+ if (
1138
+ isLocal(parentObj.attributedTo, baseUrl) &&
1139
+ (await actorSuppressesInteractionFrom(
1140
+ db,
1141
+ parentObj.attributedTo,
1142
+ attributedTo,
1143
+ ))
1144
+ ) {
1145
+ return false;
1146
+ }
1147
+ }
1148
+
1062
1149
  // Best-effort author profile fill so the surfaced boost renders with the
1063
1150
  // author's name/icon. Cache-when-absent; a failure never blocks the persist.
1064
1151
  const cachedAuthor = await db
@@ -1078,8 +1165,7 @@ export async function fetchAndPersistAnnouncedNote(
1078
1165
  }
1079
1166
  }
1080
1167
 
1081
- const attachments = note.attachment ? JSON.stringify(note.attachment) : "[]";
1082
- await db
1168
+ const insertObject = db
1083
1169
  .insert(objects)
1084
1170
  .values({
1085
1171
  apId: objectId,
@@ -1087,14 +1173,16 @@ export async function fetchAndPersistAnnouncedNote(
1087
1173
  attributedTo,
1088
1174
  content: boundInboundContent(note.content),
1089
1175
  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,
1176
+ attachmentsJson: boundInboundNoteAttachmentsJson(note.attachment),
1177
+ tagsJson: boundInboundTagsJson(note.tag),
1178
+ // Stored verbatim and never remotely resolved (depth cap): a boosted
1179
+ // reply keeps its honest thread link even when the parent stays unknown.
1180
+ inReplyTo: parentId,
1094
1181
  visibility,
1095
1182
  toJson: boundAddressJson(note.to),
1096
1183
  ccJson: boundAddressJson(note.cc),
1097
- communityApId: null,
1184
+ audienceJson: JSON.stringify(audience),
1185
+ communityApId: communityScope.communityApId,
1098
1186
  published: normalizeInboundTimestamp(
1099
1187
  note.published,
1100
1188
  new Date().toISOString(),
@@ -1103,6 +1191,18 @@ export async function fetchAndPersistAnnouncedNote(
1103
1191
  })
1104
1192
  .onConflictDoNothing();
1105
1193
 
1194
+ if (parentId) {
1195
+ await runBatch(db, [
1196
+ insertObject,
1197
+ recomputeObjectReplyCount(db, parentId),
1198
+ // Unknown Announce targets race normal Create delivery; the no-op insert
1199
+ // plus recompute keeps both arrival paths idempotent and repairable.
1200
+ recomputeObjectReplyCount(db, objectId),
1201
+ ]);
1202
+ } else {
1203
+ await runBatch(db, [insertObject, recomputeObjectReplyCount(db, objectId)]);
1204
+ }
1205
+
1106
1206
  return true;
1107
1207
  }
1108
1208
 
@@ -1110,6 +1210,17 @@ export async function fetchAndPersistAnnouncedNote(
1110
1210
  // Delete handler
1111
1211
  // ---------------------------------------------------------------------------
1112
1212
 
1213
+ function retainedRemoteActorAliasesSql(column: SQLiteColumn, actorId: string) {
1214
+ return activityPubActorIdentityMatchesSql(
1215
+ sql`SELECT ${column} FROM ${column.table}`,
1216
+ actorId,
1217
+ );
1218
+ }
1219
+
1220
+ function retainedRemoteActorWhere(column: SQLiteColumn, actorId: string) {
1221
+ return sql`${column} IN (${retainedRemoteActorAliasesSql(column, actorId)})`;
1222
+ }
1223
+
1113
1224
  /**
1114
1225
  * Tombstone a remote actor locally in response to a verified inbound
1115
1226
  * Delete(Actor). Mirrors the local /me/delete teardown for the federation-facing
@@ -1122,163 +1233,258 @@ export async function fetchAndPersistAnnouncedNote(
1122
1233
  async function handleRemoteActorDelete(
1123
1234
  c: ActivityContext,
1124
1235
  actorId: string,
1236
+ deleteActivityApId: string,
1125
1237
  ): Promise<void> {
1126
1238
  const db = c.get("db");
1239
+ const canonicalActorId = normalizeActivityPubActorId(actorId) ?? actorId;
1240
+
1241
+ // A Delete(actor) is one authority transition: the remote identity, its
1242
+ // relationship authority, cached content, and every denormalized counter
1243
+ // must disappear together. Keeping these as independent commits made retry
1244
+ // unsafe: a failure after one counterpart counter decrement but before the
1245
+ // follow delete let the retry observe the same edge and decrement again.
1246
+ // D1 batch is the only atomic multi-statement primitive available here.
1247
+ const remoteObjectIds = () =>
1248
+ db
1249
+ .select({ id: objects.apId })
1250
+ .from(objects)
1251
+ .where(retainedRemoteActorWhere(objects.attributedTo, actorId));
1127
1252
 
1128
1253
  // Counterpart count reconcile BEFORE dropping edges (mirrors actors.ts):
1129
1254
  // everyone the deleted remote followed loses a follower; everyone who followed
1130
1255
  // it loses a following. The subquery naturally scopes to LOCAL actors (remote
1131
1256
  // actors have no `actors` row); gt(...,0) guards underflow.
1132
- await db
1133
- .update(actors)
1134
- .set({ followerCount: sql`${actors.followerCount} - 1` })
1135
- .where(
1136
- and(
1137
- inArray(
1138
- actors.apId,
1139
- db
1140
- .select({ id: follows.followingApId })
1141
- .from(follows)
1142
- .where(eq(follows.followerApId, actorId)),
1257
+ await runBatch(db, [
1258
+ // Establish deletion authority in the SAME batch that tears down retained
1259
+ // state. A concurrent actor fetch must see either no tombstone before this
1260
+ // batch (and be deleted by it) or the tombstone after it (and refuse its
1261
+ // cache write); it can never commit between these effects.
1262
+ db
1263
+ .insert(remoteActorTombstones)
1264
+ .values({
1265
+ actorApId: canonicalActorId,
1266
+ deleteActivityApId,
1267
+ })
1268
+ .onConflictDoNothing(),
1269
+ db
1270
+ .update(actors)
1271
+ .set({
1272
+ followerCount: sql`MAX(0, ${actors.followerCount} - (SELECT COUNT(*) FROM ${follows} WHERE ${follows.followingApId} = ${actors.apId} AND ${retainedRemoteActorWhere(follows.followerApId, actorId)} AND ${follows.status} = 'accepted'))`,
1273
+ })
1274
+ .where(
1275
+ and(
1276
+ inArray(
1277
+ actors.apId,
1278
+ db
1279
+ .select({ id: follows.followingApId })
1280
+ .from(follows)
1281
+ .where(
1282
+ and(
1283
+ retainedRemoteActorWhere(follows.followerApId, actorId),
1284
+ eq(follows.status, "accepted"),
1285
+ ),
1286
+ ),
1287
+ ),
1288
+ gt(actors.followerCount, 0),
1143
1289
  ),
1144
- gt(actors.followerCount, 0),
1145
1290
  ),
1146
- );
1147
- await db
1148
- .update(actors)
1149
- .set({ followingCount: sql`${actors.followingCount} - 1` })
1150
- .where(
1151
- and(
1152
- inArray(
1153
- actors.apId,
1154
- db
1155
- .select({ id: follows.followerApId })
1156
- .from(follows)
1157
- .where(eq(follows.followingApId, actorId)),
1291
+ db
1292
+ .update(actors)
1293
+ .set({
1294
+ followingCount: sql`MAX(0, ${actors.followingCount} - (SELECT COUNT(*) FROM ${follows} WHERE ${follows.followerApId} = ${actors.apId} AND ${retainedRemoteActorWhere(follows.followingApId, actorId)} AND ${follows.status} = 'accepted'))`,
1295
+ })
1296
+ .where(
1297
+ and(
1298
+ inArray(
1299
+ actors.apId,
1300
+ db
1301
+ .select({ id: follows.followerApId })
1302
+ .from(follows)
1303
+ .where(
1304
+ and(
1305
+ retainedRemoteActorWhere(follows.followingApId, actorId),
1306
+ eq(follows.status, "accepted"),
1307
+ ),
1308
+ ),
1309
+ ),
1310
+ gt(actors.followingCount, 0),
1158
1311
  ),
1159
- gt(actors.followingCount, 0),
1160
1312
  ),
1161
- );
1162
- await db
1163
- .delete(follows)
1164
- .where(
1165
- or(eq(follows.followerApId, actorId), eq(follows.followingApId, actorId)),
1166
- );
1167
-
1168
- // Recompute the replyCount of any LOCAL parent the remote's cached objects
1169
- // replied to, counting only the replies that will REMAIN (not authored by the
1170
- // deleted remote), BEFORE the cascade removes them (mirrors actors.ts).
1171
- await db
1172
- .update(objects)
1173
- .set({
1174
- replyCount: sql`(SELECT COUNT(*) FROM objects AS child WHERE child.in_reply_to = ${objects.apId} AND child.attributed_to <> ${actorId})`,
1175
- })
1176
- .where(
1177
- inArray(
1178
- objects.apId,
1179
- db
1180
- .select({ id: objects.inReplyTo })
1181
- .from(objects)
1182
- .where(
1183
- and(
1184
- eq(objects.attributedTo, actorId),
1185
- isNotNull(objects.inReplyTo),
1186
- ),
1187
- ),
1313
+ db
1314
+ .delete(follows)
1315
+ .where(
1316
+ or(
1317
+ retainedRemoteActorWhere(follows.followerApId, actorId),
1318
+ retainedRemoteActorWhere(follows.followingApId, actorId),
1319
+ ),
1188
1320
  ),
1189
- );
1190
-
1191
- // Reconcile the like/announce/share counters on OTHER objects the deleted
1192
- // remote INTERACTED with, BEFORE dropping its edges — mirrors the local
1193
- // account-delete griefing defense (actors.ts). A throwaway remote could ratchet
1194
- // a local post's like/announce/share counts then self-delete via a signed
1195
- // Delete(Person); without this those counts stay permanently inflated and the
1196
- // edge rows orphan (the object-scoped cascade below only reaps interactions ON
1197
- // the remote's OWN posts, not the ones it authored on others'). gt(...,0) guards
1198
- // underflow; the subquery scopes without splicing ids (D1 param ceiling).
1199
- await db
1200
- .update(objects)
1201
- .set({ likeCount: sql`${objects.likeCount} - 1` })
1202
- .where(
1203
- and(
1204
- inArray(
1205
- objects.apId,
1206
- db
1207
- .select({ id: likes.objectApId })
1208
- .from(likes)
1209
- .where(eq(likes.actorApId, actorId)),
1321
+ // Cancel recipient-addressed first-hop work while it still has actor
1322
+ // identity. Endpoint-aggregated work is handled by its separate recipient
1323
+ // attribution immediately below.
1324
+ db
1325
+ .delete(deliveryResolutions)
1326
+ .where(
1327
+ retainedRemoteActorWhere(
1328
+ deliveryResolutions.recipientActorApId,
1329
+ actorId,
1210
1330
  ),
1211
- gt(objects.likeCount, 0),
1212
1331
  ),
1213
- );
1214
- await db
1215
- .update(objects)
1216
- .set({ announceCount: sql`${objects.announceCount} - 1` })
1217
- .where(
1332
+ // Endpoint jobs created after 0029 retain every actor identity they
1333
+ // represent. Remove only this deleted actor's attribution first, then
1334
+ // cancel a recipient-aware job only if no co-recipient remains. Legacy
1335
+ // jobs stay fail-safe because their attribution-complete flag is 0.
1336
+ db
1337
+ .delete(deliveryEndpointRecipients)
1338
+ .where(
1339
+ retainedRemoteActorWhere(
1340
+ deliveryEndpointRecipients.recipientActorApId,
1341
+ actorId,
1342
+ ),
1343
+ ),
1344
+ db.delete(deliveryQueue).where(
1218
1345
  and(
1219
- inArray(
1220
- objects.apId,
1346
+ eq(deliveryQueue.recipientAttributionComplete, 1),
1347
+ notExists(
1221
1348
  db
1222
- .select({ id: announces.objectApId })
1223
- .from(announces)
1224
- .where(eq(announces.actorApId, actorId)),
1349
+ .select({
1350
+ deliveryJobId: deliveryEndpointRecipients.deliveryJobId,
1351
+ })
1352
+ .from(deliveryEndpointRecipients)
1353
+ .where(
1354
+ eq(deliveryEndpointRecipients.deliveryJobId, deliveryQueue.id),
1355
+ ),
1225
1356
  ),
1226
- gt(objects.announceCount, 0),
1227
1357
  ),
1228
- );
1229
- await db
1230
- .update(objects)
1231
- .set({ shareCount: sql`${objects.shareCount} - 1` })
1232
- .where(
1233
- and(
1358
+ ),
1359
+
1360
+ // Recompute the replyCount of any LOCAL parent the remote's cached objects
1361
+ // replied to, counting only replies that will remain after this batch.
1362
+ db
1363
+ .update(objects)
1364
+ .set({
1365
+ replyCount: sql`(SELECT COUNT(*) FROM objects AS child WHERE child.in_reply_to = ${objects.apId} AND child.attributed_to NOT IN (${retainedRemoteActorAliasesSql(objects.attributedTo, actorId)}))`,
1366
+ })
1367
+ .where(
1234
1368
  inArray(
1235
1369
  objects.apId,
1236
1370
  db
1237
- .select({ id: storyShares.storyApId })
1238
- .from(storyShares)
1239
- .where(eq(storyShares.actorApId, actorId)),
1371
+ .select({ id: objects.inReplyTo })
1372
+ .from(objects)
1373
+ .where(
1374
+ and(
1375
+ retainedRemoteActorWhere(objects.attributedTo, actorId),
1376
+ isNotNull(objects.inReplyTo),
1377
+ ),
1378
+ ),
1240
1379
  ),
1241
- gt(objects.shareCount, 0),
1242
1380
  ),
1243
- );
1244
- // Delete the interaction edges the remote AUTHORED on OTHER objects.
1245
- await db.delete(likes).where(eq(likes.actorApId, actorId));
1246
- await db.delete(announces).where(eq(announces.actorApId, actorId));
1247
- await db.delete(bookmarks).where(eq(bookmarks.actorApId, actorId));
1248
- await db.delete(storyShares).where(eq(storyShares.actorApId, actorId));
1249
- await db.delete(storyVotes).where(eq(storyVotes.actorApId, actorId));
1250
- await db.delete(storyViews).where(eq(storyViews.actorApId, actorId));
1251
-
1252
- // Cascade child rows keyed by the remote's authored objects (no FK cascade on
1253
- // prod D1), then the objects themselves. A fresh subquery per statement avoids
1254
- // shared-AST reuse.
1255
- const remoteObjectIds = () =>
1381
+
1382
+ // Reconcile counters on OTHER objects the remote interacted with before
1383
+ // dropping those edges. The subqueries stay bounded and D1-param-safe.
1256
1384
  db
1257
- .select({ id: objects.apId })
1258
- .from(objects)
1259
- .where(eq(objects.attributedTo, actorId));
1260
- await db.delete(likes).where(inArray(likes.objectApId, remoteObjectIds()));
1261
- await db
1262
- .delete(announces)
1263
- .where(inArray(announces.objectApId, remoteObjectIds()));
1264
- await db
1265
- .delete(bookmarks)
1266
- .where(inArray(bookmarks.objectApId, remoteObjectIds()));
1267
- await db
1268
- .delete(objectRecipients)
1269
- .where(inArray(objectRecipients.objectApId, remoteObjectIds()));
1270
- await db
1271
- .delete(storyVotes)
1272
- .where(inArray(storyVotes.storyApId, remoteObjectIds()));
1273
- await db
1274
- .delete(storyViews)
1275
- .where(inArray(storyViews.storyApId, remoteObjectIds()));
1276
- await db
1277
- .delete(storyShares)
1278
- .where(inArray(storyShares.storyApId, remoteObjectIds()));
1279
- await db.delete(objects).where(eq(objects.attributedTo, actorId));
1280
-
1281
- await db.delete(actorCache).where(eq(actorCache.apId, actorId));
1385
+ .update(objects)
1386
+ .set({
1387
+ likeCount: sql`MAX(0, ${objects.likeCount} - (SELECT COUNT(*) FROM ${likes} WHERE ${likes.objectApId} = ${objects.apId} AND ${retainedRemoteActorWhere(likes.actorApId, actorId)}))`,
1388
+ })
1389
+ .where(
1390
+ and(
1391
+ inArray(
1392
+ objects.apId,
1393
+ db
1394
+ .select({ id: likes.objectApId })
1395
+ .from(likes)
1396
+ .where(retainedRemoteActorWhere(likes.actorApId, actorId)),
1397
+ ),
1398
+ gt(objects.likeCount, 0),
1399
+ ),
1400
+ ),
1401
+ db
1402
+ .update(objects)
1403
+ .set({
1404
+ announceCount: sql`MAX(0, ${objects.announceCount} - (SELECT COUNT(*) FROM ${announces} WHERE ${announces.objectApId} = ${objects.apId} AND ${retainedRemoteActorWhere(announces.actorApId, actorId)}))`,
1405
+ })
1406
+ .where(
1407
+ and(
1408
+ inArray(
1409
+ objects.apId,
1410
+ db
1411
+ .select({ id: announces.objectApId })
1412
+ .from(announces)
1413
+ .where(retainedRemoteActorWhere(announces.actorApId, actorId)),
1414
+ ),
1415
+ gt(objects.announceCount, 0),
1416
+ ),
1417
+ ),
1418
+ db
1419
+ .update(objects)
1420
+ .set({
1421
+ shareCount: sql`MAX(0, ${objects.shareCount} - (SELECT COUNT(*) FROM ${storyShares} WHERE ${storyShares.storyApId} = ${objects.apId} AND ${retainedRemoteActorWhere(storyShares.actorApId, actorId)}))`,
1422
+ })
1423
+ .where(
1424
+ and(
1425
+ inArray(
1426
+ objects.apId,
1427
+ db
1428
+ .select({ id: storyShares.storyApId })
1429
+ .from(storyShares)
1430
+ .where(retainedRemoteActorWhere(storyShares.actorApId, actorId)),
1431
+ ),
1432
+ gt(objects.shareCount, 0),
1433
+ ),
1434
+ ),
1435
+
1436
+ // Delete interaction edges the remote authored on other objects.
1437
+ db.delete(likes).where(retainedRemoteActorWhere(likes.actorApId, actorId)),
1438
+ db
1439
+ .delete(announces)
1440
+ .where(retainedRemoteActorWhere(announces.actorApId, actorId)),
1441
+ db
1442
+ .delete(bookmarks)
1443
+ .where(retainedRemoteActorWhere(bookmarks.actorApId, actorId)),
1444
+ db
1445
+ .delete(storyShares)
1446
+ .where(retainedRemoteActorWhere(storyShares.actorApId, actorId)),
1447
+ db
1448
+ .delete(storyVotes)
1449
+ .where(retainedRemoteActorWhere(storyVotes.actorApId, actorId)),
1450
+ db
1451
+ .delete(storyViews)
1452
+ .where(retainedRemoteActorWhere(storyViews.actorApId, actorId)),
1453
+
1454
+ // Cascade child rows keyed by the remote's authored objects (no FK cascade
1455
+ // is assumed), then remove the objects and cached identity itself. A fresh
1456
+ // subquery per statement avoids shared-AST reuse.
1457
+ db.delete(likes).where(inArray(likes.objectApId, remoteObjectIds())),
1458
+ db
1459
+ .delete(announces)
1460
+ .where(inArray(announces.objectApId, remoteObjectIds())),
1461
+ db
1462
+ .delete(bookmarks)
1463
+ .where(inArray(bookmarks.objectApId, remoteObjectIds())),
1464
+ db
1465
+ .delete(objectRecipients)
1466
+ .where(inArray(objectRecipients.objectApId, remoteObjectIds())),
1467
+ db
1468
+ .delete(storyVotes)
1469
+ .where(inArray(storyVotes.storyApId, remoteObjectIds())),
1470
+ db
1471
+ .delete(storyViews)
1472
+ .where(inArray(storyViews.storyApId, remoteObjectIds())),
1473
+ db
1474
+ .delete(storyShares)
1475
+ .where(inArray(storyShares.storyApId, remoteObjectIds())),
1476
+ db
1477
+ .delete(objects)
1478
+ .where(retainedRemoteActorWhere(objects.attributedTo, actorId)),
1479
+ db
1480
+ .delete(actorCache)
1481
+ .where(retainedRemoteActorWhere(actorCache.apId, actorId)),
1482
+ db
1483
+ .delete(remoteActorFetchFailures)
1484
+ .where(
1485
+ retainedRemoteActorWhere(remoteActorFetchFailures.actorApId, actorId),
1486
+ ),
1487
+ ]);
1282
1488
 
1283
1489
  log.info("Processed inbound Delete(actor)", {
1284
1490
  event: "ap.delete.actor",
@@ -1318,14 +1524,14 @@ export async function handleDelete(c: ActivityContext, activity: Activity) {
1318
1524
  // (same origin), so an object that equals the verified actor is owned by the
1319
1525
  // signer. Tombstone the remote locally so a stale profile + dangling follow
1320
1526
  // edge + cached content do not survive indefinitely.
1321
- if (objectId === actorId) {
1322
- await handleRemoteActorDelete(c, actorId);
1527
+ if (isSameActivityPubActor(objectId, actorId)) {
1528
+ await handleRemoteActorDelete(c, actorId, activity.id ?? actorId);
1323
1529
  }
1324
1530
  return;
1325
1531
  }
1326
1532
 
1327
1533
  // Verify actor owns the object before deleting
1328
- if (delObj.attributedTo !== actorId) {
1534
+ if (!isSameActivityPubActor(delObj.attributedTo, actorId)) {
1329
1535
  log.warn("Delete rejected: actor does not own object", {
1330
1536
  event: "ap.delete.actor_ownership_mismatch",
1331
1537
  actor: actorId,
@@ -1413,8 +1619,11 @@ export async function handleUpdate(
1413
1619
  // mirroring the actor==object self-update contract). The remote document is
1414
1620
  // re-fetched from origin (never trusted from the wire) so a spoofed Update
1415
1621
  // body cannot poison the cache.
1416
- if (isActorTypeUpdate(object.type) || objectId === actor) {
1417
- if (objectId !== actor) {
1622
+ if (
1623
+ isActorTypeUpdate(object.type) ||
1624
+ isSameActivityPubActor(objectId, actor)
1625
+ ) {
1626
+ if (!isSameActivityPubActor(objectId, actor)) {
1418
1627
  log.warn("Update(actor) rejected: object id does not match actor", {
1419
1628
  event: "ap.update.actor_self_mismatch",
1420
1629
  actor,
@@ -1430,7 +1639,7 @@ export async function handleUpdate(
1430
1639
  const cached = await db
1431
1640
  .select({ lastFetchedAt: actorCache.lastFetchedAt })
1432
1641
  .from(actorCache)
1433
- .where(eq(actorCache.apId, objectId))
1642
+ .where(eq(actorCache.apId, actor))
1434
1643
  .get();
1435
1644
  if (cached?.lastFetchedAt) {
1436
1645
  const age = Date.now() - new Date(cached.lastFetchedAt).getTime();
@@ -1441,45 +1650,369 @@ export async function handleUpdate(
1441
1650
  ) {
1442
1651
  log.debug("Update(actor) re-fetch skipped: within cooldown", {
1443
1652
  event: "ap.update.actor_refetch_cooldown",
1444
- actor: objectId,
1653
+ actor,
1445
1654
  ageMs: age,
1446
1655
  });
1447
1656
  return;
1448
1657
  }
1449
1658
  }
1450
- await refreshActorCache(db, objectId);
1659
+ await refreshActorCache(db, actor);
1451
1660
  return;
1452
1661
  }
1453
1662
 
1454
1663
  const existing = await db
1455
- .select({ attributedTo: objects.attributedTo })
1664
+ .select({
1665
+ attributedTo: objects.attributedTo,
1666
+ inReplyTo: objects.inReplyTo,
1667
+ type: objects.type,
1668
+ attachmentsJson: objects.attachmentsJson,
1669
+ published: objects.published,
1670
+ endTime: objects.endTime,
1671
+ communityApId: objects.communityApId,
1672
+ })
1456
1673
  .from(objects)
1457
1674
  .where(eq(objects.apId, objectId))
1458
1675
  .get();
1459
- if (!existing || existing.attributedTo !== actor) return;
1676
+ if (!existing || !isSameActivityPubActor(existing.attributedTo, actor)) {
1677
+ return;
1678
+ }
1679
+
1680
+ // Story is deliberately handled before Note: Yurucommu emits
1681
+ // type=["Story","Note"] for interoperability, but its durable projection is
1682
+ // not a Note attachment array. The old generic branch rewrote
1683
+ // attachments_json/content into the wrong shape and corrupted the Story UI.
1684
+ if (existing.type === "Story" || isStoryType(object.type)) {
1685
+ if (existing.type !== "Story" || !isStoryType(object.type)) {
1686
+ log.warn("Update rejected: object type does not match stored Story", {
1687
+ event: "ap.update.story_type_mismatch",
1688
+ actor,
1689
+ objectId,
1690
+ });
1691
+ return;
1692
+ }
1693
+ if (await ownerSuppressesInboundActor(db, actor)) return;
1694
+
1695
+ const now = new Date().toISOString();
1696
+ const endTime = normalizeInboundStoryUpdateEndTime(
1697
+ existing.published,
1698
+ existing.endTime,
1699
+ object.endTime,
1700
+ now,
1701
+ );
1702
+ if (!endTime) {
1703
+ log.debug("Update(Story) dropped: invalid or expired lifetime", {
1704
+ event: "ap.update.story_expired",
1705
+ actor,
1706
+ objectId,
1707
+ });
1708
+ return;
1709
+ }
1710
+
1711
+ // A Story's scope is fixed at creation. Re-authorize the retained community
1712
+ // on every Update (membership/ban policy may have changed) and fold any new
1713
+ // addressing into the same resolution. A second local community is then
1714
+ // ambiguous and rejected; a valid Update never widens or moves scope.
1715
+ const hasAddressingUpdate = declaresStoryAddressing(activity, object);
1716
+ if (hasAddressingUpdate || existing.communityApId !== null) {
1717
+ const storyAddressing = storyAddressedCollections(activity, object);
1718
+ if (storyAddressing.overflow) {
1719
+ log.warn("Update(Story) rejected: too many addressing entries", {
1720
+ event: "ap.update.story_addressing_overflow",
1721
+ actor,
1722
+ objectId,
1723
+ });
1724
+ return;
1725
+ }
1726
+ const scopeAddresses = existing.communityApId
1727
+ ? [...new Set([...storyAddressing.addresses, existing.communityApId])]
1728
+ : storyAddressing.addresses;
1729
+ const communityScope = await resolveInboundCommunityScope(
1730
+ db,
1731
+ actor,
1732
+ scopeAddresses,
1733
+ );
1734
+ if (
1735
+ !communityScope.allowed ||
1736
+ communityScope.communityApId !== existing.communityApId
1737
+ ) {
1738
+ log.warn("Update(Story) rejected: scope is unauthorized or changed", {
1739
+ event: "ap.update.story_scope_mismatch",
1740
+ actor,
1741
+ objectId,
1742
+ existingCommunityApId: existing.communityApId,
1743
+ });
1744
+ return;
1745
+ }
1746
+ }
1747
+
1748
+ const projection = buildInboundStoryUpdateProjection(
1749
+ object,
1750
+ existing.attachmentsJson,
1751
+ );
1752
+ if (!projection) {
1753
+ log.warn("Update(Story) rejected: invalid story projection", {
1754
+ event: "ap.update.story_invalid_projection",
1755
+ actor,
1756
+ objectId,
1757
+ });
1758
+ return;
1759
+ }
1760
+
1761
+ const projectionChanged = hasStoryProjectionUpdate(object);
1762
+ const updateStory = db
1763
+ .update(objects)
1764
+ .set({
1765
+ attachmentsJson: projectionChanged ? projection.json : undefined,
1766
+ endTime,
1767
+ updated: now,
1768
+ })
1769
+ .where(eq(objects.apId, objectId));
1770
+ if (object.overlays !== undefined) {
1771
+ // Poll votes are indexed only by option position. Replacing/clearing the
1772
+ // overlay list must clear old votes in the same D1 batch, otherwise an old
1773
+ // option 0 is silently counted for a different new option 0.
1774
+ await runBatch(db, [
1775
+ updateStory,
1776
+ db.delete(storyVotes).where(eq(storyVotes.storyApId, objectId)),
1777
+ ]);
1778
+ } else {
1779
+ await updateStory;
1780
+ }
1781
+ return;
1782
+ }
1460
1783
 
1461
1784
  // Update object content
1462
- if (typeIncludes(object.type, "Note")) {
1463
- const attachments = object.attachment
1464
- ? JSON.stringify(object.attachment)
1785
+ if (existing.type === "Note" && typeIncludes(object.type, "Note")) {
1786
+ // Update is instance-dispatched and therefore has no single recipient row.
1787
+ // Yurucommu's default deployment has one local owner, so the same owner-wide
1788
+ // block/mute policy used by Story Update must stop a newly suppressed remote
1789
+ // actor from replacing already-retained Note content or reach.
1790
+ if (await ownerSuppressesInboundActor(db, actor)) return;
1791
+
1792
+ const addressingContract = collectBoundedInboundAddresses([object]);
1793
+ if (!addressingContract.ok) {
1794
+ log.warn("Update(Note) rejected: invalid addressing projection", {
1795
+ event: "ap.update.note_addressing_invalid",
1796
+ actor,
1797
+ objectId,
1798
+ reason: addressingContract.reason,
1799
+ });
1800
+ return;
1801
+ }
1802
+
1803
+ const replyTarget = validateInboundReplyTarget(object.inReplyTo);
1804
+ if (!replyTarget.ok) {
1805
+ log.warn("Update(Note) rejected: invalid reply target", {
1806
+ event: "ap.update.note_reply_target_invalid",
1807
+ actor,
1808
+ objectId,
1809
+ reason: replyTarget.reason,
1810
+ });
1811
+ return;
1812
+ }
1813
+
1814
+ // Content and reach are one authority decision. A remote author can narrow
1815
+ // an existing public Note to followers/direct in the same Update; applying
1816
+ // only its new body would leave that private content readable through the
1817
+ // stale public single-object gate. Treat the presence of either addressing
1818
+ // field as a complete reach update (an omitted counterpart is empty), while
1819
+ // preserving both old fields for peers that send a legacy content-only
1820
+ // partial Update.
1821
+ const hasAddressingUpdate = declaresAddressing(object);
1822
+ const updatedAddressing = hasAddressingUpdate
1823
+ ? noteAddressing(object)
1824
+ : undefined;
1825
+ const hasAudienceUpdate = object.audience !== undefined;
1826
+ const updatedAudience = hasAudienceUpdate
1827
+ ? normalizedObjectAudience(activity, object)
1828
+ : undefined;
1829
+ const communityScope = hasAudienceUpdate
1830
+ ? await resolveInboundCommunityScope(db, actor, updatedAudience ?? [])
1831
+ : existing.communityApId
1832
+ ? await resolveInboundCommunityScope(db, actor, [
1833
+ existing.communityApId,
1834
+ ])
1835
+ : { allowed: true as const, communityApId: null };
1836
+ if (
1837
+ !communityScope.allowed ||
1838
+ (!hasAudienceUpdate &&
1839
+ communityScope.communityApId !== existing.communityApId)
1840
+ ) {
1841
+ log.warn("Update(Note) rejected: actor cannot project into community", {
1842
+ event: "ap.update.note_community_unauthorized",
1843
+ actor,
1844
+ objectId,
1845
+ });
1846
+ return;
1847
+ }
1848
+
1849
+ const hasThreadUpdate = replyTarget.parentId !== undefined;
1850
+ const updatedParentId = replyTarget.parentId;
1851
+ if (updatedParentId === objectId) {
1852
+ log.warn("Update(Note) rejected: object cannot reply to itself", {
1853
+ event: "ap.update.note_self_reply",
1854
+ actor,
1855
+ objectId,
1856
+ });
1857
+ return;
1858
+ }
1859
+ if (updatedParentId) {
1860
+ const parent = await db
1861
+ .select({
1862
+ apId: objects.apId,
1863
+ attributedTo: objects.attributedTo,
1864
+ visibility: objects.visibility,
1865
+ toJson: objects.toJson,
1866
+ ccJson: objects.ccJson,
1867
+ audienceJson: objects.audienceJson,
1868
+ communityApId: objects.communityApId,
1869
+ type: objects.type,
1870
+ endTime: objects.endTime,
1871
+ deletedAt: objects.deletedAt,
1872
+ })
1873
+ .from(objects)
1874
+ .where(eq(objects.apId, updatedParentId))
1875
+ .get();
1876
+ if (
1877
+ parent &&
1878
+ (parent.deletedAt !== null ||
1879
+ !(await canViewerReadObjectFull(db, parent, actor)) ||
1880
+ (isLocal(parent.attributedTo, c.env.APP_URL) &&
1881
+ (await actorSuppressesInteractionFrom(
1882
+ db,
1883
+ parent.attributedTo,
1884
+ actor,
1885
+ ))))
1886
+ ) {
1887
+ log.warn("Update(Note) rejected: reply parent is not readable", {
1888
+ event: "ap.update.note_parent_unreadable",
1889
+ actor,
1890
+ objectId,
1891
+ parentId: updatedParentId,
1892
+ });
1893
+ return;
1894
+ }
1895
+ }
1896
+
1897
+ const updatedVisibility = updatedAddressing
1898
+ ? isDirectShapedNote(updatedAddressing)
1899
+ ? "direct"
1900
+ : classifyInboundNoteVisibility(updatedAddressing)
1465
1901
  : undefined;
1466
- await db
1902
+ const projectedRecipients = updatedAddressing
1903
+ ? specificRecipientAddresses(updatedAddressing)
1904
+ : [];
1905
+ // DM conversation ids are pair authority. Never carry an old recipient's
1906
+ // thread id across a re-address; only a single specific recipient has a
1907
+ // 1:1 conversation in Yurucommu's model. Public/followers/multi-recipient/
1908
+ // empty reach clears the old DM conversation.
1909
+ const updatedConversation = hasAddressingUpdate
1910
+ ? updatedVisibility === "direct" && projectedRecipients.length === 1
1911
+ ? getConversationId(c.env.APP_URL, actor, projectedRecipients[0])
1912
+ : null
1913
+ : undefined;
1914
+ const updateObject = db
1467
1915
  .update(objects)
1468
1916
  .set({
1469
1917
  content:
1470
- typeof object.content === "string" && object.content
1471
- ? truncate(object.content, MAX_POST_CONTENT_LENGTH)
1918
+ object.content !== undefined
1919
+ ? boundInboundContent(object.content)
1472
1920
  : undefined,
1473
1921
  summary:
1474
- typeof object.summary === "string" && object.summary
1475
- ? truncate(object.summary, MAX_POST_SUMMARY_LENGTH)
1922
+ object.summary !== undefined
1923
+ ? boundInboundSummary(object.summary)
1924
+ : undefined,
1925
+ attachmentsJson:
1926
+ object.attachment !== undefined
1927
+ ? boundInboundNoteAttachmentsJson(object.attachment)
1476
1928
  : undefined,
1477
- attachmentsJson: attachments
1478
- ? boundAttachmentsJson(attachments)
1929
+ tagsJson:
1930
+ object.tag !== undefined
1931
+ ? boundInboundTagsJson(object.tag)
1932
+ : undefined,
1933
+ visibility: updatedVisibility,
1934
+ // bto/bcc are deliberately absent: their recipients are private and
1935
+ // represented only by the indexed recipient projection below.
1936
+ toJson: updatedAddressing
1937
+ ? boundAddressJson(updatedAddressing.to)
1938
+ : undefined,
1939
+ ccJson: updatedAddressing
1940
+ ? boundAddressJson(updatedAddressing.cc)
1941
+ : undefined,
1942
+ audienceJson: hasAudienceUpdate
1943
+ ? JSON.stringify(updatedAudience)
1944
+ : undefined,
1945
+ communityApId: hasAudienceUpdate
1946
+ ? communityScope.communityApId
1479
1947
  : undefined,
1948
+ inReplyTo: hasThreadUpdate ? (updatedParentId ?? null) : undefined,
1949
+ conversation: updatedConversation,
1480
1950
  updated: new Date().toISOString(),
1481
1951
  })
1482
1952
  .where(eq(objects.apId, objectId));
1953
+
1954
+ // `object_recipients(type=to)` is the indexed DM-read authority used by
1955
+ // contacts, requests, unread counts, and conversation discovery. Updating
1956
+ // only toJson would revoke canonical object GET while the old recipient
1957
+ // still received the NEW private body in their contact preview. Replace the
1958
+ // projection in the same D1 batch as the object row so neither old nor new
1959
+ // reach can be observed half-applied. insertMany keeps every statement
1960
+ // below D1's parameter ceiling for the bounded 64-address input.
1961
+ const recipientProjectionStatements = hasAddressingUpdate
1962
+ ? [
1963
+ db
1964
+ .delete(objectRecipients)
1965
+ .where(
1966
+ and(
1967
+ eq(objectRecipients.objectApId, objectId),
1968
+ eq(objectRecipients.type, "to"),
1969
+ ),
1970
+ ),
1971
+ ...insertMany(
1972
+ db,
1973
+ objectRecipients,
1974
+ projectedRecipients.map((recipientApId) => ({
1975
+ objectApId: objectId,
1976
+ recipientApId,
1977
+ type: "to",
1978
+ })),
1979
+ { conflict: "ignore" },
1980
+ ),
1981
+ ]
1982
+ : [];
1983
+
1984
+ // A reply edge and both denormalized parent counters are one mutation.
1985
+ // Recompute (rather than increment/decrement) after the child UPDATE so a
1986
+ // duplicate/retry converges without double-counting. The old and new parent
1987
+ // set has at most two entries, keeping the whole projection far below D1's
1988
+ // 50-statement batch ceiling even at the 64-recipient addressing bound.
1989
+ const parentIdsToRecompute = hasThreadUpdate
1990
+ ? [
1991
+ ...new Set(
1992
+ [existing.inReplyTo, updatedParentId ?? null].filter(
1993
+ (id): id is string => typeof id === "string" && id.length > 0,
1994
+ ),
1995
+ ),
1996
+ ]
1997
+ : [];
1998
+ const parentCounterStatements = parentIdsToRecompute.map((parentId) =>
1999
+ db
2000
+ .update(objects)
2001
+ .set({
2002
+ replyCount: sql`(SELECT COUNT(*) FROM ${objects} WHERE ${objects.inReplyTo} = ${parentId})`,
2003
+ })
2004
+ .where(eq(objects.apId, parentId)),
2005
+ );
2006
+
2007
+ const projectionStatements = [
2008
+ ...recipientProjectionStatements,
2009
+ ...parentCounterStatements,
2010
+ ];
2011
+ if (projectionStatements.length === 0) {
2012
+ await updateObject;
2013
+ } else {
2014
+ await runBatch(db, [updateObject, ...projectionStatements]);
2015
+ }
1483
2016
  }
1484
2017
  }
1485
2018
 
@@ -1493,14 +2026,15 @@ export async function handleMove(
1493
2026
  actor: string,
1494
2027
  ) {
1495
2028
  const db = c.get("db");
1496
- const oldActorApId = getActivityObjectId(activity);
2029
+ const claimedOldActorApId = getActivityObjectId(activity);
1497
2030
  const newActorApId = getActivityTargetId(activity);
1498
- if (!oldActorApId || !newActorApId) return;
2031
+ if (!claimedOldActorApId || !newActorApId) return;
1499
2032
 
1500
2033
  // Only accept self-move. Signature verification already ensures the request is signed,
1501
2034
  // but we also require Move.object to match Move.actor (defense-in-depth).
1502
- if (oldActorApId !== actor) return;
1503
- if (oldActorApId === newActorApId) return;
2035
+ if (!isSameActivityPubActor(claimedOldActorApId, actor)) return;
2036
+ if (isSameActivityPubActor(actor, newActorApId)) return;
2037
+ const oldActorApId = actor;
1504
2038
 
1505
2039
  if (!isSafeRemoteUrl(newActorApId)) {
1506
2040
  log.warn("Blocked unsafe Move target", {
@@ -1511,6 +2045,31 @@ export async function handleMove(
1511
2045
  return;
1512
2046
  }
1513
2047
 
2048
+ const baseUrl = c.env.APP_URL;
2049
+ const refollowPrefix = await moveRefollowPrefix({
2050
+ baseUrl,
2051
+ inboundActivityId: activity.id,
2052
+ oldActorApId,
2053
+ newActorApId,
2054
+ });
2055
+
2056
+ // The graph + outbound Follow activities commit atomically below, while the
2057
+ // external Queue send necessarily happens afterwards. If that send threw,
2058
+ // the inbox row remains processed=0 and a peer retry reaches this handler.
2059
+ // Resume from the durable activity namespace BEFORE re-fetching the alias:
2060
+ // the old edges are already gone, and a temporary destination fetch outage
2061
+ // must not turn a retryable Queue failure into a silently completed no-op.
2062
+ if (
2063
+ (await enqueuePersistedMoveRefollows({
2064
+ db,
2065
+ env: c.env,
2066
+ newActorApId,
2067
+ refollowPrefix,
2068
+ })) > 0
2069
+ ) {
2070
+ return;
2071
+ }
2072
+
1514
2073
  // SECURITY (account-migration follow-graph hijack): a signed Move only proves
1515
2074
  // the OLD actor consents to move; it does NOT prove the destination is the same
1516
2075
  // person. Without verifying the destination's `alsoKnownAs` back-reference, a
@@ -1536,266 +2095,22 @@ export async function handleMove(
1536
2095
  // Refresh/cache the new actor document (best-effort).
1537
2096
  await refreshActorCache(db, newActorApId);
1538
2097
 
1539
- // Rewrite follow graph references from old -> new in batches.
1540
- const followerRows = await db
1541
- .select({
1542
- followingApId: follows.followingApId,
1543
- status: follows.status,
1544
- activityApId: follows.activityApId,
1545
- createdAt: follows.createdAt,
1546
- acceptedAt: follows.acceptedAt,
1547
- })
1548
- .from(follows)
1549
- .where(eq(follows.followerApId, oldActorApId));
1550
-
1551
- const followingRows = await db
1552
- .select({
1553
- followerApId: follows.followerApId,
1554
- status: follows.status,
1555
- activityApId: follows.activityApId,
1556
- createdAt: follows.createdAt,
1557
- acceptedAt: follows.acceptedAt,
1558
- })
1559
- .from(follows)
1560
- .where(eq(follows.followingApId, oldActorApId));
1561
-
1562
- const followerTargets = followerRows.map((row) => row.followingApId);
1563
- const followingSources = followingRows.map((row) => row.followerApId);
1564
-
1565
- const existingFollowerPairs =
1566
- followerTargets.length > 0
1567
- ? await db
1568
- .select({ followingApId: follows.followingApId })
1569
- .from(follows)
1570
- .where(
1571
- and(
1572
- eq(follows.followerApId, newActorApId),
1573
- // Subquery, not `inArray(followerTargets)`: the old actor's follow
1574
- // graph can exceed D1's 100-bound-parameter ceiling. Same set as
1575
- // followerTargets (the old actor's followees).
1576
- inArray(
1577
- follows.followingApId,
1578
- db
1579
- .select({ id: follows.followingApId })
1580
- .from(follows)
1581
- .where(eq(follows.followerApId, oldActorApId)),
1582
- ),
1583
- ),
1584
- )
1585
- : [];
1586
- const existingFollowingPairs =
1587
- followingSources.length > 0
1588
- ? await db
1589
- .select({ followerApId: follows.followerApId })
1590
- .from(follows)
1591
- .where(
1592
- and(
1593
- // Subquery, not `inArray(followingSources)`: the old actor's
1594
- // follower graph can exceed D1's 100-bound-parameter ceiling. Same
1595
- // set as followingSources (the old actor's followers).
1596
- inArray(
1597
- follows.followerApId,
1598
- db
1599
- .select({ id: follows.followerApId })
1600
- .from(follows)
1601
- .where(eq(follows.followingApId, oldActorApId)),
1602
- ),
1603
- eq(follows.followingApId, newActorApId),
1604
- ),
1605
- )
1606
- : [];
1607
-
1608
- const existingFollowerTargetSet = new Set(
1609
- existingFollowerPairs.map((row) => row.followingApId),
1610
- );
1611
- const existingFollowingSourceSet = new Set(
1612
- existingFollowingPairs.map((row) => row.followerApId),
1613
- );
1614
-
1615
- // Drop self-edges in addition to the existing-pair dedup: if the old and new
1616
- // actor were already connected (old followed/was-followed-by new, or vice
1617
- // versa), rewriting the endpoint to the new actor would produce a row where
1618
- // followerApId === followingApId (a self-follow). Filter those out so the
1619
- // migration never materializes a self-follow.
1620
- const followerRewrites = followerRows
1621
- .filter(
1622
- (row) =>
1623
- !existingFollowerTargetSet.has(row.followingApId) &&
1624
- row.followingApId !== newActorApId,
1625
- )
1626
- .map((row) => ({
1627
- followerApId: newActorApId,
1628
- followingApId: row.followingApId,
1629
- status: row.status,
1630
- activityApId: row.activityApId,
1631
- createdAt: row.createdAt,
1632
- acceptedAt: row.acceptedAt,
1633
- }));
1634
- // For followers that are LOCAL to this instance, a bare edge rewrite is not
1635
- // enough: the destination server has no record of the follow, so it would
1636
- // never deliver the migrated account's posts (the local user's following list
1637
- // would point at the new actor but silently receive nothing). Re-issue a
1638
- // fresh, *pending* Follow to the new actor and enqueue outbound delivery —
1639
- // the standard Mastodon "follow the move target on the user's behalf"
1640
- // behavior; the destination's Accept flips it to accepted. Remote followers
1641
- // are left as a plain edge rewrite: re-establishing their follow is their own
1642
- // server's responsibility.
1643
- const baseUrl = c.env.APP_URL;
1644
- const localReFollows: { followerApId: string; followId: string }[] = [];
1645
- const followingRewrites = followingRows
1646
- .filter(
1647
- (row) =>
1648
- !existingFollowingSourceSet.has(row.followerApId) &&
1649
- row.followerApId !== newActorApId,
1650
- )
1651
- .map((row) => {
1652
- if (isLocal(row.followerApId, baseUrl)) {
1653
- const followId = activityApId(baseUrl, generateId());
1654
- localReFollows.push({ followerApId: row.followerApId, followId });
1655
- return {
1656
- followerApId: row.followerApId,
1657
- followingApId: newActorApId,
1658
- status: "pending",
1659
- activityApId: followId,
1660
- createdAt: row.createdAt,
1661
- acceptedAt: null,
1662
- };
1663
- }
1664
- return {
1665
- followerApId: row.followerApId,
1666
- followingApId: newActorApId,
1667
- status: row.status,
1668
- activityApId: row.activityApId,
1669
- createdAt: row.createdAt,
1670
- acceptedAt: row.acceptedAt,
1671
- };
1672
- });
1673
-
1674
- // LOCAL followers whose ACCEPTED edge to the old actor we are about to delete.
1675
- // Each such edge was counted in that follower's followingCount; the re-issued
1676
- // Follow to the new actor is created PENDING (uncounted) and only re-adds the
1677
- // +1 when the destination Accepts (handleAccept). So the old +1 must be removed
1678
- // now, otherwise the eventual Accept stacks a second +1 on the never-removed
1679
- // old count → a permanent over-count of 1 per migrated follow. Decrementing at
1680
- // delete time is correct in every Accept-timing case: during the pending window
1681
- // the follower counts 0 of this relationship (right — it is pending), after the
1682
- // Accept it is back to 1, and if the Accept never arrives it stays decremented
1683
- // (right — the edge is perpetually pending). Remote followers' counts are not
1684
- // ours to manage; only local followingCount is authoritative here.
1685
- const localAcceptedFollowerApIds = Array.from(
1686
- new Set(
1687
- followingRows
1688
- .filter(
1689
- (row) =>
1690
- row.status === "accepted" && isLocal(row.followerApId, baseUrl),
1691
- )
1692
- .map((row) => row.followerApId),
1693
- ),
1694
- );
1695
-
1696
- // Symmetric to the above, on the FOLLOWEE side: the old actor's ACCEPTED follow
1697
- // of a LOCAL actor L incremented L.followerCount (handleFollow). When the
1698
- // (old→L) rewrite is DROPPED as a duplicate (the NEW actor already follows L,
1699
- // i.e. L ∈ existingFollowerTargetSet) or as a self-edge (L === newActor), the
1700
- // delete below still removes (old→L) but NO rewrite re-adds an (new→L) edge for
1701
- // it — so L would keep a permanent +1 over-count. Decrement those dropped,
1702
- // accepted, local followees' followerCount once (the non-dropped case is
1703
- // count-preserving: delete old→L + insert new→L). gt(>0) guards underflow.
1704
- const droppedAcceptedLocalFolloweeApIds = Array.from(
1705
- new Set(
1706
- followerRows
1707
- .filter(
1708
- (row) =>
1709
- row.status === "accepted" &&
1710
- isLocal(row.followingApId, baseUrl) &&
1711
- (existingFollowerTargetSet.has(row.followingApId) ||
1712
- row.followingApId === newActorApId),
1713
- )
1714
- .map((row) => row.followingApId),
1715
- ),
1716
- );
1717
-
1718
- // Co-commit the four edge mutations + the per-follower followingCount
1719
- // decrements in ONE atomic batch. D1 has no interactive transactions, and the
1720
- // OLD sequential form was non-convergent: a crash between "delete old edges"
1721
- // and "decrement" left the old edges gone, so a re-dispatch (the row is still
1722
- // processed=0) re-read EMPTY follower/following rows, skipped the decrement,
1723
- // and left every migrated local follower's followingCount permanently +1 over.
1724
- // Batching makes delete+decrement all-or-nothing: a crash before commit changes
1725
- // nothing (retry re-runs cleanly from the still-present old edges); a crash
1726
- // after commit re-reads no old edges (the batch is then a no-op) → the
1727
- // decrement is applied exactly once.
1728
- const moveOps = [];
1729
- if (followerRewrites.length > 0) {
1730
- moveOps.push(db.insert(follows).values(followerRewrites));
1731
- }
1732
- if (followerRows.length > 0) {
1733
- moveOps.push(
1734
- db.delete(follows).where(eq(follows.followerApId, oldActorApId)),
1735
- );
1736
- }
1737
- if (followingRewrites.length > 0) {
1738
- moveOps.push(db.insert(follows).values(followingRewrites));
1739
- }
1740
- if (followingRows.length > 0) {
1741
- moveOps.push(
1742
- db.delete(follows).where(eq(follows.followingApId, oldActorApId)),
1743
- );
1744
- }
1745
- for (const followerApId of localAcceptedFollowerApIds) {
1746
- moveOps.push(
1747
- db
1748
- .update(actors)
1749
- .set({ followingCount: sql`${actors.followingCount} - 1` })
1750
- .where(
1751
- and(eq(actors.apId, followerApId), gt(actors.followingCount, 0)),
1752
- ),
1753
- );
1754
- }
1755
- for (const followeeApId of droppedAcceptedLocalFolloweeApIds) {
1756
- moveOps.push(
1757
- db
1758
- .update(actors)
1759
- .set({ followerCount: sql`${actors.followerCount} - 1` })
1760
- .where(and(eq(actors.apId, followeeApId), gt(actors.followerCount, 0))),
1761
- );
1762
- }
1763
- if (moveOps.length > 0) {
1764
- await runBatch(db, moveOps as unknown as Parameters<typeof runBatch>[1]);
1765
- }
2098
+ await rewriteMovedFollowGraph({
2099
+ db,
2100
+ oldActorApId,
2101
+ newActorApId,
2102
+ refollowPrefix,
2103
+ });
1766
2104
 
1767
- // Record + deliver the outbound Follow activities for migrated local
1768
- // followers so the destination server registers them as followers and starts
1769
- // delivering. Best-effort per follower: a delivery enqueue failure must not
1770
- // abort the rest of the migration (the follow row is already pending and will
1771
- // simply lack delivery until retried).
1772
- for (const { followerApId, followId } of localReFollows) {
1773
- const followActivity = {
1774
- "@context": "https://www.w3.org/ns/activitystreams",
1775
- id: followId,
1776
- type: "Follow",
1777
- actor: followerApId,
1778
- object: newActorApId,
1779
- };
1780
- try {
1781
- await db.insert(activities).values({
1782
- apId: followId,
1783
- type: "Follow",
1784
- actorApId: followerApId,
1785
- objectApId: newActorApId,
1786
- rawJson: JSON.stringify(followActivity),
1787
- direction: "outbound",
1788
- });
1789
- await enqueueDeliveryToActor(c.env, followId, newActorApId);
1790
- } catch (e) {
1791
- log.warn("Failed to issue migration re-follow to move target", {
1792
- event: "ap.move.refollow_failed",
1793
- follower: followerApId,
1794
- newActor: newActorApId,
1795
- error: e,
1796
- });
1797
- }
1798
- }
2105
+ // Queue delivery is the only non-transactional step. Do not swallow a real
2106
+ // producer failure: the inbox claim remains uncommitted and a retry resumes
2107
+ // the durable activities through the prefix check at the top of this handler.
2108
+ await enqueuePersistedMoveRefollows({
2109
+ db,
2110
+ env: c.env,
2111
+ newActorApId,
2112
+ refollowPrefix,
2113
+ });
1799
2114
  }
1800
2115
 
1801
2116
  // ---------------------------------------------------------------------------