@takosjp/yurucommu-core 3.4.3 → 3.4.4

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 (112) hide show
  1. package/migrations/0023_delivery_resolution_outbox.sql +33 -0
  2. package/migrations/0024_delivery_fanout_outbox.sql +35 -0
  3. package/migrations/0025_delivery_endpoint_terminal_retention.sql +6 -0
  4. package/migrations/0026_remote_actor_fetch_failures.sql +29 -0
  5. package/migrations/0027_remote_actor_tombstones.sql +12 -0
  6. package/migrations/0028_remote_actor_delivery_fence.sql +30 -0
  7. package/migrations/0029_delivery_endpoint_recipients.sql +47 -0
  8. package/package.json +2 -1
  9. package/packages/api/package.json +1 -1
  10. package/packages/api/src/lib/api/communities.ts +2 -0
  11. package/packages/api/src/lib/api/fetch.ts +69 -13
  12. package/packages/api/src/lib/api/normalize.ts +32 -10
  13. package/packages/api/src/lib/api/notifications.ts +4 -1
  14. package/packages/api/src/lib/rtc-client.ts +127 -29
  15. package/src/backend/federation-helpers.ts +1 -0
  16. package/src/backend/index.ts +16 -3
  17. package/src/backend/lib/account-migration.ts +340 -0
  18. package/src/backend/lib/activity-delete-cascade.ts +193 -0
  19. package/src/backend/lib/activitypub-actor-cache.ts +615 -48
  20. package/src/backend/lib/activitypub-actor-identity-sql.ts +179 -0
  21. package/src/backend/lib/activitypub-actor-identity.ts +39 -0
  22. package/src/backend/lib/activitypub-validators.ts +62 -4
  23. package/src/backend/lib/ap-ids.ts +36 -6
  24. package/src/backend/lib/ap-verify.ts +7 -17
  25. package/src/backend/lib/blocklist-purge.ts +676 -42
  26. package/src/backend/lib/blocklist.ts +278 -39
  27. package/src/backend/lib/community-visibility.ts +47 -33
  28. package/src/backend/lib/delivery/fanout-outbox.ts +336 -0
  29. package/src/backend/lib/delivery/planner.ts +16 -12
  30. package/src/backend/lib/delivery/queue-batching.ts +389 -145
  31. package/src/backend/lib/delivery/queue-delivery.ts +47 -25
  32. package/src/backend/lib/delivery/queue.ts +479 -73
  33. package/src/backend/lib/delivery/resolution-outbox.ts +456 -0
  34. package/src/backend/lib/delivery/types.ts +31 -7
  35. package/src/backend/lib/feed-exclude.ts +40 -28
  36. package/src/backend/lib/follow-edge-mutations.ts +217 -0
  37. package/src/backend/lib/notification-eligibility.ts +15 -9
  38. package/src/backend/lib/notification-push.ts +2 -2
  39. package/src/backend/lib/oidc-id-token.ts +66 -0
  40. package/src/backend/lib/personal-actor-moderation.ts +239 -0
  41. package/src/backend/lib/post-visibility.ts +79 -16
  42. package/src/backend/lib/remote-activity-id.ts +61 -0
  43. package/src/backend/lib/unread-counts.ts +3 -0
  44. package/src/backend/retention.ts +38 -1
  45. package/src/backend/routes/account-teardown.ts +422 -156
  46. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +104 -105
  47. package/src/backend/routes/activitypub/handlers/inbound-community-scope.ts +121 -0
  48. package/src/backend/routes/activitypub/handlers/inbound-object-identity.ts +54 -0
  49. package/src/backend/routes/activitypub/handlers/inbound-reply-target.ts +34 -0
  50. package/src/backend/routes/activitypub/handlers/inbound-story-projection.ts +438 -0
  51. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1121 -806
  52. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +118 -153
  53. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +185 -168
  54. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +220 -32
  55. package/src/backend/routes/activitypub/inbound-activity-identity.ts +16 -0
  56. package/src/backend/routes/activitypub/inbound-activity-reference.ts +116 -0
  57. package/src/backend/routes/activitypub/inbound-addressing.ts +87 -0
  58. package/src/backend/routes/activitypub/inbox-addressing.ts +22 -19
  59. package/src/backend/routes/activitypub/inbox-types.ts +14 -2
  60. package/src/backend/routes/activitypub/inbox.ts +81 -105
  61. package/src/backend/routes/activitypub/outbox.ts +0 -0
  62. package/src/backend/routes/activitypub.ts +6 -5
  63. package/src/backend/routes/actors-helpers.ts +34 -8
  64. package/src/backend/routes/actors.ts +328 -164
  65. package/src/backend/routes/auth-helpers.ts +57 -9
  66. package/src/backend/routes/auth.ts +10 -2
  67. package/src/backend/routes/communities/membership-invites.ts +4 -1
  68. package/src/backend/routes/communities/membership-members.ts +201 -73
  69. package/src/backend/routes/communities/membership-requests.ts +141 -76
  70. package/src/backend/routes/communities/membership-shared.ts +228 -21
  71. package/src/backend/routes/communities/messages.ts +9 -2
  72. package/src/backend/routes/communities/routes.ts +48 -13
  73. package/src/backend/routes/dm/contacts.ts +29 -41
  74. package/src/backend/routes/dm/conversations-helpers.ts +9 -1
  75. package/src/backend/routes/dm/messages.ts +36 -42
  76. package/src/backend/routes/dm/read-archive.ts +4 -2
  77. package/src/backend/routes/dm/requests.ts +62 -54
  78. package/src/backend/routes/follow-helpers.ts +200 -60
  79. package/src/backend/routes/follow.ts +146 -140
  80. package/src/backend/routes/media.ts +20 -94
  81. package/src/backend/routes/moderation.ts +72 -4
  82. package/src/backend/routes/notes.ts +3 -4
  83. package/src/backend/routes/notifications.ts +209 -108
  84. package/src/backend/routes/posts/delete-cascade.ts +253 -89
  85. package/src/backend/routes/posts/federation.ts +373 -0
  86. package/src/backend/routes/posts/interactions.ts +160 -184
  87. package/src/backend/routes/posts/like-mutation.ts +240 -0
  88. package/src/backend/routes/posts/post-helpers.ts +112 -162
  89. package/src/backend/routes/posts/queries.ts +150 -54
  90. package/src/backend/routes/posts/routes.ts +169 -329
  91. package/src/backend/routes/posts/transformers.ts +61 -6
  92. package/src/backend/routes/recommendations.ts +37 -44
  93. package/src/backend/routes/rtc/index.ts +26 -6
  94. package/src/backend/routes/search.ts +39 -55
  95. package/src/backend/routes/stories/interactions.ts +22 -6
  96. package/src/backend/routes/stories/query-helpers.ts +28 -41
  97. package/src/backend/routes/stories/routes.ts +125 -108
  98. package/src/backend/routes/takos-tools/dm.ts +17 -17
  99. package/src/backend/routes/takos-tools/posts.ts +189 -106
  100. package/src/backend/routes/takos-tools/search.ts +13 -4
  101. package/src/backend/routes/takos-tools/timeline.ts +35 -27
  102. package/src/backend/routes/takos-tools-response.ts +6 -2
  103. package/src/backend/routes/timeline.ts +5 -5
  104. package/src/backend/runtime/call-signaling-do.ts +35 -4
  105. package/src/backend/runtime/one-time-ticket.ts +116 -0
  106. package/src/backend/runtime/realtime-stream-do.ts +5 -61
  107. package/src/backend/runtime/signaling-hub.ts +36 -3
  108. package/src/backend/server.ts +95 -80
  109. package/src/db/d1-write.ts +67 -43
  110. package/src/db/schema/federation.ts +42 -1
  111. package/src/db/schema/messaging.ts +110 -2
  112. package/src/db/schema.ts +1 -1
