@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
@@ -18,7 +18,9 @@ import {
18
18
  } from "../../federation-helpers.ts";
19
19
  import { sha256Hex } from "../../lib/delivery/transformers.ts";
20
20
  import { getInstanceActor, loadFederatedCommunity } from "./query-helpers.ts";
21
- import { communityApId, getDomain } from "../../lib/ap-ids.ts";
21
+ import { communityApId } from "../../lib/ap-ids.ts";
22
+ import { isTrustedRemoteActivityId } from "../../lib/remote-activity-id.ts";
23
+ import { isSameActivityPubActor } from "../../lib/activitypub-actor-identity.ts";
22
24
  import type { Activity } from "./inbox-types.ts";
23
25
  import {
24
26
  getActivityObject,
@@ -39,10 +41,11 @@ import {
39
41
  import {
40
42
  fetchAndUpsertActorCache,
41
43
  getInstanceFetchSignerByDb,
44
+ isRemoteActorTombstoned,
42
45
  } from "../../lib/activitypub-actor-cache.ts";
43
46
  import { logger } from "../../lib/logger.ts";
44
47
  import { verifyHttpSignature } from "../../lib/ap-verify.ts";
45
- import { isActorBlocked, isDomainBlocked } from "../../lib/blocklist.ts";
48
+ import { isActorBlockedStrict } from "../../lib/blocklist.ts";
46
49
  import {
47
50
  consumeRateLimitProgrammatic,
48
51
  RateLimitConfigs,
@@ -68,13 +71,13 @@ import {
68
71
  handleUndo,
69
72
  handleUpdate,
70
73
  } from "./handlers/user-inbox-handlers.ts";
74
+ import { internalInboundActivityId } from "./inbound-activity-identity.ts";
71
75
 
72
76
  const log = logger.child({ component: "activitypub.inbox" });
73
77
 
74
78
  type HonoContext = Context<{ Bindings: Env; Variables: Variables }>;
75
79
 
76
80
  const MAX_PAYLOAD_BYTES = 512 * 1024;
77
- const MAX_REMOTE_ACTIVITY_ID_LENGTH = 2_048;
78
81
  const INBOUND_DISPATCH_LEASE_MS = 2 * 60 * 1000;
79
82
  const TEXT_DECODER = new TextDecoder("utf-8", { fatal: true });
80
83
 
@@ -132,26 +135,6 @@ export function signingActorFromKeyId(
132
135
  return keyId.includes("#") ? keyId.split("#")[0] : keyId;
133
136
  }
134
137
 
135
- /**
136
- * Normalize an actor URL for identity comparison: lowercase the host (host names
137
- * are case-insensitive) and drop a single trailing slash + any fragment, leaving
138
- * the (case-sensitive) path intact. Used to compare the signing-key owner with
139
- * the activity actor without rejecting cosmetically-different-but-identical IRIs
140
- * (trailing slash / host case) that conformant peers occasionally emit. Returns
141
- * null for an unparseable URL.
142
- */
143
- function normalizeActorUrl(url: string): string | null {
144
- try {
145
- const u = new URL(url);
146
- u.hash = "";
147
- let normalized = `${u.protocol}//${u.host}${u.pathname}${u.search}`;
148
- if (normalized.endsWith("/")) normalized = normalized.slice(0, -1);
149
- return normalized;
150
- } catch {
151
- return null;
152
- }
153
- }
154
-
155
138
  /**
156
139
  * Returns true when the HTTP-signature signing key does NOT belong to exactly
157
140
  * the activity actor (after URL normalization).
@@ -181,18 +164,7 @@ export function isActorMismatch(
181
164
  actor: string,
182
165
  ): boolean {
183
166
  if (!signingActorUrl) return true;
184
- if (signingActorUrl === actor) return false;
185
-
186
- const normalizedSigner = normalizeActorUrl(signingActorUrl);
187
- const normalizedActor = normalizeActorUrl(actor);
188
- if (
189
- normalizedSigner !== null &&
190
- normalizedActor !== null &&
191
- normalizedSigner === normalizedActor
192
- ) {
193
- return false;
194
- }
195
- return true;
167
+ return !isSameActivityPubActor(signingActorUrl, actor);
196
168
  }
197
169
 
198
170
  type ParsedActivity = {
@@ -208,26 +180,6 @@ type ParsedActivity = {
208
180
  activityObjectId: string | null;
209
181
  };
210
182
 
211
- function isBoundedProtocolActivityId(value: string): boolean {
212
- return (
213
- value.length > 0 &&
214
- value.length <= MAX_REMOTE_ACTIVITY_ID_LENGTH &&
215
- !/[\u0000-\u001f\u007f]/u.test(value)
216
- );
217
- }
218
-
219
- async function internalInboundActivityId(
220
- baseUrl: string,
221
- actor: string,
222
- source: string,
223
- ): Promise<string> {
224
- const actorIdentity = normalizeActorUrl(actor) ?? actor;
225
- return activityApId(
226
- baseUrl,
227
- `inbound-${await sha256Hex(`${actorIdentity}\0${source}`)}`,
228
- );
229
- }
230
-
231
183
  /**
232
184
  * Shared pipeline for both inbox endpoints: size check, signature verification,
233
185
  * JSON parse, field extraction, and actor-mismatch check. Returns either a
@@ -285,42 +237,60 @@ async function verifyAndParseInbox(
285
237
  return c.json({ error: "Invalid JSON" }, 400);
286
238
  }
287
239
 
288
- const actor = typeof activity.actor === "string" ? activity.actor : null;
240
+ const claimedActor =
241
+ typeof activity.actor === "string" ? activity.actor : null;
289
242
  const activityType = typeof activity.type === "string" ? activity.type : null;
290
243
 
291
- if (!actor || !activityType) {
244
+ if (!claimedActor || !activityType) {
292
245
  return c.json({ error: "Invalid activity" }, 400);
293
246
  }
294
247
 
248
+ const signingActor = signingActorFromKeyId(signatureResult.keyId);
249
+ if (isActorMismatch(signingActor, claimedActor)) {
250
+ log.warn("Actor mismatch between activity and signing key", {
251
+ event: "ap.signature.actor_mismatch",
252
+ actor: claimedActor,
253
+ signingActor,
254
+ keyId: signatureResult.keyId,
255
+ });
256
+ return c.json({ error: "Actor mismatch" }, 401);
257
+ }
258
+
259
+ // From this point onward the key owner is the authoritative actor spelling.
260
+ // The actor document fetched during signature verification asserts this exact
261
+ // URL as its id, whereas the envelope may carry an accepted cosmetic variant.
262
+ // Keeping the original body in rawActivityJson preserves protocol evidence;
263
+ // normalizing the dispatched Activity prevents one verified identity from
264
+ // creating separate relationship keys merely by toggling host case or `/`.
265
+ const actor = signingActor!;
266
+ activity.actor = actor;
267
+
295
268
  // A peer controls Activity.id. Never use that unbounded string as our primary
296
269
  // key, queue key, or structured-log identifier. First validate the protocol
297
270
  // id against the signature-bound actor origin, then derive a fixed-size local
298
271
  // id from actor + source. The original envelope remains in rawJson (bounded by
299
272
  // MAX_PAYLOAD_BYTES) for protocol evidence.
300
273
  const rawActivityId = typeof activity.id === "string" ? activity.id : null;
301
- let activityIdTrusted = false;
302
- if (
274
+ const trustedActivityId =
303
275
  rawActivityId !== null &&
304
- isBoundedProtocolActivityId(rawActivityId) &&
305
- !isLocal(rawActivityId, baseUrl)
306
- ) {
307
- try {
308
- activityIdTrusted = getDomain(rawActivityId) === getDomain(actor);
309
- } catch {
310
- activityIdTrusted = false;
311
- }
312
- }
313
- const sourceActivityId =
314
- rawActivityId !== null && activityIdTrusted
276
+ isTrustedRemoteActivityId(rawActivityId, actor, baseUrl)
315
277
  ? rawActivityId
278
+ : null;
279
+ const identitySource =
280
+ trustedActivityId !== null
281
+ ? trustedActivityId
316
282
  : `synthetic:${await sha256Hex(
317
283
  `${actor}\0${activityType}\0${getActivityObjectId(activity) ?? ""}\0${body}`,
318
284
  )}`;
319
285
  const activityId = await internalInboundActivityId(
320
286
  baseUrl,
321
287
  actor,
322
- sourceActivityId,
288
+ identitySource,
323
289
  );
290
+ // Only a safe, actor-owned wire id may cross back out in an Accept/Reject.
291
+ // When the peer omitted or supplied an unsafe id, use our stable retained
292
+ // ledger IRI instead of leaking an invalid `synthetic:<hash>` pseudo-IRI.
293
+ const sourceActivityId = trustedActivityId ?? activityId;
324
294
 
325
295
  // Handlers persist activity ids on interaction/follow edges. Stamp the
326
296
  // canonical internal id before dispatch so no remote string escapes into
@@ -328,28 +298,33 @@ async function verifyAndParseInbox(
328
298
  // source-id lookup in the Undo helpers and therefore still resolve.
329
299
  activity.id = activityId;
330
300
 
331
- const signingActor = signingActorFromKeyId(signatureResult.keyId);
332
- if (isActorMismatch(signingActor, actor)) {
333
- log.warn("Actor mismatch between activity and signing key", {
334
- event: "ap.signature.actor_mismatch",
335
- actor,
336
- signingActor,
337
- keyId: signatureResult.keyId,
338
- });
339
- return c.json({ error: "Actor mismatch" }, 401);
340
- }
341
-
342
301
  // Central federation blocklist gate. Applied once here so every activity
343
302
  // type (Follow / Like / Announce / Undo / content / group inbox / ...) is
344
303
  // covered regardless of which handler dispatches it. Blocked traffic is
345
304
  // silently discarded with a 202 ACK (never 4xx) — a 4xx would make the
346
305
  // sending instance retry on a backoff and keep redelivering blocked
347
- // traffic. The blocklist helpers fail open on a DB read error (see
348
- // lib/blocklist.ts), so a transient DB fault never black-holes federation.
306
+ // traffic. A blocklist read failure propagates as 5xx so the peer retries;
307
+ // acknowledging while moderation authority is unavailable could admit a
308
+ // sender the operator has blocked.
349
309
  if (await isActivityBlocked(c, actor, activityType)) {
350
310
  return c.body(null, 202);
351
311
  }
352
312
 
313
+ // A verified self-Delete is durable identity authority. Signature
314
+ // verification still runs first (so an attacker cannot use an unverified
315
+ // actor spelling as an oracle), then every inbox shape silently discards
316
+ // later traffic before dedup allocation or handler effects. As with an
317
+ // operator block, 202 prevents a peer from retrying traffic that can never
318
+ // become admissible under this actor id.
319
+ if (await isRemoteActorTombstoned(c.get("db"), actor)) {
320
+ log.info("Discarding activity from tombstoned actor", {
321
+ event: "ap.actor_tombstone.discard",
322
+ actor,
323
+ activityType,
324
+ });
325
+ return c.body(null, 202);
326
+ }
327
+
353
328
  return {
354
329
  activity,
355
330
  activityId,
@@ -374,32 +349,14 @@ async function isActivityBlocked(
374
349
  ): Promise<boolean> {
375
350
  const db = c.get("db");
376
351
 
377
- if (await isActorBlocked(db, actor)) {
378
- log.info("Discarding activity from blocked actor", {
352
+ if (await isActorBlockedStrict(db, actor)) {
353
+ log.info("Discarding activity from blocked actor or domain", {
379
354
  event: "ap.blocklist.actor_discard",
380
355
  actor,
381
356
  activityType,
382
357
  });
383
358
  return true;
384
359
  }
385
-
386
- let hostname: string | null = null;
387
- try {
388
- hostname = new URL(actor).hostname;
389
- } catch {
390
- return false;
391
- }
392
-
393
- if (await isDomainBlocked(db, hostname)) {
394
- log.info("Discarding activity from blocked domain", {
395
- event: "ap.blocklist.domain_discard",
396
- actor,
397
- domain: hostname,
398
- activityType,
399
- });
400
- return true;
401
- }
402
-
403
360
  return false;
404
361
  }
405
362
 
@@ -1185,7 +1142,15 @@ async function resolveObjectActorTarget(
1185
1142
  }
1186
1143
  const innerId = getActivityObjectId(activity);
1187
1144
  if (innerId) {
1188
- const follow = await findFollowByActivityId(c.get("db"), innerId);
1145
+ const sourceActor =
1146
+ typeof activity.actor === "string" ? activity.actor : null;
1147
+ const follow = await findFollowByActivityId(
1148
+ c.get("db"),
1149
+ innerId,
1150
+ sourceActor
1151
+ ? { actorApId: sourceActor, localBaseUrl: baseUrl }
1152
+ : undefined,
1153
+ );
1189
1154
  if (follow && isLocal(follow.followingApId, baseUrl)) {
1190
1155
  return {
1191
1156
  scoped: true,
@@ -1318,6 +1283,17 @@ ap.post("/ap/inbox", async (c) => {
1318
1283
  MAX_SHARED_INBOX_FANOUT,
1319
1284
  );
1320
1285
 
1286
+ if (resolution.invalidReason) {
1287
+ log.warn("Shared-inbox activity has invalid addressing", {
1288
+ event: "ap.shared_inbox.addressing_invalid",
1289
+ activityType,
1290
+ actor,
1291
+ reason: resolution.invalidReason,
1292
+ });
1293
+ await commitActivityDispatch(c, claim, PROCESSED_UNDELIVERABLE);
1294
+ return c.json({ error: "Invalid or oversized addressing" }, 422);
1295
+ }
1296
+
1321
1297
  if (resolution.recipients.length === 0) {
1322
1298
  if (resolution.cls === "audience") {
1323
1299
  // The activity addressed a COLLECTION (Public / followers) and nobody
@@ -440,13 +440,14 @@ ap.get(
440
440
  following: showCollections ? actor.followingUrl : undefined,
441
441
  // Advertise sharedInbox so remote servers can deduplicate fan-out
442
442
  // delivery (Mastodon convention). The endpoint accepts signed
443
- // activities just like the per-actor inbox. `rtcSignal` advertises the
444
- // call-signaling ingest so a caller's instance can reach this user
445
- // directly (yurucommu extension; peers that don't recognize it just fall
446
- // back to deriving it from the inbox origin).
443
+ // activities just like the per-actor inbox. `rtcSignal` is a Yurucommu
444
+ // extension and must be advertised only when this exact deployment has a
445
+ // signaling namespace; otherwise peers are sent to a permanent 503.
447
446
  endpoints: {
448
447
  sharedInbox: `${baseUrl}/ap/inbox`,
449
- rtcSignal: `${baseUrl}/ap/rtc/signal`,
448
+ ...(c.env.CALL_SIGNALING
449
+ ? { rtcSignal: `${baseUrl}/ap/rtc/signal` }
450
+ : {}),
450
451
  },
451
452
  publicKey: buildPublicKey(actor.apId, actor.publicKeyPem),
452
453
  discoverable: !actor.isPrivate,
@@ -20,6 +20,8 @@ import {
20
20
  parseLimit,
21
21
  parseOffset,
22
22
  } from "../federation-helpers.ts";
23
+ import { canonicalPersonalModerationActorId } from "../lib/personal-actor-moderation.ts";
24
+ import { operatorActorNotBlockedSql } from "../lib/blocklist.ts";
23
25
 
24
26
  // Hono context with our app's bindings and variables
25
27
  export type AppContext = Context<
@@ -153,7 +155,7 @@ export function formatActorSummary(
153
155
  } {
154
156
  return {
155
157
  ap_id: apId,
156
- username: formatUsername(apId),
158
+ username: formatUsername(apId, info?.preferredUsername),
157
159
  preferred_username: info?.preferredUsername || null,
158
160
  name: info?.name || null,
159
161
  icon_url: info?.iconUrl || null,
@@ -353,6 +355,11 @@ export async function createRelation(
353
355
  targetApId: string,
354
356
  ) => Promise<unknown>,
355
357
  countExisting: (db: Database, actorApIdVal: string) => Promise<number>,
358
+ relationExists: (
359
+ db: Database,
360
+ actorApIdVal: string,
361
+ targetApId: string,
362
+ ) => Promise<boolean>,
356
363
  ): Promise<Response> {
357
364
  const result = requireActor(c);
358
365
  if (result instanceof Response) return result;
@@ -360,17 +367,27 @@ export async function createRelation(
360
367
 
361
368
  const body = await c.req.json<{ ap_id: string }>();
362
369
  if (!body.ap_id) return c.json({ error: "ap_id required" }, 400);
363
- if (body.ap_id === actor.ap_id) {
370
+ const canonicalTargetApId = canonicalPersonalModerationActorId(body.ap_id);
371
+ if (canonicalTargetApId === canonicalPersonalModerationActorId(actor.ap_id)) {
364
372
  return c.json({ error: `Cannot ${verb} yourself` }, 400);
365
373
  }
366
374
 
367
375
  const db = c.get("db");
368
376
  // Bound the per-actor relation set (soft cap; a small concurrent overshoot is
369
- // harmless). onConflictDoNothing means re-blocking an existing target is a
370
- // no-op, so this only rejects genuinely-new rows past the limit.
371
- if ((await countExisting(db, actor.ap_id)) >= MAX_RELATIONS_PER_ACTOR) {
377
+ // harmless). Existing identities remain idempotent at the cap, including a
378
+ // legacy raw spelling that is cosmetically equivalent to the presented ID.
379
+ // Check identity only on the full-cap path so ordinary creates retain the
380
+ // existing one-count + one-upsert query shape.
381
+ if (
382
+ (await countExisting(db, actor.ap_id)) >= MAX_RELATIONS_PER_ACTOR &&
383
+ !(await relationExists(db, actor.ap_id, body.ap_id))
384
+ ) {
372
385
  return c.json({ error: `${verb} limit reached` }, 429);
373
386
  }
387
+ // Retain the exact actor spelling the user acted on. Feed queries expand
388
+ // this one row to raw + canonical identity, so historical content under the
389
+ // presented spelling and future canonical content are both suppressed
390
+ // without storing duplicate moderation rows or consuming the relation quota.
374
391
  await upsert(db, actor.ap_id, body.ap_id);
375
392
 
376
393
  return c.json({ success: true });
@@ -394,9 +411,10 @@ export async function deleteRelation(
394
411
 
395
412
  const body = await c.req.json<{ ap_id: string }>();
396
413
  if (!body.ap_id) return c.json({ error: "ap_id required" }, 400);
414
+ const targetApId = canonicalPersonalModerationActorId(body.ap_id);
397
415
 
398
416
  const db = c.get("db");
399
- await remove(db, actor.ap_id, body.ap_id);
417
+ await remove(db, actor.ap_id, targetApId);
400
418
 
401
419
  return c.json({ success: true });
402
420
  }
@@ -419,8 +437,16 @@ export async function listFollowRelation(
419
437
 
420
438
  const isFollowers = direction === "followers";
421
439
  const whereCondition = isFollowers
422
- ? and(eq(follows.followingApId, apId), eq(follows.status, "accepted"))
423
- : and(eq(follows.followerApId, apId), eq(follows.status, "accepted"));
440
+ ? and(
441
+ eq(follows.followingApId, apId),
442
+ eq(follows.status, "accepted"),
443
+ operatorActorNotBlockedSql(sql`${follows.followerApId}`),
444
+ )
445
+ : and(
446
+ eq(follows.followerApId, apId),
447
+ eq(follows.status, "accepted"),
448
+ operatorActorNotBlockedSql(sql`${follows.followingApId}`),
449
+ );
424
450
 
425
451
  // A private (locked) account's follower/following LIST is owner-only — mirror
426
452
  // the ActivityPub collection gate (canViewPrivateActorCollections), which this