@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,370 @@
1
+ import type { Context } from "hono";
2
+ import { and, eq, sql } from "drizzle-orm";
3
+ import type { Env, Variables } from "../types.ts";
4
+ import type { Database } from "../../db/index.ts";
5
+ import {
6
+ activities,
7
+ actorCache,
8
+ actors,
9
+ follows,
10
+ inbox,
11
+ } from "../../db/index.ts";
12
+ import {
13
+ activityApId,
14
+ generateId,
15
+ isLocal,
16
+ isSafeRemoteUrl,
17
+ } from "../federation-helpers.ts";
18
+ import { enqueueDeliveryToActor } from "../lib/delivery/queue.ts";
19
+ import {
20
+ isUniqueConstraintError,
21
+ parseJsonObject,
22
+ parseNonEmptyString,
23
+ } from "../lib/parse-helpers.ts";
24
+ import { fetchAndUpsertActorCache } from "../lib/activitypub-actor-cache.ts";
25
+ import { actorIsBlockedBy } from "../lib/post-visibility.ts";
26
+ import { getInstanceFetchSigner } from "./activitypub/query-helpers.ts";
27
+ import { requireActor } from "./actors-helpers.ts";
28
+ import { logger } from "../lib/logger.ts";
29
+
30
+ const log = logger.child({ component: "follow.helpers" });
31
+
32
+ // `.batch` lives only on the concrete D1/libsql subclasses, not the Database
33
+ // union; reach it through a narrow structural cast (matching the other routes).
34
+ type Batchable = { batch: (stmts: unknown[]) => Promise<unknown> };
35
+
36
+ const REMOTE_FETCH_TIMEOUT_MS = 10000;
37
+
38
+ export type HonoContext = Context<{ Bindings: Env; Variables: Variables }>;
39
+
40
+ export type RemoteActor = {
41
+ id: string;
42
+ type?: string;
43
+ preferredUsername?: string;
44
+ name?: string;
45
+ summary?: string;
46
+ icon?: { url?: string };
47
+ inbox?: string;
48
+ outbox?: string;
49
+ followers?: string;
50
+ following?: string;
51
+ endpoints?: { sharedInbox?: string };
52
+ publicKey?: { id?: string; publicKeyPem?: string };
53
+ };
54
+
55
+ export type RequestContext = {
56
+ actor: { ap_id: string };
57
+ body: Record<string, unknown>;
58
+ baseUrl: string;
59
+ db: Database;
60
+ };
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Pure helpers
64
+ // ---------------------------------------------------------------------------
65
+
66
+ export { isUniqueConstraintError, parseJsonObject, parseNonEmptyString };
67
+
68
+ export function parseStringArray(value: unknown): string[] | null {
69
+ if (!Array.isArray(value)) return null;
70
+ const parsed = value
71
+ .map((v) => parseNonEmptyString(v))
72
+ .filter((v): v is string => v !== null);
73
+ if (parsed.length !== value.length) return null;
74
+ return parsed;
75
+ }
76
+
77
+ export function buildApActivity(
78
+ type: string,
79
+ actorId: string,
80
+ object: unknown,
81
+ id: string,
82
+ ): Record<string, unknown> {
83
+ return {
84
+ "@context": "https://www.w3.org/ns/activitystreams",
85
+ id,
86
+ type,
87
+ actor: actorId,
88
+ object,
89
+ published: new Date().toISOString(),
90
+ };
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Request helpers
95
+ // ---------------------------------------------------------------------------
96
+
97
+ /**
98
+ * Extracts the authenticated actor and parsed JSON body from a request.
99
+ * Returns a Response on auth or parse failure, or the extracted context on success.
100
+ */
101
+ export async function requireActorAndBody(
102
+ c: HonoContext,
103
+ ): Promise<RequestContext | Response> {
104
+ const actor = requireActor(c);
105
+ if (actor instanceof Response) return actor;
106
+ const body = await parseJsonObject(c);
107
+ if (!body) {
108
+ return c.json({ error: "Invalid request body", code: "BAD_REQUEST" }, 400);
109
+ }
110
+ return { actor, body, baseUrl: c.env.APP_URL, db: c.get("db") };
111
+ }
112
+
113
+ export function isResponse(
114
+ value: RequestContext | Response,
115
+ ): value is Response {
116
+ return value instanceof Response;
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Activity helpers
121
+ // ---------------------------------------------------------------------------
122
+
123
+ /**
124
+ * Creates an outbound AP activity record and enqueues it for delivery.
125
+ */
126
+ export async function createAndDeliverActivity(
127
+ env: Env,
128
+ db: Database,
129
+ baseUrl: string,
130
+ type: string,
131
+ actorId: string,
132
+ object: unknown,
133
+ recipientApId: string,
134
+ objectApId?: string | null,
135
+ ): Promise<void> {
136
+ const id = activityApId(baseUrl, generateId());
137
+ const activity = buildApActivity(type, actorId, object, id);
138
+
139
+ await db.insert(activities).values({
140
+ apId: id,
141
+ type,
142
+ actorApId: actorId,
143
+ objectApId: objectApId || undefined,
144
+ rawJson: JSON.stringify(activity),
145
+ direction: "outbound",
146
+ });
147
+
148
+ await enqueueDeliveryToActor(env, id, recipientApId);
149
+ }
150
+
151
+ /**
152
+ * Delivers an Accept or Reject activity to a remote requester.
153
+ * No-op if the requester is local.
154
+ */
155
+ export async function deliverResponseIfRemote(
156
+ env: Env,
157
+ db: Database,
158
+ baseUrl: string,
159
+ type: "Accept" | "Reject",
160
+ actorId: string,
161
+ requesterApId: string,
162
+ originalActivityApId: string | null,
163
+ ): Promise<void> {
164
+ if (isLocal(requesterApId, baseUrl)) return;
165
+ await createAndDeliverActivity(
166
+ env,
167
+ db,
168
+ baseUrl,
169
+ type,
170
+ actorId,
171
+ originalActivityApId,
172
+ requesterApId,
173
+ originalActivityApId,
174
+ );
175
+ }
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // Database helpers
179
+ // ---------------------------------------------------------------------------
180
+
181
+ /**
182
+ * Finds a pending follow request where `requesterApId` is trying to follow `targetApId`.
183
+ */
184
+ export async function findPendingFollow(
185
+ db: Database,
186
+ requesterApId: string,
187
+ targetApId: string,
188
+ ) {
189
+ return db
190
+ .select()
191
+ .from(follows)
192
+ .where(
193
+ and(
194
+ eq(follows.followerApId, requesterApId),
195
+ eq(follows.followingApId, targetApId),
196
+ eq(follows.status, "pending"),
197
+ ),
198
+ )
199
+ .get();
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Follow flow handlers
204
+ // ---------------------------------------------------------------------------
205
+
206
+ export async function handleLocalFollow(
207
+ c: HonoContext,
208
+ db: Database,
209
+ baseUrl: string,
210
+ actor: { ap_id: string },
211
+ targetApId: string,
212
+ ) {
213
+ const target = await db
214
+ .select({ isPrivate: actors.isPrivate })
215
+ .from(actors)
216
+ .where(eq(actors.apId, targetApId))
217
+ .get();
218
+ if (!target) return c.json({ error: "Target actor not found" }, 404);
219
+
220
+ // A blocked actor must not be able to follow / send a follow request to (and
221
+ // notify) the target who blocked them, nor re-establish a follow edge to a
222
+ // public account that blocked them. Same not-found shape so the block is not
223
+ // leaked; mirrors the story-like / DM block guard.
224
+ if (await actorIsBlockedBy(db, targetApId, actor.ap_id)) {
225
+ return c.json({ error: "Target actor not found" }, 404);
226
+ }
227
+
228
+ const status = target.isPrivate ? "pending" : "accepted";
229
+ const id = activityApId(baseUrl, generateId());
230
+ const now = new Date().toISOString();
231
+ const followActivity = buildApActivity("Follow", actor.ap_id, targetApId, id);
232
+
233
+ try {
234
+ const followInsert = db.insert(follows).values({
235
+ followerApId: actor.ap_id,
236
+ followingApId: targetApId,
237
+ status,
238
+ activityApId: id,
239
+ acceptedAt: status === "accepted" ? now : null,
240
+ });
241
+
242
+ if (status === "accepted") {
243
+ // Co-commit the edge insert + both increments in ONE batch so a crash
244
+ // between them can't leave the edge accepted with un-bumped counts (the
245
+ // retry would see the existing edge and skip the increment → permanent
246
+ // under-count). Increments guarded by NOT EXISTS(edge) — evaluated before
247
+ // the in-batch insert — so a concurrent duplicate can't double-count; the
248
+ // bare insert still throws on a true duplicate, rolling back the whole
249
+ // batch so the catch below returns the "Already following" 400.
250
+ const edgeAbsent = sql`NOT EXISTS (SELECT 1 FROM ${follows} WHERE ${follows.followerApId} = ${actor.ap_id} AND ${follows.followingApId} = ${targetApId})`;
251
+ await (db as unknown as Batchable).batch([
252
+ db
253
+ .update(actors)
254
+ .set({ followingCount: sql`${actors.followingCount} + 1` })
255
+ .where(and(eq(actors.apId, actor.ap_id), edgeAbsent)),
256
+ db
257
+ .update(actors)
258
+ .set({ followerCount: sql`${actors.followerCount} + 1` })
259
+ .where(and(eq(actors.apId, targetApId), edgeAbsent)),
260
+ followInsert,
261
+ ]);
262
+ } else {
263
+ await followInsert;
264
+ }
265
+
266
+ await db.insert(activities).values({
267
+ apId: id,
268
+ type: "Follow",
269
+ actorApId: actor.ap_id,
270
+ objectApId: targetApId,
271
+ rawJson: JSON.stringify(followActivity),
272
+ direction: "local",
273
+ });
274
+
275
+ await db.insert(inbox).values({
276
+ actorApId: targetApId,
277
+ activityApId: id,
278
+ read: 0,
279
+ });
280
+ } catch (error) {
281
+ if (isUniqueConstraintError(error)) {
282
+ return c.json({ error: "Already following or pending" }, 400);
283
+ }
284
+ throw error;
285
+ }
286
+
287
+ return c.json({ success: true, status });
288
+ }
289
+
290
+ export async function handleRemoteFollow(
291
+ c: HonoContext,
292
+ db: Database,
293
+ baseUrl: string,
294
+ actor: { ap_id: string },
295
+ targetApId: string,
296
+ ) {
297
+ if (!isSafeRemoteUrl(targetApId)) {
298
+ return c.json({ error: "Invalid target_ap_id" }, 400);
299
+ }
300
+
301
+ let cachedActorRow = await db
302
+ .select()
303
+ .from(actorCache)
304
+ .where(eq(actorCache.apId, targetApId))
305
+ .get();
306
+
307
+ if (!cachedActorRow) {
308
+ // Sign the actor GET as the instance actor so a remote running in
309
+ // authorized-fetch / secure mode (which 401s unsigned GETs) serves the
310
+ // document — otherwise following anyone on such an instance silently fails
311
+ // at resolution.
312
+ const result = await fetchAndUpsertActorCache(db, targetApId, {
313
+ timeout: REMOTE_FETCH_TIMEOUT_MS,
314
+ mode: "upsert",
315
+ signer: await getInstanceFetchSigner(c),
316
+ });
317
+ if (!result.ok) {
318
+ switch (result.reason) {
319
+ case "fetch_not_ok":
320
+ return c.json({ error: "Could not fetch remote actor" }, 400);
321
+ case "id_mismatch":
322
+ return c.json({ error: "Remote actor id mismatch" }, 400);
323
+ case "invalid_document":
324
+ case "missing_inbox":
325
+ case "missing_public_key":
326
+ return c.json({ error: "Invalid remote actor data" }, 400);
327
+ case "fetch_failed":
328
+ default:
329
+ return c.json({ error: "Failed to fetch remote actor" }, 400);
330
+ }
331
+ }
332
+ cachedActorRow = result.row;
333
+ }
334
+
335
+ if (!cachedActorRow?.inbox || !isSafeRemoteUrl(cachedActorRow.inbox)) {
336
+ return c.json({ error: "Invalid inbox URL" }, 400);
337
+ }
338
+
339
+ const id = activityApId(baseUrl, generateId());
340
+ const followActivity = buildApActivity("Follow", actor.ap_id, targetApId, id);
341
+
342
+ try {
343
+ await db.insert(follows).values({
344
+ followerApId: actor.ap_id,
345
+ followingApId: targetApId,
346
+ status: "pending",
347
+ activityApId: id,
348
+ });
349
+
350
+ await db.insert(activities).values({
351
+ apId: id,
352
+ type: "Follow",
353
+ actorApId: actor.ap_id,
354
+ objectApId: targetApId,
355
+ rawJson: JSON.stringify(followActivity),
356
+ direction: "outbound",
357
+ });
358
+ } catch (e) {
359
+ log.error("Failed to create remote follow", {
360
+ event: "follow.remote.create_failed",
361
+ actor: actor.ap_id,
362
+ target: targetApId,
363
+ error: e,
364
+ });
365
+ return c.json({ error: "Failed to follow remote actor" }, 500);
366
+ }
367
+
368
+ await enqueueDeliveryToActor(c.env, id, targetApId);
369
+ return c.json({ success: true, status: "pending" });
370
+ }