@takosjp/yurucommu-core 3.4.3 → 3.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/migrations/0023_delivery_resolution_outbox.sql +33 -0
  2. package/migrations/0024_delivery_fanout_outbox.sql +35 -0
  3. package/migrations/0025_delivery_endpoint_terminal_retention.sql +6 -0
  4. package/migrations/0026_remote_actor_fetch_failures.sql +29 -0
  5. package/migrations/0027_remote_actor_tombstones.sql +12 -0
  6. package/migrations/0028_remote_actor_delivery_fence.sql +30 -0
  7. package/migrations/0029_delivery_endpoint_recipients.sql +47 -0
  8. package/package.json +2 -1
  9. package/packages/api/package.json +1 -1
  10. package/packages/api/src/lib/api/communities.ts +2 -0
  11. package/packages/api/src/lib/api/fetch.ts +69 -13
  12. package/packages/api/src/lib/api/normalize.ts +32 -10
  13. package/packages/api/src/lib/api/notifications.ts +4 -1
  14. package/packages/api/src/lib/rtc-client.ts +127 -29
  15. package/src/backend/federation-helpers.ts +1 -0
  16. package/src/backend/index.ts +16 -3
  17. package/src/backend/lib/account-migration.ts +340 -0
  18. package/src/backend/lib/activity-delete-cascade.ts +193 -0
  19. package/src/backend/lib/activitypub-actor-cache.ts +615 -48
  20. package/src/backend/lib/activitypub-actor-identity-sql.ts +179 -0
  21. package/src/backend/lib/activitypub-actor-identity.ts +39 -0
  22. package/src/backend/lib/activitypub-validators.ts +62 -4
  23. package/src/backend/lib/ap-ids.ts +36 -6
  24. package/src/backend/lib/ap-verify.ts +7 -17
  25. package/src/backend/lib/blocklist-purge.ts +676 -42
  26. package/src/backend/lib/blocklist.ts +278 -39
  27. package/src/backend/lib/community-visibility.ts +47 -33
  28. package/src/backend/lib/delivery/fanout-outbox.ts +336 -0
  29. package/src/backend/lib/delivery/planner.ts +16 -12
  30. package/src/backend/lib/delivery/queue-batching.ts +389 -145
  31. package/src/backend/lib/delivery/queue-delivery.ts +47 -25
  32. package/src/backend/lib/delivery/queue.ts +479 -73
  33. package/src/backend/lib/delivery/resolution-outbox.ts +456 -0
  34. package/src/backend/lib/delivery/types.ts +31 -7
  35. package/src/backend/lib/feed-exclude.ts +40 -28
  36. package/src/backend/lib/follow-edge-mutations.ts +217 -0
  37. package/src/backend/lib/notification-eligibility.ts +15 -9
  38. package/src/backend/lib/notification-push.ts +2 -2
  39. package/src/backend/lib/oidc-id-token.ts +66 -0
  40. package/src/backend/lib/personal-actor-moderation.ts +239 -0
  41. package/src/backend/lib/post-visibility.ts +79 -16
  42. package/src/backend/lib/remote-activity-id.ts +61 -0
  43. package/src/backend/lib/unread-counts.ts +3 -0
  44. package/src/backend/retention.ts +38 -1
  45. package/src/backend/routes/account-teardown.ts +422 -156
  46. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +104 -105
  47. package/src/backend/routes/activitypub/handlers/inbound-community-scope.ts +121 -0
  48. package/src/backend/routes/activitypub/handlers/inbound-object-identity.ts +54 -0
  49. package/src/backend/routes/activitypub/handlers/inbound-reply-target.ts +34 -0
  50. package/src/backend/routes/activitypub/handlers/inbound-story-projection.ts +438 -0
  51. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1121 -806
  52. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +118 -153
  53. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +185 -168
  54. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +220 -32
  55. package/src/backend/routes/activitypub/inbound-activity-identity.ts +16 -0
  56. package/src/backend/routes/activitypub/inbound-activity-reference.ts +116 -0
  57. package/src/backend/routes/activitypub/inbound-addressing.ts +87 -0
  58. package/src/backend/routes/activitypub/inbox-addressing.ts +22 -19
  59. package/src/backend/routes/activitypub/inbox-types.ts +14 -2
  60. package/src/backend/routes/activitypub/inbox.ts +81 -105
  61. package/src/backend/routes/activitypub/outbox.ts +0 -0
  62. package/src/backend/routes/activitypub.ts +6 -5
  63. package/src/backend/routes/actors-helpers.ts +34 -8
  64. package/src/backend/routes/actors.ts +328 -164
  65. package/src/backend/routes/auth-helpers.ts +57 -9
  66. package/src/backend/routes/auth.ts +10 -2
  67. package/src/backend/routes/communities/membership-invites.ts +4 -1
  68. package/src/backend/routes/communities/membership-members.ts +201 -73
  69. package/src/backend/routes/communities/membership-requests.ts +141 -76
  70. package/src/backend/routes/communities/membership-shared.ts +228 -21
  71. package/src/backend/routes/communities/messages.ts +9 -2
  72. package/src/backend/routes/communities/routes.ts +48 -13
  73. package/src/backend/routes/dm/contacts.ts +29 -41
  74. package/src/backend/routes/dm/conversations-helpers.ts +9 -1
  75. package/src/backend/routes/dm/messages.ts +36 -42
  76. package/src/backend/routes/dm/read-archive.ts +4 -2
  77. package/src/backend/routes/dm/requests.ts +62 -54
  78. package/src/backend/routes/follow-helpers.ts +200 -60
  79. package/src/backend/routes/follow.ts +146 -140
  80. package/src/backend/routes/media.ts +20 -94
  81. package/src/backend/routes/moderation.ts +72 -4
  82. package/src/backend/routes/notes.ts +3 -4
  83. package/src/backend/routes/notifications.ts +209 -108
  84. package/src/backend/routes/posts/delete-cascade.ts +253 -89
  85. package/src/backend/routes/posts/federation.ts +373 -0
  86. package/src/backend/routes/posts/interactions.ts +160 -184
  87. package/src/backend/routes/posts/like-mutation.ts +240 -0
  88. package/src/backend/routes/posts/post-helpers.ts +112 -162
  89. package/src/backend/routes/posts/queries.ts +150 -54
  90. package/src/backend/routes/posts/routes.ts +169 -329
  91. package/src/backend/routes/posts/transformers.ts +61 -6
  92. package/src/backend/routes/recommendations.ts +37 -44
  93. package/src/backend/routes/rtc/index.ts +26 -6
  94. package/src/backend/routes/search.ts +39 -55
  95. package/src/backend/routes/stories/interactions.ts +22 -6
  96. package/src/backend/routes/stories/query-helpers.ts +28 -41
  97. package/src/backend/routes/stories/routes.ts +125 -108
  98. package/src/backend/routes/takos-tools/dm.ts +17 -17
  99. package/src/backend/routes/takos-tools/posts.ts +189 -106
  100. package/src/backend/routes/takos-tools/search.ts +13 -4
  101. package/src/backend/routes/takos-tools/timeline.ts +35 -27
  102. package/src/backend/routes/takos-tools-response.ts +6 -2
  103. package/src/backend/routes/timeline.ts +5 -5
  104. package/src/backend/runtime/call-signaling-do.ts +35 -4
  105. package/src/backend/runtime/one-time-ticket.ts +116 -0
  106. package/src/backend/runtime/realtime-stream-do.ts +5 -61
  107. package/src/backend/runtime/signaling-hub.ts +36 -3
  108. package/src/backend/server.ts +95 -80
  109. package/src/db/d1-write.ts +67 -43
  110. package/src/db/schema/federation.ts +42 -1
  111. package/src/db/schema/messaging.ts +110 -2
  112. package/src/db/schema.ts +1 -1
