@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
@@ -2,42 +2,40 @@ import { Hono } from "hono";
2
2
  import { and, desc, eq, gt, inArray, sql } from "drizzle-orm";
3
3
  import type { Env, Variables } from "../types.ts";
4
4
  import {
5
- activities,
6
5
  actorCache,
7
6
  actors,
8
7
  follows,
9
8
  inbox,
9
+ runBatch,
10
+ type D1Statement,
10
11
  } from "../../db/index.ts";
11
12
  import {
12
- activityApId,
13
13
  formatUsername,
14
- generateId,
15
14
  isLocal,
16
15
  isSafeRemoteUrl,
17
16
  parseLimit,
18
17
  parseOffset,
19
18
  } from "../federation-helpers.ts";
20
- import { enqueueDeliveryToActor } from "../lib/delivery/queue.ts";
21
19
  import {
22
- buildApActivity,
23
- createAndDeliverActivity,
24
- deliverResponseIfRemote,
20
+ filterBlockedActorApIdsStrict,
21
+ isActorBlockedStrict,
22
+ operatorActorNotBlockedSql,
23
+ } from "../lib/blocklist.ts";
24
+ import {
25
25
  findPendingFollow,
26
26
  handleLocalFollow,
27
27
  handleRemoteFollow,
28
28
  isResponse,
29
29
  parseNonEmptyString,
30
30
  parseStringArray,
31
+ prepareActivityDelivery,
32
+ prepareResponseIfRemote,
31
33
  requireActorAndBody,
32
34
  } from "./follow-helpers.ts";
33
35
  import { logger } from "../lib/logger.ts";
34
36
 
35
37
  const log = logger.child({ component: "follow" });
36
38
 
37
- // `.batch` lives only on the concrete D1/libsql subclasses, not the Database
38
- // union; reach it through a narrow structural cast (matching the other routes).
39
- type Batchable = { batch: (stmts: unknown[]) => Promise<unknown> };
40
-
41
39
  // Capped at 90 (not 100): the accepted ids are re-queried via
42
40
  // `inArray(follows.followerApId, requesterApIds)` and Cloudflare D1 allows at
43
41
  // most 100 bound parameters per query (libsql, used by the tests, allows ~32k).