@@ -1,31 +1,27 @@
1
1
  import type { Context, Hono } from "hono";
2
2
  import { and, count, desc, eq, sql } from "drizzle-orm";
3
3
  import {
4
- activities,
5
4
  communities,
6
5
  communityJoinRequests,
7
- communityMembers,
8
6
  follows,
7
+ runBatch,
8
+ type D1Statement,
9
9
  } from "../../../db/index.ts";
10
10
  import type { Env, Variables } from "../../types.ts";
11
+ import { formatUsername, isLocal } from "../../federation-helpers.ts";
11
12
  import {
12
- activityApId,
13
- formatUsername,
14
- generateId,
15
- isLocal,
16
- } from "../../federation-helpers.ts";
17
- import { enqueueDeliveryToActor } from "../../lib/delivery/queue.ts";
13
+ isActorBlockedStrict,
14
+ operatorActorNotBlockedSql,
15
+ } from "../../lib/blocklist.ts";
16
+ import { prepareResponseIfRemote } from "../follow-helpers.ts";
18
17
  import {
19
- addMemberAtomic,
20
18
  batchLoadActorInfo,
21
19
  fetchCommunityId,
22
- memberWhere,
20
+ prepareAddMemberStatements,
21
+ prepareUnbanMemberStatement,
23
22
  requireManager,
24
- unbanMember,
25
23
  } from "./membership-shared.ts";
