@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
@@ -9,11 +9,13 @@ import {
9
9
  isNull,
10
10
  or,
11
11
  sql,
12
+ type SQLWrapper,
12
13
  } from "drizzle-orm";
13
14
  import {
14
15
  communities,
15
16
  communityJoinRequests,
16
17
  communityMembers,
18
+ follows,
17
19
  mediaUploads,
18
20
  objects,
19
21
  } from "../../../db/index.ts";
@@ -32,6 +34,7 @@ import {
32
34
  } from "./membership-shared.ts";
33
35
  import { isUniqueConstraintError } from "../../lib/parse-helpers.ts";
34
36
  import { communityRequiresMembership } from "../../lib/community-visibility.ts";
37
+ import { operatorActorNotBlockedSql } from "../../lib/blocklist.ts";
35
38
  import { reapReplacedMediaUrl } from "../posts/delete-cascade.ts";
36
39
 
37
40
  /**
@@ -46,6 +49,32 @@ type Batchable = {
46
49
 
47
50
  const communitiesRouter = new Hono<{ Bindings: Env; Variables: Variables }>();
48
51
 
52
+ /**
53
+ * Public member count = role-bearing local memberships + accepted Follow-backed
54
+ * memberships. `communities.memberCount` intentionally remains the local-row
55
+ * denormalization; federated authority already lives in `follows`, so reads
56
+ * compose the two sources without adding a second mutable counter lifecycle.
57
+ */
58
+ function projectedCommunityMemberCountSql(
59
+ communityApIdVal: string | SQLWrapper,
60
+ ) {
61
+ return sql<number>`
62
+ ${communities.memberCount} + (
63
+ SELECT COUNT(*)
64
+ FROM ${follows}
65
+ WHERE ${follows.followingApId} = ${communityApIdVal}
66
+ AND ${follows.status} = 'accepted'
67
+ AND ${operatorActorNotBlockedSql(sql`${follows.followerApId}`)}
68
+ AND NOT EXISTS (
69
+ SELECT 1
70
+ FROM ${communityMembers}
71
+ WHERE ${communityMembers.communityApId} = ${communityApIdVal}
72
+ AND ${communityMembers.actorApId} = ${follows.followerApId}
73
+ )
74
+ )
75
+ `;
76
+ }
77
+
49
78
  function isValidCommunityIconUrl(value: string): boolean {
50
79
  const trimmed = value.trim();
51
80
  if (trimmed.startsWith("/media/")) return true;
@@ -216,7 +245,7 @@ communitiesRouter.get("/", async (c) => {
216
245
  visibility: communities.visibility,
217
246
  joinPolicy: communities.joinPolicy,
218
247
  postPolicy: communities.postPolicy,
219
- memberCount: communities.memberCount,
248
+ memberCount: projectedCommunityMemberCountSql(communities.apId),
220
249
  createdAt: communities.createdAt,
221
250
  lastMessageAt: communities.lastMessageAt,
222
251
  })
@@ -460,19 +489,25 @@ communitiesRouter.get("/:identifier", async (c) => {
460
489
  const restricted =
461
490
  communityRequiresMembership(community.visibility) && !isMember;
462
491
 
463
- // member_count is the maintained `communities.memberCount` column (kept atomic
464
- // by addMemberAtomic/removeMemberAtomic). post_count has no denorm column, so it
465
- // is counted (indexed range via objects_comm_published_idx) but only when the
466
- // viewer is allowed to see it.
467
- const postCount = restricted
468
- ? 0
469
- : (
470
- await db
492
+ // `communities.memberCount` is the local-row denormalization. Compose it with
493
+ // accepted Follow-backed membership at read time, while preserving private
494
+ // community redaction. Post count similarly remains an indexed projection.
495
+ const [memberCountRow, postCountRow] = restricted
496
+ ? [{ count: 0 }, { count: 0 }]
497
+ : await Promise.all([
498
+ db
499
+ .select({
500
+ count: projectedCommunityMemberCountSql(community.apId),
501
+ })
502
+ .from(communities)
503
+ .where(eq(communities.apId, community.apId))
504
+ .get(),
505
+ db
471
506
  .select({ count: count() })
472
507
  .from(objects)
473
508
  .where(eq(objects.communityApId, community.apId))
474
- .get()
475
- )?.count || 0;
509
+ .get(),
510
+ ]);
476
511
 
477
512
  return c.json({
478
513
  community: {
@@ -484,8 +519,8 @@ communitiesRouter.get("/:identifier", async (c) => {
484
519
  visibility: community.visibility,
485
520
  join_policy: community.joinPolicy,
486
521
  post_policy: community.postPolicy,
487
- member_count: restricted ? 0 : community.memberCount || 0,
488
- post_count: postCount,
522
+ member_count: memberCountRow?.count || 0,
523
+ post_count: postCountRow?.count || 0,
489
524
  created_by: restricted ? null : community.createdBy,
490
525
  created_at: community.createdAt,
491
526
  is_member: isMember,
@@ -1,18 +1,7 @@
1
1
  // GET /contacts - List DM contacts and communities
2
2
 
3
3
  import { Hono } from "hono";
4
- import {
5
- and,
6
- count,
7
- desc,
8
- eq,
9
- inArray,
10
- isNotNull,
11
- isNull,
12
- ne,
13
- or,
14
- sql,
15
- } from "drizzle-orm";
4
+ import { and, count, desc, eq, inArray, isNull, ne, sql } from "drizzle-orm";
16
5
  import {
17
6
  communities,
18
7
  communityMembers,
@@ -23,10 +12,15 @@ import {
23
12
  } from "../../../db/index.ts";
24
13
  import { formatUsername } from "../../federation-helpers.ts";
25
14
  import { chunkForInClause } from "../../lib/chunk.ts";
15
+ import {
16
+ isActorBlockedStrict,
17
+ operatorActorNotBlockedSql,
18
+ } from "../../lib/blocklist.ts";
26
19
  import { yurumeUnreadCounts } from "../../lib/unread-counts.ts";
27
20
  import {
28
21
  buildActorInfoMap,
29
22
  byTimeDesc,
23
+ dmWhereForActor,
30
24
  findRepliedConversations,
31
25
  formatActorProfile,
32
26
  groupConversations,
@@ -42,31 +36,10 @@ contacts.get("/contacts", async (c) => {
42
36
  if (!actor) return c.json({ error: "Unauthorized" }, 401);
43
37
  const db = c.get("db");
44
38
 
45
- // DMs where this actor is the recipient are addressed via the
46
- // `object_recipients` index (`recipient_ap_id = <actor>`, `type = 'to'`),
47
- // written on every inbound/outbound DM whose recipient is this local actor
48
- // (see dm/messages.ts, takos-tools/dm.ts, inbox-content-handlers.ts). Using
49
- // that index instead of an unindexable `to_json LIKE '%"<apId>"%'` substring
50
- // scan keeps the same membership semantics (recipient OR author) without a
51
- // full-table scan.
52
- const recipientObjectIds = db
53
- .select({ objectApId: objectRecipients.objectApId })
54
- .from(objectRecipients)
55
- .where(
56
- and(
57
- eq(objectRecipients.recipientApId, actor.ap_id),
58
- eq(objectRecipients.type, "to"),
59
- ),
60
- );
61
- const dmWhere = and(
62
- eq(objects.visibility, "direct"),
63
- eq(objects.type, "Note"),
64
- isNotNull(objects.conversation),
65
- or(
66
- eq(objects.attributedTo, actor.ap_id),
67
- inArray(objects.apId, recipientObjectIds),
68
- ),
69
- );
39
+ // Shared indexed DM membership + operator-block predicate. Keeping the
40
+ // counterpart decision in one helper makes contacts, archived threads, and
41
+ // Takos tool threads agree for both incoming and outgoing messages.
42
+ const dmWhere = dmWhereForActor(db, actor.ap_id);
70
43
 
71
44
  // GET must be side-effect-free: this handler no longer prunes orphaned
72
45
  // dm_read_status rows. Read-status cleanup happens on the write paths that
@@ -154,6 +127,7 @@ contacts.get("/contacts", async (c) => {
154
127
  eq(objects.visibility, "direct"),
155
128
  ne(objects.attributedTo, actor.ap_id),
156
129
  sql`${objects.published} > ${lastReadAt}`,
130
+ operatorActorNotBlockedSql(sql`${objects.attributedTo}`),
157
131
  ),
158
132
  )
159
133
  .groupBy(objects.conversation);
@@ -205,7 +179,12 @@ contacts.get("/contacts", async (c) => {
205
179
  communities,
206
180
  eq(communityMembers.communityApId, communities.apId),
207
181
  )
208
- .where(eq(communityMembers.actorApId, actor.ap_id));
182
+ .where(
183
+ and(
184
+ eq(communityMembers.actorApId, actor.ap_id),
185
+ isNull(communities.deletedAt),
186
+ ),
187
+ );
209
188
 
210
189
  // Batch get the last CHAT message for all communities to avoid N+1.
211
190
  //
@@ -250,6 +229,7 @@ contacts.get("/contacts", async (c) => {
250
229
  eq(objectRecipients.type, "audience"),
251
230
  eq(objects.type, "Note"),
252
231
  isNull(objects.communityApId),
232
+ operatorActorNotBlockedSql(sql`${objects.attributedTo}`),
253
233
  ),
254
234
  )
255
235
  .orderBy(desc(objects.published))
@@ -301,6 +281,7 @@ contacts.get("/contacts", async (c) => {
301
281
  ON r.community_ap_id = cm.community_ap_id
302
282
  AND r.actor_ap_id = ${me}
303
283
  WHERE cm.actor_ap_id = ${me}
284
+ AND ${operatorActorNotBlockedSql(sql.raw("o.attributed_to"))}
304
285
  AND o.published > COALESCE(r.last_read_at, cm.joined_at, '1970-01-01T00:00:00Z')
305
286
  GROUP BY cm.community_ap_id
306
287
  `);
@@ -315,7 +296,10 @@ contacts.get("/contacts", async (c) => {
315
296
  return {
316
297
  type: "community" as const,
317
298
  ap_id: cm.community.apId,
318
- username: formatUsername(cm.community.apId),
299
+ username: formatUsername(
300
+ cm.community.apId,
301
+ cm.community.preferredUsername,
302
+ ),
319
303
  preferred_username: cm.community.preferredUsername,
320
304
  name: cm.community.name,
321
305
  icon_url: cm.community.iconUrl,
@@ -356,6 +340,7 @@ contacts.get("/contacts", async (c) => {
356
340
  eq(objects.type, "Note"),
357
341
  eq(objectRecipients.recipientApId, actor.ap_id),
358
342
  eq(objectRecipients.type, "to"),
343
+ operatorActorNotBlockedSql(sql`${objects.attributedTo}`),
359
344
  ),
360
345
  );
361
346
 
@@ -402,7 +387,7 @@ contacts.get("/contact/:encodedApId", async (c) => {
402
387
  visibility: communities.visibility,
403
388
  })
404
389
  .from(communities)
405
- .where(eq(communities.apId, apId))
390
+ .where(and(eq(communities.apId, apId), isNull(communities.deletedAt)))
406
391
  .get();
407
392
 
408
393
  if (community) {
@@ -427,7 +412,7 @@ contacts.get("/contact/:encodedApId", async (c) => {
427
412
  contact: {
428
413
  type: "community" as const,
429
414
  ap_id: community.apId,
430
- username: formatUsername(community.apId),
415
+ username: formatUsername(community.apId, community.preferredUsername),
431
416
  preferred_username: community.preferredUsername,
432
417
  name: community.name,
433
418
  icon_url: community.iconUrl,
@@ -440,6 +425,9 @@ contacts.get("/contact/:encodedApId", async (c) => {
440
425
  }
441
426
 
442
427
  // Otherwise treat it as a user (local actor or cached remote actor).
428
+ if (await isActorBlockedStrict(db, apId)) {
429
+ return c.json({ error: "Contact not found" }, 404);
430
+ }
443
431
  const infoMap = await buildActorInfoMap(db, [apId]);
444
432
  const info = infoMap.get(apId);
445
433
  if (!info) return c.json({ error: "Contact not found" }, 404);
@@ -1,12 +1,13 @@
1
1
  // Shared helpers for DM conversations
2
2
 
3
3
  import type { Context } from "hono";
4
- import { and, eq, inArray, isNotNull, or } from "drizzle-orm";
4
+ import { and, eq, inArray, isNotNull, or, sql } from "drizzle-orm";
5
5
  import type { Database } from "../../../db/index.ts";
6
6
  import { objectRecipients, objects } from "../../../db/index.ts";
7
7
  import type { Env, Variables } from "../../types.ts";
8
8
  import { safeJsonParse } from "../../federation-helpers.ts";
9
9
  import { chunkForInClause } from "../../lib/chunk.ts";
10
+ import { operatorActorNotBlockedSql } from "../../lib/blocklist.ts";
10
11
 
11
12
  /**
12
13
  * Indexed lookup of the object AP-IDs a given actor was addressed on as a DM
@@ -75,6 +76,13 @@ export function dmWhereForActor(db: Database, actorApId: string) {
75
76
  eq(objects.attributedTo, actorApId),
76
77
  inArray(objects.apId, recipientObjectIds(db, actorApId)),
77
78
  ),
79
+ operatorActorNotBlockedSql(sql`
80
+ CASE
81
+ WHEN ${objects.attributedTo} = ${actorApId}
82
+ THEN json_extract(${objects.toJson}, '$[0]')
83
+ ELSE ${objects.attributedTo}
84
+ END
85
+ `),
78
86
  );
79
87
  }
80
88
 
@@ -3,7 +3,7 @@
3
3
  // Threading via conversation field
4
4
 
5
5
  import { Hono } from "hono";
6
- import { and, desc, eq, inArray, like } from "drizzle-orm";
6
+ import { and, desc, eq, inArray, like, or } from "drizzle-orm";
7
7
  import type { Database } from "../../../db/index.ts";
8
8
  import {
9
9
  activities,
@@ -34,6 +34,7 @@ import {
34
34
  MAX_DM_PAGE_LIMIT,
35
35
  resolveConversationId,
36
36
  } from "./query-helpers.ts";
37
+ import { recipientObjectIds } from "./conversations-helpers.ts";
37
38
  import { enqueueDeliveryToActor } from "../../lib/delivery/queue.ts";
38
39
  import {
39
40
  emitRealtimeBestEffort,
@@ -43,6 +44,8 @@ import { feedCursorWhere } from "../../lib/feed-cursor.ts";
43
44
  import { toApAttachments } from "../../lib/activitypub-helpers.ts";
44
45
  import { validateChatAttachments } from "../../lib/attachments.ts";
45
46
  import { logger } from "../../lib/logger.ts";
47
+ import { isActorBlockedStrict } from "../../lib/blocklist.ts";
48
+ import { deleteActivitiesCascade } from "../../lib/activity-delete-cascade.ts";
46
49
 
47
50
  const log = logger.child({ component: "dm.messages" });
48
51
 
@@ -81,12 +84,7 @@ type SenderInfo = {
81
84
  // on the 4s-polled endpoint).
82
85
  type DmMessageRow = Pick<
83
86
  typeof objects.$inferSelect,
84
- | "apId"
85
- | "attributedTo"
86
- | "content"
87
- | "attachmentsJson"
88
- | "published"
89
- | "toJson"
87
+ "apId" | "attributedTo" | "content" | "attachmentsJson" | "published"
90
88
  >;
91
89
 
92
90
  type DmMessageResponse = {
@@ -156,7 +154,7 @@ function buildSenderFromActor(actor: {
156
154
  }): SenderInfo {
157
155
  return {
158
156
  ap_id: actor.ap_id,
159
- username: formatUsername(actor.ap_id),
157
+ username: formatUsername(actor.ap_id, actor.preferred_username),
160
158
  preferred_username: actor.preferred_username,
161
159
  name: actor.name,
162
160
  icon_url: actor.icon_url,
@@ -177,12 +175,19 @@ async function fetchAuthorizedMessages(
177
175
  limit: number,
178
176
  before: string | undefined,
179
177
  ): Promise<{ rows: DmMessageRow[]; hasMore: boolean }> {
180
- // Build where clause: filter by conversation + visibility + type
181
- // Authorization is re-validated in code below (defense-in-depth)
178
+ // Authorization is part of the SQL set, not a post-query to_json filter.
179
+ // Hidden bto/bcc recipients intentionally do not appear in to_json; their
180
+ // indexed object_recipients link is the read authority used everywhere else
181
+ // in the DM subsystem. Keeping the predicate inside the query also makes the
182
+ // limit+1 pagination signal exact after authorization.
182
183
  const baseCondition = and(
183
184
  eq(objects.visibility, "direct"),
184
185
  eq(objects.type, "Note"),
185
186
  eq(objects.conversation, conversationId),
187
+ or(
188
+ eq(objects.attributedTo, actorApId),
189
+ inArray(objects.apId, recipientObjectIds(db, actorApId)),
190
+ ),
186
191
  );
187
192
 
188
193
  // Composite (published, apId) cursor so two messages sharing a published
@@ -203,24 +208,14 @@ async function fetchAuthorizedMessages(
203
208
  content: objects.content,
204
209
  attachmentsJson: objects.attachmentsJson,
205
210
  published: objects.published,
206
- toJson: objects.toJson,
207
211
  })
208
212
  .from(objects)
209
213
  .where(whereClause!)
210
214
  .orderBy(desc(objects.published), desc(objects.apId))
211
215
  .limit(limit + 1);
212
216
 
213
- // Defence-in-depth: re-validate authorization at the code level. (In practice
214
- // every row in the actor's own conversation passes, so the +1 reliably signals
215
- // an older page.)
216
- const authorized = messages.filter((msg) => {
217
- if (msg.attributedTo === actorApId) return true;
218
- const toRecipients = safeJsonParse<string[]>(msg.toJson, []);
219
- return toRecipients.includes(actorApId);
220
- });
221
-
222
- const hasMore = authorized.length > limit;
223
- return { rows: hasMore ? authorized.slice(0, limit) : authorized, hasMore };
217
+ const hasMore = messages.length > limit;
218
+ return { rows: hasMore ? messages.slice(0, limit) : messages, hasMore };
224
219
  }
225
220
 
226
221
  /** Build a map from ap_id -> actor info, checking local actors then cached actors. */
@@ -273,7 +268,7 @@ function formatMessages(
273
268
  id: msg.apId,
274
269
  sender: {
275
270
  ap_id: msg.attributedTo,
276
- username: formatUsername(msg.attributedTo),
271
+ username: formatUsername(msg.attributedTo, info?.preferredUsername),
277
272
  preferred_username: info?.preferredUsername || null,
278
273
  name: info?.name || null,
279
274
  icon_url: info?.iconUrl || null,
@@ -379,6 +374,9 @@ dm.get("/user/:encodedApId/messages", async (c) => {
379
374
 
380
375
  const db = c.get("db");
381
376
  const otherApId = decodeURIComponent(c.req.param("encodedApId"));
377
+ if (await isActorBlockedStrict(db, otherApId)) {
378
+ return c.json({ error: "User not found" }, 404);
379
+ }
382
380
  const limit = parseLimit(c.req.query("limit"), 50, MAX_DM_PAGE_LIMIT);
383
381
  const before = c.req.query("before");
384
382
  // Resolve to the STORED conversation id of an existing thread rather than
@@ -429,6 +427,13 @@ dm.post("/user/:encodedApId/messages", async (c) => {
429
427
 
430
428
  const db = c.get("db");
431
429
  const otherApId = decodeURIComponent(c.req.param("encodedApId"));
430
+ // Operator defederation is an authority boundary, not merely a late queue
431
+ // filter. Reject before recipient lookup, body work, conversation resolution,
432
+ // or any Note/Activity write so the API cannot claim a DM was sent when its
433
+ // delivery is guaranteed to be skipped.
434
+ if (await isActorBlockedStrict(db, otherApId)) {
435
+ return c.json({ error: "User not found" }, 404);
436
+ }
432
437
  const body = await c.req.json<{
433
438
  content?: string;
434
439
  attachments?: unknown;
@@ -708,25 +713,14 @@ dm.delete("/messages/:messageId", async (c) => {
708
713
  }
709
714
  const message = messageOrError;
710
715
 
711
- // Sequential operations (D1 doesn't support interactive transactions).
712
- // Also remove the delivery Create activity + the recipient's inbox row created
713
- // at send time (messages.ts send path). These tables are addressed by AP id
714
- // with no FK to `objects`, so deleting only the object orphans them — and
715
- // because the notifications query LEFT JOINs the now-missing object (a
716
- // deleted DM's object is gone → NULL visibility → not excluded as "direct"),
717
- // the orphan Create inbox row would resurface as a blank "mention"
718
- // notification with a dead /post link. Drop them first.
719
- const relatedActivities = await db
720
- .select({ apId: activities.apId })
721
- .from(activities)
722
- .where(eq(activities.objectApId, message.apId));
723
- const activityIds = relatedActivities.map((a) => a.apId);
724
- if (activityIds.length > 0) {
725
- await db
726
- .delete(inboxTable)
727
- .where(inArray(inboxTable.activityApId, activityIds));
728
- await db.delete(activities).where(inArray(activities.apId, activityIds));
729
- }
716
+ // Sequential object/media operations (D1 doesn't support interactive
717
+ // transactions). First atomically remove every delivery Create activity and
718
+ // its activity-keyed inbox/push/delivery/archive/claim projections. These
719
+ // tables are addressed by AP id with no object FK, so deleting only the object
720
+ // orphans them — and because the notifications query LEFT JOINs the now-missing
721
+ // object (NULL visibility → not excluded as "direct"), an orphan inbox row
722
+ // resurfaces as a blank "mention" with a dead /post link.
723
+ await deleteActivitiesCascade(db, eq(activities.objectApId, message.apId));
730
724
  // Reap the message's child rows AND any attached R2 blob + media_uploads row
731
725
  // via the shared cascade (covers objectRecipients + media + likes/announces/
732
726
  // bookmarks/story*), then drop the object. Local DMs are text-only today so
@@ -1,7 +1,7 @@
1
1
  // DM read status and archive management
2
2
 
3
3
  import { Hono } from "hono";
4
- import { and, desc, eq } from "drizzle-orm";
4
+ import { and, desc, eq, isNull } from "drizzle-orm";
5
5
  import {
6
6
  communities,
7
7
  communityMembers,
@@ -95,7 +95,9 @@ readArchive.post("/community/:encodedApId/read", async (c) => {
95
95
  const community = await db
96
96
  .select({ apId: communities.apId })
97
97
  .from(communities)
98
- .where(eq(communities.apId, communityApId))
98
+ .where(
99
+ and(eq(communities.apId, communityApId), isNull(communities.deletedAt)),
100
+ )
99
101
  .get();
100
102
  if (!community) return c.json({ error: "Community not found" }, 404);
101
103
 
@@ -1,14 +1,8 @@
1
1
  // DM requests - list, accept, reject
2
2
 
3
3
  import { Hono } from "hono";
4
- import { and, desc, eq, inArray, isNotNull, sql } from "drizzle-orm";
5
- import {
6
- activities,
7
- blocks,
8
- inbox as inboxTable,
9
- objectRecipients,
10
- objects,
11
- } from "../../../db/index.ts";
4
+ import { and, asc, desc, eq, gt, inArray, isNotNull, sql } from "drizzle-orm";
5
+ import { activities, blocks, objects } from "../../../db/index.ts";
12
6
  import { resolveConversationId } from "./query-helpers.ts";
13
7
  import {
14
8
  buildActorInfoMap,
@@ -17,6 +11,13 @@ import {
17
11
  type HonoEnv,
18
12
  recipientObjectIds,
19
13
  } from "./conversations-helpers.ts";
14
+ import { operatorActorNotBlockedSql } from "../../lib/blocklist.ts";
15
+ import { deleteActivitiesCascade } from "../../lib/activity-delete-cascade.ts";
16
+ import { D1_IN_CHUNK } from "../../lib/chunk.ts";
17
+ import {
18
+ deleteObjectsCascade,
19
+ purgeMediaBlobs,
20
+ } from "../posts/delete-cascade.ts";
20
21
 
21
22
  // Upper bound on the number of pending-request CONVERSATIONS returned. The list
22
23
  // is collapsed to one row per conversation in SQL (GROUP BY), so this bounds
@@ -58,6 +59,7 @@ requests.get("/requests", async (c) => {
58
59
  // instead of an unindexable `to_json LIKE '%"<apId>"%'` scan; same
59
60
  // recipient-membership semantics.
60
61
  inArray(objects.apId, recipientObjectIds(db, actor.ap_id)),
62
+ operatorActorNotBlockedSql(sql`${objects.attributedTo}`),
61
63
  ),
62
64
  )
63
65
  .groupBy(objects.conversation)
@@ -121,56 +123,62 @@ requests.post("/requests/reject", async (c) => {
121
123
  body.sender_ap_id,
122
124
  );
123
125
 
124
- // The set of the sender's messages in this conversation, as a reusable
125
- // SUBQUERY (never materialised into an `IN (...)`, which would blow D1's
126
- // 100-bound-parameter ceiling once a spammer has written >~100 messages).
126
+ // One exact owner predicate for every cleanup stage. A legacy/corrupt public
127
+ // row can share the conversation string; omitting the direct-Note scope from
128
+ // only the Activity/recipient subqueries would delete that survivor's
129
+ // projections while leaving its object behind.
130
+ const senderMessageWhere = (afterApId?: string) =>
131
+ and(
132
+ eq(objects.conversation, conversationId),
133
+ eq(objects.visibility, "direct"),
134
+ eq(objects.type, "Note"),
135
+ eq(objects.attributedTo, body.sender_ap_id),
136
+ afterApId ? gt(objects.apId, afterApId) : undefined,
137
+ );
138
+
139
+ // The complete sender message set stays a subquery here (never materialised
140
+ // into one unbounded `IN (...)`) so Activity hard deletion remains one
141
+ // constant-parameter atomic unit even after a spammer writes >100 messages.
127
142
  const senderObjectIds = db
128
143
  .select({ apId: objects.apId })
129
144
  .from(objects)
130
- .where(
131
- and(
132
- eq(objects.conversation, conversationId),
133
- eq(objects.attributedTo, body.sender_ap_id),
134
- ),
135
- );
145
+ .where(senderMessageWhere());
146
+
147
+ // Drop every delivery Create activity and its durable projections FIRST,
148
+ // before the objects vanish. These tables are addressed by AP id with no FK
149
+ // to `objects`, so deleting only the object orphans them — and the
150
+ // notifications query LEFT JOINs the now-missing object (gone → NULL
151
+ // visibility → no longer excluded as "direct"), so each orphan Create would
152
+ // resurface as a blank "mention" with a dead link plus an inflated unread
153
+ // badge while push/delivery workers churn. This mirrors DM message deletion.
154
+ await deleteActivitiesCascade(
155
+ db,
156
+ inArray(activities.objectApId, senderObjectIds),
157
+ );
136
158
 
137
- // Drop the inbox + delivery Create activities for those messages FIRST, before
138
- // the objects vanish. These tables are addressed by AP id with no FK to
139
- // `objects`, so deleting only the object orphans them and the notifications
140
- // query LEFT JOINs the now-missing object (gone NULL visibility no longer
141
- // excluded as "direct"), so each orphan Create would resurface as a blank
142
- // "mention" notification with a dead link plus an inflated unread badge. This
143
- // mirrors the DM message-delete cleanup (messages.ts).
144
- await db
145
- .delete(inboxTable)
146
- .where(
147
- inArray(
148
- inboxTable.activityApId,
149
- db
150
- .select({ apId: activities.apId })
151
- .from(activities)
152
- .where(inArray(activities.objectApId, senderObjectIds)),
153
- ),
154
- );
155
- await db
156
- .delete(activities)
157
- .where(inArray(activities.objectApId, senderObjectIds));
158
-
159
- // Delete the recipient rows for every message the sender wrote in this
160
- // conversation (same subquery-not-IN reasoning as above).
161
- await db
162
- .delete(objectRecipients)
163
- .where(inArray(objectRecipients.objectApId, senderObjectIds));
164
-
165
- await db
166
- .delete(objects)
167
- .where(
168
- and(
169
- eq(objects.conversation, conversationId),
170
- eq(objects.visibility, "direct"),
171
- eq(objects.attributedTo, body.sender_ap_id),
172
- ),
173
- );
159
+ // Drain object ids in D1-safe keyset pages. Each page routes through the same
160
+ // semantic child + durable projection + media owner as single-message
161
+ // deletion, then drops the objects and finally performs the irreversible R2
162
+ // purge. This avoids both an unbounded parameter list and loading a spammer's
163
+ // complete private history into isolate memory.
164
+ let cursor: string | undefined;
165
+ while (true) {
166
+ const page = await db
167
+ .select({ apId: objects.apId })
168
+ .from(objects)
169
+ .where(senderMessageWhere(cursor))
170
+ .orderBy(asc(objects.apId))
171
+ .limit(D1_IN_CHUNK);
172
+ if (page.length === 0) break;
173
+
174
+ const apIds = page.map((row) => row.apId);
175
+ const mediaKeys = await deleteObjectsCascade(db, apIds, c.env.MEDIA);
176
+ await db.delete(objects).where(inArray(objects.apId, apIds));
177
+ await purgeMediaBlobs(c.env.MEDIA, mediaKeys);
178
+
179
+ cursor = apIds.at(-1);
180
+ if (!cursor || page.length < D1_IN_CHUNK) break;
181
+ }
174
182
 
175
183
  if (body.block) {
176
184
  await db