@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,588 @@
1
+ import { Hono } from "hono";
2
+ import { and, desc, eq, gt, inArray, sql } from "drizzle-orm";
3
+ import type { Env, Variables } from "../types.ts";
4
+ import {
5
+ activities,
6
+ actorCache,
7
+ actors,
8
+ follows,
9
+ inbox,
10
+ } from "../../db/index.ts";
11
+ import {
12
+ activityApId,
13
+ formatUsername,
14
+ generateId,
15
+ isLocal,
16
+ isSafeRemoteUrl,
17
+ parseLimit,
18
+ parseOffset,
19
+ } from "../federation-helpers.ts";
20
+ import { enqueueDeliveryToActor } from "../lib/delivery/queue.ts";
21
+ import {
22
+ buildApActivity,
23
+ createAndDeliverActivity,
24
+ deliverResponseIfRemote,
25
+ findPendingFollow,
26
+ handleLocalFollow,
27
+ handleRemoteFollow,
28
+ isResponse,
29
+ parseNonEmptyString,
30
+ parseStringArray,
31
+ requireActorAndBody,
32
+ } from "./follow-helpers.ts";
33
+ import { logger } from "../lib/logger.ts";
34
+
35
+ const log = logger.child({ component: "follow" });
36
+
37
+ // `.batch` lives only on the concrete D1/libsql subclasses, not the Database
38
+ // union; reach it through a narrow structural cast (matching the other routes).
39
+ type Batchable = { batch: (stmts: unknown[]) => Promise<unknown> };
40
+
41
+ // Capped at 90 (not 100): the accepted ids are re-queried via
42
+ // `inArray(follows.followerApId, requesterApIds)` and Cloudflare D1 allows at
43
+ // most 100 bound parameters per query (libsql, used by the tests, allows ~32k).
44
+ const MAX_BATCH_ACCEPT_SIZE = 90;
45
+
46
+ const follow = new Hono<{ Bindings: Env; Variables: Variables }>();
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // POST / -- Follow an actor (local or remote)
50
+ // ---------------------------------------------------------------------------
51
+
52
+ follow.post("/", async (c) => {
53
+ const ctx = await requireActorAndBody(c);
54
+ if (isResponse(ctx)) return ctx;
55
+ const { actor, body, baseUrl, db } = ctx;
56
+
57
+ const targetApId = parseNonEmptyString(body.target_ap_id);
58
+ if (!targetApId) {
59
+ return c.json({ error: "target_ap_id required", code: "BAD_REQUEST" }, 400);
60
+ }
61
+ if (targetApId === actor.ap_id) {
62
+ return c.json({ error: "Cannot follow yourself" }, 400);
63
+ }
64
+
65
+ const existing = await db
66
+ .select()
67
+ .from(follows)
68
+ .where(
69
+ and(
70
+ eq(follows.followerApId, actor.ap_id),
71
+ eq(follows.followingApId, targetApId),
72
+ ),
73
+ )
74
+ .get();
75
+ if (existing) {
76
+ // A leftover 'rejected' edge (from before reject switched to deleting the
77
+ // row) must not permanently block a re-follow — clear it and fall through to
78
+ // a fresh request. A live pending/accepted edge still blocks.
79
+ if (existing.status === "rejected") {
80
+ await db
81
+ .delete(follows)
82
+ .where(
83
+ and(
84
+ eq(follows.followerApId, actor.ap_id),
85
+ eq(follows.followingApId, targetApId),
86
+ ),
87
+ );
88
+ } else {
89
+ return c.json({ error: "Already following or pending" }, 400);
90
+ }
91
+ }
92
+
93
+ if (isLocal(targetApId, baseUrl)) {
94
+ return handleLocalFollow(c, db, baseUrl, actor, targetApId);
95
+ }
96
+ return handleRemoteFollow(c, db, baseUrl, actor, targetApId);
97
+ });
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // DELETE / -- Unfollow
101
+ // ---------------------------------------------------------------------------
102
+
103
+ follow.delete("/", async (c) => {
104
+ const ctx = await requireActorAndBody(c);
105
+ if (isResponse(ctx)) return ctx;
106
+ const { actor, body, baseUrl, db } = ctx;
107
+
108
+ const targetApId = parseNonEmptyString(body.target_ap_id);
109
+ if (!targetApId) {
110
+ return c.json({ error: "target_ap_id required", code: "BAD_REQUEST" }, 400);
111
+ }
112
+
113
+ const existingFollow = await db
114
+ .select()
115
+ .from(follows)
116
+ .where(
117
+ and(
118
+ eq(follows.followerApId, actor.ap_id),
119
+ eq(follows.followingApId, targetApId),
120
+ ),
121
+ )
122
+ .get();
123
+ if (!existingFollow) return c.json({ error: "Not following" }, 400);
124
+
125
+ const wasAccepted = existingFollow.status === "accepted";
126
+ const targetIsLocal = isLocal(targetApId, baseUrl);
127
+
128
+ const deleteEdge = db
129
+ .delete(follows)
130
+ .where(
131
+ and(
132
+ eq(follows.followerApId, actor.ap_id),
133
+ eq(follows.followingApId, targetApId),
134
+ ),
135
+ );
136
+
137
+ if (wasAccepted) {
138
+ // Co-commit the decrements + delete in ONE batch so a crash between them
139
+ // can't leave the edge gone with un-decremented counts (permanent over-
140
+ // count). Decrements run BEFORE the delete, guarded by EXISTS(accepted edge)
141
+ // + count>0 (underflow) — mirrors the federated undoFollowEdge.
142
+ const acceptedEdgeExists = sql`EXISTS (SELECT 1 FROM ${follows} WHERE ${follows.followerApId} = ${actor.ap_id} AND ${follows.followingApId} = ${targetApId} AND ${follows.status} = 'accepted')`;
143
+ const stmts: unknown[] = [
144
+ db
145
+ .update(actors)
146
+ .set({ followingCount: sql`${actors.followingCount} - 1` })
147
+ .where(
148
+ and(
149
+ eq(actors.apId, actor.ap_id),
150
+ gt(actors.followingCount, 0),
151
+ acceptedEdgeExists,
152
+ ),
153
+ ),
154
+ ];
155
+ if (targetIsLocal) {
156
+ stmts.push(
157
+ db
158
+ .update(actors)
159
+ .set({ followerCount: sql`${actors.followerCount} - 1` })
160
+ .where(
161
+ and(
162
+ eq(actors.apId, targetApId),
163
+ gt(actors.followerCount, 0),
164
+ acceptedEdgeExists,
165
+ ),
166
+ ),
167
+ );
168
+ }
169
+ stmts.push(deleteEdge);
170
+ await (db as unknown as Batchable).batch(stmts);
171
+ } else {
172
+ await deleteEdge;
173
+ }
174
+
175
+ if (!targetIsLocal) {
176
+ const undoObject = {
177
+ type: "Follow",
178
+ actor: actor.ap_id,
179
+ object: targetApId,
180
+ };
181
+ await createAndDeliverActivity(
182
+ c.env,
183
+ db,
184
+ baseUrl,
185
+ "Undo",
186
+ actor.ap_id,
187
+ undoObject,
188
+ targetApId,
189
+ targetApId,
190
+ );
191
+ }
192
+
193
+ return c.json({ success: true });
194
+ });
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // POST /accept -- Accept a single follow request
198
+ // ---------------------------------------------------------------------------
199
+
200
+ follow.post("/accept", async (c) => {
201
+ const ctx = await requireActorAndBody(c);
202
+ if (isResponse(ctx)) return ctx;
203
+ const { actor, body, baseUrl, db } = ctx;
204
+
205
+ const requesterApId = parseNonEmptyString(body.requester_ap_id);
206
+ if (!requesterApId) {
207
+ return c.json(
208
+ { error: "requester_ap_id required", code: "BAD_REQUEST" },
209
+ 400,
210
+ );
211
+ }
212
+
213
+ let pendingFollow: Awaited<ReturnType<typeof findPendingFollow>>;
214
+ try {
215
+ // Read the pending edge first (we need its activityApId for the Accept
216
+ // delivery below), then co-commit the flip + both increments in ONE batch.
217
+ // Previously the flip committed and the +1s were SEPARATE autocommits: a
218
+ // crash between them left the edge accepted with un-bumped counts, and the
219
+ // retry's conditional flip matched 0 rows so the increment was permanently
220
+ // SKIPPED (under-count). The increments are guarded by EXISTS(... pending) —
221
+ // evaluated before the in-batch flip — and the flip keeps its `status=
222
+ // 'pending'` predicate, so a concurrent duplicate accept can neither double-
223
+ // count nor under-count. Mirrors the federated handleAccept.
224
+ const pending = await findPendingFollow(db, requesterApId, actor.ap_id);
225
+ if (!pending) {
226
+ pendingFollow = undefined;
227
+ } else {
228
+ const pendingExists = sql`EXISTS (SELECT 1 FROM ${follows} WHERE ${follows.followerApId} = ${requesterApId} AND ${follows.followingApId} = ${actor.ap_id} AND ${follows.status} = 'pending')`;
229
+ const stmts: unknown[] = [
230
+ db
231
+ .update(actors)
232
+ .set({ followerCount: sql`${actors.followerCount} + 1` })
233
+ .where(and(eq(actors.apId, actor.ap_id), pendingExists)),
234
+ ];
235
+ if (isLocal(requesterApId, baseUrl)) {
236
+ stmts.push(
237
+ db
238
+ .update(actors)
239
+ .set({ followingCount: sql`${actors.followingCount} + 1` })
240
+ .where(and(eq(actors.apId, requesterApId), pendingExists)),
241
+ );
242
+ }
243
+ stmts.push(
244
+ db
245
+ .update(follows)
246
+ .set({ status: "accepted", acceptedAt: new Date().toISOString() })
247
+ .where(
248
+ and(
249
+ eq(follows.followerApId, requesterApId),
250
+ eq(follows.followingApId, actor.ap_id),
251
+ eq(follows.status, "pending"),
252
+ ),
253
+ ),
254
+ );
255
+ await (db as unknown as Batchable).batch(stmts);
256
+ pendingFollow = pending;
257
+ }
258
+ } catch (e) {
259
+ log.error("Error in accept", {
260
+ event: "follow.accept.error",
261
+ error: e,
262
+ });
263
+ return c.json({ error: "Internal error" }, 500);
264
+ }
265
+
266
+ if (!pendingFollow) {
267
+ return c.json({ error: "No pending follow request" }, 404);
268
+ }
269
+
270
+ await deliverResponseIfRemote(
271
+ c.env,
272
+ db,
273
+ baseUrl,
274
+ "Accept",
275
+ actor.ap_id,
276
+ requesterApId,
277
+ pendingFollow.activityApId,
278
+ );
279
+
280
+ return c.json({ success: true });
281
+ });
282
+
283
+ // ---------------------------------------------------------------------------
284
+ // POST /accept/batch -- Batch accept follow requests
285
+ // ---------------------------------------------------------------------------
286
+
287
+ follow.post("/accept/batch", async (c) => {
288
+ const ctx = await requireActorAndBody(c);
289
+ if (isResponse(ctx)) return ctx;
290
+ const { actor, body, baseUrl, db } = ctx;
291
+
292
+ const requesterApIds = parseStringArray(body.requester_ap_ids);
293
+ if (!requesterApIds || requesterApIds.length === 0) {
294
+ return c.json(
295
+ {
296
+ error: "requester_ap_ids array required",
297
+ code: "BAD_REQUEST",
298
+ },
299
+ 400,
300
+ );
301
+ }
302
+ if (requesterApIds.length > MAX_BATCH_ACCEPT_SIZE) {
303
+ return c.json(
304
+ {
305
+ error: `Batch size exceeds maximum of ${MAX_BATCH_ACCEPT_SIZE}`,
306
+ },
307
+ 400,
308
+ );
309
+ }
310
+
311
+ const pendingFollows = await db
312
+ .select()
313
+ .from(follows)
314
+ .where(
315
+ and(
316
+ inArray(follows.followerApId, requesterApIds),
317
+ eq(follows.followingApId, actor.ap_id),
318
+ eq(follows.status, "pending"),
319
+ ),
320
+ );
321
+ const pendingFollowMap = new Map(
322
+ pendingFollows.map((f) => [f.followerApId, f]),
323
+ );
324
+
325
+ const results: { ap_id: string; success: boolean; error?: string }[] = [];
326
+ const activitiesToCreate: Array<{
327
+ apId: string;
328
+ type: string;
329
+ actorApId: string;
330
+ objectApId: string | undefined;
331
+ rawJson: string;
332
+ direction: string;
333
+ }> = [];
334
+ const remoteEnqueues: Array<{ activityId: string; recipientApId: string }> =
335
+ [];
336
+
337
+ for (const requesterApId of requesterApIds) {
338
+ const pendingFollow = pendingFollowMap.get(requesterApId);
339
+ if (!pendingFollow) {
340
+ results.push({
341
+ ap_id: requesterApId,
342
+ success: false,
343
+ error: "No pending follow request",
344
+ });
345
+ continue;
346
+ }
347
+
348
+ try {
349
+ // Co-commit the flip + increments per request in ONE batch (mirrors the
350
+ // single accept). Previously each flip committed in the loop and the
351
+ // counts were bumped ONCE after the loop, so a crash mid-loop lost the
352
+ // accumulated increments for already-flipped rows. Increments guarded by
353
+ // EXISTS(pending) before the flip; the flip keeps its pending predicate.
354
+ const requesterIsLocal = isLocal(requesterApId, baseUrl);
355
+ const pendingExists = sql`EXISTS (SELECT 1 FROM ${follows} WHERE ${follows.followerApId} = ${requesterApId} AND ${follows.followingApId} = ${actor.ap_id} AND ${follows.status} = 'pending')`;
356
+ const stmts: unknown[] = [
357
+ db
358
+ .update(actors)
359
+ .set({ followerCount: sql`${actors.followerCount} + 1` })
360
+ .where(and(eq(actors.apId, actor.ap_id), pendingExists)),
361
+ ];
362
+ if (requesterIsLocal) {
363
+ stmts.push(
364
+ db
365
+ .update(actors)
366
+ .set({ followingCount: sql`${actors.followingCount} + 1` })
367
+ .where(and(eq(actors.apId, requesterApId), pendingExists)),
368
+ );
369
+ }
370
+ stmts.push(
371
+ db
372
+ .update(follows)
373
+ .set({ status: "accepted", acceptedAt: new Date().toISOString() })
374
+ .where(
375
+ and(
376
+ eq(follows.followerApId, requesterApId),
377
+ eq(follows.followingApId, actor.ap_id),
378
+ eq(follows.status, "pending"),
379
+ ),
380
+ ),
381
+ );
382
+ await (db as unknown as Batchable).batch(stmts);
383
+
384
+ if (requesterIsLocal) {
385
+ // local follower's followingCount already bumped in the batch above
386
+ } else if (isSafeRemoteUrl(requesterApId)) {
387
+ const id = activityApId(baseUrl, generateId());
388
+ const activity = buildApActivity(
389
+ "Accept",
390
+ actor.ap_id,
391
+ pendingFollow.activityApId,
392
+ id,
393
+ );
394
+
395
+ activitiesToCreate.push({
396
+ apId: id,
397
+ type: "Accept",
398
+ actorApId: actor.ap_id,
399
+ objectApId: pendingFollow.activityApId || undefined,
400
+ rawJson: JSON.stringify(activity),
401
+ direction: "outbound",
402
+ });
403
+ remoteEnqueues.push({ activityId: id, recipientApId: requesterApId });
404
+ } else {
405
+ log.warn("Blocked unsafe remote actor", {
406
+ event: "follow.accept.unsafe_remote_actor",
407
+ actor: requesterApId,
408
+ });
409
+ }
410
+
411
+ results.push({ ap_id: requesterApId, success: true });
412
+ } catch {
413
+ results.push({
414
+ ap_id: requesterApId,
415
+ success: false,
416
+ error: "Internal error",
417
+ });
418
+ }
419
+ }
420
+
421
+ // (Counts are bumped per-request inside the loop's batch — no aggregate
422
+ // post-loop increment, which could be lost on a mid-loop crash.)
423
+
424
+ if (activitiesToCreate.length > 0) {
425
+ await db.insert(activities).values(activitiesToCreate);
426
+ }
427
+
428
+ if (remoteEnqueues.length > 0) {
429
+ await Promise.allSettled(
430
+ remoteEnqueues.map((e) =>
431
+ enqueueDeliveryToActor(c.env, e.activityId, e.recipientApId),
432
+ ),
433
+ );
434
+ }
435
+
436
+ return c.json({
437
+ results,
438
+ accepted_count: results.filter((r) => r.success).length,
439
+ });
440
+ });
441
+
442
+ // ---------------------------------------------------------------------------
443
+ // POST /reject -- Reject a follow request
444
+ // ---------------------------------------------------------------------------
445
+
446
+ follow.post("/reject", async (c) => {
447
+ const ctx = await requireActorAndBody(c);
448
+ if (isResponse(ctx)) return ctx;
449
+ const { actor, body, baseUrl, db } = ctx;
450
+
451
+ const requesterApId = parseNonEmptyString(body.requester_ap_id);
452
+ if (!requesterApId) {
453
+ return c.json(
454
+ { error: "requester_ap_id required", code: "BAD_REQUEST" },
455
+ 400,
456
+ );
457
+ }
458
+
459
+ const pendingFollow = await findPendingFollow(db, requesterApId, actor.ap_id);
460
+ if (!pendingFollow) {
461
+ return c.json({ error: "No pending follow request" }, 404);
462
+ }
463
+
464
+ // DELETE the edge rather than parking it at status='rejected'. A 'rejected'
465
+ // row is a permanent dead state: the create path blocks on ANY existing edge
466
+ // ("Already following or pending") and the inbound handleFollow early-returns
467
+ // on a present edge, so the requester could NEVER re-follow. Deleting matches
468
+ // the INBOUND reject path (handleReject -> deleteFollowByCompoundKey) and the
469
+ // community join-request re-pend behaviour, letting a fresh Follow start clean.
470
+ // A pending edge was never counted, so no counter reconcile is needed.
471
+ await db
472
+ .delete(follows)
473
+ .where(
474
+ and(
475
+ eq(follows.followerApId, requesterApId),
476
+ eq(follows.followingApId, actor.ap_id),
477
+ ),
478
+ );
479
+
480
+ if (pendingFollow.activityApId) {
481
+ await db
482
+ .update(inbox)
483
+ .set({ read: 1 })
484
+ .where(
485
+ and(
486
+ eq(inbox.actorApId, actor.ap_id),
487
+ eq(inbox.activityApId, pendingFollow.activityApId),
488
+ ),
489
+ );
490
+ }
491
+
492
+ await deliverResponseIfRemote(
493
+ c.env,
494
+ db,
495
+ baseUrl,
496
+ "Reject",
497
+ actor.ap_id,
498
+ requesterApId,
499
+ pendingFollow.activityApId,
500
+ );
501
+
502
+ return c.json({ success: true });
503
+ });
504
+
505
+ // ---------------------------------------------------------------------------
506
+ // GET /requests -- Pending follow requests
507
+ // ---------------------------------------------------------------------------
508
+
509
+ follow.get("/requests", async (c) => {
510
+ const actor = c.get("actor");
511
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
512
+
513
+ const db = c.get("db");
514
+ // Capped at 90: this page's followerApIds are re-queried via `inArray` below,
515
+ // and Cloudflare D1 allows at most 100 bound parameters per query. The
516
+ // unclamped fallback (parseLimit returns it verbatim when no param is given)
517
+ // must also be <=90, hence 90/90. Offset paginates the rest.
518
+ const limit = parseLimit(c.req.query("limit"), 90, 90);
519
+ const offset = parseOffset(c.req.query("offset"), 0, 10000);
520
+
521
+ const followRows = await db
522
+ .select()
523
+ .from(follows)
524
+ .where(
525
+ and(
526
+ eq(follows.followingApId, actor.ap_id),
527
+ eq(follows.status, "pending"),
528
+ ),
529
+ )
530
+ // followerApId is the PK discriminator (followingApId is fixed = me); add it
531
+ // as a tiebreaker so same-millisecond pending requests page deterministically.
532
+ .orderBy(desc(follows.createdAt), desc(follows.followerApId))
533
+ .limit(limit)
534
+ .offset(offset);
535
+
536
+ const followerApIds = followRows.map((f) => f.followerApId);
537
+ const [localActors, cachedActors] = await Promise.all([
538
+ followerApIds.length > 0
539
+ ? db
540
+ .select({
541
+ apId: actors.apId,
542
+ preferredUsername: actors.preferredUsername,
543
+ name: actors.name,
544
+ iconUrl: actors.iconUrl,
545
+ })
546
+ .from(actors)
547
+ .where(inArray(actors.apId, followerApIds))
548
+ : Promise.resolve([]),
549
+ followerApIds.length > 0
550
+ ? db
551
+ .select({
552
+ apId: actorCache.apId,
553
+ preferredUsername: actorCache.preferredUsername,
554
+ name: actorCache.name,
555
+ iconUrl: actorCache.iconUrl,
556
+ })
557
+ .from(actorCache)
558
+ .where(inArray(actorCache.apId, followerApIds))
559
+ : Promise.resolve([]),
560
+ ]);
561
+
562
+ const actorInfoMap = new Map<
563
+ string,
564
+ {
565
+ preferredUsername: string | null;
566
+ name: string | null;
567
+ iconUrl: string | null;
568
+ }
569
+ >();
570
+ for (const a of cachedActors) actorInfoMap.set(a.apId, a);
571
+ for (const a of localActors) actorInfoMap.set(a.apId, a);
572
+
573
+ const result = followRows.map((f) => {
574
+ const actorInfo = actorInfoMap.get(f.followerApId);
575
+ return {
576
+ ap_id: f.followerApId,
577
+ username: formatUsername(f.followerApId),
578
+ preferred_username: actorInfo?.preferredUsername || null,
579
+ name: actorInfo?.name || null,
580
+ icon_url: actorInfo?.iconUrl || null,
581
+ created_at: f.createdAt,
582
+ };
583
+ });
584
+
585
+ return c.json({ requests: result });
586
+ });
587
+
588
+ export default follow;