@@ -62,6 +60,17 @@ follow.post("/", async (c) => {
62
60
  return c.json({ error: "Cannot follow yourself" }, 400);
63
61
  }
64
62
 
63
+ // An operator block is an instance-wide authority boundary, not merely an
64
+ // outbound delivery filter. Reject before reading/cleaning an existing edge
65
+ // or resolving a remote actor so the request cannot report success while the
66
+ // queue silently drops it, create an inert Follow ledger row, fetch the
67
+ // defederated host, or mutate a retained relationship that unblock restores.
68
+ // Match the personal-block route's not-found response to avoid presenting a
69
+ // blocked target through this mutation surface.
70
+ if (await isActorBlockedStrict(db, targetApId)) {
71
+ return c.json({ error: "Target actor not found" }, 404);
72
+ }
73
+
65
74
  const existing = await db
66
75
  .select()
67
76
  .from(follows)
@@ -125,6 +134,23 @@ follow.delete("/", async (c) => {
125
134
  const wasAccepted = existingFollow.status === "accepted";
126
135
  const targetIsLocal = isLocal(targetApId, baseUrl);
127
136
 
137
+ const preparedUndo = targetIsLocal
138
+ ? null
139
+ : await prepareActivityDelivery(
140
+ c.env,
141
+ db,
142
+ baseUrl,
143
+ "Undo",
144
+ actor.ap_id,
145
+ {
146
+ type: "Follow",
147
+ actor: actor.ap_id,
148
+ object: targetApId,
149
+ },
150
+ targetApId,
151
+ targetApId,
152
+ );
153
+
128
154
  const deleteEdge = db
129
155
  .delete(follows)
130
156
  .where(
@@ -134,13 +160,14 @@ follow.delete("/", async (c) => {
134
160
  ),
135
161
  );
136
162
 
163
+ const statements: D1Statement[] = [];
137
164
  if (wasAccepted) {
138
165
  // Co-commit the decrements + delete in ONE batch so a crash between them
139
166
  // can't leave the edge gone with un-decremented counts (permanent over-
140
167
  // count). Decrements run BEFORE the delete, guarded by EXISTS(accepted edge)
141
168
  // + count>0 (underflow) — mirrors the federated undoFollowEdge.
142
169
  const acceptedEdgeExists = sql`EXISTS (SELECT 1 FROM ${follows} WHERE ${follows.followerApId} = ${actor.ap_id} AND ${follows.followingApId} = ${targetApId} AND ${follows.status} = 'accepted')`;
143
- const stmts: unknown[] = [
170
+ statements.push(
144
171
  db
145
172
  .update(actors)
146
173
  .set({ followingCount: sql`${actors.followingCount} - 1` })
@@ -150,10 +177,10 @@ follow.delete("/", async (c) => {
150
177
  gt(actors.followingCount, 0),
151
178
  acceptedEdgeExists,
152
179
  ),
153
- ),
154
- ];
180
+ ) as D1Statement,
181
+ );
155
182
  if (targetIsLocal) {
156
- stmts.push(
183
+ statements.push(
157
184
  db
158
185
  .update(actors)
159
186
  .set({ followerCount: sql`${actors.followerCount} - 1` })
@@ -163,32 +190,14 @@ follow.delete("/", async (c) => {
163
190
  gt(actors.followerCount, 0),
164
191
  acceptedEdgeExists,
165
192
  ),
166
- ),
193
+ ) as D1Statement,
167
194
  );
168
195
  }
169
- stmts.push(deleteEdge);
170
- await (db as unknown as Batchable).batch(stmts);
171
- } else {
172
- await deleteEdge;
173
- }
174
-
175
- if (!targetIsLocal) {
176
- const undoObject = {
177
- type: "Follow",
178
- actor: actor.ap_id,
179
- object: targetApId,
180
- };
181
- await createAndDeliverActivity(
182
- c.env,
183
- db,
184
- baseUrl,
185
- "Undo",
186
- actor.ap_id,
187
- undoObject,
188
- targetApId,
189
- targetApId,
190
- );
191
196
  }
197
+ statements.push(deleteEdge as D1Statement);
198
+ if (preparedUndo) statements.push(...preparedUndo.statements);
199
+ await runBatch(db, statements as [D1Statement, ...D1Statement[]]);
200
+ await preparedUndo?.publish();
192
201
 
193
202
  return c.json({ success: true });
194
203
  });
@@ -209,8 +218,10 @@ follow.post("/accept", async (c) => {
209
218
  400,
210
219
  );
211
220
  }
221
+ if (await isActorBlockedStrict(db, requesterApId)) {
222
+ return c.json({ error: "No pending follow request" }, 404);
223
+ }
212
224
 
213
- let pendingFollow: Awaited<ReturnType<typeof findPendingFollow>>;
214
225
  try {
215
226
  // Read the pending edge first (we need its activityApId for the Accept
216
227
  // delivery below), then co-commit the flip + both increments in ONE batch.
@@ -223,21 +234,34 @@ follow.post("/accept", async (c) => {
223
234
  // count nor under-count. Mirrors the federated handleAccept.
224
235
  const pending = await findPendingFollow(db, requesterApId, actor.ap_id);
225
236
  if (!pending) {
226
- pendingFollow = undefined;
237
+ return c.json({ error: "No pending follow request" }, 404);
227
238
  } else {
239
+ const preparedResponse = await prepareResponseIfRemote(
240
+ c.env,
241
+ db,
242
+ baseUrl,
243
+ "Accept",
244
+ actor.ap_id,
245
+ requesterApId,
246
+ pending.activityApId,
247
+ );
228
248
  const pendingExists = sql`EXISTS (SELECT 1 FROM ${follows} WHERE ${follows.followerApId} = ${requesterApId} AND ${follows.followingApId} = ${actor.ap_id} AND ${follows.status} = 'pending')`;
229
- const stmts: unknown[] = [
249
+ const stmts: D1Statement[] = [
230
250
  db
231
251
  .update(actors)
232
252
  .set({ followerCount: sql`${actors.followerCount} + 1` })
233
- .where(and(eq(actors.apId, actor.ap_id), pendingExists)),
253
+ .where(
254
+ and(eq(actors.apId, actor.ap_id), pendingExists),
255
+ ) as D1Statement,
234
256
  ];
235
257
  if (isLocal(requesterApId, baseUrl)) {
236
258
  stmts.push(
237
259
  db
238
260
  .update(actors)
239
261
  .set({ followingCount: sql`${actors.followingCount} + 1` })
240
- .where(and(eq(actors.apId, requesterApId), pendingExists)),
262
+ .where(
263
+ and(eq(actors.apId, requesterApId), pendingExists),
264
+ ) as D1Statement,
241
265
  );
242
266
  }
243
267
  stmts.push(
@@ -250,10 +274,11 @@ follow.post("/accept", async (c) => {
250
274
  eq(follows.followingApId, actor.ap_id),
251
275
  eq(follows.status, "pending"),
252
276
  ),
253
- ),
277
+ ) as D1Statement,
254
278
  );
255
- await (db as unknown as Batchable).batch(stmts);
256
- pendingFollow = pending;
279
+ if (preparedResponse) stmts.push(...preparedResponse.statements);
280
+ await runBatch(db, stmts as [D1Statement, ...D1Statement[]]);
281
+ await preparedResponse?.publish();
257
282
  }
258
283
  } catch (e) {
259
284
  log.error("Error in accept", {
@@ -263,20 +288,6 @@ follow.post("/accept", async (c) => {
263
288
  return c.json({ error: "Internal error" }, 500);
264
289
  }
265
290
 
266
- if (!pendingFollow) {
267
- return c.json({ error: "No pending follow request" }, 404);
268
- }
269
-
270
- await deliverResponseIfRemote(
271
- c.env,
272
- db,
273
- baseUrl,
274
- "Accept",
275
- actor.ap_id,
276
- requesterApId,
277
- pendingFollow.activityApId,
278
- );
279
-
280
291
  return c.json({ success: true });
281
292
  });
282
293
 
@@ -308,32 +319,31 @@ follow.post("/accept/batch", async (c) => {
308
319
  );
309
320
  }
310
321
 
311
- const pendingFollows = await db
312
- .select()
313
- .from(follows)
314
- .where(
315
- and(
316
- inArray(follows.followerApId, requesterApIds),
317
- eq(follows.followingApId, actor.ap_id),
318
- eq(follows.status, "pending"),
319
- ),
320
- );
322
+ const blockedRequesterApIds = await filterBlockedActorApIdsStrict(
323
+ db,
324
+ requesterApIds,
325
+ );
326
+ const visibleRequesterApIds = requesterApIds.filter(
327
+ (requesterApId) => !blockedRequesterApIds.has(requesterApId),
328
+ );
329
+ const pendingFollows =
330
+ visibleRequesterApIds.length === 0
331
+ ? []
332
+ : await db
333
+ .select()
334
+ .from(follows)
335
+ .where(
336
+ and(
337
+ inArray(follows.followerApId, visibleRequesterApIds),
338
+ eq(follows.followingApId, actor.ap_id),
339
+ eq(follows.status, "pending"),
340
+ ),
341
+ );
321
342
  const pendingFollowMap = new Map(
322
343
  pendingFollows.map((f) => [f.followerApId, f]),
323
344
  );
324
345
 
325
346
  const results: { ap_id: string; success: boolean; error?: string }[] = [];
326
- const activitiesToCreate: Array<{
327
- apId: string;
328
- type: string;
329
- actorApId: string;
330
- objectApId: string | undefined;
331
- rawJson: string;
332
- direction: string;
333
- }> = [];
334
- const remoteEnqueues: Array<{ activityId: string; recipientApId: string }> =
335
- [];
336
-
337
347
  for (const requesterApId of requesterApIds) {
338
348
  const pendingFollow = pendingFollowMap.get(requesterApId);
339
349
  if (!pendingFollow) {
@@ -352,19 +362,36 @@ follow.post("/accept/batch", async (c) => {
352
362
  // accumulated increments for already-flipped rows. Increments guarded by
353
363
  // EXISTS(pending) before the flip; the flip keeps its pending predicate.
354
364
  const requesterIsLocal = isLocal(requesterApId, baseUrl);
365
+ const requesterIsSafeRemote =
366
+ !requesterIsLocal && isSafeRemoteUrl(requesterApId);
367
+ const preparedResponse = requesterIsSafeRemote
368
+ ? await prepareResponseIfRemote(
369
+ c.env,
370
+ db,
371
+ baseUrl,
372
+ "Accept",
373
+ actor.ap_id,
374
+ requesterApId,
375
+ pendingFollow.activityApId,
376
+ )
377
+ : null;
355
378
  const pendingExists = sql`EXISTS (SELECT 1 FROM ${follows} WHERE ${follows.followerApId} = ${requesterApId} AND ${follows.followingApId} = ${actor.ap_id} AND ${follows.status} = 'pending')`;
356
- const stmts: unknown[] = [
379
+ const stmts: D1Statement[] = [
357
380
  db
358
381
  .update(actors)
359
382
  .set({ followerCount: sql`${actors.followerCount} + 1` })
360
- .where(and(eq(actors.apId, actor.ap_id), pendingExists)),
383
+ .where(
384
+ and(eq(actors.apId, actor.ap_id), pendingExists),
385
+ ) as D1Statement,
361
386
  ];
362
387
  if (requesterIsLocal) {
363
388
  stmts.push(
364
389
  db
365
390
  .update(actors)
366
391
  .set({ followingCount: sql`${actors.followingCount} + 1` })
367
- .where(and(eq(actors.apId, requesterApId), pendingExists)),
392
+ .where(
393
+ and(eq(actors.apId, requesterApId), pendingExists),
394
+ ) as D1Statement,
368
395
  );
369
396
  }
370
397
  stmts.push(
@@ -377,31 +404,15 @@ follow.post("/accept/batch", async (c) => {
377
404
  eq(follows.followingApId, actor.ap_id),
378
405
  eq(follows.status, "pending"),
379
406
  ),
380
- ),
407
+ ) as D1Statement,
381
408
  );
382
- await (db as unknown as Batchable).batch(stmts);
409
+ if (preparedResponse) stmts.push(...preparedResponse.statements);
410
+ await runBatch(db, stmts as [D1Statement, ...D1Statement[]]);
411
+ await preparedResponse?.publish();
383
412
 
384
413
  if (requesterIsLocal) {
385
414
  // local follower's followingCount already bumped in the batch above
386
- } else if (isSafeRemoteUrl(requesterApId)) {
387
- const id = activityApId(baseUrl, generateId());
388
- const activity = buildApActivity(
389
- "Accept",
390
- actor.ap_id,
391
- pendingFollow.activityApId,
392
- id,
393
- );
394
-
395
- activitiesToCreate.push({
396
- apId: id,
397
- type: "Accept",
398
- actorApId: actor.ap_id,
399
- objectApId: pendingFollow.activityApId || undefined,
400
- rawJson: JSON.stringify(activity),
401
- direction: "outbound",
402
- });
403
- remoteEnqueues.push({ activityId: id, recipientApId: requesterApId });
404
- } else {
415
+ } else if (!requesterIsSafeRemote) {
405
416
  log.warn("Blocked unsafe remote actor", {
406
417
  event: "follow.accept.unsafe_remote_actor",
407
418
  actor: requesterApId,
@@ -421,18 +432,6 @@ follow.post("/accept/batch", async (c) => {
421
432
  // (Counts are bumped per-request inside the loop's batch — no aggregate
422
433
  // post-loop increment, which could be lost on a mid-loop crash.)
423
434
 
424
- if (activitiesToCreate.length > 0) {
425
- await db.insert(activities).values(activitiesToCreate);
426
- }
427
-
428
- if (remoteEnqueues.length > 0) {
429
- await Promise.allSettled(
430
- remoteEnqueues.map((e) =>
431
- enqueueDeliveryToActor(c.env, e.activityId, e.recipientApId),
432
- ),
433
- );
434
- }
435
-
436
435
  return c.json({
437
436
  results,
438
437
  accepted_count: results.filter((r) => r.success).length,
@@ -468,28 +467,7 @@ follow.post("/reject", async (c) => {
468
467
  // the INBOUND reject path (handleReject -> deleteFollowByCompoundKey) and the
469
468
  // community join-request re-pend behaviour, letting a fresh Follow start clean.
470
469
  // A pending edge was never counted, so no counter reconcile is needed.
471
- await db
472
- .delete(follows)
473
- .where(
474
- and(
475
- eq(follows.followerApId, requesterApId),
476
- eq(follows.followingApId, actor.ap_id),
477
- ),
478
- );
479
-
480
- if (pendingFollow.activityApId) {
481
- await db
482
- .update(inbox)
483
- .set({ read: 1 })
484
- .where(
485
- and(
486
- eq(inbox.actorApId, actor.ap_id),
487
- eq(inbox.activityApId, pendingFollow.activityApId),
488
- ),
489
- );
490
- }
491
-
492
- await deliverResponseIfRemote(
470
+ const preparedResponse = await prepareResponseIfRemote(
493
471
  c.env,
494
472
  db,
495
473
  baseUrl,
@@ -498,6 +476,33 @@ follow.post("/reject", async (c) => {
498
476
  requesterApId,
499
477
  pendingFollow.activityApId,
500
478
  );
479
+ const statements: D1Statement[] = [
480
+ db
481
+ .delete(follows)
482
+ .where(
483
+ and(
484
+ eq(follows.followerApId, requesterApId),
485
+ eq(follows.followingApId, actor.ap_id),
486
+ ),
487
+ ) as D1Statement,
488
+ ];
489
+
490
+ if (pendingFollow.activityApId) {
491
+ statements.push(
492
+ db
493
+ .update(inbox)
494
+ .set({ read: 1 })
495
+ .where(
496
+ and(
497
+ eq(inbox.actorApId, actor.ap_id),
498
+ eq(inbox.activityApId, pendingFollow.activityApId),
499
+ ),
500
+ ) as D1Statement,
501
+ );
502
+ }
503
+ if (preparedResponse) statements.push(...preparedResponse.statements);
504
+ await runBatch(db, statements as [D1Statement, ...D1Statement[]]);
505
+ await preparedResponse?.publish();
501
506
 
502
507
  return c.json({ success: true });
503
508
  });
@@ -525,6 +530,7 @@ follow.get("/requests", async (c) => {
525
530
  and(
526
531
  eq(follows.followingApId, actor.ap_id),
527
532
  eq(follows.status, "pending"),
533
+ operatorActorNotBlockedSql(sql`${follows.followerApId}`),
528
534
  ),
529
535
  )
530
536
  // followerApId is the PK discriminator (followingApId is fixed = me); add it
@@ -574,7 +580,7 @@ follow.get("/requests", async (c) => {
574
580
  const actorInfo = actorInfoMap.get(f.followerApId);
575
581
  return {
576
582
  ap_id: f.followerApId,
577
- username: formatUsername(f.followerApId),
583
+ username: formatUsername(f.followerApId, actorInfo?.preferredUsername),
578
584
  preferred_username: actorInfo?.preferredUsername || null,
579
585
  name: actorInfo?.name || null,
580
586
  icon_url: actorInfo?.iconUrl || null,
@@ -6,13 +6,12 @@ import {
6
6
  actors,
7
7
  communities,
8
8
  communityMembers,
9
- follows,
10
9
  mediaUploads,
11
10
  objects,
12
11
  } from "../../db/index.ts";
13
- import { generateId, safeJsonParse } from "../federation-helpers.ts";
12
+ import { generateId } from "../federation-helpers.ts";
14
13
  import { canViewerReadObject } from "../lib/community-visibility.ts";
15
- import { isExplicitRecipient } from "../lib/post-visibility.ts";
14
+ import { canViewerReadObjectFull } from "../lib/post-visibility.ts";
16
15
  import { stripImageMetadata } from "../lib/strip-image-metadata.ts";
17
16
  import { logger } from "../lib/logger.ts";
18
17
 
@@ -311,6 +310,7 @@ type ReferencingObject = {
311
310
  visibility: string;
312
311
  toJson: string;
313
312
  ccJson: string;
313
+ audienceJson: string;
314
314
  communityApId: string | null;
315
315
  endTime: string | null;
316
316
  };
@@ -364,6 +364,7 @@ async function findReferencingObject(
364
364
  visibility: objects.visibility,
365
365
  toJson: objects.toJson,
366
366
  ccJson: objects.ccJson,
367
+ audienceJson: objects.audienceJson,
367
368
  communityApId: objects.communityApId,
368
369
  endTime: objects.endTime,
369
370
  attachmentsJson: objects.attachmentsJson,
@@ -389,6 +390,7 @@ async function findReferencingObject(
389
390
  visibility: row.visibility,
390
391
  toJson: row.toJson,
391
392
  ccJson: row.ccJson,
393
+ audienceJson: row.audienceJson,
392
394
  communityApId: row.communityApId,
393
395
  endTime: row.endTime,
394
396
  };
@@ -518,102 +520,26 @@ async function checkMediaAuthorization(
518
520
  return ALLOW_PRIVATE;
519
521
  }
520
522
 
521
- // Stories are ephemeral (24h endTime). Once expired, the blob must not be
522
- // served to anyone but the author (handled just above) — mirror the feed /
523
- // single-object gates (gt(endTime, now)) so the media lifetime matches the
524
- // content lifetime instead of lingering until the best-effort reap fires.
525
- if (
526
- obj.type === "Story" &&
527
- obj.endTime &&
528
- obj.endTime <= new Date().toISOString()
529
- ) {
523
+ // Delegate object reach to the same gate used by post detail, replies,
524
+ // interactions and ActivityPub object reads. This includes community scope,
525
+ // Story expiry, accepted follows, visible to/cc recipients, and private
526
+ // bto/bcc recipient projections. Keeping one authority prevents the post body
527
+ // and its attachment from disagreeing about who may read them.
528
+ if (!(await canViewerReadObjectFull(db, obj, currentActorApId))) {
530
529
  return currentActorApId ? DENY_NOT_AUTHORIZED : DENY_AUTH_REQUIRED;
531
530
  }
532
531
 
533
- // Community-scoped media (a Story / community post is stored
534
- // `visibility = "public"` but addressed to a community): a PRIVATE community's
535
- // blob must stay members-only, so the world-readable `ALLOW_PUBLIC` below
536
- // would leak it. `canViewerReadObject` short-circuits to true for
537
- // public / non-community objects (never widening access) and gates a private
538
- // community on membership. Served PRIVATE (no shared cache) so a
539
- // member-fetched private blob is never replayed to a non-member from CDN cache.
540
- if (obj.communityApId) {
541
- const allowed = await canViewerReadObject(
542
- db,
543
- { communityApId: obj.communityApId },
544
- currentActorApId,
545
- );
546
- if (!allowed) {
547
- return currentActorApId ? DENY_NOT_AUTHORIZED : DENY_AUTH_REQUIRED;
548
- }
549
- // Community membership is necessary but NOT sufficient: a community post
550
- // created with visibility=followers/direct must ALSO pass that per-post gate
551
- // so this blob matches the post-detail / outbox / feed gates (which all
552
- // follower-gate such a post). Public / unlisted / Story community posts are
553
- // member-readable; followers/direct fall through to the gates below.
554
- if (obj.visibility !== "followers" && obj.visibility !== "direct") {
555
- return ALLOW_PRIVATE;
556
- }
557
- }
558
-
559
- // A personal Story is stored visibility="public" but its REACH is the author's
560
- // followers (addressed to=<actor>/followers; it only surfaces in followers'
561
- // story feed). The public short-circuit below would make its media blob
562
- // world-readable to anyone with the URL, so gate it on follower status like a
563
- // followers-only post. (Community stories were gated by the branch above; the
564
- // author by the branch above that.)
565
- if (obj.type === "Story") {
566
- if (!currentActorApId) return DENY_AUTH_REQUIRED;
567
- const follow = await db
568
- .select()
569
- .from(follows)
570
- .where(
571
- and(
572
- eq(follows.followerApId, currentActorApId),
573
- eq(follows.followingApId, obj.attributedTo),
574
- eq(follows.status, "accepted"),
575
- ),
576
- )
577
- .get();
578
- return follow ? ALLOW_PRIVATE : DENY_NOT_AUTHORIZED;
579
- }
580
-
581
- if (obj.visibility === "public" || obj.visibility === "unlisted") {
532
+ // Only ordinary world-readable objects may use shared/public caching.
533
+ // Community content and Stories remain private-cache responses even when the
534
+ // canonical gate admits the current viewer.
535
+ if (
536
+ !obj.communityApId &&
537
+ obj.type !== "Story" &&
538
+ (obj.visibility === "public" || obj.visibility === "unlisted")
539
+ ) {
582
540
  return ALLOW_PUBLIC;
583
541
  }
584
-
585
- // Non-public content requires authentication
586
- if (!currentActorApId) return DENY_AUTH_REQUIRED;
587
-
588
- if (obj.visibility === "followers") {
589
- // An explicitly-addressed (to/cc) recipient — e.g. a mention — reads it even
590
- // without a follow edge, matching the canonical canViewerReadObjectFull gate
591
- // (this branch previously ignored to/cc and 403'd the blob for a legit
592
- // recipient the post-detail view showed).
593
- if (isExplicitRecipient(obj, currentActorApId)) return ALLOW_PRIVATE;
594
- const follow = await db
595
- .select()
596
- .from(follows)
597
- .where(
598
- and(
599
- eq(follows.followerApId, currentActorApId),
600
- eq(follows.followingApId, obj.attributedTo),
601
- eq(follows.status, "accepted"),
602
- ),
603
- )
604
- .get();
605
- return follow ? ALLOW_PRIVATE : DENY_NOT_AUTHORIZED;
606
- }
607
-
608
- if (obj.visibility === "direct") {
609
- // Check both to AND cc (the canonical gate does), not toJson alone.
610
- return isExplicitRecipient(obj, currentActorApId)
611
- ? ALLOW_PRIVATE
612
- : DENY_NOT_AUTHORIZED;
613
- }
614
-
615
- // Unknown visibility - deny by default
616
- return DENY_NOT_AUTHORIZED;
542
+ return ALLOW_PRIVATE;
617
543
  }
618
544
 
619
545
  async function serveMediaByR2Key(c: MediaContext, r2Key: string) {