@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,539 @@
1
+ import type { Context, Hono } from "hono";
2
+ import { and, asc, count, desc, eq, inArray, or } from "drizzle-orm";
3
+ import { communities, communityMembers, follows } from "../../../db/index.ts";
4
+ import { chunkForInClause } from "../../lib/chunk.ts";
5
+ import { communityRequiresMembership } from "../../lib/community-visibility.ts";
6
+ import type { Env, Variables } from "../../types.ts";
7
+ import {
8
+ formatUsername,
9
+ parseLimit,
10
+ parseOffset,
11
+ } from "../../federation-helpers.ts";
12
+ import {
13
+ banMember,
14
+ batchLoadActorInfo,
15
+ demoteOwnerIfAnotherExists,
16
+ fetchCommunityId,
17
+ memberWhere,
18
+ removeMemberAtomic,
19
+ removeOwnerIfAnotherExists,
20
+ requireManager,
21
+ resolveCommunityApId,
22
+ } from "./membership-shared.ts";
23
+
24
+ const MAX_MEMBER_BATCH_SIZE = 100;
25
+
26
+ function validateBatchApIds(ids: unknown): string | null {
27
+ if (!Array.isArray(ids) || ids.length === 0) {
28
+ return "actor_ap_ids array is required";
29
+ }
30
+ if (ids.some((id) => typeof id !== "string" || id.trim().length === 0)) {
31
+ return "actor_ap_ids array is required";
32
+ }
33
+ if (ids.length > MAX_MEMBER_BATCH_SIZE) {
34
+ return `Batch size exceeds maximum of ${MAX_MEMBER_BATCH_SIZE}`;
35
+ }
36
+ return null;
37
+ }
38
+
39
+ export function registerMembershipMemberRoutes(
40
+ communitiesRouter: Hono<{ Bindings: Env; Variables: Variables }>,
41
+ ) {
42
+ // DELETE /api/communities/:identifier/members/:actorApId - Remove a member
43
+ communitiesRouter.delete(
44
+ "/:identifier/members/:actorApId",
45
+ async (c: Context<{ Bindings: Env; Variables: Variables }>) => {
46
+ const actor = c.get("actor");
47
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
48
+
49
+ const identifier = c.req.param("identifier")!;
50
+ const targetApId = decodeURIComponent(c.req.param("actorApId")!);
51
+ const db = c.get("db");
52
+
53
+ const { community } = await fetchCommunityId(c, identifier);
54
+ if (!community) {
55
+ return c.json({ error: "Community not found" }, 404);
56
+ }
57
+
58
+ const actorMembership = await requireManager(
59
+ db,
60
+ community.apId,
61
+ actor.ap_id,
62
+ );
63
+ if (!actorMembership) {
64
+ return c.json({ error: "Forbidden" }, 403);
65
+ }
66
+
67
+ if (targetApId === actor.ap_id) {
68
+ return c.json(
69
+ { error: "Use /leave endpoint to leave the community" },
70
+ 400,
71
+ );
72
+ }
73
+
74
+ const targetMembership = await db
75
+ .select()
76
+ .from(communityMembers)
77
+ .where(memberWhere(community.apId, targetApId))
78
+ .get();
79
+ if (!targetMembership) {
80
+ // A REMOTE member has no communityMembers row — their membership is the
81
+ // accepted follows edge to the Group actor, which BOTH the Announce-relay
82
+ // fan-out and the members-only post gate (handleGroupCreate) key on.
83
+ // Deleting that edge is the moderation lever over a remote member: it
84
+ // immediately drops them from the relay and makes their inbound posts
85
+ // fail the members-only gate (no outbound Reject is required for the
86
+ // removal to take effect locally). Pending join follows are cleared too.
87
+ // A durable ban (below) auto-Rejects a re-Follow into an open community,
88
+ // so the kick sticks; an approval re-request or invite lets a mod
89
+ // re-admit (which lifts the ban).
90
+ const remoteFollow = await db
91
+ .select({ followerApId: follows.followerApId })
92
+ .from(follows)
93
+ .where(
94
+ and(
95
+ eq(follows.followerApId, targetApId),
96
+ eq(follows.followingApId, community.apId),
97
+ ),
98
+ )
99
+ .get();
100
+ if (!remoteFollow) {
101
+ return c.json({ error: "User is not a member" }, 404);
102
+ }
103
+ await db
104
+ .delete(follows)
105
+ .where(
106
+ and(
107
+ eq(follows.followerApId, targetApId),
108
+ eq(follows.followingApId, community.apId),
109
+ ),
110
+ );
111
+ // Durable ban so the removed remote actor cannot simply re-Follow back
112
+ // into an open community (handleGroupFollow consults it).
113
+ await banMember(db, community.apId, targetApId);
114
+ return c.json({ success: true });
115
+ }
116
+
117
+ // A moderator may only remove plain MEMBERS; removing a moderator or an
118
+ // owner requires owner role (mirrors role-change + governance being
119
+ // owner-only). Without this a single moderator could kick the entire
120
+ // peer-moderator team — a remove is a strictly stronger action than the
121
+ // demote a moderator is already forbidden from doing.
122
+ if (
123
+ actorMembership.role !== "owner" &&
124
+ targetMembership.role !== "member"
125
+ ) {
126
+ return c.json(
127
+ { error: "Owner role required to remove a manager" },
128
+ 403,
129
+ );
130
+ }
131
+
132
+ // Removing an owner must never orphan the community: two owners who kick
133
+ // each other concurrently would both pass the role checks above and leave
134
+ // ZERO owners. Gate an owner removal on ANOTHER owner (besides the target)
135
+ // still existing, evaluated atomically inside the delete — mirroring the
136
+ // last-owner LEAVE guard. D1 serializes the deletes, so the second sees
137
+ // the first already gone and matches 0 rows.
138
+ if (targetMembership.role === "owner") {
139
+ const removed = await removeOwnerIfAnotherExists(
140
+ db,
141
+ community.apId,
142
+ targetApId,
143
+ );
144
+ if (!removed) {
145
+ return c.json({ error: "Cannot remove the last owner" }, 400);
146
+ }
147
+ await banMember(db, community.apId, targetApId);
148
+ return c.json({ success: true });
149
+ }
150
+
151
+ await removeMemberAtomic(db, community.apId, targetApId);
152
+ await banMember(db, community.apId, targetApId);
153
+
154
+ return c.json({ success: true });
155
+ },
156
+ );
157
+
158
+ // PATCH /api/communities/:identifier/members/:actorApId - Update member role
159
+ communitiesRouter.patch(
160
+ "/:identifier/members/:actorApId",
161
+ async (c: Context<{ Bindings: Env; Variables: Variables }>) => {
162
+ const actor = c.get("actor");
163
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
164
+
165
+ const identifier = c.req.param("identifier")!;
166
+ const targetApId = decodeURIComponent(c.req.param("actorApId")!);
167
+ const db = c.get("db");
168
+ const body = await c.req.json<{
169
+ role: "owner" | "moderator" | "member";
170
+ }>();
171
+
172
+ if (!body.role || !["owner", "moderator", "member"].includes(body.role)) {
173
+ return c.json({ error: "Invalid role" }, 400);
174
+ }
175
+
176
+ const { community } = await fetchCommunityId(c, identifier);
177
+ if (!community) {
178
+ return c.json({ error: "Community not found" }, 404);
179
+ }
180
+
181
+ // Only owners can change roles
182
+ const actorMembership = await db
183
+ .select()
184
+ .from(communityMembers)
185
+ .where(memberWhere(community.apId, actor.ap_id))
186
+ .get();
187
+ if (!actorMembership || actorMembership.role !== "owner") {
188
+ return c.json({ error: "Only owners can change member roles" }, 403);
189
+ }
190
+
191
+ const targetMembership = await db
192
+ .select()
193
+ .from(communityMembers)
194
+ .where(memberWhere(community.apId, targetApId))
195
+ .get();
196
+ if (!targetMembership) {
197
+ return c.json({ error: "User is not a member" }, 404);
198
+ }
199
+
200
+ // Demoting an owner must never drop the community to ZERO owners — this
201
+ // holds whether you demote YOURSELF or ANOTHER owner. Two owners who demote
202
+ // each other concurrently both pass the initial role read above and would
203
+ // leave the community ownerless; the helper gates the UPDATE on another
204
+ // owner (besides the target) still existing, atomically.
205
+ if (targetMembership.role === "owner" && body.role !== "owner") {
206
+ const demoted = await demoteOwnerIfAnotherExists(
207
+ db,
208
+ community.apId,
209
+ targetApId,
210
+ body.role,
211
+ );
212
+ if (!demoted) {
213
+ return c.json({ error: "Cannot demote the last owner" }, 400);
214
+ }
215
+ return c.json({ success: true });
216
+ }
217
+
218
+ await db
219
+ .update(communityMembers)
220
+ .set({ role: body.role })
221
+ .where(memberWhere(community.apId, targetApId));
222
+
223
+ return c.json({ success: true });
224
+ },
225
+ );
226
+
227
+ // GET /api/communities/:identifier/members - List members
228
+ communitiesRouter.get(
229
+ "/:identifier/members",
230
+ async (c: Context<{ Bindings: Env; Variables: Variables }>) => {
231
+ const identifier = c.req.param("identifier")!;
232
+ const db = c.get("db");
233
+ const baseUrl = c.env.APP_URL;
234
+ const apId = resolveCommunityApId(baseUrl, identifier);
235
+ const limit = parseLimit(c.req.query("limit"), 100, 500);
236
+ const offset = parseOffset(c.req.query("offset"), 0, 10000);
237
+
238
+ const community = await db
239
+ .select({ apId: communities.apId, visibility: communities.visibility })
240
+ .from(communities)
241
+ .where(
242
+ or(
243
+ eq(communities.apId, apId),
244
+ eq(communities.preferredUsername, identifier),
245
+ ),
246
+ )
247
+ .get();
248
+ if (!community) {
249
+ return c.json({ members: [] });
250
+ }
251
+
252
+ // Read gate: a non-public community's roster is members-only, mirroring the
253
+ // /messages and /timeline?community= gates. Without this, anyone (even
254
+ // unauthenticated) could enumerate a private community's full membership.
255
+ if (communityRequiresMembership(community.visibility)) {
256
+ const actor = c.get("actor");
257
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
258
+ const membership = await db
259
+ .select({ role: communityMembers.role })
260
+ .from(communityMembers)
261
+ .where(memberWhere(community.apId, actor.ap_id))
262
+ .get();
263
+ if (!membership) {
264
+ return c.json({ error: "Not a community member" }, 403);
265
+ }
266
+ }
267
+
268
+ // Fetch limit+1 to signal whether more members exist beyond this page —
269
+ // previously the roster was silently truncated at the page size with no
270
+ // has_more, so a client could not tell a >page community from a complete
271
+ // one. `total` is an accurate COUNT (the indexed communityApId scan is
272
+ // cheap) so the UI can show the real member count.
273
+ const [scanned, countRow] = await Promise.all([
274
+ db
275
+ .select()
276
+ .from(communityMembers)
277
+ .where(eq(communityMembers.communityApId, community.apId))
278
+ .orderBy(desc(communityMembers.role), asc(communityMembers.joinedAt))
279
+ .limit(limit + 1)
280
+ .offset(offset),
281
+ db
282
+ .select({ c: count() })
283
+ .from(communityMembers)
284
+ .where(eq(communityMembers.communityApId, community.apId))
285
+ .get(),
286
+ ]);
287
+ const hasMore = scanned.length > limit;
288
+ const members = hasMore ? scanned.slice(0, limit) : scanned;
289
+ const total = countRow?.c ?? members.length;
290
+
291
+ const memberApIds = members.map((m) => m.actorApId);
292
+ const actorInfoMap = await batchLoadActorInfo(db, memberApIds);
293
+
294
+ const result = members.map((m) => {
295
+ const actorInfo = actorInfoMap.get(m.actorApId);
296
+ return {
297
+ ap_id: m.actorApId,
298
+ username: formatUsername(m.actorApId),
299
+ preferred_username: actorInfo?.preferredUsername || null,
300
+ name: actorInfo?.name || null,
301
+ icon_url: actorInfo?.iconUrl || null,
302
+ role: m.role,
303
+ joined_at: m.joinedAt,
304
+ };
305
+ });
306
+
307
+ return c.json({ members: result, has_more: hasMore, total });
308
+ },
309
+ );
310
+
311
+ // POST /api/communities/:identifier/members/batch/remove - Bulk remove members
312
+ communitiesRouter.post(
313
+ "/:identifier/members/batch/remove",
314
+ async (c: Context<{ Bindings: Env; Variables: Variables }>) => {
315
+ const actor = c.get("actor");
316
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
317
+
318
+ const identifier = c.req.param("identifier")!;
319
+ const db = c.get("db");
320
+ const body = await c.req.json<{ actor_ap_ids: string[] }>();
321
+
322
+ const validationError = validateBatchApIds(body.actor_ap_ids);
323
+ if (validationError) return c.json({ error: validationError }, 400);
324
+
325
+ const { community } = await fetchCommunityId(c, identifier);
326
+ if (!community) {
327
+ return c.json({ error: "Community not found" }, 404);
328
+ }
329
+
330
+ const actorMembership = await requireManager(
331
+ db,
332
+ community.apId,
333
+ actor.ap_id,
334
+ );
335
+ if (!actorMembership) {
336
+ return c.json({ error: "Permission denied" }, 403);
337
+ }
338
+
339
+ const results: { ap_id: string; success: boolean; error?: string }[] = [];
340
+
341
+ // Pre-load every target's membership in ONE (chunked) query instead of a
342
+ // SELECT per target; the role/owner checks below read from this map and
343
+ // only the atomic removal stays per-target. Chunked because the batch
344
+ // (<=100) plus the community param would otherwise exceed D1's 100-bound-
345
+ // parameter cap.
346
+ const membershipByApId = new Map<string, { role: string }>();
347
+ for (const chunk of chunkForInClause(body.actor_ap_ids)) {
348
+ const rows = await db
349
+ .select({
350
+ actorApId: communityMembers.actorApId,
351
+ role: communityMembers.role,
352
+ })
353
+ .from(communityMembers)
354
+ .where(
355
+ and(
356
+ eq(communityMembers.communityApId, community.apId),
357
+ inArray(communityMembers.actorApId, chunk),
358
+ ),
359
+ );
360
+ for (const r of rows) {
361
+ membershipByApId.set(r.actorApId, { role: r.role });
362
+ }
363
+ }
364
+
365
+ for (const targetApId of body.actor_ap_ids) {
366
+ try {
367
+ if (targetApId === actor.ap_id) {
368
+ results.push({
369
+ ap_id: targetApId,
370
+ success: false,
371
+ error: "Cannot remove yourself",
372
+ });
373
+ continue;
374
+ }
375
+
376
+ const targetMembership = membershipByApId.get(targetApId);
377
+ if (!targetMembership) {
378
+ results.push({
379
+ ap_id: targetApId,
380
+ success: false,
381
+ error: "Not a member",
382
+ });
383
+ continue;
384
+ }
385
+
386
+ // A moderator may only remove plain members (same as the single
387
+ // DELETE path); removing a manager requires owner.
388
+ if (
389
+ actorMembership.role !== "owner" &&
390
+ targetMembership.role !== "member"
391
+ ) {
392
+ results.push({
393
+ ap_id: targetApId,
394
+ success: false,
395
+ error: "Owner role required to remove a manager",
396
+ });
397
+ continue;
398
+ }
399
+
400
+ // An owner removal must not orphan the community: this path used the
401
+ // unguarded removeMemberAtomic, so two owners batch-removing each other
402
+ // concurrently both succeeded → ZERO owners. Gate owner removals on
403
+ // another owner still existing, exactly like the single DELETE path.
404
+ if (targetMembership.role === "owner") {
405
+ const removed = await removeOwnerIfAnotherExists(
406
+ db,
407
+ community.apId,
408
+ targetApId,
409
+ );
410
+ results.push(
411
+ removed
412
+ ? { ap_id: targetApId, success: true }
413
+ : {
414
+ ap_id: targetApId,
415
+ success: false,
416
+ error: "Cannot remove the last owner",
417
+ },
418
+ );
419
+ continue;
420
+ }
421
+
422
+ await removeMemberAtomic(db, community.apId, targetApId);
423
+
424
+ results.push({ ap_id: targetApId, success: true });
425
+ } catch {
426
+ results.push({
427
+ ap_id: targetApId,
428
+ success: false,
429
+ error: "Internal error",
430
+ });
431
+ }
432
+ }
433
+
434
+ const removedCount = results.filter((r) => r.success).length;
435
+ return c.json({ results, removed_count: removedCount });
436
+ },
437
+ );
438
+
439
+ // POST /api/communities/:identifier/members/batch/role - Bulk update member roles
440
+ communitiesRouter.post(
441
+ "/:identifier/members/batch/role",
442
+ async (c: Context<{ Bindings: Env; Variables: Variables }>) => {
443
+ const actor = c.get("actor");
444
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
445
+
446
+ const identifier = c.req.param("identifier")!;
447
+ const db = c.get("db");
448
+ const body = await c.req.json<{
449
+ actor_ap_ids: string[];
450
+ role: "owner" | "moderator" | "member";
451
+ }>();
452
+
453
+ const validationError = validateBatchApIds(body.actor_ap_ids);
454
+ if (validationError) return c.json({ error: validationError }, 400);
455
+
456
+ if (!body.role || !["owner", "moderator", "member"].includes(body.role)) {
457
+ return c.json({ error: "Valid role is required" }, 400);
458
+ }
459
+
460
+ const { community } = await fetchCommunityId(c, identifier);
461
+ if (!community) {
462
+ return c.json({ error: "Community not found" }, 404);
463
+ }
464
+
465
+ // Only owners can change roles
466
+ const actorMembership = await db
467
+ .select()
468
+ .from(communityMembers)
469
+ .where(memberWhere(community.apId, actor.ap_id))
470
+ .get();
471
+ if (!actorMembership || actorMembership.role !== "owner") {
472
+ return c.json({ error: "Only owners can change roles" }, 403);
473
+ }
474
+
475
+ const results: { ap_id: string; success: boolean; error?: string }[] = [];
476
+
477
+ for (const targetApId of body.actor_ap_ids) {
478
+ try {
479
+ const targetMembership = await db
480
+ .select()
481
+ .from(communityMembers)
482
+ .where(memberWhere(community.apId, targetApId))
483
+ .get();
484
+ if (!targetMembership) {
485
+ results.push({
486
+ ap_id: targetApId,
487
+ success: false,
488
+ error: "Not a member",
489
+ });
490
+ continue;
491
+ }
492
+
493
+ // Demoting an OWNER (self OR another) must preserve the ">=1 owner"
494
+ // invariant. The previous self-only count()-then-UPDATE was a TOCTOU
495
+ // AND skipped the non-self branch entirely, so a single bulk request
496
+ // demoting BOTH owners (or two owners demoting each other) could leave
497
+ // ZERO owners. Route every owner demotion through the same atomic guard
498
+ // the single-target PATCH uses (EXISTS(another owner) inside the UPDATE).
499
+ if (targetMembership.role === "owner" && body.role !== "owner") {
500
+ const demoted = await demoteOwnerIfAnotherExists(
501
+ db,
502
+ community.apId,
503
+ targetApId,
504
+ body.role,
505
+ );
506
+ if (!demoted) {
507
+ results.push({
508
+ ap_id: targetApId,
509
+ success: false,
510
+ error: "Cannot demote the last owner",
511
+ });
512
+ continue;
513
+ }
514
+ results.push({ ap_id: targetApId, success: true });
515
+ continue;
516
+ }
517
+
518
+ await db
519
+ .update(communityMembers)
520
+ .set({ role: body.role })
521
+ .where(memberWhere(community.apId, targetApId));
522
+
523
+ results.push({ ap_id: targetApId, success: true });
524
+ } catch {
525
+ results.push({
526
+ ap_id: targetApId,
527
+ success: false,
528
+ error: "Internal error",
529
+ });
530
+ }
531
+ }
532
+
533
+ return c.json({
534
+ results,
535
+ updated_count: results.filter((r) => r.success).length,
536
+ });
537
+ },
538
+ );
539
+ }