@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,13 @@
1
+ // Story routes for Yurucommu backend
2
+ // v2: 1 Story = 1 Media (Instagram style)
3
+ import { Hono } from "hono";
4
+ import type { Env, Variables } from "../types.ts";
5
+ import baseRoutes from "./stories/routes.ts";
6
+ import interactionRoutes from "./stories/interactions.ts";
7
+
8
+ const stories = new Hono<{ Bindings: Env; Variables: Variables }>();
9
+
10
+ stories.route("/", baseRoutes);
11
+ stories.route("/", interactionRoutes);
12
+
13
+ export default stories;
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Takos Tools - DM handlers
3
+ *
4
+ * Handles: yurucommu_send_dm, yurucommu_get_dm_threads,
5
+ * yurucommu_get_dm_messages
6
+ */
7
+
8
+ import { and, desc, eq, isNotNull } from "drizzle-orm";
9
+ import {
10
+ activities,
11
+ actors,
12
+ blocks,
13
+ inbox,
14
+ objectRecipients,
15
+ objects,
16
+ } from "../../../db/index.ts";
17
+ import {
18
+ activityApId,
19
+ generateId,
20
+ objectApId,
21
+ safeJsonParse,
22
+ } from "../../federation-helpers.ts";
23
+ import {
24
+ MAX_DM_CONTENT_LENGTH,
25
+ resolveConversationId,
26
+ } from "../dm/query-helpers.ts";
27
+ import {
28
+ errAuth,
29
+ errNotFound,
30
+ errRequired,
31
+ ok,
32
+ requireString,
33
+ resolveDmPartner,
34
+ toolLimit,
35
+ type ToolResponse,
36
+ } from "../takos-tools-response.ts";
37
+ import type { Input, ToolContext } from "./types.ts";
38
+
39
+ // `.batch` lives only on the concrete D1/libsql subclasses; reach it through a
40
+ // narrow structural cast so the Note + recipient + activity + inbox commit
41
+ // atomically (no orphan-recipient DM on a partial write).
42
+ type Batchable = { batch: (stmts: unknown[]) => Promise<unknown> };
43
+
44
+ export async function handleSendDm(
45
+ c: ToolContext,
46
+ input: Input,
47
+ actor: { ap_id: string } | null,
48
+ ) {
49
+ if (!actor) return c.json(errAuth(), 401);
50
+
51
+ const db = c.get("db");
52
+ const recipient = requireString(input, "recipient");
53
+ const content = requireString(input, "content");
54
+ if (!recipient || !content) {
55
+ return c.json(errRequired("Recipient and content"), 400);
56
+ }
57
+ // Same content cap as the canonical DM route.
58
+ if (content.length > MAX_DM_CONTENT_LENGTH) {
59
+ return c.json(
60
+ { success: false, error: "Content too long" } as ToolResponse,
61
+ 400,
62
+ );
63
+ }
64
+
65
+ const target = await db
66
+ .select({ apId: actors.apId })
67
+ .from(actors)
68
+ .where(eq(actors.preferredUsername, recipient))
69
+ .get();
70
+ if (!target) return c.json(errNotFound("Recipient"), 404);
71
+
72
+ // SECURITY (#23, broken access control / block bypass): mirror the canonical
73
+ // DM send path (routes/dm/messages.ts) — reject when the recipient has blocked
74
+ // the sender. Respond with the same not-found shape as a missing recipient so
75
+ // the sender cannot distinguish a block from a non-existent user (no block leak).
76
+ const blockedBy = await db
77
+ .select({ blockerApId: blocks.blockerApId })
78
+ .from(blocks)
79
+ .where(
80
+ and(
81
+ eq(blocks.blockerApId, target.apId),
82
+ eq(blocks.blockedApId, actor.ap_id),
83
+ ),
84
+ )
85
+ .get();
86
+ if (blockedBy) return c.json(errNotFound("Recipient"), 404);
87
+
88
+ const baseUrl = c.env.APP_URL;
89
+ const now = new Date().toISOString();
90
+ const messageId = generateId();
91
+ const apId = objectApId(baseUrl, messageId);
92
+ // Resolve to the STORED conversation id of an existing thread (incl. legacy-
93
+ // scheme) so a tool-sent DM joins the same thread the web/API client sees,
94
+ // instead of forking a new current-scheme conversation. Matches dm/messages.ts.
95
+ const conversationId = await resolveConversationId(
96
+ db,
97
+ baseUrl,
98
+ actor.ap_id,
99
+ target.apId,
100
+ );
101
+ const toJson = JSON.stringify([target.apId]);
102
+ const ccJson = JSON.stringify([]);
103
+ const actId = activityApId(baseUrl, generateId());
104
+
105
+ // Co-commit the Note + recipient row + activity + inbox notification in one
106
+ // atomic batch. D1 has no interactive transactions, and the recipient's DM
107
+ // reader resolves membership ONLY via object_recipients — so a partial write
108
+ // (Note without its recipient row) would be permanently invisible to them.
109
+ await (db as unknown as Batchable).batch([
110
+ db.insert(objects).values({
111
+ apId,
112
+ type: "Note",
113
+ attributedTo: actor.ap_id,
114
+ content,
115
+ summary: null,
116
+ attachmentsJson: "[]",
117
+ inReplyTo: null,
118
+ conversation: conversationId,
119
+ visibility: "direct",
120
+ toJson,
121
+ ccJson,
122
+ published: now,
123
+ isLocal: 1,
124
+ }),
125
+ db
126
+ .insert(objectRecipients)
127
+ .values({ objectApId: apId, recipientApId: target.apId, type: "to" })
128
+ .onConflictDoNothing(),
129
+ db.insert(activities).values({
130
+ apId: actId,
131
+ type: "Create",
132
+ actorApId: actor.ap_id,
133
+ objectApId: apId,
134
+ rawJson: JSON.stringify({
135
+ type: "Create",
136
+ actor: actor.ap_id,
137
+ object: apId,
138
+ }),
139
+ direction: "inbound",
140
+ }),
141
+ db.insert(inbox).values({
142
+ actorApId: target.apId,
143
+ activityApId: actId,
144
+ }),
145
+ ]);
146
+
147
+ return c.json(ok({ message_id: apId, conversation_id: conversationId }));
148
+ }
149
+
150
+ export async function handleGetDmThreads(
151
+ c: ToolContext,
152
+ input: Input,
153
+ actor: { ap_id: string } | null,
154
+ ) {
155
+ if (!actor) return c.json(errAuth(), 401);
156
+
157
+ const db = c.get("db");
158
+ const limit = toolLimit(input.limit, 20, 50);
159
+
160
+ const dms = await db
161
+ .select({
162
+ attributedTo: objects.attributedTo,
163
+ toJson: objects.toJson,
164
+ published: objects.published,
165
+ content: objects.content,
166
+ })
167
+ .from(objects)
168
+ .where(
169
+ and(
170
+ eq(objects.visibility, "direct"),
171
+ eq(objects.type, "Note"),
172
+ isNotNull(objects.conversation),
173
+ ),
174
+ )
175
+ .orderBy(desc(objects.published))
176
+ .limit(2000);
177
+
178
+ // Filter to only messages where actor is sender or recipient, then group by partner
179
+ const threads: Record<
180
+ string,
181
+ { partner: string; lastMessage: string; lastDate: string }
182
+ > = {};
183
+
184
+ for (const dm of dms) {
185
+ const partner = resolveDmPartner(dm, actor.ap_id);
186
+ if (partner && !threads[partner]) {
187
+ threads[partner] = {
188
+ partner,
189
+ lastMessage: dm.content || "",
190
+ lastDate: dm.published,
191
+ };
192
+ }
193
+ }
194
+
195
+ return c.json(ok({ threads: Object.values(threads).slice(0, limit) }));
196
+ }
197
+
198
+ export async function handleGetDmMessages(
199
+ c: ToolContext,
200
+ input: Input,
201
+ actor: { ap_id: string } | null,
202
+ ) {
203
+ if (!actor) return c.json(errAuth(), 401);
204
+
205
+ const db = c.get("db");
206
+ const threadId = requireString(input, "thread_id");
207
+ const limit = toolLimit(input.limit, 50, 100);
208
+ if (!threadId) return c.json(errRequired("Thread ID"), 400);
209
+
210
+ const baseUrl = c.env.APP_URL;
211
+ // Read from the STORED conversation id (incl. a legacy-scheme thread) so the
212
+ // agent sees the full history, not just messages under the current-scheme id.
213
+ const conversationId = await resolveConversationId(
214
+ db,
215
+ baseUrl,
216
+ actor.ap_id,
217
+ threadId,
218
+ );
219
+
220
+ const messages = await db
221
+ .select()
222
+ .from(objects)
223
+ .where(
224
+ and(
225
+ eq(objects.visibility, "direct"),
226
+ eq(objects.type, "Note"),
227
+ eq(objects.conversation, conversationId),
228
+ ),
229
+ )
230
+ .orderBy(desc(objects.published))
231
+ .limit(limit);
232
+
233
+ const filtered = messages.filter((m) => {
234
+ if (m.attributedTo === actor.ap_id) return true;
235
+ return safeJsonParse<string[]>(m.toJson, []).includes(actor.ap_id);
236
+ });
237
+
238
+ return c.json(
239
+ ok({
240
+ messages: filtered.map((m) => ({
241
+ ap_id: m.apId,
242
+ content: m.content,
243
+ from: m.attributedTo,
244
+ published: m.published,
245
+ })),
246
+ conversation_id: conversationId,
247
+ }),
248
+ );
249
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Takos Tools - Follow handlers
3
+ *
4
+ * Handles: yurucommu_follow_user, yurucommu_unfollow_user,
5
+ * yurucommu_get_followers, yurucommu_get_following
6
+ */
7
+
8
+ import { and, eq, gt } from "drizzle-orm";
9
+ import { sql } from "drizzle-orm";
10
+ import { actors, follows } from "../../../db/index.ts";
11
+ import { actorIsBlockedBy } from "../../lib/post-visibility.ts";
12
+ import { isUniqueConstraintError } from "../../lib/parse-helpers.ts";
13
+ import {
14
+ errAuth,
15
+ errNotFound,
16
+ errRequired,
17
+ fetchFollowList,
18
+ formatActorSummary,
19
+ ok,
20
+ requireString,
21
+ toolLimit,
22
+ type ToolResponse,
23
+ } from "../takos-tools-response.ts";
24
+ import type { Input, ToolContext } from "./types.ts";
25
+
26
+ // `.batch` lives only on the concrete D1/libsql subclasses; reach it through a
27
+ // narrow structural cast so the edge + counter updates commit atomically.
28
+ type Batchable = { batch: (stmts: unknown[]) => Promise<unknown> };
29
+
30
+ export async function handleFollowUser(
31
+ c: ToolContext,
32
+ input: Input,
33
+ actor: { ap_id: string } | null,
34
+ ) {
35
+ if (!actor) return c.json(errAuth(), 401);
36
+
37
+ const db = c.get("db");
38
+ const username = requireString(input, "username");
39
+ if (!username) return c.json(errRequired("Username"), 400);
40
+
41
+ const target = await db
42
+ .select()
43
+ .from(actors)
44
+ .where(eq(actors.preferredUsername, username))
45
+ .get();
46
+ if (!target) return c.json(errNotFound("User"), 404);
47
+
48
+ if (target.apId === actor.ap_id) {
49
+ return c.json(
50
+ { success: false, error: "Cannot follow yourself" } as ToolResponse,
51
+ 400,
52
+ );
53
+ }
54
+
55
+ // A blocked actor must not (re-)establish a follow edge to the blocker — the
56
+ // block-sever invariant (block removes follows + gates inbound Follow) would be
57
+ // bypassed by this local edge insert. Mirrors the canonical follow route
58
+ // (follow-helpers.ts) and the DM tool's own block guard.
59
+ if (await actorIsBlockedBy(db, target.apId, actor.ap_id)) {
60
+ return c.json(errNotFound("User"), 404);
61
+ }
62
+
63
+ const existingFollow = await db
64
+ .select()
65
+ .from(follows)
66
+ .where(
67
+ and(
68
+ eq(follows.followerApId, actor.ap_id),
69
+ eq(follows.followingApId, target.apId),
70
+ ),
71
+ )
72
+ .get();
73
+
74
+ if (!existingFollow) {
75
+ const status = target.isPrivate ? "pending" : "accepted";
76
+
77
+ // Co-commit the edge + counter bumps in one atomic batch so a mid-write
78
+ // failure can't leave the edge with un-incremented counts (permanent drift).
79
+ const ops: unknown[] = [
80
+ db.insert(follows).values({
81
+ followerApId: actor.ap_id,
82
+ followingApId: target.apId,
83
+ status,
84
+ }),
85
+ ];
86
+ if (status === "accepted") {
87
+ ops.push(
88
+ db
89
+ .update(actors)
90
+ .set({ followingCount: sql`${actors.followingCount} + 1` })
91
+ .where(eq(actors.apId, actor.ap_id)),
92
+ db
93
+ .update(actors)
94
+ .set({ followerCount: sql`${actors.followerCount} + 1` })
95
+ .where(eq(actors.apId, target.apId)),
96
+ );
97
+ }
98
+ try {
99
+ await (db as unknown as Batchable).batch(ops);
100
+ } catch (e) {
101
+ // A concurrent follow won the race past the existing-check: the unique
102
+ // (follower, following) edge now exists and its atomic batch already
103
+ // applied the +1s. Treat as idempotent success instead of a 500.
104
+ if (!isUniqueConstraintError(e)) throw e;
105
+ }
106
+ }
107
+
108
+ return c.json(
109
+ ok({
110
+ following: true,
111
+ status: target.isPrivate ? "pending" : "accepted",
112
+ }),
113
+ );
114
+ }
115
+
116
+ export async function handleUnfollowUser(
117
+ c: ToolContext,
118
+ input: Input,
119
+ actor: { ap_id: string } | null,
120
+ ) {
121
+ if (!actor) return c.json(errAuth(), 401);
122
+
123
+ const db = c.get("db");
124
+ const username = requireString(input, "username");
125
+ if (!username) return c.json(errRequired("Username"), 400);
126
+
127
+ const target = await db
128
+ .select()
129
+ .from(actors)
130
+ .where(eq(actors.preferredUsername, username))
131
+ .get();
132
+ if (!target) return c.json(errNotFound("User"), 404);
133
+
134
+ const follow = await db
135
+ .select()
136
+ .from(follows)
137
+ .where(
138
+ and(
139
+ eq(follows.followerApId, actor.ap_id),
140
+ eq(follows.followingApId, target.apId),
141
+ ),
142
+ )
143
+ .get();
144
+
145
+ if (follow) {
146
+ // #COUNTER-SYM: the decrements run BEFORE the delete and are each gated on
147
+ // the accepted edge STILL existing (correlated EXISTS), so two concurrent /
148
+ // retried unfollows can't double-decrement — the second batch's EXISTS is
149
+ // false (the first already deleted the edge) → its -1s match 0 rows. gt(>0)
150
+ // additionally guards underflow. Mirrors the canonical web/federated paths.
151
+ const acceptedEdgeExists = sql`EXISTS (SELECT 1 FROM ${follows} WHERE ${follows.followerApId} = ${actor.ap_id} AND ${follows.followingApId} = ${target.apId} AND ${follows.status} = 'accepted')`;
152
+ const ops: unknown[] = [];
153
+ if (follow.status === "accepted") {
154
+ ops.push(
155
+ db
156
+ .update(actors)
157
+ .set({ followingCount: sql`${actors.followingCount} - 1` })
158
+ .where(
159
+ and(
160
+ eq(actors.apId, actor.ap_id),
161
+ gt(actors.followingCount, 0),
162
+ acceptedEdgeExists,
163
+ ),
164
+ ),
165
+ db
166
+ .update(actors)
167
+ .set({ followerCount: sql`${actors.followerCount} - 1` })
168
+ .where(
169
+ and(
170
+ eq(actors.apId, target.apId),
171
+ gt(actors.followerCount, 0),
172
+ acceptedEdgeExists,
173
+ ),
174
+ ),
175
+ );
176
+ }
177
+ ops.push(
178
+ db
179
+ .delete(follows)
180
+ .where(
181
+ and(
182
+ eq(follows.followerApId, actor.ap_id),
183
+ eq(follows.followingApId, target.apId),
184
+ ),
185
+ ),
186
+ );
187
+ await (db as unknown as Batchable).batch(ops);
188
+ }
189
+
190
+ return c.json(ok({ unfollowed: true }));
191
+ }
192
+
193
+ export async function handleGetFollowList(
194
+ c: ToolContext,
195
+ input: Input,
196
+ actor: { ap_id: string } | null,
197
+ direction: "followers" | "following",
198
+ ) {
199
+ const db = c.get("db");
200
+ const username = requireString(input, "username");
201
+ const limit = toolLimit(input.limit, 20, 50);
202
+
203
+ if (!username) return c.json(errRequired("Username"), 400);
204
+
205
+ const target = await db
206
+ .select()
207
+ .from(actors)
208
+ .where(eq(actors.preferredUsername, username))
209
+ .get();
210
+ if (!target) return c.json(errNotFound("User"), 404);
211
+
212
+ // A locked (private) account's follower/following list is withheld from
213
+ // everyone but the account owner — count only — matching the web route
214
+ // listFollowRelation. Without this the MCP surface would be a graph-
215
+ // enumeration bypass of the locked-account gate.
216
+ if (target.isPrivate && actor?.ap_id !== target.apId) {
217
+ const count =
218
+ direction === "followers" ? target.followerCount : target.followingCount;
219
+ return c.json(ok({ [direction]: [], count }));
220
+ }
221
+
222
+ const actorList = await fetchFollowList(db, target.apId, direction, limit);
223
+
224
+ return c.json(ok({ [direction]: actorList.map(formatActorSummary) }));
225
+ }