@takosjp/yurucommu-core 3.4.1 → 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 (113) 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 +35 -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/public.ts +16 -0
  45. package/src/backend/retention.ts +115 -0
  46. package/src/backend/routes/account-teardown.ts +422 -156
  47. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +104 -105
  48. package/src/backend/routes/activitypub/handlers/inbound-community-scope.ts +121 -0
  49. package/src/backend/routes/activitypub/handlers/inbound-object-identity.ts +54 -0
  50. package/src/backend/routes/activitypub/handlers/inbound-reply-target.ts +34 -0
  51. package/src/backend/routes/activitypub/handlers/inbound-story-projection.ts +438 -0
  52. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1121 -806
  53. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +118 -153
  54. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +185 -168
  55. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +220 -32
  56. package/src/backend/routes/activitypub/inbound-activity-identity.ts +16 -0
  57. package/src/backend/routes/activitypub/inbound-activity-reference.ts +116 -0
  58. package/src/backend/routes/activitypub/inbound-addressing.ts +87 -0
  59. package/src/backend/routes/activitypub/inbox-addressing.ts +22 -19
  60. package/src/backend/routes/activitypub/inbox-types.ts +14 -2
  61. package/src/backend/routes/activitypub/inbox.ts +81 -105
  62. package/src/backend/routes/activitypub/outbox.ts +0 -0
  63. package/src/backend/routes/activitypub.ts +6 -5
  64. package/src/backend/routes/actors-helpers.ts +34 -8
  65. package/src/backend/routes/actors.ts +333 -169
  66. package/src/backend/routes/auth-helpers.ts +57 -9
  67. package/src/backend/routes/auth.ts +10 -2
  68. package/src/backend/routes/communities/membership-invites.ts +4 -1
  69. package/src/backend/routes/communities/membership-members.ts +201 -73
  70. package/src/backend/routes/communities/membership-requests.ts +141 -76
  71. package/src/backend/routes/communities/membership-shared.ts +228 -21
  72. package/src/backend/routes/communities/messages.ts +9 -2
  73. package/src/backend/routes/communities/routes.ts +48 -13
  74. package/src/backend/routes/dm/contacts.ts +29 -41
  75. package/src/backend/routes/dm/conversations-helpers.ts +9 -1
  76. package/src/backend/routes/dm/messages.ts +36 -42
  77. package/src/backend/routes/dm/read-archive.ts +4 -2
  78. package/src/backend/routes/dm/requests.ts +62 -54
  79. package/src/backend/routes/follow-helpers.ts +200 -60
  80. package/src/backend/routes/follow.ts +146 -140
  81. package/src/backend/routes/media.ts +20 -94
  82. package/src/backend/routes/moderation.ts +72 -4
  83. package/src/backend/routes/notes.ts +3 -4
  84. package/src/backend/routes/notifications.ts +209 -108
  85. package/src/backend/routes/posts/delete-cascade.ts +253 -89
  86. package/src/backend/routes/posts/federation.ts +373 -0
  87. package/src/backend/routes/posts/interactions.ts +160 -184
  88. package/src/backend/routes/posts/like-mutation.ts +240 -0
  89. package/src/backend/routes/posts/post-helpers.ts +112 -162
  90. package/src/backend/routes/posts/queries.ts +150 -54
  91. package/src/backend/routes/posts/routes.ts +169 -329
  92. package/src/backend/routes/posts/transformers.ts +61 -6
  93. package/src/backend/routes/recommendations.ts +37 -44
  94. package/src/backend/routes/rtc/index.ts +26 -6
  95. package/src/backend/routes/search.ts +39 -55
  96. package/src/backend/routes/stories/interactions.ts +22 -6
  97. package/src/backend/routes/stories/query-helpers.ts +28 -41
  98. package/src/backend/routes/stories/routes.ts +130 -115
  99. package/src/backend/routes/takos-tools/dm.ts +17 -17
  100. package/src/backend/routes/takos-tools/posts.ts +189 -106
  101. package/src/backend/routes/takos-tools/search.ts +13 -4
  102. package/src/backend/routes/takos-tools/timeline.ts +35 -27
  103. package/src/backend/routes/takos-tools-response.ts +6 -2
  104. package/src/backend/routes/timeline.ts +5 -5
  105. package/src/backend/runtime/call-signaling-do.ts +35 -4
  106. package/src/backend/runtime/one-time-ticket.ts +116 -0
  107. package/src/backend/runtime/realtime-stream-do.ts +5 -61
  108. package/src/backend/runtime/signaling-hub.ts +36 -3
  109. package/src/backend/server.ts +95 -80
  110. package/src/db/d1-write.ts +67 -43
  111. package/src/db/schema/federation.ts +42 -1
  112. package/src/db/schema/messaging.ts +110 -2
  113. package/src/db/schema.ts +1 -1
