@takosjp/yurucommu-core 3.0.0

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 (185) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +82 -0
  3. package/migrations/0001_init.sql +495 -0
  4. package/migrations/0002_social_remote_actor_edges.sql +92 -0
  5. package/migrations/0003_activity_remote_object_edges.sql +68 -0
  6. package/migrations/0004_blocklist.sql +26 -0
  7. package/migrations/0005_story_community_scope.sql +13 -0
  8. package/migrations/0006_dm_community_read_status.sql +19 -0
  9. package/migrations/0007_moderation_reports.sql +22 -0
  10. package/migrations/0008_actor_fields_aka.sql +18 -0
  11. package/migrations/0009_object_tags.sql +13 -0
  12. package/migrations/0010_object_recipients_drop_actor_fk.sql +34 -0
  13. package/migrations/0011_drop_remote_actor_fks.sql +205 -0
  14. package/migrations/0012_objects_content_fts.sql +39 -0
  15. package/migrations/0013_efficiency_indexes.sql +13 -0
  16. package/migrations/0014_inbox_actor_created_idx.sql +15 -0
  17. package/migrations/0015_community_bans.sql +16 -0
  18. package/migrations/0016_namespace_takos_oidc_subject.sql +19 -0
  19. package/migrations/0017_mobile_push_registrations.sql +22 -0
  20. package/migrations/README.md +122 -0
  21. package/package.json +75 -0
  22. package/packages/api/LICENSE +16 -0
  23. package/packages/api/package.json +30 -0
  24. package/packages/api/src/index.ts +4 -0
  25. package/packages/api/src/lib/api/account.ts +20 -0
  26. package/packages/api/src/lib/api/actors.ts +149 -0
  27. package/packages/api/src/lib/api/auth.ts +46 -0
  28. package/packages/api/src/lib/api/communities.ts +329 -0
  29. package/packages/api/src/lib/api/dm.test.ts +67 -0
  30. package/packages/api/src/lib/api/dm.ts +236 -0
  31. package/packages/api/src/lib/api/fetch.ts +111 -0
  32. package/packages/api/src/lib/api/follow.ts +30 -0
  33. package/packages/api/src/lib/api/media.ts +100 -0
  34. package/packages/api/src/lib/api/moderation.ts +98 -0
  35. package/packages/api/src/lib/api/normalize.ts +71 -0
  36. package/packages/api/src/lib/api/notifications.test.ts +63 -0
  37. package/packages/api/src/lib/api/notifications.ts +61 -0
  38. package/packages/api/src/lib/api/posts.test.ts +110 -0
  39. package/packages/api/src/lib/api/posts.ts +181 -0
  40. package/packages/api/src/lib/api/recommendations.ts +22 -0
  41. package/packages/api/src/lib/api/search.ts +88 -0
  42. package/packages/api/src/lib/api/stories.ts +80 -0
  43. package/packages/api/src/lib/api.ts +15 -0
  44. package/packages/api/src/lib/fetch-with-timeout.ts +42 -0
  45. package/packages/api/src/lib/transport.ts +40 -0
  46. package/packages/api/src/social-server.ts +47 -0
  47. package/packages/api/src/types/index.ts +185 -0
  48. package/scripts/apply-takosumi-migrations.ts +621 -0
  49. package/src/backend/federation-helpers.ts +36 -0
  50. package/src/backend/index.ts +872 -0
  51. package/src/backend/lib/account-migration.ts +106 -0
  52. package/src/backend/lib/activitypub-actor-cache.ts +238 -0
  53. package/src/backend/lib/activitypub-helpers.ts +131 -0
  54. package/src/backend/lib/activitypub-validators.ts +323 -0
  55. package/src/backend/lib/ap-context.ts +16 -0
  56. package/src/backend/lib/ap-ids.ts +101 -0
  57. package/src/backend/lib/ap-response.ts +30 -0
  58. package/src/backend/lib/ap-signing.ts +87 -0
  59. package/src/backend/lib/ap-verify.ts +670 -0
  60. package/src/backend/lib/auth-lockout.ts +230 -0
  61. package/src/backend/lib/backend-paths.ts +34 -0
  62. package/src/backend/lib/base64.ts +30 -0
  63. package/src/backend/lib/blocklist-purge.ts +109 -0
  64. package/src/backend/lib/blocklist.ts +279 -0
  65. package/src/backend/lib/chunk.ts +33 -0
  66. package/src/backend/lib/client-ip.ts +169 -0
  67. package/src/backend/lib/community-visibility.ts +230 -0
  68. package/src/backend/lib/crypto.ts +424 -0
  69. package/src/backend/lib/delivery/circuit.ts +265 -0
  70. package/src/backend/lib/delivery/metrics.ts +30 -0
  71. package/src/backend/lib/delivery/planner.ts +190 -0
  72. package/src/backend/lib/delivery/queue-batching.ts +626 -0
  73. package/src/backend/lib/delivery/queue-delivery.ts +641 -0
  74. package/src/backend/lib/delivery/queue.ts +576 -0
  75. package/src/backend/lib/delivery/transformers.ts +56 -0
  76. package/src/backend/lib/delivery/types.ts +139 -0
  77. package/src/backend/lib/errors.ts +114 -0
  78. package/src/backend/lib/federation-fetch.ts +296 -0
  79. package/src/backend/lib/feed-cursor.ts +57 -0
  80. package/src/backend/lib/feed-exclude.ts +48 -0
  81. package/src/backend/lib/hex.ts +8 -0
  82. package/src/backend/lib/log-mask.ts +213 -0
  83. package/src/backend/lib/logger.ts +285 -0
  84. package/src/backend/lib/mobile-contract.ts +137 -0
  85. package/src/backend/lib/oauth-providers.ts +324 -0
  86. package/src/backend/lib/oauth-utils.ts +148 -0
  87. package/src/backend/lib/oidc-id-token.ts +151 -0
  88. package/src/backend/lib/parse-helpers.ts +31 -0
  89. package/src/backend/lib/post-visibility.ts +190 -0
  90. package/src/backend/lib/session-actor.ts +61 -0
  91. package/src/backend/lib/ssrf.ts +428 -0
  92. package/src/backend/lib/strip-image-metadata.ts +191 -0
  93. package/src/backend/middleware/bearer-auth.ts +70 -0
  94. package/src/backend/middleware/body-limit.ts +212 -0
  95. package/src/backend/middleware/cache.ts +429 -0
  96. package/src/backend/middleware/csrf.ts +130 -0
  97. package/src/backend/middleware/error-handler.ts +77 -0
  98. package/src/backend/middleware/rate-limit.ts +308 -0
  99. package/src/backend/public.ts +21 -0
  100. package/src/backend/routes/account-teardown.ts +430 -0
  101. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +354 -0
  102. package/src/backend/routes/activitypub/handlers/inbound-timestamp.ts +29 -0
  103. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1634 -0
  104. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +547 -0
  105. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +497 -0
  106. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +262 -0
  107. package/src/backend/routes/activitypub/handlers/user-inbox-handlers.ts +35 -0
  108. package/src/backend/routes/activitypub/inbox-types.ts +74 -0
  109. package/src/backend/routes/activitypub/inbox.ts +1191 -0
  110. package/src/backend/routes/activitypub/outbox.ts +0 -0
  111. package/src/backend/routes/activitypub/query-helpers.ts +227 -0
  112. package/src/backend/routes/activitypub.ts +616 -0
  113. package/src/backend/routes/actors-helpers.ts +487 -0
  114. package/src/backend/routes/actors.ts +1311 -0
  115. package/src/backend/routes/apps.ts +313 -0
  116. package/src/backend/routes/auth-helpers.ts +566 -0
  117. package/src/backend/routes/auth.ts +615 -0
  118. package/src/backend/routes/communities/membership-invites.ts +208 -0
  119. package/src/backend/routes/communities/membership-join.ts +335 -0
  120. package/src/backend/routes/communities/membership-members.ts +539 -0
  121. package/src/backend/routes/communities/membership-requests.ts +296 -0
  122. package/src/backend/routes/communities/membership-shared.ts +364 -0
  123. package/src/backend/routes/communities/messages.ts +479 -0
  124. package/src/backend/routes/communities/routes.ts +624 -0
  125. package/src/backend/routes/communities.ts +21 -0
  126. package/src/backend/routes/dm/contacts.ts +525 -0
  127. package/src/backend/routes/dm/conversations-helpers.ts +197 -0
  128. package/src/backend/routes/dm/conversations.ts +25 -0
  129. package/src/backend/routes/dm/messages.ts +658 -0
  130. package/src/backend/routes/dm/query-helpers.ts +85 -0
  131. package/src/backend/routes/dm/read-archive.ts +228 -0
  132. package/src/backend/routes/dm/requests.ts +222 -0
  133. package/src/backend/routes/dm/typing.ts +81 -0
  134. package/src/backend/routes/dm.ts +15 -0
  135. package/src/backend/routes/follow-helpers.ts +370 -0
  136. package/src/backend/routes/follow.ts +588 -0
  137. package/src/backend/routes/media.ts +692 -0
  138. package/src/backend/routes/mobile.ts +159 -0
  139. package/src/backend/routes/moderation.ts +373 -0
  140. package/src/backend/routes/notifications.ts +757 -0
  141. package/src/backend/routes/posts/delete-cascade.ts +330 -0
  142. package/src/backend/routes/posts/interactions.ts +795 -0
  143. package/src/backend/routes/posts/post-helpers.ts +847 -0
  144. package/src/backend/routes/posts/queries.ts +537 -0
  145. package/src/backend/routes/posts/routes.ts +865 -0
  146. package/src/backend/routes/posts/transformers.ts +161 -0
  147. package/src/backend/routes/posts.ts +17 -0
  148. package/src/backend/routes/recommendations.ts +88 -0
  149. package/src/backend/routes/search.ts +730 -0
  150. package/src/backend/routes/stories/interactions.ts +576 -0
  151. package/src/backend/routes/stories/query-helpers.ts +482 -0
  152. package/src/backend/routes/stories/routes.ts +906 -0
  153. package/src/backend/routes/stories.ts +13 -0
  154. package/src/backend/routes/takos-tools/dm.ts +249 -0
  155. package/src/backend/routes/takos-tools/follows.ts +225 -0
  156. package/src/backend/routes/takos-tools/posts.ts +292 -0
  157. package/src/backend/routes/takos-tools/search.ts +228 -0
  158. package/src/backend/routes/takos-tools/timeline.ts +132 -0
  159. package/src/backend/routes/takos-tools/types.ts +10 -0
  160. package/src/backend/routes/takos-tools-response.ts +178 -0
  161. package/src/backend/routes/takos-tools.ts +153 -0
  162. package/src/backend/routes/timeline.ts +755 -0
  163. package/src/backend/runtime/bun.ts +620 -0
  164. package/src/backend/runtime/cloudflare.ts +202 -0
  165. package/src/backend/runtime/compat-bun/types.ts +44 -0
  166. package/src/backend/runtime/memory-kv.ts +104 -0
  167. package/src/backend/runtime/shared.ts +142 -0
  168. package/src/backend/runtime/types.ts +205 -0
  169. package/src/backend/server.ts +636 -0
  170. package/src/backend/types.ts +143 -0
  171. package/src/db/index.ts +97 -0
  172. package/src/db/schema/actors.ts +129 -0
  173. package/src/db/schema/communities.ts +133 -0
  174. package/src/db/schema/date-utils.ts +17 -0
  175. package/src/db/schema/index.ts +17 -0
  176. package/src/db/schema/messaging.ts +241 -0
  177. package/src/db/schema/mobile.ts +37 -0
  178. package/src/db/schema/posts.ts +150 -0
  179. package/src/db/schema/relations.ts +266 -0
  180. package/src/db/schema/reports.ts +33 -0
  181. package/src/db/schema/social.ts +106 -0
  182. package/src/db/schema/stories.ts +70 -0
  183. package/src/db/schema.ts +15 -0
  184. package/src/plugin/public.ts +7 -0
  185. package/src/runtime/site-worker.ts +10 -0
