@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
@@ -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
- // This is the read-time choke point: mutes are read-only everywhere, and not
140
- // every notify WRITE path block-checks, so gating here covers like/repost/
141
- // follow/reply/mention (local AND federated) for both blocks and mutes.
142
- const blockMute = excludeBlockedMutedAuthors(
143
- db,
144
- actorApId,
145
- activities.actorApId,
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 { excludeBlockedMutedAuthors } from "./feed-exclude.ts";
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
- excludeBlockedMutedAuthors(db, actorApId, activities.actorApId),
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
+ }