@@ -7,15 +7,21 @@
7
7
  * 4xx would cause sender instances to retry on a backoff, wasting their
8
8
  * delivery budget and ours.
9
9
  *
10
- * The helpers return `false` when the underlying read fails so that a
11
- * transient database error never causes federation traffic to be black-holed.
12
- * Each call site logs the failure so that the operator can investigate.
10
+ * The best-effort helpers return `false` when the underlying read fails so UI
11
+ * discovery can degrade without hiding every result. Federation ingress and
12
+ * delivery authority use the strict variants: an unavailable blocklist must
13
+ * make the message retryable, never silently route around an operator block.
13
14
  */
14
15
 
15
- import { eq, inArray } from "drizzle-orm";
16
+ import { eq, inArray, sql, type SQL } from "drizzle-orm";
16
17
 
17
18
  import type { Database } from "../../db/index.ts";
18
- import { blockedActors, blockedDomains } from "../../db/index.ts";
19
+ import { blockedActors, blockedDomains, runBatch } from "../../db/index.ts";
20
+ import { normalizeActivityPubActorId } from "./activitypub-actor-identity.ts";
21
+ import {
22
+ activityPubActorIdentityMatchesSql,
23
+ activityPubActorIdentitySetSql,
24
+ } from "./activitypub-actor-identity-sql.ts";
19
25
  import { logger } from "./logger.ts";
20
26
 
21
27
  const log = logger.child({ component: "blocklist" });
@@ -30,6 +36,140 @@ const log = logger.child({ component: "blocklist" });
30
36
  // defederation bypass we must not allow).
31
37
  const BLOCKLIST_IN_CHUNK = 90;
32
38
 
