@takosjp/yurucommu-core 3.0.0

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 (185) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +82 -0
  3. package/migrations/0001_init.sql +495 -0
  4. package/migrations/0002_social_remote_actor_edges.sql +92 -0
  5. package/migrations/0003_activity_remote_object_edges.sql +68 -0
  6. package/migrations/0004_blocklist.sql +26 -0
  7. package/migrations/0005_story_community_scope.sql +13 -0
  8. package/migrations/0006_dm_community_read_status.sql +19 -0
  9. package/migrations/0007_moderation_reports.sql +22 -0
  10. package/migrations/0008_actor_fields_aka.sql +18 -0
  11. package/migrations/0009_object_tags.sql +13 -0
  12. package/migrations/0010_object_recipients_drop_actor_fk.sql +34 -0
  13. package/migrations/0011_drop_remote_actor_fks.sql +205 -0
  14. package/migrations/0012_objects_content_fts.sql +39 -0
  15. package/migrations/0013_efficiency_indexes.sql +13 -0
  16. package/migrations/0014_inbox_actor_created_idx.sql +15 -0
  17. package/migrations/0015_community_bans.sql +16 -0
  18. package/migrations/0016_namespace_takos_oidc_subject.sql +19 -0
  19. package/migrations/0017_mobile_push_registrations.sql +22 -0
  20. package/migrations/README.md +122 -0
  21. package/package.json +75 -0
  22. package/packages/api/LICENSE +16 -0
  23. package/packages/api/package.json +30 -0
  24. package/packages/api/src/index.ts +4 -0
  25. package/packages/api/src/lib/api/account.ts +20 -0
  26. package/packages/api/src/lib/api/actors.ts +149 -0
  27. package/packages/api/src/lib/api/auth.ts +46 -0
  28. package/packages/api/src/lib/api/communities.ts +329 -0
  29. package/packages/api/src/lib/api/dm.test.ts +67 -0
  30. package/packages/api/src/lib/api/dm.ts +236 -0
  31. package/packages/api/src/lib/api/fetch.ts +111 -0
  32. package/packages/api/src/lib/api/follow.ts +30 -0
  33. package/packages/api/src/lib/api/media.ts +100 -0
  34. package/packages/api/src/lib/api/moderation.ts +98 -0
  35. package/packages/api/src/lib/api/normalize.ts +71 -0
  36. package/packages/api/src/lib/api/notifications.test.ts +63 -0
  37. package/packages/api/src/lib/api/notifications.ts +61 -0
  38. package/packages/api/src/lib/api/posts.test.ts +110 -0
  39. package/packages/api/src/lib/api/posts.ts +181 -0
  40. package/packages/api/src/lib/api/recommendations.ts +22 -0
  41. package/packages/api/src/lib/api/search.ts +88 -0
  42. package/packages/api/src/lib/api/stories.ts +80 -0
  43. package/packages/api/src/lib/api.ts +15 -0
  44. package/packages/api/src/lib/fetch-with-timeout.ts +42 -0
  45. package/packages/api/src/lib/transport.ts +40 -0
  46. package/packages/api/src/social-server.ts +47 -0
  47. package/packages/api/src/types/index.ts +185 -0
  48. package/scripts/apply-takosumi-migrations.ts +621 -0
  49. package/src/backend/federation-helpers.ts +36 -0
  50. package/src/backend/index.ts +872 -0
  51. package/src/backend/lib/account-migration.ts +106 -0
  52. package/src/backend/lib/activitypub-actor-cache.ts +238 -0
  53. package/src/backend/lib/activitypub-helpers.ts +131 -0
  54. package/src/backend/lib/activitypub-validators.ts +323 -0
  55. package/src/backend/lib/ap-context.ts +16 -0
  56. package/src/backend/lib/ap-ids.ts +101 -0
  57. package/src/backend/lib/ap-response.ts +30 -0
  58. package/src/backend/lib/ap-signing.ts +87 -0
  59. package/src/backend/lib/ap-verify.ts +670 -0
  60. package/src/backend/lib/auth-lockout.ts +230 -0
  61. package/src/backend/lib/backend-paths.ts +34 -0
  62. package/src/backend/lib/base64.ts +30 -0
  63. package/src/backend/lib/blocklist-purge.ts +109 -0
  64. package/src/backend/lib/blocklist.ts +279 -0
  65. package/src/backend/lib/chunk.ts +33 -0
  66. package/src/backend/lib/client-ip.ts +169 -0
  67. package/src/backend/lib/community-visibility.ts +230 -0
  68. package/src/backend/lib/crypto.ts +424 -0
  69. package/src/backend/lib/delivery/circuit.ts +265 -0
  70. package/src/backend/lib/delivery/metrics.ts +30 -0
  71. package/src/backend/lib/delivery/planner.ts +190 -0
  72. package/src/backend/lib/delivery/queue-batching.ts +626 -0
  73. package/src/backend/lib/delivery/queue-delivery.ts +641 -0
  74. package/src/backend/lib/delivery/queue.ts +576 -0
  75. package/src/backend/lib/delivery/transformers.ts +56 -0
  76. package/src/backend/lib/delivery/types.ts +139 -0
  77. package/src/backend/lib/errors.ts +114 -0
  78. package/src/backend/lib/federation-fetch.ts +296 -0
  79. package/src/backend/lib/feed-cursor.ts +57 -0
  80. package/src/backend/lib/feed-exclude.ts +48 -0
  81. package/src/backend/lib/hex.ts +8 -0
  82. package/src/backend/lib/log-mask.ts +213 -0
  83. package/src/backend/lib/logger.ts +285 -0
  84. package/src/backend/lib/mobile-contract.ts +137 -0
  85. package/src/backend/lib/oauth-providers.ts +324 -0
  86. package/src/backend/lib/oauth-utils.ts +148 -0
  87. package/src/backend/lib/oidc-id-token.ts +151 -0
  88. package/src/backend/lib/parse-helpers.ts +31 -0
  89. package/src/backend/lib/post-visibility.ts +190 -0
  90. package/src/backend/lib/session-actor.ts +61 -0
  91. package/src/backend/lib/ssrf.ts +428 -0
  92. package/src/backend/lib/strip-image-metadata.ts +191 -0
  93. package/src/backend/middleware/bearer-auth.ts +70 -0
  94. package/src/backend/middleware/body-limit.ts +212 -0
  95. package/src/backend/middleware/cache.ts +429 -0
  96. package/src/backend/middleware/csrf.ts +130 -0
  97. package/src/backend/middleware/error-handler.ts +77 -0
  98. package/src/backend/middleware/rate-limit.ts +308 -0
  99. package/src/backend/public.ts +21 -0
  100. package/src/backend/routes/account-teardown.ts +430 -0
  101. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +354 -0
  102. package/src/backend/routes/activitypub/handlers/inbound-timestamp.ts +29 -0
  103. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1634 -0
  104. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +547 -0
  105. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +497 -0
  106. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +262 -0
  107. package/src/backend/routes/activitypub/handlers/user-inbox-handlers.ts +35 -0
  108. package/src/backend/routes/activitypub/inbox-types.ts +74 -0
  109. package/src/backend/routes/activitypub/inbox.ts +1191 -0
  110. package/src/backend/routes/activitypub/outbox.ts +0 -0
  111. package/src/backend/routes/activitypub/query-helpers.ts +227 -0
  112. package/src/backend/routes/activitypub.ts +616 -0
  113. package/src/backend/routes/actors-helpers.ts +487 -0
  114. package/src/backend/routes/actors.ts +1311 -0
  115. package/src/backend/routes/apps.ts +313 -0
  116. package/src/backend/routes/auth-helpers.ts +566 -0
  117. package/src/backend/routes/auth.ts +615 -0
  118. package/src/backend/routes/communities/membership-invites.ts +208 -0
  119. package/src/backend/routes/communities/membership-join.ts +335 -0
  120. package/src/backend/routes/communities/membership-members.ts +539 -0
  121. package/src/backend/routes/communities/membership-requests.ts +296 -0
  122. package/src/backend/routes/communities/membership-shared.ts +364 -0
  123. package/src/backend/routes/communities/messages.ts +479 -0
  124. package/src/backend/routes/communities/routes.ts +624 -0
  125. package/src/backend/routes/communities.ts +21 -0
  126. package/src/backend/routes/dm/contacts.ts +525 -0
  127. package/src/backend/routes/dm/conversations-helpers.ts +197 -0
  128. package/src/backend/routes/dm/conversations.ts +25 -0
  129. package/src/backend/routes/dm/messages.ts +658 -0
  130. package/src/backend/routes/dm/query-helpers.ts +85 -0
  131. package/src/backend/routes/dm/read-archive.ts +228 -0
  132. package/src/backend/routes/dm/requests.ts +222 -0
  133. package/src/backend/routes/dm/typing.ts +81 -0
  134. package/src/backend/routes/dm.ts +15 -0
  135. package/src/backend/routes/follow-helpers.ts +370 -0
  136. package/src/backend/routes/follow.ts +588 -0
  137. package/src/backend/routes/media.ts +692 -0
  138. package/src/backend/routes/mobile.ts +159 -0
  139. package/src/backend/routes/moderation.ts +373 -0
  140. package/src/backend/routes/notifications.ts +757 -0
  141. package/src/backend/routes/posts/delete-cascade.ts +330 -0
  142. package/src/backend/routes/posts/interactions.ts +795 -0
  143. package/src/backend/routes/posts/post-helpers.ts +847 -0
  144. package/src/backend/routes/posts/queries.ts +537 -0
  145. package/src/backend/routes/posts/routes.ts +865 -0
  146. package/src/backend/routes/posts/transformers.ts +161 -0
  147. package/src/backend/routes/posts.ts +17 -0
  148. package/src/backend/routes/recommendations.ts +88 -0
  149. package/src/backend/routes/search.ts +730 -0
  150. package/src/backend/routes/stories/interactions.ts +576 -0
  151. package/src/backend/routes/stories/query-helpers.ts +482 -0
  152. package/src/backend/routes/stories/routes.ts +906 -0
  153. package/src/backend/routes/stories.ts +13 -0
  154. package/src/backend/routes/takos-tools/dm.ts +249 -0
  155. package/src/backend/routes/takos-tools/follows.ts +225 -0
  156. package/src/backend/routes/takos-tools/posts.ts +292 -0
  157. package/src/backend/routes/takos-tools/search.ts +228 -0
  158. package/src/backend/routes/takos-tools/timeline.ts +132 -0
  159. package/src/backend/routes/takos-tools/types.ts +10 -0
  160. package/src/backend/routes/takos-tools-response.ts +178 -0
  161. package/src/backend/routes/takos-tools.ts +153 -0
  162. package/src/backend/routes/timeline.ts +755 -0
  163. package/src/backend/runtime/bun.ts +620 -0
  164. package/src/backend/runtime/cloudflare.ts +202 -0
  165. package/src/backend/runtime/compat-bun/types.ts +44 -0
  166. package/src/backend/runtime/memory-kv.ts +104 -0
  167. package/src/backend/runtime/shared.ts +142 -0
  168. package/src/backend/runtime/types.ts +205 -0
  169. package/src/backend/server.ts +636 -0
  170. package/src/backend/types.ts +143 -0
  171. package/src/db/index.ts +97 -0
  172. package/src/db/schema/actors.ts +129 -0
  173. package/src/db/schema/communities.ts +133 -0
  174. package/src/db/schema/date-utils.ts +17 -0
  175. package/src/db/schema/index.ts +17 -0
  176. package/src/db/schema/messaging.ts +241 -0
  177. package/src/db/schema/mobile.ts +37 -0
  178. package/src/db/schema/posts.ts +150 -0
  179. package/src/db/schema/relations.ts +266 -0
  180. package/src/db/schema/reports.ts +33 -0
  181. package/src/db/schema/social.ts +106 -0
  182. package/src/db/schema/stories.ts +70 -0
  183. package/src/db/schema.ts +15 -0
  184. package/src/plugin/public.ts +7 -0
  185. package/src/runtime/site-worker.ts +10 -0
