@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
@@ -23,6 +23,9 @@ import {
23
23
  MAX_PROFILE_URL_LENGTH,
24
24
  } from "./actors-helpers.ts";
25
25
  import { logger } from "../lib/logger.ts";
26
+ import type { VerifiedTakosumiWorkspaceGrant } from "../lib/oidc-id-token.ts";
27
+
28
+ export type { VerifiedTakosumiWorkspaceGrant } from "../lib/oidc-id-token.ts";
26
29
 
27
30
  /**
28
31
  * Bound + validate an OAuth/OIDC provider's `name` / `picture` before they are
@@ -55,6 +58,29 @@ function sanitizeOAuthProfile(userInfo: { name?: string; picture?: string }): {
55
58
 
56
59
  const log = logger.child({ component: "auth.helpers" });
57
60
 
61
+ function isExplicitlyEnabled(value: string | undefined): boolean {
62
+ return ["1", "true", "yes", "on"].includes(
63
+ (value ?? "").trim().toLowerCase(),
64
+ );
65
+ }
66
+
67
+ /**
68
+ * OAuth identities are persisted as `<provider>:<subject>`, while an operator
69
+ * may only know the issuer's bare subject when configuring the first login.
70
+ * Accept either spelling without broadening the match to another subject.
71
+ */
72
+ function configuredSubjectMatches(
73
+ configuredSubject: string,
74
+ providerUserId: string,
75
+ ): boolean {
76
+ if (configuredSubject === providerUserId) return true;
77
+ const namespaceSeparator = providerUserId.indexOf(":");
78
+ return (
79
+ namespaceSeparator >= 0 &&
80
+ configuredSubject === providerUserId.slice(namespaceSeparator + 1)
81
+ );
82
+ }
83
+
58
84
  // Upper bound for the OAuth token-exchange / userinfo fetches so a hung provider
59
85
  // can't stall the login request (the self-host runtime has no wall-clock cap).
60
86
  export const OAUTH_FETCH_TIMEOUT_MS = 10_000;
@@ -337,6 +363,7 @@ export async function createActorFromOAuth(
337
363
  username?: string;
338
364
  },
339
365
  providerUserId: string,
