@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
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { and, eq, gt, not, or, sql } from "drizzle-orm";
|
|
2
|
+
import type { SQL } from "drizzle-orm";
|
|
3
|
+
import type { BatchItem } from "drizzle-orm/batch";
|
|
4
|
+
import { actors, blocks, follows } from "../../db/index.ts";
|
|
5
|
+
import type { Database } from "../../db/index.ts";
|
|
6
|
+
import { activityPubActorIdentityMatchesSql } from "./activitypub-actor-identity-sql.ts";
|
|
7
|
+
import { resolveRetainedPersonalBlockTarget } from "./personal-actor-moderation.ts";
|
|
8
|
+
|
|
9
|
+
type BatchStatement = BatchItem<"sqlite">;
|
|
10
|
+
interface BatchableDb {
|
|
11
|
+
batch(
|
|
12
|
+
statements: readonly [BatchStatement, ...BatchStatement[]],
|
|
13
|
+
): Promise<unknown>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function followDirectionIdentityWhere(
|
|
17
|
+
followerApId: string,
|
|
18
|
+
followingApId: string,
|
|
19
|
+
): SQL {
|
|
20
|
+
// Every personal follow edge has at least one exact local endpoint. Narrow on
|
|
21
|
+
// that indexed endpoint, then compare the retained remote endpoint through
|
|
22
|
+
// the complete verified-actor identity set.
|
|
23
|
+
const followingMatches = activityPubActorIdentityMatchesSql(
|
|
24
|
+
sql`
|
|
25
|
+
SELECT ${follows.followingApId}
|
|
26
|
+
FROM ${follows}
|
|
27
|
+
WHERE ${follows.followerApId} = ${followerApId}
|
|
28
|
+
`,
|
|
29
|
+
followingApId,
|
|
30
|
+
);
|
|
31
|
+
const followerMatches = activityPubActorIdentityMatchesSql(
|
|
32
|
+
sql`
|
|
33
|
+
SELECT ${follows.followerApId}
|
|
34
|
+
FROM ${follows}
|
|
35
|
+
WHERE ${follows.followingApId} = ${followingApId}
|
|
36
|
+
`,
|
|
37
|
+
followerApId,
|
|
38
|
+
);
|
|
39
|
+
return or(
|
|
40
|
+
and(
|
|
41
|
+
eq(follows.followerApId, followerApId),
|
|
42
|
+
sql`${follows.followingApId} IN (${followingMatches})`,
|
|
43
|
+
),
|
|
44
|
+
and(
|
|
45
|
+
eq(follows.followingApId, followingApId),
|
|
46
|
+
sql`${follows.followerApId} IN (${followerMatches})`,
|
|
47
|
+
),
|
|
48
|
+
) as SQL;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function actorIdentityWhere(actorApId: string): SQL {
|
|
52
|
+
const retainedActors = activityPubActorIdentityMatchesSql(
|
|
53
|
+
sql`SELECT ${actors.apId} FROM ${actors}`,
|
|
54
|
+
actorApId,
|
|
55
|
+
);
|
|
56
|
+
return sql`${actors.apId} IN (${retainedActors})`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function severFollowDirectionStatements(
|
|
60
|
+
db: Database,
|
|
61
|
+
followerApId: string,
|
|
62
|
+
followingApId: string,
|
|
63
|
+
): [BatchStatement, BatchStatement, BatchStatement] {
|
|
64
|
+
const edgeWhere = followDirectionIdentityWhere(followerApId, followingApId);
|
|
65
|
+
const acceptedEdgeCount = sql<number>`(
|
|
66
|
+
SELECT COUNT(*)
|
|
67
|
+
FROM ${follows}
|
|
68
|
+
WHERE ${edgeWhere} AND ${follows.status} = 'accepted'
|
|
69
|
+
)`;
|
|
70
|
+
return [
|
|
71
|
+
db
|
|
72
|
+
.update(actors)
|
|
73
|
+
.set({
|
|
74
|
+
followingCount: sql`MAX(0, ${actors.followingCount} - ${acceptedEdgeCount})`,
|
|
75
|
+
})
|
|
76
|
+
.where(
|
|
77
|
+
and(actorIdentityWhere(followerApId), gt(actors.followingCount, 0)),
|
|
78
|
+
),
|
|
79
|
+
db
|
|
80
|
+
.update(actors)
|
|
81
|
+
.set({
|
|
82
|
+
followerCount: sql`MAX(0, ${actors.followerCount} - ${acceptedEdgeCount})`,
|
|
83
|
+
})
|
|
84
|
+
.where(
|
|
85
|
+
and(actorIdentityWhere(followingApId), gt(actors.followerCount, 0)),
|
|
86
|
+
),
|
|
87
|
+
db.delete(follows).where(edgeWhere),
|
|
88
|
+
];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Remove every retained spelling of one logical follow direction and reconcile
|
|
93
|
+
* both endpoint counters in the same atomic batch.
|
|
94
|
+
*
|
|
95
|
+
* At least one endpoint must be the exact local actor/group IRI. The other may
|
|
96
|
+
* be any verified cosmetic spelling of the same remote actor. This keeps the
|
|
97
|
+
* query bounded to an indexed local endpoint while preventing a Reject,
|
|
98
|
+
* Remove, or Undo from leaving a second equivalent row with delivery or
|
|
99
|
+
* membership authority.
|
|
100
|
+
*/
|
|
101
|
+
export async function severFollowDirection(
|
|
102
|
+
db: Database,
|
|
103
|
+
followerApId: string,
|
|
104
|
+
followingApId: string,
|
|
105
|
+
): Promise<void> {
|
|
106
|
+
await runBatch(
|
|
107
|
+
db,
|
|
108
|
+
severFollowDirectionStatements(db, followerApId, followingApId),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Converge one logical follow direction to exactly one accepted retained row.
|
|
114
|
+
*
|
|
115
|
+
* Cosmetic remote-actor spellings can coexist in legacy data because the raw
|
|
116
|
+
* pair is the physical primary key. Accept/Add must not accept one row while a
|
|
117
|
+
* second remains pending, nor count two already-accepted spellings as two
|
|
118
|
+
* people. Reconcile each endpoint by the accepted-row delta, delete equivalent
|
|
119
|
+
* duplicates, and transition the retained row in one batch.
|
|
120
|
+
*/
|
|
121
|
+
export async function acceptRetainedFollowDirection(
|
|
122
|
+
db: Database,
|
|
123
|
+
retainedFollowerApId: string,
|
|
124
|
+
retainedFollowingApId: string,
|
|
125
|
+
acceptedAt: string,
|
|
126
|
+
): Promise<void> {
|
|
127
|
+
const edgeWhere = followDirectionIdentityWhere(
|
|
128
|
+
retainedFollowerApId,
|
|
129
|
+
retainedFollowingApId,
|
|
130
|
+
);
|
|
131
|
+
const eligibleContribution = sql<number>`(
|
|
132
|
+
CASE WHEN EXISTS (
|
|
133
|
+
SELECT 1
|
|
134
|
+
FROM ${follows}
|
|
135
|
+
WHERE ${edgeWhere}
|
|
136
|
+
AND ${follows.status} IN ('pending', 'accepted')
|
|
137
|
+
) THEN 1 ELSE 0 END
|
|
138
|
+
)`;
|
|
139
|
+
const acceptedEdgeCount = sql<number>`(
|
|
140
|
+
SELECT COUNT(*)
|
|
141
|
+
FROM ${follows}
|
|
142
|
+
WHERE ${edgeWhere} AND ${follows.status} = 'accepted'
|
|
143
|
+
)`;
|
|
144
|
+
const retainedEdge = and(
|
|
145
|
+
eq(follows.followerApId, retainedFollowerApId),
|
|
146
|
+
eq(follows.followingApId, retainedFollowingApId),
|
|
147
|
+
) as SQL;
|
|
148
|
+
|
|
149
|
+
await runBatch(db, [
|
|
150
|
+
db
|
|
151
|
+
.update(actors)
|
|
152
|
+
.set({
|
|
153
|
+
followingCount: sql`MAX(0, ${actors.followingCount} + ${eligibleContribution} - ${acceptedEdgeCount})`,
|
|
154
|
+
})
|
|
155
|
+
.where(actorIdentityWhere(retainedFollowerApId)),
|
|
156
|
+
db
|
|
157
|
+
.update(actors)
|
|
158
|
+
.set({
|
|
159
|
+
followerCount: sql`MAX(0, ${actors.followerCount} + ${eligibleContribution} - ${acceptedEdgeCount})`,
|
|
160
|
+
})
|
|
161
|
+
.where(actorIdentityWhere(retainedFollowingApId)),
|
|
162
|
+
db.delete(follows).where(and(edgeWhere, not(retainedEdge))),
|
|
163
|
+
db
|
|
164
|
+
.update(follows)
|
|
165
|
+
.set({ status: "accepted", acceptedAt })
|
|
166
|
+
.where(and(retainedEdge, eq(follows.status, "pending"))),
|
|
167
|
+
]);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function runBatch(
|
|
171
|
+
db: Database,
|
|
172
|
+
statements: readonly [BatchStatement, ...BatchStatement[]],
|
|
173
|
+
): Promise<void> {
|
|
174
|
+
await (db as unknown as BatchableDb).batch(statements);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Atomically sever both follow directions and reconcile their denormalized
|
|
179
|
+
* counters. Pending/absent edges are clean no-ops and retries cannot underflow.
|
|
180
|
+
*/
|
|
181
|
+
export async function severFollowPair(
|
|
182
|
+
db: Database,
|
|
183
|
+
leftApId: string,
|
|
184
|
+
rightApId: string,
|
|
185
|
+
): Promise<void> {
|
|
186
|
+
await runBatch(db, [
|
|
187
|
+
...severFollowDirectionStatements(db, leftApId, rightApId),
|
|
188
|
+
...severFollowDirectionStatements(db, rightApId, leftApId),
|
|
189
|
+
]);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Persist a local personal block and sever both follow directions as one D1
|
|
194
|
+
* commit. A storage failure must not leave either authority state half-applied.
|
|
195
|
+
*/
|
|
196
|
+
export async function blockActorAndSeverFollowPair(
|
|
197
|
+
db: Database,
|
|
198
|
+
blockerApId: string,
|
|
199
|
+
blockedApId: string,
|
|
200
|
+
): Promise<void> {
|
|
201
|
+
const retainedBlockTarget = await resolveRetainedPersonalBlockTarget(
|
|
202
|
+
db,
|
|
203
|
+
blockerApId,
|
|
204
|
+
blockedApId,
|
|
205
|
+
);
|
|
206
|
+
await runBatch(db, [
|
|
207
|
+
db
|
|
208
|
+
.insert(blocks)
|
|
209
|
+
.values({
|
|
210
|
+
blockerApId,
|
|
211
|
+
blockedApId: retainedBlockTarget ?? blockedApId,
|
|
212
|
+
})
|
|
213
|
+
.onConflictDoNothing(),
|
|
214
|
+
...severFollowDirectionStatements(db, blockedApId, blockerApId),
|
|
215
|
+
...severFollowDirectionStatements(db, blockerApId, blockedApId),
|
|
216
|
+
]);
|
|
217
|
+
}
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
ne,
|
|
21
21
|
notExists,
|
|
22
22
|
or,
|
|
23
|
+
sql,
|
|
23
24
|
type SQL,
|
|
24
25
|
} from "drizzle-orm";
|
|
25
26
|
|
|
@@ -32,6 +33,7 @@ import {
|
|
|
32
33
|
type Database,
|
|
33
34
|
} from "../../db/index.ts";
|
|
34
35
|
import { excludeBlockedMutedAuthors } from "./feed-exclude.ts";
|
|
36
|
+
import { operatorActorNotBlockedSql } from "./blocklist.ts";
|
|
35
37
|
|
|
36
38
|
/**
|
|
37
39
|
* The activity types that surface as notifications. Any addition must hold for
|
|
@@ -135,15 +137,19 @@ export function notificationEligibilityWhere(
|
|
|
135
137
|
directCondition,
|
|
136
138
|
];
|
|
137
139
|
|
|
138
|
-
// Suppress notifications whose actor the recipient has blocked or muted
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
140
|
+
// Suppress notifications whose actor the recipient has blocked or muted,
|
|
141
|
+
// and identities defederated by the instance operator. This is deliberately
|
|
142
|
+
// a read-time gate shared by list, unread badge, realtime snapshot, and push:
|
|
143
|
+
// retained inbox/activity rows must become inert immediately even when an
|
|
144
|
+
// earlier content purge was partial and will converge on retry.
|
|
145
|
+
conditions.push(operatorActorNotBlockedSql(sql`${activities.actorApId}`));
|
|
146
|
+
|
|
147
|
+
// Personal block/mute suppression remains per recipient.
|
|
148
|
+
// This remains the read-time choke point for every notification path even
|
|
149
|
+
// when a specific inbound write path also suppresses muted interactions, so
|
|
150
|
+
// like/repost/follow/reply/mention stay covered for both local and federated
|
|
151
|
+
// producers.
|
|
152
|
+
const blockMute = excludeBlockedMutedAuthors(actorApId, activities.actorApId);
|
|
147
153
|
if (blockMute) conditions.push(blockMute);
|
|
148
154
|
|
|
149
155
|
return conditions;
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
type DeliveryNotificationPushMessageV1,
|
|
27
27
|
type DeliveryQueueMessageV1,
|
|
28
28
|
} from "./delivery/types.ts";
|
|
29
|
-
import {
|
|
29
|
+
import { excludeModeratedActors } from "./feed-exclude.ts";
|
|
30
30
|
import {
|
|
31
31
|
NOTIFICATION_ACTIVITY_TYPES,
|
|
32
32
|
notificationEligibilityWhere,
|
|
@@ -996,7 +996,7 @@ async function loadPushEvent(
|
|
|
996
996
|
ne(activities.actorApId, actorApId),
|
|
997
997
|
inArray(activities.type, [...NOTIFICATION_ACTIVITY_TYPES]),
|
|
998
998
|
exists(currentCommunityMembership),
|
|
999
|
-
|
|
999
|
+
excludeModeratedActors(actorApId, activities.actorApId),
|
|
1000
1000
|
),
|
|
1001
1001
|
)
|
|
1002
1002
|
.get();
|
|
@@ -23,8 +23,74 @@ export type OidcIdTokenClaims = {
|
|
|
23
23
|
email?: string;
|
|
24
24
|
email_verified?: boolean;
|
|
25
25
|
preferred_username?: string;
|
|
26
|
+
takosumi?: unknown;
|
|
26
27
|
};
|
|
27
28
|
|
|
29
|
+
export type VerifiedTakosumiWorkspaceGrant = {
|
|
30
|
+
workspaceId: string;
|
|
31
|
+
capsuleId: string;
|
|
32
|
+
role: "owner" | "admin" | "member" | "viewer";
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Read the exact Workspace authority Takosumi Accounts placed in a verified
|
|
37
|
+
* ID token. Call this only after `verifyOidcIdToken`: this parser validates the
|
|
38
|
+
* claim shape, while the signature/issuer/audience checks establish who said
|
|
39
|
+
* it. Unknown or partial shapes grant nothing.
|
|
40
|
+
*/
|
|
41
|
+
export function readVerifiedTakosumiWorkspaceGrant(
|
|
42
|
+
claims: OidcIdTokenClaims,
|
|
43
|
+
): VerifiedTakosumiWorkspaceGrant | undefined {
|
|
44
|
+
const value = claims.takosumi;
|
|
45
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
const record = value as Record<string, unknown>;
|
|
49
|
+
if (
|
|
50
|
+
!sameStrings(Object.keys(record).sort(), [
|
|
51
|
+
"capsule_id",
|
|
52
|
+
"role",
|
|
53
|
+
"workspace_id",
|
|
54
|
+
]) ||
|
|
55
|
+
!boundedIdentifier(record.workspace_id) ||
|
|
56
|
+
!boundedIdentifier(record.capsule_id) ||
|
|
57
|
+
!isTakosumiWorkspaceRole(record.role)
|
|
58
|
+
) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
workspaceId: record.workspace_id,
|
|
63
|
+
capsuleId: record.capsule_id,
|
|
64
|
+
role: record.role,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function boundedIdentifier(value: unknown): value is string {
|
|
69
|
+
return (
|
|
70
|
+
typeof value === "string" &&
|
|
71
|
+
value.length > 0 &&
|
|
72
|
+
value.length <= 512 &&
|
|
73
|
+
value.trim() === value &&
|
|
74
|
+
!/\s/u.test(value)
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isTakosumiWorkspaceRole(
|
|
79
|
+
value: unknown,
|
|
80
|
+
): value is VerifiedTakosumiWorkspaceGrant["role"] {
|
|
81
|
+
return (
|
|
82
|
+
typeof value === "string" &&
|
|
83
|
+
["owner", "admin", "member", "viewer"].includes(value)
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function sameStrings(left: readonly string[], right: readonly string[]) {
|
|
88
|
+
return (
|
|
89
|
+
left.length === right.length &&
|
|
90
|
+
left.every((value, index) => value === right[index])
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
28
94
|
type Jwk = JsonWebKey & { kid?: string; alg?: string; use?: string };
|
|
29
95
|
|
|
30
96
|
function b64urlToBytes(segment: string): Uint8Array {
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { and, eq, sql, type SQL } from "drizzle-orm";
|
|
2
|
+
|
|
3
|
+
import type { Database } from "../../db/index.ts";
|
|
4
|
+
import { blocks, mutes } from "../../db/index.ts";
|
|
5
|
+
import { normalizeActivityPubActorId } from "./activitypub-actor-identity.ts";
|
|
6
|
+
import {
|
|
7
|
+
activityPubActorIdentityMatchesSql,
|
|
8
|
+
activityPubActorIdentitySetSql,
|
|
9
|
+
} from "./activitypub-actor-identity-sql.ts";
|
|
10
|
+
|
|
11
|
+
// Historical regression boundary retained for fixtures: mutation compatibility
|
|
12
|
+
// used to inspect only the newest 512 rows. Production mutation queries no
|
|
13
|
+
// longer use this value; they match the complete SQL identity set.
|
|
14
|
+
export const LEGACY_PERSONAL_MODERATION_CANDIDATE_LIMIT = 512;
|
|
15
|
+
|
|
16
|
+
export function canonicalPersonalModerationActorId(actorApId: string): string {
|
|
17
|
+
return normalizeActivityPubActorId(actorApId) ?? actorApId;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function actorIdentitySetContains(
|
|
21
|
+
db: Database,
|
|
22
|
+
actorApId: string,
|
|
23
|
+
rawActorIds: SQL,
|
|
24
|
+
): Promise<boolean> {
|
|
25
|
+
const identitySet = activityPubActorIdentitySetSql(rawActorIds);
|
|
26
|
+
const canonicalActorApId = canonicalPersonalModerationActorId(actorApId);
|
|
27
|
+
const match = (await db.get(sql`
|
|
28
|
+
SELECT CASE
|
|
29
|
+
WHEN ${canonicalActorApId} IN (${identitySet}) THEN 1
|
|
30
|
+
ELSE 0
|
|
31
|
+
END AS matched
|
|
32
|
+
`)) as { matched: number } | undefined;
|
|
33
|
+
return match?.matched === 1;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function resolveRetainedPersonalBlockTarget(
|
|
37
|
+
db: Database,
|
|
38
|
+
blockerApId: string,
|
|
39
|
+
blockedApId: string,
|
|
40
|
+
): Promise<string | null> {
|
|
41
|
+
const exact = await db
|
|
42
|
+
.select({ actorApId: blocks.blockedApId })
|
|
43
|
+
.from(blocks)
|
|
44
|
+
.where(
|
|
45
|
+
and(
|
|
46
|
+
eq(blocks.blockerApId, blockerApId),
|
|
47
|
+
eq(blocks.blockedApId, blockedApId),
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
.get();
|
|
51
|
+
if (exact) return exact.actorApId;
|
|
52
|
+
|
|
53
|
+
const matches = activityPubActorIdentityMatchesSql(
|
|
54
|
+
sql`
|
|
55
|
+
SELECT ${blocks.blockedApId}
|
|
56
|
+
FROM ${blocks}
|
|
57
|
+
WHERE ${blocks.blockerApId} = ${blockerApId}
|
|
58
|
+
`,
|
|
59
|
+
blockedApId,
|
|
60
|
+
);
|
|
61
|
+
// BaseSQLiteDatabase.get() normalizes a missing libsql row as an object and
|
|
62
|
+
// throws before returning. A scalar subquery always yields one row and uses
|
|
63
|
+
// NULL for the normal "no retained spelling" case on both libsql and D1.
|
|
64
|
+
const retained = (await db.get(sql`
|
|
65
|
+
SELECT (${matches}) AS actor_id
|
|
66
|
+
`)) as { actor_id: string | null } | undefined;
|
|
67
|
+
return retained?.actor_id ?? null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function resolveRetainedPersonalMuteTarget(
|
|
71
|
+
db: Database,
|
|
72
|
+
muterApId: string,
|
|
73
|
+
mutedApId: string,
|
|
74
|
+
): Promise<string | null> {
|
|
75
|
+
const exact = await db
|
|
76
|
+
.select({ actorApId: mutes.mutedApId })
|
|
77
|
+
.from(mutes)
|
|
78
|
+
.where(and(eq(mutes.muterApId, muterApId), eq(mutes.mutedApId, mutedApId)))
|
|
79
|
+
.get();
|
|
80
|
+
if (exact) return exact.actorApId;
|
|
81
|
+
|
|
82
|
+
const matches = activityPubActorIdentityMatchesSql(
|
|
83
|
+
sql`
|
|
84
|
+
SELECT ${mutes.mutedApId}
|
|
85
|
+
FROM ${mutes}
|
|
86
|
+
WHERE ${mutes.muterApId} = ${muterApId}
|
|
87
|
+
`,
|
|
88
|
+
mutedApId,
|
|
89
|
+
);
|
|
90
|
+
const retained = (await db.get(sql`
|
|
91
|
+
SELECT (${matches}) AS actor_id
|
|
92
|
+
`)) as { actor_id: string | null } | undefined;
|
|
93
|
+
return retained?.actor_id ?? null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function personalActorIsBlockedBy(
|
|
97
|
+
db: Database,
|
|
98
|
+
blockerApId: string,
|
|
99
|
+
blockedApId: string,
|
|
100
|
+
): Promise<boolean> {
|
|
101
|
+
const exact = await db
|
|
102
|
+
.select({ actorApId: blocks.blockedApId })
|
|
103
|
+
.from(blocks)
|
|
104
|
+
.where(
|
|
105
|
+
and(
|
|
106
|
+
eq(blocks.blockerApId, blockerApId),
|
|
107
|
+
eq(blocks.blockedApId, blockedApId),
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
.get();
|
|
111
|
+
if (exact) return true;
|
|
112
|
+
|
|
113
|
+
return actorIdentitySetContains(
|
|
114
|
+
db,
|
|
115
|
+
blockedApId,
|
|
116
|
+
sql`
|
|
117
|
+
SELECT ${blocks.blockedApId}
|
|
118
|
+
FROM ${blocks}
|
|
119
|
+
WHERE ${blocks.blockerApId} = ${blockerApId}
|
|
120
|
+
`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function personalActorIsSuppressedBy(
|
|
125
|
+
db: Database,
|
|
126
|
+
ownerApId: string,
|
|
127
|
+
actorApId: string,
|
|
128
|
+
): Promise<boolean> {
|
|
129
|
+
const [exactBlock, exactMute] = await Promise.all([
|
|
130
|
+
db
|
|
131
|
+
.select({ actorApId: blocks.blockedApId })
|
|
132
|
+
.from(blocks)
|
|
133
|
+
.where(
|
|
134
|
+
and(
|
|
135
|
+
eq(blocks.blockerApId, ownerApId),
|
|
136
|
+
eq(blocks.blockedApId, actorApId),
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
.get(),
|
|
140
|
+
db
|
|
141
|
+
.select({ actorApId: mutes.mutedApId })
|
|
142
|
+
.from(mutes)
|
|
143
|
+
.where(
|
|
144
|
+
and(eq(mutes.muterApId, ownerApId), eq(mutes.mutedApId, actorApId)),
|
|
145
|
+
)
|
|
146
|
+
.get(),
|
|
147
|
+
]);
|
|
148
|
+
if (exactBlock || exactMute) return true;
|
|
149
|
+
|
|
150
|
+
return actorIdentitySetContains(
|
|
151
|
+
db,
|
|
152
|
+
actorApId,
|
|
153
|
+
sql`
|
|
154
|
+
SELECT ${blocks.blockedApId}
|
|
155
|
+
FROM ${blocks}
|
|
156
|
+
WHERE ${blocks.blockerApId} = ${ownerApId}
|
|
157
|
+
UNION ALL
|
|
158
|
+
SELECT ${mutes.mutedApId}
|
|
159
|
+
FROM ${mutes}
|
|
160
|
+
WHERE ${mutes.muterApId} = ${ownerApId}
|
|
161
|
+
`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Single-user policy: any local owner's block/mute suppresses public ingress. */
|
|
166
|
+
export async function anyOwnerSuppressesInboundActor(
|
|
167
|
+
db: Database,
|
|
168
|
+
actorApId: string,
|
|
169
|
+
): Promise<boolean> {
|
|
170
|
+
const [exactBlock, exactMute] = await Promise.all([
|
|
171
|
+
db
|
|
172
|
+
.select({ actorApId: blocks.blockerApId })
|
|
173
|
+
.from(blocks)
|
|
174
|
+
.where(eq(blocks.blockedApId, actorApId))
|
|
175
|
+
.get(),
|
|
176
|
+
db
|
|
177
|
+
.select({ actorApId: mutes.muterApId })
|
|
178
|
+
.from(mutes)
|
|
179
|
+
.where(eq(mutes.mutedApId, actorApId))
|
|
180
|
+
.get(),
|
|
181
|
+
]);
|
|
182
|
+
if (exactBlock || exactMute) return true;
|
|
183
|
+
|
|
184
|
+
return actorIdentitySetContains(
|
|
185
|
+
db,
|
|
186
|
+
actorApId,
|
|
187
|
+
sql`
|
|
188
|
+
SELECT ${blocks.blockedApId} FROM ${blocks}
|
|
189
|
+
UNION ALL
|
|
190
|
+
SELECT ${mutes.mutedApId} FROM ${mutes}
|
|
191
|
+
`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function deletePersonalActorBlock(
|
|
196
|
+
db: Database,
|
|
197
|
+
blockerApId: string,
|
|
198
|
+
blockedApId: string,
|
|
199
|
+
): Promise<void> {
|
|
200
|
+
const matches = activityPubActorIdentityMatchesSql(
|
|
201
|
+
sql`
|
|
202
|
+
SELECT ${blocks.blockedApId}
|
|
203
|
+
FROM ${blocks}
|
|
204
|
+
WHERE ${blocks.blockerApId} = ${blockerApId}
|
|
205
|
+
`,
|
|
206
|
+
blockedApId,
|
|
207
|
+
);
|
|
208
|
+
await db
|
|
209
|
+
.delete(blocks)
|
|
210
|
+
.where(
|
|
211
|
+
and(
|
|
212
|
+
eq(blocks.blockerApId, blockerApId),
|
|
213
|
+
sql`${blocks.blockedApId} IN (${matches})`,
|
|
214
|
+
),
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export async function deletePersonalActorMute(
|
|
219
|
+
db: Database,
|
|
220
|
+
muterApId: string,
|
|
221
|
+
mutedApId: string,
|
|
222
|
+
): Promise<void> {
|
|
223
|
+
const matches = activityPubActorIdentityMatchesSql(
|
|
224
|
+
sql`
|
|
225
|
+
SELECT ${mutes.mutedApId}
|
|
226
|
+
FROM ${mutes}
|
|
227
|
+
WHERE ${mutes.muterApId} = ${muterApId}
|
|
228
|
+
`,
|
|
229
|
+
mutedApId,
|
|
230
|
+
);
|
|
231
|
+
await db
|
|
232
|
+
.delete(mutes)
|
|
233
|
+
.where(
|
|
234
|
+
and(
|
|
235
|
+
eq(mutes.muterApId, muterApId),
|
|
236
|
+
sql`${mutes.mutedApId} IN (${matches})`,
|
|
237
|
+
),
|
|
238
|
+
);
|
|
239
|
+
}
|