26
24
 
27
- const AS_CONTEXT = "https://www.w3.org/ns/activitystreams";
28
-
29
25
  export function registerMembershipRequestRoutes(
30
26
  communitiesRouter: Hono<{ Bindings: Env; Variables: Variables }>,
31
27
  ) {
@@ -61,6 +57,7 @@ export function registerMembershipRequestRoutes(
61
57
  and(
62
58
  eq(communityJoinRequests.communityApId, community.apId),
63
59
  eq(communityJoinRequests.status, "pending"),
60
+ operatorActorNotBlockedSql(sql`${communityJoinRequests.actorApId}`),
64
61
  ),
65
62
  )
66
63
  .orderBy(desc(communityJoinRequests.createdAt))
@@ -75,6 +72,7 @@ export function registerMembershipRequestRoutes(
75
72
  and(
76
73
  eq(follows.followingApId, community.apId),
77
74
  eq(follows.status, "pending"),
75
+ operatorActorNotBlockedSql(sql`${follows.followerApId}`),
78
76
  ),
79
77
  )
80
78
  .orderBy(desc(follows.createdAt))
@@ -96,7 +94,7 @@ export function registerMembershipRequestRoutes(
96
94
  const actorInfo = actorInfoMap.get(apId);
97
95
  return {
98
96
  ap_id: apId,
99
- username: formatUsername(apId),
97
+ username: formatUsername(apId, actorInfo?.preferredUsername),
100
98
  preferred_username: actorInfo?.preferredUsername || null,
101
99
  name: actorInfo?.name || null,
102
100
  icon_url: actorInfo?.iconUrl || null,
@@ -132,6 +130,9 @@ export function registerMembershipRequestRoutes(
132
130
  if (!manager) {
133
131
  return c.json({ error: "Forbidden" }, 403);
134
132
  }
133
+ if (await isActorBlockedStrict(db, body.actor_ap_id)) {
134
+ return c.json({ error: "Join request not found" }, 404);
135
+ }
135
136
 
136
137
  // A pending request is EITHER a community_join_requests row (local
137
138
  // approval-join) OR a pending follows edge to the Group (remote
@@ -165,22 +166,32 @@ export function registerMembershipRequestRoutes(
165
166
  const now = new Date().toISOString();
166
167
 
167
168
  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(
169
+ const statements: D1Statement[] = [
170
+ ...prepareAddMemberStatements(
177
171
  db,
178
172
  community.apId,
179
173
  body.actor_ap_id,
180
174
  "member",
181
175
  now,
176
+ ),
177
+ // Accepting a join request is an explicit re-admission.
178
+ prepareUnbanMemberStatement(db, community.apId, body.actor_ap_id),
179
+ ];
180
+ if (localRequest) {
181
+ statements.push(
182
+ db
183
+ .update(communityJoinRequests)
184
+ .set({ status: "accepted", processedAt: now })
185
+ .where(
186
+ and(
187
+ eq(communityJoinRequests.communityApId, community.apId),
188
+ eq(communityJoinRequests.actorApId, body.actor_ap_id),
189
+ eq(communityJoinRequests.status, "pending"),
190
+ ),
191
+ ) as D1Statement,
182
192
  );
183
193
  }
194
+ await runBatch(db, statements as [D1Statement, ...D1Statement[]]);
184
195
  } else {
185
196
  // A REMOTE member's membership IS the pending follows edge to the Group
186
197
  // actor — NOT a communityMembers row (which would diverge from how the
@@ -189,51 +200,51 @@ export function registerMembershipRequestRoutes(
189
200
  // was approved and our handleGroupCreate (which requires status=accepted)
190
201
  // starts relaying its posts. The pending edge carries the original Follow
191
202
  // 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
- ),
203
+ const preparedResponse = pendingEdge?.activityApId
204
+ ? await prepareResponseIfRemote(
205
+ c.env,
206
+ db,
207
+ c.env.APP_URL,
208
+ "Accept",
209
+ community.apId,
210
+ body.actor_ap_id,
211
+ pendingEdge.activityApId,
212
+ )
213
+ : null;
214
+ const statements: D1Statement[] = [
215
+ db
216
+ .update(follows)
217
+ .set({ status: "accepted", acceptedAt: now })
218
+ .where(
219
+ and(
220
+ eq(follows.followerApId, body.actor_ap_id),
221
+ eq(follows.followingApId, community.apId),
222
+ eq(follows.status, "pending"),
223
+ ),
224
+ ) as D1Statement,
225
+ ];
226
+ if (preparedResponse) statements.push(...preparedResponse.statements);
227
+ statements.push(
228
+ prepareUnbanMemberStatement(db, community.apId, body.actor_ap_id),
229
+ );
230
+ if (localRequest) {
231
+ statements.push(
232
+ db
233
+ .update(communityJoinRequests)
234
+ .set({ status: "accepted", processedAt: now })
235
+ .where(
236
+ and(
237
+ eq(communityJoinRequests.communityApId, community.apId),
238
+ eq(communityJoinRequests.actorApId, body.actor_ap_id),
239
+ eq(communityJoinRequests.status, "pending"),
240
+ ),
241
+ ) as D1Statement,
200
242
  );
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
243
  }
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
- );
244
+ await runBatch(db, statements as [D1Statement, ...D1Statement[]]);
245
+ // The durable intent is committed above; Queue is only a best-effort
246
+ // wakeup, so an outage cannot turn a committed approval into a 500.
247
+ await preparedResponse?.publish();
237
248
  }
238
249
 
239
250
  return c.json({ success: true });
@@ -265,7 +276,7 @@ export function registerMembershipRequestRoutes(
265
276
  return c.json({ error: "Forbidden" }, 403);
266
277
  }
267
278
 
268
- const request = await db
279
+ const localRequest = await db
269
280
  .select()
270
281
  .from(communityJoinRequests)
271
282
  .where(
@@ -276,19 +287,73 @@ export function registerMembershipRequestRoutes(
276
287
  ),
277
288
  )
278
289
  .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() })
290
+ const pendingEdge = await db
291
+ .select({ activityApId: follows.activityApId })
292
+ .from(follows)
286
293
  .where(
287
294
  and(
288
- eq(communityJoinRequests.communityApId, community.apId),
289
- eq(communityJoinRequests.actorApId, body.actor_ap_id),
295
+ eq(follows.followerApId, body.actor_ap_id),
296
+ eq(follows.followingApId, community.apId),
297
+ eq(follows.status, "pending"),
290
298
  ),
299
+ )
300
+ .get();
301
+ if (!localRequest && !pendingEdge) {
302
+ return c.json({ error: "Join request not found" }, 404);
303
+ }
304
+
305
+ const now = new Date().toISOString();
306
+ const statements: D1Statement[] = [];
307
+
308
+ if (pendingEdge) {
309
+ statements.push(
310
+ db
311
+ .delete(follows)
312
+ .where(
313
+ and(
314
+ eq(follows.followerApId, body.actor_ap_id),
315
+ eq(follows.followingApId, community.apId),
316
+ eq(follows.status, "pending"),
317
+ ),
318
+ ) as D1Statement,
291
319
  );
320
+ }
321
+
322
+ // A remote approval request exists as a pending Follow edge. Reject it
323
+ // with the Group actor and commit the edge removal, Activity, and durable
324
+ // delivery intent together so the local and remote states cannot diverge.
325
+ const preparedResponse = pendingEdge
326
+ ? await prepareResponseIfRemote(
327
+ c.env,
328
+ db,
329
+ c.env.APP_URL,
330
+ "Reject",
331
+ community.apId,
332
+ body.actor_ap_id,
333
+ pendingEdge.activityApId,
334
+ )
335
+ : null;
336
+ if (preparedResponse) statements.push(...preparedResponse.statements);
337
+
338
+ if (localRequest) {
339
+ statements.push(
340
+ db
341
+ .update(communityJoinRequests)
342
+ .set({ status: "rejected", processedAt: now })
343
+ .where(
344
+ and(
345
+ eq(communityJoinRequests.communityApId, community.apId),
346
+ eq(communityJoinRequests.actorApId, body.actor_ap_id),
347
+ eq(communityJoinRequests.status, "pending"),
348
+ ),
349
+ ) as D1Statement,
350
+ );
351
+ }
352
+
353
+ await runBatch(db, statements as [D1Statement, ...D1Statement[]]);
354
+ // The durable intent is committed above; Queue is only a best-effort
355
+ // wakeup, so an outage cannot turn a committed rejection into a 500.
356
+ await preparedResponse?.publish();
292
357
 
293
358
  return c.json({ success: true });
294
359
  },
@@ -1,16 +1,20 @@
1
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";
2
+ import { and, eq, gt, inArray, isNull, or, sql } from "drizzle-orm";
4
3
  import {
5
4
  actorCache,
6
5
  actors,
7
6
  communities,
8
7
  communityBans,
9
8
  communityMembers,
9
+ follows,
10
+ runBatch,
11
+ type D1Statement,
12
+ type Database,
10
13
  } from "../../../db/index.ts";
11
14
  import type { Env, Variables } from "../../types.ts";
12
15
  import { communityApId } from "../../federation-helpers.ts";
13
16
  import { chunkForInClause } from "../../lib/chunk.ts";
17
+ import { activityPubActorIdentityMatchesSql } from "../../lib/activitypub-actor-identity-sql.ts";
14
18
 
15
19
  export const managerRoles = new Set(["owner", "moderator"]);
16
20
 
@@ -23,10 +27,27 @@ export async function banMember(
23
27
  communityApIdVal: string,
24
28
  bannedApId: string,
25
29
  ): Promise<void> {
26
- await db
27
- .insert(communityBans)
28
- .values({ communityApId: communityApIdVal, bannedApId })
29
- .onConflictDoNothing();
30
+ const retainedBans = activityPubActorIdentityMatchesSql(
31
+ sql`
32
+ SELECT ${communityBans.bannedApId}
33
+ FROM ${communityBans}
34
+ WHERE ${communityBans.communityApId} = ${communityApIdVal}
35
+ `,
36
+ bannedApId,
37
+ );
38
+ await db.run(sql`
39
+ INSERT INTO ${communityBans} (
40
+ community_ap_id, banned_ap_id, created_at
41
+ )
42
+ SELECT ${communityApIdVal}, ${bannedApId}, ${new Date().toISOString()}
43
+ WHERE NOT EXISTS (
44
+ SELECT 1
45
+ FROM ${communityBans}
46
+ WHERE ${communityBans.communityApId} = ${communityApIdVal}
47
+ AND ${communityBans.bannedApId} IN (${retainedBans})
48
+ )
49
+ ON CONFLICT DO NOTHING
50
+ `);
30
51
  }
31
52
 
32
53
  /**
@@ -38,14 +59,33 @@ export async function unbanMember(
38
59
  communityApIdVal: string,
39
60
  bannedApId: string,
40
61
  ): Promise<void> {
41
- await db
62
+ await runBatch(db, [
63
+ prepareUnbanMemberStatement(db, communityApIdVal, bannedApId),
64
+ ]);
65
+ }
66
+
67
+ /** Prepare explicit re-admission so callers can co-commit its authority state. */
68
+ export function prepareUnbanMemberStatement(
69
+ db: Database,
70
+ communityApIdVal: string,
71
+ bannedApId: string,
72
+ ): D1Statement {
73
+ const retainedBans = activityPubActorIdentityMatchesSql(
74
+ sql`
75
+ SELECT ${communityBans.bannedApId}
76
+ FROM ${communityBans}
77
+ WHERE ${communityBans.communityApId} = ${communityApIdVal}
78
+ `,
79
+ bannedApId,
80
+ );
81
+ return db
42
82
  .delete(communityBans)
43
83
  .where(
44
84
  and(
45
85
  eq(communityBans.communityApId, communityApIdVal),
46
- eq(communityBans.bannedApId, bannedApId),
86
+ sql`${communityBans.bannedApId} IN (${retainedBans})`,
47
87
  ),
48
- );
88
+ ) as D1Statement;
49
89
  }
