@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,487 @@
1
+ import type { Context } from "hono";
2
+ import { and, count, desc, eq, inArray, sql } from "drizzle-orm";
3
+ import type { Database } from "../../db/index.ts";
4
+ import {
5
+ actorCache,
6
+ actors,
7
+ announces,
8
+ blocks,
9
+ bookmarks,
10
+ follows,
11
+ likes,
12
+ mutes,
13
+ } from "../../db/index.ts";
14
+ import { chunkForInClause } from "../lib/chunk.ts";
15
+ import type { Actor, Env, Variables } from "../types.ts";
16
+ import {
17
+ actorApId,
18
+ formatUsername,
19
+ getDomain,
20
+ parseLimit,
21
+ parseOffset,
22
+ } from "../federation-helpers.ts";
23
+
24
+ // Hono context with our app's bindings and variables
25
+ export type AppContext = Context<
26
+ { Bindings: Env; Variables: Variables },
27
+ string
28
+ >;
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Constants
32
+ // ---------------------------------------------------------------------------
33
+
34
+ // Capped at 90 (not 100): a profile page's post ids are re-queried via
35
+ // `inArray(col, postApIds)` for like/bookmark/announce enrichment, and
36
+ // Cloudflare D1 allows at most 100 bound parameters per query. (libsql, which
37
+ // the tests run on, allows ~32k and hides this.)
38
+ export const MAX_ACTOR_POSTS_LIMIT = 90;
39
+ export const MAX_PROFILE_NAME_LENGTH = 50;
40
+ export const MAX_PROFILE_SUMMARY_LENGTH = 500;
41
+ export const MAX_PROFILE_URL_LENGTH = 2000;
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Types
45
+ // ---------------------------------------------------------------------------
46
+
47
+ export type ActorInfo = {
48
+ apId: string;
49
+ preferredUsername: string | null;
50
+ name: string | null;
51
+ iconUrl: string | null;
52
+ summary?: string | null;
53
+ };
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Helpers
57
+ // ---------------------------------------------------------------------------
58
+
59
+ export function isValidHttpUrl(value: string): boolean {
60
+ try {
61
+ const parsed = new URL(value);
62
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Validate a profile image URL (icon / header). Accepts either an app-relative
70
+ * upload path (`/media/<hash>.<ext>` produced by POST /api/media/upload) or an
71
+ * absolute http(s) URL. Relative upload paths are stored verbatim and
72
+ * absolutized against APP_URL only when serialized for federation, mirroring
73
+ * how community icons and story attachments are handled.
74
+ */
75
+ export function isValidProfileImageUrl(value: string): boolean {
76
+ if (value.startsWith("/media/")) return true;
77
+ return isValidHttpUrl(value);
78
+ }
79
+
80
+ /**
81
+ * Batch-load actor info from both local and cached tables, returning a
82
+ * single lookup map keyed by apId. Local actors take precedence.
83
+ */
84
+ export async function loadActorInfoMap(
85
+ db: Database,
86
+ apIds: string[],
87
+ mode: "full" | "author" = "full",
88
+ ): Promise<Map<string, ActorInfo>> {
89
+ if (apIds.length === 0) return new Map();
90
+
91
+ const selectFull = {
92
+ apId: actors.apId,
93
+ preferredUsername: actors.preferredUsername,
94
+ name: actors.name,
95
+ iconUrl: actors.iconUrl,
96
+ summary: actors.summary,
97
+ };
98
+ const selectAuthor = {
99
+ apId: actors.apId,
100
+ preferredUsername: actors.preferredUsername,
101
+ name: actors.name,
102
+ iconUrl: actors.iconUrl,
103
+ };
104
+ const selectCacheFull = {
105
+ apId: actorCache.apId,
106
+ preferredUsername: actorCache.preferredUsername,
107
+ name: actorCache.name,
108
+ iconUrl: actorCache.iconUrl,
109
+ summary: actorCache.summary,
110
+ };
111
+ const selectCacheAuthor = {
112
+ apId: actorCache.apId,
113
+ preferredUsername: actorCache.preferredUsername,
114
+ name: actorCache.name,
115
+ iconUrl: actorCache.iconUrl,
116
+ };
117
+
118
+ const localSelect = mode === "full" ? selectFull : selectAuthor;
119
+ const cacheSelect = mode === "full" ? selectCacheFull : selectCacheAuthor;
120
+
121
+ // Chunk the IN(...) lookups: callers may pass a whole page of author ids
122
+ // (up to ~90) and D1 caps a query at 100 bound parameters. Chunks are
123
+ // disjoint id slices, so merging their maps is collision-free; cached-then-
124
+ // local ordering still gives local-wins within each chunk.
125
+ const map = new Map<string, ActorInfo>();
126
+ for (const ids of chunkForInClause(apIds)) {
127
+ const [local, cached] = await Promise.all([
128
+ db.select(localSelect).from(actors).where(inArray(actors.apId, ids)),
129
+ db
130
+ .select(cacheSelect)
131
+ .from(actorCache)
132
+ .where(inArray(actorCache.apId, ids)),
133
+ ]);
134
+ for (const a of cached) map.set(a.apId, a);
135
+ for (const a of local) map.set(a.apId, a); // local wins
136
+ }
137
+ return map;
138
+ }
139
+
140
+ /**
141
+ * Format a looked-up actor into the common JSON shape used by blocked/muted/followers/following lists.
142
+ */
143
+ export function formatActorSummary(
144
+ apId: string,
145
+ info: ActorInfo | undefined,
146
+ ): {
147
+ ap_id: string;
148
+ username: string;
149
+ preferred_username: string | null;
150
+ name: string | null;
151
+ icon_url: string | null;
152
+ summary: string | null;
153
+ } {
154
+ return {
155
+ ap_id: apId,
156
+ username: formatUsername(apId),
157
+ preferred_username: info?.preferredUsername || null,
158
+ name: info?.name || null,
159
+ icon_url: info?.iconUrl || null,
160
+ summary: info?.summary ?? null,
161
+ };
162
+ }
163
+
164
+ /**
165
+ * Resolve an identifier (AP ID, @user@domain, or bare username) to an AP ID string.
166
+ * Returns null when the identifier cannot be resolved.
167
+ */
168
+ export async function resolveActorApId(
169
+ db: Database,
170
+ baseUrl: string,
171
+ identifier: string,
172
+ ): Promise<string | null> {
173
+ if (identifier.startsWith("http")) return identifier;
174
+
175
+ if (!identifier.includes("@")) return actorApId(baseUrl, identifier);
176
+
177
+ const stripped = identifier.replace(/^@/, "");
178
+ const parts = stripped.split("@");
179
+ const username = parts[0];
180
+ if (!username) return null;
181
+
182
+ if (parts.length === 1) return actorApId(baseUrl, username);
183
+
184
+ const domain = parts.slice(1).join("@");
185
+ if (!domain) return null;
186
+ if (domain === getDomain(baseUrl)) return actorApId(baseUrl, username);
187
+
188
+ const cached = await db
189
+ .select({ apId: actorCache.apId })
190
+ .from(actorCache)
191
+ .where(
192
+ and(
193
+ eq(actorCache.preferredUsername, username),
194
+ // Match the domain as a LITERAL substring of the AP-ID via instr() — NOT
195
+ // `LIKE '%' || domain || '%'`. The domain comes from a federated handle
196
+ // (`@user@domain`) and is caller-influenceable: an unescaped LIKE would
197
+ // (a) treat a `%`/`_` in `domain` as a wildcard — `@alice@%` would match
198
+ // ANY cached `alice` regardless of domain (wrong-actor resolution), and
199
+ // (b) trip D1's LIKE pattern-complexity limit (SQLITE_ERROR 7500) for a
200
+ // long domain. instr() is literal (no wildcards, no escaping) and has no
201
+ // length limit, so `@alice@%` correctly resolves to null. Both sides are
202
+ // lowercased to preserve LIKE's ASCII case-insensitivity: a handle typed
203
+ // with a non-canonical host case (`@alice@Mastodon.Social`) — which
204
+ // webfinger treats as equivalent — still matches the lowercase cached host.
205
+ sql`instr(lower(${actorCache.apId}), lower(${domain})) > 0`,
206
+ ),
207
+ )
208
+ .get();
209
+ return cached?.apId || null;
210
+ }
211
+
212
+ /**
213
+ * Check that an actor exists in either the local or cached table.
214
+ */
215
+ export async function actorExists(
216
+ db: Database,
217
+ apId: string,
218
+ ): Promise<boolean> {
219
+ const [local, cached] = await Promise.all([
220
+ db
221
+ .select({ apId: actors.apId })
222
+ .from(actors)
223
+ .where(and(eq(actors.apId, apId), sql`${actors.deletedAt} IS NULL`))
224
+ .get(),
225
+ db
226
+ .select({ apId: actorCache.apId })
227
+ .from(actorCache)
228
+ .where(eq(actorCache.apId, apId))
229
+ .get(),
230
+ ]);
231
+ return !!(local || cached);
232
+ }
233
+
234
+ /**
235
+ * Require the current actor from context. Returns the actor or a 401 Response.
236
+ */
237
+ export function requireActor(c: AppContext): Actor | Response {
238
+ const actor = c.get("actor");
239
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
240
+ return actor;
241
+ }
242
+
243
+ /**
244
+ * Batch-load interaction status (liked, bookmarked, reposted) for the given
245
+ * post AP IDs. Returns empty sets when the actor is not logged in.
246
+ */
247
+ export async function loadPostInteractions(
248
+ db: Database,
249
+ actorApIdVal: string | null,
250
+ postApIds: string[],
251
+ ): Promise<{
252
+ likedIds: Set<string>;
253
+ bookmarkedIds: Set<string>;
254
+ repostedIds: Set<string>;
255
+ }> {
256
+ if (!actorApIdVal || postApIds.length === 0) {
257
+ return {
258
+ likedIds: new Set(),
259
+ bookmarkedIds: new Set(),
260
+ repostedIds: new Set(),
261
+ };
262
+ }
263
+
264
+ const [likeRows, bookmarkRows, announceRows] = await Promise.all([
265
+ db
266
+ .select({ objectApId: likes.objectApId })
267
+ .from(likes)
268
+ .where(
269
+ and(
270
+ eq(likes.actorApId, actorApIdVal),
271
+ inArray(likes.objectApId, postApIds),
272
+ ),
273
+ ),
274
+ db
275
+ .select({ objectApId: bookmarks.objectApId })
276
+ .from(bookmarks)
277
+ .where(
278
+ and(
279
+ eq(bookmarks.actorApId, actorApIdVal),
280
+ inArray(bookmarks.objectApId, postApIds),
281
+ ),
282
+ ),
283
+ db
284
+ .select({ objectApId: announces.objectApId })
285
+ .from(announces)
286
+ .where(
287
+ and(
288
+ eq(announces.actorApId, actorApIdVal),
289
+ inArray(announces.objectApId, postApIds),
290
+ ),
291
+ ),
292
+ ]);
293
+
294
+ return {
295
+ likedIds: new Set(likeRows.map((l) => l.objectApId)),
296
+ bookmarkedIds: new Set(bookmarkRows.map((b) => b.objectApId)),
297
+ repostedIds: new Set(announceRows.map((a) => a.objectApId)),
298
+ };
299
+ }
300
+
301
+ /**
302
+ * Generic list handler for relation lists (blocked, muted).
303
+ * Fetches paginated relations, batch-loads actor info, and returns formatted summaries.
304
+ */
305
+ export async function listRelation<
306
+ T extends { [K in ApIdKey]: string },
307
+ ApIdKey extends string,
308
+ >(
309
+ c: AppContext,
310
+ findMany: (
311
+ db: Database,
312
+ actorApIdVal: string,
313
+ limit: number,
314
+ offset: number,
315
+ ) => Promise<T[]>,
316
+ apIdKey: ApIdKey,
317
+ responseKey: string,
318
+ ): Promise<Response> {
319
+ const result = requireActor(c);
320
+ if (result instanceof Response) return result;
321
+ const actor = result;
322
+
323
+ const db = c.get("db");
324
+ const limit = parseLimit(c.req.query("limit"), 100, 500);
325
+ const offset = parseOffset(c.req.query("offset"), 0, 10000);
326
+
327
+ const rows = await findMany(db, actor.ap_id, limit, offset);
328
+ const targetApIds = rows.map((r) => r[apIdKey]);
329
+ const infoMap = await loadActorInfoMap(db, targetApIds);
330
+
331
+ return c.json({
332
+ [responseKey]: rows.map((r) =>
333
+ formatActorSummary(r[apIdKey], infoMap.get(r[apIdKey])),
334
+ ),
335
+ });
336
+ }
337
+
338
+ // Per-actor cap on block / mute rows. The target ap_id is an arbitrary string
339
+ // (no FK, no existence check — a remote actor need not be resolvable), so
340
+ // without a cap one account could loop POST with distinct synthetic ids and grow
341
+ // the blocks/mutes table without bound. 5000 is far above any real user's set.
342
+ export const MAX_RELATIONS_PER_ACTOR = 5000;
343
+
344
+ /**
345
+ * Generic create handler for relation upserts (block, mute).
346
+ */
347
+ export async function createRelation(
348
+ c: AppContext,
349
+ verb: string,
350
+ upsert: (
351
+ db: Database,
352
+ actorApIdVal: string,
353
+ targetApId: string,
354
+ ) => Promise<unknown>,
355
+ countExisting: (db: Database, actorApIdVal: string) => Promise<number>,
356
+ ): Promise<Response> {
357
+ const result = requireActor(c);
358
+ if (result instanceof Response) return result;
359
+ const actor = result;
360
+
361
+ const body = await c.req.json<{ ap_id: string }>();
362
+ if (!body.ap_id) return c.json({ error: "ap_id required" }, 400);
363
+ if (body.ap_id === actor.ap_id) {
364
+ return c.json({ error: `Cannot ${verb} yourself` }, 400);
365
+ }
366
+
367
+ const db = c.get("db");
368
+ // Bound the per-actor relation set (soft cap; a small concurrent overshoot is
369
+ // harmless). onConflictDoNothing means re-blocking an existing target is a
370
+ // no-op, so this only rejects genuinely-new rows past the limit.
371
+ if ((await countExisting(db, actor.ap_id)) >= MAX_RELATIONS_PER_ACTOR) {
372
+ return c.json({ error: `${verb} limit reached` }, 429);
373
+ }
374
+ await upsert(db, actor.ap_id, body.ap_id);
375
+
376
+ return c.json({ success: true });
377
+ }
378
+
379
+ /**
380
+ * Generic delete handler for relation removals (unblock, unmute).
381
+ */
382
+ export async function deleteRelation(
383
+ c: AppContext,
384
+ label: string,
385
+ remove: (
386
+ db: Database,
387
+ actorApIdVal: string,
388
+ targetApId: string,
389
+ ) => Promise<unknown>,
390
+ ): Promise<Response> {
391
+ const result = requireActor(c);
392
+ if (result instanceof Response) return result;
393
+ const actor = result;
394
+
395
+ const body = await c.req.json<{ ap_id: string }>();
396
+ if (!body.ap_id) return c.json({ error: "ap_id required" }, 400);
397
+
398
+ const db = c.get("db");
399
+ await remove(db, actor.ap_id, body.ap_id);
400
+
401
+ return c.json({ success: true });
402
+ }
403
+
404
+ /**
405
+ * Shared handler for followers / following lists.
406
+ */
407
+ export async function listFollowRelation(
408
+ c: AppContext,
409
+ direction: "followers" | "following",
410
+ ): Promise<Response> {
411
+ const identifier = c.req.param("identifier");
412
+ if (!identifier) return c.json({ error: "Actor not found" }, 404);
413
+ const apId = await resolveActorApId(c.get("db"), c.env.APP_URL, identifier);
414
+ if (!apId) return c.json({ error: "Actor not found" }, 404);
415
+
416
+ const limit = parseLimit(c.req.query("limit"), 50, 100);
417
+ const offset = parseOffset(c.req.query("offset"), 0, 10000);
418
+ const db = c.get("db");
419
+
420
+ const isFollowers = direction === "followers";
421
+ const whereCondition = isFollowers
422
+ ? and(eq(follows.followingApId, apId), eq(follows.status, "accepted"))
423
+ : and(eq(follows.followerApId, apId), eq(follows.status, "accepted"));
424
+
425
+ // A private (locked) account's follower/following LIST is owner-only — mirror
426
+ // the ActivityPub collection gate (canViewPrivateActorCollections), which this
427
+ // API path was missing, so a locked account's social graph (who follows it /
428
+ // who it follows) is not exposed to non-owners. The count stays (it is shown on
429
+ // the profile); only the member list (the sensitive WHO) is withheld.
430
+ const viewer = c.get("actor");
431
+ const targetRow = await db
432
+ .select({ isPrivate: actors.isPrivate })
433
+ .from(actors)
434
+ .where(eq(actors.apId, apId))
435
+ .get();
436
+ if (targetRow?.isPrivate && viewer?.ap_id !== apId) {
437
+ const total =
438
+ (
439
+ await db
440
+ .select({ count: count() })
441
+ .from(follows)
442
+ .where(whereCondition)
443
+ .get()
444
+ )?.count ?? 0;
445
+ return c.json({ [direction]: [], total, limit, offset, has_more: false });
446
+ }
447
+
448
+ const [followRows, totalResult] = await Promise.all([
449
+ db
450
+ .select()
451
+ .from(follows)
452
+ .where(whereCondition)
453
+ // createdAt (nowIso millisecond text) is NOT unique, so OFFSET paging over
454
+ // it alone has undefined order among same-millisecond ties — a tied row can
455
+ // shift across a page boundary and be skipped or duplicated. Append the
456
+ // composite-PK columns as a unique tiebreaker for a deterministic order
457
+ // (mirrors the federated /ap/.../followers keyset paging).
458
+ .orderBy(
459
+ desc(follows.createdAt),
460
+ desc(follows.followerApId),
461
+ desc(follows.followingApId),
462
+ )
463
+ .offset(offset)
464
+ .limit(limit),
465
+ db.select({ count: count() }).from(follows).where(whereCondition).get(),
466
+ ]);
467
+
468
+ const total = totalResult?.count ?? 0;
469
+
470
+ const extractApId = isFollowers
471
+ ? (f: { followerApId: string }) => f.followerApId
472
+ : (f: { followingApId: string }) => f.followingApId;
473
+ const targetApIds = followRows.map(extractApId);
474
+ const infoMap = await loadActorInfoMap(db, targetApIds);
475
+ const items = followRows.map((f) => {
476
+ const id = extractApId(f);
477
+ return formatActorSummary(id, infoMap.get(id));
478
+ });
479
+
480
+ return c.json({
481
+ [direction]: items,
482
+ total,
483
+ limit,
484
+ offset,
485
+ has_more: offset + items.length < total,
486
+ });
487
+ }