@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,547 @@
1
+ import type { Database } from "../../../../db/index.ts";
2
+ import { and, eq, gt, inArray, sql } from "drizzle-orm";
3
+ import {
4
+ activities,
5
+ actors,
6
+ follows,
7
+ likes,
8
+ objects,
9
+ } from "../../../../db/index.ts";
10
+ import {
11
+ activityApId,
12
+ generateId,
13
+ isLocal,
14
+ } from "../../../federation-helpers.ts";
15
+ import { enqueueDeliveryToActor } from "../../../lib/delivery/queue.ts";
16
+ import { actorIsBlockedBy } from "../../../lib/post-visibility.ts";
17
+ import { logger } from "../../../lib/logger.ts";
18
+ import {
19
+ type Activity,
20
+ type ActivityContext,
21
+ getActivityObject,
22
+ getActivityObjectId,
23
+ typeIncludes,
24
+ } from "../inbox-types.ts";
25
+ import {
26
+ findFollowByActivityId,
27
+ runBatch,
28
+ undoInteraction,
29
+ upsertActivityAndNotify,
30
+ } from "./inbox-shared-helpers.ts";
31
+
32
+ type ActorRow = typeof actors.$inferSelect;
33
+
34
+ const log = logger.child({ component: "activitypub.inbox.follow" });
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Follow handler
38
+ // ---------------------------------------------------------------------------
39
+
40
+ export async function handleFollow(
41
+ c: ActivityContext,
42
+ activity: Activity,
43
+ recipient: ActorRow,
44
+ actor: string,
45
+ baseUrl: string,
46
+ ) {
47
+ const db = c.get("db");
48
+
49
+ // A Follow from an actor the recipient has personally BLOCKED must be dropped —
50
+ // mirror the local follow guard (handleLocalFollow's actorIsBlockedBy 404).
51
+ // Without this, a blocked remote could re-follow (auto-accepting on a public
52
+ // recipient → followerCount++ + a follow notification + an Accept, so the
53
+ // blocked actor resumes receiving fan-out), or surface in the owner's
54
+ // follow-request list. `actor` is the HTTP-signature-verified signer. The inbox
55
+ // already ACKs, so dropping here causes no retry storm and does not leak the
56
+ // block to the sender.
57
+ if (await actorIsBlockedBy(db, recipient.apId, actor)) {
58
+ return;
59
+ }
60
+
61
+ const activityId = activity.id || activityApId(baseUrl, generateId());
62
+
63
+ // Determine if we need to approve
64
+ const status = recipient.isPrivate ? "pending" : "accepted";
65
+ const now = new Date().toISOString();
66
+
67
+ // Was the edge already present BEFORE this dispatch? This gates the one-shot
68
+ // owner notification / Accept reply below so a duplicate (re)delivery does not
69
+ // spam them — it does NOT gate the counter, which is reconciled atomically in
70
+ // the batch below against the edge table's pre-insert state.
71
+ const existingEdge = await db
72
+ .select({ followerApId: follows.followerApId })
73
+ .from(follows)
74
+ .where(
75
+ and(
76
+ eq(follows.followerApId, actor),
77
+ eq(follows.followingApId, recipient.apId),
78
+ ),
79
+ )
80
+ .get();
81
+
82
+ // #COUNTER-SYM (crash-retry convergence): the followers-edge insert and the
83
+ // recipient.followerCount +1 MUST commit together. Previously the edge was
84
+ // inserted (onConflictDoNothing) and, for an auto-accept recipient, the count
85
+ // was bumped in a SEPARATE statement gated on `isNewFollow`; under the
86
+ // claim/processed re-dispatch model a crash between the insert and the +1, then
87
+ // a peer retry (whose no-op insert sets isNewFollow=false → early return),
88
+ // permanently SKIPPED the increment → a follower UNDER-count. Co-commit the
89
+ // insert and the increment in one atomic batch (mirrors handleAccept / handleAdd).
90
+ //
91
+ // The increment runs BEFORE the insert, guarded by a correlated
92
+ // `NOT EXISTS(edge)` subquery, so it fires only when THIS batch is the one that
93
+ // creates the edge (the absent edge is observed in pre-insert state) AND only
94
+ // for an 'accepted' (non-private) recipient. A duplicate Follow, or a retry
95
+ // after the edge already existed, sees the edge present → the guard is false and
96
+ // the insert is a no-op, so the count can neither double-bump nor under-count.
97
+ // For a private recipient the edge inserts as 'pending' with no count change.
98
+ if (status === "accepted") {
99
+ const edgeAbsent = sql`NOT EXISTS (SELECT 1 FROM ${follows} WHERE ${follows.followerApId} = ${actor} AND ${follows.followingApId} = ${recipient.apId})`;
100
+ await runBatch(db, [
101
+ db
102
+ .update(actors)
103
+ .set({ followerCount: sql`${actors.followerCount} + 1` })
104
+ .where(and(eq(actors.apId, recipient.apId), edgeAbsent)),
105
+ db
106
+ .insert(follows)
107
+ .values({
108
+ followerApId: actor,
109
+ followingApId: recipient.apId,
110
+ status,
111
+ activityApId: activityId,
112
+ acceptedAt: now,
113
+ })
114
+ .onConflictDoNothing(),
115
+ ]);
116
+ } else {
117
+ // Private recipient: follow stays pending, no count change.
118
+ await db
119
+ .insert(follows)
120
+ .values({
121
+ followerApId: actor,
122
+ followingApId: recipient.apId,
123
+ status,
124
+ activityApId: activityId,
125
+ acceptedAt: null,
126
+ })
127
+ .onConflictDoNothing();
128
+ }
129
+
130
+ // If the edge already existed, this is a duplicate (re)delivery: the counter is
131
+ // already correct (the batch's no-op insert kept the NOT-EXISTS guard false),
132
+ // so do not re-notify the owner or re-send an Accept.
133
+ if (existingEdge) return;
134
+
135
+ // Store activity and add to inbox (AP Native notification)
136
+ await upsertActivityAndNotify(
137
+ db,
138
+ activityId,
139
+ "Follow",
140
+ actor,
141
+ recipient.apId,
142
+ activity,
143
+ recipient.apId,
144
+ );
145
+
146
+ // Send Accept response
147
+ // If the recipient requires approval, do NOT auto-accept.
148
+ if (status === "accepted" && !isLocal(actor, baseUrl)) {
149
+ const acceptId = activityApId(baseUrl, generateId());
150
+ const acceptActivity = {
151
+ "@context": "https://www.w3.org/ns/activitystreams",
152
+ id: acceptId,
153
+ type: "Accept",
154
+ actor: recipient.apId,
155
+ object: activityId,
156
+ };
157
+
158
+ // Store accept activity before enqueue.
159
+ await db.insert(activities).values({
160
+ apId: acceptId,
161
+ type: "Accept",
162
+ actorApId: recipient.apId,
163
+ objectApId: activityId,
164
+ rawJson: JSON.stringify(acceptActivity),
165
+ direction: "outbound",
166
+ });
167
+
168
+ // Outbound delivery must be async (no remote POST in request path).
169
+ await enqueueDeliveryToActor(c.env, acceptId, actor);
170
+ }
171
+ }
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // Accept handler
175
+ // ---------------------------------------------------------------------------
176
+
177
+ export async function handleAccept(
178
+ c: ActivityContext,
179
+ activity: Activity,
180
+ actor: string | null,
181
+ ) {
182
+ const db = c.get("db");
183
+ const followId = getActivityObjectId(activity);
184
+ if (!followId) return;
185
+
186
+ const follow = await findFollowByActivityId(db, followId);
187
+ if (!follow || follow.status === "accepted") return;
188
+
189
+ // Only the followed party may Accept the follow. The signing actor is bound to
190
+ // its domain upstream, so without this a different-domain actor that learned
191
+ // the follow activity id could flip someone else's pending follow to accepted.
192
+ if (!actor || follow.followingApId !== actor) return;
193
+
194
+ const now = new Date().toISOString();
195
+
196
+ try {
197
+ // #COUNTER-SYM (crash-retry convergence): the pending->accepted flip and
198
+ // both +1s MUST commit together. Previously the flip committed first and the
199
+ // increments were SEPARATE statements; a crash between them left the edge
200
+ // 'accepted' while the counts were un-bumped, and the peer's retry saw an
201
+ // already-accepted edge (the early-return above) so the increments were
202
+ // SKIPPED → a permanent UNDER-count. Co-commit the flip and the increments
203
+ // in one atomic batch so the whole transition is all-or-nothing.
204
+ //
205
+ // The two increments run BEFORE the flip and are each guarded by a
206
+ // correlated `EXISTS(... status='pending')` subquery, so they fire only when
207
+ // THIS batch is the one performing the transition (the still-pending edge is
208
+ // observed in pre-flip state). A concurrent duplicate Accept, or a retry
209
+ // after the flip already committed, sees a non-pending edge → both guards
210
+ // are false and the flip's `status='pending'` predicate is a no-op, so the
211
+ // counts can neither double-bump nor (on retry) under-count.
212
+ const pendingEdgeExists = sql`EXISTS (SELECT 1 FROM ${follows} WHERE ${follows.followerApId} = ${follow.followerApId} AND ${follows.followingApId} = ${follow.followingApId} AND ${follows.status} = 'pending')`;
213
+ await runBatch(db, [
214
+ db
215
+ .update(actors)
216
+ .set({ followingCount: sql`${actors.followingCount} + 1` })
217
+ .where(and(eq(actors.apId, follow.followerApId), pendingEdgeExists)),
218
+ db
219
+ .update(actors)
220
+ .set({ followerCount: sql`${actors.followerCount} + 1` })
221
+ .where(and(eq(actors.apId, follow.followingApId), pendingEdgeExists)),
222
+ db
223
+ .update(follows)
224
+ .set({ status: "accepted", acceptedAt: now })
225
+ .where(
226
+ and(
227
+ eq(follows.followerApId, follow.followerApId),
228
+ eq(follows.followingApId, follow.followingApId),
229
+ eq(follows.status, "pending"),
230
+ ),
231
+ ),
232
+ ]);
233
+ } catch (e) {
234
+ log.error("Error in handleAccept", {
235
+ event: "ap.accept.handler_error",
236
+ error: e,
237
+ });
238
+ }
239
+ }
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // Reject handler
243
+ // ---------------------------------------------------------------------------
244
+
245
+ export async function handleReject(
246
+ c: ActivityContext,
247
+ activity: Activity,
248
+ actor: string | null,
249
+ ) {
250
+ const db = c.get("db");
251
+ const followId = getActivityObjectId(activity);
252
+ if (!followId) return;
253
+
254
+ const follow = await findFollowByActivityId(db, followId);
255
+ if (!follow) return;
256
+
257
+ // Only the followed party may Reject the follow (see handleAccept).
258
+ if (!actor || follow.followingApId !== actor) return;
259
+
260
+ // A remote followee can Reject an ALREADY-ACCEPTED follow to terminate it
261
+ // (Mastodon does this on lock + remove-follower). handleAccept incremented the
262
+ // local follower's followingCount when the edge became accepted, so a Reject of
263
+ // an accepted edge MUST decrement it — otherwise the follower's following_count
264
+ // stays permanently +1 over its true value (the edge IS correctly deleted, only
265
+ // the denormalized count drifts). Co-commit the accepted-gated decrement with
266
+ // the delete, mirroring undoFollowEdge: a pending (never-counted) reject and a
267
+ // duplicate are no-ops via the EXISTS(... status='accepted') + gt(>0) guards.
268
+ // (followingApId — the rejecting followee — is the remote signer with no local
269
+ // actors row, so only the local follower's followingCount is reconciled.)
270
+ const acceptedEdgeExists = sql`EXISTS (SELECT 1 FROM ${follows} WHERE ${follows.followerApId} = ${follow.followerApId} AND ${follows.followingApId} = ${follow.followingApId} AND ${follows.status} = 'accepted')`;
271
+ await runBatch(db, [
272
+ db
273
+ .update(actors)
274
+ .set({ followingCount: sql`${actors.followingCount} - 1` })
275
+ .where(
276
+ and(
277
+ eq(actors.apId, follow.followerApId),
278
+ gt(actors.followingCount, 0),
279
+ acceptedEdgeExists,
280
+ ),
281
+ ),
282
+ db
283
+ .delete(follows)
284
+ .where(
285
+ and(
286
+ eq(follows.followerApId, follow.followerApId),
287
+ eq(follows.followingApId, follow.followingApId),
288
+ ),
289
+ ),
290
+ ]);
291
+ }
292
+
293
+ // ---------------------------------------------------------------------------
294
+ // Undo handler
295
+ // ---------------------------------------------------------------------------
296
+
297
+ export async function handleUndo(
298
+ c: ActivityContext,
299
+ activity: Activity,
300
+ recipient: ActorRow,
301
+ actor: string,
302
+ _baseUrl: string,
303
+ ) {
304
+ const db = c.get("db");
305
+ const activityObject = getActivityObject(activity);
306
+ const objectType = activityObject?.type;
307
+ const objectId = getActivityObjectId(activity);
308
+
309
+ // If object is just a string (activity ID), try to find the original activity
310
+ if (!objectType && objectId) {
311
+ const resolved = await resolveUndoByActivityId(
312
+ db,
313
+ objectId,
314
+ actor,
315
+ recipient,
316
+ );
317
+ if (resolved) return;
318
+ }
319
+
320
+ if (typeIncludes(objectType, "Follow")) {
321
+ await undoFollow(db, objectId, actor, recipient);
322
+ } else if (typeIncludes(objectType, "Like")) {
323
+ await undoLike(db, objectId, activityObject, actor, recipient);
324
+ } else if (typeIncludes(objectType, "Announce")) {
325
+ await undoAnnounce(db, objectId, activityObject, actor);
326
+ }
327
+ }
328
+
329
+ // ---------------------------------------------------------------------------
330
+ // Undo sub-handlers (internal)
331
+ // ---------------------------------------------------------------------------
332
+
333
+ /**
334
+ * When the Undo object is a bare ID string, look up the original activity
335
+ * and undo it based on its stored type.
336
+ * Returns true if handled (caller should return), false otherwise.
337
+ */
338
+ async function resolveUndoByActivityId(
339
+ db: Database,
340
+ objectId: string,
341
+ actor: string,
342
+ recipient: ActorRow,
343
+ ): Promise<boolean> {
344
+ const originalActivity = await db
345
+ .select({
346
+ type: activities.type,
347
+ objectApId: activities.objectApId,
348
+ actorApId: activities.actorApId,
349
+ })
350
+ .from(activities)
351
+ .where(eq(activities.apId, objectId))
352
+ .get();
353
+ if (!originalActivity) return false;
354
+
355
+ if (originalActivity.actorApId && originalActivity.actorApId !== actor) {
356
+ log.warn("Undo actor mismatch", {
357
+ event: "ap.undo.actor_mismatch",
358
+ actor,
359
+ originalActor: originalActivity.actorApId,
360
+ activityId: objectId,
361
+ });
362
+ return true;
363
+ }
364
+
365
+ if (originalActivity.type === "Follow") {
366
+ const follow = await findFollowByActivityId(db, objectId);
367
+ if (follow) {
368
+ await undoFollowEdge(
369
+ db,
370
+ follow.followerApId,
371
+ follow.followingApId,
372
+ recipient.apId,
373
+ );
374
+ }
375
+ return true;
376
+ }
377
+
378
+ if (
379
+ (originalActivity.type === "Like" ||
380
+ originalActivity.type === "Announce") &&
381
+ originalActivity.objectApId
382
+ ) {
383
+ const kind =
384
+ originalActivity.type === "Like"
385
+ ? ("like" as const)
386
+ : ("announce" as const);
387
+ const countField =
388
+ kind === "like" ? ("likeCount" as const) : ("announceCount" as const);
389
+ // #COUNTER-SYM: delegate to `undoInteraction`'s activityId path, which now
390
+ // co-commits the edge delete and a COUNT(*) recompute in one atomic batch.
391
+ // A crash-then-retry converges (the recompute is idempotent against the true
392
+ // edge set) instead of permanently over-counting on the retry's no-op
393
+ // delete. The actor-mismatch guard above still constrains who may undo.
394
+ await undoInteraction(db, kind, countField, undefined, objectId, actor);
395
+ return true;
396
+ }
397
+
398
+ return true;
399
+ }
400
+
401
+ /**
402
+ * Atomically remove a follow edge and reconcile `followerCount`.
403
+ *
404
+ * #COUNTER-SYM (crash-retry convergence): the edge delete and the -1 MUST
405
+ * commit together. Previously the delete committed first and the decrement was
406
+ * a SEPARATE statement; a crash between them left the edge gone but the count
407
+ * un-decremented, and the peer's retry matched 0 rows so the decrement was
408
+ * SKIPPED → a permanent OVER-count. Co-commit both in one batch. The decrement
409
+ * runs BEFORE the delete and is guarded by a correlated
410
+ * `EXISTS(... status='accepted')` subquery (so a pending/never-counted edge,
411
+ * a duplicate Undo, or an unknown edge does not drift the count) plus a
412
+ * `followerCount > 0` underflow guard (mirrors the local API delete paths in
413
+ * posts/interactions.ts which batch + guard both sides).
414
+ */
415
+ async function undoFollowEdge(
416
+ db: Database,
417
+ followerApId: string,
418
+ followingApId: string,
419
+ recipientApId: string,
420
+ ): Promise<void> {
421
+ const acceptedEdgeExists = sql`EXISTS (SELECT 1 FROM ${follows} WHERE ${follows.followerApId} = ${followerApId} AND ${follows.followingApId} = ${followingApId} AND ${follows.status} = 'accepted')`;
422
+ await runBatch(db, [
423
+ db
424
+ .update(actors)
425
+ .set({ followerCount: sql`${actors.followerCount} - 1` })
426
+ .where(
427
+ and(
428
+ eq(actors.apId, recipientApId),
429
+ gt(actors.followerCount, 0),
430
+ acceptedEdgeExists,
431
+ ),
432
+ ),
433
+ db
434
+ .delete(follows)
435
+ .where(
436
+ and(
437
+ eq(follows.followerApId, followerApId),
438
+ eq(follows.followingApId, followingApId),
439
+ ),
440
+ ),
441
+ ]);
442
+ }
443
+
444
+ async function undoFollow(
445
+ db: Database,
446
+ objectId: string | null,
447
+ actor: string,
448
+ recipient: ActorRow,
449
+ ): Promise<void> {
450
+ const follow = objectId ? await findFollowByActivityId(db, objectId) : null;
451
+
452
+ // Bind the undo to the VERIFIED signer: an Undo(Follow) may only remove the
453
+ // SIGNER's own follow edge. The activity id is public (it appears in the
454
+ // originator's outbox), so a resolved edge whose follower != `actor` is a
455
+ // cross-actor forgery — an attacker severing a victim's follow + decrementing
456
+ // their followerCount. The bare-string Undo path already enforces this via
457
+ // resolveUndoByActivityId; the typed-object path must too.
458
+ if (follow && follow.followerApId !== actor) {
459
+ log.warn("Undo(Follow) actor mismatch", {
460
+ event: "ap.undo.follow.actor_mismatch",
461
+ actor,
462
+ followOwner: follow.followerApId,
463
+ activityId: objectId,
464
+ });
465
+ return;
466
+ }
467
+
468
+ // #COUNTER-SYM: co-commit the edge delete and the followerCount -1 atomically
469
+ // (see `undoFollowEdge`). `handleFollow` increments followerCount only for an
470
+ // 'accepted' follow, so the decrement is gated on the edge being accepted; a
471
+ // duplicate Undo, an Undo of a never-accepted (pending) follow, or an Undo of
472
+ // an unknown follow is a clean no-op and a crash-then-retry converges instead
473
+ // of permanently over-counting.
474
+ const followerApId = follow ? follow.followerApId : actor;
475
+ const followingApId = follow ? follow.followingApId : recipient.apId;
476
+ await undoFollowEdge(db, followerApId, followingApId, recipient.apId);
477
+ }
478
+
479
+ async function undoLike(
480
+ db: Database,
481
+ objectId: string | null,
482
+ activityObject: ReturnType<typeof getActivityObject>,
483
+ actor: string,
484
+ recipient: ActorRow,
485
+ ): Promise<void> {
486
+ const handled = await undoInteraction(
487
+ db,
488
+ "like",
489
+ "likeCount",
490
+ activityObject?.object,
491
+ objectId,
492
+ actor,
493
+ );
494
+ if (handled) return;
495
+
496
+ // Last resort: delete any like from this actor for the recipient's objects.
497
+ // #COUNTER-SYM: co-commit the edge delete and a COUNT(*) recompute of likeCount
498
+ // in one atomic batch (mirrors the primary undoInteraction path). Previously
499
+ // the delete committed first and the count was decremented (`- 1`) in a
500
+ // SEPARATE statement; a crash between them, then a peer retry whose delete
501
+ // matches 0 rows, permanently SKIPPED the decrement → a like OVER-count.
502
+ // Deriving each affected object's likeCount from COUNT(*) of the remaining like
503
+ // rows makes the fallback idempotent so a crash-then-retry CONVERGES instead of
504
+ // drifting.
505
+ // The recipient (local user) can have authored thousands of objects; splicing
506
+ // every id into an `IN (...)` would blow D1's 100-bound-parameter ceiling.
507
+ // Express the affected set as a SUBQUERY (delete) and a direct predicate
508
+ // (update) so no per-object parameter is bound. An empty set is a harmless
509
+ // no-op batch, so the previous early-return is unnecessary.
510
+ const recipientObjectsSubquery = db
511
+ .select({ apId: objects.apId })
512
+ .from(objects)
513
+ .where(eq(objects.attributedTo, recipient.apId));
514
+
515
+ await runBatch(db, [
516
+ db
517
+ .delete(likes)
518
+ .where(
519
+ and(
520
+ eq(likes.actorApId, actor),
521
+ inArray(likes.objectApId, recipientObjectsSubquery),
522
+ ),
523
+ ),
524
+ db
525
+ .update(objects)
526
+ .set({
527
+ likeCount: sql`(SELECT COUNT(*) FROM ${likes} WHERE ${likes.objectApId} = ${objects.apId})`,
528
+ })
529
+ .where(eq(objects.attributedTo, recipient.apId)),
530
+ ]);
531
+ }
532
+
533
+ async function undoAnnounce(
534
+ db: Database,
535
+ objectId: string | null,
536
+ activityObject: ReturnType<typeof getActivityObject>,
537
+ actor: string,
538
+ ): Promise<void> {
539
+ await undoInteraction(
540
+ db,
541
+ "announce",
542
+ "announceCount",
543
+ activityObject?.object,
544
+ objectId,
545
+ actor,
546
+ );
547
+ }