@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
@@ -11,6 +11,7 @@ import {
11
11
  follows,
12
12
  inbox as inboxTable,
13
13
  likes,
14
+ objectRecipients,
14
15
  objects,
15
16
  } from "../../../db/index.ts";
16
17
  import { and, desc, eq, gt, inArray, or, sql } from "drizzle-orm";
@@ -38,6 +39,11 @@ import {
38
39
  passesPostVisibilitySync,
39
40
  } from "../../lib/post-visibility.ts";
40
41
  import { logger } from "../../lib/logger.ts";
42
+ import { excludeModeratedActors } from "../../lib/feed-exclude.ts";
43
+ import { isActorBlockedStrict } from "../../lib/blocklist.ts";
44
+ import { activityDeleteCascadeStatements } from "../../lib/activity-delete-cascade.ts";
45
+ import { setPostLike } from "./like-mutation.ts";
46
+ import { prepareDeliveryFanoutJob } from "../../lib/delivery/fanout-outbox.ts";
41
47
 
42
48
  const log = logger.child({ component: "posts.interactions" });
43
49
 
@@ -113,6 +119,29 @@ async function deliverToRemote(
113
119
  }
114
120
  }
115
121
 
122
+ async function publishDurableFollowerFanoutWakeup(
123
+ env: Env,
124
+ activityId: string,
125
+ actorApId: string,
126
+ operation: "repost" | "unrepost",
127
+ ): Promise<void> {
128
+ try {
129
+ await enqueueFanoutToFollowers(env, activityId, actorApId);
130
+ } catch (error) {
131
+ // The route has already committed the interaction, Activity, and fanout
132
+ // intent atomically. A Queue RPC is only a wakeup; leave the row pending so
133
+ // scheduled/startup recovery can publish it without turning a successful
134
+ // user mutation into a misleading HTTP 500.
135
+ log.error("Failed to publish durable repost fanout wakeup", {
136
+ event: "posts.repost.fanout_wakeup_failed",
137
+ operation,
138
+ activityId,
139
+ actor: actorApId,
140
+ error,
141
+ });
142
+ }
143
+ }
144
+
116
145
  /**
117
146
  * Atomic multi-statement commit.
118
147
  *
@@ -148,7 +177,6 @@ posts.post("/:id/like", async (c) => {
148
177
  if (!post) return c.json({ error: "Post not found" }, 404);
149
178
 
150
179
  const db = c.get("db");
151
- const baseUrl = c.env.APP_URL;
152
180
 
153
181
  // Only a post the actor can actually read may be liked: without this an authed
154
182
  // user who learns a post's apId could "like" a followers-only / direct /
@@ -165,77 +193,14 @@ posts.post("/:id/like", async (c) => {
165
193
  return c.json({ error: "Post not found" }, 404);
166
194
  }
167
195
 
168
- const existingLike = await db
169
- .select({ actorApId: likes.actorApId })
170
- .from(likes)
171
- .where(
172
- and(eq(likes.actorApId, actor.ap_id), eq(likes.objectApId, post.apId)),
173
- )
174
- .get();
175
- if (existingLike) return c.json({ error: "Already liked" }, 400);
176
-
177
- const likeId = generateId();
178
- const likeActivityId = activityApId(baseUrl, likeId);
179
- const likeActivityRaw = {
180
- "@context": "https://www.w3.org/ns/activitystreams",
181
- id: likeActivityId,
182
- type: "Like",
183
- actor: actor.ap_id,
184
- object: post.apId,
185
- };
186
- const now = new Date().toISOString();
187
- const shouldNotifyLocal =
188
- post.attributedTo !== actor.ap_id && isLocal(post.attributedTo, baseUrl);
189
-
190
- // D1 has no interactive transactions; group the child-row insert, counter
191
- // bump, activity row, and (optional) inbox row into a single atomic batch so
192
- // the like row and likeCount can never diverge on a mid-request failure.
193
- const likeStatements: BatchStatement[] = [
194
- db.insert(likes).values({
195
- actorApId: actor.ap_id,
196
- objectApId: post.apId,
197
- activityApId: likeActivityId,
198
- }),
199
- db
200
- .update(objects)
201
- .set({ likeCount: sql`${objects.likeCount} + 1` })
202
- .where(eq(objects.apId, post.apId)),
203
- db.insert(activities).values({
204
- apId: likeActivityId,
205
- type: "Like",
206
- actorApId: actor.ap_id,
207
- objectApId: post.apId,
208
- rawJson: JSON.stringify(likeActivityRaw),
209
- createdAt: now,
210
- }),
211
- ];
212
-
213
- if (shouldNotifyLocal) {
214
- likeStatements.push(
215
- db.insert(inboxTable).values({
216
- actorApId: post.attributedTo,
217
- activityApId: likeActivityId,
218
- read: 0,
219
- createdAt: now,
220
- }),
221
- );
222
- }
223
-
224
- try {
225
- await runBatch(db, likeStatements as [BatchStatement, ...BatchStatement[]]);
226
- } catch (e) {
227
- // Two concurrent likes (two tabs / a retried slow request) both pass the
228
- // SELECT above; the loser's composite-PK insert hits a UNIQUE constraint.
229
- // That is the idempotent "already liked" case, not a 500.
230
- if (isUniqueConstraintError(e)) {
231
- return c.json({ error: "Already liked" }, 400);
232
- }
233
- throw e;
234
- }
235
-
236
- if (!isLocal(post.apId, baseUrl)) {
237
- await deliverToRemote(c.env, likeActivityId, post.attributedTo);
238
- }
196
+ const result = await setPostLike({
197
+ db,
198
+ env: c.env,
199
+ actorApId: actor.ap_id,
200
+ post,
201
+ active: true,
202
+ });
203
+ if (!result.changed) return c.json({ error: "Already liked" }, 400);
239
204
 
240
205
  return c.json({ success: true, liked: true });
241
206
  });
@@ -248,76 +213,15 @@ posts.delete("/:id/like", async (c) => {
248
213
  if (!post) return c.json({ error: "Post not found" }, 404);
249
214
 
250
215
  const db = c.get("db");
251
- const baseUrl = c.env.APP_URL;
252
-
253
- const like = await db
254
- .select({
255
- actorApId: likes.actorApId,
256
- activityApId: likes.activityApId,
257
- })
258
- .from(likes)
259
- .where(
260
- and(eq(likes.actorApId, actor.ap_id), eq(likes.objectApId, post.apId)),
261
- )
262
- .get();
263
- if (!like) return c.json({ error: "Not liked" }, 400);
264
-
265
- // D1 has no interactive transactions; group the like-row delete and the
266
- // counter decrement into a single atomic batch so they cannot diverge. Also
267
- // reap the original like's notification: the like mints a FRESH activity id
268
- // each time and the dedup guard only checks the `likes` edge, so without this
269
- // a like→unlike→like cycle would leave the first notification behind and add a
270
- // second — duplicate "X liked your post" rows (and a phantom unread +1). The
271
- // inbound-federated path already gates its notify on the existing edge; this
272
- // gives the local path the same idempotency.
273
- const reapLikeNotification: BatchStatement[] = like.activityApId
274
- ? [
275
- db
276
- .delete(inboxTable)
277
- .where(eq(inboxTable.activityApId, like.activityApId)),
278
- db.delete(activities).where(eq(activities.apId, like.activityApId)),
279
- ]
280
- : [];
281
- await runBatch(db, [
282
- db
283
- .delete(likes)
284
- .where(
285
- and(eq(likes.actorApId, actor.ap_id), eq(likes.objectApId, post.apId)),
286
- ),
287
- db
288
- .update(objects)
289
- .set({ likeCount: sql`${objects.likeCount} - 1` })
290
- .where(and(eq(objects.apId, post.apId), gt(objects.likeCount, 0))),
291
- ...reapLikeNotification,
292
- ]);
293
-
294
- if (!isLocal(post.apId, baseUrl)) {
295
- const undoObject = like.activityApId
296
- ? like.activityApId
297
- : { type: "Like", actor: actor.ap_id, object: post.apId };
298
-
299
- const undoActivity = {
300
- "@context": "https://www.w3.org/ns/activitystreams",
301
- id: activityApId(baseUrl, generateId()),
302
- type: "Undo",
303
- actor: actor.ap_id,
304
- object: undoObject,
305
- };
306
216
 
307
- await db
308
- .insert(activities)
309
- .values({
310
- apId: undoActivity.id,
311
- type: "Undo",
312
- actorApId: actor.ap_id,
313
- objectApId: post.apId,
314
- rawJson: JSON.stringify(undoActivity),
315
- direction: "outbound",
316
- })
317
- .onConflictDoNothing();
318
-
319
- await deliverToRemote(c.env, undoActivity.id, post.attributedTo);
320
- }
217
+ const result = await setPostLike({
218
+ db,
219
+ env: c.env,
220
+ actorApId: actor.ap_id,
221
+ post,
222
+ active: false,
223
+ });
224
+ if (!result.changed) return c.json({ error: "Not liked" }, 400);
321
225
 
322
226
  return c.json({ success: true, liked: false });
323
227
  });
@@ -336,6 +240,13 @@ posts.post("/:id/repost", async (c) => {
336
240
  const db = c.get("db");
337
241
  const baseUrl = c.env.APP_URL;
338
242
 
243
+ // Repost has a public-reach gate rather than the canonical read gate below,
244
+ // so enforce instance moderation explicitly before it can persist/fan out an
245
+ // Announce for a retained object from a defederated actor/domain.
246
+ if (await isActorBlockedStrict(db, post.attributedTo)) {
247
+ return c.json({ error: "Post not found" }, 404);
248
+ }
249
+
339
250
  // A repost is always an Announce addressed to Public + the booster's
340
251
  // followers (see the activity built below), so it re-broadcasts the object to
341
252
  // a wider audience than the original. Only a post whose reach is ALREADY
@@ -387,6 +298,11 @@ posts.post("/:id/repost", async (c) => {
387
298
  const now = new Date().toISOString();
388
299
  const shouldNotifyLocal =
389
300
  post.attributedTo !== actor.ap_id && isLocal(post.attributedTo, baseUrl);
301
+ const preparedFanout = await prepareDeliveryFanoutJob(db, {
302
+ kind: "followers",
303
+ activityId: announceActivityId,
304
+ targetApId: actor.ap_id,
305
+ });
390
306
 
391
307
  // D1 has no interactive transactions; group the child-row insert, counter
392
308
  // bump, activity row, and (optional) inbox row into a single atomic batch so
@@ -409,6 +325,7 @@ posts.post("/:id/repost", async (c) => {
409
325
  rawJson: JSON.stringify(announceActivityRaw),
410
326
  createdAt: now,
411
327
  }),
328
+ preparedFanout.statement as BatchStatement,
412
329
  ];
413
330
 
414
331
  if (shouldNotifyLocal) {
@@ -438,7 +355,12 @@ posts.post("/:id/repost", async (c) => {
438
355
  // The Announce is cc'd to the booster's own followers collection, so fan it
439
356
  // out to them — otherwise a repost only ever reached the original author's
440
357
  // inbox and was invisible to the booster's remote followers.
441
- await enqueueFanoutToFollowers(c.env, announceActivityId, actor.ap_id);
358
+ await publishDurableFollowerFanoutWakeup(
359
+ c.env,
360
+ announceActivityId,
361
+ actor.ap_id,
362
+ "repost",
363
+ );
442
364
 
443
365
  if (!isLocal(post.apId, baseUrl)) {
444
366
  await deliverToRemote(c.env, announceActivityId, post.attributedTo);
@@ -472,17 +394,33 @@ posts.delete("/:id/repost", async (c) => {
472
394
  .get();
473
395
  if (!announce) return c.json({ error: "Not reposted" }, 400);
474
396
 
397
+ const undoActivity = {
398
+ "@context": "https://www.w3.org/ns/activitystreams",
399
+ id: activityApId(baseUrl, generateId()),
400
+ type: "Undo",
401
+ actor: actor.ap_id,
402
+ to: ["https://www.w3.org/ns/activitystreams#Public"],
403
+ cc: [actor.ap_id + "/followers"],
404
+ object: { type: "Announce", actor: actor.ap_id, object: post.apId },
405
+ };
406
+ const preparedFanout = await prepareDeliveryFanoutJob(db, {
407
+ kind: "followers",
408
+ activityId: undoActivity.id,
409
+ targetApId: actor.ap_id,
410
+ });
411
+
475
412
  // D1 has no interactive transactions; group the announce-row delete and the
476
413
  // counter decrement into a single atomic batch so they cannot diverge. Also
477
- // reap the original repost's notification (same duplicate-notification issue
478
- // as unlike: each repost mints a fresh Announce activity id and the guard only
479
- // checks the edge, so repost→unrepost→repost would stack notifications).
414
+ // reap the original repost activity plus all durable projections (same
415
+ // duplicate-notification issue as unlike: each repost mints a fresh Announce
416
+ // activity id and the guard only checks the edge, so
417
+ // repost→unrepost→repost would stack notifications and queue residue).
480
418
  const reapRepostNotification: BatchStatement[] = announce.activityApId
481
419
  ? [
482
- db
483
- .delete(inboxTable)
484
- .where(eq(inboxTable.activityApId, announce.activityApId)),
485
- db.delete(activities).where(eq(activities.apId, announce.activityApId)),
420
+ ...activityDeleteCascadeStatements(
421
+ db,
422
+ eq(activities.apId, announce.activityApId),
423
+ ),
486
424
  ]
487
425
  : [];
488
426
  await runBatch(db, [
@@ -499,33 +437,28 @@ posts.delete("/:id/repost", async (c) => {
499
437
  .set({ announceCount: sql`${objects.announceCount} - 1` })
500
438
  .where(and(eq(objects.apId, post.apId), gt(objects.announceCount, 0))),
501
439
  ...reapRepostNotification,
440
+ db
441
+ .insert(activities)
442
+ .values({
443
+ apId: undoActivity.id,
444
+ type: "Undo",
445
+ actorApId: actor.ap_id,
446
+ objectApId: post.apId,
447
+ rawJson: JSON.stringify(undoActivity),
448
+ direction: "outbound",
449
+ })
450
+ .onConflictDoNothing(),
451
+ preparedFanout.statement as BatchStatement,
502
452
  ]);
503
453
 
504
- const undoActivity = {
505
- "@context": "https://www.w3.org/ns/activitystreams",
506
- id: activityApId(baseUrl, generateId()),
507
- type: "Undo",
508
- actor: actor.ap_id,
509
- to: ["https://www.w3.org/ns/activitystreams#Public"],
510
- cc: [actor.ap_id + "/followers"],
511
- object: { type: "Announce", actor: actor.ap_id, object: post.apId },
512
- };
513
-
514
- await db
515
- .insert(activities)
516
- .values({
517
- apId: undoActivity.id,
518
- type: "Undo",
519
- actorApId: actor.ap_id,
520
- objectApId: post.apId,
521
- rawJson: JSON.stringify(undoActivity),
522
- direction: "outbound",
523
- })
524
- .onConflictDoNothing();
525
-
526
454
  // Mirror the Announce reach: the Undo must reach the booster's followers (who
527
455
  // saw the repost) regardless of whether the boosted post was local or remote.
528
- await enqueueFanoutToFollowers(c.env, undoActivity.id, actor.ap_id);
456
+ await publishDurableFollowerFanoutWakeup(
457
+ c.env,
458
+ undoActivity.id,
459
+ actor.ap_id,
460
+ "unrepost",
461
+ );
529
462
 
530
463
  // The boosted post's author instance is only an extra recipient for a remote post.
531
464
  if (!isLocal(post.apId, baseUrl)) {
@@ -640,20 +573,28 @@ posts.get("/bookmarks", async (c) => {
640
573
  // Fetch limit+1 and advance the cursor by the last SCANNED row, so the
641
574
  // visibility gate dropping rows can only shorten a page, never make a readable
642
575
  // bookmark permanently unreachable (load-more continues past the dropped ones).
643
- const scanned = await db.query.bookmarks.findMany({
644
- where: cursorPredicate
645
- ? and(eq(bookmarks.actorApId, actor.ap_id), cursorPredicate)
646
- : eq(bookmarks.actorApId, actor.ap_id),
647
- with: { object: true },
648
- orderBy: [desc(bookmarks.createdAt), desc(bookmarks.objectApId)],
649
- limit: limit + 1,
650
- });
576
+ const scanned = await db
577
+ .select({ bookmark: bookmarks, object: objects })
578
+ .from(bookmarks)
579
+ .innerJoin(objects, eq(bookmarks.objectApId, objects.apId))
580
+ .where(
581
+ and(
582
+ eq(bookmarks.actorApId, actor.ap_id),
583
+ cursorPredicate,
584
+ excludeModeratedActors(actor.ap_id),
585
+ ),
586
+ )
587
+ .orderBy(desc(bookmarks.createdAt), desc(bookmarks.objectApId))
588
+ .limit(limit + 1);
651
589
  const hasMore = scanned.length > limit;
652
590
  const allBookmarkRows = hasMore ? scanned.slice(0, limit) : scanned;
653
591
  const lastScanned = allBookmarkRows[allBookmarkRows.length - 1];
654
592
  const nextCursor =
655
593
  hasMore && lastScanned
656
- ? encodeFeedCursor(lastScanned.createdAt, lastScanned.objectApId)
594
+ ? encodeFeedCursor(
595
+ lastScanned.bookmark.createdAt,
596
+ lastScanned.bookmark.objectApId,
597
+ )
657
598
  : null;
658
599
 
659
600
  // Re-check read-access at read time so a bookmark can never resurface a post
@@ -700,11 +641,43 @@ posts.get("/bookmarks", async (c) => {
700
641
  )
701
642
  : new Set<string>();
702
643
 
644
+ // Hidden bto/bcc identities cannot live in to_json/cc_json. Resolve the
645
+ // indexed recipient projection once for this page so a hidden recipient who
646
+ // successfully bookmarked a readable post does not lose it on list refresh.
647
+ const projectedCandidateIds = allBookmarkRows
648
+ .filter(
649
+ (b) =>
650
+ b.object.attributedTo !== actor.ap_id &&
651
+ (b.object.visibility === "direct" ||
652
+ b.object.visibility === "followers"),
653
+ )
654
+ .map((b) => b.object.apId);
655
+ const projectedRecipientIds =
656
+ projectedCandidateIds.length > 0
657
+ ? new Set(
658
+ (
659
+ await db
660
+ .select({ objectApId: objectRecipients.objectApId })
661
+ .from(objectRecipients)
662
+ .where(
663
+ and(
664
+ eq(objectRecipients.recipientApId, actor.ap_id),
665
+ eq(objectRecipients.type, "to"),
666
+ inArray(objectRecipients.objectApId, projectedCandidateIds),
667
+ ),
668
+ )
669
+ ).map((row) => row.objectApId),
670
+ )
671
+ : new Set<string>();
672
+
703
673
  // Sync visibility-gate first, then ONE batched community read-gate over the
704
674
  // survivors (2 queries instead of 1-2 per bookmarked post).
705
675
  const visibilityOk = allBookmarkRows.filter((b) =>
706
- passesPostVisibilitySync(b.object, actor.ap_id, (a) =>
707
- followedAuthors.has(a),
676
+ passesPostVisibilitySync(
677
+ b.object,
678
+ actor.ap_id,
679
+ (a) => followedAuthors.has(a),
680
+ (objectApId) => projectedRecipientIds.has(objectApId),
708
681
  ),
709
682
  );
710
683
  const communityReadable = await communityReadableApIds(
@@ -768,7 +741,10 @@ posts.get("/bookmarks", async (c) => {
768
741
  type: obj.type,
769
742
  author: {
770
743
  ap_id: obj.attributedTo,
771
- username: formatUsername(obj.attributedTo),
744
+ username: formatUsername(
745
+ obj.attributedTo,
746
+ authorInfo?.preferredUsername,
747
+ ),
772
748
  preferred_username: authorInfo?.preferredUsername ?? null,
773
749
  name: authorInfo?.name ?? null,
774
750
  icon_url: authorInfo?.iconUrl ?? null,
@@ -0,0 +1,240 @@
1
+ import { and, count, eq, sql } from "drizzle-orm";
2
+ import type { BatchItem } from "drizzle-orm/batch";
3
+ import type { Database } from "../../../db/index.ts";
4
+ import { activities, inbox, likes, objects } from "../../../db/index.ts";
5
+ import type { Env } from "../../types.ts";
6
+ import { activityApId, generateId, isLocal } from "../../federation-helpers.ts";
7
+ import { activityDeleteCascadeStatements } from "../../lib/activity-delete-cascade.ts";
8
+ import { enqueueDeliveryToActor } from "../../lib/delivery/queue.ts";
9
+ import { logger } from "../../lib/logger.ts";
10
+ import { isUniqueConstraintError } from "../../lib/parse-helpers.ts";
11
+
12
+ const log = logger.child({ component: "posts.like_mutation" });
13
+
14
+ type BatchStatement = BatchItem<"sqlite">;
15
+ type BatchableDb = {
16
+ batch(
17
+ statements: readonly [BatchStatement, ...BatchStatement[]],
18
+ ): Promise<unknown>;
19
+ };
20
+
21
+ export type LikeTarget = {
22
+ apId: string;
23
+ attributedTo: string;
24
+ };
25
+
26
+ export type PostLikeMutationResult = {
27
+ active: boolean;
28
+ changed: boolean;
29
+ likeCount: number;
30
+ };
31
+
32
+ async function readLikeCount(
33
+ db: Database,
34
+ objectApId: string,
35
+ ): Promise<number> {
36
+ const row = await db
37
+ .select({ count: count() })
38
+ .from(likes)
39
+ .where(eq(likes.objectApId, objectApId))
40
+ .get();
41
+ return row?.count ?? 0;
42
+ }
43
+
44
+ async function enqueueRemoteInteraction(
45
+ env: Env,
46
+ activityId: string,
47
+ recipientApId: string,
48
+ type: "Like" | "Undo",
49
+ ): Promise<void> {
50
+ try {
51
+ await enqueueDeliveryToActor(env, activityId, recipientApId);
52
+ } catch (error) {
53
+ log.error("Failed to enqueue post interaction", {
54
+ event: "posts.like_mutation.enqueue_failed",
55
+ type,
56
+ activityId,
57
+ recipient: recipientApId,
58
+ error,
59
+ });
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Set one actor's Like edge and all of its durable Activity/notification state.
65
+ *
66
+ * Web and agent callers deliberately keep their own response semantics: this
67
+ * owner reports whether the requested state changed, so HTTP may reject a
68
+ * duplicate while the Takos tool remains safely idempotent.
69
+ */
70
+ export async function setPostLike(input: {
71
+ db: Database;
72
+ env: Env;
73
+ actorApId: string;
74
+ post: LikeTarget;
75
+ active: boolean;
76
+ }): Promise<PostLikeMutationResult> {
77
+ const { db, env, actorApId, post, active } = input;
78
+ const existing = await db
79
+ .select({ activityApId: likes.activityApId })
80
+ .from(likes)
81
+ .where(and(eq(likes.actorApId, actorApId), eq(likes.objectApId, post.apId)))
82
+ .get();
83
+
84
+ if (active) {
85
+ if (existing) {
86
+ return {
87
+ active: true,
88
+ changed: false,
89
+ likeCount: await readLikeCount(db, post.apId),
90
+ };
91
+ }
92
+
93
+ const now = new Date().toISOString();
94
+ const likeActivityId = activityApId(env.APP_URL, generateId());
95
+ const raw = {
96
+ "@context": "https://www.w3.org/ns/activitystreams",
97
+ id: likeActivityId,
98
+ type: "Like",
99
+ actor: actorApId,
100
+ object: post.apId,
101
+ };
102
+ const statements: BatchStatement[] = [
103
+ db.insert(likes).values({
104
+ actorApId,
105
+ objectApId: post.apId,
106
+ activityApId: likeActivityId,
107
+ createdAt: now,
108
+ }),
109
+ db
110
+ .update(objects)
111
+ .set({
112
+ likeCount: sql`(SELECT COUNT(*) FROM ${likes} WHERE ${likes.objectApId} = ${post.apId})`,
113
+ })
114
+ .where(eq(objects.apId, post.apId)),
115
+ db.insert(activities).values({
116
+ apId: likeActivityId,
117
+ type: "Like",
118
+ actorApId,
119
+ objectApId: post.apId,
120
+ rawJson: JSON.stringify(raw),
121
+ direction: "outbound",
122
+ createdAt: now,
123
+ }),
124
+ ];
125
+ if (
126
+ post.attributedTo !== actorApId &&
127
+ isLocal(post.attributedTo, env.APP_URL)
128
+ ) {
129
+ statements.push(
130
+ db.insert(inbox).values({
131
+ actorApId: post.attributedTo,
132
+ activityApId: likeActivityId,
133
+ read: 0,
134
+ createdAt: now,
135
+ }),
136
+ );
137
+ }
138
+
139
+ try {
140
+ await (db as unknown as BatchableDb).batch(
141
+ statements as [BatchStatement, ...BatchStatement[]],
142
+ );
143
+ } catch (error) {
144
+ if (!isUniqueConstraintError(error)) throw error;
145
+ return {
146
+ active: true,
147
+ changed: false,
148
+ likeCount: await readLikeCount(db, post.apId),
149
+ };
150
+ }
151
+
152
+ if (!isLocal(post.apId, env.APP_URL)) {
153
+ await enqueueRemoteInteraction(
154
+ env,
155
+ likeActivityId,
156
+ post.attributedTo,
157
+ "Like",
158
+ );
159
+ }
160
+ return {
161
+ active: true,
162
+ changed: true,
163
+ likeCount: await readLikeCount(db, post.apId),
164
+ };
165
+ }
166
+
167
+ if (!existing) {
168
+ return {
169
+ active: false,
170
+ changed: false,
171
+ likeCount: await readLikeCount(db, post.apId),
172
+ };
173
+ }
174
+
175
+ const remote = !isLocal(post.apId, env.APP_URL);
176
+ const undoActivityId = remote
177
+ ? activityApId(env.APP_URL, generateId())
178
+ : null;
179
+ const undoObject = existing.activityApId
180
+ ? existing.activityApId
181
+ : { type: "Like", actor: actorApId, object: post.apId };
182
+ const undoRaw = undoActivityId
183
+ ? {
184
+ "@context": "https://www.w3.org/ns/activitystreams",
185
+ id: undoActivityId,
186
+ type: "Undo",
187
+ actor: actorApId,
188
+ object: undoObject,
189
+ }
190
+ : null;
191
+ const reapOriginal = existing.activityApId
192
+ ? activityDeleteCascadeStatements(
193
+ db,
194
+ eq(activities.apId, existing.activityApId),
195
+ )
196
+ : [];
197
+ const statements: BatchStatement[] = [
198
+ db
199
+ .delete(likes)
200
+ .where(
201
+ and(eq(likes.actorApId, actorApId), eq(likes.objectApId, post.apId)),
202
+ ),
203
+ db
204
+ .update(objects)
205
+ .set({
206
+ likeCount: sql`(SELECT COUNT(*) FROM ${likes} WHERE ${likes.objectApId} = ${post.apId})`,
207
+ })
208
+ .where(eq(objects.apId, post.apId)),
209
+ ...reapOriginal,
210
+ ];
211
+ if (undoActivityId && undoRaw) {
212
+ statements.push(
213
+ db.insert(activities).values({
214
+ apId: undoActivityId,
215
+ type: "Undo",
216
+ actorApId,
217
+ objectApId: post.apId,
218
+ rawJson: JSON.stringify(undoRaw),
219
+ direction: "outbound",
220
+ }),
221
+ );
222
+ }
223
+ await (db as unknown as BatchableDb).batch(
224
+ statements as [BatchStatement, ...BatchStatement[]],
225
+ );
226
+
227
+ if (undoActivityId) {
228
+ await enqueueRemoteInteraction(
229
+ env,
230
+ undoActivityId,
231
+ post.attributedTo,
232
+ "Undo",
233
+ );
234
+ }
235
+ return {
236
+ active: false,
237
+ changed: true,
238
+ likeCount: await readLikeCount(db, post.apId),
239
+ };
240
+ }