@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
@@ -28,6 +28,8 @@ import {
28
28
  import {
29
29
  blockActor,
30
30
  blockDomain,
31
+ domainSuffixCandidates,
32
+ normalizeDomain,
31
33
  unblockActor,
32
34
  unblockDomain,
33
35
  } from "../lib/blocklist.ts";
@@ -91,6 +93,29 @@ function readReason(body: unknown): string | null {
91
93
  return typeof reason === "string" && reason.length > 0 ? reason : null;
92
94
  }
93
95
 
96
+ function domainBlockCoversLocalHost(
97
+ domainOrUrl: string,
98
+ appUrl: string,
99
+ ): boolean {
100
+ const requestedDomain = normalizeDomain(domainOrUrl);
101
+ const localDomain = normalizeDomain(appUrl);
102
+ return (
103
+ requestedDomain !== null &&
104
+ localDomain !== null &&
105
+ domainSuffixCandidates(localDomain).includes(requestedDomain)
106
+ );
107
+ }
108
+
109
+ function actorBlockTargetsLocalHost(
110
+ actorApId: string,
111
+ appUrl: string,
112
+ ): boolean {
113
+ if (isLocal(actorApId, appUrl)) return true;
114
+ const actorDomain = normalizeDomain(actorApId);
115
+ const localDomain = normalizeDomain(appUrl);
116
+ return actorDomain !== null && actorDomain === localDomain;
117
+ }
118
+
94
119
  // ---------------------------------------------------------------------------
95
120
  // Blocked domains
96
121
  // ---------------------------------------------------------------------------
@@ -125,6 +150,14 @@ moderationRoutes.post("/domains", async (c) => {
125
150
 
126
151
  const parsed = await readApIdBody(c, "domain");
127
152
  if ("error" in parsed) return parsed.error;
153
+ // A domain block immediately purges already-ingested content. Blocking the
154
+ // local host (or one of its parent domains) would therefore turn an operator
155
+ // typo into irreversible deletion of local posts and Activity rows. Reject
156
+ // before writing the blocklist row; this is a destructive safety boundary,
157
+ // not merely a delivery preference.
158
+ if (domainBlockCoversLocalHost(parsed.value, c.env.APP_URL)) {
159
+ return c.json({ error: "Cannot block the local domain" }, 400);
160
+ }
128
161
 
129
162
  try {
130
163
  await blockDomain(c.get("db"), parsed.value, readReason(parsed.body));
@@ -133,8 +166,24 @@ moderationRoutes.post("/domains", async (c) => {
133
166
  }
134
167
  // Defederation also purges the domain's already-ingested content so it stops
135
168
  // being served (the blocklist is otherwise ingest/delivery-only).
136
- await purgeDomainContent(c.get("db"), parsed.value, c.env.MEDIA);
137
- return c.json({ success: true });
169
+ const purge = await purgeDomainContent(
170
+ c.get("db"),
171
+ parsed.value,
172
+ c.env.MEDIA,
173
+ );
174
+ if (!purge.complete) {
175
+ c.header("Retry-After", "1");
176
+ return c.json(
177
+ {
178
+ error:
179
+ "Domain block is active, but retained content cleanup did not finish. Retry this block to complete cleanup.",
180
+ block_applied: true,
181
+ cleanup_complete: false,
182
+ },
183
+ 503,
184
+ );
185
+ }
186
+ return c.json({ success: true, cleanup_complete: true });
138
187
  });
139
188
 
140
189
  moderationRoutes.delete("/domains", async (c) => {
@@ -186,6 +235,13 @@ moderationRoutes.post("/actors", async (c) => {
186
235
 
187
236
  const parsed = await readApIdBody(c, "ap_id");
188
237
  if ("error" in parsed) return parsed.error;
238
+ // Operator actor blocks use the same destructive retained-content purge.
239
+ // They are only for remote actors; never let a local AP ID reach the block
240
+ // mutation or purge path. The normalized-host check also catches cosmetic
241
+ // trailing-root-dot spellings that URL-origin comparison alone misses.
242
+ if (actorBlockTargetsLocalHost(parsed.value, c.env.APP_URL)) {
243
+ return c.json({ error: "Cannot block a local actor" }, 400);
244
+ }
189
245
 
190
246
  try {
191
247
  await blockActor(c.get("db"), parsed.value, readReason(parsed.body));
@@ -193,8 +249,20 @@ moderationRoutes.post("/actors", async (c) => {
193
249
  return c.json({ error: "Invalid actor" }, 400);
194
250
  }
195
251
  // Purge the blocked actor's already-ingested content so it stops being served.
196
- await purgeActorContent(c.get("db"), parsed.value, c.env.MEDIA);
197
- return c.json({ success: true });
252
+ const purge = await purgeActorContent(c.get("db"), parsed.value, c.env.MEDIA);
253
+ if (!purge.complete) {
254
+ c.header("Retry-After", "1");
255
+ return c.json(
256
+ {
257
+ error:
258
+ "Actor block is active, but retained content cleanup did not finish. Retry this block to complete cleanup.",
259
+ block_applied: true,
260
+ cleanup_complete: false,
261
+ },
262
+ 503,
263
+ );
264
+ }
265
+ return c.json({ success: true, cleanup_complete: true });
198
266
  });
199
267
 
200
268
  moderationRoutes.delete("/actors", async (c) => {
@@ -5,7 +5,7 @@ import type { SQL } from "drizzle-orm";
5
5
  import { actorNotes, actors, follows, type Database } from "../../db/index.ts";
6
6
  import type { Env, Variables } from "../types.ts";
7
7
  import { formatUsername } from "../federation-helpers.ts";
8
- import { excludeBlockedMutedAuthors } from "../lib/feed-exclude.ts";
8
+ import { excludeModeratedActors } from "../lib/feed-exclude.ts";
9
9
 
10
10
  const notes = new Hono<{ Bindings: Env; Variables: Variables }>();
11
11
 
@@ -64,7 +64,7 @@ function formatNote(row: NoteRow, viewerApId: string): NoteResponse {
64
64
  return {
65
65
  actor: {
66
66
  ap_id: row.actorApId,
67
- username: formatUsername(row.actorApId),
67
+ username: formatUsername(row.actorApId, row.preferredUsername),
68
68
  preferred_username: row.preferredUsername,
69
69
  name: row.name,
70
70
  icon_url: row.iconUrl,
@@ -98,8 +98,7 @@ async function loadActiveNotes(
98
98
  inArray(actorNotes.actorApId, followingSubquery),
99
99
  )!,
100
100
  ];
101
- const excludeAuthors = excludeBlockedMutedAuthors(
102
- db,
101
+ const excludeAuthors = excludeModeratedActors(
103
102
  viewerApId,
104
103
  actorNotes.actorApId,
105
104
  );
@@ -11,6 +11,7 @@ import {
11
11
  isNull,
12
12
  lt,
13
13
  or,
14
+ sql,
14
15
  type SQL,
15
16
  } from "drizzle-orm";
16
17
  import type { Env, Variables } from "../types.ts";
@@ -22,9 +23,11 @@ import {
22
23
  import type { Database } from "../../db/index.ts";
23
24
  import {
24
25
  activities,
26
+ affectedRowCount,
25
27
  follows,
26
28
  inbox as inboxTable,
27
29
  notificationArchived,
30
+ objectRecipients,
28
31
  objects,
29
32
  } from "../../db/index.ts";
30
33
  import { batchLoadActorInfo } from "./communities/membership-shared.ts";
@@ -39,6 +42,7 @@ import {
39
42
  emitUnreadSnapshot,
40
43
  runRealtimeAfterResponse,
41
44
  } from "../runtime/realtime-hub.ts";
45
+ import { passesPostVisibilitySync } from "../lib/post-visibility.ts";
42
46
 
43
47
  const notifications = new Hono<{ Bindings: Env; Variables: Variables }>();
44
48
 
@@ -49,14 +53,43 @@ const ARCHIVED_CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
49
53
  // at 100 bound parameters (libsql, used by tests, allows ~32k and hides this).
50
54
  const MAX_ARCHIVE_BATCH_SIZE = 90;
51
55
  const MAX_READ_BATCH_SIZE = 90;
52
- // Multi-row INSERT chunk: each archive row binds 3 columns, so a chunk of N
53
- // rows uses 3*N bound params. 30*3 = 90, under D1's 100-param ceiling.
56
+ // INSERT ... SELECT chunk: each requested id is one bind, plus actor + archive
57
+ // timestamp. Keep the established 30-id chunk comfortably under D1's
58
+ // 100-parameter ceiling.
54
59
  const ARCHIVE_CREATE_BATCH_SIZE = 30;
55
60
  // Bound the opportunistic retention-cleanup per run so a user with a very large
56
61
  // archived backlog doesn't load + delete it all in one fired-from-read-path run;
57
62
  // the rest drains on later runs.
58
63
  const ARCHIVE_CLEANUP_BATCH = 200;
59
- const ARCHIVE_ALL_CAP = 1000;
64
+
65
+ type ParsedNotificationIds =
66
+ | { readonly ok: true; readonly ids: string[] }
67
+ | { readonly ok: false; readonly reason: "invalid" | "too_long" };
68
+
69
+ /**
70
+ * Runtime owner for notification mutation ID lists. JSON generic arguments do
71
+ * not validate input, and passing a string or mixed array to Drizzle's
72
+ * `inArray` can become a 500 or a partially applied write. Normalizing here
73
+ * keeps read, archive, and unarchive on one pre-write contract.
74
+ */
75
+ function parseNotificationIds(
76
+ value: unknown,
77
+ maxSize: number,
78
+ ): ParsedNotificationIds {
79
+ if (!Array.isArray(value) || value.length === 0) {
80
+ return { ok: false, reason: "invalid" };
81
+ }
82
+ if (value.length > maxSize) {
83
+ return { ok: false, reason: "too_long" };
84
+ }
85
+ if (value.some((id) => typeof id !== "string" || id.trim().length === 0)) {
86
+ return { ok: false, reason: "invalid" };
87
+ }
88
+ return {
89
+ ok: true,
90
+ ids: [...new Set(value.map((id) => (id as string).trim()))],
91
+ };
92
+ }
60
93
 
61
94
  /**
62
95
  * Tracks the last cleanup timestamp per actor so cleanup is throttled to one
@@ -93,28 +126,111 @@ export const __archivedCleanupInternals = {
93
126
  };
94
127
 
95
128
  /**
96
- * Batch-insert archive rows with unique-constraint tolerance.
129
+ * Materialize archive markers only from rows in the authenticated actor's
130
+ * retained inbox. Keeping eligibility and insertion in the same statement
131
+ * prevents foreign, missing, or concurrently removed inbox rows from becoming
132
+ * durable markers. The unique constraint makes retries idempotent.
133
+ *
97
134
  * Returns the number of rows actually inserted.
98
135
  */
99
136
  async function batchArchiveInsert(
100
137
  db: Database,
101
- rows: Array<{ actorApId: string; activityApId: string; archivedAt: string }>,
138
+ actorApId: string,
139
+ activityApIds: string[],
140
+ archivedAt: string,
102
141
  batchSize: number,
103
142
  ): Promise<number> {
104
143
  let inserted = 0;
105
144
 
106
- for (let i = 0; i < rows.length; i += batchSize) {
107
- const batch = rows.slice(i, i + batchSize);
145
+ for (let i = 0; i < activityApIds.length; i += batchSize) {
146
+ const batch = activityApIds.slice(i, i + batchSize);
147
+ const candidates = db
148
+ .select({
149
+ actorApId: inboxTable.actorApId,
150
+ activityApId: inboxTable.activityApId,
151
+ archivedAt: sql<string>`${archivedAt}`.as("archived_at"),
152
+ })
153
+ .from(inboxTable)
154
+ .where(
155
+ and(
156
+ eq(inboxTable.actorApId, actorApId),
157
+ inArray(inboxTable.activityApId, batch),
158
+ ),
159
+ );
108
160
  const result = await db
109
161
  .insert(notificationArchived)
110
- .values(batch)
162
+ .select(candidates)
111
163
  .onConflictDoNothing();
112
- inserted += (result as { meta?: { changes?: number } }).meta?.changes ?? 0;
164
+ inserted += affectedRowCount(result);
113
165
  }
114
166
 
115
167
  return inserted;
116
168
  }
117
169
 
170
+ /**
171
+ * Archive the authenticated actor's complete retained inbox as one set-based
172
+ * authority transition. A client-side or server-side page loop would expose a
173
+ * partial-success state between batches; INSERT ... SELECT gives SQLite/D1 one
174
+ * atomic statement, binds only actor + timestamp, and stays idempotent under
175
+ * retries through the archive primary key.
176
+ */
177
+ async function archiveAllInboxRows(
178
+ db: Database,
179
+ actorApId: string,
180
+ archivedAt: string,
181
+ ): Promise<number> {
182
+ const candidates = db
183
+ .select({
184
+ actorApId: inboxTable.actorApId,
185
+ activityApId: inboxTable.activityApId,
186
+ archivedAt: sql<string>`${archivedAt}`.as("archived_at"),
187
+ })
188
+ .from(inboxTable)
189
+ .where(eq(inboxTable.actorApId, actorApId));
190
+ const result = await db
191
+ .insert(notificationArchived)
192
+ .select(candidates)
193
+ .onConflictDoNothing();
194
+ return affectedRowCount(result);
195
+ }
196
+
197
+ /**
198
+ * Mark every currently visible social notification read using the same
199
+ * authority predicate as the list and unread badge. Inbox also retains rows
200
+ * for DMs, archived history, self activity, and non-user-facing ActivityPub
201
+ * types; a broad actor-only UPDATE would silently mutate those other domains.
202
+ */
203
+ async function markAllEligibleNotificationsRead(
204
+ db: Database,
205
+ actorApId: string,
206
+ ): Promise<void> {
207
+ const eligibleActivityIds = db
208
+ .select({ activityApId: inboxTable.activityApId })
209
+ .from(inboxTable)
210
+ .innerJoin(activities, eq(inboxTable.activityApId, activities.apId))
211
+ .leftJoin(objects, eq(activities.objectApId, objects.apId))
212
+ .where(
213
+ and(
214
+ eq(inboxTable.actorApId, actorApId),
215
+ eq(inboxTable.read, 0),
216
+ ...notificationEligibilityWhere(db, actorApId, {
217
+ direct: "exclude",
218
+ }),
219
+ ),
220
+ );
221
+
222
+ await db
223
+ .update(inboxTable)
224
+ .set({ read: 1 })
225
+ .where(
226
+ and(
227
+ eq(inboxTable.actorApId, actorApId),
228
+ eq(inboxTable.read, 0),
229
+ inArray(inboxTable.activityApId, eligibleActivityIds),
230
+ ),
231
+ );
232
+ }
233
+
118
234
  async function cleanupArchivedNotifications(
119
235
  db: Database,
120
236
  actorApId: string,
@@ -383,6 +499,9 @@ notifications.get("/", async (c) => {
383
499
  communityApId: objects.communityApId,
384
500
  visibility: objects.visibility,
385
501
  attributedTo: objects.attributedTo,
502
+ toJson: objects.toJson,
503
+ ccJson: objects.ccJson,
504
+ endTime: objects.endTime,
386
505
  })
387
506
  .from(objects)
388
507
  .where(inArray(objects.apId, objectApIds))
@@ -411,7 +530,10 @@ notifications.get("/", async (c) => {
411
530
  ...new Set(
412
531
  objectRows
413
532
  .filter(
414
- (o) => o.visibility === "followers" && o.attributedTo !== actor.ap_id,
533
+ (o) =>
534
+ o.attributedTo !== actor.ap_id &&
535
+ ((o.type === "Story" && !o.communityApId) ||
536
+ (o.type !== "Story" && o.visibility === "followers")),
415
537
  )
416
538
  .map((o) => o.attributedTo),
417
539
  ),
@@ -434,22 +556,46 @@ notifications.get("/", async (c) => {
434
556
  )
435
557
  : new Set<string>();
436
558
 
437
- const followersGateAllows = (o: {
438
- visibility: string;
439
- attributedTo: string;
440
- }): boolean => {
441
- if (o.visibility !== "followers") return true;
442
- return (
443
- o.attributedTo === actor.ap_id || followedAuthors.has(o.attributedTo)
444
- );
445
- };
559
+ const projectionCandidates = objectRows
560
+ .filter(
561
+ (o) =>
562
+ o.attributedTo !== actor.ap_id &&
563
+ (o.visibility === "followers" || o.visibility === "direct"),
564
+ )
565
+ .map((o) => o.apId);
566
+ const projectedRecipientIds =
567
+ projectionCandidates.length > 0
568
+ ? new Set(
569
+ (
570
+ await db
571
+ .select({ objectApId: objectRecipients.objectApId })
572
+ .from(objectRecipients)
573
+ .where(
574
+ and(
575
+ eq(objectRecipients.recipientApId, actor.ap_id),
576
+ eq(objectRecipients.type, "to"),
577
+ inArray(objectRecipients.objectApId, projectionCandidates),
578
+ ),
579
+ )
580
+ ).map((row) => row.objectApId),
581
+ )
582
+ : new Set<string>();
583
+
584
+ const visibilityReadableRows = objectRows.filter((object) =>
585
+ passesPostVisibilitySync(
586
+ object,
587
+ actor.ap_id,
588
+ (authorApId) => followedAuthors.has(authorApId),
589
+ (objectApId) => projectedRecipientIds.has(objectApId),
590
+ ),
591
+ );
446
592
 
447
593
  // Apply the synchronous followers-gate first, then resolve the community
448
594
  // read-gate for all survivors in ONE batched call (2 queries) rather than
449
595
  // 1-2 queries per row.
450
596
  const readableObjectIds = await communityReadableApIds(
451
597
  db,
452
- objectRows.filter(followersGateAllows),
598
+ visibilityReadableRows,
453
599
  actor.ap_id,
454
600
  );
455
601
 
@@ -522,7 +668,10 @@ notifications.get("/", async (c) => {
522
668
  created_at: entry.createdAt,
523
669
  actor: {
524
670
  ap_id: entry.activityActorApId,
525
- username: formatUsername(entry.activityActorApId),
671
+ username: formatUsername(
672
+ entry.activityActorApId,
673
+ actorInfo?.preferredUsername,
674
+ ),
526
675
  preferred_username: actorInfo?.preferredUsername ?? null,
527
676
  name: actorInfo?.name ?? null,
528
677
  icon_url: actorInfo?.iconUrl ?? null,
@@ -585,23 +734,31 @@ notifications.post("/read", async (c) => {
585
734
  if (actor instanceof Response) return actor;
586
735
 
587
736
  const db = c.get("db");
588
- // A literal `null`/primitive JSON body parses without throwing, then field
589
- // access throws a TypeError that the global handler maps to 500 (not 400).
590
- // Guard the body shape so a malformed request is a clean 400.
591
- const body = await c.req
592
- .json<{ ids?: string[]; read_all?: boolean }>()
593
- .catch(() => null);
594
- if (!body || typeof body !== "object") {
737
+ // JSON's static type parameter is not runtime validation. Accept exactly one
738
+ // operation and validate every value before any write, so truthy strings or
739
+ // mixed arrays cannot trigger a broad or partially applied update.
740
+ const body = await c.req.json<Record<string, unknown>>().catch(() => null);
741
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
595
742
  return c.json({ error: "Invalid request body" }, 400);
596
743
  }
597
744
 
598
- if (body.read_all) {
599
- await db
600
- .update(inboxTable)
601
- .set({ read: 1 })
602
- .where(eq(inboxTable.actorApId, actor.ap_id));
603
- } else if (body.ids && body.ids.length > 0) {
604
- if (body.ids.length > MAX_READ_BATCH_SIZE) {
745
+ const hasReadAll = Object.hasOwn(body, "read_all");
746
+ const hasIds = Object.hasOwn(body, "ids");
747
+ if (hasReadAll === hasIds) {
748
+ return c.json({ error: "Exactly one of ids or read_all is required" }, 400);
749
+ }
750
+
751
+ if (hasReadAll) {
752
+ if (body.read_all !== true) {
753
+ return c.json({ error: "read_all must be true" }, 400);
754
+ }
755
+ await markAllEligibleNotificationsRead(db, actor.ap_id);
756
+ } else {
757
+ const parsedIds = parseNotificationIds(body.ids, MAX_READ_BATCH_SIZE);
758
+ if (!parsedIds.ok && parsedIds.reason === "invalid") {
759
+ return c.json({ error: "ids must be a non-empty array of strings" }, 400);
760
+ }
761
+ if (!parsedIds.ok) {
605
762
  return c.json(
606
763
  {
607
764
  error: "array_too_long",
@@ -616,14 +773,9 @@ notifications.post("/read", async (c) => {
616
773
  .where(
617
774
  and(
618
775
  eq(inboxTable.actorApId, actor.ap_id),
619
- inArray(inboxTable.activityApId, body.ids),
776
+ inArray(inboxTable.activityApId, parsedIds.ids),
620
777
  ),
621
778
  );
622
- } else {
623
- return c.json(
624
- { error: "Either ids array or read_all flag is required" },
625
- 400,
626
- );
627
779
  }
628
780
 
629
781
  // Sync the reader's OTHER tabs/devices: push the fresh authoritative badge.
@@ -640,20 +792,16 @@ notifications.post("/archive", async (c) => {
640
792
  if (actor instanceof Response) return actor;
641
793
 
642
794
  const db = c.get("db");
643
- const body = await c.req.json<{ ids: string[] }>().catch(() => null);
644
- if (!body || typeof body !== "object") {
795
+ const body = await c.req.json<Record<string, unknown>>().catch(() => null);
796
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
645
797
  return c.json({ error: "Invalid request body" }, 400);
646
798
  }
647
799
 
648
- if (
649
- !body.ids ||
650
- !Array.isArray(body.ids) ||
651
- body.ids.length === 0 ||
652
- body.ids.some((id) => typeof id !== "string" || id.trim().length === 0)
653
- ) {
800
+ const parsedIds = parseNotificationIds(body.ids, MAX_ARCHIVE_BATCH_SIZE);
801
+ if (!parsedIds.ok && parsedIds.reason === "invalid") {
654
802
  return c.json({ error: "ids array is required" }, 400);
655
803
  }
656
- if (body.ids.length > MAX_ARCHIVE_BATCH_SIZE) {
804
+ if (!parsedIds.ok) {
657
805
  return c.json(
658
806
  {
659
807
  error: `Batch size exceeds maximum of ${MAX_ARCHIVE_BATCH_SIZE}`,
@@ -663,30 +811,12 @@ notifications.post("/archive", async (c) => {
663
811
  }
664
812
 
665
813
  const now = new Date().toISOString();
666
- const uniqueIds = [...new Set(body.ids.map((id) => id.trim()))];
667
814
 
668
- const alreadyArchived = await db
669
- .select({ activityApId: notificationArchived.activityApId })
670
- .from(notificationArchived)
671
- .where(
672
- and(
673
- eq(notificationArchived.actorApId, actor.ap_id),
674
- inArray(notificationArchived.activityApId, uniqueIds),
675
- ),
676
- );
677
- const alreadyArchivedSet = new Set(
678
- alreadyArchived.map((row) => row.activityApId),
679
- );
680
- const toArchive = uniqueIds.filter((id) => !alreadyArchivedSet.has(id));
681
-
682
- const rows = toArchive.map((id) => ({
683
- actorApId: actor.ap_id,
684
- activityApId: id,
685
- archivedAt: now,
686
- }));
687
815
  const archived_count = await batchArchiveInsert(
688
816
  db,
689
- rows,
817
+ actor.ap_id,
818
+ parsedIds.ids,
819
+ now,
690
820
  ARCHIVE_CREATE_BATCH_SIZE,
691
821
  );
692
822
 
@@ -704,14 +834,16 @@ notifications.delete("/archive", async (c) => {
704
834
  if (actor instanceof Response) return actor;
705
835
 
706
836
  const db = c.get("db");
707
- const body = await c.req.json<{ ids: string[] }>().catch(() => null);
708
- if (!body || typeof body !== "object") {
837
+ const body = await c.req.json<Record<string, unknown>>().catch(() => null);
838
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
709
839
  return c.json({ error: "Invalid request body" }, 400);
710
840
  }
711
- if (!body.ids || body.ids.length === 0) {
841
+
842
+ const parsedIds = parseNotificationIds(body.ids, MAX_ARCHIVE_BATCH_SIZE);
843
+ if (!parsedIds.ok && parsedIds.reason === "invalid") {
712
844
  return c.json({ error: "ids array is required" }, 400);
713
845
  }
714
- if (body.ids.length > MAX_ARCHIVE_BATCH_SIZE) {
846
+ if (!parsedIds.ok) {
715
847
  return c.json(
716
848
  {
717
849
  error: "array_too_long",
@@ -726,7 +858,7 @@ notifications.delete("/archive", async (c) => {
726
858
  .where(
727
859
  and(
728
860
  eq(notificationArchived.actorApId, actor.ap_id),
729
- inArray(notificationArchived.activityApId, body.ids),
861
+ inArray(notificationArchived.activityApId, parsedIds.ids),
730
862
  ),
731
863
  );
732
864
 
@@ -745,38 +877,7 @@ notifications.post("/archive/all", async (c) => {
745
877
 
746
878
  const db = c.get("db");
747
879
  const now = new Date().toISOString();
748
-
749
- const [alreadyArchived, inboxItems] = await Promise.all([
750
- db
751
- .select({ activityApId: notificationArchived.activityApId })
752
- .from(notificationArchived)
753
- .where(eq(notificationArchived.actorApId, actor.ap_id))
754
- .limit(ARCHIVE_ALL_CAP),
755
- db
756
- .select({ activityApId: inboxTable.activityApId })
757
- .from(inboxTable)
758
- .where(eq(inboxTable.actorApId, actor.ap_id))
759
- .limit(ARCHIVE_ALL_CAP),
760
- ]);
761
-
762
- const alreadyArchivedIds = new Set(
763
- alreadyArchived.map((a) => a.activityApId),
764
- );
765
- const toArchive = inboxItems.filter(
766
- (item) => !alreadyArchivedIds.has(item.activityApId),
767
- );
768
-
769
- const rows = toArchive.map((item) => ({
770
- actorApId: actor.ap_id,
771
- activityApId: item.activityApId,
772
- archivedAt: now,
773
- }));
774
-
775
- const archived_count = await batchArchiveInsert(
776
- db,
777
- rows,
778
- ARCHIVE_CREATE_BATCH_SIZE,
779
- );
880
+ const archived_count = await archiveAllInboxRows(db, actor.ap_id, now);
780
881
 
781
882
  // Archive-all clears the whole badge; sync the reader's other tabs/devices.
782
883
  await runRealtimeAfterResponse(c, () =>