@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
@@ -5,17 +5,34 @@
5
5
  * yurucommu_like_post, yurucommu_bookmark_post
6
6
  */
7
7
 
8
- import { and, count, eq, gt } from "drizzle-orm";
8
+ import { and, eq, gt, or } from "drizzle-orm";
9
9
  import { sql } from "drizzle-orm";
10
- import { actors, bookmarks, likes, objects } from "../../../db/index.ts";
10
+ import {
11
+ actors,
12
+ bookmarks,
13
+ objects,
14
+ runBatch,
15
+ type D1Statement,
16
+ } from "../../../db/index.ts";
17
+ import { objectApId } from "../../federation-helpers.ts";
11
18
  import {
12
19
  actorIsBlockedBy,
13
20
  canViewerReadObjectFull,
14
21
  } from "../../lib/post-visibility.ts";
22
+ import {
23
+ prepareObjectDeleteCascade,
24
+ purgeMediaBlobs,
25
+ } from "../posts/delete-cascade.ts";
15
26
  import {
16
27
  MAX_POST_CONTENT_LENGTH,
17
28
  normalizeVisibility,
18
29
  } from "../posts/transformers.ts";
30
+ import {
31
+ prepareCreatedPostFederation,
32
+ prepareDeletedPostFederation,
33
+ } from "../posts/federation.ts";
34
+ import { setPostLike } from "../posts/like-mutation.ts";
35
+ import { preparePostInsertStatements } from "../posts/post-helpers.ts";
19
36
  import {
20
37
  errAuth,
21
38
  errNotFound,
@@ -28,9 +45,20 @@ import {
28
45
  } from "../takos-tools-response.ts";
29
46
  import type { Input, ToolContext } from "./types.ts";
30
47
 
31
- // `.batch` lives only on the concrete D1/libsql subclasses; reach it through a
32
- // narrow structural cast so the object write + postCount update commit together.
33
- type Batchable = { batch: (stmts: unknown[]) => Promise<unknown> };
48
+ /**
49
+ * Tool results expose both a full `ap_id` and a compact `post_id`, so every
50
+ * tool mutation must accept either form. Keep the historical `/ap/notes/:id`
51
+ * candidate too: older versions minted tool posts under that non-canonical
52
+ * path, and their returned compact IDs must remain usable for cleanup.
53
+ */
54
+ function toolPostWhereByIdOrApId(baseUrl: string, postId: string) {
55
+ const normalizedBase = baseUrl.replace(/\/+$/u, "");
56
+ return or(
57
+ eq(objects.apId, postId),
58
+ eq(objects.apId, objectApId(normalizedBase, postId)),
59
+ eq(objects.apId, `${normalizedBase}/ap/notes/${postId}`),
60
+ );
61
+ }
34
62
 
35
63
  export async function handleCreatePost(
36
64
  c: ToolContext,
@@ -44,7 +72,11 @@ export async function handleCreatePost(
44
72
  // Constrain visibility to the canonical enum (unknown → "public"), matching
45
73
  // the web post route; a raw value would be invisible to every feed filter.
46
74
  const visibility = normalizeVisibility(String(input.visibility || "public"));
47
- const inReplyTo = input.in_reply_to ? String(input.in_reply_to) : null;
75
+ const requestedInReplyTo = input.in_reply_to
76
+ ? String(input.in_reply_to)
77
+ : null;
78
+ let inReplyTo: string | null = null;
79
+ let parentAuthor: string | null = null;
48
80
 
49
81
  if (!content) return c.json(errRequired("Content"), 400);
50
82
  // Enforce the same content cap as the canonical post route so this MCP path
@@ -62,9 +94,10 @@ export async function handleCreatePost(
62
94
  // private-community), bumping that restricted parent's reply_count and exposing
63
95
  // its existence via the stored in_reply_to (a privacy oracle), or reply to a
64
96
  // parent whose author blocked the actor (block bypass + notification).
65
- if (inReplyTo) {
97
+ if (requestedInReplyTo) {
66
98
  const parent = await db
67
99
  .select({
100
+ apId: objects.apId,
68
101
  attributedTo: objects.attributedTo,
69
102
  visibility: objects.visibility,
70
103
  toJson: objects.toJson,
@@ -75,7 +108,7 @@ export async function handleCreatePost(
75
108
  endTime: objects.endTime,
76
109
  })
77
110
  .from(objects)
78
- .where(eq(objects.apId, inReplyTo))
111
+ .where(toolPostWhereByIdOrApId(c.env.APP_URL, requestedInReplyTo))
79
112
  .get();
80
113
  if (
81
114
  !parent ||
@@ -87,54 +120,58 @@ export async function handleCreatePost(
87
120
  404,
88
121
  );
89
122
  }
123
+ // Persist the resolved canonical/full AP identifier, never the compact
124
+ // caller input, so reply traversal and counter recomputation share one key.
125
+ inReplyTo = parent.apId;
126
+ parentAuthor = parent.attributedTo;
90
127
  }
91
128
 
92
129
  const postId = crypto.randomUUID();
93
130
  const now = new Date().toISOString();
94
- const apId = `${c.env.APP_URL}/ap/notes/${postId}`;
131
+ const apId = objectApId(c.env.APP_URL.replace(/\/+$/u, ""), postId);
95
132
 
96
- // Co-commit the Note + author postCount bump (+ parent replyCount recompute for
97
- // a reply) atomically (no drift on a mid-write failure). Direct posts do NOT
98
- // count toward postCount (mirror the canonical create/delete + DM paths).
99
- const stmts: unknown[] = [
100
- db.insert(objects).values({
101
- apId,
102
- type: "Note",
103
- attributedTo: actor.ap_id,
104
- content,
105
- summary: null,
106
- attachmentsJson: "[]",
107
- inReplyTo,
108
- visibility,
109
- likeCount: 0,
110
- replyCount: 0,
111
- announceCount: 0,
112
- shareCount: 0,
113
- published: now,
114
- isLocal: 1,
115
- }),
116
- ];
117
- if (visibility !== "direct") {
118
- stmts.push(
119
- db
120
- .update(actors)
121
- .set({ postCount: sql`${actors.postCount} + 1` })
122
- .where(eq(actors.apId, actor.ap_id)),
123
- );
124
- }
125
- if (inReplyTo) {
126
- stmts.push(
127
- db
128
- .update(objects)
129
- .set({
130
- replyCount: sql`(SELECT COUNT(*) FROM ${objects} WHERE ${objects.inReplyTo} = ${inReplyTo})`,
131
- })
132
- .where(eq(objects.apId, inReplyTo)),
133
- );
134
- }
135
- await (db as unknown as Batchable).batch(stmts);
133
+ const preparedFederation = await prepareCreatedPostFederation({
134
+ db,
135
+ env: c.env,
136
+ actorApId: actor.ap_id,
137
+ objectApId: apId,
138
+ content,
139
+ summary: null,
140
+ inReplyTo,
141
+ parentAuthor,
142
+ visibility,
143
+ community: null,
144
+ published: now,
145
+ });
146
+ const localStatements = preparePostInsertStatements(db, {
147
+ apId,
148
+ actorApId: actor.ap_id,
149
+ content,
150
+ summary: null,
151
+ attachments: undefined,
152
+ inReplyTo,
153
+ visibility,
154
+ communityId: null,
155
+ to: preparedFederation.to,
156
+ cc: preparedFederation.cc,
157
+ audience: preparedFederation.audience,
158
+ tags: preparedFederation.tags,
159
+ parentAuthor,
160
+ baseUrl: c.env.APP_URL,
161
+ now,
162
+ });
163
+ await runBatch(db, [...localStatements, ...preparedFederation.statements]);
164
+ const { mentionFailures } = await preparedFederation.complete();
136
165
 
137
- return c.json(ok({ post_id: postId, ap_id: apId }));
166
+ return c.json(
167
+ ok({
168
+ post_id: postId,
169
+ ap_id: apId,
170
+ ...(mentionFailures.length > 0
171
+ ? { mention_processing: { failed_count: mentionFailures.length } }
172
+ : {}),
173
+ }),
174
+ );
138
175
  }
139
176
 
140
177
  export async function handleDeletePost(
@@ -149,9 +186,21 @@ export async function handleDeletePost(
149
186
  if (!postId) return c.json(errRequired("Post ID"), 400);
150
187
 
151
188
  const post = await db
152
- .select({ apId: objects.apId })
189
+ .select({
190
+ apId: objects.apId,
191
+ inReplyTo: objects.inReplyTo,
192
+ visibility: objects.visibility,
193
+ communityApId: objects.communityApId,
194
+ toJson: objects.toJson,
195
+ ccJson: objects.ccJson,
196
+ })
153
197
  .from(objects)
154
- .where(and(eq(objects.apId, postId), eq(objects.attributedTo, actor.ap_id)))
198
+ .where(
199
+ and(
200
+ toolPostWhereByIdOrApId(c.env.APP_URL, postId),
201
+ eq(objects.attributedTo, actor.ap_id),
202
+ ),
203
+ )
155
204
  .get();
156
205
  if (!post) {
157
206
  return c.json(
@@ -163,25 +212,61 @@ export async function handleDeletePost(
163
212
  );
164
213
  }
165
214
 
215
+ const [cascade, preparedFederation] = await Promise.all([
216
+ prepareObjectDeleteCascade(db, post.apId, c.env.MEDIA),
217
+ prepareDeletedPostFederation({
218
+ db,
219
+ env: c.env,
220
+ actorApId: actor.ap_id,
221
+ post,
222
+ }),
223
+ ]);
224
+
166
225
  // #COUNTER-SYM: gate the decrement on the object STILL existing (correlated
167
226
  // EXISTS) and run it BEFORE the delete, so two concurrent/retried deletes of
168
227
  // the same post can't double-decrement postCount — the second batch's EXISTS
169
228
  // is false (the first already deleted the row) → its -1 matches 0 rows. gt(>0)
170
229
  // guards underflow. Mirrors the canonical web delete path (posts/routes.ts).
171
- const objectExists = sql`EXISTS (SELECT 1 FROM ${objects} WHERE ${objects.apId} = ${postId})`;
172
- await (db as unknown as Batchable).batch([
173
- db
174
- .update(actors)
175
- .set({ postCount: sql`${actors.postCount} - 1` })
176
- .where(
177
- and(
178
- eq(actors.apId, actor.ap_id),
179
- gt(actors.postCount, 0),
180
- objectExists,
181
- ),
182
- ),
183
- db.delete(objects).where(eq(objects.apId, postId)),
184
- ]);
230
+ const objectExists = sql`EXISTS (SELECT 1 FROM ${objects} WHERE ${objects.apId} = ${post.apId})`;
231
+ const ops: D1Statement[] = [...cascade.statements];
232
+ // Direct messages never increment postCount, so their deletion must not
233
+ // decrement it. This keeps the tool path symmetric with canonical post/DM
234
+ // creation and deletion.
235
+ if (post.visibility !== "direct") {
236
+ ops.push(
237
+ db
238
+ .update(actors)
239
+ .set({ postCount: sql`${actors.postCount} - 1` })
240
+ .where(
241
+ and(
242
+ eq(actors.apId, actor.ap_id),
243
+ gt(actors.postCount, 0),
244
+ objectExists,
245
+ ),
246
+ ) as D1Statement,
247
+ );
248
+ }
249
+ ops.push(...preparedFederation.statements);
250
+ ops.push(
251
+ db.delete(objects).where(eq(objects.apId, post.apId)) as D1Statement,
252
+ );
253
+ if (post.inReplyTo) {
254
+ const parentId = post.inReplyTo;
255
+ ops.push(
256
+ db
257
+ .update(objects)
258
+ .set({
259
+ replyCount: sql`(SELECT COUNT(*) FROM ${objects} WHERE ${objects.inReplyTo} = ${parentId})`,
260
+ })
261
+ .where(eq(objects.apId, parentId)) as D1Statement,
262
+ );
263
+ }
264
+ await runBatch(db, ops as [D1Statement, ...D1Statement[]]);
265
+
266
+ // The irreversible external delete is last: failure leaks a blob instead of
267
+ // leaving a live post whose media has already been destroyed.
268
+ await purgeMediaBlobs(c.env.MEDIA, cascade.mediaKeys);
269
+ await preparedFederation.complete();
185
270
 
186
271
  return c.json(ok({ deleted: true }));
187
272
  }
@@ -216,54 +301,34 @@ export async function handleLikePost(
216
301
  endTime: objects.endTime,
217
302
  })
218
303
  .from(objects)
219
- .where(eq(objects.apId, postId))
304
+ .where(toolPostWhereByIdOrApId(c.env.APP_URL, postId))
220
305
  .get();
221
306
  // Block-gate too (the read-gate passes for any public post, so it alone does
222
307
  // not stop a blocked actor): an actor the author blocked must not bump the
223
308
  // author's like_count, mirroring the canonical like route (interactions.ts).
309
+ if (!post) {
310
+ return c.json(errNotFound("Post"), 404);
311
+ }
312
+ // Creating an interaction requires current read/moderation entitlement.
313
+ // Removing a retained interaction stays available by id after access is
314
+ // revoked, matching the canonical web unlike cleanup path.
224
315
  if (
225
- !post ||
226
- !(await canViewerReadObjectFull(db, post, actor.ap_id)) ||
227
- (await actorIsBlockedBy(db, post.attributedTo, actor.ap_id))
316
+ likeActive &&
317
+ (!(await canViewerReadObjectFull(db, post, actor.ap_id)) ||
318
+ (await actorIsBlockedBy(db, post.attributedTo, actor.ap_id)))
228
319
  ) {
229
320
  return c.json(errNotFound("Post"), 404);
230
321
  }
231
322
 
232
- // Co-commit the like-edge toggle AND the likeCount recompute in ONE atomic
233
- // batch (the canonical route does the same): a mid-request failure between the
234
- // toggle and the count UPDATE would otherwise leave likeCount diverged from the
235
- // likes table. The recompute is a COUNT(*) subquery so it is idempotent.
236
- const toggleStmt = likeActive
237
- ? db
238
- .insert(likes)
239
- .values({ actorApId: actor.ap_id, objectApId: post.apId })
240
- .onConflictDoNothing()
241
- : db
242
- .delete(likes)
243
- .where(
244
- and(
245
- eq(likes.actorApId, actor.ap_id),
246
- eq(likes.objectApId, post.apId),
247
- ),
248
- );
249
- await (db as unknown as Batchable).batch([
250
- toggleStmt,
251
- db
252
- .update(objects)
253
- .set({
254
- likeCount: sql`(SELECT COUNT(*) FROM ${likes} WHERE ${likes.objectApId} = ${post.apId})`,
255
- })
256
- .where(eq(objects.apId, post.apId)),
257
- ]);
323
+ const result = await setPostLike({
324
+ db,
325
+ env: c.env,
326
+ actorApId: actor.ap_id,
327
+ post,
328
+ active: likeActive,
329
+ });
258
330
 
259
- const likeCountResult = await db
260
- .select({ count: count() })
261
- .from(likes)
262
- .where(eq(likes.objectApId, post.apId))
263
- .get();
264
- const likeCount = likeCountResult?.count ?? 0;
265
-
266
- return c.json(ok({ liked: likeActive, like_count: likeCount }));
331
+ return c.json(ok({ liked: result.active, like_count: result.likeCount }));
267
332
  }
268
333
 
269
334
  export async function handleBookmarkPost(
@@ -280,12 +345,30 @@ export async function handleBookmarkPost(
280
345
  if (!postId) return c.json(errRequired("Post ID"), 400);
281
346
 
282
347
  const post = await db
283
- .select({ apId: objects.apId })
348
+ .select({
349
+ apId: objects.apId,
350
+ visibility: objects.visibility,
351
+ attributedTo: objects.attributedTo,
352
+ toJson: objects.toJson,
353
+ ccJson: objects.ccJson,
354
+ audienceJson: objects.audienceJson,
355
+ communityApId: objects.communityApId,
356
+ type: objects.type,
357
+ endTime: objects.endTime,
358
+ })
284
359
  .from(objects)
285
- .where(eq(objects.apId, postId))
360
+ .where(toolPostWhereByIdOrApId(c.env.APP_URL, postId))
286
361
  .get();
287
362
  if (!post) return c.json(errNotFound("Post"), 404);
288
363
 
364
+ if (
365
+ bookmark &&
366
+ (!(await canViewerReadObjectFull(db, post, actor.ap_id)) ||
367
+ (await actorIsBlockedBy(db, post.attributedTo, actor.ap_id)))
368
+ ) {
369
+ return c.json(errNotFound("Post"), 404);
370
+ }
371
+
289
372
  await togglePostRelation(db, bookmarks, actor.ap_id, post.apId, bookmark);
290
373
 
291
374
  return c.json(ok({ bookmarked: bookmark }));
@@ -10,7 +10,8 @@ import { sql } from "drizzle-orm";
10
10
  import { actors, objects } from "../../../db/index.ts";
11
11
 
12
12
  import { NO_AUDIENCE_PREDICATE } from "../../lib/community-visibility.ts";
13
- import { excludeBlockedMutedAuthors } from "../../lib/feed-exclude.ts";
13
+ import { excludeModeratedActors } from "../../lib/feed-exclude.ts";
14
+ import { operatorActorNotBlockedSql } from "../../lib/blocklist.ts";
14
15
  import { extractHashtags } from "../posts/transformers.ts";
15
16
  import { postContentSearchPredicate } from "../search.ts";
16
17
  import {
@@ -79,6 +80,7 @@ async function searchUsers(c: ToolContext, input: Input) {
79
80
  // otherwise a scrubbed `deleted-<id>` tombstone can surface here.
80
81
  isNull(actors.deletedAt),
81
82
  eq(actors.isPrivate, 0),
83
+ operatorActorNotBlockedSql(sql`${actors.apId}`),
82
84
  or(
83
85
  sql`instr(lower(${actors.preferredUsername}), lower(${query})) > 0`,
84
86
  sql`instr(lower(${actors.name}), lower(${query})) > 0`,
@@ -124,8 +126,8 @@ async function searchPosts(
124
126
  NO_AUDIENCE_PREDICATE,
125
127
  isNull(objects.deletedAt),
126
128
  // Honor the caller's block/mute filter, matching the web search +
127
- // feed surfaces (undefined for an anonymous caller → and() drops it).
128
- excludeBlockedMutedAuthors(db, actor?.ap_id ?? ""),
129
+ // feed surfaces, plus the global operator defederation boundary.
130
+ excludeModeratedActors(actor?.ap_id ?? ""),
129
131
  ),
130
132
  )
131
133
  .orderBy(desc(objects.published), desc(objects.apId))
@@ -158,6 +160,7 @@ async function getTrending(c: ToolContext, input: Input) {
158
160
  eq(objects.visibility, "public"),
159
161
  NO_AUDIENCE_PREDICATE,
160
162
  isNull(objects.deletedAt),
163
+ operatorActorNotBlockedSql(sql`${objects.attributedTo}`),
161
164
  sql`${objects.published} > ${sinceDate}`,
162
165
  ),
163
166
  )
@@ -206,7 +209,13 @@ async function getUserProfile(
206
209
  isPrivate: actors.isPrivate,
207
210
  })
208
211
  .from(actors)
209
- .where(eq(actors.preferredUsername, username))
212
+ .where(
213
+ and(
214
+ eq(actors.preferredUsername, username),
215
+ isNull(actors.deletedAt),
216
+ operatorActorNotBlockedSql(sql`${actors.apId}`),
217
+ ),
218
+ )
210
219
  .get();
211
220
 
212
221
  if (!actorRecord) return c.json(errNotFound("User"), 404);
@@ -5,9 +5,11 @@
5
5
  */
6
6
 
7
7
  import { and, desc, eq, inArray, isNull } from "drizzle-orm";
8
- import { actors, inbox, objects } from "../../../db/index.ts";
8
+ import { activities, actors, inbox, objects } from "../../../db/index.ts";
9
9
  import { NO_AUDIENCE_PREDICATE } from "../../lib/community-visibility.ts";
10
10
  import { encodeFeedCursor, feedCursorWhere } from "../../lib/feed-cursor.ts";
11
+ import { excludeModeratedActors } from "../../lib/feed-exclude.ts";
12
+ import { notificationEligibilityWhere } from "../../lib/notification-eligibility.ts";
11
13
  import {
12
14
  ACTOR_SUMMARY_COLUMNS,
13
15
  errAuth,
@@ -20,7 +22,7 @@ import type { Input, ToolContext } from "./types.ts";
20
22
  export async function handleGetTimeline(
21
23
  c: ToolContext,
22
24
  input: Input,
23
- _actor: { ap_id: string } | null,
25
+ actor: { ap_id: string } | null,
24
26
  ) {
25
27
  const db = c.get("db");
26
28
  const limit = toolLimit(input.limit, 20, 50);
@@ -30,6 +32,7 @@ export async function handleGetTimeline(
30
32
  eq(objects.visibility, "public"),
31
33
  NO_AUDIENCE_PREDICATE,
32
34
  isNull(objects.deletedAt),
35
+ excludeModeratedActors(actor?.ap_id ?? ""),
33
36
  ];
34
37
  // Composite (published, apId) cursor so agent pagination doesn't skip posts
35
38
  // sharing a published millisecond (see lib/feed-cursor.ts).
@@ -95,35 +98,40 @@ export async function handleGetNotifications(
95
98
  const limit = toolLimit(input.limit, 20, 50);
96
99
  const unreadOnly = Boolean(input.unread_only);
97
100
 
98
- // Use query API with relations for inbox + activity join
99
- const inboxEntries = await db.query.inbox.findMany({
100
- where: and(
101
- eq(inbox.actorApId, actor.ap_id),
102
- ...(unreadOnly ? [eq(inbox.read, 0)] : []),
103
- ),
104
- with: {
105
- activity: true,
106
- },
107
- orderBy: desc(inbox.createdAt),
108
- limit,
109
- });
110
-
111
- // Filter: activity must not be from self and must be one of the expected types
112
- const allowedTypes = new Set(["Follow", "Like", "Announce", "Create"]);
113
- const filtered = inboxEntries.filter(
114
- (entry) =>
115
- entry.activity &&
116
- entry.activity.actorApId !== actor.ap_id &&
117
- allowedTypes.has(entry.activity.type),
118
- );
101
+ // Apply the same eligibility owner as the web list/badge/realtime/push paths
102
+ // in SQL before the limit. A post-query filter both drifted from moderation
103
+ // policy and let blocked rows consume the agent's page budget.
104
+ const inboxEntries = await db
105
+ .select({
106
+ activityApId: inbox.activityApId,
107
+ activityType: activities.type,
108
+ activityActorApId: activities.actorApId,
109
+ activityObjectApId: activities.objectApId,
110
+ read: inbox.read,
111
+ createdAt: inbox.createdAt,
112
+ })
113
+ .from(inbox)
114
+ .innerJoin(activities, eq(inbox.activityApId, activities.apId))
115
+ .leftJoin(objects, eq(activities.objectApId, objects.apId))
116
+ .where(
117
+ and(
118
+ eq(inbox.actorApId, actor.ap_id),
119
+ ...(unreadOnly ? [eq(inbox.read, 0)] : []),
120
+ ...notificationEligibilityWhere(db, actor.ap_id, {
121
+ direct: "exclude",
122
+ }),
123
+ ),
124
+ )
125
+ .orderBy(desc(inbox.createdAt))
126
+ .limit(limit);
119
127
 
120
128
  return c.json(
121
129
  ok({
122
- notifications: filtered.map((entry) => ({
130
+ notifications: inboxEntries.map((entry) => ({
123
131
  id: entry.activityApId,
124
- type: entry.activity.type.toLowerCase(),
125
- from_actor: entry.activity.actorApId,
126
- object: entry.activity.objectApId,
132
+ type: entry.activityType.toLowerCase(),
133
+ from_actor: entry.activityActorApId,
134
+ object: entry.activityObjectApId,
127
135
  read: !!entry.read,
128
136
  created_at: entry.createdAt,
129
137
  })),
@@ -5,7 +5,7 @@
5
5
  * input validation, and common data operations.
6
6
  */
7
7
 
8
- import { and, eq, inArray } from "drizzle-orm";
8
+ import { and, eq, inArray, sql } from "drizzle-orm";
9
9
  import type { Database } from "../../db/index.ts";
10
10
  import { actors, bookmarks, follows, likes } from "../../db/index.ts";
11
11
  import {
@@ -13,6 +13,7 @@ import {
13
13
  parseLimit,
14
14
  safeJsonParse,
15
15
  } from "../federation-helpers.ts";
16
+ import { operatorActorNotBlockedSql } from "../lib/blocklist.ts";
16
17
 
17
18
  // ---------------------------------------------------------------------------
18
19
  // Types
@@ -77,7 +78,7 @@ export function ok(data: unknown): ToolResponse {
77
78
  export function formatActorSummary(a: ActorSummary): Record<string, unknown> {
78
79
  return {
79
80
  ap_id: a.apId,
80
- username: formatUsername(a.apId),
81
+ username: formatUsername(a.apId, a.preferredUsername),
81
82
  preferred_username: a.preferredUsername,
82
83
  name: a.name,
83
84
  icon_url: a.iconUrl,
@@ -162,6 +163,9 @@ export async function fetchFollowList(
162
163
  targetApId,
163
164
  ),
164
165
  eq(follows.status, "accepted"),
166
+ operatorActorNotBlockedSql(
167
+ sql`${isFollowers ? follows.followerApId : follows.followingApId}`,
168
+ ),
165
169
  ),
166
170
  )
167
171
  .limit(limit);
@@ -24,7 +24,7 @@ import {
24
24
  safeJsonParse,
25
25
  } from "../federation-helpers.ts";
26
26
  import { CacheTags, CacheTTL, withCache } from "../middleware/cache.ts";
27
- import { excludeBlockedMutedAuthors } from "../lib/feed-exclude.ts";
27
+ import { excludeModeratedActors } from "../lib/feed-exclude.ts";
28
28
  import { chunkForInClause } from "../lib/chunk.ts";
29
29
 
30
30
  // Match every other feed's page cap. Clamping to 90 (not 100) leaves headroom
@@ -302,7 +302,7 @@ function formatPost(
302
302
  type: p.type,
303
303
  author: {
304
304
  ap_id: p.attributedTo,
305
- username: formatUsername(p.attributedTo),
305
+ username: formatUsername(p.attributedTo, author.preferredUsername),
306
306
  preferred_username: author.preferredUsername,
307
307
  name: author.name,
308
308
  icon_url: author.iconUrl,
@@ -475,7 +475,7 @@ async function handleCommunityTimeline(
475
475
  return c.json({ error: "Not a community member" }, 403);
476
476
  }
477
477
 
478
- const excludeAuthors = excludeBlockedMutedAuthors(db, viewerApId);
478
+ const excludeAuthors = excludeModeratedActors(viewerApId);
479
479
 
480
480
  // This is the home "narrow to a community" filter: it shows the slice of the
481
481
  // viewer's reach that belongs to this community — (1) posts deliberately
@@ -565,7 +565,7 @@ timeline.get(
565
565
  const before = c.req.query("before");
566
566
  const viewerApId = actor?.ap_id || "";
567
567
 
568
- const excludeAuthors = excludeBlockedMutedAuthors(db, viewerApId);
568
+ const excludeAuthors = excludeModeratedActors(viewerApId);
569
569
 
570
570
  const base = [
571
571
  eq(objects.type, "Note"),
@@ -702,7 +702,7 @@ timeline.get("/following", async (c) => {
702
702
  const before = c.req.query("before");
703
703
  const viewerApId = actor.ap_id;
704
704
 
705
- const excludeAuthors = excludeBlockedMutedAuthors(db, viewerApId);
705
+ const excludeAuthors = excludeModeratedActors(viewerApId);
706
706
 
707
707
  // Own posts: all visibilities except direct
708
708
  // Followed users' posts: public, unlisted, or followers visibility