@@ -0,0 +1,1634 @@
1
+ import type { Database } from "../../../../db/index.ts";
2
+ import { and, count, eq, gt, inArray, isNotNull, or, sql } from "drizzle-orm";
3
+ import type { BatchItem } from "drizzle-orm/batch";
4
+ import {
5
+ activities,
6
+ actorCache,
7
+ actors,
8
+ announces,
9
+ blocks,
10
+ bookmarks,
11
+ follows,
12
+ inbox as inboxTable,
13
+ likes,
14
+ objectRecipients,
15
+ objects,
16
+ storyShares,
17
+ storyViews,
18
+ storyVotes,
19
+ } from "../../../../db/index.ts";
20
+ import { upsertActivityAndNotify } from "./inbox-shared-helpers.ts";
21
+ import { normalizeInboundTimestamp } from "./inbound-timestamp.ts";
22
+ import {
23
+ deleteObjectCascade,
24
+ purgeMediaBlobs,
25
+ } from "../../posts/delete-cascade.ts";
26
+ import {
27
+ boundAttachmentsJson,
28
+ boundInboundContent,
29
+ boundInboundSummary,
30
+ MAX_ATTACHMENTS_JSON_LENGTH,
31
+ MAX_POST_CONTENT_LENGTH,
32
+ MAX_POST_SUMMARY_LENGTH,
33
+ truncate,
34
+ } from "../../posts/transformers.ts";
35
+ import {
36
+ activityApId,
37
+ generateId,
38
+ getDomain,
39
+ isLocal,
40
+ isSafeRemoteUrl,
41
+ objectApId,
42
+ } from "../../../federation-helpers.ts";
43
+ import { getConversationId } from "../../dm/query-helpers.ts";
44
+ import {
45
+ fetchAndUpsertActorCache,
46
+ getInstanceFetchSignerByDb,
47
+ } from "../../../lib/activitypub-actor-cache.ts";
48
+ import { enqueueDeliveryToActor } from "../../../lib/delivery/queue.ts";
49
+ import { destinationDeclaresAlias } from "../../../lib/account-migration.ts";
50
+ import { chunkForInClause } from "../../../lib/chunk.ts";
51
+ import {
52
+ actorIsBlockedBy,
53
+ canViewerReadObjectFull,
54
+ } from "../../../lib/post-visibility.ts";
55
+ import { logger } from "../../../lib/logger.ts";
56
+ import {
57
+ type Activity,
58
+ type ActivityContext,
59
+ type ActivityObject,
60
+ getActivityObject,
61
+ getActivityObjectId,
62
+ type StoryOverlay,
63
+ typeIncludes,
64
+ } from "../inbox-types.ts";
65
+
66
+ const log = logger.child({ component: "activitypub.inbox.content" });
67
+
68
+ type ActorRow = typeof actors.$inferSelect;
69
+
70
+ // normalizeInboundTimestamp now lives in ./inbound-timestamp.ts (shared with the
71
+ // federated group-chat path) — imported at the top of this file.
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Atomic multi-statement commit (mirrors posts/interactions.ts `runBatch` and
75
+ // the inbox-interaction / inbox-shared helper). D1 has no interactive
76
+ // transactions, but both the D1 and libsql drivers expose `db.batch([...])`,
77
+ // which commits a list of prepared statements atomically. The shared
78
+ // `Database` union aliases the abstract `BaseSQLiteDatabase` base (which does
79
+ // not surface `batch`), so we narrow to the concrete batch surface here.
80
+ // ---------------------------------------------------------------------------
81
+
82
+ type BatchStatement = BatchItem<"sqlite">;
83
+ interface BatchableDb {
84
+ batch(
85
+ statements: readonly [BatchStatement, ...BatchStatement[]],
86
+ ): Promise<unknown>;
87
+ }
88
+
89
+ async function runBatch(
90
+ db: Database,
91
+ statements: readonly [BatchStatement, ...BatchStatement[]],
92
+ ): Promise<void> {
93
+ await (db as unknown as BatchableDb).batch(statements);
94
+ }
95
+
96
+ // Federation blocklist enforcement lives centrally in
97
+ // `verifyAndParseInbox` (routes/activitypub/inbox.ts): every inbound
98
+ // activity is gated once there before any handler runs, so the per-handler
99
+ // gate that previously lived here is intentionally absent.
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Inline helpers
103
+ // ---------------------------------------------------------------------------
104
+
105
+ function isStoryType(type: string | string[] | undefined): boolean {
106
+ if (!type) return false;
107
+ return Array.isArray(type) ? type.includes("Story") : type === "Story";
108
+ }
109
+
110
+ // The actor object types whose inbound Update represents a remote
111
+ // profile / avatar / public-key change that should refresh the actor cache.
112
+ const ACTOR_OBJECT_TYPES = new Set([
113
+ "Person",
114
+ "Service",
115
+ "Group",
116
+ "Organization",
117
+ "Application",
118
+ ]);
119
+
120
+ // Minimum interval between outbound actor re-fetches triggered by an inbound
121
+ // Update(actor). Within this window we rely on the existing cache row (and the
122
+ // normal actor-cache TTL) instead of re-fetching, so a flood of Update
123
+ // activities cannot amplify into a flood of outbound fetches.
124
+ const ACTOR_UPDATE_REFETCH_COOLDOWN_MS = 60_000;
125
+
126
+ function isActorTypeUpdate(type: string | string[] | undefined): boolean {
127
+ if (!type) return false;
128
+ return Array.isArray(type)
129
+ ? type.some((t) => ACTOR_OBJECT_TYPES.has(t))
130
+ : ACTOR_OBJECT_TYPES.has(type);
131
+ }
132
+
133
+ // The ActivityStreams public-collection magic value, including the legacy
134
+ // short forms some implementations still emit.
135
+ const PUBLIC_COLLECTION = new Set([
136
+ "https://www.w3.org/ns/activitystreams#Public",
137
+ "as:Public",
138
+ "Public",
139
+ ]);
140
+
141
+ function addressesPublic(addresses: string[]): boolean {
142
+ return addresses.some((a) => PUBLIC_COLLECTION.has(a));
143
+ }
144
+
145
+ // A note addressed to a followers collection (the author's `<actor>/followers`)
146
+ // and NOT to Public is a followers-only post. We match any `/followers`
147
+ // collection by suffix (mirrors isDirectNote), which covers the author's
148
+ // collection without needing to resolve it.
149
+ function addressesFollowers(addresses: string[]): boolean {
150
+ return addresses.some((a) => a.endsWith("/followers"));
151
+ }
152
+
153
+ /**
154
+ * Recipient-INDEPENDENT visibility classification for an inbound generic Note,
155
+ * mirroring the local outbound addressing contract. CRITICAL invariant: a
156
+ * non-public Note is NEVER classified as "unlisted" (world-readable). Direct
157
+ * (addressed-to-specific-actors-only) Notes are diverted BEFORE this is reached
158
+ * (insertDirectNote / the direct-shaped skip), so the residual here is:
159
+ * - "public" — the Public collection is in `to`;
160
+ * - "unlisted" — Public is only in `cc` (Mastodon-style unlisted), or the
161
+ * note carries no usable addressing at all;
162
+ * - "followers" — a followers collection is addressed and Public is absent.
163
+ * Previously this was derived solely from `to.includes(Public)`, so a remote
164
+ * followers-only post (Public absent) was silently downgraded to "unlisted" and
165
+ * became world-readable. */
166
+ function classifyInboundNoteVisibility(object: {
167
+ to?: string[];
168
+ cc?: string[];
169
+ }): "public" | "unlisted" | "followers" {
170
+ const to = object.to ?? [];
171
+ const cc = object.cc ?? [];
172
+ if (addressesPublic(to)) return "public";
173
+ if (addressesPublic(cc)) return "unlisted";
174
+ if (addressesFollowers([...to, ...cc])) return "followers";
175
+ return "unlisted";
176
+ }
177
+
178
+ /**
179
+ * A Note addressed ONLY to specific actors — no Public, no followers collection
180
+ * — i.e. a direct/DM-shaped Note. When such a Note reaches a shared-inbox fan-out
181
+ * recipient it is NOT addressed to, it must NOT be stored as a world-readable
182
+ * generic Note; the addressed local actor's own delivery handles it via
183
+ * insertDirectNote. Recipient-independent (keyed on the activity's own
184
+ * addressing), unlike isDirectNote.
185
+ */
186
+ function isDirectShapedNote(object: { to?: string[]; cc?: string[] }): boolean {
187
+ const all = [...(object.to ?? []), ...(object.cc ?? [])];
188
+ if (all.length === 0) return false;
189
+ if (addressesPublic(all)) return false;
190
+ if (addressesFollowers(all)) return false;
191
+ return true;
192
+ }
193
+
194
+ // Cap persisted addressing arrays so a remote cannot bloat a row with a huge
195
+ // to/cc list; 64 entries is far beyond any real audience and keeps the explicit-
196
+ // recipient (mention) gate working.
197
+ const MAX_ADDRESS_ENTRIES = 64;
198
+ function boundAddressJson(addresses: string[] | undefined): string {
199
+ if (!Array.isArray(addresses) || addresses.length === 0) return "[]";
200
+ return JSON.stringify(
201
+ addresses
202
+ .filter((a) => typeof a === "string")
203
+ .slice(0, MAX_ADDRESS_ENTRIES),
204
+ );
205
+ }
206
+
207
+ /**
208
+ * Reject an inbound object whose `object.id` is asserted under a host the
209
+ * delivering actor does not control (object-ID squatting / cross-origin
210
+ * injection). A remote actor may only Create objects under its own origin, and
211
+ * never under the local domain. Returns true when the object id must be
212
+ * rejected. Mirrors the ownership checks already enforced for Delete/Update.
213
+ */
214
+ function isObjectIdOriginMismatch(
215
+ objectId: string | undefined,
216
+ actor: string,
217
+ baseUrl: string,
218
+ ): boolean {
219
+ if (!objectId) return false;
220
+ // A remote actor must never assert a local-domain object id.
221
+ if (isLocal(objectId, baseUrl)) return true;
222
+ try {
223
+ return getDomain(objectId) !== getDomain(actor);
224
+ } catch {
225
+ // Unparseable object id: treat as a mismatch (reject) rather than insert.
226
+ return true;
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Extract the `href` of every `Mention` tag on an inbound object. AS2 `tag` may
232
+ * be an array, a single object, or absent; each Mention carries the mentioned
233
+ * actor's id in `href`. Used to fan-in mention notifications for federated posts
234
+ * (mirrors the local processMentions path).
235
+ */
236
+ // Cap on the number of distinct local mentions a single inbound activity can
237
+ // fan a notification out to. `object.tag` is bounded only by the 512 KiB inbox
238
+ // payload cap (~9-10k Mention entries), and each one used to cost a serial
239
+ // `actors` SELECT — an attacker could blow the Workers subrequest budget with
240
+ // one signed POST. Real posts mention a handful of people, so this ceiling is
241
+ // generous while bounding the worst case.
242
+ const MAX_INBOUND_MENTIONS = 50;
243
+
244
+ function extractMentionHrefs(tag: unknown): string[] {
245
+ const arr = Array.isArray(tag) ? tag : tag ? [tag] : [];
246
+ const hrefs: string[] = [];
247
+ for (const t of arr) {
248
+ if (
249
+ t &&
250
+ typeof t === "object" &&
251
+ typeIncludes((t as { type?: string | string[] }).type, "Mention")
252
+ ) {
253
+ const href = (t as { href?: unknown }).href;
254
+ if (typeof href === "string" && href) hrefs.push(href);
255
+ }
256
+ }
257
+ return hrefs;
258
+ }
259
+
260
+ /**
261
+ * Detect an inbound direct (DM) Note: it is addressed (in `to`/`cc`) to one or
262
+ * more recipients but NOT to the Public collection and NOT to a followers
263
+ * collection. The local addressed recipient is the inbox owner (`recipient`),
264
+ * who is necessarily a known local actor row. Mirrors the outbound DM contract
265
+ * in dm/messages.ts (visibility="direct", to=[recipient]).
266
+ */
267
+ function isDirectNote(
268
+ object: { to?: string[]; cc?: string[] },
269
+ recipient: ActorRow,
270
+ ): boolean {
271
+ const to = object.to ?? [];
272
+ const cc = object.cc ?? [];
273
+ const all = [...to, ...cc];
274
+ if (all.length === 0) return false;
275
+ // Direct notes are never addressed to the Public collection...
276
+ if (addressesPublic(all)) return false;
277
+ // ...nor to a followers collection (follower-only posts are not DMs).
278
+ if (all.some((a) => a.endsWith("/followers"))) return false;
279
+ if (recipient.followersUrl && all.includes(recipient.followersUrl)) {
280
+ return false;
281
+ }
282
+ // The inbox owner must be explicitly addressed in `to` (the recipient set
283
+ // that defines a DM); a mere `cc` mention is not treated as a DM.
284
+ return to.includes(recipient.apId);
285
+ }
286
+
287
+ /**
288
+ * Route an inbound direct (DM) Note into the recipient's DM inbox /
289
+ * message-request flow, mirroring the local outbound path in
290
+ * dm/messages.ts: a direct-visibility Note row, an objectRecipients row, a
291
+ * stored inbound Create activity, and an inbox row so it surfaces.
292
+ *
293
+ * Scope: a single local recipient (the inbox owner). The outbound DM model is
294
+ * strictly 1:1 (to=[otherApId]) and `objects.conversation` is a single column,
295
+ * so multi-recipient / group direct Notes are intentionally out of scope and
296
+ * fall back to the generic Note insert.
297
+ */
298
+ async function insertDirectNote(
299
+ db: Database,
300
+ activity: Activity,
301
+ object: ActivityObject,
302
+ objectId: string,
303
+ actor: string,
304
+ recipient: ActorRow,
305
+ baseUrl: string,
306
+ ): Promise<void> {
307
+ // Derive the conversation. Honour a sender-supplied `object.conversation`
308
+ // only when it matches the value yurucommu itself would compute for this
309
+ // (sender, localRecipient) pair — otherwise a remote actor could force a
310
+ // message into an arbitrary thread (spoof a reply context). Fall back to the
311
+ // computed id for foreign-origin DMs that carry no/invalid conversation.
312
+ const computedConversation = getConversationId(
313
+ baseUrl,
314
+ actor,
315
+ recipient.apId,
316
+ );
317
+ const conversationId =
318
+ object.conversation === computedConversation
319
+ ? object.conversation
320
+ : computedConversation;
321
+
322
+ const attachments = object.attachment
323
+ ? JSON.stringify(object.attachment)
324
+ : "[]";
325
+ const publishedAt = normalizeInboundTimestamp(
326
+ object.published,
327
+ new Date().toISOString(),
328
+ );
329
+ const toJson = JSON.stringify([recipient.apId]);
330
+
331
+ // Was the object already present BEFORE this dispatch? This decides whether
332
+ // this delivery is the one that creates the row (and therefore the one that
333
+ // owns the postCount +1 and the inbox surfacing). It is read once here and
334
+ // used only to gate the post-commit side effects; the counter itself is made
335
+ // crash-/retry-safe by the in-batch NOT-EXISTS guard below.
336
+ const existingObject = await db
337
+ .select({ apId: objects.apId })
338
+ .from(objects)
339
+ .where(eq(objects.apId, objectId))
340
+ .get();
341
+
342
+ // #3 (atomicity + idempotency): the object insert and the author postCount
343
+ // bump MUST commit together. Previously the row was inserted
344
+ // (onConflictDoNothing) and postCount bumped in a SEPARATE await; under the
345
+ // claim/processed re-dispatch model a crash between them left the row present
346
+ // but the count un-bumped, and a peer retry's no-op insert SKIPPED the bump →
347
+ // a permanent under-count. Co-commit both in one atomic batch. The postCount
348
+ // +1 runs BEFORE the insert and is guarded by a correlated NOT-EXISTS(object)
349
+ // subquery, so it fires only when THIS batch creates the row (mirrors the
350
+ // edge-absent guard in handleAdd); a duplicate / retry sees the row present →
351
+ // the guard is false and the insert is a no-op, so the count can neither
352
+ // double-bump nor under-count.
353
+ const objectAbsent = sql`NOT EXISTS (SELECT 1 FROM ${objects} WHERE ${objects.apId} = ${objectId})`;
354
+ await runBatch(db, [
355
+ db
356
+ .update(actors)
357
+ .set({ postCount: sql`${actors.postCount} + 1` })
358
+ .where(and(eq(actors.apId, actor), objectAbsent)),
359
+ db
360
+ .insert(objects)
361
+ .values({
362
+ apId: objectId,
363
+ type: "Note",
364
+ attributedTo: actor,
365
+ content: boundInboundContent(object.content),
366
+ summary: boundInboundSummary(object.summary),
367
+ attachmentsJson: boundAttachmentsJson(attachments),
368
+ inReplyTo: object.inReplyTo || null,
369
+ visibility: "direct",
370
+ toJson,
371
+ conversation: conversationId,
372
+ communityApId: null,
373
+ published: publishedAt,
374
+ isLocal: 0,
375
+ })
376
+ .onConflictDoNothing(),
377
+ // The recipient link MUST co-commit with the object. Inbound-DM recipient
378
+ // membership is resolved EXCLUSIVELY through object_recipients (contacts /
379
+ // requests / unread-count), so an object that committed WITHOUT its
380
+ // object_recipients row is a DM permanently invisible to the recipient.
381
+ // Previously this insert ran as a SEPARATE await after the batch: a crash /
382
+ // isolate-eviction in that window left exactly that orphan, and the caller's
383
+ // `if (existing) return` made the re-dispatch skip the repair. In-batch with
384
+ // onConflictDoNothing it is atomic (never orphaned) AND idempotent (a retry
385
+ // is a safe no-op). The local-send / community / takos-tools paths already
386
+ // co-commit this row for the same reason.
387
+ db
388
+ .insert(objectRecipients)
389
+ .values({
390
+ objectApId: objectId,
391
+ recipientApId: recipient.apId,
392
+ type: "to",
393
+ })
394
+ .onConflictDoNothing(),
395
+ ]);
396
+
397
+ if (existingObject) return; // duplicate: no inbox surfacing, no double count
398
+
399
+ // Store the inbound Create and surface it in the recipient's inbox so the DM
400
+ // appears in the conversation / message-requests view.
401
+ const activityId = activity.id || activityApId(baseUrl, generateId());
402
+ await upsertActivityAndNotify(
403
+ db,
404
+ activityId,
405
+ "Create",
406
+ actor,
407
+ objectId,
408
+ activity,
409
+ recipient.apId,
410
+ );
411
+ }
412
+
413
+ // ---------------------------------------------------------------------------
414
+ // Create handler
415
+ // ---------------------------------------------------------------------------
416
+
417
+ export async function handleCreate(
418
+ c: ActivityContext,
419
+ activity: Activity,
420
+ recipient: ActorRow,
421
+ actor: string,
422
+ baseUrl: string,
423
+ ) {
424
+ const db = c.get("db");
425
+ const object = getActivityObject(activity);
426
+ if (!object) return;
427
+
428
+ // Handle Story type
429
+ if (isStoryType(object.type)) {
430
+ await handleCreateStory(c, activity, actor, baseUrl);
431
+ return;
432
+ }
433
+
434
+ // Handle Note type (a remote may send `type` as a string or an array)
435
+ if (!typeIncludes(object.type, "Note")) return;
436
+
437
+ // Same-origin guard: a remote actor may only Create objects under its own
438
+ // origin, never under another host or the local domain. This closes the
439
+ // object-ID squatting / cross-origin injection vector and mirrors the
440
+ // ownership checks already enforced for Delete/Update.
441
+ if (isObjectIdOriginMismatch(object.id, actor, baseUrl)) {
442
+ log.warn("Create rejected: object id origin does not match actor", {
443
+ event: "ap.create.object_origin_mismatch",
444
+ actor,
445
+ objectId: object.id,
446
+ });
447
+ return;
448
+ }
449
+
450
+ // Direct (DM) Note routing: a Note addressed to the local inbox owner that
451
+ // is neither public nor follower-only belongs in the recipient's DM inbox /
452
+ // message-request flow rather than the generic public Note insert.
453
+ if (object.id && isDirectNote(object, recipient)) {
454
+ // A DM from an actor the recipient has personally BLOCKED must be dropped —
455
+ // mirror the local DM send guard (dm/messages.ts) so the reject+block remedy
456
+ // actually stops federated DM harassment (the operator-scoped federation
457
+ // blocklist checked in verifyAndParseInbox is a SEPARATE mechanism). `actor`
458
+ // is the HTTP-signature-verified signer, so blockedApId=actor is not
459
+ // spoofable. The inbox already ACKs, so dropping here causes no retry storm.
460
+ const blockedBySigner = await db
461
+ .select({ b: blocks.blockerApId })
462
+ .from(blocks)
463
+ .where(
464
+ and(
465
+ eq(blocks.blockerApId, recipient.apId),
466
+ eq(blocks.blockedApId, actor),
467
+ ),
468
+ )
469
+ .get();
470
+ if (blockedBySigner) {
471
+ log.info("Dropped inbound DM from a blocked actor", {
472
+ event: "ap.create.direct_note_blocked",
473
+ actor,
474
+ recipient: recipient.apId,
475
+ });
476
+ return;
477
+ }
478
+ const existing = await db
479
+ .select({ apId: objects.apId })
480
+ .from(objects)
481
+ .where(eq(objects.apId, object.id))
482
+ .get();
483
+ if (existing) return;
484
+ await insertDirectNote(
485
+ db,
486
+ activity,
487
+ object,
488
+ object.id,
489
+ actor,
490
+ recipient,
491
+ baseUrl,
492
+ );
493
+ return;
494
+ }
495
+
496
+ // A direct/DM-shaped Note (addressed only to specific actors, neither Public
497
+ // nor followers) that is NOT addressed to THIS fan-out recipient: the shared
498
+ // inbox calls handleCreate once per local follower of the sender, so a DM
499
+ // addressed to actor A is also dispatched for an unrelated follower B. We must
500
+ // NOT store it as a world-readable generic Note for B — the addressed actor's
501
+ // own delivery handles it via insertDirectNote above. Skip it here.
502
+ if (isDirectShapedNote(object)) {
503
+ log.warn("Skipping direct Note not addressed to this recipient", {
504
+ event: "ap.create.direct_note_not_addressed",
505
+ actor,
506
+ recipient: recipient.apId,
507
+ objectId: object.id,
508
+ });
509
+ return;
510
+ }
511
+
512
+ const objectId = object.id || objectApId(baseUrl, generateId());
513
+
514
+ // Was the object already present BEFORE this dispatch? This is read ONCE and
515
+ // used only to gate the one-shot side effects (parent notification) below; it
516
+ // intentionally does NOT early-return, because the idempotent count batch must
517
+ // still run on a retry so a parent replyCount left stale by an interrupted
518
+ // prior attempt CONVERGES (mirrors handleInteraction, which always runs the
519
+ // recompute batch and uses the pre-read only to gate the notification).
520
+ const existingBeforeInsert = await db
521
+ .select({ apId: objects.apId })
522
+ .from(objects)
523
+ .where(eq(objects.apId, objectId))
524
+ .get();
525
+
526
+ const attachments = object.attachment
527
+ ? JSON.stringify(object.attachment)
528
+ : "[]";
529
+ const publishedAt = normalizeInboundTimestamp(
530
+ object.published,
531
+ new Date().toISOString(),
532
+ );
533
+ const parentObj = object.inReplyTo
534
+ ? await db
535
+ .select({
536
+ attributedTo: objects.attributedTo,
537
+ visibility: objects.visibility,
538
+ toJson: objects.toJson,
539
+ ccJson: objects.ccJson,
540
+ audienceJson: objects.audienceJson,
541
+ communityApId: objects.communityApId,
542
+ type: objects.type,
543
+ endTime: objects.endTime,
544
+ })
545
+ .from(objects)
546
+ .where(eq(objects.apId, object.inReplyTo))
547
+ .get()
548
+ : null;
549
+
550
+ // Inbound reply to a LOCAL parent the sending actor cannot read (or that has
551
+ // blocked them) is REFUSED — mirroring the local reply 404 gate
552
+ // (routes/posts/routes.ts). Without this, a remote who merely learns a
553
+ // followers-only / direct(DM) / private-community post's apId could inflate its
554
+ // replyCount, deliver a reply notification to the owner (bypassing a personal
555
+ // block), and — because the stored reply's in_reply_to discloses the parent —
556
+ // build an existence oracle for the restricted post. A legitimate follower /
557
+ // addressed recipient still passes canViewerReadObjectFull, so their reply is
558
+ // ingested normally. (Only gated for LOCAL parents: a remote parent's audience
559
+ // is the remote instance's concern, and we hold no counter/notification for it.)
560
+ if (
561
+ object.inReplyTo &&
562
+ parentObj &&
563
+ isLocal(parentObj.attributedTo, baseUrl) &&
564
+ (!(await canViewerReadObjectFull(db, parentObj, actor)) ||
565
+ (await actorIsBlockedBy(db, parentObj.attributedTo, actor)))
566
+ ) {
567
+ return;
568
+ }
569
+
570
+ const shouldNotifyParent = !!(
571
+ parentObj && isLocal(parentObj.attributedTo, baseUrl)
572
+ );
573
+ const replyActivityId = shouldNotifyParent
574
+ ? activity.id || activityApId(baseUrl, generateId())
575
+ : null;
576
+
577
+ // #3 (atomicity + idempotency): the object insert, the author postCount bump,
578
+ // and (for a reply) the parent replyCount bump MUST commit together.
579
+ // Previously the row was inserted (onConflictDoNothing) and the counts bumped
580
+ // in SEPARATE awaits; under the claim/processed re-dispatch model a crash
581
+ // between them left the row present but the counts un-bumped, and a peer
582
+ // retry's no-op insert SKIPPED the bumps → permanent postCount/replyCount
583
+ // drift. Co-commit them in one atomic batch:
584
+ // - postCount +1 runs BEFORE the insert, guarded by a correlated
585
+ // NOT-EXISTS(object) subquery so it fires only when THIS batch creates
586
+ // the row (mirrors handleAdd's edge-absent guard); a duplicate / retry
587
+ // observes the row present → guard false → no double-bump, no under-count.
588
+ // - replyCount is RECOMPUTED from COUNT(*) of the reply edge set AFTER the
589
+ // insert (mirrors the object-counter recompute in handleInteraction /
590
+ // undoInteraction): exact and idempotent, so a retry after a mid-write
591
+ // crash CONVERGES to the true reply count and a duplicate cannot inflate.
592
+ const objectAbsent = sql`NOT EXISTS (SELECT 1 FROM ${objects} WHERE ${objects.apId} = ${objectId})`;
593
+ const insertObject = db
594
+ .insert(objects)
595
+ .values({
596
+ apId: objectId,
597
+ type: "Note",
598
+ attributedTo: actor,
599
+ content: boundInboundContent(object.content),
600
+ summary: boundInboundSummary(object.summary),
601
+ attachmentsJson: boundAttachmentsJson(attachments),
602
+ inReplyTo: object.inReplyTo || null,
603
+ // Recipient-independent classification: a non-public Note is never stored
604
+ // as world-readable "unlisted". A followers-only post → "followers" (gated
605
+ // by the accepted-follow edge), preserving the remote author's audience.
606
+ visibility: classifyInboundNoteVisibility(object),
607
+ // Persist the addressing so the explicit-recipient (mention) gate in
608
+ // canViewerReadObjectFull / the post-detail route can evaluate.
609
+ toJson: boundAddressJson(object.to),
610
+ ccJson: boundAddressJson(object.cc),
611
+ communityApId: null,
612
+ published: publishedAt,
613
+ isLocal: 0,
614
+ })
615
+ .onConflictDoNothing();
616
+
617
+ const bumpPostCount = db
618
+ .update(actors)
619
+ .set({ postCount: sql`${actors.postCount} + 1` })
620
+ .where(and(eq(actors.apId, actor), objectAbsent));
621
+
622
+ if (object.inReplyTo) {
623
+ const parentId = object.inReplyTo;
624
+ await runBatch(db, [
625
+ bumpPostCount,
626
+ insertObject,
627
+ db
628
+ .update(objects)
629
+ .set({
630
+ replyCount: sql`(SELECT COUNT(*) FROM ${objects} WHERE ${objects.inReplyTo} = ${parentId})`,
631
+ })
632
+ .where(eq(objects.apId, parentId)),
633
+ ]);
634
+ } else {
635
+ await runBatch(db, [bumpPostCount, insertObject]);
636
+ }
637
+
638
+ if (existingBeforeInsert) return; // duplicate: no double notification
639
+
640
+ if (shouldNotifyParent && parentObj && replyActivityId) {
641
+ await upsertActivityAndNotify(
642
+ db,
643
+ replyActivityId,
644
+ "Create",
645
+ actor,
646
+ objectId,
647
+ activity,
648
+ parentObj.attributedTo,
649
+ );
650
+ }
651
+
652
+ // Notify every LOCAL actor @-mentioned in the post — the federated counterpart
653
+ // of the local processMentions fan-in. Without this a cross-instance @-mention
654
+ // produced no notification at all (mention is a first-class notification type
655
+ // that only ever fired for local-origin posts). Runs once (the duplicate
656
+ // delivery short-circuits at `existingBeforeInsert` above). Skips the post
657
+ // author and the parent author (already notified by the reply branch) and
658
+ // honors the mentioned actor's block of the sender, mirroring the reply gate.
659
+ const mentionedLocalApIds = new Set<string>();
660
+ for (const href of extractMentionHrefs(object.tag)) {
661
+ if (!isLocal(href, baseUrl)) continue;
662
+ if (href === actor) continue;
663
+ if (parentObj && href === parentObj.attributedTo) continue;
664
+ mentionedLocalApIds.add(href);
665
+ // Bound attacker-controlled fan-out: stop collecting once the cap is hit so
666
+ // a tag array full of distinct fake local hrefs cannot drive unbounded work.
667
+ if (mentionedLocalApIds.size >= MAX_INBOUND_MENTIONS) break;
668
+ }
669
+ if (mentionedLocalApIds.size === 0) return;
670
+
671
+ // Batch-resolve which of the mentioned hrefs are real local actors in one
672
+ // chunked query (D1 caps bound params at 100), instead of a serial SELECT per
673
+ // href. An attacker can pack thousands of distinct fake local hrefs into the
674
+ // tag array; resolving them one-by-one was an N+1 / subrequest-budget
675
+ // amplification. The chunked inArray collapses it to ceil(N/90) queries, and
676
+ // only the resolved (existing) actors are then notified.
677
+ const existingLocalApIds = (
678
+ await Promise.all(
679
+ chunkForInClause([...mentionedLocalApIds]).map((chunk) =>
680
+ db
681
+ .select({ apId: actors.apId })
682
+ .from(actors)
683
+ .where(inArray(actors.apId, chunk)),
684
+ ),
685
+ )
686
+ ).flat();
687
+
688
+ for (const { apId: mentionedApId } of existingLocalApIds) {
689
+ if (await actorIsBlockedBy(db, mentionedApId, actor)) continue;
690
+ await upsertActivityAndNotify(
691
+ db,
692
+ activityApId(baseUrl, generateId()),
693
+ "Create",
694
+ actor,
695
+ objectId,
696
+ activity,
697
+ mentionedApId,
698
+ );
699
+ }
700
+ }
701
+
702
+ // ---------------------------------------------------------------------------
703
+ // Create(Story) handler
704
+ // ---------------------------------------------------------------------------
705
+
706
+ export async function handleCreateStory(
707
+ c: ActivityContext,
708
+ activity: Activity,
709
+ actor: string,
710
+ baseUrl: string,
711
+ ) {
712
+ const db = c.get("db");
713
+ const object = getActivityObject(activity);
714
+ if (!object) return;
715
+
716
+ // Same-origin guard: reject a story whose object id is squatted under another
717
+ // host or the local domain (see handleCreate for rationale).
718
+ if (isObjectIdOriginMismatch(object.id, actor, baseUrl)) {
719
+ log.warn("Create(Story) rejected: object id origin does not match actor", {
720
+ event: "ap.story.object_origin_mismatch",
721
+ actor,
722
+ objectId: object.id,
723
+ });
724
+ return;
725
+ }
726
+
727
+ const objectId = object.id || objectApId(baseUrl, generateId());
728
+
729
+ // Per-user block: drop a Story from an actor the local owner has blocked,
730
+ // mirroring the inbound DM blockedBySigner drop + the inbound Like/Announce/
731
+ // Follow/reply block gates. The other inbound owner-visible paths all enforce
732
+ // the per-user `blocks` table; Create(Story) was the gap, so a blocked actor's
733
+ // stories were still stored (consuming the per-author cap + retrievable via
734
+ // GET /api/posts/:id). Single-user instance: any blocks row blocking this actor
735
+ // is the owner's block.
736
+ const blockedByOwner = await db
737
+ .select({ b: blocks.blockerApId })
738
+ .from(blocks)
739
+ .where(eq(blocks.blockedApId, actor))
740
+ .get();
741
+ if (blockedByOwner) return;
742
+
743
+ // Check if story already exists
744
+ const existing = await db
745
+ .select({ apId: objects.apId })
746
+ .from(objects)
747
+ .where(eq(objects.apId, objectId))
748
+ .get();
749
+ if (existing) return;
750
+
751
+ // Per-author flood cap. A hostile remote could Create() an unbounded number of
752
+ // Stories to bloat our feed/storage (each carries an attachment blob + caption).
753
+ // The local create path is naturally bounded by the owner; inbound has no such
754
+ // bound, so cap the concurrent LIVE (non-expired) remote stories per author.
755
+ // Expired stories are reaped, so this limits the live set, not lifetime volume.
756
+ const MAX_INBOUND_STORIES_PER_ACTOR = 50;
757
+ const nowIso = new Date().toISOString();
758
+ const liveStories = await db
759
+ .select({ n: count() })
760
+ .from(objects)
761
+ .where(
762
+ and(
763
+ eq(objects.attributedTo, actor),
764
+ eq(objects.type, "Story"),
765
+ eq(objects.isLocal, 0),
766
+ gt(objects.endTime, nowIso),
767
+ ),
768
+ )
769
+ .get();
770
+ if ((liveStories?.n ?? 0) >= MAX_INBOUND_STORIES_PER_ACTOR) {
771
+ log.warn("Create(Story) rejected: author live-story cap reached", {
772
+ event: "ap.story.author_cap",
773
+ actor,
774
+ });
775
+ return;
776
+ }
777
+
778
+ // attachment validation (required)
779
+ if (!object.attachment) {
780
+ log.error("Remote story has no attachment", {
781
+ event: "ap.story.missing_attachment",
782
+ objectId,
783
+ });
784
+ return;
785
+ }
786
+
787
+ // Normalize attachment (handle array or single object)
788
+ const attachmentArray = Array.isArray(object.attachment)
789
+ ? object.attachment
790
+ : [object.attachment];
791
+ const attachment = attachmentArray[0] as {
792
+ url?: string;
793
+ mediaType?: string;
794
+ width?: number;
795
+ height?: number;
796
+ };
797
+
798
+ if (!attachment || !attachment.url) {
799
+ log.error("Remote story attachment has no URL", {
800
+ event: "ap.story.attachment_missing_url",
801
+ objectId,
802
+ });
803
+ return;
804
+ }
805
+
806
+ // overlays validation (optional, validate if present). Cap the COUNT — the
807
+ // local create path bounds overlays via validateOverlays (MAX_OVERLAYS=20),
808
+ // and a hostile remote must not pad an unbounded array into attachments_json.
809
+ const MAX_INBOUND_OVERLAYS = 20;
810
+ let overlays: StoryOverlay[] | undefined;
811
+ if (Array.isArray(object.overlays)) {
812
+ const filtered = (object.overlays as StoryOverlay[])
813
+ .filter(
814
+ (o: StoryOverlay) =>
815
+ o &&
816
+ o.position &&
817
+ typeof o.position.x === "number" &&
818
+ typeof o.position.y === "number",
819
+ )
820
+ .slice(0, MAX_INBOUND_OVERLAYS);
821
+ // Keep at most ONE Question (poll) overlay — votes are keyed only by
822
+ // (storyApId, actorApId) and tallied by optionIndex with no question
823
+ // dimension, so a second poll would conflate tallies. Mirror validateOverlays.
824
+ let seenQuestion = false;
825
+ const capped = filtered.filter((o: StoryOverlay) => {
826
+ if (o.type === "Question") {
827
+ if (seenQuestion) return false;
828
+ seenQuestion = true;
829
+ }
830
+ return true;
831
+ });
832
+ if (capped.length > 0) overlays = capped;
833
+ }
834
+
835
+ // Build attachments_json
836
+ const attachmentData = {
837
+ attachment: {
838
+ r2_key: "", // Remote stories don't have local R2 key
839
+ content_type: attachment.mediaType || "image/jpeg",
840
+ url: attachment.url,
841
+ width: attachment.width || 1080,
842
+ height: attachment.height || 1920,
843
+ },
844
+ displayDuration:
845
+ (object as { displayDuration?: string }).displayDuration || "PT5S",
846
+ // The remote caption arrives as the AS2 Note `content`; persist it (bounded
847
+ // to the same local content cap as every other inbound Note path) so the
848
+ // local renderer shows the same caption as the originating instance.
849
+ caption:
850
+ typeof object.content === "string" && object.content.trim().length > 0
851
+ ? boundInboundContent(object.content)
852
+ : undefined,
853
+ overlays,
854
+ };
855
+
856
+ const now = new Date().toISOString();
857
+ // Clamp the attacker-controlled `endTime`: a story must expire. A non-ISO or
858
+ // far-future value stored verbatim would never satisfy the expiry filter
859
+ // (`lt(endTime, now)`, a lexical compare), so a malicious remote could create
860
+ // never-expiring stories that accumulate forever. Bound it to published + ~25h
861
+ // (the ~24h story lifetime + slack) and normalize to ISO so the compare holds.
862
+ const STORY_MAX_LIFETIME_MS = 25 * 60 * 60 * 1000;
863
+ // Clamp+normalize the inbound `published` FIRST and anchor the endTime bound to
864
+ // THAT, not the raw value: a far-future `published` ("9999-…") would otherwise
865
+ // push maxEndMs far into the future too and defeat this very expiry clamp.
866
+ const publishedAt = normalizeInboundTimestamp(object.published, now);
867
+ const publishedMs = Date.parse(publishedAt);
868
+ const maxEndMs =
869
+ (Number.isNaN(publishedMs) ? Date.now() : publishedMs) +
870
+ STORY_MAX_LIFETIME_MS;
871
+ const requestedEndMs = object.endTime ? Date.parse(object.endTime) : NaN;
872
+ const endTime = new Date(
873
+ Number.isNaN(requestedEndMs)
874
+ ? maxEndMs
875
+ : Math.min(requestedEndMs, maxEndMs),
876
+ ).toISOString();
877
+
878
+ // The early existence check above is best-effort (TOCTOU): two isolates
879
+ // racing the same cold story can both pass it. `onConflictDoNothing` keeps
880
+ // that race insert-safe, and gating follow-on side effects on the returned
881
+ // row mirrors the duplicate guard in handleCreate.
882
+ // Bound the serialized story data. Caption is capped and overlays are
883
+ // count-limited above, but per-overlay padding could still inflate it; if the
884
+ // blob exceeds the attachments cap, drop the (decorative) overlays so the core
885
+ // attachment + caption still persist within bounds.
886
+ let storyDataJson = JSON.stringify(attachmentData);
887
+ if (storyDataJson.length > MAX_ATTACHMENTS_JSON_LENGTH) {
888
+ storyDataJson = JSON.stringify({ ...attachmentData, overlays: undefined });
889
+ }
890
+
891
+ const inserted = await db
892
+ .insert(objects)
893
+ .values({
894
+ apId: objectId,
895
+ type: "Story",
896
+ attributedTo: actor,
897
+ content: "",
898
+ attachmentsJson: storyDataJson,
899
+ endTime,
900
+ published: publishedAt,
901
+ isLocal: 0,
902
+ })
903
+ .onConflictDoNothing()
904
+ .returning()
905
+ .get();
906
+
907
+ if (!inserted) return; // duplicate
908
+ }
909
+
910
+ // ---------------------------------------------------------------------------
911
+ // Delete handler
912
+ // ---------------------------------------------------------------------------
913
+
914
+ /**
915
+ * Tombstone a remote actor locally in response to a verified inbound
916
+ * Delete(Actor). Mirrors the local /me/delete teardown for the federation-facing
917
+ * state we hold about a remote: reconcile LOCAL counterparts' follower/following
918
+ * counts, drop the follow edges in both directions, purge the actor cache, and
919
+ * cascade-delete the remote's cached content. All deletes are subquery-scoped
920
+ * (no spliced ids → D1-param-safe) and remote objects carry no R2 blobs (their
921
+ * media are remote URLs, not local uploads), so no media purge is needed.
922
+ */
923
+ async function handleRemoteActorDelete(
924
+ c: ActivityContext,
925
+ actorId: string,
926
+ ): Promise<void> {
927
+ const db = c.get("db");
928
+
929
+ // Counterpart count reconcile BEFORE dropping edges (mirrors actors.ts):
930
+ // everyone the deleted remote followed loses a follower; everyone who followed
931
+ // it loses a following. The subquery naturally scopes to LOCAL actors (remote
932
+ // actors have no `actors` row); gt(...,0) guards underflow.
933
+ await db
934
+ .update(actors)
935
+ .set({ followerCount: sql`${actors.followerCount} - 1` })
936
+ .where(
937
+ and(
938
+ inArray(
939
+ actors.apId,
940
+ db
941
+ .select({ id: follows.followingApId })
942
+ .from(follows)
943
+ .where(eq(follows.followerApId, actorId)),
944
+ ),
945
+ gt(actors.followerCount, 0),
946
+ ),
947
+ );
948
+ await db
949
+ .update(actors)
950
+ .set({ followingCount: sql`${actors.followingCount} - 1` })
951
+ .where(
952
+ and(
953
+ inArray(
954
+ actors.apId,
955
+ db
956
+ .select({ id: follows.followerApId })
957
+ .from(follows)
958
+ .where(eq(follows.followingApId, actorId)),
959
+ ),
960
+ gt(actors.followingCount, 0),
961
+ ),
962
+ );
963
+ await db
964
+ .delete(follows)
965
+ .where(
966
+ or(eq(follows.followerApId, actorId), eq(follows.followingApId, actorId)),
967
+ );
968
+
969
+ // Recompute the replyCount of any LOCAL parent the remote's cached objects
970
+ // replied to, counting only the replies that will REMAIN (not authored by the
971
+ // deleted remote), BEFORE the cascade removes them (mirrors actors.ts).
972
+ await db
973
+ .update(objects)
974
+ .set({
975
+ replyCount: sql`(SELECT COUNT(*) FROM objects AS child WHERE child.in_reply_to = ${objects.apId} AND child.attributed_to <> ${actorId})`,
976
+ })
977
+ .where(
978
+ inArray(
979
+ objects.apId,
980
+ db
981
+ .select({ id: objects.inReplyTo })
982
+ .from(objects)
983
+ .where(
984
+ and(
985
+ eq(objects.attributedTo, actorId),
986
+ isNotNull(objects.inReplyTo),
987
+ ),
988
+ ),
989
+ ),
990
+ );
991
+
992
+ // Reconcile the like/announce/share counters on OTHER objects the deleted
993
+ // remote INTERACTED with, BEFORE dropping its edges — mirrors the local
994
+ // account-delete griefing defense (actors.ts). A throwaway remote could ratchet
995
+ // a local post's like/announce/share counts then self-delete via a signed
996
+ // Delete(Person); without this those counts stay permanently inflated and the
997
+ // edge rows orphan (the object-scoped cascade below only reaps interactions ON
998
+ // the remote's OWN posts, not the ones it authored on others'). gt(...,0) guards
999
+ // underflow; the subquery scopes without splicing ids (D1 param ceiling).
1000
+ await db
1001
+ .update(objects)
1002
+ .set({ likeCount: sql`${objects.likeCount} - 1` })
1003
+ .where(
1004
+ and(
1005
+ inArray(
1006
+ objects.apId,
1007
+ db
1008
+ .select({ id: likes.objectApId })
1009
+ .from(likes)
1010
+ .where(eq(likes.actorApId, actorId)),
1011
+ ),
1012
+ gt(objects.likeCount, 0),
1013
+ ),
1014
+ );
1015
+ await db
1016
+ .update(objects)
1017
+ .set({ announceCount: sql`${objects.announceCount} - 1` })
1018
+ .where(
1019
+ and(
1020
+ inArray(
1021
+ objects.apId,
1022
+ db
1023
+ .select({ id: announces.objectApId })
1024
+ .from(announces)
1025
+ .where(eq(announces.actorApId, actorId)),
1026
+ ),
1027
+ gt(objects.announceCount, 0),
1028
+ ),
1029
+ );
1030
+ await db
1031
+ .update(objects)
1032
+ .set({ shareCount: sql`${objects.shareCount} - 1` })
1033
+ .where(
1034
+ and(
1035
+ inArray(
1036
+ objects.apId,
1037
+ db
1038
+ .select({ id: storyShares.storyApId })
1039
+ .from(storyShares)
1040
+ .where(eq(storyShares.actorApId, actorId)),
1041
+ ),
1042
+ gt(objects.shareCount, 0),
1043
+ ),
1044
+ );
1045
+ // Delete the interaction edges the remote AUTHORED on OTHER objects.
1046
+ await db.delete(likes).where(eq(likes.actorApId, actorId));
1047
+ await db.delete(announces).where(eq(announces.actorApId, actorId));
1048
+ await db.delete(bookmarks).where(eq(bookmarks.actorApId, actorId));
1049
+ await db.delete(storyShares).where(eq(storyShares.actorApId, actorId));
1050
+ await db.delete(storyVotes).where(eq(storyVotes.actorApId, actorId));
1051
+ await db.delete(storyViews).where(eq(storyViews.actorApId, actorId));
1052
+
1053
+ // Cascade child rows keyed by the remote's authored objects (no FK cascade on
1054
+ // prod D1), then the objects themselves. A fresh subquery per statement avoids
1055
+ // shared-AST reuse.
1056
+ const remoteObjectIds = () =>
1057
+ db
1058
+ .select({ id: objects.apId })
1059
+ .from(objects)
1060
+ .where(eq(objects.attributedTo, actorId));
1061
+ await db.delete(likes).where(inArray(likes.objectApId, remoteObjectIds()));
1062
+ await db
1063
+ .delete(announces)
1064
+ .where(inArray(announces.objectApId, remoteObjectIds()));
1065
+ await db
1066
+ .delete(bookmarks)
1067
+ .where(inArray(bookmarks.objectApId, remoteObjectIds()));
1068
+ await db
1069
+ .delete(objectRecipients)
1070
+ .where(inArray(objectRecipients.objectApId, remoteObjectIds()));
1071
+ await db
1072
+ .delete(storyVotes)
1073
+ .where(inArray(storyVotes.storyApId, remoteObjectIds()));
1074
+ await db
1075
+ .delete(storyViews)
1076
+ .where(inArray(storyViews.storyApId, remoteObjectIds()));
1077
+ await db
1078
+ .delete(storyShares)
1079
+ .where(inArray(storyShares.storyApId, remoteObjectIds()));
1080
+ await db.delete(objects).where(eq(objects.attributedTo, actorId));
1081
+
1082
+ await db.delete(actorCache).where(eq(actorCache.apId, actorId));
1083
+
1084
+ log.info("Processed inbound Delete(actor)", {
1085
+ event: "ap.delete.actor",
1086
+ actor: actorId,
1087
+ });
1088
+ }
1089
+
1090
+ export async function handleDelete(c: ActivityContext, activity: Activity) {
1091
+ const db = c.get("db");
1092
+ const objectId = getActivityObjectId(activity);
1093
+ if (!objectId) return;
1094
+
1095
+ const actorId = typeof activity.actor === "string" ? activity.actor : null;
1096
+ if (!actorId) {
1097
+ log.warn("Delete activity missing actor", {
1098
+ event: "ap.delete.missing_actor",
1099
+ objectId,
1100
+ });
1101
+ return;
1102
+ }
1103
+
1104
+ const delObj = await db
1105
+ .select({
1106
+ attributedTo: objects.attributedTo,
1107
+ type: objects.type,
1108
+ replyCount: objects.replyCount,
1109
+ inReplyTo: objects.inReplyTo,
1110
+ })
1111
+ .from(objects)
1112
+ .where(eq(objects.apId, objectId))
1113
+ .get();
1114
+ if (!delObj) {
1115
+ // Delete(Actor): a remote announcing its OWN account deletion addresses the
1116
+ // actor as the object (object === actor). Remote actors are never stored in
1117
+ // `objects` (they live in actorCache), so the per-object path above finds no
1118
+ // row. verifyAndParseInbox has already bound the signer to activity.actor
1119
+ // (same origin), so an object that equals the verified actor is owned by the
1120
+ // signer. Tombstone the remote locally so a stale profile + dangling follow
1121
+ // edge + cached content do not survive indefinitely.
1122
+ if (objectId === actorId) {
1123
+ await handleRemoteActorDelete(c, actorId);
1124
+ }
1125
+ return;
1126
+ }
1127
+
1128
+ // Verify actor owns the object before deleting
1129
+ if (delObj.attributedTo !== actorId) {
1130
+ log.warn("Delete rejected: actor does not own object", {
1131
+ event: "ap.delete.actor_ownership_mismatch",
1132
+ actor: actorId,
1133
+ objectId,
1134
+ ownedBy: delObj.attributedTo,
1135
+ });
1136
+ return;
1137
+ }
1138
+
1139
+ // Delete every child row keyed by this object before the object row itself.
1140
+ // FK ON DELETE CASCADE is not reliably enforced on every runtime/connection
1141
+ // (D1 ignores PRAGMA foreign_keys), so cascade explicitly to avoid orphans.
1142
+ // Covers likes/announces/bookmarks/object_recipients/story_* in one place,
1143
+ // shared with the local DELETE /posts/:id path.
1144
+ const mediaKeys = await deleteObjectCascade(db, objectId, c.env.MEDIA);
1145
+
1146
+ // #3 (atomicity + idempotency): the object-row delete and the counter
1147
+ // decrements MUST commit together. Previously the row was deleted and the
1148
+ // counts decremented in SEPARATE awaits; under the claim/processed
1149
+ // re-dispatch model a crash between them left the row gone but the counts
1150
+ // un-decremented, and a peer retry early-returns on the absent row so the
1151
+ // decrements were SKIPPED → permanent postCount/replyCount drift. Co-commit
1152
+ // them in one atomic batch (the media cascade above is intentionally NOT
1153
+ // moved into the batch — it must run first while attachments_json is still
1154
+ // readable). Statement ordering inside the batch:
1155
+ // - postCount -1 runs BEFORE the delete, guarded by a correlated
1156
+ // EXISTS(object) subquery (so a duplicate Delete / retry on an
1157
+ // already-gone row is a no-op) plus a gt(postCount,0) underflow guard
1158
+ // (mirrors handleRemove).
1159
+ // - replyCount is RECOMPUTED from COUNT(*) of the remaining reply edge set
1160
+ // AFTER the delete (mirrors undoInteraction's object-counter recompute):
1161
+ // exact and idempotent, so a retry CONVERGES to the true reply count.
1162
+ const objectExists = sql`EXISTS (SELECT 1 FROM ${objects} WHERE ${objects.apId} = ${objectId})`;
1163
+ const author = delObj.attributedTo;
1164
+ const deleteObject = db.delete(objects).where(eq(objects.apId, objectId));
1165
+ const decPostCount = db
1166
+ .update(actors)
1167
+ .set({ postCount: sql`${actors.postCount} - 1` })
1168
+ .where(and(eq(actors.apId, author), gt(actors.postCount, 0), objectExists));
1169
+
1170
+ if (delObj.inReplyTo) {
1171
+ const parentId = delObj.inReplyTo;
1172
+ await runBatch(db, [
1173
+ decPostCount,
1174
+ deleteObject,
1175
+ db
1176
+ .update(objects)
1177
+ .set({
1178
+ replyCount: sql`(SELECT COUNT(*) FROM ${objects} WHERE ${objects.inReplyTo} = ${parentId})`,
1179
+ })
1180
+ .where(eq(objects.apId, parentId)),
1181
+ ]);
1182
+ } else {
1183
+ await runBatch(db, [decPostCount, deleteObject]);
1184
+ }
1185
+
1186
+ // Irreversible R2 purge LAST — after the objects row is gone. On the queue-
1187
+ // backed inbox path a failure here is also self-healing: a Delete retry
1188
+ // re-runs, finds no media_uploads rows, and proceeds.
1189
+ await purgeMediaBlobs(c.env.MEDIA, mediaKeys);
1190
+ }
1191
+
1192
+ // ---------------------------------------------------------------------------
1193
+ // Update handler
1194
+ // ---------------------------------------------------------------------------
1195
+
1196
+ export async function handleUpdate(
1197
+ c: ActivityContext,
1198
+ activity: Activity,
1199
+ actor: string,
1200
+ ) {
1201
+ const db = c.get("db");
1202
+ const object = getActivityObject(activity);
1203
+ if (!object) return;
1204
+
1205
+ const objectId = object.id;
1206
+ if (!objectId) return;
1207
+
1208
+ // Update(Person/Service/Group) — an inbound actor-document update (remote
1209
+ // profile / avatar / public-key rotation). Apply it immediately by
1210
+ // re-fetching and upserting the actor through the same canonical actor-cache
1211
+ // path used by cacheRemoteActor, instead of waiting for the 24h actor-cache
1212
+ // TTL to expire. A signed actor may only update its own document, so the
1213
+ // updated object must be the actor itself (`object.id === activity.actor`,
1214
+ // mirroring the actor==object self-update contract). The remote document is
1215
+ // re-fetched from origin (never trusted from the wire) so a spoofed Update
1216
+ // body cannot poison the cache.
1217
+ if (isActorTypeUpdate(object.type) || objectId === actor) {
1218
+ if (objectId !== actor) {
1219
+ log.warn("Update(actor) rejected: object id does not match actor", {
1220
+ event: "ap.update.actor_self_mismatch",
1221
+ actor,
1222
+ objectId,
1223
+ });
1224
+ return;
1225
+ }
1226
+ // Amplification guard: an inbound Update(actor) would otherwise trigger an
1227
+ // unconditional outbound re-fetch of the actor document on EVERY activity,
1228
+ // so a remote could flood us into hammering its origin (or a third party).
1229
+ // Skip the re-fetch when the cached row was fetched within a short cooldown
1230
+ // window; the normal actor-cache TTL refresh still picks up later changes.
1231
+ const cached = await db
1232
+ .select({ lastFetchedAt: actorCache.lastFetchedAt })
1233
+ .from(actorCache)
1234
+ .where(eq(actorCache.apId, objectId))
1235
+ .get();
1236
+ if (cached?.lastFetchedAt) {
1237
+ const age = Date.now() - new Date(cached.lastFetchedAt).getTime();
1238
+ if (
1239
+ Number.isFinite(age) &&
1240
+ age >= 0 &&
1241
+ age < ACTOR_UPDATE_REFETCH_COOLDOWN_MS
1242
+ ) {
1243
+ log.debug("Update(actor) re-fetch skipped: within cooldown", {
1244
+ event: "ap.update.actor_refetch_cooldown",
1245
+ actor: objectId,
1246
+ ageMs: age,
1247
+ });
1248
+ return;
1249
+ }
1250
+ }
1251
+ await refreshActorCache(db, objectId);
1252
+ return;
1253
+ }
1254
+
1255
+ const existing = await db
1256
+ .select({ attributedTo: objects.attributedTo })
1257
+ .from(objects)
1258
+ .where(eq(objects.apId, objectId))
1259
+ .get();
1260
+ if (!existing || existing.attributedTo !== actor) return;
1261
+
1262
+ // Update object content
1263
+ if (typeIncludes(object.type, "Note")) {
1264
+ const attachments = object.attachment
1265
+ ? JSON.stringify(object.attachment)
1266
+ : undefined;
1267
+ await db
1268
+ .update(objects)
1269
+ .set({
1270
+ content:
1271
+ typeof object.content === "string" && object.content
1272
+ ? truncate(object.content, MAX_POST_CONTENT_LENGTH)
1273
+ : undefined,
1274
+ summary:
1275
+ typeof object.summary === "string" && object.summary
1276
+ ? truncate(object.summary, MAX_POST_SUMMARY_LENGTH)
1277
+ : undefined,
1278
+ attachmentsJson: attachments
1279
+ ? boundAttachmentsJson(attachments)
1280
+ : undefined,
1281
+ updated: new Date().toISOString(),
1282
+ })
1283
+ .where(eq(objects.apId, objectId));
1284
+ }
1285
+ }
1286
+
1287
+ // ---------------------------------------------------------------------------
1288
+ // Move handler (account migration)
1289
+ // ---------------------------------------------------------------------------
1290
+
1291
+ export async function handleMove(
1292
+ c: ActivityContext,
1293
+ activity: Activity,
1294
+ actor: string,
1295
+ ) {
1296
+ const db = c.get("db");
1297
+ const oldActorApId = getActivityObjectId(activity);
1298
+ const newActorApId = getActivityTargetId(activity);
1299
+ if (!oldActorApId || !newActorApId) return;
1300
+
1301
+ // Only accept self-move. Signature verification already ensures the request is signed,
1302
+ // but we also require Move.object to match Move.actor (defense-in-depth).
1303
+ if (oldActorApId !== actor) return;
1304
+ if (oldActorApId === newActorApId) return;
1305
+
1306
+ if (!isSafeRemoteUrl(newActorApId)) {
1307
+ log.warn("Blocked unsafe Move target", {
1308
+ event: "ap.move.unsafe_target",
1309
+ newActor: newActorApId,
1310
+ oldActor: oldActorApId,
1311
+ });
1312
+ return;
1313
+ }
1314
+
1315
+ // SECURITY (account-migration follow-graph hijack): a signed Move only proves
1316
+ // the OLD actor consents to move; it does NOT prove the destination is the same
1317
+ // person. Without verifying the destination's `alsoKnownAs` back-reference, a
1318
+ // remote actor that accumulated local followers could redirect them all to an
1319
+ // arbitrary unconsenting account (follower-stealing). Require the standard
1320
+ // Mastodon Move guard: the destination actor document must list the old actor
1321
+ // in `alsoKnownAs`. Fails closed.
1322
+ if (
1323
+ !(await destinationDeclaresAlias(
1324
+ newActorApId,
1325
+ oldActorApId,
1326
+ (await getInstanceFetchSignerByDb(db)) ?? undefined,
1327
+ ))
1328
+ ) {
1329
+ log.warn("Blocked Move without alsoKnownAs back-reference", {
1330
+ event: "ap.move.unverified_alias",
1331
+ newActor: newActorApId,
1332
+ oldActor: oldActorApId,
1333
+ });
1334
+ return;
1335
+ }
1336
+
1337
+ // Refresh/cache the new actor document (best-effort).
1338
+ await refreshActorCache(db, newActorApId);
1339
+
1340
+ // Rewrite follow graph references from old -> new in batches.
1341
+ const followerRows = await db
1342
+ .select({
1343
+ followingApId: follows.followingApId,
1344
+ status: follows.status,
1345
+ activityApId: follows.activityApId,
1346
+ createdAt: follows.createdAt,
1347
+ acceptedAt: follows.acceptedAt,
1348
+ })
1349
+ .from(follows)
1350
+ .where(eq(follows.followerApId, oldActorApId));
1351
+
1352
+ const followingRows = await db
1353
+ .select({
1354
+ followerApId: follows.followerApId,
1355
+ status: follows.status,
1356
+ activityApId: follows.activityApId,
1357
+ createdAt: follows.createdAt,
1358
+ acceptedAt: follows.acceptedAt,
1359
+ })
1360
+ .from(follows)
1361
+ .where(eq(follows.followingApId, oldActorApId));
1362
+
1363
+ const followerTargets = followerRows.map((row) => row.followingApId);
1364
+ const followingSources = followingRows.map((row) => row.followerApId);
1365
+
1366
+ const existingFollowerPairs =
1367
+ followerTargets.length > 0
1368
+ ? await db
1369
+ .select({ followingApId: follows.followingApId })
1370
+ .from(follows)
1371
+ .where(
1372
+ and(
1373
+ eq(follows.followerApId, newActorApId),
1374
+ // Subquery, not `inArray(followerTargets)`: the old actor's follow
1375
+ // graph can exceed D1's 100-bound-parameter ceiling. Same set as
1376
+ // followerTargets (the old actor's followees).
1377
+ inArray(
1378
+ follows.followingApId,
1379
+ db
1380
+ .select({ id: follows.followingApId })
1381
+ .from(follows)
1382
+ .where(eq(follows.followerApId, oldActorApId)),
1383
+ ),
1384
+ ),
1385
+ )
1386
+ : [];
1387
+ const existingFollowingPairs =
1388
+ followingSources.length > 0
1389
+ ? await db
1390
+ .select({ followerApId: follows.followerApId })
1391
+ .from(follows)
1392
+ .where(
1393
+ and(
1394
+ // Subquery, not `inArray(followingSources)`: the old actor's
1395
+ // follower graph can exceed D1's 100-bound-parameter ceiling. Same
1396
+ // set as followingSources (the old actor's followers).
1397
+ inArray(
1398
+ follows.followerApId,
1399
+ db
1400
+ .select({ id: follows.followerApId })
1401
+ .from(follows)
1402
+ .where(eq(follows.followingApId, oldActorApId)),
1403
+ ),
1404
+ eq(follows.followingApId, newActorApId),
1405
+ ),
1406
+ )
1407
+ : [];
1408
+
1409
+ const existingFollowerTargetSet = new Set(
1410
+ existingFollowerPairs.map((row) => row.followingApId),
1411
+ );
1412
+ const existingFollowingSourceSet = new Set(
1413
+ existingFollowingPairs.map((row) => row.followerApId),
1414
+ );
1415
+
1416
+ // Drop self-edges in addition to the existing-pair dedup: if the old and new
1417
+ // actor were already connected (old followed/was-followed-by new, or vice
1418
+ // versa), rewriting the endpoint to the new actor would produce a row where
1419
+ // followerApId === followingApId (a self-follow). Filter those out so the
1420
+ // migration never materializes a self-follow.
1421
+ const followerRewrites = followerRows
1422
+ .filter(
1423
+ (row) =>
1424
+ !existingFollowerTargetSet.has(row.followingApId) &&
1425
+ row.followingApId !== newActorApId,
1426
+ )
1427
+ .map((row) => ({
1428
+ followerApId: newActorApId,
1429
+ followingApId: row.followingApId,
1430
+ status: row.status,
1431
+ activityApId: row.activityApId,
1432
+ createdAt: row.createdAt,
1433
+ acceptedAt: row.acceptedAt,
1434
+ }));
1435
+ // For followers that are LOCAL to this instance, a bare edge rewrite is not
1436
+ // enough: the destination server has no record of the follow, so it would
1437
+ // never deliver the migrated account's posts (the local user's following list
1438
+ // would point at the new actor but silently receive nothing). Re-issue a
1439
+ // fresh, *pending* Follow to the new actor and enqueue outbound delivery —
1440
+ // the standard Mastodon "follow the move target on the user's behalf"
1441
+ // behavior; the destination's Accept flips it to accepted. Remote followers
1442
+ // are left as a plain edge rewrite: re-establishing their follow is their own
1443
+ // server's responsibility.
1444
+ const baseUrl = c.env.APP_URL;
1445
+ const localReFollows: { followerApId: string; followId: string }[] = [];
1446
+ const followingRewrites = followingRows
1447
+ .filter(
1448
+ (row) =>
1449
+ !existingFollowingSourceSet.has(row.followerApId) &&
1450
+ row.followerApId !== newActorApId,
1451
+ )
1452
+ .map((row) => {
1453
+ if (isLocal(row.followerApId, baseUrl)) {
1454
+ const followId = activityApId(baseUrl, generateId());
1455
+ localReFollows.push({ followerApId: row.followerApId, followId });
1456
+ return {
1457
+ followerApId: row.followerApId,
1458
+ followingApId: newActorApId,
1459
+ status: "pending",
1460
+ activityApId: followId,
1461
+ createdAt: row.createdAt,
1462
+ acceptedAt: null,
1463
+ };
1464
+ }
1465
+ return {
1466
+ followerApId: row.followerApId,
1467
+ followingApId: newActorApId,
1468
+ status: row.status,
1469
+ activityApId: row.activityApId,
1470
+ createdAt: row.createdAt,
1471
+ acceptedAt: row.acceptedAt,
1472
+ };
1473
+ });
1474
+
1475
+ // LOCAL followers whose ACCEPTED edge to the old actor we are about to delete.
1476
+ // Each such edge was counted in that follower's followingCount; the re-issued
1477
+ // Follow to the new actor is created PENDING (uncounted) and only re-adds the
1478
+ // +1 when the destination Accepts (handleAccept). So the old +1 must be removed
1479
+ // now, otherwise the eventual Accept stacks a second +1 on the never-removed
1480
+ // old count → a permanent over-count of 1 per migrated follow. Decrementing at
1481
+ // delete time is correct in every Accept-timing case: during the pending window
1482
+ // the follower counts 0 of this relationship (right — it is pending), after the
1483
+ // Accept it is back to 1, and if the Accept never arrives it stays decremented
1484
+ // (right — the edge is perpetually pending). Remote followers' counts are not
1485
+ // ours to manage; only local followingCount is authoritative here.
1486
+ const localAcceptedFollowerApIds = Array.from(
1487
+ new Set(
1488
+ followingRows
1489
+ .filter(
1490
+ (row) =>
1491
+ row.status === "accepted" && isLocal(row.followerApId, baseUrl),
1492
+ )
1493
+ .map((row) => row.followerApId),
1494
+ ),
1495
+ );
1496
+
1497
+ // Symmetric to the above, on the FOLLOWEE side: the old actor's ACCEPTED follow
1498
+ // of a LOCAL actor L incremented L.followerCount (handleFollow). When the
1499
+ // (old→L) rewrite is DROPPED as a duplicate (the NEW actor already follows L,
1500
+ // i.e. L ∈ existingFollowerTargetSet) or as a self-edge (L === newActor), the
1501
+ // delete below still removes (old→L) but NO rewrite re-adds an (new→L) edge for
1502
+ // it — so L would keep a permanent +1 over-count. Decrement those dropped,
1503
+ // accepted, local followees' followerCount once (the non-dropped case is
1504
+ // count-preserving: delete old→L + insert new→L). gt(>0) guards underflow.
1505
+ const droppedAcceptedLocalFolloweeApIds = Array.from(
1506
+ new Set(
1507
+ followerRows
1508
+ .filter(
1509
+ (row) =>
1510
+ row.status === "accepted" &&
1511
+ isLocal(row.followingApId, baseUrl) &&
1512
+ (existingFollowerTargetSet.has(row.followingApId) ||
1513
+ row.followingApId === newActorApId),
1514
+ )
1515
+ .map((row) => row.followingApId),
1516
+ ),
1517
+ );
1518
+
1519
+ // Co-commit the four edge mutations + the per-follower followingCount
1520
+ // decrements in ONE atomic batch. D1 has no interactive transactions, and the
1521
+ // OLD sequential form was non-convergent: a crash between "delete old edges"
1522
+ // and "decrement" left the old edges gone, so a re-dispatch (the row is still
1523
+ // processed=0) re-read EMPTY follower/following rows, skipped the decrement,
1524
+ // and left every migrated local follower's followingCount permanently +1 over.
1525
+ // Batching makes delete+decrement all-or-nothing: a crash before commit changes
1526
+ // nothing (retry re-runs cleanly from the still-present old edges); a crash
1527
+ // after commit re-reads no old edges (the batch is then a no-op) → the
1528
+ // decrement is applied exactly once.
1529
+ const moveOps = [];
1530
+ if (followerRewrites.length > 0) {
1531
+ moveOps.push(db.insert(follows).values(followerRewrites));
1532
+ }
1533
+ if (followerRows.length > 0) {
1534
+ moveOps.push(
1535
+ db.delete(follows).where(eq(follows.followerApId, oldActorApId)),
1536
+ );
1537
+ }
1538
+ if (followingRewrites.length > 0) {
1539
+ moveOps.push(db.insert(follows).values(followingRewrites));
1540
+ }
1541
+ if (followingRows.length > 0) {
1542
+ moveOps.push(
1543
+ db.delete(follows).where(eq(follows.followingApId, oldActorApId)),
1544
+ );
1545
+ }
1546
+ for (const followerApId of localAcceptedFollowerApIds) {
1547
+ moveOps.push(
1548
+ db
1549
+ .update(actors)
1550
+ .set({ followingCount: sql`${actors.followingCount} - 1` })
1551
+ .where(
1552
+ and(eq(actors.apId, followerApId), gt(actors.followingCount, 0)),
1553
+ ),
1554
+ );
1555
+ }
1556
+ for (const followeeApId of droppedAcceptedLocalFolloweeApIds) {
1557
+ moveOps.push(
1558
+ db
1559
+ .update(actors)
1560
+ .set({ followerCount: sql`${actors.followerCount} - 1` })
1561
+ .where(and(eq(actors.apId, followeeApId), gt(actors.followerCount, 0))),
1562
+ );
1563
+ }
1564
+ if (moveOps.length > 0) {
1565
+ await runBatch(db, moveOps as unknown as Parameters<typeof runBatch>[1]);
1566
+ }
1567
+
1568
+ // Record + deliver the outbound Follow activities for migrated local
1569
+ // followers so the destination server registers them as followers and starts
1570
+ // delivering. Best-effort per follower: a delivery enqueue failure must not
1571
+ // abort the rest of the migration (the follow row is already pending and will
1572
+ // simply lack delivery until retried).
1573
+ for (const { followerApId, followId } of localReFollows) {
1574
+ const followActivity = {
1575
+ "@context": "https://www.w3.org/ns/activitystreams",
1576
+ id: followId,
1577
+ type: "Follow",
1578
+ actor: followerApId,
1579
+ object: newActorApId,
1580
+ };
1581
+ try {
1582
+ await db.insert(activities).values({
1583
+ apId: followId,
1584
+ type: "Follow",
1585
+ actorApId: followerApId,
1586
+ objectApId: newActorApId,
1587
+ rawJson: JSON.stringify(followActivity),
1588
+ direction: "outbound",
1589
+ });
1590
+ await enqueueDeliveryToActor(c.env, followId, newActorApId);
1591
+ } catch (e) {
1592
+ log.warn("Failed to issue migration re-follow to move target", {
1593
+ event: "ap.move.refollow_failed",
1594
+ follower: followerApId,
1595
+ newActor: newActorApId,
1596
+ error: e,
1597
+ });
1598
+ }
1599
+ }
1600
+ }
1601
+
1602
+ // ---------------------------------------------------------------------------
1603
+ // Internal helpers
1604
+ // ---------------------------------------------------------------------------
1605
+
1606
+ function getActivityTargetId(activity: Activity): string | null {
1607
+ const target = activity.target;
1608
+ if (!target) return null;
1609
+ if (typeof target === "string") return target;
1610
+ return target.id || null;
1611
+ }
1612
+
1613
+ /** Fetch a remote actor document and cache it locally. Best-effort (errors are logged, not thrown). */
1614
+ async function refreshActorCache(
1615
+ db: Database,
1616
+ actorApIdValue: string,
1617
+ ): Promise<void> {
1618
+ const result = await fetchAndUpsertActorCache(db, actorApIdValue, {
1619
+ timeout: 15000,
1620
+ mode: "upsert",
1621
+ // Sign as the instance actor so a secure-mode remote serves its doc.
1622
+ signer: (await getInstanceFetchSignerByDb(db)) ?? undefined,
1623
+ });
1624
+ if (!result.ok && result.reason === "fetch_failed") {
1625
+ // Shared by Move (refresh the migration target) and Update(actor)
1626
+ // (apply a remote profile / key rotation immediately). Best-effort: a
1627
+ // failed refresh simply leaves the existing cache row in place until the
1628
+ // normal TTL refresh, so it is logged rather than thrown.
1629
+ log.warn("Failed to refresh remote actor cache", {
1630
+ event: "ap.actor.cache_refresh_failed",
1631
+ actor: actorApIdValue,
1632
+ });
1633
+ }
1634
+ }