@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,208 @@
1
+ import type { Context, Hono } from "hono";
2
+ import { and, count, desc, eq, gt, isNull, or } from "drizzle-orm";
3
+ import { communityInvites } from "../../../db/index.ts";
4
+ import type { Env, Variables } from "../../types.ts";
5
+ import { formatUsername, generateId } from "../../federation-helpers.ts";
6
+ import {
7
+ batchLoadActorInfo,
8
+ fetchCommunityId,
9
+ requireManager,
10
+ } from "./membership-shared.ts";
11
+
12
+ // Newest-first cap on the manager invite list (bounds response size).
13
+ const INVITE_LIST_MAX = 200;
14
+ // Cap on outstanding (unused, unexpired) invites per community so a manager
15
+ // can't grow community_invites without bound.
16
+ const MAX_OUTSTANDING_INVITES = 200;
17
+
18
+ export function registerMembershipInviteRoutes(
19
+ communitiesRouter: Hono<{ Bindings: Env; Variables: Variables }>,
20
+ ) {
21
+ // GET /api/communities/:identifier/invites - List invites
22
+ communitiesRouter.get(
23
+ "/:identifier/invites",
24
+ async (c: Context<{ Bindings: Env; Variables: Variables }>) => {
25
+ const actor = c.get("actor");
26
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
27
+
28
+ const identifier = c.req.param("identifier")!;
29
+ const db = c.get("db");
30
+
31
+ const { community } = await fetchCommunityId(c, identifier);
32
+ if (!community) {
33
+ return c.json({ error: "Community not found" }, 404);
34
+ }
35
+
36
+ const manager = await requireManager(db, community.apId, actor.ap_id);
37
+ if (!manager) {
38
+ return c.json({ error: "Forbidden" }, 403);
39
+ }
40
+
41
+ // Cap the manager invite list (newest-first) so the response can't grow
42
+ // unbounded with invite-row count.
43
+ const invites = await db
44
+ .select()
45
+ .from(communityInvites)
46
+ .where(eq(communityInvites.communityApId, community.apId))
47
+ .orderBy(desc(communityInvites.createdAt))
48
+ .limit(INVITE_LIST_MAX);
49
+
50
+ const invitedByApIds = [
51
+ ...new Set(invites.map((inv) => inv.invitedByApId)),
52
+ ];
53
+ const actorInfoMap = await batchLoadActorInfo(db, invitedByApIds, false);
54
+
55
+ const result = invites.map((inv) => {
56
+ const invitedByInfo = actorInfoMap.get(inv.invitedByApId);
57
+ return {
58
+ id: inv.id,
59
+ invited_ap_id: inv.invitedApId,
60
+ invited_by: {
61
+ ap_id: inv.invitedByApId,
62
+ username: formatUsername(inv.invitedByApId),
63
+ preferred_username: invitedByInfo?.preferredUsername || null,
64
+ name: invitedByInfo?.name || null,
65
+ },
66
+ created_at: inv.createdAt,
67
+ expires_at: inv.expiresAt,
68
+ used_at: inv.usedAt,
69
+ used_by_ap_id: inv.usedByApId,
70
+ is_valid:
71
+ !inv.usedAt &&
72
+ (!inv.expiresAt || new Date(inv.expiresAt) > new Date()),
73
+ };
74
+ });
75
+
76
+ return c.json({ invites: result });
77
+ },
78
+ );
79
+
80
+ // POST /api/communities/:identifier/invites - Create invite
81
+ communitiesRouter.post(
82
+ "/:identifier/invites",
83
+ async (c: Context<{ Bindings: Env; Variables: Variables }>) => {
84
+ const actor = c.get("actor");
85
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
86
+
87
+ const identifier = c.req.param("identifier")!;
88
+ const db = c.get("db");
89
+ let invitedApId: string | null = null;
90
+ let expiresInHours: number | null = null;
91
+ try {
92
+ const body = await c.req.json<{
93
+ invited_ap_id?: string;
94
+ expires_in_hours?: number;
95
+ }>();
96
+ invitedApId =
97
+ typeof body.invited_ap_id === "string"
98
+ ? body.invited_ap_id.trim() || null
99
+ : null;
100
+ // Only accept a finite POSITIVE number. A non-numeric value (the JSON
101
+ // type isn't enforced at runtime) would flow into `new Date(now + h*…)`
102
+ // below as NaN and throw a RangeError on .toISOString() → 500.
103
+ expiresInHours =
104
+ typeof body.expires_in_hours === "number" &&
105
+ Number.isFinite(body.expires_in_hours) &&
106
+ body.expires_in_hours > 0
107
+ ? body.expires_in_hours
108
+ : null;
109
+ } catch {
110
+ invitedApId = null;
111
+ expiresInHours = null;
112
+ }
113
+
114
+ const { community } = await fetchCommunityId(c, identifier);
115
+ if (!community) {
116
+ return c.json({ error: "Community not found" }, 404);
117
+ }
118
+
119
+ const manager = await requireManager(db, community.apId, actor.ap_id);
120
+ if (!manager) {
121
+ return c.json({ error: "Forbidden" }, 403);
122
+ }
123
+
124
+ // Bound outstanding (unused, unexpired) invites per community so a manager
125
+ // can't loop create and grow community_invites without limit.
126
+ const nowIso = new Date().toISOString();
127
+ const outstanding = await db
128
+ .select({ n: count() })
129
+ .from(communityInvites)
130
+ .where(
131
+ and(
132
+ eq(communityInvites.communityApId, community.apId),
133
+ isNull(communityInvites.usedAt),
134
+ or(
135
+ isNull(communityInvites.expiresAt),
136
+ gt(communityInvites.expiresAt, nowIso),
137
+ ),
138
+ ),
139
+ )
140
+ .get();
141
+ if ((outstanding?.n ?? 0) >= MAX_OUTSTANDING_INVITES) {
142
+ return c.json({ error: "Too many outstanding invites" }, 429);
143
+ }
144
+
145
+ const inviteId = generateId();
146
+ const now = new Date();
147
+ const expiresAt = expiresInHours
148
+ ? new Date(
149
+ now.getTime() + expiresInHours * 60 * 60 * 1000,
150
+ ).toISOString()
151
+ : null;
152
+
153
+ await db.insert(communityInvites).values({
154
+ id: inviteId,
155
+ communityApId: community.apId,
156
+ invitedByApId: actor.ap_id,
157
+ invitedApId,
158
+ createdAt: now.toISOString(),
159
+ expiresAt,
160
+ });
161
+
162
+ return c.json({ invite_id: inviteId, expires_at: expiresAt });
163
+ },
164
+ );
165
+
166
+ // DELETE /api/communities/:identifier/invites/:inviteId - Revoke invite
167
+ communitiesRouter.delete(
168
+ "/:identifier/invites/:inviteId",
169
+ async (c: Context<{ Bindings: Env; Variables: Variables }>) => {
170
+ const actor = c.get("actor");
171
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
172
+
173
+ const identifier = c.req.param("identifier")!;
174
+ const inviteId = c.req.param("inviteId")!;
175
+ const db = c.get("db");
176
+
177
+ const { community } = await fetchCommunityId(c, identifier);
178
+ if (!community) {
179
+ return c.json({ error: "Community not found" }, 404);
180
+ }
181
+
182
+ const manager = await requireManager(db, community.apId, actor.ap_id);
183
+ if (!manager) {
184
+ return c.json({ error: "Forbidden" }, 403);
185
+ }
186
+
187
+ const invite = await db
188
+ .select()
189
+ .from(communityInvites)
190
+ .where(
191
+ and(
192
+ eq(communityInvites.id, inviteId),
193
+ eq(communityInvites.communityApId, community.apId),
194
+ ),
195
+ )
196
+ .get();
197
+ if (!invite) {
198
+ return c.json({ error: "Invite not found" }, 404);
199
+ }
200
+
201
+ await db
202
+ .delete(communityInvites)
203
+ .where(eq(communityInvites.id, inviteId));
204
+
205
+ return c.json({ success: true });
206
+ },
207
+ );
208
+ }
@@ -0,0 +1,335 @@
1
+ import type { Context, Hono } from "hono";
2
+ import { and, eq, gt, isNull, or, sql } from "drizzle-orm";
3
+ import {
4
+ affectedRowCount,
5
+ communities,
6
+ communityInvites,
7
+ communityJoinRequests,
8
+ communityMembers,
9
+ } from "../../../db/index.ts";
10
+ import type { Env, Variables } from "../../types.ts";
11
+ import {
12
+ fetchCommunityDetails,
13
+ isMemberBanned,
14
+ memberWhere,
15
+ removeMemberAtomic,
16
+ removeOwnerIfAnotherExists,
17
+ unbanMember,
18
+ } from "./membership-shared.ts";
19
+ import { isUniqueConstraintError } from "../../lib/parse-helpers.ts";
20
+ import { logger } from "../../lib/logger.ts";
21
+
22
+ const log = logger.child({ component: "communities.join" });
23
+
24
+ /**
25
+ * Narrow view over the concrete D1/libsql drizzle client's atomic batch API.
26
+ * The shared `Database` union type does not surface `batch` (it lives on the
27
+ * concrete subclasses), so we reach it through a structural cast at the one
28
+ * call site that needs an atomic multi-statement write.
29
+ */
30
+ type Batchable = {
31
+ batch(statements: readonly unknown[]): Promise<unknown>;
32
+ };
33
+
34
+ export function registerMembershipJoinRoutes(
35
+ communitiesRouter: Hono<{ Bindings: Env; Variables: Variables }>,
36
+ ) {
37
+ // POST /api/communities/:identifier/join - Join a community
38
+ communitiesRouter.post(
39
+ "/:identifier/join",
40
+ async (c: Context<{ Bindings: Env; Variables: Variables }>) => {
41
+ const actor = c.get("actor");
42
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
43
+
44
+ const identifier = c.req.param("identifier")!;
45
+ const db = c.get("db");
46
+
47
+ let inviteId: string | undefined;
48
+ try {
49
+ const body = await c.req.json<{ invite_id?: string }>();
50
+ inviteId = body.invite_id?.trim() || undefined;
51
+ } catch {
52
+ inviteId = undefined;
53
+ }
54
+
55
+ const { community } = await fetchCommunityDetails(c, identifier);
56
+ if (!community) {
57
+ return c.json({ error: "Community not found" }, 404);
58
+ }
59
+
60
+ // Check if already member
61
+ const existing = await db
62
+ .select()
63
+ .from(communityMembers)
64
+ .where(memberWhere(community.apId, actor.ap_id))
65
+ .get();
66
+ if (existing) {
67
+ return c.json({ error: "Already a member" }, 409);
68
+ }
69
+
70
+ const now = new Date().toISOString();
71
+
72
+ // A PRIVATE community must never be OPENLY self-joinable — open join would
73
+ // let ANY logged-in actor join and thereby read its members-only content,
74
+ // defeating the privacy. If an owner left joinPolicy="open" while flipping
75
+ // the community private, treat the join as "approval" (held pending) so the
76
+ // owner still gates who gets in. Invite-policy is unaffected.
77
+ const effectiveJoinPolicy =
78
+ community.visibility === "private" && community.joinPolicy === "open"
79
+ ? "approval"
80
+ : community.joinPolicy;
81
+
82
+ if (effectiveJoinPolicy === "approval") {
83
+ // Upsert: check if exists, then insert or update
84
+ const existingRequest = await db
85
+ .select()
86
+ .from(communityJoinRequests)
87
+ .where(
88
+ and(
89
+ eq(communityJoinRequests.communityApId, community.apId),
90
+ eq(communityJoinRequests.actorApId, actor.ap_id),
91
+ ),
92
+ )
93
+ .get();
94
+
95
+ if (existingRequest) {
96
+ await db
97
+ .update(communityJoinRequests)
98
+ .set({ status: "pending", createdAt: now, processedAt: null })
99
+ .where(
100
+ and(
101
+ eq(communityJoinRequests.communityApId, community.apId),
102
+ eq(communityJoinRequests.actorApId, actor.ap_id),
103
+ ),
104
+ );
105
+ } else {
106
+ // onConflictDoNothing so two concurrent first-time join requests by
107
+ // the same actor (double-click / retry) don't 500 the loser on the
108
+ // (communityApId, actorApId) composite PK — the request is idempotent
109
+ // (matches the invite-join / open-join branches' unique handling).
110
+ await db
111
+ .insert(communityJoinRequests)
112
+ .values({
113
+ communityApId: community.apId,
114
+ actorApId: actor.ap_id,
115
+ status: "pending",
116
+ createdAt: now,
117
+ })
118
+ .onConflictDoNothing();
119
+ }
120
+
121
+ return c.json({ success: true, status: "pending" });
122
+ }
123
+
124
+ if (effectiveJoinPolicy === "invite") {
125
+ if (!inviteId) {
126
+ return c.json(
127
+ { error: "Invite required", status: "invite_required" },
128
+ 403,
129
+ );
130
+ }
131
+
132
+ try {
133
+ // Find the invite
134
+ const invite = await db
135
+ .select()
136
+ .from(communityInvites)
137
+ .where(
138
+ and(
139
+ eq(communityInvites.id, inviteId),
140
+ eq(communityInvites.communityApId, community.apId),
141
+ isNull(communityInvites.usedAt),
142
+ or(
143
+ isNull(communityInvites.expiresAt),
144
+ gt(communityInvites.expiresAt, now),
145
+ ),
146
+ ),
147
+ )
148
+ .get();
149
+
150
+ if (!invite) {
151
+ return c.json(
152
+ {
153
+ error: "Invalid or expired invite",
154
+ status: "invite_required",
155
+ },
156
+ 403,
157
+ );
158
+ }
159
+ if (invite.invitedApId && invite.invitedApId !== actor.ap_id) {
160
+ return c.json(
161
+ {
162
+ error: "Invite not for this account",
163
+ status: "invite_required",
164
+ },
165
+ 403,
166
+ );
167
+ }
168
+
169
+ // Claim the invite FIRST — this conditional single-use UPDATE is the
170
+ // race GATE. Only the writer that flips usedAt from NULL (affecting
171
+ // exactly one row) is allowed to materialize the membership; a racing
172
+ // loser sees 0 rows affected and is rejected, so it never leaves a
173
+ // phantom member row or an inflated memberCount behind.
174
+ const claimResult = await db
175
+ .update(communityInvites)
176
+ .set({ usedByApId: actor.ap_id, usedAt: now })
177
+ .where(
178
+ and(
179
+ eq(communityInvites.id, inviteId),
180
+ eq(communityInvites.communityApId, community.apId),
181
+ isNull(communityInvites.usedAt),
182
+ or(
183
+ isNull(communityInvites.expiresAt),
184
+ gt(communityInvites.expiresAt, now),
185
+ ),
186
+ or(
187
+ isNull(communityInvites.invitedApId),
188
+ eq(communityInvites.invitedApId, actor.ap_id),
189
+ ),
190
+ ),
191
+ );
192
+ if (affectedRowCount(claimResult) !== 1) {
193
+ // Lost the single-use claim race (or invite was used/expired
194
+ // between the read above and this write). Reject without touching
195
+ // membership or the count.
196
+ return c.json(
197
+ {
198
+ error: "Invalid or expired invite",
199
+ status: "invite_required",
200
+ },
201
+ 403,
202
+ );
203
+ }
204
+
205
+ // Won the claim: create the member + bump the count atomically.
206
+ // D1 has no interactive transactions; group the member insert and
207
+ // the counter increment into a single batch so they cannot diverge.
208
+ // The `Database` union type does not surface `batch` (it is only on
209
+ // the concrete D1/libsql subclasses), so reach it through a narrow
210
+ // structural cast.
211
+ const memberInsert = db.insert(communityMembers).values({
212
+ communityApId: community.apId,
213
+ actorApId: actor.ap_id,
214
+ role: "member",
215
+ joinedAt: now,
216
+ });
217
+ const countBump = db
218
+ .update(communities)
219
+ .set({ memberCount: sql`${communities.memberCount} + 1` })
220
+ .where(eq(communities.apId, community.apId));
221
+ await (db as unknown as Batchable).batch([memberInsert, countBump]);
222
+
223
+ // Consuming a valid invite is an explicit re-admission — lift any ban.
224
+ await unbanMember(db, community.apId, actor.ap_id);
225
+ return c.json({ success: true, status: "joined" });
226
+ } catch (error) {
227
+ if (isUniqueConstraintError(error)) {
228
+ return c.json({ error: "Already a member" }, 409);
229
+ }
230
+ log.error("Failed to join with invite", {
231
+ event: "communities.join.invite_failed",
232
+ community: community.apId,
233
+ actor: actor.ap_id,
234
+ error,
235
+ });
236
+ return c.json({ error: "Failed to join community" }, 500);
237
+ }
238
+ }
239
+
240
+ // Durable ban: a removed member cannot self-rejoin via OPEN join (the
241
+ // auto-admit path the kick was meant to stop). Approval (a mod decides)
242
+ // and invite (explicit re-admission) paths above stay open and lift the
243
+ // ban on success, so a moderator can always let someone back in.
244
+ if (await isMemberBanned(db, community.apId, actor.ap_id)) {
245
+ return c.json(
246
+ {
247
+ error: "You have been removed from this community",
248
+ status: "banned",
249
+ },
250
+ 403,
251
+ );
252
+ }
253
+
254
+ // Open join — create the member + bump the count atomically. D1 has no
255
+ // interactive transactions; group the member insert and the counter
256
+ // increment into a single batch so they cannot diverge on a mid-request
257
+ // failure (matching the invite-join path). The `Database` union type does
258
+ // not surface `batch` (it is only on the concrete D1/libsql subclasses),
259
+ // so reach it through a narrow structural cast.
260
+ try {
261
+ const memberInsert = db.insert(communityMembers).values({
262
+ communityApId: community.apId,
263
+ actorApId: actor.ap_id,
264
+ role: "member",
265
+ joinedAt: now,
266
+ });
267
+ const countBump = db
268
+ .update(communities)
269
+ .set({ memberCount: sql`${communities.memberCount} + 1` })
270
+ .where(eq(communities.apId, community.apId));
271
+ await (db as unknown as Batchable).batch([memberInsert, countBump]);
272
+
273
+ return c.json({ success: true, status: "joined" });
274
+ } catch (error) {
275
+ if (isUniqueConstraintError(error)) {
276
+ return c.json({ error: "Already a member" }, 409);
277
+ }
278
+ log.error("Failed to join community", {
279
+ event: "communities.join.failed",
280
+ community: community.apId,
281
+ actor: actor.ap_id,
282
+ error,
283
+ });
284
+ return c.json({ error: "Failed to join community" }, 500);
285
+ }
286
+ },
287
+ );
288
+
289
+ // POST /api/communities/:identifier/leave - Leave a community
290
+ communitiesRouter.post(
291
+ "/:identifier/leave",
292
+ async (c: Context<{ Bindings: Env; Variables: Variables }>) => {
293
+ const actor = c.get("actor");
294
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
295
+
296
+ const identifier = c.req.param("identifier")!;
297
+ const db = c.get("db");
298
+
299
+ const { community } = await fetchCommunityDetails(c, identifier);
300
+ if (!community) {
301
+ return c.json({ error: "Community not found" }, 404);
302
+ }
303
+
304
+ const membership = await db
305
+ .select()
306
+ .from(communityMembers)
307
+ .where(memberWhere(community.apId, actor.ap_id))
308
+ .get();
309
+ if (!membership) {
310
+ return c.json({ error: "Not a member" }, 400);
311
+ }
312
+
313
+ // Don't allow the last owner to leave. Enforced ATOMICALLY rather than as
314
+ // a count(owners) > 1 check then a separate delete — that is a TOCTOU two
315
+ // concurrent owners can both pass, leaving the community with ZERO owners.
316
+ // removeOwnerIfAnotherExists conditions the delete on another owner still
317
+ // existing and reports whether it removed.
318
+ if (membership.role === "owner") {
319
+ const removed = await removeOwnerIfAnotherExists(
320
+ db,
321
+ community.apId,
322
+ actor.ap_id,
323
+ );
324
+ if (!removed) {
325
+ return c.json({ error: "Cannot leave: you are the only owner" }, 400);
326
+ }
327
+ return c.json({ success: true });
328
+ }
329
+
330
+ await removeMemberAtomic(db, community.apId, actor.ap_id);
331
+
332
+ return c.json({ success: true });
333
+ },
334
+ );
335
+ }