39
+ type RawSqlDatabase = {
40
+ all?: (query: SQL) => Promise<unknown[]>;
41
+ get?: (query: SQL) => Promise<unknown>;
42
+ };
43
+
44
+ function retainedBlockedActorIdsSql() {
45
+ return sql`SELECT ${blockedActors.actorApId} FROM ${blockedActors}`;
46
+ }
47
+
48
+ function retainedBlockedActorMatchesSql(actorApId: string) {
49
+ return activityPubActorIdentityMatchesSql(
50
+ retainedBlockedActorIdsSql(),
51
+ actorApId,
52
+ );
53
+ }
54
+
55
+ /**
56
+ * Return true when a row's actor expression is outside every blocked domain.
57
+ *
58
+ * The staged, materialized parser mirrors `normalizeDomain`: it folds scheme
59
+ * and authority case, removes an explicit port and one DNS root dot, rejects
60
+ * credentials, and leaves path/query bytes outside the host decision. Keeping
61
+ * each intermediate value named avoids the exponentially-expanded scalar SQL
62
+ * that can overflow SQLite's parser stack when embedded in a paginated query.
63
+ */
64
+ function operatorActorDomainNotBlockedSql(actorApId: SQL): SQL {
65
+ return sql`
66
+ NOT EXISTS (
67
+ WITH
68
+ operator_actor_url(actor_id) AS MATERIALIZED (
69
+ SELECT ${actorApId}
70
+ ),
71
+ operator_actor_fragmentless(actor_id) AS MATERIALIZED (
72
+ SELECT CASE WHEN instr(actor_id, '#') > 0
73
+ THEN substr(actor_id, 1, instr(actor_id, '#') - 1)
74
+ ELSE actor_id
75
+ END
76
+ FROM operator_actor_url
77
+ ),
78
+ operator_actor_split(separator, scheme, tail) AS MATERIALIZED (
79
+ SELECT
80
+ instr(lower(actor_id), '://'),
81
+ substr(lower(actor_id), 1, instr(lower(actor_id), '://') - 1),
82
+ substr(lower(actor_id), instr(lower(actor_id), '://') + 3)
83
+ FROM operator_actor_fragmentless
84
+ ),
85
+ operator_actor_bounds(separator, scheme, tail, boundary) AS MATERIALIZED (
86
+ SELECT
87
+ separator,
88
+ scheme,
89
+ tail,
90
+ CASE
91
+ WHEN instr(tail, '/') = 0 THEN instr(tail, '?')
92
+ WHEN instr(tail, '?') = 0 THEN instr(tail, '/')
93
+ WHEN instr(tail, '/') < instr(tail, '?') THEN instr(tail, '/')
94
+ ELSE instr(tail, '?')
95
+ END
96
+ FROM operator_actor_split
97
+ ),
98
+ operator_actor_authority(separator, scheme, authority) AS MATERIALIZED (
99
+ SELECT
100
+ separator,
101
+ scheme,
102
+ CASE WHEN boundary = 0
103
+ THEN tail
104
+ ELSE substr(tail, 1, boundary - 1)
105
+ END
106
+ FROM operator_actor_bounds
107
+ ),
108
+ operator_actor_host(separator, scheme, authority, host) AS MATERIALIZED (
109
+ SELECT
110
+ separator,
111
+ scheme,
112
+ authority,
113
+ CASE
114
+ WHEN substr(authority, 1, 1) = '[' AND instr(authority, ']') > 1
115
+ THEN substr(authority, 1, instr(authority, ']'))
116
+ WHEN instr(authority, ':') > 0
117
+ THEN substr(authority, 1, instr(authority, ':') - 1)
118
+ ELSE authority
119
+ END
120
+ FROM operator_actor_authority
121
+ ),
122
+ operator_actor_hostname(hostname) AS MATERIALIZED (
123
+ SELECT CASE WHEN substr(host, -1) = '.'
124
+ THEN substr(host, 1, length(host) - 1)
125
+ ELSE host
126
+ END
127
+ FROM operator_actor_host
128
+ WHERE separator > 1
129
+ AND scheme IN ('http', 'https')
130
+ AND length(authority) > 0
131
+ AND instr(authority, '@') = 0
132
+ )
133
+ SELECT 1
134
+ FROM operator_actor_hostname
135
+ INNER JOIN ${blockedDomains}
136
+ ON operator_actor_hostname.hostname = ${blockedDomains.domain}
137
+ OR substr(
138
+ operator_actor_hostname.hostname,
139
+ -length('.' || ${blockedDomains.domain})
140
+ ) = '.' || ${blockedDomains.domain}
141
+ )
142
+ `;
143
+ }
144
+
145
+ /**
146
+ * SQL read predicate for identities that are visible under the operator
147
+ * blocklist. It covers exact and retained cosmetic actor spellings plus exact
148
+ * host/parent-domain blocks. Callers apply it before pagination; block rows and
149
+ * relationship edges remain stored so an unblock stays reversible.
150
+ */
151
+ export function operatorActorNotBlockedSql(actorApId: SQL): SQL {
152
+ const retainedActorMatches = activityPubActorIdentityMatchesSql(
153
+ retainedBlockedActorIdsSql(),
154
+ actorApId,
155
+ );
156
+
157
+ return sql`
158
+ NOT EXISTS (${retainedActorMatches})
159
+ AND ${operatorActorDomainNotBlockedSql(actorApId)}
160
+ `;
161
+ }
162
+
163
+ function retainedBlockedActorKeySql(actorApId: string) {
164
+ const matches = retainedBlockedActorMatchesSql(actorApId);
165
+ return sql`
166
+ SELECT actor_id
167
+ FROM (${matches}) AS retained_blocked_actor_matches
168
+ ORDER BY actor_id
169
+ LIMIT 1
170
+ `;
171
+ }
172
+
33
173
  /**
34
174
  * Normalise an actor AP-ID hostname for blocklist lookups: lowercase and
35
175
  * strip any trailing dot (DNS root form). Returns `null` when the input
@@ -73,25 +213,37 @@ export async function isDomainBlocked(
73
213
  db: Database,
74
214
  hostnameOrUrl: string,
75
215
  ): Promise<boolean> {
76
- const domain = normalizeDomain(hostnameOrUrl);
77
- if (!domain) return false;
78
-
79
216
  try {
80
- const row = await db.query.blockedDomains.findFirst({
81
- where: inArray(blockedDomains.domain, domainSuffixCandidates(domain)),
82
- columns: { domain: true },
83
- });
84
- return !!row;
217
+ return await isDomainBlockedStrict(db, hostnameOrUrl);
85
218
  } catch (err) {
219
+ const domain = normalizeDomain(hostnameOrUrl);
86
220
  log.warn("blocklist.isDomainBlocked failed", {
87
221
  event: "blocklist.domain_lookup_failed",
88
- domain,
222
+ domain: domain ?? hostnameOrUrl,
89
223
  error: err,
90
224
  });
91
225
  return false;
92
226
  }
93
227
  }
94
228
 
229
+ /**
230
+ * Authority-bearing domain lookup. Unlike {@link isDomainBlocked}, a database
231
+ * read failure is propagated so the caller can retry instead of routing around
232
+ * an operator block whose state could not be read.
233
+ */
234
+ export async function isDomainBlockedStrict(
235
+ db: Database,
236
+ hostnameOrUrl: string,
237
+ ): Promise<boolean> {
238
+ const domain = normalizeDomain(hostnameOrUrl);
239
+ if (!domain) return false;
240
+ const row = await db.query.blockedDomains.findFirst({
241
+ where: inArray(blockedDomains.domain, domainSuffixCandidates(domain)),
242
+ columns: { domain: true },
243
+ });
244
+ return !!row;
245
+ }
246
+
95
247
  /**
96
248
  * Returns `true` when the operator has blocked the given actor AP-ID, or
97
249
  * (transitively) when the actor's hostname is blocked.
@@ -100,16 +252,8 @@ export async function isActorBlocked(
100
252
  db: Database,
101
253
  actorApId: string,
102
254
  ): Promise<boolean> {
103
- if (typeof actorApId !== "string" || actorApId.length === 0) {
104
- return false;
105
- }
106
-
107
255
  try {
108
- const row = await db.query.blockedActors.findFirst({
109
- where: eq(blockedActors.actorApId, actorApId),
110
- columns: { actorApId: true },
111
- });
112
- if (row) return true;
256
+ return await isActorBlockedStrict(db, actorApId);
113
257
  } catch (err) {
114
258
  log.warn("blocklist.isActorBlocked failed", {
115
259
  event: "blocklist.actor_lookup_failed",
@@ -118,23 +262,56 @@ export async function isActorBlocked(
118
262
  });
119
263
  return false;
120
264
  }
265
+ }
266
+
267
+ /**
268
+ * Authority-bearing actor/domain lookup. Database failures propagate so
269
+ * ingress or delivery work stays retryable and no possibly-blocked peer is
270
+ * admitted merely because moderation authority was temporarily unavailable.
271
+ */
272
+ export async function isActorBlockedStrict(
273
+ db: Database,
274
+ actorApId: string,
275
+ ): Promise<boolean> {
276
+ if (typeof actorApId !== "string" || actorApId.length === 0) {
277
+ return false;
278
+ }
279
+
280
+ const row = await db.query.blockedActors.findFirst({
281
+ where: eq(blockedActors.actorApId, actorApId),
282
+ columns: { actorApId: true },
283
+ });
284
+ if (row) return true;
285
+
286
+ // Operator rows written before the verified-key-owner invariant may carry
287
+ // an accepted cosmetic spelling. Match the complete retained set in SQL:
288
+ // a fixed prefix silently turned an older block into fail-open ingress.
289
+ const rawDb = db as unknown as RawSqlDatabase;
290
+ if (typeof rawDb.get === "function") {
291
+ const matches = retainedBlockedActorMatchesSql(actorApId);
292
+ const retained = (await rawDb.get(sql`
293
+ SELECT (${matches}) AS actor_id
294
+ `)) as { actor_id?: string | null } | undefined;
295
+ if (retained?.actor_id) return true;
296
+ }
121
297
 
122
298
  const domain = normalizeDomain(actorApId);
123
299
  if (!domain) return false;
124
- return await isDomainBlocked(db, domain);
300
+ return await isDomainBlockedStrict(db, domain);
125
301
  }
