@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,15 +1,19 @@
1
1
  import { Hono } from "hono";
2
- import { actors, follows, objects } from "../../../db/index.ts";
2
+ import {
3
+ actors,
4
+ follows,
5
+ objectRecipients,
6
+ objects,
7
+ runBatch,
8
+ type D1Statement,
9
+ } from "../../../db/index.ts";
3
10
  import type { Database } from "../../../db/index.ts";
4
- import { OBJECT_CONTEXT } from "../../lib/ap-context.ts";
5
11
  import { and, desc, eq, gt, inArray, sql } from "drizzle-orm";
6
12
  import type { Actor, Env, Variables } from "../../types.ts";
7
13
  import {
8
14
  activityApId,
9
15
  formatUsername,
10
16
  generateId,
11
- isLocal,
12
- isSafeRemoteUrl,
13
17
  objectApId,
14
18
  parseLimit,
15
19
  safeJsonParse,
@@ -22,62 +26,51 @@ import {
22
26
  } from "./transformers.ts";
23
27
  import {
24
28
  AUTHOR_WITH,
25
- buildAddressing,
26
- buildCommunityObjectAddressing,
27
- loadCachedAuthorMap,
28
29
  loadInteractionFlags,
29
- mergeCc,
30
- persistActivity,
31
- persistAndFanout,
32
- persistAndFanoutToCommunity,
30
+ preparePersistAndFanout,
31
+ preparePersistAndFanoutToCommunity,
33
32
  type PostDetailRow,
34
33
  postWhereByIdOrApId,
35
- type PostWithAuthor,
36
34
  resolveAuthor,
37
35
  resolveAuthorWithCache,
38
36
  toPostRow,
39
37
  } from "./queries.ts";
40
- import { enqueueDeliveryToActor } from "../../lib/delivery/queue.ts";
41
- import { deleteObjectCascade, purgeMediaBlobs } from "./delete-cascade.ts";
38
+ import {
39
+ prepareObjectDeleteCascade,
40
+ purgeMediaBlobs,
41
+ } from "./delete-cascade.ts";
42
42
  import {
43
43
  checkCommunityPostPermission,
44
44
  deriveContentTags,
45
- insertPostAndHandleReply,
46
- processMentions,
47
- REPLY_TARGET_NOT_FOUND,
45
+ preparePostInsertStatements,
48
46
  validateContentEdit,
49
47
  validateCreatePostBody,
50
48
  validateEditBody,
51
49
  validateSummaryEdit,
52
50
  } from "./post-helpers.ts";
53
- import { requireActor } from "../actors-helpers.ts";
51
+ import { loadActorInfoMap, requireActor } from "../actors-helpers.ts";
54
52
  import { communityReadableApIds } from "../../lib/community-visibility.ts";
55
53
  import { encodeFeedCursor, feedCursorWhere } from "../../lib/feed-cursor.ts";
56
54
  import {
57
55
  actorIsBlockedBy,
58
56
  canViewerReadObjectFull,
57
+ passesPostVisibilitySync,
58
+ type ReadGateObject,
59
59
  } from "../../lib/post-visibility.ts";
60
- import { toApAttachments } from "../../lib/activitypub-helpers.ts";
61
60
  import { logger } from "../../lib/logger.ts";
61
+ import { excludeModeratedActors } from "../../lib/feed-exclude.ts";
62
+ import {
63
+ prepareCreatedPostFederation,
64
+ prepareDeletedPostFederation,
65
+ } from "./federation.ts";
62
66
 
63
67
  const log = logger.child({ component: "posts.routes" });
64
68
 
65
- // `.batch` lives only on the concrete D1/libsql subclasses, not the Database
66
- // union; reach it through a narrow structural cast (matching the other routes).
67
- type Batchable = { batch: (stmts: unknown[]) => Promise<unknown> };
68
-
69
69
  const posts = new Hono<{ Bindings: Env; Variables: Variables }>();
70
70
 
71
- const PUBLIC_COLLECTION = "https://www.w3.org/ns/activitystreams#Public";
72
-
73
71
  /** Reply row shape needed for the visibility gate (subset of the object row). */
74
- type ReplyVisibilityRow = {
72
+ type ReplyVisibilityRow = ReadGateObject & {
75
73
  apId: string;
76
- attributedTo: string;
77
- visibility: string;
78
- toJson?: string | null;
79
- audienceJson?: string | null;
80
- communityApId?: string | null;
81
74
  };
82
75
 
83
76
  /**
@@ -120,6 +113,27 @@ async function filterVisibleReplies<T extends ReplyVisibilityRow>(
120
113
  acceptedFollowing = new Set(rows.map((r) => r.followingApId));
121
114
  }
122
115
 
116
+ const projectedRecipientIds =
117
+ viewerApId && replies.length > 0
118
+ ? new Set(
119
+ (
120
+ await db
121
+ .select({ objectApId: objectRecipients.objectApId })
122
+ .from(objectRecipients)
123
+ .where(
124
+ and(
125
+ eq(objectRecipients.recipientApId, viewerApId),
126
+ eq(objectRecipients.type, "to"),
127
+ inArray(
128
+ objectRecipients.objectApId,
129
+ replies.map((reply) => reply.apId),
130
+ ),
131
+ ),
132
+ )
133
+ ).map((row) => row.objectApId),
134
+ )
135
+ : new Set<string>();
136
+
123
137
  // Pre-compute the community read-gate for every reply: a community-scoped
124
138
  // reply is stored "public" but carries an audience, so the per-visibility
125
139
  // checks below would let it through. Resolving membership here (rather than
@@ -136,18 +150,12 @@ async function filterVisibleReplies<T extends ReplyVisibilityRow>(
136
150
  // A private-community reply is hidden from anyone who is not an accepted
137
151
  // member, regardless of the (stored "public") visibility.
138
152
  if (!communityReadable.has(reply.apId)) return false;
139
- if (reply.visibility === "followers") {
140
- if (!viewerApId) return false;
141
- if (reply.attributedTo === viewerApId) return true;
142
- return acceptedFollowing.has(reply.attributedTo);
143
- }
144
- if (reply.visibility === "direct") {
145
- if (!viewerApId) return false;
146
- if (reply.attributedTo === viewerApId) return true;
147
- const recipients = safeJsonParse<string[]>(reply.toJson, []);
148
- return recipients.includes(viewerApId);
149
- }
150
- return true;
153
+ return passesPostVisibilitySync(
154
+ reply,
155
+ viewerApId,
156
+ (authorApId) => acceptedFollowing.has(authorApId),
157
+ (objectApId) => projectedRecipientIds.has(objectApId),
158
+ );
151
159
  });
152
160
  }
153
161
 
@@ -190,9 +198,11 @@ posts.post("/", async (c) => {
190
198
  // replyCount, sending them a reply notification, and publishing a public reply
191
199
  // whose inReplyTo discloses the restricted parent's existence (and bypassing a
192
200
  // block). Mirror the like/repost gates; 404 to avoid leaking existence.
201
+ let parentAuthor: string | null = null;
193
202
  if (body.in_reply_to) {
194
203
  const parent = await db
195
204
  .select({
205
+ apId: objects.apId,
196
206
  visibility: objects.visibility,
197
207
  attributedTo: objects.attributedTo,
198
208
  toJson: objects.toJson,
@@ -212,6 +222,7 @@ posts.post("/", async (c) => {
212
222
  ) {
213
223
  return c.json({ error: "Post not found" }, 404);
214
224
  }
225
+ parentAuthor = parent.attributedTo;
215
226
  }
216
227
 
217
228
  const baseUrl = c.env.APP_URL;
@@ -219,9 +230,25 @@ posts.post("/", async (c) => {
219
230
  const apId = objectApId(baseUrl, postId);
220
231
  const now = new Date().toISOString();
221
232
 
222
- let parentAuthor: string | null = null;
233
+ let preparedFederation: Awaited<
234
+ ReturnType<typeof prepareCreatedPostFederation>
235
+ >;
223
236
  try {
224
- parentAuthor = await insertPostAndHandleReply(db, {
237
+ preparedFederation = await prepareCreatedPostFederation({
238
+ db,
239
+ env: c.env,
240
+ actorApId: actor.ap_id,
241
+ objectApId: apId,
242
+ content,
243
+ summary: summary || null,
244
+ attachments: body.attachments,
245
+ inReplyTo: body.in_reply_to || null,
246
+ parentAuthor,
247
+ visibility,
248
+ community,
249
+ published: now,
250
+ });
251
+ const localStatements = preparePostInsertStatements(db, {
225
252
  apId,
226
253
  actorApId: actor.ap_id,
227
254
  content,
@@ -230,14 +257,16 @@ posts.post("/", async (c) => {
230
257
  inReplyTo: body.in_reply_to || null,
231
258
  visibility,
232
259
  communityId,
233
- community,
260
+ to: preparedFederation.to,
261
+ cc: preparedFederation.cc,
262
+ audience: preparedFederation.audience,
263
+ tags: preparedFederation.tags,
264
+ parentAuthor,
234
265
  baseUrl,
235
266
  now,
236
267
  });
268
+ await runBatch(db, [...localStatements, ...preparedFederation.statements]);
237
269
  } catch (e) {
238
- if (e instanceof Error && e.message === REPLY_TARGET_NOT_FOUND) {
239
- return c.json({ error: "Reply target not found" }, 404);
240
- }
241
270
  log.error("Failed to create post transaction", {
242
271
  event: "posts.create.transaction_failed",
243
272
  actor: actor.ap_id,
@@ -247,167 +276,14 @@ posts.post("/", async (c) => {
247
276
  return c.json({ error: "Failed to create post" }, 500);
248
277
  }
249
278
 
250
- // Process mentions: resolve @mentions (local + remote), build `Mention`
251
- // tags, create local notifications, and collect the resolved recipient IRIs.
252
- const {
253
- failures: mentionFailures,
254
- tags: mentionTags,
255
- mentionedActorApIds,
256
- remoteMentionedActorApIds,
257
- } = await processMentions(db, {
258
- content,
259
- postApId: apId,
260
- actorApId: actor.ap_id,
261
- parentAuthor,
262
- baseUrl,
263
- now,
264
- });
265
-
266
- // A reply must reach the post it replies to. processMentions only addresses
267
- // actors EXPLICITLY @-mentioned in the body, so a reply to a remote post that
268
- // doesn't manually @-mention its author was delivered to the replier's own
269
- // followers but NEVER to the upstream instance — it never landed in the
270
- // original thread (whereas Like/Undo-repost already reach the remote object
271
- // author). Auto-address the parent author of a NON-direct reply: add it to cc
272
- // and a `Mention` tag (so the reply threads + notifies on the receiving
273
- // server) and, when remote, deliver the Create to its inbox. Direct replies
274
- // keep mentions-only addressing (no implicit parent disclosure). The local
275
- // parent author is already notified by the reply path, so this only augments
276
- // addressing/delivery, never a duplicate local notification.
277
- const replyRecipients = [...mentionedActorApIds];
278
- const replyRemoteRecipients = [...remoteMentionedActorApIds];
279
- const replyTags = [...mentionTags];
280
- if (
281
- body.in_reply_to &&
282
- parentAuthor &&
283
- parentAuthor !== actor.ap_id &&
284
- visibility !== "direct" &&
285
- !replyRecipients.includes(parentAuthor)
286
- ) {
287
- replyRecipients.push(parentAuthor);
288
- replyTags.push({
289
- type: "Mention",
290
- href: parentAuthor,
291
- name: `@${formatUsername(parentAuthor)}`,
292
- });
293
- if (!isLocal(parentAuthor, baseUrl)) {
294
- replyRemoteRecipients.push(parentAuthor);
295
- }
296
- }
297
-
298
- // Federate when the post has follower/public/community reach OR when it has
299
- // resolved mentions (a mention is an explicit recipient, so even a "direct"
300
- // post must federate the Create to its remote mentioned actors).
301
- //
302
- // Community-scoped posts have reach == community: address the Create toward
303
- // the community Group actor + its followers collection (NOT the author's
304
- // personal followers), record the community in `audience`, and fan out to
305
- // the community's members/followers. Non-community posts keep the existing
306
- // author-follower addressing and fan-out. Mentioned actors are always added
307
- // to `cc` so the post is addressed to them on the receiving server.
308
- if (visibility !== "direct" || mentionedActorApIds.length > 0) {
309
- let to: string[];
310
- let cc: string[];
311
- let audience: string[] | undefined;
312
-
313
- if (visibility === "direct") {
314
- // Direct post with mentions: no follower/public reach, only the
315
- // mentioned actors are recipients.
316
- to = [];
317
- cc = [];
318
- } else if (community) {
319
- const objectAddressing = buildCommunityObjectAddressing(
320
- visibility,
321
- community,
322
- );
323
- to = objectAddressing.to;
324
- cc = objectAddressing.cc;
325
- audience = objectAddressing.audience;
326
- } else {
327
- const followersUrl = `${actor.ap_id}/followers`;
328
- ({ to, cc } = buildAddressing(visibility, followersUrl));
329
- }
330
-
331
- // Add every mentioned actor IRI — plus an auto-addressed reply parent — to
332
- // cc (de-duplicated).
333
- cc = mergeCc(cc, replyRecipients);
334
-
335
- const tag = replyTags.length > 0 ? replyTags : undefined;
336
-
337
- const createActivity = {
338
- "@context": OBJECT_CONTEXT,
339
- id: activityApId(baseUrl, generateId()),
340
- type: "Create",
341
- actor: actor.ap_id,
342
- published: now,
343
- to,
344
- cc,
345
- ...(audience ? { audience } : {}),
346
- ...(tag ? { tag } : {}),
347
- object: {
348
- "@context": OBJECT_CONTEXT,
349
- id: apId,
350
- type: "Note",
351
- attributedTo: actor.ap_id,
352
- content,
353
- summary: summary || null,
354
- // A non-empty summary is a content warning; Mastodon-compatible peers
355
- // gate rendering on BOTH `summary` (the CW text) and `sensitive`. The
356
- // served object doc (routes/activitypub/outbox.ts) already sets this, so
357
- // the delivered Create must match or the CW federates inconsistently.
358
- ...(summary ? { sensitive: true } : {}),
359
- // Media is stored as an app-relative /media path; absolutize for the
360
- // federated copy so remote servers can fetch the image.
361
- attachment: toApAttachments(body.attachments || [], baseUrl),
362
- inReplyTo: body.in_reply_to || null,
363
- published: now,
364
- to,
365
- cc,
366
- ...(audience ? { audience } : {}),
367
- ...(tag ? { tag } : {}),
368
- },
369
- };
370
-
371
- if (visibility === "direct") {
372
- // No follower/community fanout for a direct post — persist only, then
373
- // direct-deliver to the remote mentioned actors below.
374
- await persistActivity(db, createActivity, apId);
375
- } else if (community) {
376
- await persistAndFanoutToCommunity(
377
- db,
378
- c.env,
379
- createActivity,
380
- apId,
381
- community.apId,
382
- );
383
- } else {
384
- await persistAndFanout(db, c.env, createActivity, apId);
385
- }
386
-
387
- // Deliver the Create directly to each remote mentioned actor's inbox — plus
388
- // the remote parent author of a reply (auto-addressed above). Community/
389
- // follower fanout does not include arbitrary remote actors, so this is the
390
- // only path that reaches a remote @user@domain mention or reply target.
391
- for (const recipient of replyRemoteRecipients) {
392
- try {
393
- await enqueueDeliveryToActor(c.env, createActivity.id, recipient);
394
- } catch (err) {
395
- log.error("Failed to enqueue mention delivery", {
396
- event: "posts.mention.delivery_enqueue_failed",
397
- activityId: createActivity.id,
398
- recipient,
399
- error: err,
400
- });
401
- }
402
- }
403
- }
279
+ const { mentionFailures } = await preparedFederation.complete();
404
280
 
405
281
  const createdPost = {
406
282
  ap_id: apId,
407
283
  type: "Note",
408
284
  author: {
409
285
  ap_id: actor.ap_id,
410
- username: formatUsername(actor.ap_id),
286
+ username: formatUsername(actor.ap_id, actor.preferred_username),
411
287
  preferred_username: actor.preferred_username,
412
288
  name: actor.name,
413
289
  icon_url: actor.icon_url,
@@ -451,6 +327,24 @@ posts.get("/:id", async (c) => {
451
327
  });
452
328
 
453
329
  if (!post) return c.json({ error: "Post not found" }, 404);
330
+ if (currentActor) {
331
+ // Drizzle's relational query aliases every embedded column reference to the
332
+ // outer table, so keep the moderation CTE in this ordinary select instead
333
+ // of injecting it into findFirst({ with: ... }).
334
+ const moderationReadable = await db
335
+ .select({ apId: objects.apId })
336
+ .from(objects)
337
+ .where(
338
+ and(
339
+ eq(objects.apId, post.apId),
340
+ excludeModeratedActors(currentActor.ap_id),
341
+ ),
342
+ )
343
+ .get();
344
+ if (!moderationReadable) {
345
+ return c.json({ error: "Post not found" }, 404);
346
+ }
347
+ }
454
348
 
455
349
  // Resolve author and interaction flags in parallel
456
350
  const [author, { likedIds, bookmarkedIds }] = await Promise.all([
@@ -497,7 +391,12 @@ posts.get("/:id/replies", async (c) => {
497
391
  endTime: objects.endTime,
498
392
  })
499
393
  .from(objects)
500
- .where(postWhereByIdOrApId(baseUrl, postId)!)
394
+ .where(
395
+ and(
396
+ postWhereByIdOrApId(baseUrl, postId),
397
+ excludeModeratedActors(currentActor?.ap_id ?? ""),
398
+ ),
399
+ )
501
400
  .get();
502
401
 
503
402
  if (!parentPost) return c.json({ error: "Post not found" }, 404);
@@ -524,14 +423,18 @@ posts.get("/:id/replies", async (c) => {
524
423
  // last readable one) means unreadable replies are skipped without ever
525
424
  // skipping a readable one — so load-more reaches every readable reply, and
526
425
  // the gate dropping rows can only make a page short, never lose a reply.
527
- const scanned = await db.query.objects.findMany({
528
- where: cursorPredicate
529
- ? and(eq(objects.inReplyTo, parentPost.apId), cursorPredicate)
530
- : eq(objects.inReplyTo, parentPost.apId),
531
- with: AUTHOR_WITH,
532
- orderBy: [desc(objects.published), desc(objects.apId)],
533
- limit: limit + 1,
534
- });
426
+ const scanned = await db
427
+ .select()
428
+ .from(objects)
429
+ .where(
430
+ and(
431
+ eq(objects.inReplyTo, parentPost.apId),
432
+ cursorPredicate,
433
+ excludeModeratedActors(currentActor?.ap_id ?? ""),
434
+ ),
435
+ )
436
+ .orderBy(desc(objects.published), desc(objects.apId))
437
+ .limit(limit + 1);
535
438
  const hasMore = scanned.length > limit;
536
439
  const page = hasMore ? scanned.slice(0, limit) : scanned;
537
440
  const lastScanned = page[page.length - 1];
@@ -548,18 +451,18 @@ posts.get("/:id/replies", async (c) => {
548
451
 
549
452
  // Batch load cached authors and interaction flags in parallel
550
453
  const replyApIds = replies.map((r) => r.apId);
551
- const [cachedAuthorMap, { likedIds }] = await Promise.all([
552
- loadCachedAuthorMap(db, replies as PostWithAuthor[]),
454
+ const [authorMap, { likedIds }] = await Promise.all([
455
+ loadActorInfoMap(
456
+ db,
457
+ [...new Set(replies.map((reply) => reply.attributedTo))],
458
+ "author",
459
+ ),
553
460
  loadInteractionFlags(db, currentActor?.ap_id, replyApIds),
554
461
  ]);
555
462
 
556
463
  const result = replies.map((reply) => {
557
- const author = resolveAuthor(
558
- reply.author,
559
- reply.attributedTo,
560
- cachedAuthorMap,
561
- );
562
- const postRow = toPostRow(reply as PostWithAuthor, author, {
464
+ const author = resolveAuthor(undefined, reply.attributedTo, authorMap);
465
+ const postRow = toPostRow(reply, author, {
563
466
  liked: likedIds.has(reply.apId),
564
467
  });
565
468
  return formatPost(postRow, currentActor?.ap_id);
@@ -647,8 +550,6 @@ posts.patch("/:id", async (c) => {
647
550
  updateData.tagsJson = JSON.stringify(nextTags);
648
551
  }
649
552
 
650
- await db.update(objects).set(updateData).where(eq(objects.apId, post.apId));
651
-
652
553
  // Mirror the stored post's addressing onto the Update so its audience matches
653
554
  // the ORIGINAL post (like the Delete path). Without this the Update carried no
654
555
  // to/cc/audience and — combined with the community branch below — fanned out
@@ -678,7 +579,10 @@ posts.patch("/:id", async (c) => {
678
579
  sensitive: Boolean(nextSummary),
679
580
  // Carry the re-derived tags so a receiver updating the Note keeps its
680
581
  // Hashtag/Mention tags instead of dropping them on edit.
681
- ...(nextTags.length > 0 ? { tag: nextTags } : {}),
582
+ // Always carry the projection. `[]` is an explicit clear; omission is
583
+ // reserved for partial third-party Updates that ask receivers to preserve
584
+ // their existing tag projection.
585
+ tag: nextTags,
682
586
  to: updateTo,
683
587
  cc: updateCc,
684
588
  ...(updateAudience.length > 0 ? { audience: updateAudience } : {}),
@@ -689,17 +593,28 @@ posts.patch("/:id", async (c) => {
689
593
  // A community-scoped post's Update must reach the COMMUNITY (the members who
690
594
  // got the Create), NOT the author's personal followers — who never received
691
595
  // the Create. Mirror the create path's community-vs-personal fan-out branch.
692
- if (post.communityApId) {
693
- await persistAndFanoutToCommunity(
694
- db,
695
- c.env,
696
- updateActivity,
697
- post.apId,
698
- post.communityApId,
699
- );
700
- } else {
701
- await persistAndFanout(db, c.env, updateActivity, post.apId);
702
- }
596
+ const preparedFanout = post.communityApId
597
+ ? await preparePersistAndFanoutToCommunity(
598
+ db,
599
+ c.env,
600
+ updateActivity,
601
+ post.apId,
602
+ post.communityApId,
603
+ )
604
+ : await preparePersistAndFanout(db, c.env, updateActivity, post.apId);
605
+
606
+ // The edited object and its outbound Update/fanout intent are one mutation.
607
+ // If the durable intent cannot be stored, the old content/tags stay visible
608
+ // and the client can safely retry instead of receiving 500 after a local-only
609
+ // edit already committed.
610
+ await runBatch(db, [
611
+ db
612
+ .update(objects)
613
+ .set(updateData)
614
+ .where(eq(objects.apId, post.apId)) as D1Statement,
615
+ ...preparedFanout.statements,
616
+ ]);
617
+ await preparedFanout.publish();
703
618
 
704
619
  return c.json({
705
620
  success: true,
@@ -730,11 +645,15 @@ posts.delete("/:id", async (c) => {
730
645
  return c.json({ error: "Forbidden" }, 403);
731
646
  }
732
647
 
733
- // D1 doesn't support interactive transactions; use sequential operations.
734
- // FK ON DELETE CASCADE is not reliably enforced (PRAGMA foreign_keys is not
735
- // guaranteed on every runtime/connection, and D1 ignores it), so delete the
736
- // object's child rows explicitly before the object row to avoid orphans.
737
- const mediaKeys = await deleteObjectCascade(db, post.apId, c.env.MEDIA);
648
+ const [cascade, preparedFederation] = await Promise.all([
649
+ prepareObjectDeleteCascade(db, post.apId, c.env.MEDIA),
650
+ prepareDeletedPostFederation({
651
+ db,
652
+ env: c.env,
653
+ actorApId: actor.ap_id,
654
+ post,
655
+ }),
656
+ ]);
738
657
 
739
658
  // Co-commit the object delete + author postCount-- + parent replyCount in ONE
740
659
  // batch (mirrors the federated handleDelete): a crash between separate
@@ -748,16 +667,19 @@ posts.delete("/:id", async (c) => {
748
667
  .set({ postCount: sql`${actors.postCount} - 1` })
749
668
  .where(
750
669
  and(eq(actors.apId, actor.ap_id), gt(actors.postCount, 0), objectExists),
751
- );
752
- const deleteObject = db.delete(objects).where(eq(objects.apId, post.apId));
670
+ ) as D1Statement;
671
+ const deleteObject = db
672
+ .delete(objects)
673
+ .where(eq(objects.apId, post.apId)) as D1Statement;
753
674
  // DM notes (`visibility="direct"`, created by createDmNote) are NOT counted in
754
675
  // postCount on send, so deleting one here must NOT decrement it — otherwise a
755
676
  // DM deleted through this generic endpoint (the dedicated DELETE
756
677
  // /dm/messages/:id correctly skips the count) under-counts the author's
757
678
  // postCount (floored at 0). Keep create/delete symmetric: only regular posts
758
679
  // (which incremented) decrement.
759
- const ops: unknown[] = [];
680
+ const ops: D1Statement[] = [...cascade.statements];
760
681
  if (post.visibility !== "direct") ops.push(decPostCount);
682
+ ops.push(...preparedFederation.statements);
761
683
  ops.push(deleteObject);
762
684
  if (post.inReplyTo) {
763
685
  const parentId = post.inReplyTo;
@@ -767,97 +689,15 @@ posts.delete("/:id", async (c) => {
767
689
  .set({
768
690
  replyCount: sql`(SELECT COUNT(*) FROM ${objects} WHERE ${objects.inReplyTo} = ${parentId})`,
769
691
  })
770
- .where(eq(objects.apId, parentId)),
692
+ .where(eq(objects.apId, parentId)) as D1Statement,
771
693
  );
772
694
  }
773
- await (db as unknown as Batchable).batch(ops);
695
+ await runBatch(db, ops as [D1Statement, ...D1Statement[]]);
774
696
 
775
697
  // Irreversible R2 purge LAST — only now that the objects row is gone. A
776
698
  // failure here degrades to a leaked blob, not a live post with a deleted blob.
777
- await purgeMediaBlobs(c.env.MEDIA, mediaKeys);
778
-
779
- // The Delete must reach everyone the original object reached, not just the
780
- // author's current followers: mirror the object's stored to/cc, and emit a
781
- // Tombstone object (per AP) instead of a bare IRI so receivers can render
782
- // the deletion correctly.
783
- const originalTo = safeJsonParse<string[]>(post.toJson, []);
784
- const originalCc = safeJsonParse<string[]>(post.ccJson, []);
785
-
786
- // For a reply, the parent author's instance must also be told (it counts the
787
- // reply); for a direct post, the DM recipients are exactly the addressed
788
- // actors. Collect explicit (actor-IRI) recipients for direct per-actor
789
- // delivery — anything that is not a Public/collection IRI is treated as an
790
- // actor inbox target if it is a safe remote URL.
791
- const explicitRecipients = new Set<string>();
792
- for (const iri of [...originalTo, ...originalCc]) {
793
- if (
794
- iri &&
795
- iri !== PUBLIC_COLLECTION &&
796
- !iri.endsWith("/followers") &&
797
- isLocal(iri, baseUrl) === false &&
798
- isSafeRemoteUrl(iri)
799
- ) {
800
- explicitRecipients.add(iri);
801
- }
802
- }
803
-
804
- // Parent author's instance (replies): ensure the reply deletion propagates
805
- // to the thread root's host even if it was not in the object's to/cc.
806
- if (post.inReplyTo) {
807
- const parent = await db
808
- .select({ attributedTo: objects.attributedTo })
809
- .from(objects)
810
- .where(eq(objects.apId, post.inReplyTo))
811
- .get();
812
- if (
813
- parent?.attributedTo &&
814
- !isLocal(parent.attributedTo, baseUrl) &&
815
- isSafeRemoteUrl(parent.attributedTo)
816
- ) {
817
- explicitRecipients.add(parent.attributedTo);
818
- }
819
- }
820
-
821
- const deleteActivity = {
822
- "@context": "https://www.w3.org/ns/activitystreams",
823
- id: activityApId(baseUrl, generateId()),
824
- type: "Delete",
825
- actor: actor.ap_id,
826
- to: originalTo,
827
- cc: originalCc,
828
- object: {
829
- id: post.apId,
830
- type: "Tombstone",
831
- },
832
- };
833
-
834
- // Fan out matching the original create reach (community → the community, not
835
- // the author's personal followers) and additionally deliver directly to each
836
- // explicitly-addressed remote actor.
837
- if (post.communityApId) {
838
- await persistAndFanoutToCommunity(
839
- db,
840
- c.env,
841
- deleteActivity,
842
- post.apId,
843
- post.communityApId,
844
- );
845
- } else {
846
- await persistAndFanout(db, c.env, deleteActivity, post.apId);
847
- }
848
-
849
- for (const recipient of explicitRecipients) {
850
- try {
851
- await enqueueDeliveryToActor(c.env, deleteActivity.id, recipient);
852
- } catch (err) {
853
- log.error("Failed to enqueue delete delivery", {
854
- event: "posts.delete.delivery_enqueue_failed",
855
- activityId: deleteActivity.id,
856
- recipient,
857
- error: err,
858
- });
859
- }
860
- }
699
+ await purgeMediaBlobs(c.env.MEDIA, cascade.mediaKeys);
700
+ await preparedFederation.complete();
861
701
 
862
702
  return c.json({ success: true });
863
703
  });