50
90
 
51
91
  /** Whether `actorApId` is banned from the community. */
@@ -64,7 +104,28 @@ export async function isMemberBanned(
64
104
  ),
65
105
  )
66
106
  .get();
67
- return !!row;
107
+ if (row) return true;
108
+
109
+ const retainedBans = activityPubActorIdentityMatchesSql(
110
+ sql`
111
+ SELECT ${communityBans.bannedApId}
112
+ FROM ${communityBans}
113
+ WHERE ${communityBans.communityApId} = ${communityApIdVal}
114
+ `,
115
+ actorApId,
116
+ );
117
+ const retained = await db
118
+ .select({ bannedApId: communityBans.bannedApId })
119
+ .from(communityBans)
120
+ .where(
121
+ and(
122
+ eq(communityBans.communityApId, communityApIdVal),
123
+ sql`${communityBans.bannedApId} IN (${retainedBans})`,
124
+ ),
125
+ )
126
+ .limit(1)
127
+ .get();
128
+ return retained !== undefined;
68
129
  }
69
130
 
70
131
  // `Database` is a union whose `.batch` lives only on the concrete D1/libsql
@@ -102,6 +163,68 @@ export async function removeMemberAtomic(
102
163
  ]);
103
164
  }
104
165
 
166
+ /**
167
+ * Atomically remove a LOCAL member, reconcile memberCount, and record the
168
+ * durable community ban that makes a moderator removal stick. This is distinct
169
+ * from removeMemberAtomic because a voluntary leave must not ban the actor.
170
+ *
171
+ * Put the idempotent ban insert inside the same D1 batch as the removal. If the
172
+ * ban cannot commit, neither the member row nor the count is changed, avoiding
173
+ * an expelled-but-immediately-rejoinable partial state.
174
+ */
175
+ export async function removeMemberAndBanAtomic(
176
+ db: Database,
177
+ communityApIdVal: string,
178
+ actorApIdVal: string,
179
+ ): Promise<void> {
180
+ const memberExists = sql`EXISTS (SELECT 1 FROM ${communityMembers} WHERE ${communityMembers.communityApId} = ${communityApIdVal} AND ${communityMembers.actorApId} = ${actorApIdVal})`;
181
+ await (db as unknown as Batchable).batch([
182
+ db
183
+ .update(communities)
184
+ .set({ memberCount: sql`${communities.memberCount} - 1` })
185
+ .where(
186
+ and(
187
+ eq(communities.apId, communityApIdVal),
188
+ gt(communities.memberCount, 0),
189
+ memberExists,
190
+ ),
191
+ ),
192
+ db
193
+ .insert(communityBans)
194
+ .values({ communityApId: communityApIdVal, bannedApId: actorApIdVal })
195
+ .onConflictDoNothing(),
196
+ db
197
+ .delete(communityMembers)
198
+ .where(memberWhere(communityApIdVal, actorApIdVal)),
199
+ ]);
200
+ }
201
+
202
+ /**
203
+ * Prepare removal of a REMOTE community member's accepted/pending Follow and
204
+ * the same durable ban used for local removals. The route composes these with
205
+ * the peer-facing Reject Activity and delivery intent in one D1 batch.
206
+ */
207
+ export function prepareRemoveRemoteMemberAndBanStatements(
208
+ db: Database,
209
+ communityApIdVal: string,
210
+ actorApIdVal: string,
211
+ ): [D1Statement, D1Statement] {
212
+ return [
213
+ db
214
+ .insert(communityBans)
215
+ .values({ communityApId: communityApIdVal, bannedApId: actorApIdVal })
216
+ .onConflictDoNothing() as D1Statement,
217
+ db
218
+ .delete(follows)
219
+ .where(
220
+ and(
221
+ eq(follows.followerApId, actorApIdVal),
222
+ eq(follows.followingApId, communityApIdVal),
223
+ ),
224
+ ) as D1Statement,
225
+ ];
226
+ }
227
+
105
228
  /**
106
229
  * Atomically remove an OWNER only if ANOTHER owner still remains. Returns false
107
230
  * (nothing removed) when the actor is the last owner.
@@ -113,14 +236,15 @@ export async function removeMemberAtomic(
113
236
  * statement closes the window: D1 serializes the two deletes, so the second one
114
237
  * to execute sees the first already gone and matches 0 rows.
115
238
  */
