@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,497 @@
1
+ import { and, eq, gt, sql } from "drizzle-orm";
2
+ import type { BatchItem } from "drizzle-orm/batch";
3
+ import {
4
+ actors,
5
+ announces,
6
+ follows,
7
+ likes,
8
+ objects,
9
+ reports,
10
+ } from "../../../../db/index.ts";
11
+ import type { Database } from "../../../../db/index.ts";
12
+ import {
13
+ activityApId,
14
+ generateId,
15
+ getDomain,
16
+ } from "../../../federation-helpers.ts";
17
+ import {
18
+ type Activity,
19
+ type ActivityContext,
20
+ getActivityObjectId,
21
+ } from "../inbox-types.ts";
22
+ import { notifyLocalObjectOwner } from "./inbox-shared-helpers.ts";
23
+ import { isLocal } from "../../../lib/ap-ids.ts";
24
+ import {
25
+ actorIsBlockedBy,
26
+ canViewerReadObjectFull,
27
+ } from "../../../lib/post-visibility.ts";
28
+ import { logger } from "../../../lib/logger.ts";
29
+
30
+ type ActorRow = typeof actors.$inferSelect;
31
+
32
+ const log = logger.child({ component: "activitypub.inbox.interaction" });
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Atomic multi-statement commit (mirrors posts/interactions.ts `runBatch`)
36
+ //
37
+ // D1 has no interactive transactions, but both the D1 and libsql drivers
38
+ // expose `db.batch([...])`, which commits a list of prepared statements
39
+ // atomically. The shared `Database` union aliases the abstract
40
+ // `BaseSQLiteDatabase` base (which does not surface `batch`), so we narrow to
41
+ // the concrete batch surface here rather than weakening the shared type.
42
+ // ---------------------------------------------------------------------------
43
+
44
+ type BatchStatement = BatchItem<"sqlite">;
45
+ interface BatchableDb {
46
+ batch(
47
+ statements: readonly [BatchStatement, ...BatchStatement[]],
48
+ ): Promise<unknown>;
49
+ }
50
+
51
+ async function runBatch(
52
+ db: Database,
53
+ statements: readonly [BatchStatement, ...BatchStatement[]],
54
+ ): Promise<void> {
55
+ await (db as unknown as BatchableDb).batch(statements);
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Interaction table / count-field mapping
60
+ // ---------------------------------------------------------------------------
61
+
62
+ type InteractionKind = "like" | "announce";
63
+
64
+ const INTERACTION_CONFIG = {
65
+ like: {
66
+ table: likes,
67
+ countField: "likeCount" as const,
68
+ activityType: "Like",
69
+ },
70
+ announce: {
71
+ table: announces,
72
+ countField: "announceCount" as const,
73
+ activityType: "Announce",
74
+ },
75
+ } as const;
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Generic interaction handler (shared by Like & Announce)
79
+ // ---------------------------------------------------------------------------
80
+
81
+ async function handleInteraction(
82
+ kind: InteractionKind,
83
+ c: ActivityContext,
84
+ activity: Activity,
85
+ actor: string,
86
+ baseUrl: string,
87
+ ): Promise<void> {
88
+ const db = c.get("db");
89
+ const objectId = getActivityObjectId(activity);
90
+ if (!objectId) return;
91
+
92
+ // Block + read gate for a LOCAL target. Every LOCAL interaction path (like /
93
+ // repost / reply / story like-vote-share) refuses an actor the owner has
94
+ // blocked or who cannot read the object; the inbound federated Like/Announce
95
+ // path did neither, so a personally-blocked remote could still bump the
96
+ // owner's like/boost counter AND deliver a "X liked your post" notification
97
+ // (defeating the block as a harassment remedy), and a remote that cannot read a
98
+ // restricted post could still interact with it. Mirror the local guards.
99
+ const target = await db
100
+ .select({
101
+ attributedTo: objects.attributedTo,
102
+ visibility: objects.visibility,
103
+ toJson: objects.toJson,
104
+ ccJson: objects.ccJson,
105
+ audienceJson: objects.audienceJson,
106
+ communityApId: objects.communityApId,
107
+ type: objects.type,
108
+ endTime: objects.endTime,
109
+ })
110
+ .from(objects)
111
+ .where(eq(objects.apId, objectId))
112
+ .get();
113
+ if (target && isLocal(target.attributedTo, baseUrl)) {
114
+ if (await actorIsBlockedBy(db, target.attributedTo, actor)) return;
115
+ if (!(await canViewerReadObjectFull(db, target, actor))) return;
116
+ }
117
+
118
+ const { table, countField, activityType } = INTERACTION_CONFIG[kind];
119
+ const activityId = activity.id || activityApId(baseUrl, generateId());
120
+
121
+ // Was the edge already present BEFORE this dispatch? This decides whether the
122
+ // dispatch represents a genuinely new interaction (gate for the one-shot
123
+ // owner notification below) — it does NOT gate the counter, which is derived
124
+ // atomically from the edge table state at commit (see below).
125
+ const existingEdge = await db
126
+ .select({ actorApId: table.actorApId })
127
+ .from(table)
128
+ .where(and(eq(table.actorApId, actor), eq(table.objectApId, objectId)))
129
+ .get();
130
+
131
+ // #7 (atomicity + idempotency): the edge insert and the counter maintenance
132
+ // MUST commit together. Previously the edge was inserted (onConflictDoNothing)
133
+ // and the counter was bumped in a SEPARATE statement; under the claim/processed
134
+ // re-dispatch model an interruption between the two left the edge present but
135
+ // the counter un-bumped, and a retry's no-op insert SKIPPED the bump → a
136
+ // permanent under-count. Group both into one atomic `db.batch`, and derive the
137
+ // counter from `COUNT(*)` of the edge table rather than a blind `+ 1`: the
138
+ // recompute is exact and idempotent, so a retry after a mid-write crash
139
+ // converges to the correct value and a genuine duplicate can never double-count.
140
+ await runBatch(db, [
141
+ db
142
+ .insert(table)
143
+ .values({
144
+ actorApId: actor,
145
+ objectApId: objectId,
146
+ activityApId: activityId,
147
+ })
148
+ .onConflictDoNothing(),
149
+ db
150
+ .update(objects)
151
+ .set({
152
+ [countField]: sql`(SELECT COUNT(*) FROM ${table} WHERE ${table.objectApId} = ${objectId})`,
153
+ })
154
+ .where(eq(objects.apId, objectId)),
155
+ ]);
156
+
157
+ // Only notify the local owner for a genuinely new interaction. A duplicate
158
+ // (re)delivery — including a wave-8 re-dispatch of an already-applied edge —
159
+ // must not spam a second notification.
160
+ if (existingEdge) return;
161
+
162
+ await notifyLocalObjectOwner(
163
+ db,
164
+ objectId,
165
+ activityId,
166
+ activityType,
167
+ actor,
168
+ activity,
169
+ baseUrl,
170
+ );
171
+ }
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // Like handler
175
+ // ---------------------------------------------------------------------------
176
+
177
+ export async function handleLike(
178
+ c: ActivityContext,
179
+ activity: Activity,
180
+ _recipient: ActorRow,
181
+ actor: string,
182
+ baseUrl: string,
183
+ ) {
184
+ await handleInteraction("like", c, activity, actor, baseUrl);
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Announce handler (repost/boost)
189
+ // ---------------------------------------------------------------------------
190
+
191
+ export async function handleAnnounce(
192
+ c: ActivityContext,
193
+ activity: Activity,
194
+ _recipient: ActorRow,
195
+ actor: string,
196
+ baseUrl: string,
197
+ ) {
198
+ await handleInteraction("announce", c, activity, actor, baseUrl);
199
+ }
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // Add handler (collection add; used by some servers for membership)
203
+ // ---------------------------------------------------------------------------
204
+
205
+ export async function handleAdd(
206
+ c: ActivityContext,
207
+ activity: Activity,
208
+ recipient: ActorRow,
209
+ actor: string,
210
+ ) {
211
+ const followingApId = resolveCollectionTarget(activity, recipient, actor);
212
+ if (!followingApId) return;
213
+
214
+ const db = c.get("db");
215
+ const now = new Date().toISOString();
216
+
217
+ // SECURITY (consent — federated follow-graph forgery): an `Add <local user>
218
+ // to <remote>/followers` is the remote CONFIRMING the local user's OWN Follow
219
+ // (it is an alias of Accept), NOT a license to make the local user follow the
220
+ // sender. It must therefore only TRANSITION a PRE-EXISTING pending edge to
221
+ // accepted (mirroring handleAccept) — never CREATE an edge. The previous
222
+ // version inserted a fresh `accepted` edge + bumped both counters whenever the
223
+ // edge was absent, so a remote could sign an unsolicited `Add` naming any
224
+ // local user as `object` and forge an accepted follow `<victim> -> <sender>`,
225
+ // inflating the victim's followingCount and routing the sender's posts into
226
+ // the victim's home feed — all without the victim ever following anyone.
227
+ //
228
+ // #COUNTER-SYM: like handleAccept, the two +1s run BEFORE the flip, each
229
+ // guarded by a correlated `EXISTS(... status='pending')` subquery, and the
230
+ // flip's own `status='pending'` predicate makes a duplicate/already-accepted
231
+ // (or absent) edge a total no-op — so counters can neither double-bump,
232
+ // under-count on retry, nor bump for an edge that was never pending.
233
+ const pendingEdgeExists = sql`EXISTS (SELECT 1 FROM ${follows} WHERE ${follows.followerApId} = ${recipient.apId} AND ${follows.followingApId} = ${followingApId} AND ${follows.status} = 'pending')`;
234
+ await runBatch(db, [
235
+ db
236
+ .update(actors)
237
+ .set({ followingCount: sql`${actors.followingCount} + 1` })
238
+ .where(and(eq(actors.apId, recipient.apId), pendingEdgeExists)),
239
+ db
240
+ .update(actors)
241
+ .set({ followerCount: sql`${actors.followerCount} + 1` })
242
+ .where(and(eq(actors.apId, followingApId), pendingEdgeExists)),
243
+ db
244
+ .update(follows)
245
+ .set({ status: "accepted", acceptedAt: now })
246
+ .where(
247
+ and(
248
+ eq(follows.followerApId, recipient.apId),
249
+ eq(follows.followingApId, followingApId),
250
+ eq(follows.status, "pending"),
251
+ ),
252
+ ),
253
+ ]);
254
+ }
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // Remove handler (collection remove; used for expulsion/ban)
258
+ // ---------------------------------------------------------------------------
259
+
260
+ export async function handleRemove(
261
+ c: ActivityContext,
262
+ activity: Activity,
263
+ recipient: ActorRow,
264
+ actor: string,
265
+ ) {
266
+ const followingApId = resolveCollectionTarget(activity, recipient, actor);
267
+ if (!followingApId) return;
268
+
269
+ const db = c.get("db");
270
+
271
+ // #COUNTER-SYM (crash-retry convergence): the edge delete and both -1s MUST
272
+ // commit together. Previously the delete committed first and the decrements
273
+ // were SEPARATE statements; a crash between them left the edge gone but the
274
+ // counts un-decremented, and the peer's retry matched 0 rows so the decrements
275
+ // were SKIPPED → a permanent OVER-count. Co-commit them in one atomic batch.
276
+ //
277
+ // The two decrements run BEFORE the delete and are each guarded by a
278
+ // correlated `EXISTS(... status='accepted')` subquery (so a pending /
279
+ // never-counted edge, a duplicate Remove, or an unknown edge does not drift
280
+ // the counts) plus a `count > 0` underflow guard (mirrors the local API delete
281
+ // paths in posts/interactions.ts which batch + guard both sides).
282
+ const acceptedEdgeExists = sql`EXISTS (SELECT 1 FROM ${follows} WHERE ${follows.followerApId} = ${recipient.apId} AND ${follows.followingApId} = ${followingApId} AND ${follows.status} = 'accepted')`;
283
+ await runBatch(db, [
284
+ db
285
+ .update(actors)
286
+ .set({ followingCount: sql`${actors.followingCount} - 1` })
287
+ .where(
288
+ and(
289
+ eq(actors.apId, recipient.apId),
290
+ gt(actors.followingCount, 0),
291
+ acceptedEdgeExists,
292
+ ),
293
+ ),
294
+ db
295
+ .update(actors)
296
+ .set({ followerCount: sql`${actors.followerCount} - 1` })
297
+ .where(
298
+ and(
299
+ eq(actors.apId, followingApId),
300
+ gt(actors.followerCount, 0),
301
+ acceptedEdgeExists,
302
+ ),
303
+ ),
304
+ db
305
+ .delete(follows)
306
+ .where(
307
+ and(
308
+ eq(follows.followerApId, recipient.apId),
309
+ eq(follows.followingApId, followingApId),
310
+ ),
311
+ ),
312
+ ]);
313
+ }
314
+
315
+ // ---------------------------------------------------------------------------
316
+ // Block handler (remote actor blocks the recipient)
317
+ // ---------------------------------------------------------------------------
318
+
319
+ export async function handleBlock(
320
+ c: ActivityContext,
321
+ activity: Activity,
322
+ recipient: ActorRow,
323
+ actor: string,
324
+ ) {
325
+ const db = c.get("db");
326
+ const blockedId = getActivityObjectId(activity);
327
+ if (!blockedId) return;
328
+
329
+ // Only act when the recipient is being blocked.
330
+ if (blockedId !== recipient.apId) return;
331
+
332
+ // Best-effort: sever follow relations in both directions. #COUNTER-SYM
333
+ // (crash-retry convergence): handle each direction as its own atomic
334
+ // edge-delete + counter reconcile so a crash between the delete and the
335
+ // decrements cannot leave a counter permanently over-counted (the peer's
336
+ // retry would otherwise match 0 rows and skip the decrement).
337
+ await severFollowEdge(db, recipient.apId, actor); // recipient follows actor
338
+ await severFollowEdge(db, actor, recipient.apId); // actor follows recipient
339
+ }
340
+
341
+ /**
342
+ * Atomically delete a (followerApId -> followingApId) follow edge and reconcile
343
+ * both denormalized counters in a single batch.
344
+ *
345
+ * #COUNTER-SYM: the decrements run BEFORE the delete, each guarded by a
346
+ * correlated `EXISTS(... status='accepted')` subquery (pending edges were never
347
+ * counted) plus a `count > 0` underflow guard, so a pending edge, a duplicate
348
+ * Block, a never-existing edge, or a crash-then-retry is a clean no-op rather
349
+ * than permanently over-counting.
350
+ */
351
+ export async function severFollowEdge(
352
+ db: Database,
353
+ followerApId: string,
354
+ followingApId: string,
355
+ ): Promise<void> {
356
+ const acceptedEdgeExists = sql`EXISTS (SELECT 1 FROM ${follows} WHERE ${follows.followerApId} = ${followerApId} AND ${follows.followingApId} = ${followingApId} AND ${follows.status} = 'accepted')`;
357
+ await runBatch(db, [
358
+ db
359
+ .update(actors)
360
+ .set({ followingCount: sql`${actors.followingCount} - 1` })
361
+ .where(
362
+ and(
363
+ eq(actors.apId, followerApId),
364
+ gt(actors.followingCount, 0),
365
+ acceptedEdgeExists,
366
+ ),
367
+ ),
368
+ db
369
+ .update(actors)
370
+ .set({ followerCount: sql`${actors.followerCount} - 1` })
371
+ .where(
372
+ and(
373
+ eq(actors.apId, followingApId),
374
+ gt(actors.followerCount, 0),
375
+ acceptedEdgeExists,
376
+ ),
377
+ ),
378
+ db
379
+ .delete(follows)
380
+ .where(
381
+ and(
382
+ eq(follows.followerApId, followerApId),
383
+ eq(follows.followingApId, followingApId),
384
+ ),
385
+ ),
386
+ ]);
387
+ }
388
+
389
+ // ---------------------------------------------------------------------------
390
+ // Flag handler (report)
391
+ // ---------------------------------------------------------------------------
392
+
393
+ export async function handleFlag(
394
+ c: ActivityContext,
395
+ activity: Activity,
396
+ actor: string,
397
+ ) {
398
+ const objectId = getActivityObjectId(activity);
399
+ const targetId = getActivityTargetId(activity);
400
+ // Flag activities carry a free-text reason in `content` (not part of the
401
+ // narrowed Activity type, so read it defensively).
402
+ const rawContent = (activity as { content?: unknown }).content;
403
+ // Cap the inbound reason length at ingest. The Flag `content` is fully
404
+ // attacker-controlled free text, so without a bound the reports table grows
405
+ // unbounded under report spam. 2000 chars is ample for a moderation reason.
406
+ const content =
407
+ typeof rawContent === "string" ? rawContent.slice(0, 2000) : null;
408
+
409
+ // The report target is the flagged object (preferred) or the activity target.
410
+ const reportTarget = objectId ?? targetId ?? null;
411
+
412
+ let instance: string | null = null;
413
+ try {
414
+ instance = getDomain(actor);
415
+ } catch {
416
+ instance = null;
417
+ }
418
+
419
+ // Persist the report so operators can triage it via the moderation API.
420
+ // Best-effort: never let a storage error 5xx the inbox (which would make
421
+ // the sender retry on a backoff). Log the failure for the operator.
422
+ try {
423
+ const db = c.get("db");
424
+ await db.insert(reports).values({
425
+ id: generateId(),
426
+ reporterApId: actor,
427
+ targetApId: reportTarget,
428
+ content,
429
+ instance,
430
+ });
431
+ } catch (err) {
432
+ log.warn("Failed to persist Flag report", {
433
+ event: "ap.flag.persist_failed",
434
+ actor,
435
+ object: objectId,
436
+ target: targetId,
437
+ error: err,
438
+ });
439
+ }
440
+
441
+ log.warn("Flag received", {
442
+ event: "ap.flag.received",
443
+ actor,
444
+ object: objectId,
445
+ target: targetId,
446
+ activityId: activity.id || null,
447
+ });
448
+ }
449
+
450
+ // ---------------------------------------------------------------------------
451
+ // Internal helpers
452
+ // ---------------------------------------------------------------------------
453
+
454
+ function getActivityTargetId(activity: Activity): string | null {
455
+ const target = activity.target;
456
+ if (!target) return null;
457
+ if (typeof target === "string") return target;
458
+ return target.id || null;
459
+ }
460
+
461
+ function normalizeCollectionTarget(targetId: string): string {
462
+ if (targetId.endsWith("/followers")) {
463
+ return targetId.slice(0, -"/followers".length);
464
+ }
465
+ return targetId;
466
+ }
467
+
468
+ /**
469
+ * Resolve the collection target for Add/Remove activities.
470
+ * Returns the normalized followingApId, or null if the activity should be ignored
471
+ * (missing object, object does not target recipient, or missing target).
472
+ */
473
+ function resolveCollectionTarget(
474
+ activity: Activity,
475
+ recipient: ActorRow,
476
+ actor: string,
477
+ ): string | null {
478
+ const objectId = getActivityObjectId(activity);
479
+ if (!objectId || objectId !== recipient.apId) return null;
480
+
481
+ const targetId = getActivityTargetId(activity);
482
+ const followingApId = normalizeCollectionTarget(targetId || actor) || null;
483
+ if (!followingApId) return null;
484
+
485
+ // SECURITY (federated follow-graph forgery): `activity.target` is
486
+ // attacker-controlled and only the signing actor is authenticated, NOT the
487
+ // target. Without this check a signed Add/Remove could forge or delete a local
488
+ // user's follow edge to an ARBITRARY third party (followingApId on any host).
489
+ // Constrain the resolved target to the signing actor's own origin, so an
490
+ // Add/Remove can only affect a relationship involving the sending actor.
491
+ try {
492
+ if (getDomain(followingApId) !== getDomain(actor)) return null;
493
+ } catch {
494
+ return null;
495
+ }
496
+ return followingApId;
497
+ }