126
302
 
127
303
  /**
128
304
  * Batched blocklist filter for a list of recipient actor AP-IDs: returns the
129
305
  * SUBSET that is blocked (by actor OR transitively by hostname) using exactly
130
306
  * two queries (blocked_actors + blocked_domains) instead of two-per-recipient.
131
- * Replaces an O(recipients) serial `isActorBlocked` loop on the delivery
132
- * fan-out hot path. Fail-open like the singular helpers: a read error yields an
133
- * empty blocked set so a transient DB error never black-holes federation.
307
+ * Replaces an O(recipients) serial `isActorBlocked` loop. The default remains
308
+ * best-effort for UI discovery; delivery authority uses the strict wrapper
309
+ * below so an unreadable blocklist cannot become a defederation bypass.
134
310
  */
135
311
  export async function filterBlockedActorApIds(
136
312
  db: Database,
137
313
  actorApIds: string[],
314
+ options: { readonly strict?: boolean } = {},
138
315
  ): Promise<Set<string>> {
139
316
  const blocked = new Set<string>();
140
317
  const uniqueIds = [...new Set(actorApIds.filter((id) => id.length > 0))];
@@ -143,9 +320,8 @@ export async function filterBlockedActorApIds(
143
320
  try {
144
321
  // Chunk the IN(...) lookups so a very large recipient set (e.g. a big
145
322
  // community fan-out, tens of thousands of unique actors/domains) cannot
146
- // exceed SQLite's bound-parameter ceiling and throw which, under the
147
- // fail-open catch below, would silently DISABLE the operator blocklist for
148
- // that whole fan-out (a defederation bypass).
323
+ // exceed SQLite's bound-parameter ceiling. Strict delivery callers retry
324
+ // such failures; best-effort discovery callers degrade to an empty set.
149
325
  const blockedActorSet = new Set<string>();
150
326
  for (let i = 0; i < uniqueIds.length; i += BLOCKLIST_IN_CHUNK) {
151
327
  const rows = await db
@@ -159,6 +335,43 @@ export async function filterBlockedActorApIds(
159
335
  );
160
336
  for (const r of rows) blockedActorSet.add(r.actorApId);
161
337
  }
338
+ // Resolve legacy cosmetic spellings only against the requested identities.
339
+ // Each chunk stays below D1's parameter ceiling; the retained operator set
340
+ // is complete, and path/query bytes remain case-sensitive in the shared SQL
341
+ // identity expansion.
342
+ const retainedActorIdentities = new Set<string>();
343
+ const requestedIdentities = [
344
+ ...new Set(
345
+ uniqueIds
346
+ .map((id) => normalizeActivityPubActorId(id))
347
+ .filter((identity): identity is string => identity !== null),
348
+ ),
349
+ ];
350
+ const rawDb = db as unknown as RawSqlDatabase;
351
+ if (typeof rawDb.all === "function") {
352
+ for (let i = 0; i < requestedIdentities.length; i += BLOCKLIST_IN_CHUNK) {
353
+ const requestedChunk = requestedIdentities.slice(
354
+ i,
355
+ i + BLOCKLIST_IN_CHUNK,
356
+ );
357
+ const retainedIdentitySet = activityPubActorIdentitySetSql(
358
+ retainedBlockedActorIdsSql(),
359
+ );
360
+ const rows = (await rawDb.all(sql`
361
+ SELECT actor_id
362
+ FROM (${retainedIdentitySet}) AS retained_actor_identities
363
+ WHERE actor_id IN (${sql.join(
364
+ requestedChunk.map((identity) => sql`${identity}`),
365
+ sql`, `,
366
+ )})
367
+ `)) as Array<{ actor_id?: string }>;
368
+ for (const row of rows) {
369
+ if (typeof row.actor_id === "string") {
370
+ retainedActorIdentities.add(row.actor_id);
371
+ }
372
+ }
373
+ }
374
+ }
162
375
 
163
376
  // Expand each actor's hostname to its parent-domain candidates so a domain
164
377
  // block covers subdomains (see domainSuffixCandidates) — querying the union
@@ -190,8 +403,11 @@ export async function filterBlockedActorApIds(
190
403
 
191
404
  for (const id of uniqueIds) {
192
405
  const candidates = idToCandidates.get(id) ?? [];
406
+ const normalizedActorId = normalizeActivityPubActorId(id);
193
407
  if (
194
408
  blockedActorSet.has(id) ||
409
+ (normalizedActorId !== null &&
410
+ retainedActorIdentities.has(normalizedActorId)) ||
195
411
  candidates.some((c) => blockedDomainSet.has(c))
196
412
  ) {
197
413
  blocked.add(id);
@@ -202,11 +418,20 @@ export async function filterBlockedActorApIds(
202
418
  event: "blocklist.batch_lookup_failed",
203
419
  error: err,
204
420
  });
205
- return new Set(); // fail-open
421
+ if (options.strict) throw err;
422
+ return new Set();
206
423
  }
207
424
  return blocked;
208
425
  }
209
426
 
427
+ /** Strict batch lookup for ingress/delivery authority. */
428
+ export async function filterBlockedActorApIdsStrict(
429
+ db: Database,
430
+ actorApIds: string[],
431
+ ): Promise<Set<string>> {
432
+ return await filterBlockedActorApIds(db, actorApIds, { strict: true });
433
+ }
434
+
210
435
  /**
211
436
  * Insert (or update) a domain blocklist entry. Idempotent: re-blocking the
212
437
  * same domain refreshes the recorded reason but keeps the original
@@ -258,13 +483,24 @@ export async function blockActor(
258
483
  if (typeof actorApId !== "string" || actorApId.length === 0) {
259
484
  throw new Error("blocklist.blockActor: actorApId is required");
260
485
  }
261
- await db
262
- .insert(blockedActors)
263
- .values({ actorApId, reason })
264
- .onConflictDoUpdate({
265
- target: blockedActors.actorApId,
266
- set: { reason },
267
- });
486
+ const retainedKey = sql`COALESCE((${retainedBlockedActorKeySql(actorApId)}), ${actorApId})`;
487
+ const equivalentRows = retainedBlockedActorMatchesSql(actorApId);
488
+ await runBatch(db, [
489
+ db
490
+ .insert(blockedActors)
491
+ .values({ actorApId: retainedKey, reason })
492
+ .onConflictDoUpdate({
493
+ target: blockedActors.actorApId,
494
+ set: { reason },
495
+ }),
496
+ db.delete(blockedActors).where(sql`
497
+ ${blockedActors.actorApId} IN (${equivalentRows})
498
+ AND ${blockedActors.actorApId} <> COALESCE(
499
+ (${retainedBlockedActorKeySql(actorApId)}),
500
+ ${actorApId}
501
+ )
502
+ `),
503
+ ]);
268
504
  }
269
505
 
270
506
  /**
@@ -275,5 +511,8 @@ export async function unblockActor(
275
511
  actorApId: string,
276
512
  ): Promise<void> {
277
513
  if (typeof actorApId !== "string" || actorApId.length === 0) return;
278
- await db.delete(blockedActors).where(eq(blockedActors.actorApId, actorApId));
514
+ const equivalentRows = retainedBlockedActorMatchesSql(actorApId);
515
+ await db
516
+ .delete(blockedActors)
517
+ .where(sql`${blockedActors.actorApId} IN (${equivalentRows})`);
279
518
  }
@@ -7,11 +7,11 @@
7
7
  * what keeps them out of the public / home / following feeds (which filter on
8
8
  * `audienceJson = "[]"`).
9
9
  *
10
- * For a PRIVATE community this is not enough: a single-object fetch (GET a post,
11
- * a reply, or an `/ap/objects/:id`) bypasses the audience filter entirely, so a
12
- * "public"-visibility community post would leak to any caller. This module
13
- * centralizes the membership gate those single-object paths must apply on top of
14
- * the normal visibility check.
10
+ * For a PRIVATE or soft-deleted community this is not enough: a single-object
11
+ * fetch (GET a post, a reply, or an `/ap/objects/:id`) bypasses the audience
12
+ * filter entirely, so a "public"-visibility community post would leak to any
13
+ * caller. This module centralizes the lifecycle + membership gate those
14
+ * single-object paths must apply on top of the normal visibility check.
15
15
  *
16
16
  * Membership model: `community_members` has no status column — the presence of a
17
17
  * row IS the acceptance (this mirrors `resolveCommunityRead` in routes/timeline.ts).
@@ -96,10 +96,11 @@ function communityApIdsFor(obj: CommunityGateObject): string[] {
96
96
  /**
97
97
  * Single-object community read-gate.
98
98
  *
99
- * If `obj` is addressed to a community whose `visibility` is "private", this
99
+ * If `obj` is addressed to a soft-deleted community, this always returns false.
100
+ * If it is addressed to a live community whose `visibility` is "private", this
100
101
  * returns `true` only when `viewerApId` is an accepted member (a row in
101
- * `community_members`) of that community; otherwise it returns `true` and leaves
102
- * the normal public/followers/direct visibility check to the caller.
102
+ * `community_members`) of that community; otherwise it returns `true` and
103
+ * leaves the normal public/followers/direct visibility check to the caller.
103
104
  *
104
105
  * An anonymous viewer (`viewerApId` null/undefined) against a private community
105
106
  * always returns `false`. This NEVER widens access: a non-community or
@@ -114,17 +115,23 @@ export async function canViewerReadObject(
114
115
  const communityIds = communityApIdsFor(obj);
115
116
  if (communityIds.length === 0) return true;
116
117
 
117
- // Only PRIVATE communities gate single-object reads. Resolve which (if any)
118
- // of the addressed communities are private in one batched query.
119
- const privateRows = await db
120
- .select({ apId: communities.apId })
118
+ // Resolve local lifecycle + visibility in one query. Unknown IDs are left to
119
+ // the caller's ordinary visibility rules (they may name a remote audience),
120
+ // while any known soft-deleted local community fails closed.
121
+ const communityRows = await db
122
+ .select({
123
+ apId: communities.apId,
124
+ visibility: communities.visibility,
125
+ deletedAt: communities.deletedAt,
126
+ })
121
127
  .from(communities)
122
- .where(
123
- and(
124
- inArray(communities.apId, communityIds),
125
- inArray(communities.visibility, MEMBERSHIP_REQUIRED_VISIBILITIES),
126
- ),
127
- );
128
+ .where(inArray(communities.apId, communityIds));
129
+
130
+ if (communityRows.some((row) => row.deletedAt !== null)) return false;
131
+
132
+ const privateRows = communityRows.filter((row) =>
133
+ communityRequiresMembership(row.visibility),
134
+ );
128
135
 
129
136
  if (privateRows.length === 0) return true;
130
137
 
@@ -154,9 +161,10 @@ export async function canViewerReadObject(
154
161
  * Returns the set of `apId`s that PASS the community read-gate, in TWO queries
155
162
  * total (private-community lookup + viewer-membership lookup) instead of the
156
163
  * 1-2 queries PER object the per-row form costs. The per-object semantics are
157
- * identical: an object passes unless it is addressed to a private community the
158
- * viewer is not a member of (an anonymous viewer never satisfies a private
159
- * community). Objects with no community audience always pass.
164
+ * identical: an object fails when it is addressed to a deleted community, and
165
+ * otherwise passes unless it is addressed to a private community the viewer is
166
+ * not a member of (an anonymous viewer never satisfies a private community).
167
+ * Objects with no community audience always pass.
160
168
  *
161
169
  * Both IN(...) lookups are chunked to stay under D1's 100-bound-parameter cap,
162
170
  * since a page can address up to ~90 distinct communities.
@@ -183,19 +191,24 @@ export async function communityReadableApIds<
183
191
  return readable;
184
192
  }
185
193
 
186
- // Which addressed communities are private? (only private communities gate)
194
+ // Resolve local lifecycle + visibility. Unknown IDs may be remote audiences
195
+ // and remain governed by the caller's ordinary visibility rules.
187
196
  const privateSet = new Set<string>();
197
+ const deletedSet = new Set<string>();
188
198
  for (const chunk of chunkForInClause([...unionIds])) {
189
199
  const rows = await db
190
- .select({ apId: communities.apId })
200
+ .select({
201
+ apId: communities.apId,
202
+ visibility: communities.visibility,
203
+ deletedAt: communities.deletedAt,
204
+ })
191
205
  .from(communities)
192
- .where(
193
- and(
194
- inArray(communities.apId, chunk),
195
- inArray(communities.visibility, MEMBERSHIP_REQUIRED_VISIBILITIES),
196
- ),
197
- );
198
- for (const r of rows) privateSet.add(r.apId);
206
+ .where(inArray(communities.apId, chunk));
207
+ for (const row of rows) {
208
+ if (row.deletedAt !== null) deletedSet.add(row.apId);
209
+ else if (communityRequiresMembership(row.visibility))
210
+ privateSet.add(row.apId);
211
+ }
199
212
  }
200
213
 
201
214
  // Which of those private communities is the viewer a member of?
@@ -216,9 +229,10 @@ export async function communityReadableApIds<
216
229
  }
217
230
 
218
231
  for (const o of objs) {
219
- const privateForObj = (perObjectCommunityIds.get(o.apId) ?? []).filter(
220
- (id) => privateSet.has(id),
221
- );
232
+ const communityIds = perObjectCommunityIds.get(o.apId) ?? [];
233
+ if (communityIds.some((id) => deletedSet.has(id))) continue;
234
+
235
+ const privateForObj = communityIds.filter((id) => privateSet.has(id));
222
236
  if (privateForObj.length === 0) {
223
237
  readable.add(o.apId); // not addressed to any private community
224
238
  } else if (privateForObj.some((id) => viewerMemberSet.has(id))) {