@@ -1,15 +1,16 @@
1
1
  import { formatUsername, safeJsonParse } from "../../federation-helpers.ts";
2
+ import {
3
+ boundAttachmentsJson,
4
+ MAX_ATTACHMENTS,
5
+ MAX_ATTACHMENTS_JSON_LENGTH,
6
+ } from "../../lib/attachments.ts";
2
7
 
3
8
  export const MAX_POST_CONTENT_LENGTH = 5000;
4
9
  export const MAX_POST_SUMMARY_LENGTH = 500;
5
10
  // Attachment bounds live in lib/attachments.ts (shared with the DM and
6
11
  // community-chat validators); re-exported here for the existing post-route and
7
12
  // inbound-handler importers.
8
- export {
9
- boundAttachmentsJson,
10
- MAX_ATTACHMENTS,
11
- MAX_ATTACHMENTS_JSON_LENGTH,
12
- } from "../../lib/attachments.ts";
13
+ export { boundAttachmentsJson, MAX_ATTACHMENTS, MAX_ATTACHMENTS_JSON_LENGTH };
13
14
 
14
15
  /** Truncate a string to `max` characters (no-op when already within bounds). */
15
16
  export function truncate(s: string, max: number): string {
@@ -30,6 +31,60 @@ export function boundInboundSummary(summary: unknown): string | null {
30
31
  ? truncate(summary, MAX_POST_SUMMARY_LENGTH)
31
32
  : null;
32
33
  }
34
+
35
+ /** Maximum persisted ActivityStreams tag objects on one inbound Note. */
36
+ export const MAX_INBOUND_TAGS = 64;
37
+ export const MAX_INBOUND_TAGS_JSON_LENGTH = 16 * 1024;
38
+
39
+ function isJsonRecord(value: unknown): value is Record<string, unknown> {
40
+ return value !== null && typeof value === "object" && !Array.isArray(value);
41
+ }
42
+
43
+ function normalizeBoundedRecordArray(
44
+ value: unknown,
45
+ maxItems: number,
46
+ maxJsonLength: number,
47
+ ): string {
48
+ const candidates =
49
+ value === undefined || value === null
50
+ ? []
51
+ : Array.isArray(value)
52
+ ? value
53
+ : [value];
54
+ const records: Record<string, unknown>[] = [];
55
+ for (const candidate of candidates) {
56
+ if (!isJsonRecord(candidate)) continue;
57
+ records.push(candidate);
58
+ if (records.length >= maxItems) break;
59
+ }
60
+ const json = JSON.stringify(records);
61
+ return json.length <= maxJsonLength ? json : "[]";
62
+ }
63
+
64
+ /**
65
+ * Normalize the AS2 `attachment` cardinality before it reaches the SQL/API
66
+ * projection. ActivityStreams permits a single object or an array, while the
67
+ * Yurucommu post contract is always an array. Invalid entries are ignored and
68
+ * both count and serialized size are bounded at the local post limits.
69
+ */
70
+ export function boundInboundNoteAttachmentsJson(attachment: unknown): string {
71
+ return boundAttachmentsJson(
72
+ normalizeBoundedRecordArray(
73
+ attachment,
74
+ MAX_ATTACHMENTS,
75
+ MAX_ATTACHMENTS_JSON_LENGTH,
76
+ ),
77
+ );
78
+ }
79
+
80
+ /** Persist only a bounded array of object-shaped AS2 tag entries. */
81
+ export function boundInboundTagsJson(tag: unknown): string {
82
+ return normalizeBoundedRecordArray(
83
+ tag,
84
+ MAX_INBOUND_TAGS,
85
+ MAX_INBOUND_TAGS_JSON_LENGTH,
86
+ );
87
+ }
33
88
  // Capped at 90 (not 100): a page's object ids are re-queried via
34
89
  // `inArray(col, objectApIds)` for like/bookmark enrichment, and Cloudflare D1
35
90
  // allows at most 100 bound parameters per query. 90 leaves headroom for the
@@ -116,7 +171,7 @@ export function formatPost(
116
171
  type: p.type,
117
172
  author: {
118
173
  ap_id: p.attributed_to,
119
- username: formatUsername(p.attributed_to),
174
+ username: formatUsername(p.attributed_to, p.author_username),
120
175
  preferred_username: p.author_username,
121
176
  name: p.author_name,
122
177
  icon_url: p.author_icon_url,
@@ -2,8 +2,8 @@ import { Hono } from "hono";
2
2
  import { sql } from "drizzle-orm";
3
3
  import type { Env, Variables } from "../types.ts";
4
4
  import { formatUsername } from "../federation-helpers.ts";
5
+ import { operatorActorNotBlockedSql } from "../lib/blocklist.ts";
5
6
  import { logger } from "../lib/logger.ts";
6
- import { CacheTags, CacheTTL, withCache } from "../middleware/cache.ts";
7
7
  import { batchLoadActorInfo } from "./communities/membership-shared.ts";
8
8
 
9
9
  const recommendations = new Hono<{ Bindings: Env; Variables: Variables }>();
@@ -16,23 +16,16 @@ const log = logger.child({ component: "recommendations" });
16
16
  * Finds users followed by people the current user follows,
17
17
  * ranked by number of mutual connections.
18
18
  */
19
- recommendations.get(
20
- "/users",
21
- withCache({
22
- ttl: CacheTTL.ACTOR_PROFILE,
23
- varyByActor: true,
24
- cacheTag: CacheTags.ACTOR,
25
- }),
26
- async (c) => {
27
- const actor = c.get("actor");
28
- if (!actor) return c.json({ error: "Unauthorized" }, 401);
19
+ recommendations.get("/users", async (c) => {
20
+ const actor = c.get("actor");
21
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
29
22
 
30
- const db = c.get("db");
31
- const myApId = actor.ap_id;
23
+ const db = c.get("db");
24
+ const myApId = actor.ap_id;
32
25
 
33
- try {
34
- const candidates = await db.all<{ ap_id: string; mutual_count: number }>(
35
- sql`
26
+ try {
27
+ const candidates = await db.all<{ ap_id: string; mutual_count: number }>(
28
+ sql`
36
29
  SELECT f2.following_ap_id AS ap_id, COUNT(DISTINCT f2.follower_ap_id) AS mutual_count
37
30
  FROM follows f1
38
31
  JOIN follows f2 ON f1.following_ap_id = f2.follower_ap_id AND f2.status = 'accepted'
@@ -59,41 +52,41 @@ recommendations.get(
59
52
  -- (actorCache) are unaffected, same scope as the other surfaces.
60
53
  SELECT ap_id FROM actors WHERE deleted_at IS NOT NULL OR is_private = 1
61
54
  )
55
+ AND ${operatorActorNotBlockedSql(sql.raw("f2.following_ap_id"))}
62
56
  GROUP BY f2.following_ap_id
63
57
  ORDER BY mutual_count DESC
64
58
  LIMIT 5
65
59
  `,
66
- );
60
+ );
67
61
 
68
- if (candidates.length === 0) return c.json({ users: [] });
62
+ if (candidates.length === 0) return c.json({ users: [] });
69
63
 
70
- const actorMap = await batchLoadActorInfo(
71
- db,
72
- candidates.map((r) => r.ap_id),
73
- );
64
+ const actorMap = await batchLoadActorInfo(
65
+ db,
66
+ candidates.map((r) => r.ap_id),
67
+ );
74
68
 
75
- const users = candidates.map((row) => {
76
- const info = actorMap.get(row.ap_id);
77
- return {
78
- ap_id: row.ap_id,
79
- preferred_username: info?.preferredUsername ?? null,
80
- name: info?.name ?? null,
81
- icon_url: info?.iconUrl ?? null,
82
- username: formatUsername(row.ap_id),
83
- mutual_count: Number(row.mutual_count),
84
- };
85
- });
69
+ const users = candidates.map((row) => {
70
+ const info = actorMap.get(row.ap_id);
71
+ return {
72
+ ap_id: row.ap_id,
73
+ preferred_username: info?.preferredUsername ?? null,
74
+ name: info?.name ?? null,
75
+ icon_url: info?.iconUrl ?? null,
76
+ username: formatUsername(row.ap_id, info?.preferredUsername),
77
+ mutual_count: Number(row.mutual_count),
78
+ };
79
+ });
86
80
 
87
- return c.json({ users });
88
- } catch (error) {
89
- log.warn("Recommendations failed; returning empty suggestions", {
90
- event: "recommendations.failed",
91
- actorApId: myApId,
92
- error,
93
- });
94
- return c.json({ users: [] });
95
- }
96
- },
97
- );
81
+ return c.json({ users });
82
+ } catch (error) {
83
+ log.warn("Recommendations failed; returning empty suggestions", {
84
+ event: "recommendations.failed",
85
+ actorApId: myApId,
86
+ error,
87
+ });
88
+ return c.json({ users: [] });
89
+ }
90
+ });
98
91
 
99
92
  export default recommendations;
@@ -2,6 +2,7 @@
2
2
  * Call feature routes (WebRTC voice + video).
3
3
  *
4
4
  * POST /ap/rtc/signal server-to-server signaling ingest (HTTP-Signature)
5
+ * POST /api/rtc/ticket mint a one-time short-lived WS ticket
5
6
  * GET /api/rtc/socket browser WebSocket upgrade -> per-user signaling hub
6
7
  * GET /api/rtc/ice mint short-lived ICE (STUN/TURN) servers
7
8
  * POST /api/rtc/calls start a call (block-list gate + callId + ICE)
@@ -23,7 +24,7 @@ import {
23
24
  isActorMismatch,
24
25
  signingActorFromKeyId,
25
26
  } from "../activitypub/inbox.ts";
26
- import { isActorBlocked } from "../../lib/blocklist.ts";
27
+ import { isActorBlockedStrict } from "../../lib/blocklist.ts";
27
28
  import {
28
29
  getSignalingHub,
29
30
  isSignalingAvailable,
@@ -75,20 +76,37 @@ rtc.post("/ap/rtc/signal", async (c) => {
75
76
  if (!local) return c.json({ error: "unknown_recipient" }, 404);
76
77
 
77
78
  // Never ring for a sender the local owner has blocked; drop silently.
78
- if (await isActorBlocked(db, envelope.from)) return c.body(null, 204);
79
+ if (await isActorBlockedStrict(db, envelope.from)) return c.body(null, 204);
79
80
 
80
81
  await getSignalingHub(c.env).deliver(envelope.to, envelope);
81
82
  return c.body(null, 204);
82
83
  });
83
84
 
84
- // --- Browser WebSocket upgrade ---------------------------------------------
85
- rtc.get("/api/rtc/socket", async (c) => {
85
+ // --- Browser WebSocket ticket + upgrade ------------------------------------
86
+ rtc.post("/api/rtc/ticket", async (c) => {
86
87
  const actor = c.get("actor");
87
88
  if (!actor) return c.json({ error: "unauthorized" }, 401);
88
89
  if (!isSignalingAvailable(c.env)) {
89
90
  return c.json({ error: "signaling_unavailable" }, 503);
90
91
  }
91
- return getSignalingHub(c.env).upgrade(c.req.raw, actor.ap_id);
92
+ const ticket = await getSignalingHub(c.env).mintTicket(actor.ap_id);
93
+ if (!ticket) return c.json({ error: "ticket_mint_failed" }, 500);
94
+ return c.json({ ticket, actor_ap_id: actor.ap_id });
95
+ });
96
+
97
+ rtc.get("/api/rtc/socket", async (c) => {
98
+ if (!isSignalingAvailable(c.env)) {
99
+ return c.json({ error: "signaling_unavailable" }, 503);
100
+ }
101
+ const hub = getSignalingHub(c.env);
102
+ const actorParam = c.req.query("actor")?.trim();
103
+ const ticket = c.req.query("ticket")?.trim();
104
+ if (actorParam && ticket) {
105
+ // The actor only selects which per-user DO validates and consumes the
106
+ // ticket. A forged actor value reaches a DO that never minted it.
107
+ return hub.upgrade(c.req.raw, actorParam, ticket);
108
+ }
109
+ return c.json({ error: "unauthorized" }, 401);
92
110
  });
93
111
 
94
112
  // --- ICE servers ------------------------------------------------------------
@@ -118,7 +136,9 @@ rtc.post("/api/rtc/calls", async (c) => {
118
136
  const media = normalizeMedia(payload.media);
119
137
 
120
138
  // Do not let the local owner place a call to a contact they have blocked.
121
- if (await isActorBlocked(db, to)) return c.json({ error: "blocked" }, 403);
139
+ if (await isActorBlockedStrict(db, to)) {
140
+ return c.json({ error: "blocked" }, 403);
141
+ }
122
142
 
123
143
  const provider = createRtcProvider(c.env);
124
144
  const [iceServers, sfuFocus] = await Promise.all([
@@ -19,14 +19,17 @@ import {
19
19
  notDeleted,
20
20
  objects,
21
21
  } from "../../db/index.ts";
22
- import {
23
- parseWebFinger,
24
- tryParseRemoteActor,
25
- } from "../lib/activitypub-validators.ts";
22
+ import { parseWebFinger } from "../lib/activitypub-validators.ts";
23
+ import { cacheRemoteActorDocument } from "../lib/activitypub-actor-cache.ts";
26
24
  import { logger } from "../lib/logger.ts";
27
25
  import { chunkForInClause } from "../lib/chunk.ts";
26
+ import {
27
+ filterBlockedActorApIds,
28
+ isActorBlocked,
29
+ isDomainBlocked,
30
+ } from "../lib/blocklist.ts";
28
31
  import { type ActorInfo, loadActorInfoMap } from "./actors-helpers.ts";
29
- import { excludeBlockedMutedAuthors } from "../lib/feed-exclude.ts";
32
+ import { excludeModeratedActors } from "../lib/feed-exclude.ts";
30
33
  import { withCache } from "../middleware/cache.ts";
31
34
 
32
35
  const log = logger.child({ component: "search" });
@@ -259,7 +262,7 @@ function formatPost(
259
262
  ap_id: post.apId,
260
263
  author: {
261
264
  ap_id: post.attributedTo,
262
- username: formatUsername(post.attributedTo),
265
+ username: formatUsername(post.attributedTo, author?.preferredUsername),
263
266
  preferred_username: author?.preferredUsername ?? null,
264
267
  name: author?.name ?? null,
265
268
  icon_url: author?.iconUrl ?? null,
@@ -371,6 +374,10 @@ search.get("/actors", async (c) => {
371
374
  )
372
375
  .limit(ACTOR_SEARCH_SCAN_CAP),
373
376
  ]);
377
+ const blockedCachedApIds = await filterBlockedActorApIds(
378
+ db,
379
+ cachedRows.map((actor) => actor.apId),
380
+ );
374
381
 
375
382
  // UNION local + cached, with local taking priority on apId collision.
376
383
  const seen = new Set(localRows.map((a) => a.apId));
@@ -385,7 +392,7 @@ search.get("/actors", async (c) => {
385
392
  }[] = [
386
393
  ...localRows,
387
394
  ...cachedRows
388
- .filter((a) => !seen.has(a.apId))
395
+ .filter((a) => !seen.has(a.apId) && !blockedCachedApIds.has(a.apId))
389
396
  .map((a) => ({
390
397
  apId: a.apId,
391
398
  preferredUsername: a.preferredUsername,
@@ -430,7 +437,7 @@ search.get("/actors", async (c) => {
430
437
  summary: a.summary,
431
438
  follower_count: a.followerCount,
432
439
  created_at: a.createdAt,
433
- username: formatUsername(a.apId),
440
+ username: formatUsername(a.apId, a.preferredUsername),
434
441
  }));
435
442
 
436
443
  return c.json({ actors: result, limit, offset, has_more });
@@ -466,7 +473,7 @@ search.get("/posts", async (c) => {
466
473
  // Suppress blocked/muted authors so keyword search honors the same
467
474
  // moderation filter the home/timeline/notifications feeds apply
468
475
  // (undefined for an anonymous viewer → and() drops it).
469
- excludeBlockedMutedAuthors(db, actor?.ap_id ?? ""),
476
+ excludeModeratedActors(actor?.ap_id ?? ""),
470
477
  ),
471
478
  )
472
479
  .orderBy(...postOrderByDrizzle(sort))
@@ -498,6 +505,14 @@ search.get("/remote", async (c) => {
498
505
  const [, username, domain] = match;
499
506
  const safeDomain = normalizeRemoteDomain(domain);
500
507
  if (!safeDomain) return c.json({ actors: [] });
508
+ const db = c.get("db");
509
+
510
+ // Remote search is an active federation fetch, not a passive local lookup.
511
+ // A domain the operator defederated must never be contacted through this
512
+ // convenience endpoint after ingress and delivery have already been cut.
513
+ if (await isDomainBlocked(db, safeDomain)) {
514
+ return c.json({ actors: [] });
515
+ }
501
516
 
502
517
  try {
503
518
  // WebFinger lookup
@@ -521,6 +536,12 @@ search.get("/remote", async (c) => {
521
536
  if (!actorLink?.href || !isSafeRemoteUrl(actorLink.href)) {
522
537
  return c.json({ actors: [] });
523
538
  }
539
+ // WebFinger may redirect discovery authority to a different actor host.
540
+ // Re-check the resolved actor before the signed GET so an allowed alias
541
+ // cannot bypass an actor or domain block and repopulate the local cache.
542
+ if (await isActorBlocked(db, actorLink.href)) {
543
+ return c.json({ actors: [] });
544
+ }
524
545
 
525
546
  // Fetch actor profile. Sign the GET as the instance actor so a remote in
526
547
  // authorized-fetch / secure mode (which 401s the unsigned actor GET while
@@ -542,55 +563,18 @@ search.get("/remote", async (c) => {
542
563
  if (!actorRes.ok) return c.json({ actors: [] });
543
564
 
544
565
  const actorRaw: unknown = await actorRes.json();
545
- const actorData = tryParseRemoteActor(actorRaw);
546
- if (!actorData) return c.json({ actors: [] });
547
-
548
- if (actorData.id !== actorLink.href || !isSafeRemoteUrl(actorData.id)) {
549
- return c.json({ actors: [] });
550
- }
551
-
552
- // Cache the actor (upsert: check if exists, then insert or update)
553
- const db = c.get("db");
554
- const cacheFields = {
555
- type: actorData.type || "Person",
556
- preferredUsername: actorData.preferredUsername || null,
557
- name: actorData.name || null,
558
- summary: actorData.summary || null,
559
- iconUrl: actorData.icon?.url || null,
560
- inbox: actorData.inbox || "",
561
- outbox: actorData.outbox || null,
562
- publicKeyId: actorData.publicKey?.id || null,
563
- publicKeyPem: actorData.publicKey?.publicKeyPem || null,
564
- rawJson: JSON.stringify(actorRaw),
565
- };
566
-
567
- const existing = await db
568
- .select({ apId: actorCache.apId })
569
- .from(actorCache)
570
- .where(eq(actorCache.apId, actorData.id))
571
- .get();
572
-
573
- if (existing) {
574
- await db
575
- .update(actorCache)
576
- .set(cacheFields)
577
- .where(eq(actorCache.apId, actorData.id));
578
- } else {
579
- await db.insert(actorCache).values({
580
- apId: actorData.id,
581
- ...cacheFields,
582
- });
583
- }
566
+ const cached = await cacheRemoteActorDocument(db, actorLink.href, actorRaw);
567
+ if (!cached.ok) return c.json({ actors: [] });
584
568
 
585
569
  return c.json({
586
570
  actors: [
587
571
  {
588
- ap_id: actorData.id,
589
- username: `${actorData.preferredUsername}@${safeDomain}`,
590
- preferred_username: actorData.preferredUsername,
591
- name: actorData.name,
592
- summary: actorData.summary,
593
- icon_url: actorData.icon?.url,
572
+ ap_id: cached.row.apId,
573
+ username: `${cached.row.preferredUsername ?? username}@${safeDomain}`,
574
+ preferred_username: cached.row.preferredUsername,
575
+ name: cached.row.name,
576
+ summary: cached.row.summary,
577
+ icon_url: cached.row.iconUrl,
594
578
  },
595
579
  ],
596
580
  });
@@ -625,7 +609,7 @@ search.get("/hashtag/:tag", async (c) => {
625
609
  const postWhere = publicSearchableWhere(
626
610
  sql`instr(lower(${objects.content}), lower(${hashtagPattern})) > 0`,
627
611
  // Same block/mute moderation filter as the feeds (see /search/posts).
628
- excludeBlockedMutedAuthors(db, actor?.ap_id ?? ""),
612
+ excludeModeratedActors(actor?.ap_id ?? ""),
629
613
  );
630
614
 
631
615
  // `LIKE '%#tag%'` is a SUPERSET prefilter: it also matches longer tags that
@@ -31,6 +31,8 @@ import { actorIsBlockedBy } from "../../lib/post-visibility.ts";
31
31
  import { rateLimit, RateLimitConfigs } from "../../middleware/rate-limit.ts";
32
32
  import { logger } from "../../lib/logger.ts";
33
33
  import { isUniqueConstraintError } from "../../lib/parse-helpers.ts";
34
+ import { excludeModeratedActors } from "../../lib/feed-exclude.ts";
35
+ import { activityDeleteCascadeStatements } from "../../lib/activity-delete-cascade.ts";
34
36
 
35
37
  const log = logger.child({ component: "stories.interactions" });
36
38
 
@@ -354,11 +356,14 @@ stories.delete("/:id/like", async (c) => {
354
356
  // time and the re-like dedup guard only checks the `likes` edge (removed here),
355
357
  // so without this a like→unlike→like cycle would leave the first inbox/activity
356
358
  // rows behind and add new ones — duplicate "X liked your story" + a phantom
357
- // unread +1. Mirrors the post-path unlike reap.
359
+ // unread +1. Remove all activity-keyed push/delivery projections too; mirrors
360
+ // the post-path unlike reap.
358
361
  const reapLikeNotification = like.activityApId
359
362
  ? [
360
- db.delete(inbox).where(eq(inbox.activityApId, like.activityApId)),
361
- db.delete(activities).where(eq(activities.apId, like.activityApId)),
363
+ ...activityDeleteCascadeStatements(
364
+ db,
365
+ eq(activities.apId, like.activityApId),
366
+ ),
362
367
  ]
363
368
  : [];
364
369
  await (db as unknown as Batchable).batch([
@@ -602,11 +607,17 @@ stories.get("/:id/views", async (c) => {
602
607
  return c.json({ error: "Story has expired" }, 410);
603
608
  }
604
609
 
605
- // True total (uncapped) — stays accurate even when the list below is capped.
610
+ // True eligible total (uncapped) — stays accurate even when the list below is
611
+ // capped, while retained operator/personal-blocked viewers stay suppressed.
606
612
  const totalRow = await db
607
613
  .select({ value: count() })
608
614
  .from(storyViews)
609
- .where(eq(storyViews.storyApId, apId))
615
+ .where(
616
+ and(
617
+ eq(storyViews.storyApId, apId),
618
+ excludeModeratedActors(actor.ap_id, storyViews.actorApId),
619
+ ),
620
+ )
610
621
  .get();
611
622
  const view_count = totalRow?.value ?? 0;
612
623
 
@@ -617,7 +628,12 @@ stories.get("/:id/views", async (c) => {
617
628
  viewedAt: storyViews.viewedAt,
618
629
  })
619
630
  .from(storyViews)
620
- .where(eq(storyViews.storyApId, apId))
631
+ .where(
632
+ and(
633
+ eq(storyViews.storyApId, apId),
634
+ excludeModeratedActors(actor.ap_id, storyViews.actorApId),
635
+ ),
636
+ )
621
637
  .orderBy(desc(storyViews.viewedAt))
622
638
  .limit(STORY_VIEWERS_LIMIT);
623
639
 
@@ -3,10 +3,8 @@ import type { Database } from "../../../db/index.ts";
3
3
  import {
4
4
  actorCache,
5
5
  actors,
6
- blocks,
7
6
  communityMembers,
8
7
  follows,
9
- mutes,
10
8
  objects,
11
9
  storyVotes,
12
10
  } from "../../../db/index.ts";
@@ -17,10 +15,14 @@ import {
17
15
  safeJsonParse,
18
16
  } from "../../federation-helpers.ts";
19
17
  import {
20
- deleteObjectCascade,
18
+ deleteObjectsCascade,
21
19
  purgeMediaBlobs,
22
20
  } from "../posts/delete-cascade.ts";
23
21
  import { chunkForInClause } from "../../lib/chunk.ts";
22
+ import {
23
+ isActorBlockedStrict,
24
+ operatorActorNotBlockedSql,
25
+ } from "../../lib/blocklist.ts";
24
26
 
25
27
  // Bound the per-run expired-story reap. The cleanup is opportunistic (fired,
26
28
  // unawaited, from the hot story-read path), so a hostile followed host that
@@ -108,6 +110,9 @@ export async function canViewerReadStory(
108
110
  viewerApId: string | null | undefined,
109
111
  ): Promise<boolean> {
110
112
  if (!viewerApId) return false;
113
+ // Retained rows from a failed/partial purge are unreadable and cannot be
114
+ // interacted with while their author is instance-operator blocked.
115
+ if (await isActorBlockedStrict(db, story.attributedTo)) return false;
111
116
  if (story.attributedTo === viewerApId) return true;
112
117
  if (story.communityApId) {
113
118
  const member = await db
@@ -136,32 +141,6 @@ export async function canViewerReadStory(
136
141
  return Boolean(follow);
137
142
  }
138
143
 
139
- // ---------------------------------------------------------------------------
140
- // Blocked / muted helpers
141
- // ---------------------------------------------------------------------------
142
-
143
- /** Fetch blocked and muted ap_ids for the given actor. */
144
- export async function fetchBlockedAndMutedIds(
145
- db: Database,
146
- actorApId: string,
147
- ): Promise<{ blockedIds: string[]; mutedIds: string[] }> {
148
- const [blockRows, muteRows] = await Promise.all([
149
- db
150
- .select({ blockedApId: blocks.blockedApId })
151
- .from(blocks)
152
- .where(eq(blocks.blockerApId, actorApId)),
153
- db
154
- .select({ mutedApId: mutes.mutedApId })
155
- .from(mutes)
156
- .where(eq(mutes.muterApId, actorApId)),
157
- ]);
158
-
159
- return {
160
- blockedIds: blockRows.map((b) => b.blockedApId),
161
- mutedIds: muteRows.map((m) => m.mutedApId),
162
- };
163
- }
164
-
165
144
  // ---------------------------------------------------------------------------
166
145
  // Vote helpers
167
146
  // ---------------------------------------------------------------------------
@@ -177,7 +156,12 @@ export async function getVoteCounts(
177
156
  count: count(),
178
157
  })
179
158
  .from(storyVotes)
180
- .where(eq(storyVotes.storyApId, storyApId))
159
+ .where(
160
+ and(
161
+ eq(storyVotes.storyApId, storyApId),
162
+ operatorActorNotBlockedSql(sql`${storyVotes.actorApId}`),
163
+ ),
164
+ )
181
165
  .groupBy(storyVotes.optionIndex);
182
166
 
183
167
  return Object.fromEntries(votes.map((v) => [v.optionIndex, v.count]));
@@ -211,7 +195,12 @@ export async function fetchBatchVotes(
211
195
  count: count(),
212
196
  })
213
197
  .from(storyVotes)
214
- .where(inArray(storyVotes.storyApId, storyApIds))
198
+ .where(
199
+ and(
200
+ inArray(storyVotes.storyApId, storyApIds),
201
+ operatorActorNotBlockedSql(sql`${storyVotes.actorApId}`),
202
+ ),
203
+ )
215
204
  .groupBy(storyVotes.storyApId, storyVotes.optionIndex);
216
205
 
217
206
  const allVotes: Record<string, VoteResults> = {};
@@ -232,6 +221,7 @@ export async function fetchBatchVotes(
232
221
  and(
233
222
  inArray(storyVotes.storyApId, storyApIds),
234
223
  eq(storyVotes.actorApId, actorApId),
224
+ operatorActorNotBlockedSql(sql`${storyVotes.actorApId}`),
235
225
  ),
236
226
  );
237
227
  userVotes = Object.fromEntries(
@@ -311,7 +301,7 @@ export function buildAuthor(
311
301
  ): StoryAuthor {
312
302
  return {
313
303
  ap_id: apId,
314
- username: formatUsername(apId),
304
+ username: formatUsername(apId, data?.preferredUsername),
315
305
  preferred_username: data?.preferredUsername || null,
316
306
  name: data?.name || null,
317
307
  icon_url: data?.iconUrl || null,
@@ -338,17 +328,14 @@ export async function cleanupExpiredStories(
338
328
 
339
329
  const expiredApIds = expiredStories.map((s) => s.apId);
340
330
 
341
- // Reap every child edge for each expired story through the shared object
331
+ // Reap every child edge for the expired set through the shared batched object
342
332
  // cascade (likes / announces / bookmarks / object_recipients / story_views /
343
333
  // story_votes / story_shares + attached media_uploads), so expiry can't
344
- // orphan rows the hand-rolled list previously missed (announces, bookmarks,
345
- // media). The helper reads each object row, so run it before deleting them.
346
- // When a `media` binding is threaded in, the backing R2 blobs are best-effort
347
- // purged too so expired-story storage does not leak.
348
- const mediaKeys: string[] = [];
349
- for (const apId of expiredApIds) {
350
- mediaKeys.push(...(await deleteObjectCascade(db, apId, media)));
351
- }
334
+ // orphan rows the hand-rolled list previously missed or make one D1 round
335
+ // trip per child table per story. The helper reads the object rows, so run it
336
+ // before deleting them. When a `media` binding is threaded in, the backing R2
337
+ // blobs are best-effort purged too so expired-story storage does not leak.
338
+ const mediaKeys = await deleteObjectsCascade(db, expiredApIds, media);
352
339
 
353
340
  // Delete EXACTLY the cascaded set (chunked for D1's 100-bound-param cap), not a
354
341
  // broad `endTime < now` — with the per-run limit a broad delete could remove a