@@ -0,0 +1,757 @@
1
+ // Notifications routes for Yurucommu backend
2
+ // AP Native: Notifications are derived from inbox (activities addressed to the actor)
3
+ import { Hono } from "hono";
4
+ import {
5
+ and,
6
+ count,
7
+ desc,
8
+ eq,
9
+ exists,
10
+ inArray,
11
+ isNotNull,
12
+ isNull,
13
+ lt,
14
+ ne,
15
+ notExists,
16
+ or,
17
+ type SQL,
18
+ } from "drizzle-orm";
19
+ import type { Env, Variables } from "../types.ts";
20
+ import {
21
+ formatUsername,
22
+ parseLimit,
23
+ parseOffset,
24
+ } from "../federation-helpers.ts";
25
+ import type { Database } from "../../db/index.ts";
26
+ import {
27
+ activities,
28
+ follows,
29
+ inbox as inboxTable,
30
+ notificationArchived,
31
+ objects,
32
+ } from "../../db/index.ts";
33
+ import { batchLoadActorInfo } from "./communities/membership-shared.ts";
34
+ import { requireActor } from "./actors-helpers.ts";
35
+ import { communityReadableApIds } from "../lib/community-visibility.ts";
36
+ import { chunkForInClause } from "../lib/chunk.ts";
37
+ import { excludeBlockedMutedAuthors } from "../lib/feed-exclude.ts";
38
+
39
+ const notifications = new Hono<{ Bindings: Env; Variables: Variables }>();
40
+
41
+ const ARCHIVE_RETENTION_DAYS = 90;
42
+ const ARCHIVED_CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
43
+ // Client-facing batch caps. <=90 because each id is re-queried via
44
+ // `inArray(..., body.ids)` plus an `eq()` param, and Cloudflare D1 caps a query
45
+ // at 100 bound parameters (libsql, used by tests, allows ~32k and hides this).
46
+ const MAX_ARCHIVE_BATCH_SIZE = 90;
47
+ const MAX_READ_BATCH_SIZE = 90;
48
+ // Multi-row INSERT chunk: each archive row binds 3 columns, so a chunk of N
49
+ // rows uses 3*N bound params. 30*3 = 90, under D1's 100-param ceiling.
50
+ const ARCHIVE_CREATE_BATCH_SIZE = 30;
51
+ // Bound the opportunistic retention-cleanup per run so a user with a very large
52
+ // archived backlog doesn't load + delete it all in one fired-from-read-path run;
53
+ // the rest drains on later runs.
54
+ const ARCHIVE_CLEANUP_BATCH = 200;
55
+ const ARCHIVE_ALL_CAP = 1000;
56
+ const NOTIFICATION_ACTIVITY_TYPES = ["Follow", "Like", "Announce", "Create"];
57
+
58
+ /**
59
+ * Tracks the last cleanup timestamp per actor so cleanup is throttled to one
60
+ * run per `ARCHIVED_CLEANUP_INTERVAL_MS`. Entries older than the interval no
61
+ * longer gate work and can be evicted. A hard cap also bounds worst-case
62
+ * growth when many unique actors hit notifications in a single window.
63
+ */
64
+ const ARCHIVED_CLEANUP_TIMESTAMPS_MAX = 10_000;
65
+ const archivedCleanupTimestamps = new Map<string, number>();
66
+
67
+ function pruneArchivedCleanupTimestamps(now: number): void {
68
+ for (const [actor, lastRun] of archivedCleanupTimestamps) {
69
+ if (now - lastRun >= ARCHIVED_CLEANUP_INTERVAL_MS) {
70
+ archivedCleanupTimestamps.delete(actor);
71
+ }
72
+ }
73
+ if (archivedCleanupTimestamps.size >= ARCHIVED_CLEANUP_TIMESTAMPS_MAX) {
74
+ // Last-resort: every entry is still fresh but cap is hit. Clear to bound
75
+ // memory; the worst that happens is duplicate cleanup work within the
76
+ // window for actors evicted here.
77
+ archivedCleanupTimestamps.clear();
78
+ }
79
+ }
80
+
81
+ /** @internal Test-only inspector for the cleanup-timestamps bookkeeping. */
82
+ export const __archivedCleanupInternals = {
83
+ size: () => archivedCleanupTimestamps.size,
84
+ clear: () => archivedCleanupTimestamps.clear(),
85
+ set: (key: string, value: number) =>
86
+ archivedCleanupTimestamps.set(key, value),
87
+ prune: pruneArchivedCleanupTimestamps,
88
+ maxEntries: ARCHIVED_CLEANUP_TIMESTAMPS_MAX,
89
+ intervalMs: ARCHIVED_CLEANUP_INTERVAL_MS,
90
+ };
91
+
92
+ /**
93
+ * Batch-insert archive rows with unique-constraint tolerance.
94
+ * Returns the number of rows actually inserted.
95
+ */
96
+ async function batchArchiveInsert(
97
+ db: Database,
98
+ rows: Array<{ actorApId: string; activityApId: string; archivedAt: string }>,
99
+ batchSize: number,
100
+ ): Promise<number> {
101
+ let inserted = 0;
102
+
103
+ for (let i = 0; i < rows.length; i += batchSize) {
104
+ const batch = rows.slice(i, i + batchSize);
105
+ const result = await db
106
+ .insert(notificationArchived)
107
+ .values(batch)
108
+ .onConflictDoNothing();
109
+ inserted += (result as { meta?: { changes?: number } }).meta?.changes ?? 0;
110
+ }
111
+
112
+ return inserted;
113
+ }
114
+
115
+ async function cleanupArchivedNotifications(
116
+ db: Database,
117
+ actorApId: string,
118
+ ): Promise<void> {
119
+ const retentionDate = new Date();
120
+ retentionDate.setDate(retentionDate.getDate() - ARCHIVE_RETENTION_DAYS);
121
+ const retentionDateStr = retentionDate.toISOString();
122
+
123
+ const archivedToDelete = await db
124
+ .select({ activityApId: notificationArchived.activityApId })
125
+ .from(notificationArchived)
126
+ .where(
127
+ and(
128
+ eq(notificationArchived.actorApId, actorApId),
129
+ lt(notificationArchived.archivedAt, retentionDateStr),
130
+ ),
131
+ )
132
+ .limit(ARCHIVE_CLEANUP_BATCH);
133
+
134
+ if (archivedToDelete.length === 0) return;
135
+
136
+ const activityApIds = archivedToDelete.map((a) => a.activityApId);
137
+
138
+ // Delete the inbox rows AND the archived markers for EXACTLY the bounded set,
139
+ // chunked for D1's 100-bound-parameter cap. Both target the same activity ids
140
+ // (not a broad `archivedAt < retentionDate`): with the per-run limit, a broad
141
+ // archived-marker delete would drop markers for rows whose inbox entry wasn't
142
+ // deleted this run, re-surfacing an expired-archived notification as active.
143
+ // The inbox delete runs first so a crash can't leave a notification visible
144
+ // with its retention marker already gone.
145
+ for (const ids of chunkForInClause(activityApIds)) {
146
+ await db
147
+ .delete(inboxTable)
148
+ .where(
149
+ and(
150
+ eq(inboxTable.actorApId, actorApId),
151
+ inArray(inboxTable.activityApId, ids),
152
+ ),
153
+ );
154
+ await db
155
+ .delete(notificationArchived)
156
+ .where(
157
+ and(
158
+ eq(notificationArchived.actorApId, actorApId),
159
+ inArray(notificationArchived.activityApId, ids),
160
+ ),
161
+ );
162
+ }
163
+ }
164
+
165
+ async function maybeCleanupArchivedNotifications(
166
+ db: Database,
167
+ actorApId: string,
168
+ ): Promise<void> {
169
+ const now = Date.now();
170
+ const lastRun = archivedCleanupTimestamps.get(actorApId) ?? 0;
171
+ if (now - lastRun < ARCHIVED_CLEANUP_INTERVAL_MS) return;
172
+
173
+ pruneArchivedCleanupTimestamps(now);
174
+ archivedCleanupTimestamps.set(actorApId, now);
175
+ await cleanupArchivedNotifications(db, actorApId);
176
+ }
177
+
178
+ function activityToNotificationType(
179
+ activityType: string,
180
+ hasInReplyTo: boolean,
181
+ followStatus?: string | null,
182
+ ): string | null {
183
+ switch (activityType) {
184
+ case "Follow":
185
+ return followStatus === "pending" ? "follow_request" : "follow";
186
+ case "Like":
187
+ return "like";
188
+ case "Announce":
189
+ return "announce";
190
+ case "Create":
191
+ return hasInReplyTo ? "reply" : "mention";
192
+ default:
193
+ return null;
194
+ }
195
+ }
196
+
197
+ // Notifications order by createdAt desc, but createdAt is NOT unique — several
198
+ // inbox rows can share a millisecond — so an exclusive cursor on createdAt alone
199
+ // would skip the rows on either side of a page boundary that share the cursor's
200
+ // timestamp. The cursor is therefore a composite of (createdAt, activityApId);
201
+ // activityApId is unique within an actor's inbox, so (createdAt desc,
202
+ // activityApId desc) is a total order. The two parts are encoded into the opaque
203
+ // `before` string with a NUL separator (NUL cannot appear in an ISO timestamp or
204
+ // an http(s) ap_id URL). A legacy plain-createdAt cursor (no separator) is still
205
+ // accepted — it is never wider than the composite form.
206
+ const NOTIF_CURSOR_SEP = "\u0000";
207
+
208
+ type NotifCursor = { createdAt: string; activityApId?: string };
209
+
210
+ function decodeNotifCursor(before: string): NotifCursor {
211
+ const idx = before.indexOf(NOTIF_CURSOR_SEP);
212
+ if (idx === -1) return { createdAt: before };
213
+ return {
214
+ createdAt: before.slice(0, idx),
215
+ activityApId: before.slice(idx + 1),
216
+ };
217
+ }
218
+
219
+ function notifCursorPredicate(cursor: NotifCursor): SQL {
220
+ if (cursor.activityApId === undefined) {
221
+ return lt(inboxTable.createdAt, cursor.createdAt);
222
+ }
223
+ return or(
224
+ lt(inboxTable.createdAt, cursor.createdAt),
225
+ and(
226
+ eq(inboxTable.createdAt, cursor.createdAt),
227
+ lt(inboxTable.activityApId, cursor.activityApId),
228
+ ),
229
+ )!;
230
+ }
231
+
232
+ function encodeNotifCursor(row: { created_at: string; id: string }): string {
233
+ return `${row.created_at}${NOTIF_CURSOR_SEP}${row.id}`;
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // Routes
238
+ // ---------------------------------------------------------------------------
239
+
240
+ // GET / -- List notifications with type/archive filters
241
+ notifications.get("/", async (c) => {
242
+ const actor = requireActor(c);
243
+ if (actor instanceof Response) return actor;
244
+
245
+ const db = c.get("db");
246
+ await maybeCleanupArchivedNotifications(db, actor.ap_id);
247
+
248
+ // Max 90 (not 100): this page's activityApIds are re-queried via
249
+ // `inArray(inboxTable.activityApId, ...)` for the archived-state join, and
250
+ // Cloudflare D1 allows at most 100 bound parameters per query.
251
+ const limit = parseLimit(c.req.query("limit"), 20, 90);
252
+ const offset = parseOffset(c.req.query("offset"), 0, 10000);
253
+ const before = c.req.query("before");
254
+ const typeFilter = c.req.query("type");
255
+ const showArchived = c.req.query("archived") === "true";
256
+
257
+ const typeToActivityType: Record<string, string[]> = {
258
+ follow: ["Follow"],
259
+ like: ["Like"],
260
+ announce: ["Announce"],
261
+ reply: ["Create"],
262
+ mention: ["Create"],
263
+ };
264
+
265
+ const activityTypes =
266
+ typeFilter && typeToActivityType[typeFilter]
267
+ ? typeToActivityType[typeFilter]
268
+ : NOTIFICATION_ACTIVITY_TYPES;
269
+
270
+ // Archive partition pushed INTO SQL as a correlated EXISTS / NOT EXISTS, not a
271
+ // post-query filter. Filtering archived rows out in the result loop made two
272
+ // bugs: (1) `has_more` under-reported — a page whose limit+1 probe rows were
273
+ // mostly the wrong archive state returned < limit items yet there were older
274
+ // pages, so the client stopped loading; (2) every archived id for the actor
275
+ // was loaded into an unbounded in-memory Set per request. As an SQL predicate
276
+ // the limit+1 probe counts only rows that actually belong on the page.
277
+ const archivedCorrelation = and(
278
+ eq(notificationArchived.actorApId, inboxTable.actorApId),
279
+ eq(notificationArchived.activityApId, inboxTable.activityApId),
280
+ );
281
+ const archivedSubquery = db
282
+ .select({ activityApId: notificationArchived.activityApId })
283
+ .from(notificationArchived)
284
+ .where(archivedCorrelation);
285
+ const archiveCondition = showArchived
286
+ ? exists(archivedSubquery)
287
+ : notExists(archivedSubquery);
288
+
289
+ // Build inbox query with JOIN to activities. A direct (DM) Note is delivered
290
+ // as a `Create` inbox row just like a mention, so without excluding it every
291
+ // DM would double-surface here as a "mention" (with its body) AND inflate the
292
+ // unread badge independently of the DM view. LEFT JOIN the object and drop
293
+ // direct-visibility Creates; the join is LEFT because Follow's object is an
294
+ // actor (no `objects` row) → NULL visibility must be kept.
295
+ const conditions = [
296
+ eq(inboxTable.actorApId, actor.ap_id),
297
+ ne(activities.actorApId, actor.ap_id),
298
+ inArray(activities.type, activityTypes),
299
+ or(isNull(objects.visibility), ne(objects.visibility, "direct"))!,
300
+ archiveCondition,
301
+ ];
302
+ // reply vs mention both map to a Create; the split is whether the Create's
303
+ // object is a reply (`inReplyTo` set). Pushed into SQL too so a type-filtered
304
+ // page can't under-report has_more for the same reason as the archive split.
305
+ if (typeFilter === "reply") {
306
+ conditions.push(isNotNull(objects.inReplyTo));
307
+ } else if (typeFilter === "mention") {
308
+ conditions.push(isNull(objects.inReplyTo));
309
+ }
310
+ if (before) {
311
+ conditions.push(notifCursorPredicate(decodeNotifCursor(before)));
312
+ }
313
+ // Suppress notifications whose actor the recipient has blocked or muted. This
314
+ // is the read-time choke point: mutes are read-only everywhere, and not every
315
+ // notify WRITE path block-checks, so gating here covers like/repost/follow/
316
+ // reply/mention (local AND federated) for both blocks and mutes. Keyed on the
317
+ // activity actor (subquery-scoped → D1-param-safe).
318
+ const listBlockMute = excludeBlockedMutedAuthors(
319
+ db,
320
+ actor.ap_id,
321
+ activities.actorApId,
322
+ );
323
+ if (listBlockMute) conditions.push(listBlockMute);
324
+
325
+ const inboxEntries = await db
326
+ .select({
327
+ actorApId: inboxTable.actorApId,
328
+ activityApId: inboxTable.activityApId,
329
+ read: inboxTable.read,
330
+ createdAt: inboxTable.createdAt,
331
+ activityType: activities.type,
332
+ activityActorApId: activities.actorApId,
333
+ activityObjectApId: activities.objectApId,
334
+ })
335
+ .from(inboxTable)
336
+ .innerJoin(activities, eq(inboxTable.activityApId, activities.apId))
337
+ .leftJoin(objects, eq(activities.objectApId, objects.apId))
338
+ .where(and(...conditions))
339
+ .orderBy(desc(inboxTable.createdAt), desc(inboxTable.activityApId))
340
+ .limit(limit + 1);
341
+
342
+ // Batch fetch related data
343
+ const actorApIds = [...new Set(inboxEntries.map((i) => i.activityActorApId))];
344
+ const objectApIds = [
345
+ ...new Set(
346
+ inboxEntries
347
+ .map((i) => i.activityObjectApId)
348
+ .filter((id): id is string => id !== null),
349
+ ),
350
+ ];
351
+ const activityApIdsArr = [
352
+ ...new Set(inboxEntries.map((i) => i.activityApId)),
353
+ ];
354
+
355
+ const [actorMap, objectRows, followRows] = await Promise.all([
356
+ batchLoadActorInfo(db, actorApIds),
357
+ objectApIds.length > 0
358
+ ? db
359
+ .select({
360
+ apId: objects.apId,
361
+ content: objects.content,
362
+ inReplyTo: objects.inReplyTo,
363
+ audienceJson: objects.audienceJson,
364
+ communityApId: objects.communityApId,
365
+ visibility: objects.visibility,
366
+ attributedTo: objects.attributedTo,
367
+ })
368
+ .from(objects)
369
+ .where(inArray(objects.apId, objectApIds))
370
+ : Promise.resolve([]),
371
+ activityApIdsArr.length > 0
372
+ ? db
373
+ .select({
374
+ activityApId: follows.activityApId,
375
+ status: follows.status,
376
+ })
377
+ .from(follows)
378
+ .where(inArray(follows.activityApId, activityApIdsArr))
379
+ : Promise.resolve([]),
380
+ ]);
381
+
382
+ // Content read-gate. A restricted object can land in a NON-entitled inbox: a
383
+ // private-community post via an @-mention Create, or — since a local reply
384
+ // creates a notification for the parent author regardless of the reply's
385
+ // visibility (post-helpers.ts) — a FOLLOWERS-ONLY reply by someone the
386
+ // recipient neither follows nor authored. Projecting the body verbatim would
387
+ // leak it. Gate each object's content against the recipient (actor.ap_id):
388
+ // the community membership gate AND the followers-only gate (mirrors the
389
+ // post-detail / replies gate: own post or an accepted follow to the author).
390
+ // Direct posts are already dropped from the query above.
391
+ const followerGateAuthors = [
392
+ ...new Set(
393
+ objectRows
394
+ .filter(
395
+ (o) => o.visibility === "followers" && o.attributedTo !== actor.ap_id,
396
+ )
397
+ .map((o) => o.attributedTo),
398
+ ),
399
+ ];
400
+ const followedAuthors =
401
+ followerGateAuthors.length > 0
402
+ ? new Set(
403
+ (
404
+ await db
405
+ .select({ followingApId: follows.followingApId })
406
+ .from(follows)
407
+ .where(
408
+ and(
409
+ eq(follows.followerApId, actor.ap_id),
410
+ inArray(follows.followingApId, followerGateAuthors),
411
+ eq(follows.status, "accepted"),
412
+ ),
413
+ )
414
+ ).map((r) => r.followingApId),
415
+ )
416
+ : new Set<string>();
417
+
418
+ const followersGateAllows = (o: {
419
+ visibility: string;
420
+ attributedTo: string;
421
+ }): boolean => {
422
+ if (o.visibility !== "followers") return true;
423
+ return (
424
+ o.attributedTo === actor.ap_id || followedAuthors.has(o.attributedTo)
425
+ );
426
+ };
427
+
428
+ // Apply the synchronous followers-gate first, then resolve the community
429
+ // read-gate for all survivors in ONE batched call (2 queries) rather than
430
+ // 1-2 queries per row.
431
+ const readableObjectIds = await communityReadableApIds(
432
+ db,
433
+ objectRows.filter(followersGateAllows),
434
+ actor.ap_id,
435
+ );
436
+
437
+ const objectMap = new Map(
438
+ objectRows.map((o) => [
439
+ o.apId,
440
+ {
441
+ content: readableObjectIds.has(o.apId) ? o.content : "",
442
+ inReplyTo: o.inReplyTo,
443
+ },
444
+ ]),
445
+ );
446
+ const followMap = new Map(
447
+ followRows
448
+ .filter((f) => f.activityApId)
449
+ .map((f) => [f.activityApId!, f.status]),
450
+ );
451
+
452
+ // Filter and transform inbox entries into notifications in a single pass
453
+ const notifications_list: Array<{
454
+ id: string;
455
+ type: string;
456
+ object_ap_id: string | null;
457
+ read: boolean;
458
+ created_at: string;
459
+ actor: {
460
+ ap_id: string;
461
+ username: string;
462
+ preferred_username: string | null;
463
+ name: string | null;
464
+ icon_url: string | null;
465
+ };
466
+ object_content: string;
467
+ }> = [];
468
+
469
+ for (const entry of inboxEntries) {
470
+ if (notifications_list.length > limit) break;
471
+
472
+ // Archive partition and reply/mention split are now enforced in SQL (see the
473
+ // conditions above), so this pass is a pure transform — no row is dropped
474
+ // here, which is what keeps `has_more` honest.
475
+ const objectData = entry.activityObjectApId
476
+ ? objectMap.get(entry.activityObjectApId)
477
+ : null;
478
+ const inReplyTo = objectData?.inReplyTo ?? null;
479
+
480
+ const followStatus = followMap.get(entry.activityApId) ?? null;
481
+ const notifType = activityToNotificationType(
482
+ entry.activityType,
483
+ !!inReplyTo,
484
+ followStatus,
485
+ );
486
+ const actorInfo = actorMap.get(entry.activityActorApId);
487
+
488
+ notifications_list.push({
489
+ id: entry.activityApId,
490
+ type: notifType || entry.activityType.toLowerCase(),
491
+ object_ap_id: entry.activityObjectApId,
492
+ read: !!entry.read,
493
+ created_at: entry.createdAt,
494
+ actor: {
495
+ ap_id: entry.activityActorApId,
496
+ username: formatUsername(entry.activityActorApId),
497
+ preferred_username: actorInfo?.preferredUsername ?? null,
498
+ name: actorInfo?.name ?? null,
499
+ icon_url: actorInfo?.iconUrl ?? null,
500
+ },
501
+ object_content: objectData?.content ?? "",
502
+ });
503
+ }
504
+
505
+ const has_more = notifications_list.length > limit;
506
+ if (has_more) notifications_list.length = limit;
507
+
508
+ // Composite keyset cursor for the next page — resume strictly after the last
509
+ // returned row. The client should prefer this over a bare created_at so
510
+ // same-millisecond rows straddling the page boundary are not skipped.
511
+ const last = notifications_list[notifications_list.length - 1];
512
+ const next_cursor = has_more && last ? encodeNotifCursor(last) : null;
513
+
514
+ return c.json({
515
+ notifications: notifications_list,
516
+ limit,
517
+ offset,
518
+ has_more,
519
+ next_cursor,
520
+ });
521
+ });
522
+
523
+ // GET /unread/count
524
+ notifications.get("/unread/count", async (c) => {
525
+ const actor = requireActor(c);
526
+ if (actor instanceof Response) return actor;
527
+
528
+ const db = c.get("db");
529
+ await maybeCleanupArchivedNotifications(db, actor.ap_id);
530
+
531
+ // Mirror the list query's DM exclusion (see GET /): a direct Note's Create
532
+ // inbox row must not count toward the notification badge — the DM has its own
533
+ // unread badge, and marking it read never clears this inbox row.
534
+ // Mirror the default list view's archive exclusion: an archived notification
535
+ // is hidden from the inbox list, so it must NOT count toward the badge either —
536
+ // otherwise archiving an UNREAD notification (read stays 0) leaves a phantom
537
+ // count the client can never clear (its mark-read sweep only touches rows the
538
+ // inbox view returns, which no longer include the archived one).
539
+ const archivedSubquery = db
540
+ .select({ activityApId: notificationArchived.activityApId })
541
+ .from(notificationArchived)
542
+ .where(
543
+ and(
544
+ eq(notificationArchived.actorApId, inboxTable.actorApId),
545
+ eq(notificationArchived.activityApId, inboxTable.activityApId),
546
+ ),
547
+ );
548
+ const result = await db
549
+ .select({ count: count() })
550
+ .from(inboxTable)
551
+ .innerJoin(activities, eq(inboxTable.activityApId, activities.apId))
552
+ .leftJoin(objects, eq(activities.objectApId, objects.apId))
553
+ .where(
554
+ and(
555
+ eq(inboxTable.actorApId, actor.ap_id),
556
+ eq(inboxTable.read, 0),
557
+ ne(activities.actorApId, actor.ap_id),
558
+ inArray(activities.type, NOTIFICATION_ACTIVITY_TYPES),
559
+ or(isNull(objects.visibility), ne(objects.visibility, "direct"))!,
560
+ notExists(archivedSubquery),
561
+ // Mirror the list query: don't count notifications from blocked/muted
562
+ // actors toward the unread badge.
563
+ excludeBlockedMutedAuthors(db, actor.ap_id, activities.actorApId),
564
+ ),
565
+ )
566
+ .get();
567
+
568
+ return c.json({ count: result?.count ?? 0 });
569
+ });
570
+
571
+ // POST /read -- Mark notifications as read
572
+ notifications.post("/read", async (c) => {
573
+ const actor = requireActor(c);
574
+ if (actor instanceof Response) return actor;
575
+
576
+ const db = c.get("db");
577
+ // A literal `null`/primitive JSON body parses without throwing, then field
578
+ // access throws a TypeError that the global handler maps to 500 (not 400).
579
+ // Guard the body shape so a malformed request is a clean 400.
580
+ const body = await c.req
581
+ .json<{ ids?: string[]; read_all?: boolean }>()
582
+ .catch(() => null);
583
+ if (!body || typeof body !== "object") {
584
+ return c.json({ error: "Invalid request body" }, 400);
585
+ }
586
+
587
+ if (body.read_all) {
588
+ await db
589
+ .update(inboxTable)
590
+ .set({ read: 1 })
591
+ .where(eq(inboxTable.actorApId, actor.ap_id));
592
+ } else if (body.ids && body.ids.length > 0) {
593
+ if (body.ids.length > MAX_READ_BATCH_SIZE) {
594
+ return c.json(
595
+ {
596
+ error: "array_too_long",
597
+ message: `Batch size exceeds maximum of ${MAX_READ_BATCH_SIZE}`,
598
+ },
599
+ 400,
600
+ );
601
+ }
602
+ await db
603
+ .update(inboxTable)
604
+ .set({ read: 1 })
605
+ .where(
606
+ and(
607
+ eq(inboxTable.actorApId, actor.ap_id),
608
+ inArray(inboxTable.activityApId, body.ids),
609
+ ),
610
+ );
611
+ } else {
612
+ return c.json(
613
+ { error: "Either ids array or read_all flag is required" },
614
+ 400,
615
+ );
616
+ }
617
+
618
+ return c.json({ success: true });
619
+ });
620
+
621
+ // POST /archive -- Archive specific notifications
622
+ notifications.post("/archive", async (c) => {
623
+ const actor = requireActor(c);
624
+ if (actor instanceof Response) return actor;
625
+
626
+ const db = c.get("db");
627
+ const body = await c.req.json<{ ids: string[] }>().catch(() => null);
628
+ if (!body || typeof body !== "object") {
629
+ return c.json({ error: "Invalid request body" }, 400);
630
+ }
631
+
632
+ if (
633
+ !body.ids ||
634
+ !Array.isArray(body.ids) ||
635
+ body.ids.length === 0 ||
636
+ body.ids.some((id) => typeof id !== "string" || id.trim().length === 0)
637
+ ) {
638
+ return c.json({ error: "ids array is required" }, 400);
639
+ }
640
+ if (body.ids.length > MAX_ARCHIVE_BATCH_SIZE) {
641
+ return c.json(
642
+ {
643
+ error: `Batch size exceeds maximum of ${MAX_ARCHIVE_BATCH_SIZE}`,
644
+ },
645
+ 400,
646
+ );
647
+ }
648
+
649
+ const now = new Date().toISOString();
650
+ const uniqueIds = [...new Set(body.ids.map((id) => id.trim()))];
651
+
652
+ const alreadyArchived = await db
653
+ .select({ activityApId: notificationArchived.activityApId })
654
+ .from(notificationArchived)
655
+ .where(
656
+ and(
657
+ eq(notificationArchived.actorApId, actor.ap_id),
658
+ inArray(notificationArchived.activityApId, uniqueIds),
659
+ ),
660
+ );
661
+ const alreadyArchivedSet = new Set(
662
+ alreadyArchived.map((row) => row.activityApId),
663
+ );
664
+ const toArchive = uniqueIds.filter((id) => !alreadyArchivedSet.has(id));
665
+
666
+ const rows = toArchive.map((id) => ({
667
+ actorApId: actor.ap_id,
668
+ activityApId: id,
669
+ archivedAt: now,
670
+ }));
671
+ const archived_count = await batchArchiveInsert(
672
+ db,
673
+ rows,
674
+ ARCHIVE_CREATE_BATCH_SIZE,
675
+ );
676
+
677
+ return c.json({ success: true, archived_count });
678
+ });
679
+
680
+ // DELETE /archive -- Unarchive notifications
681
+ notifications.delete("/archive", async (c) => {
682
+ const actor = requireActor(c);
683
+ if (actor instanceof Response) return actor;
684
+
685
+ const db = c.get("db");
686
+ const body = await c.req.json<{ ids: string[] }>().catch(() => null);
687
+ if (!body || typeof body !== "object") {
688
+ return c.json({ error: "Invalid request body" }, 400);
689
+ }
690
+ if (!body.ids || body.ids.length === 0) {
691
+ return c.json({ error: "ids array is required" }, 400);
692
+ }
693
+ if (body.ids.length > MAX_ARCHIVE_BATCH_SIZE) {
694
+ return c.json(
695
+ {
696
+ error: "array_too_long",
697
+ message: `Batch size exceeds maximum of ${MAX_ARCHIVE_BATCH_SIZE}`,
698
+ },
699
+ 400,
700
+ );
701
+ }
702
+
703
+ await db
704
+ .delete(notificationArchived)
705
+ .where(
706
+ and(
707
+ eq(notificationArchived.actorApId, actor.ap_id),
708
+ inArray(notificationArchived.activityApId, body.ids),
709
+ ),
710
+ );
711
+
712
+ return c.json({ success: true });
713
+ });
714
+
715
+ // POST /archive/all -- Archive all notifications
716
+ notifications.post("/archive/all", async (c) => {
717
+ const actor = requireActor(c);
718
+ if (actor instanceof Response) return actor;
719
+
720
+ const db = c.get("db");
721
+ const now = new Date().toISOString();
722
+
723
+ const [alreadyArchived, inboxItems] = await Promise.all([
724
+ db
725
+ .select({ activityApId: notificationArchived.activityApId })
726
+ .from(notificationArchived)
727
+ .where(eq(notificationArchived.actorApId, actor.ap_id))
728
+ .limit(ARCHIVE_ALL_CAP),
729
+ db
730
+ .select({ activityApId: inboxTable.activityApId })
731
+ .from(inboxTable)
732
+ .where(eq(inboxTable.actorApId, actor.ap_id))
733
+ .limit(ARCHIVE_ALL_CAP),
734
+ ]);
735
+
736
+ const alreadyArchivedIds = new Set(
737
+ alreadyArchived.map((a) => a.activityApId),
738
+ );
739
+ const toArchive = inboxItems.filter(
740
+ (item) => !alreadyArchivedIds.has(item.activityApId),
741
+ );
742
+
743
+ const rows = toArchive.map((item) => ({
744
+ actorApId: actor.ap_id,
745
+ activityApId: item.activityApId,
746
+ archivedAt: now,
747
+ }));
748
+
749
+ const archived_count = await batchArchiveInsert(
750
+ db,
751
+ rows,
752
+ ARCHIVE_CREATE_BATCH_SIZE,
753
+ );
754
+ return c.json({ success: true, archived_count });
755
+ });
756
+
757
+ export default notifications;