116
- export async function removeOwnerIfAnotherExists(
239
+ async function removeOwnerIfAnotherExistsInternal(
117
240
  db: Database,
118
241
  communityApIdVal: string,
119
242
  actorApIdVal: string,
243
+ banOnRemove: boolean,
120
244
  ): Promise<boolean> {
121
245
  const anotherOwnerExists = sql`EXISTS (SELECT 1 FROM ${communityMembers} WHERE ${communityMembers.communityApId} = ${communityApIdVal} AND ${communityMembers.role} = 'owner' AND ${communityMembers.actorApId} <> ${actorApIdVal})`;
122
246
  const memberExists = sql`EXISTS (SELECT 1 FROM ${communityMembers} WHERE ${communityMembers.communityApId} = ${communityApIdVal} AND ${communityMembers.actorApId} = ${actorApIdVal})`;
123
- await (db as unknown as Batchable).batch([
247
+ const statements: unknown[] = [
124
248
  db
125
249
  .update(communities)
126
250
  .set({ memberCount: sql`${communities.memberCount} - 1` })
@@ -132,12 +256,35 @@ export async function removeOwnerIfAnotherExists(
132
256
  anotherOwnerExists,
133
257
  ),
134
258
  ),
259
+ ];
260
+ if (banOnRemove) {
261
+ // The ban must be conditional on the SAME last-owner predicate as the
262
+ // delete. Insert it before the delete so the target row still exists for
263
+ // the SELECT. Under D1's atomic batch serialization, a concurrent removal
264
+ // that would leave zero owners matches neither this insert nor the delete.
265
+ const banCandidate = db
266
+ .select({
267
+ communityApId: sql<string>`${communityApIdVal}`.as("community_ap_id"),
268
+ bannedApId: sql<string>`${actorApIdVal}`.as("banned_ap_id"),
269
+ createdAt: sql<string>`${new Date().toISOString()}`.as("created_at"),
270
+ })
271
+ .from(communityMembers)
272
+ .where(
273
+ and(memberWhere(communityApIdVal, actorApIdVal), anotherOwnerExists),
274
+ )
275
+ .limit(1);
276
+ statements.push(
277
+ db.insert(communityBans).select(banCandidate).onConflictDoNothing(),
278
+ );
279
+ }
280
+ statements.push(
135
281
  db
136
282
  .delete(communityMembers)
137
283
  .where(
138
284
  and(memberWhere(communityApIdVal, actorApIdVal), anotherOwnerExists),
139
285
  ),
140
- ]);
286
+ );
287
+ await (db as unknown as Batchable).batch(statements);
141
288
  // Re-read whether the row is gone rather than trusting a batch affected-row
142
289
  // count (its shape differs between D1 and the libsql test driver). The member
143
290
  // row is keyed by (community, actor) and only this actor's leave touches it,
@@ -151,6 +298,37 @@ export async function removeOwnerIfAnotherExists(
151
298
  return !stillMember;
152
299
  }
153
300
 
301
+ export async function removeOwnerIfAnotherExists(
302
+ db: Database,
303
+ communityApIdVal: string,
304
+ actorApIdVal: string,
305
+ ): Promise<boolean> {
306
+ return removeOwnerIfAnotherExistsInternal(
307
+ db,
308
+ communityApIdVal,
309
+ actorApIdVal,
310
+ false,
311
+ );
312
+ }
313
+
314
+ /**
315
+ * Moderation counterpart to removeOwnerIfAnotherExists: remove a co-owner only
316
+ * when another owner survives, and atomically record the durable ban. A failed
317
+ * last-owner guard records no ban and leaves the owner untouched.
318
+ */
319
+ export async function removeOwnerAndBanIfAnotherExists(
320
+ db: Database,
321
+ communityApIdVal: string,
322
+ actorApIdVal: string,
323
+ ): Promise<boolean> {
324
+ return removeOwnerIfAnotherExistsInternal(
325
+ db,
326
+ communityApIdVal,
327
+ actorApIdVal,
328
+ true,
329
+ );
330
+ }
331
+
154
332
  /**
155
333
  * Atomically demote an OWNER to `newRole` only if ANOTHER owner (besides this
156
334
  * target) still remains, preserving the ">=1 owner" invariant. Returns true when
@@ -200,12 +378,34 @@ export async function addMemberAtomic(
200
378
  role: string,
201
379
  joinedAt: string,
202
380
  ): Promise<void> {
381
+ await runBatch(
382
+ db,
383
+ prepareAddMemberStatements(
384
+ db,
385
+ communityApIdVal,
386
+ actorApIdVal,
387
+ role,
388
+ joinedAt,
389
+ ),
390
+ );
391
+ }
392
+
393
+ /** Prepare retry-safe member insertion and its guarded count increment. */
394
+ export function prepareAddMemberStatements(
395
+ db: Database,
396
+ communityApIdVal: string,
397
+ actorApIdVal: string,
398
+ role: string,
399
+ joinedAt: string,
400
+ ): readonly [D1Statement, D1Statement] {
203
401
  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([
402
+ return [
205
403
  db
206
404
  .update(communities)
207
405
  .set({ memberCount: sql`${communities.memberCount} + 1` })
208
- .where(and(eq(communities.apId, communityApIdVal), memberAbsent)),
406
+ .where(
407
+ and(eq(communities.apId, communityApIdVal), memberAbsent),
408
+ ) as D1Statement,
209
409
  db
210
410
  .insert(communityMembers)
211
411
  .values({
@@ -214,8 +414,8 @@ export async function addMemberAtomic(
214
414
  role,
215
415
  joinedAt,
216
416
  })
217
- .onConflictDoNothing(),
218
- ]);
417
+ .onConflictDoNothing() as D1Statement,
418
+ ];
219
419
  }
220
420
 
221
421
  export function resolveCommunityApId(
@@ -227,11 +427,18 @@ export function resolveCommunityApId(
227
427
  : communityApId(baseUrl, identifier);
228
428
  }
229
429
 
230
- /** Shared WHERE clause for looking up a community by identifier or apId. */
430
+ /**
431
+ * Shared WHERE clause for looking up a live community by identifier or AP-ID.
432
+ * Soft-deleted communities are absent from every ordinary route, including
433
+ * direct identifier calls that bypass discovery.
434
+ */
231
435
  export function communityWhere(apId: string, identifier: string) {
232
- return or(
233
- eq(communities.apId, apId),
234
- eq(communities.preferredUsername, identifier),
436
+ return and(
437
+ or(
438
+ eq(communities.apId, apId),
439
+ eq(communities.preferredUsername, identifier),
440
+ ),
441
+ isNull(communities.deletedAt),
235
442
  );
236
443
  }
237
444
 
@@ -41,6 +41,7 @@ import {
41
41
  memberWhere,
42
42
  resolveCommunityApId,
43
43
  } from "./membership-shared.ts";
44
+ import { operatorActorNotBlockedSql } from "../../lib/blocklist.ts";
44
45
 
45
46
  const MAX_COMMUNITY_MESSAGE_LENGTH = 5000;
46
47
  const MAX_COMMUNITY_MESSAGES_LIMIT = 100;
@@ -158,6 +159,9 @@ messagesRouter.get("/:identifier/messages", async (c) => {
158
159
  eq(objectRecipients.type, "audience"),
159
160
  eq(objects.type, "Note"),
160
161
  isNull(objects.communityApId),
162
+ // A partial purge may retain remote chat objects. Defederation must hide
163
+ // them before cursor/limit so they cannot consume or shorten the page.
164
+ operatorActorNotBlockedSql(sql`${objects.attributedTo}`),
161
165
  ];
162
166
  // Composite (published, apId) cursor so same-millisecond messages aren't
163
167
  // skipped on a load-older boundary (see lib/feed-cursor.ts).
@@ -191,7 +195,10 @@ messagesRouter.get("/:identifier/messages", async (c) => {
191
195
  id: msg.apId,
192
196
  sender: {
193
197
  ap_id: msg.attributedTo,
194
- username: formatUsername(msg.attributedTo),
198
+ username: formatUsername(
199
+ msg.attributedTo,
200
+ senderInfo?.preferredUsername,
201
+ ),
195
202
  preferred_username: senderInfo?.preferredUsername || null,
196
203
  name: senderInfo?.name || null,
197
204
  icon_url: senderInfo?.iconUrl || null,
@@ -418,7 +425,7 @@ messagesRouter.post(
418
425
  id: objectApId,
419
426
  sender: {
420
427
  ap_id: actor.ap_id,
421
- username: formatUsername(actor.ap_id),
428
+ username: formatUsername(actor.ap_id, actor.preferred_username),
422
429
  preferred_username: actor.preferred_username,
423
430
  name: actor.name,
424
431
  icon_url: actor.icon_url,