@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.
- package/migrations/0023_delivery_resolution_outbox.sql +33 -0
- package/migrations/0024_delivery_fanout_outbox.sql +35 -0
- package/migrations/0025_delivery_endpoint_terminal_retention.sql +6 -0
- package/migrations/0026_remote_actor_fetch_failures.sql +29 -0
- package/migrations/0027_remote_actor_tombstones.sql +12 -0
- package/migrations/0028_remote_actor_delivery_fence.sql +30 -0
- package/migrations/0029_delivery_endpoint_recipients.sql +47 -0
- package/package.json +2 -1
- package/packages/api/package.json +1 -1
- package/packages/api/src/lib/api/communities.ts +2 -0
- package/packages/api/src/lib/api/fetch.ts +69 -13
- package/packages/api/src/lib/api/normalize.ts +32 -10
- package/packages/api/src/lib/api/notifications.ts +4 -1
- package/packages/api/src/lib/rtc-client.ts +127 -29
- package/src/backend/federation-helpers.ts +1 -0
- package/src/backend/index.ts +16 -3
- package/src/backend/lib/account-migration.ts +340 -0
- package/src/backend/lib/activity-delete-cascade.ts +193 -0
- package/src/backend/lib/activitypub-actor-cache.ts +615 -48
- package/src/backend/lib/activitypub-actor-identity-sql.ts +179 -0
- package/src/backend/lib/activitypub-actor-identity.ts +39 -0
- package/src/backend/lib/activitypub-validators.ts +62 -4
- package/src/backend/lib/ap-ids.ts +36 -6
- package/src/backend/lib/ap-verify.ts +7 -17
- package/src/backend/lib/blocklist-purge.ts +676 -42
- package/src/backend/lib/blocklist.ts +278 -39
- package/src/backend/lib/community-visibility.ts +47 -33
- package/src/backend/lib/delivery/fanout-outbox.ts +336 -0
- package/src/backend/lib/delivery/planner.ts +16 -12
- package/src/backend/lib/delivery/queue-batching.ts +389 -145
- package/src/backend/lib/delivery/queue-delivery.ts +47 -25
- package/src/backend/lib/delivery/queue.ts +479 -73
- package/src/backend/lib/delivery/resolution-outbox.ts +456 -0
- package/src/backend/lib/delivery/types.ts +31 -7
- package/src/backend/lib/feed-exclude.ts +40 -28
- package/src/backend/lib/follow-edge-mutations.ts +217 -0
- package/src/backend/lib/notification-eligibility.ts +15 -9
- package/src/backend/lib/notification-push.ts +2 -2
- package/src/backend/lib/oidc-id-token.ts +66 -0
- package/src/backend/lib/personal-actor-moderation.ts +239 -0
- package/src/backend/lib/post-visibility.ts +79 -16
- package/src/backend/lib/remote-activity-id.ts +61 -0
- package/src/backend/lib/unread-counts.ts +3 -0
- package/src/backend/retention.ts +38 -1
- package/src/backend/routes/account-teardown.ts +422 -156
- package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +104 -105
- package/src/backend/routes/activitypub/handlers/inbound-community-scope.ts +121 -0
- package/src/backend/routes/activitypub/handlers/inbound-object-identity.ts +54 -0
- package/src/backend/routes/activitypub/handlers/inbound-reply-target.ts +34 -0
- package/src/backend/routes/activitypub/handlers/inbound-story-projection.ts +438 -0
- package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1121 -806
- package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +118 -153
- package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +185 -168
- package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +220 -32
- package/src/backend/routes/activitypub/inbound-activity-identity.ts +16 -0
- package/src/backend/routes/activitypub/inbound-activity-reference.ts +116 -0
- package/src/backend/routes/activitypub/inbound-addressing.ts +87 -0
- package/src/backend/routes/activitypub/inbox-addressing.ts +22 -19
- package/src/backend/routes/activitypub/inbox-types.ts +14 -2
- package/src/backend/routes/activitypub/inbox.ts +81 -105
- package/src/backend/routes/activitypub/outbox.ts +0 -0
- package/src/backend/routes/activitypub.ts +6 -5
- package/src/backend/routes/actors-helpers.ts +34 -8
- package/src/backend/routes/actors.ts +328 -164
- package/src/backend/routes/auth-helpers.ts +57 -9
- package/src/backend/routes/auth.ts +10 -2
- package/src/backend/routes/communities/membership-invites.ts +4 -1
- package/src/backend/routes/communities/membership-members.ts +201 -73
- package/src/backend/routes/communities/membership-requests.ts +141 -76
- package/src/backend/routes/communities/membership-shared.ts +228 -21
- package/src/backend/routes/communities/messages.ts +9 -2
- package/src/backend/routes/communities/routes.ts +48 -13
- package/src/backend/routes/dm/contacts.ts +29 -41
- package/src/backend/routes/dm/conversations-helpers.ts +9 -1
- package/src/backend/routes/dm/messages.ts +36 -42
- package/src/backend/routes/dm/read-archive.ts +4 -2
- package/src/backend/routes/dm/requests.ts +62 -54
- package/src/backend/routes/follow-helpers.ts +200 -60
- package/src/backend/routes/follow.ts +146 -140
- package/src/backend/routes/media.ts +20 -94
- package/src/backend/routes/moderation.ts +72 -4
- package/src/backend/routes/notes.ts +3 -4
- package/src/backend/routes/notifications.ts +209 -108
- package/src/backend/routes/posts/delete-cascade.ts +253 -89
- package/src/backend/routes/posts/federation.ts +373 -0
- package/src/backend/routes/posts/interactions.ts +160 -184
- package/src/backend/routes/posts/like-mutation.ts +240 -0
- package/src/backend/routes/posts/post-helpers.ts +112 -162
- package/src/backend/routes/posts/queries.ts +150 -54
- package/src/backend/routes/posts/routes.ts +169 -329
- package/src/backend/routes/posts/transformers.ts +61 -6
- package/src/backend/routes/recommendations.ts +37 -44
- package/src/backend/routes/rtc/index.ts +26 -6
- package/src/backend/routes/search.ts +39 -55
- package/src/backend/routes/stories/interactions.ts +22 -6
- package/src/backend/routes/stories/query-helpers.ts +28 -41
- package/src/backend/routes/stories/routes.ts +125 -108
- package/src/backend/routes/takos-tools/dm.ts +17 -17
- package/src/backend/routes/takos-tools/posts.ts +189 -106
- package/src/backend/routes/takos-tools/search.ts +13 -4
- package/src/backend/routes/takos-tools/timeline.ts +35 -27
- package/src/backend/routes/takos-tools-response.ts +6 -2
- package/src/backend/routes/timeline.ts +5 -5
- package/src/backend/runtime/call-signaling-do.ts +35 -4
- package/src/backend/runtime/one-time-ticket.ts +116 -0
- package/src/backend/runtime/realtime-stream-do.ts +5 -61
- package/src/backend/runtime/signaling-hub.ts +36 -3
- package/src/backend/server.ts +95 -80
- package/src/db/d1-write.ts +67 -43
- package/src/db/schema/federation.ts +42 -1
- package/src/db/schema/messaging.ts +110 -2
- 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).
|
|
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
|
|
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
|
-
"
|
|
371
|
-
"
|
|
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
|
-
|
|
397
|
-
|
|
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(
|
|
543
|
-
|
|
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 {
|
|
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(
|
|
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,
|
|
3
|
-
import {
|
|
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
|
-
|
|
19
|
-
|
|
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
|
|
86
|
-
//
|
|
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({
|
|
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
|
|
104
|
-
.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
270
|
-
//
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
const [
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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?.
|
|
381
|
+
const total = countRow?.total ?? members.length;
|
|
290
382
|
|
|
291
|
-
const memberApIds = members.map((m) => m.
|
|
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.
|
|
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.
|
|
298
|
-
username: formatUsername(m.
|
|
299
|
-
preferred_username:
|
|
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.
|
|
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
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
|
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
|
|
550
|
+
await removeMemberAndBanAtomic(db, community.apId, targetApId);
|
|
423
551
|
|
|
424
552
|
results.push({ ap_id: targetApId, success: true });
|
|
425
553
|
} catch {
|