366
+ verifiedTakosumiGrant?: VerifiedTakosumiWorkspaceGrant,
340
367
  ) {
341
368
  const baseUsername =
342
369
  userInfo.username ||
@@ -353,22 +380,36 @@ export async function createActorFromOAuth(
353
380
  // When `OIDC_OWNER_SUB` (the operator's pinned Takosumi subject) is configured,
354
381
  // ONLY that subject may take the owner slot; any other first-login is REFUSED
355
382
  // (not downgraded to member — that would consume the owner slot and lock the
356
- // real operator out forever). With no pin set we keep first-login-owner but warn.
383
+ // real operator out forever). An unpinned bootstrap must be explicitly enabled
384
+ // because otherwise any valid issuer subject could win this authority race.
357
385
  if (actorCount === 0) {
358
386
  const ownerSub =
359
387
  parseNonEmptyString(env.OIDC_OWNER_SUB) ??
360
388
  parseNonEmptyString(env.TAKOSUMI_ACCOUNTS_OWNER_SUB);
361
389
  if (ownerSub) {
362
- if (providerUserId !== ownerSub) {
390
+ if (!configuredSubjectMatches(ownerSub, providerUserId)) {
363
391
  log.warn(
364
392
  "refused OAuth owner creation: subject does not match OIDC_OWNER_SUB pin",
365
393
  );
366
394
  return null;
367
395
  }
368
- } else {
396
+ } else if (
397
+ !(
398
+ providerUserId.startsWith("takos:") &&
399
+ verifiedTakosumiGrant?.role === "owner"
400
+ ) &&
401
+ !isExplicitlyEnabled(env.ALLOW_UNPINNED_OWNER_CLAIM)
402
+ ) {
403
+ log.warn(
404
+ "refused OAuth owner creation: no owner subject pin, verified " +
405
+ "Takosumi Workspace owner grant, or explicit " +
406
+ "ALLOW_UNPINNED_OWNER_CLAIM bootstrap opt-in is configured",
407
+ );
408
+ return null;
409
+ } else if (verifiedTakosumiGrant?.role !== "owner") {
369
410
  log.warn(
370
- "first OAuth login is taking the owner slot with no OIDC_OWNER_SUB pin set; " +
371
- "set OIDC_OWNER_SUB to the operator's subject to prevent an owner-slot race",
411
+ "explicit unpinned OAuth bootstrap is taking the owner slot; " +
412
+ "pin OIDC_OWNER_SUB and clear ALLOW_UNPINNED_OWNER_CLAIM after login",
372
413
  );
373
414
  }
374
415
  }
@@ -393,8 +434,9 @@ export async function createActorFromOAuth(
393
434
  .map((s) => s.trim())
394
435
  .filter(Boolean),
395
436
  );
396
- if (ownerSub) allowed.add(ownerSub);
397
- if (!allowed.has(providerUserId)) {
437
+ const matchesOwner =
438
+ ownerSub != null && configuredSubjectMatches(ownerSub, providerUserId);
439
+ if (!matchesOwner && !allowed.has(providerUserId)) {
398
440
  log.warn(
399
441
  "refused OAuth member auto-provisioning: registration is closed " +
400
442
  "(subject not the owner pin and not in OIDC_ALLOWED_SUBS)",
@@ -515,6 +557,7 @@ export async function findOrCreateOAuthActor(
515
557
  picture?: string;
516
558
  username?: string;
517
559
  },
560
+ verifiedTakosumiGrant?: VerifiedTakosumiWorkspaceGrant,
518
561
  ) {
519
562
  // Namespace EVERY provider's subject by its provider id (`takos:<sub>`,
520
563
  // `google:<id>`, `x:<id>`). Previously the `takos` subject was stored verbatim
@@ -539,8 +582,13 @@ export async function findOrCreateOAuthActor(
539
582
  // (-> actor_creation_failed) covers both the refusal and a missing row.
540
583
  try {
541
584
  actorData =
542
- (await createActorFromOAuth(db, env, userInfo, providerUserId)) ??
543
- undefined;
585
+ (await createActorFromOAuth(
586
+ db,
587
+ env,
588
+ userInfo,
589
+ providerUserId,
590
+ verifiedTakosumiGrant,
591
+ )) ?? undefined;
544
592
  } catch (e) {
545
593
  // Lost a concurrent first-login race for the same subject: the UNIQUE on
546
594
  // takosUserId fired because the other request already created the actor.
@@ -10,7 +10,10 @@ import {
10
10
  getMobileOidcAudience,
11
11
  getProvider,
12
12
  } from "../lib/oauth-providers.ts";
13
- import { verifyOidcIdToken } from "../lib/oidc-id-token.ts";
13
+ import {
14
+ readVerifiedTakosumiWorkspaceGrant,
15
+ verifyOidcIdToken,
16
+ } from "../lib/oidc-id-token.ts";
14
17
  import {
15
18
  deleteOAuthState,
16
19
  generateCodeChallenge,
@@ -121,7 +124,7 @@ auth.get("/me", async (c) => {
121
124
  return c.json({
122
125
  actor: {
123
126
  ap_id: actor.ap_id,
124
- username: formatUsername(actor.ap_id),
127
+ username: formatUsername(actor.ap_id, actor.preferred_username),
125
128
  preferred_username: actor.preferred_username,
126
129
  name: actor.name,
127
130
  summary: actor.summary,
@@ -314,6 +317,7 @@ auth.post("/mobile/oidc", async (c) => {
314
317
  email: claims.email,
315
318
  username: claims.preferred_username,
316
319
  },
320
+ readVerifiedTakosumiWorkspaceGrant(claims),
317
321
  );
318
322
  if (!actorData) return c.json({ error: "actor_creation_failed" }, 403);
319
323
  const sessionId = await rotateSession(
@@ -462,6 +466,8 @@ auth.get("/callback/:provider", async (c) => {
462
466
  // a provider the id_token MUST be present and valid — a token response that
463
467
  // simply omits it must NOT fall through to unverified userinfo (that would skip
464
468
  // the aud + nonce binding the rest of this flow enforces). Fail closed.
469
+ let verifiedTakosumiGrant:
470
+ ReturnType<typeof readVerifiedTakosumiWorkspaceGrant> | undefined;
465
471
  if (provider.issuer && provider.jwksUrl) {
466
472
  if (!tokens.id_token) {
467
473
  log.error("OIDC provider returned no id_token", {
@@ -478,6 +484,7 @@ auth.get("/callback/:provider", async (c) => {
478
484
  jwksUrl: provider.jwksUrl,
479
485
  expectedNonce: storedState.nonce,
480
486
  });
487
+ verifiedTakosumiGrant = readVerifiedTakosumiWorkspaceGrant(claims);
481
488
  userInfo = {
482
489
  ...userInfo,
483
490
  id: claims.sub || userInfo.id,
@@ -500,6 +507,7 @@ auth.get("/callback/:provider", async (c) => {
500
507
  c.env,
501
508
  providerId,
502
509
  userInfo,
510
+ providerId === "takos" ? verifiedTakosumiGrant : undefined,
503
511
  );
504
512
  if (!actorData) return c.redirect("/?error=actor_creation_failed");
505
513
 
@@ -59,7 +59,10 @@ export function registerMembershipInviteRoutes(
59
59
  invited_ap_id: inv.invitedApId,
60
60
  invited_by: {
61
61
  ap_id: inv.invitedByApId,
62
- username: formatUsername(inv.invitedByApId),
62
+ username: formatUsername(
63
+ inv.invitedByApId,
64
+ invitedByInfo?.preferredUsername,
65
+ ),
63
66
  preferred_username: invitedByInfo?.preferredUsername || null,
64
67
  name: invitedByInfo?.name || null,
65
68
  },
@@ -1,28 +1,122 @@
1
1
  import type { Context, Hono } from "hono";
2
- import { and, asc, count, desc, eq, inArray, or } from "drizzle-orm";
3
- import { communities, communityMembers, follows } from "../../../db/index.ts";
2
+ import { and, eq, inArray, sql, type SQL } from "drizzle-orm";
3
+ import {
4
+ communities,
5
+ communityMembers,
6
+ follows,
7
+ runBatch,
8
+ type Database,
9
+ type D1Statement,
10
+ } from "../../../db/index.ts";
4
11
  import { chunkForInClause } from "../../lib/chunk.ts";
5
12
  import { communityRequiresMembership } from "../../lib/community-visibility.ts";
13
+ import { operatorActorNotBlockedSql } from "../../lib/blocklist.ts";
6
14
  import type { Env, Variables } from "../../types.ts";
7
15
  import {
16
+ formatPreferredUsername,
8
17
  formatUsername,
9
18
  parseLimit,
10
19
  parseOffset,
11
20
  } from "../../federation-helpers.ts";
21
+ import { prepareResponseIfRemote } from "../follow-helpers.ts";
12
22
  import {
13
- banMember,
14
23
  batchLoadActorInfo,
24
+ communityWhere,
15
25
  demoteOwnerIfAnotherExists,
16
26
  fetchCommunityId,
17
27
  memberWhere,
18
- removeMemberAtomic,
19
- removeOwnerIfAnotherExists,
28
+ removeMemberAndBanAtomic,
29
+ removeOwnerAndBanIfAnotherExists,
30
+ prepareRemoveRemoteMemberAndBanStatements,
20
31
  requireManager,
21
32
  resolveCommunityApId,
22
33
  } from "./membership-shared.ts";
23
34
 
24
35
  const MAX_MEMBER_BATCH_SIZE = 100;
25
36
 
37
+ type CommunityRosterRow = {
38
+ actor_ap_id: string;
39
+ role: "owner" | "moderator" | "member";
40
+ joined_at: string;
41
+ can_change_role: number | boolean;
42
+ };
43
+
44
+ type RawSqlDatabase = {
45
+ all(query: SQL): Promise<unknown[]>;
46
+ };
47
+
48
+ /**
49
+ * The community roster has two authority sources: role-bearing local rows and
50
+ * accepted ActivityPub Follow edges to the Group. Keep the union in SQL so
51
+ * limit/offset and total describe the same complete roster. An exact local row
52
+ * wins over a retained Follow edge and is never presented twice.
53
+ */
54
+ function communityRosterSql(communityApIdVal: string): SQL {
55
+ return sql`
56
+ SELECT
57
+ ${communityMembers.actorApId} AS actor_ap_id,
58
+ ${communityMembers.role} AS role,
59
+ ${communityMembers.joinedAt} AS joined_at,
60
+ 1 AS can_change_role
61
+ FROM ${communityMembers}
62
+ WHERE ${communityMembers.communityApId} = ${communityApIdVal}
63
+
64
+ UNION ALL
65
+
66
+ SELECT
67
+ ${follows.followerApId} AS actor_ap_id,
68
+ 'member' AS role,
69
+ COALESCE(${follows.acceptedAt}, ${follows.createdAt}) AS joined_at,
70
+ 0 AS can_change_role
71
+ FROM ${follows}
72
+ WHERE ${follows.followingApId} = ${communityApIdVal}
73
+ AND ${follows.status} = 'accepted'
74
+ AND ${operatorActorNotBlockedSql(sql`${follows.followerApId}`)}
75
+ AND NOT EXISTS (
76
+ SELECT 1
77
+ FROM ${communityMembers}
78
+ WHERE ${communityMembers.communityApId} = ${communityApIdVal}
79
+ AND ${communityMembers.actorApId} = ${follows.followerApId}
80
+ )
81
+ `;
82
+ }
83
+
84
+ async function removeFollowBackedMemberAndBan(
85
+ env: Env,
86
+ db: Database,
87
+ communityApIdVal: string,
88
+ actorApId: string,
89
+ originalFollowActivityApId: string | null,
90
+ ): Promise<void> {
91
+ const preparedResponse = originalFollowActivityApId
92
+ ? await prepareResponseIfRemote(
93
+ env,
94
+ db,
95
+ env.APP_URL,
96
+ "Reject",
97
+ communityApIdVal,
98
+ actorApId,
99
+ originalFollowActivityApId,
100
+ )
101
+ : null;
102
+ const statements: [D1Statement, ...D1Statement[]] = [
103
+ ...prepareRemoveRemoteMemberAndBanStatements(
104
+ db,
105
+ communityApIdVal,
106
+ actorApId,
107
+ ),
108
+ ];
109
+ if (preparedResponse) statements.push(...preparedResponse.statements);
110
+
111
+ // Ban, relay-edge removal, Reject Activity, and durable first-hop delivery
112
+ // intent are one D1 commit. A persistence failure leaves the original edge
113
+ // available for an exact retry.
114
+ await runBatch(db, statements);
115
+ // Queue is only a post-commit wakeup. The durable intent remains for the
116
+ // normal sweep when publication is temporarily unavailable.
117
+ await preparedResponse?.publish();
118
+ }
119
+
26
120
  function validateBatchApIds(ids: unknown): string | null {
27
121
  if (!Array.isArray(ids) || ids.length === 0) {
28
122
  return "actor_ap_ids array is required";
@@ -82,13 +176,14 @@ export function registerMembershipMemberRoutes(
82
176
  // fan-out and the members-only post gate (handleGroupCreate) key on.
83
177
  // Deleting that edge is the moderation lever over a remote member: it
84
178
  // immediately drops them from the relay and makes their inbound posts
85
- // fail the members-only gate (no outbound Reject is required for the
86
- // removal to take effect locally). Pending join follows are cleared too.
179
+ // fail the members-only gate. The Group-signed Reject below also tells
180
+ // the peer to remove its corresponding following edge. Pending join
181
+ // follows are cleared the same way.
87
182
  // A durable ban (below) auto-Rejects a re-Follow into an open community,
88
183
  // so the kick sticks; an approval re-request or invite lets a mod
89
184
  // re-admit (which lifts the ban).
90
185
  const remoteFollow = await db
91
- .select({ followerApId: follows.followerApId })
186
+ .select({ activityApId: follows.activityApId })
92
187
  .from(follows)
93
188
  .where(
94
189
  and(
@@ -100,17 +195,13 @@ export function registerMembershipMemberRoutes(
100
195
  if (!remoteFollow) {
101
196
  return c.json({ error: "User is not a member" }, 404);
102
197
  }
103
- await db
104
- .delete(follows)
105
- .where(
106
- and(
107
- eq(follows.followerApId, targetApId),
108
- eq(follows.followingApId, community.apId),
109
- ),
110
- );
111
- // Durable ban so the removed remote actor cannot simply re-Follow back
112
- // into an open community (handleGroupFollow consults it).
113
- await banMember(db, community.apId, targetApId);
198
+ await removeFollowBackedMemberAndBan(
199
+ c.env,
200
+ db,
201
+ community.apId,
202
+ targetApId,
203
+ remoteFollow.activityApId,
204
+ );
114
205
  return c.json({ success: true });
115
206
  }
116
207
 
@@ -136,7 +227,7 @@ export function registerMembershipMemberRoutes(
136
227
  // last-owner LEAVE guard. D1 serializes the deletes, so the second sees
137
228
  // the first already gone and matches 0 rows.
138
229
  if (targetMembership.role === "owner") {
139
- const removed = await removeOwnerIfAnotherExists(
230
+ const removed = await removeOwnerAndBanIfAnotherExists(
140
231
  db,
141
232
  community.apId,
142
233
  targetApId,
@@ -144,12 +235,10 @@ export function registerMembershipMemberRoutes(
144
235
  if (!removed) {
145
236
  return c.json({ error: "Cannot remove the last owner" }, 400);
146
237
  }
147
- await banMember(db, community.apId, targetApId);
148
238
  return c.json({ success: true });
149
239
  }
150
240
 
151
- await removeMemberAtomic(db, community.apId, targetApId);
152
- await banMember(db, community.apId, targetApId);
241
+ await removeMemberAndBanAtomic(db, community.apId, targetApId);
153
242
 
154
243
  return c.json({ success: true });
155
244
  },
@@ -238,12 +327,7 @@ export function registerMembershipMemberRoutes(
238
327
  const community = await db
239
328
  .select({ apId: communities.apId, visibility: communities.visibility })
240
329
  .from(communities)
241
- .where(
242
- or(
243
- eq(communities.apId, apId),
244
- eq(communities.preferredUsername, identifier),
245
- ),
246
- )
330
+ .where(communityWhere(apId, identifier))
247
331
  .get();
248
332
  if (!community) {
249
333
  return c.json({ members: [] });
@@ -265,42 +349,56 @@ export function registerMembershipMemberRoutes(
265
349
  }
266
350
  }
267
351
 
268
- // Fetch limit+1 to signal whether more members exist beyond this page
269
- // previously the roster was silently truncated at the page size with no
270
- // has_more, so a client could not tell a >page community from a complete
271
- // one. `total` is an accurate COUNT (the indexed communityApId scan is
272
- // cheap) so the UI can show the real member count.
273
- const [scanned, countRow] = await Promise.all([
274
- db
275
- .select()
276
- .from(communityMembers)
277
- .where(eq(communityMembers.communityApId, community.apId))
278
- .orderBy(desc(communityMembers.role), asc(communityMembers.joinedAt))
279
- .limit(limit + 1)
280
- .offset(offset),
281
- db
282
- .select({ c: count() })
283
- .from(communityMembers)
284
- .where(eq(communityMembers.communityApId, community.apId))
285
- .get(),
352
+ // Fetch limit+1 to signal whether more members exist beyond this page.
353
+ // Both the page and total use the same union, so federated members cannot
354
+ // disappear from the manager roster or its pagination metadata.
355
+ const roster = communityRosterSql(community.apId);
356
+ const rawDb = db as unknown as RawSqlDatabase;
357
+ const [scannedRows, countRows] = await Promise.all([
358
+ rawDb.all(sql`
359
+ SELECT actor_ap_id, role, joined_at, can_change_role
360
+ FROM (${roster}) AS community_roster
361
+ ORDER BY
362
+ CASE role
363
+ WHEN 'owner' THEN 0
364
+ WHEN 'moderator' THEN 1
365
+ ELSE 2
366
+ END,
367
+ joined_at ASC,
368
+ actor_ap_id ASC
369
+ LIMIT ${limit + 1}
370
+ OFFSET ${offset}
371
+ `),
372
+ rawDb.all(sql`
373
+ SELECT COUNT(*) AS total
374
+ FROM (${roster}) AS community_roster
375
+ `),
286
376
  ]);
377
+ const scanned = scannedRows as CommunityRosterRow[];
378
+ const countRow = countRows[0] as { total?: number } | undefined;
287
379
  const hasMore = scanned.length > limit;
288
380
  const members = hasMore ? scanned.slice(0, limit) : scanned;
289
- const total = countRow?.c ?? members.length;
381
+ const total = countRow?.total ?? members.length;
290
382
 
291
- const memberApIds = members.map((m) => m.actorApId);
383
+ const memberApIds = members.map((m) => m.actor_ap_id);
292
384
  const actorInfoMap = await batchLoadActorInfo(db, memberApIds);
293
385
 
294
386
  const result = members.map((m) => {
295
- const actorInfo = actorInfoMap.get(m.actorApId);
387
+ const actorInfo = actorInfoMap.get(m.actor_ap_id);
388
+ const preferredUsername = formatPreferredUsername(
389
+ m.actor_ap_id,
390
+ actorInfo?.preferredUsername,
391
+ );
296
392
  return {
297
- ap_id: m.actorApId,
298
- username: formatUsername(m.actorApId),
299
- preferred_username: actorInfo?.preferredUsername || null,
393
+ ap_id: m.actor_ap_id,
394
+ username: formatUsername(m.actor_ap_id, preferredUsername),
395
+ preferred_username: preferredUsername,
300
396
  name: actorInfo?.name || null,
301
397
  icon_url: actorInfo?.iconUrl || null,
302
398
  role: m.role,
303
- joined_at: m.joinedAt,
399
+ joined_at: m.joined_at,
400
+ can_change_role:
401
+ m.can_change_role === true || m.can_change_role === 1,
304
402
  };
305
403
  });
306
404
 
@@ -344,22 +442,40 @@ export function registerMembershipMemberRoutes(
344
442
  // (<=100) plus the community param would otherwise exceed D1's 100-bound-
345
443
  // parameter cap.
346
444
  const membershipByApId = new Map<string, { role: string }>();
445
+ const followByApId = new Map<string, { activityApId: string | null }>();
347
446
  for (const chunk of chunkForInClause(body.actor_ap_ids)) {
348
- const rows = await db
349
- .select({
350
- actorApId: communityMembers.actorApId,
351
- role: communityMembers.role,
352
- })
353
- .from(communityMembers)
354
- .where(
355
- and(
356
- eq(communityMembers.communityApId, community.apId),
357
- inArray(communityMembers.actorApId, chunk),
447
+ const [memberRows, followRows] = await Promise.all([
448
+ db
449
+ .select({
450
+ actorApId: communityMembers.actorApId,
451
+ role: communityMembers.role,
452
+ })
453
+ .from(communityMembers)
454
+ .where(
455
+ and(
456
+ eq(communityMembers.communityApId, community.apId),
457
+ inArray(communityMembers.actorApId, chunk),
458
+ ),
459
+ ),
460
+ db
461
+ .select({
462
+ actorApId: follows.followerApId,
463
+ activityApId: follows.activityApId,
464
+ })
465
+ .from(follows)
466
+ .where(
467
+ and(
468
+ eq(follows.followingApId, community.apId),
469
+ inArray(follows.followerApId, chunk),
470
+ ),
358
471
  ),
359
- );
360
- for (const r of rows) {
472
+ ]);
473
+ for (const r of memberRows) {
361
474
  membershipByApId.set(r.actorApId, { role: r.role });
362
475
  }
476
+ for (const r of followRows) {
477
+ followByApId.set(r.actorApId, { activityApId: r.activityApId });
478
+ }
363
479
  }
364
480
 
365
481
  for (const targetApId of body.actor_ap_ids) {
@@ -375,11 +491,23 @@ export function registerMembershipMemberRoutes(
375
491
 
376
492
  const targetMembership = membershipByApId.get(targetApId);
377
493
  if (!targetMembership) {
378
- results.push({
379
- ap_id: targetApId,
380
- success: false,
381
- error: "Not a member",
382
- });
494
+ const remoteFollow = followByApId.get(targetApId);
495
+ if (!remoteFollow) {
496
+ results.push({
497
+ ap_id: targetApId,
498
+ success: false,
499
+ error: "Not a member",
500
+ });
501
+ continue;
502
+ }
503
+ await removeFollowBackedMemberAndBan(
504
+ c.env,
505
+ db,
506
+ community.apId,
507
+ targetApId,
508
+ remoteFollow.activityApId,
509
+ );
510
+ results.push({ ap_id: targetApId, success: true });
383
511
  continue;
384
512
  }
385
513
 
@@ -402,7 +530,7 @@ export function registerMembershipMemberRoutes(
402
530
  // concurrently both succeeded → ZERO owners. Gate owner removals on
403
531
  // another owner still existing, exactly like the single DELETE path.
404
532
  if (targetMembership.role === "owner") {
405
- const removed = await removeOwnerIfAnotherExists(
533
+ const removed = await removeOwnerAndBanIfAnotherExists(
406
534
  db,
407
535
  community.apId,
408
536
  targetApId,
@@ -419,7 +547,7 @@ export function registerMembershipMemberRoutes(
419
547
  continue;
420
548
  }
421
549
 
422
- await removeMemberAtomic(db, community.apId, targetApId);
550
+ await removeMemberAndBanAtomic(db, community.apId, targetApId);
423
551
 
424
552
  results.push({ ap_id: targetApId, success: true });
425
553
  } catch {