@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,296 @@
1
+ import type { Context, Hono } from "hono";
2
+ import { and, count, desc, eq, sql } from "drizzle-orm";
3
+ import {
4
+ activities,
5
+ communities,
6
+ communityJoinRequests,
7
+ communityMembers,
8
+ follows,
9
+ } from "../../../db/index.ts";
10
+ import type { Env, Variables } from "../../types.ts";
11
+ import {
12
+ activityApId,
13
+ formatUsername,
14
+ generateId,
15
+ isLocal,
16
+ } from "../../federation-helpers.ts";
17
+ import { enqueueDeliveryToActor } from "../../lib/delivery/queue.ts";
18
+ import {
19
+ addMemberAtomic,
20
+ batchLoadActorInfo,
21
+ fetchCommunityId,
22
+ memberWhere,
23
+ requireManager,
24
+ unbanMember,
25
+ } from "./membership-shared.ts";
26
+
27
+ const AS_CONTEXT = "https://www.w3.org/ns/activitystreams";
28
+
29
+ export function registerMembershipRequestRoutes(
30
+ communitiesRouter: Hono<{ Bindings: Env; Variables: Variables }>,
31
+ ) {
32
+ // GET /api/communities/:identifier/requests - List pending join requests
33
+ communitiesRouter.get(
34
+ "/:identifier/requests",
35
+ async (c: Context<{ Bindings: Env; Variables: Variables }>) => {
36
+ const actor = c.get("actor");
37
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
38
+
39
+ const identifier = c.req.param("identifier")!;
40
+ const db = c.get("db");
41
+
42
+ const { community } = await fetchCommunityId(c, identifier);
43
+ if (!community) {
44
+ return c.json({ error: "Community not found" }, 404);
45
+ }
46
+
47
+ const manager = await requireManager(db, community.apId, actor.ap_id);
48
+ if (!manager) {
49
+ return c.json({ error: "Forbidden" }, 403);
50
+ }
51
+
52
+ // Pending join requests come from TWO sources, merged (newest-first, capped
53
+ // so the response can't grow unbounded): local approval-joins recorded in
54
+ // community_join_requests, AND remote approval-joins which exist ONLY as a
55
+ // PENDING follows edge to the Group actor (a remote follower has no `actors`
56
+ // row, so it can't be mirrored into community_join_requests).
57
+ const localRequests = await db
58
+ .select()
59
+ .from(communityJoinRequests)
60
+ .where(
61
+ and(
62
+ eq(communityJoinRequests.communityApId, community.apId),
63
+ eq(communityJoinRequests.status, "pending"),
64
+ ),
65
+ )
66
+ .orderBy(desc(communityJoinRequests.createdAt))
67
+ .limit(200);
68
+ const pendingEdges = await db
69
+ .select({
70
+ actorApId: follows.followerApId,
71
+ createdAt: follows.createdAt,
72
+ })
73
+ .from(follows)
74
+ .where(
75
+ and(
76
+ eq(follows.followingApId, community.apId),
77
+ eq(follows.status, "pending"),
78
+ ),
79
+ )
80
+ .orderBy(desc(follows.createdAt))
81
+ .limit(200);
82
+
83
+ const byActor = new Map<string, string>(); // actorApId -> createdAt
84
+ for (const e of pendingEdges) byActor.set(e.actorApId, e.createdAt);
85
+ for (const r of localRequests) byActor.set(r.actorApId, r.createdAt);
86
+ const merged = [...byActor.entries()]
87
+ .sort((a, b) => (a[1] < b[1] ? 1 : -1))
88
+ .slice(0, 200);
89
+
90
+ const actorInfoMap = await batchLoadActorInfo(
91
+ db,
92
+ merged.map(([apId]) => apId),
93
+ );
94
+
95
+ const result = merged.map(([apId, createdAt]) => {
96
+ const actorInfo = actorInfoMap.get(apId);
97
+ return {
98
+ ap_id: apId,
99
+ username: formatUsername(apId),
100
+ preferred_username: actorInfo?.preferredUsername || null,
101
+ name: actorInfo?.name || null,
102
+ icon_url: actorInfo?.iconUrl || null,
103
+ created_at: createdAt,
104
+ };
105
+ });
106
+
107
+ return c.json({ requests: result });
108
+ },
109
+ );
110
+
111
+ // POST /api/communities/:identifier/requests/accept - Accept join request
112
+ communitiesRouter.post(
113
+ "/:identifier/requests/accept",
114
+ async (c: Context<{ Bindings: Env; Variables: Variables }>) => {
115
+ const actor = c.get("actor");
116
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
117
+
118
+ const identifier = c.req.param("identifier")!;
119
+ const db = c.get("db");
120
+ const body = await c.req.json<{ actor_ap_id: string }>();
121
+
122
+ if (!body.actor_ap_id) {
123
+ return c.json({ error: "actor_ap_id required" }, 400);
124
+ }
125
+
126
+ const { community } = await fetchCommunityId(c, identifier);
127
+ if (!community) {
128
+ return c.json({ error: "Community not found" }, 404);
129
+ }
130
+
131
+ const manager = await requireManager(db, community.apId, actor.ap_id);
132
+ if (!manager) {
133
+ return c.json({ error: "Forbidden" }, 403);
134
+ }
135
+
136
+ // A pending request is EITHER a community_join_requests row (local
137
+ // approval-join) OR a pending follows edge to the Group (remote
138
+ // approval-join — no join-request row, since a remote has no `actors` row).
139
+ const localRequest = await db
140
+ .select()
141
+ .from(communityJoinRequests)
142
+ .where(
143
+ and(
144
+ eq(communityJoinRequests.communityApId, community.apId),
145
+ eq(communityJoinRequests.actorApId, body.actor_ap_id),
146
+ eq(communityJoinRequests.status, "pending"),
147
+ ),
148
+ )
149
+ .get();
150
+ const pendingEdge = await db
151
+ .select({ activityApId: follows.activityApId })
152
+ .from(follows)
153
+ .where(
154
+ and(
155
+ eq(follows.followerApId, body.actor_ap_id),
156
+ eq(follows.followingApId, community.apId),
157
+ eq(follows.status, "pending"),
158
+ ),
159
+ )
160
+ .get();
161
+ if (!localRequest && !pendingEdge) {
162
+ return c.json({ error: "Join request not found" }, 404);
163
+ }
164
+
165
+ const now = new Date().toISOString();
166
+
167
+ if (isLocal(body.actor_ap_id, c.env.APP_URL)) {
168
+ const existingMember = await db
169
+ .select()
170
+ .from(communityMembers)
171
+ .where(memberWhere(community.apId, body.actor_ap_id))
172
+ .get();
173
+ if (!existingMember) {
174
+ // Atomic insert + guarded increment so a crash between them, or a
175
+ // concurrent double-accept, can't leave the count under/over the truth.
176
+ await addMemberAtomic(
177
+ db,
178
+ community.apId,
179
+ body.actor_ap_id,
180
+ "member",
181
+ now,
182
+ );
183
+ }
184
+ } else {
185
+ // A REMOTE member's membership IS the pending follows edge to the Group
186
+ // actor — NOT a communityMembers row (which would diverge from how the
187
+ // rest of the system resolves remote membership). Flip that edge to
188
+ // accepted and emit the community-signed Accept so the remote learns it
189
+ // was approved and our handleGroupCreate (which requires status=accepted)
190
+ // starts relaying its posts. The pending edge carries the original Follow
191
+ // activity id the Accept must reference.
192
+ await db
193
+ .update(follows)
194
+ .set({ status: "accepted", acceptedAt: now })
195
+ .where(
196
+ and(
197
+ eq(follows.followerApId, body.actor_ap_id),
198
+ eq(follows.followingApId, community.apId),
199
+ ),
200
+ );
201
+ if (pendingEdge?.activityApId) {
202
+ const acceptId = activityApId(c.env.APP_URL, generateId());
203
+ const acceptActivity = {
204
+ "@context": AS_CONTEXT,
205
+ id: acceptId,
206
+ type: "Accept",
207
+ actor: community.apId,
208
+ object: pendingEdge.activityApId,
209
+ };
210
+ await db.insert(activities).values({
211
+ apId: acceptId,
212
+ type: "Accept",
213
+ actorApId: community.apId,
214
+ objectApId: pendingEdge.activityApId,
215
+ rawJson: JSON.stringify(acceptActivity),
216
+ direction: "outbound",
217
+ });
218
+ // Delivery resolves the community's signing key from actor=community.apId.
219
+ await enqueueDeliveryToActor(c.env, acceptId, body.actor_ap_id);
220
+ }
221
+ }
222
+
223
+ // Accepting a join request is an explicit re-admission — lift any ban.
224
+ await unbanMember(db, community.apId, body.actor_ap_id);
225
+
226
+ // Mark the local join-request row processed (a remote accept has none).
227
+ if (localRequest) {
228
+ await db
229
+ .update(communityJoinRequests)
230
+ .set({ status: "accepted", processedAt: now })
231
+ .where(
232
+ and(
233
+ eq(communityJoinRequests.communityApId, community.apId),
234
+ eq(communityJoinRequests.actorApId, body.actor_ap_id),
235
+ ),
236
+ );
237
+ }
238
+
239
+ return c.json({ success: true });
240
+ },
241
+ );
242
+
243
+ // POST /api/communities/:identifier/requests/reject - Reject join request
244
+ communitiesRouter.post(
245
+ "/:identifier/requests/reject",
246
+ async (c: Context<{ Bindings: Env; Variables: Variables }>) => {
247
+ const actor = c.get("actor");
248
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
249
+
250
+ const identifier = c.req.param("identifier")!;
251
+ const db = c.get("db");
252
+ const body = await c.req.json<{ actor_ap_id: string }>();
253
+
254
+ if (!body.actor_ap_id) {
255
+ return c.json({ error: "actor_ap_id required" }, 400);
256
+ }
257
+
258
+ const { community } = await fetchCommunityId(c, identifier);
259
+ if (!community) {
260
+ return c.json({ error: "Community not found" }, 404);
261
+ }
262
+
263
+ const manager = await requireManager(db, community.apId, actor.ap_id);
264
+ if (!manager) {
265
+ return c.json({ error: "Forbidden" }, 403);
266
+ }
267
+
268
+ const request = await db
269
+ .select()
270
+ .from(communityJoinRequests)
271
+ .where(
272
+ and(
273
+ eq(communityJoinRequests.communityApId, community.apId),
274
+ eq(communityJoinRequests.actorApId, body.actor_ap_id),
275
+ eq(communityJoinRequests.status, "pending"),
276
+ ),
277
+ )
278
+ .get();
279
+ if (!request) {
280
+ return c.json({ error: "Join request not found" }, 404);
281
+ }
282
+
283
+ await db
284
+ .update(communityJoinRequests)
285
+ .set({ status: "rejected", processedAt: new Date().toISOString() })
286
+ .where(
287
+ and(
288
+ eq(communityJoinRequests.communityApId, community.apId),
289
+ eq(communityJoinRequests.actorApId, body.actor_ap_id),
290
+ ),
291
+ );
292
+
293
+ return c.json({ success: true });
294
+ },
295
+ );
296
+ }
@@ -0,0 +1,364 @@
1
+ import type { Context } from "hono";
2
+ import { and, eq, gt, inArray, or, sql } from "drizzle-orm";
3
+ import type { Database } from "../../../db/index.ts";
4
+ import {
5
+ actorCache,
6
+ actors,
7
+ communities,
8
+ communityBans,
9
+ communityMembers,
10
+ } from "../../../db/index.ts";
11
+ import type { Env, Variables } from "../../types.ts";
12
+ import { communityApId } from "../../federation-helpers.ts";
13
+ import { chunkForInClause } from "../../lib/chunk.ts";
14
+
15
+ export const managerRoles = new Set(["owner", "moderator"]);
16
+
17
+ /**
18
+ * Record a durable ban so a removed member cannot immediately re-join an OPEN
19
+ * community (local re-join or a remote re-Follow). Idempotent.
20
+ */
21
+ export async function banMember(
22
+ db: Database,
23
+ communityApIdVal: string,
24
+ bannedApId: string,
25
+ ): Promise<void> {
26
+ await db
27
+ .insert(communityBans)
28
+ .values({ communityApId: communityApIdVal, bannedApId })
29
+ .onConflictDoNothing();
30
+ }
31
+
32
+ /**
33
+ * Lift a ban on explicit re-admission (approve a join request / accept an
34
+ * invite / add a member). No-op when no ban exists.
35
+ */
36
+ export async function unbanMember(
37
+ db: Database,
38
+ communityApIdVal: string,
39
+ bannedApId: string,
40
+ ): Promise<void> {
41
+ await db
42
+ .delete(communityBans)
43
+ .where(
44
+ and(
45
+ eq(communityBans.communityApId, communityApIdVal),
46
+ eq(communityBans.bannedApId, bannedApId),
47
+ ),
48
+ );
49
+ }
50
+
51
+ /** Whether `actorApId` is banned from the community. */
52
+ export async function isMemberBanned(
53
+ db: Database,
54
+ communityApIdVal: string,
55
+ actorApId: string,
56
+ ): Promise<boolean> {
57
+ const row = await db
58
+ .select({ bannedApId: communityBans.bannedApId })
59
+ .from(communityBans)
60
+ .where(
61
+ and(
62
+ eq(communityBans.communityApId, communityApIdVal),
63
+ eq(communityBans.bannedApId, actorApId),
64
+ ),
65
+ )
66
+ .get();
67
+ return !!row;
68
+ }
69
+
70
+ // `Database` is a union whose `.batch` lives only on the concrete D1/libsql
71
+ // subclasses; reach it through a narrow structural cast (matching membership-join).
72
+ type Batchable = { batch: (stmts: unknown[]) => Promise<unknown> };
73
+
74
+ /**
75
+ * Atomically remove a member and decrement memberCount in ONE batch. The
76
+ * decrement runs BEFORE the delete and is guarded by `EXISTS(member)` (so a
77
+ * duplicate concurrent removal — whose member row is already gone — cannot
78
+ * double-decrement) and `memberCount > 0` (underflow). Mirrors the federated
79
+ * undoFollowEdge pattern; replaces the previous non-atomic delete-then-`-1` that
80
+ * could tear on crash, underflow negative, or double-decrement under a race.
81
+ */
82
+ export async function removeMemberAtomic(
83
+ db: Database,
84
+ communityApIdVal: string,
85
+ actorApIdVal: string,
86
+ ): Promise<void> {
87
+ const memberExists = sql`EXISTS (SELECT 1 FROM ${communityMembers} WHERE ${communityMembers.communityApId} = ${communityApIdVal} AND ${communityMembers.actorApId} = ${actorApIdVal})`;
88
+ await (db as unknown as Batchable).batch([
89
+ db
90
+ .update(communities)
91
+ .set({ memberCount: sql`${communities.memberCount} - 1` })
92
+ .where(
93
+ and(
94
+ eq(communities.apId, communityApIdVal),
95
+ gt(communities.memberCount, 0),
96
+ memberExists,
97
+ ),
98
+ ),
99
+ db
100
+ .delete(communityMembers)
101
+ .where(memberWhere(communityApIdVal, actorApIdVal)),
102
+ ]);
103
+ }
104
+
105
+ /**
106
+ * Atomically remove an OWNER only if ANOTHER owner still remains. Returns false
107
+ * (nothing removed) when the actor is the last owner.
108
+ *
109
+ * A plain `count(owners) > 1` check followed by a separate delete is a TOCTOU:
110
+ * two concurrent last-owner leaves both read count=2, both pass, both delete →
111
+ * the community is orphaned with ZERO owners. Conditioning the delete (and its
112
+ * memberCount decrement) on `EXISTS(another owner)` evaluated INSIDE the
113
+ * statement closes the window: D1 serializes the two deletes, so the second one
114
+ * to execute sees the first already gone and matches 0 rows.
115
+ */
116
+ export async function removeOwnerIfAnotherExists(
117
+ db: Database,
118
+ communityApIdVal: string,
119
+ actorApIdVal: string,
120
+ ): Promise<boolean> {
121
+ const anotherOwnerExists = sql`EXISTS (SELECT 1 FROM ${communityMembers} WHERE ${communityMembers.communityApId} = ${communityApIdVal} AND ${communityMembers.role} = 'owner' AND ${communityMembers.actorApId} <> ${actorApIdVal})`;
122
+ const memberExists = sql`EXISTS (SELECT 1 FROM ${communityMembers} WHERE ${communityMembers.communityApId} = ${communityApIdVal} AND ${communityMembers.actorApId} = ${actorApIdVal})`;
123
+ await (db as unknown as Batchable).batch([
124
+ db
125
+ .update(communities)
126
+ .set({ memberCount: sql`${communities.memberCount} - 1` })
127
+ .where(
128
+ and(
129
+ eq(communities.apId, communityApIdVal),
130
+ gt(communities.memberCount, 0),
131
+ memberExists,
132
+ anotherOwnerExists,
133
+ ),
134
+ ),
135
+ db
136
+ .delete(communityMembers)
137
+ .where(
138
+ and(memberWhere(communityApIdVal, actorApIdVal), anotherOwnerExists),
139
+ ),
140
+ ]);
141
+ // Re-read whether the row is gone rather than trusting a batch affected-row
142
+ // count (its shape differs between D1 and the libsql test driver). The member
143
+ // row is keyed by (community, actor) and only this actor's leave touches it,
144
+ // so this read is reliable. Absent → removed (another owner existed); present
145
+ // → not removed (the actor was the last owner).
146
+ const stillMember = await db
147
+ .select({ actorApId: communityMembers.actorApId })
148
+ .from(communityMembers)
149
+ .where(memberWhere(communityApIdVal, actorApIdVal))
150
+ .get();
151
+ return !stillMember;
152
+ }
153
+
154
+ /**
155
+ * Atomically demote an OWNER to `newRole` only if ANOTHER owner (besides this
156
+ * target) still remains, preserving the ">=1 owner" invariant. Returns true when
157
+ * the target is no longer an owner (demotion applied), false when the target was
158
+ * the last owner and was therefore left untouched.
159
+ *
160
+ * Mirrors removeOwnerIfAnotherExists for the role-change path: a
161
+ * count(owners)>1 check followed by a separate UPDATE is a TOCTOU two owners who
162
+ * demote EACH OTHER concurrently can both pass, orphaning the community with
163
+ * zero owners. Conditioning the UPDATE on `EXISTS(another owner)` evaluated
164
+ * INSIDE the statement closes the window — D1 serializes the two updates, so the
165
+ * second to execute sees the first already demoted and matches 0 rows. The
166
+ * EXISTS is keyed on the TARGET (not the caller) so it equally covers demoting
167
+ * yourself and demoting a fellow owner.
168
+ */
169
+ export async function demoteOwnerIfAnotherExists(
170
+ db: Database,
171
+ communityApIdVal: string,
172
+ targetApId: string,
173
+ newRole: string,
174
+ ): Promise<boolean> {
175
+ const anotherOwnerExists = sql`EXISTS (SELECT 1 FROM ${communityMembers} WHERE ${communityMembers.communityApId} = ${communityApIdVal} AND ${communityMembers.role} = 'owner' AND ${communityMembers.actorApId} <> ${targetApId})`;
176
+ await db
177
+ .update(communityMembers)
178
+ .set({ role: newRole })
179
+ .where(and(memberWhere(communityApIdVal, targetApId), anotherOwnerExists));
180
+ // Re-read (driver-agnostic — no reliance on batch affected-row counts). Absent
181
+ // or no-longer-owner → demotion held; still owner → it was the last owner.
182
+ const after = await db
183
+ .select({ role: communityMembers.role })
184
+ .from(communityMembers)
185
+ .where(memberWhere(communityApIdVal, targetApId))
186
+ .get();
187
+ return after?.role !== "owner";
188
+ }
189
+
190
+ /**
191
+ * Atomically add a member and increment memberCount in ONE batch. The increment
192
+ * is guarded by `NOT EXISTS(member)` so a duplicate concurrent add (or a retry)
193
+ * cannot double-count; the insert is onConflictDoNothing. Mirrors the open-join
194
+ * batch and the federated handleFollow pattern.
195
+ */
196
+ export async function addMemberAtomic(
197
+ db: Database,
198
+ communityApIdVal: string,
199
+ actorApIdVal: string,
200
+ role: string,
201
+ joinedAt: string,
202
+ ): Promise<void> {
203
+ const memberAbsent = sql`NOT EXISTS (SELECT 1 FROM ${communityMembers} WHERE ${communityMembers.communityApId} = ${communityApIdVal} AND ${communityMembers.actorApId} = ${actorApIdVal})`;
204
+ await (db as unknown as Batchable).batch([
205
+ db
206
+ .update(communities)
207
+ .set({ memberCount: sql`${communities.memberCount} + 1` })
208
+ .where(and(eq(communities.apId, communityApIdVal), memberAbsent)),
209
+ db
210
+ .insert(communityMembers)
211
+ .values({
212
+ communityApId: communityApIdVal,
213
+ actorApId: actorApIdVal,
214
+ role,
215
+ joinedAt,
216
+ })
217
+ .onConflictDoNothing(),
218
+ ]);
219
+ }
220
+
221
+ export function resolveCommunityApId(
222
+ baseUrl: string,
223
+ identifier: string,
224
+ ): string {
225
+ return identifier.startsWith("http")
226
+ ? identifier
227
+ : communityApId(baseUrl, identifier);
228
+ }
229
+
230
+ /** Shared WHERE clause for looking up a community by identifier or apId. */
231
+ export function communityWhere(apId: string, identifier: string) {
232
+ return or(
233
+ eq(communities.apId, apId),
234
+ eq(communities.preferredUsername, identifier),
235
+ );
236
+ }
237
+
238
+ export async function fetchCommunityDetails(
239
+ c: Context<{ Bindings: Env; Variables: Variables }>,
240
+ identifier: string,
241
+ ) {
242
+ const db = c.get("db");
243
+ const apId = resolveCommunityApId(c.env.APP_URL, identifier);
244
+ const community = await db
245
+ .select()
246
+ .from(communities)
247
+ .where(communityWhere(apId, identifier))
248
+ .get();
249
+ return { apId, community };
250
+ }
251
+
252
+ export async function fetchCommunityId(
253
+ c: Context<{ Bindings: Env; Variables: Variables }>,
254
+ identifier: string,
255
+ ) {
256
+ const db = c.get("db");
257
+ const apId = resolveCommunityApId(c.env.APP_URL, identifier);
258
+ const community = await db
259
+ .select({ apId: communities.apId })
260
+ .from(communities)
261
+ .where(communityWhere(apId, identifier))
262
+ .get();
263
+ return { apId, community };
264
+ }
265
+
266
+ /** Compound key condition for CommunityMember / CommunityJoinRequest lookups. */
267
+ export function memberWhere(communityApIdVal: string, actorApId: string) {
268
+ return and(
269
+ eq(communityMembers.communityApId, communityApIdVal),
270
+ eq(communityMembers.actorApId, actorApId),
271
+ );
272
+ }
273
+
274
+ /**
275
+ * Require the actor to be a manager (owner or moderator) of the community.
276
+ * Returns the membership record on success, or null if unauthorized.
277
+ */
278
+ export async function requireManager(
279
+ db: Database,
280
+ communityApIdVal: string,
281
+ actorApId: string,
282
+ ) {
283
+ const member = await db
284
+ .select()
285
+ .from(communityMembers)
286
+ .where(memberWhere(communityApIdVal, actorApId))
287
+ .get();
288
+ if (!member || !managerRoles.has(member.role)) return null;
289
+ return member;
290
+ }
291
+
292
+ /**
293
+ * Batch load actor display info from both local actors and cached actors.
294
+ * Returns a single Map keyed by apId with the merged results (local takes priority).
295
+ */
296
+ export async function batchLoadActorInfo(
297
+ db: Database,
298
+ apIds: string[],
299
+ includeIcon = true,
300
+ ) {
301
+ if (apIds.length === 0) {
302
+ return new Map<
303
+ string,
304
+ {
305
+ preferredUsername: string | null;
306
+ name: string | null;
307
+ iconUrl?: string | null;
308
+ }
309
+ >();
310
+ }
311
+
312
+ type ActorInfo = {
313
+ preferredUsername: string | null;
314
+ name: string | null;
315
+ iconUrl?: string | null;
316
+ };
317
+
318
+ const selectLocalBase = {
319
+ apId: actors.apId,
320
+ preferredUsername: actors.preferredUsername,
321
+ name: actors.name,
322
+ iconUrl: actors.iconUrl,
323
+ } as const;
324
+ const selectCachedBase = {
325
+ apId: actorCache.apId,
326
+ preferredUsername: actorCache.preferredUsername,
327
+ name: actorCache.name,
328
+ iconUrl: actorCache.iconUrl,
329
+ } as const;
330
+
331
+ // Chunk the IN(...) lookups: a community roster page can carry up to ~90
332
+ // member ids and D1 caps a query at 100 bound parameters. Chunks are disjoint
333
+ // id slices, so per-chunk maps merge collision-free; cached-then-local
334
+ // ordering still gives local-wins within each chunk.
335
+ const map = new Map<string, ActorInfo>();
336
+ for (const ids of chunkForInClause(apIds)) {
337
+ const [localActors, cachedActors] = await Promise.all([
338
+ db.select(selectLocalBase).from(actors).where(inArray(actors.apId, ids)),
339
+ db
340
+ .select(selectCachedBase)
341
+ .from(actorCache)
342
+ .where(inArray(actorCache.apId, ids)),
343
+ ]);
344
+
345
+ // Cached first so local overrides
346
+ for (const a of cachedActors) {
347
+ const info: ActorInfo = {
348
+ preferredUsername: a.preferredUsername,
349
+ name: a.name,
350
+ };
351
+ if (includeIcon) info.iconUrl = a.iconUrl;
352
+ map.set(a.apId, info);
353
+ }
354
+ for (const a of localActors) {
355
+ const info: ActorInfo = {
356
+ preferredUsername: a.preferredUsername,
357
+ name: a.name,
358
+ };
359
+ if (includeIcon) info.iconUrl = a.iconUrl;
360
+ map.set(a.apId, info);
361
+ }
362
+ }
363
+ return map;
364
+ }