@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,179 @@
1
+ import { sql, type SQL } from "drizzle-orm";
2
+
3
+ function expandedActorIdentitiesSql(
4
+ rawActorIds: SQL,
5
+ targetActorId: SQL | undefined,
6
+ finalSelect: SQL,
7
+ ): SQL {
8
+ const targetValue = targetActorId ?? sql`NULL`;
9
+ return sql`
10
+ WITH
11
+ actor_identity_candidates(actor_id) AS MATERIALIZED (
12
+ ${rawActorIds}
13
+ ),
14
+ actor_identity_target(actor_id) AS MATERIALIZED (
15
+ SELECT ${targetValue}
16
+ ),
17
+ actor_identity_raw(source, raw_actor_id, actor_id) AS MATERIALIZED (
18
+ SELECT 0, actor_id, actor_id
19
+ FROM actor_identity_candidates
20
+ UNION ALL
21
+ SELECT 1, actor_id, actor_id
22
+ FROM actor_identity_target
23
+ WHERE actor_id IS NOT NULL
24
+ ),
25
+ actor_identity_fragmentless(source, raw_actor_id, actor_id) AS MATERIALIZED (
26
+ SELECT
27
+ source,
28
+ raw_actor_id,
29
+ CASE WHEN instr(actor_id, '#') > 0
30
+ THEN substr(actor_id, 1, instr(actor_id, '#') - 1)
31
+ ELSE actor_id
32
+ END
33
+ FROM actor_identity_raw
34
+ ),
35
+ actor_identity_clean(source, raw_actor_id, actor_id) AS MATERIALIZED (
36
+ SELECT
37
+ source,
38
+ raw_actor_id,
39
+ CASE WHEN substr(actor_id, -1) = '/'
40
+ THEN substr(actor_id, 1, length(actor_id) - 1)
41
+ ELSE actor_id
42
+ END
43
+ FROM actor_identity_fragmentless
44
+ ),
45
+ actor_identity_split(
46
+ source, raw_actor_id, actor_id, separator, tail
47
+ ) AS MATERIALIZED (
48
+ SELECT
49
+ source,
50
+ raw_actor_id,
51
+ actor_id,
52
+ instr(actor_id, '://'),
53
+ substr(actor_id, instr(actor_id, '://') + 3)
54
+ FROM actor_identity_clean
55
+ ),
56
+ actor_identity_bounds(
57
+ source, raw_actor_id, actor_id, separator, tail, boundary
58
+ ) AS MATERIALIZED (
59
+ SELECT
60
+ source,
61
+ raw_actor_id,
62
+ actor_id,
63
+ separator,
64
+ tail,
65
+ CASE
66
+ WHEN instr(tail, '/') = 0 THEN instr(tail, '?')
67
+ WHEN instr(tail, '?') = 0 THEN instr(tail, '/')
68
+ WHEN instr(tail, '/') < instr(tail, '?') THEN instr(tail, '/')
69
+ ELSE instr(tail, '?')
70
+ END
71
+ FROM actor_identity_split
72
+ ),
73
+ actor_identity_components(
74
+ source, raw_actor_id, separator, scheme, authority, path_query
75
+ ) AS MATERIALIZED (
76
+ SELECT
77
+ source,
78
+ raw_actor_id,
79
+ separator,
80
+ lower(substr(actor_id, 1, separator - 1)),
81
+ CASE WHEN boundary = 0
82
+ THEN tail
83
+ ELSE substr(tail, 1, boundary - 1)
84
+ END,
85
+ CASE
86
+ WHEN boundary = 0 THEN ''
87
+ WHEN substr(tail, boundary, 1) = '?'
88
+ THEN '/' || substr(tail, boundary)
89
+ ELSE substr(tail, boundary)
90
+ END
91
+ FROM actor_identity_bounds
92
+ ),
93
+ actor_identity_normalized(
94
+ source, raw_actor_id, separator, scheme, authority, path_query
95
+ ) AS MATERIALIZED (
96
+ SELECT
97
+ source,
98
+ raw_actor_id,
99
+ separator,
100
+ scheme,
101
+ CASE
102
+ WHEN scheme = 'https' AND substr(lower(authority), -4) = ':443'
103
+ THEN substr(lower(authority), 1, length(authority) - 4)
104
+ WHEN scheme = 'http' AND substr(lower(authority), -3) = ':80'
105
+ THEN substr(lower(authority), 1, length(authority) - 3)
106
+ ELSE lower(authority)
107
+ END,
108
+ path_query
109
+ FROM actor_identity_components
110
+ WHERE instr(authority, '@') = 0
111
+ ),
112
+ actor_identity_values(source, raw_actor_id, actor_id) AS MATERIALIZED (
113
+ SELECT source, raw_actor_id, actor_id
114
+ FROM actor_identity_raw
115
+ UNION
116
+ SELECT
117
+ source,
118
+ raw_actor_id,
119
+ scheme || '://' || authority || path_query
120
+ FROM actor_identity_normalized
121
+ WHERE separator > 1
122
+ AND scheme IN ('http', 'https')
123
+ AND length(authority) > 0
124
+ )
125
+ ${finalSelect}
126
+ `;
127
+ }
128
+
129
+ /**
130
+ * Expand an uncorrelated one-column actor-ID query into its raw spellings plus
131
+ * the canonical HTTP(S) spellings accepted by the signature boundary.
132
+ *
133
+ * Feed callers compare their actor column to this set with `NOT IN`. The CTE is
134
+ * independent of the outer feed row, so SQLite computes the small personal
135
+ * moderation set once instead of reparsing actor URLs for every post or
136
+ * notification. Raw IDs stay in the result for malformed legacy values and
137
+ * content retained under the exact historical spelling. The canonical branch
138
+ * preserves path/query bytes while folding only scheme/authority case,
139
+ * default ports, fragments, and one trailing slash.
140
+ *
141
+ * Keep the parsing stages MATERIALIZED. Letting SQLite flatten this expression
142
+ * chain causes workerd's D1 engine to exhaust memory even for a tiny relation
143
+ * set; explicit materialization also keeps runtime bounded as the set grows.
144
+ */
145
+ export function activityPubActorIdentitySetSql(rawActorIds: SQL): SQL {
146
+ return expandedActorIdentitiesSql(
147
+ rawActorIds,
148
+ undefined,
149
+ sql`
150
+ SELECT DISTINCT actor_id
151
+ FROM actor_identity_values
152
+ WHERE source = 0
153
+ `,
154
+ );
155
+ }
156
+
157
+ /**
158
+ * Return the retained raw spellings from `rawActorIds` that identify
159
+ * `targetActorId`. This is the mutation counterpart to the identity-set read
160
+ * predicate: callers can retain an existing primary-key spelling or delete all
161
+ * historical spellings without loading/scanning the whole relation set in JS.
162
+ */
163
+ export function activityPubActorIdentityMatchesSql(
164
+ rawActorIds: SQL,
165
+ targetActorId: string | SQL,
166
+ ): SQL {
167
+ return expandedActorIdentitiesSql(
168
+ rawActorIds,
169
+ typeof targetActorId === "string" ? sql`${targetActorId}` : targetActorId,
170
+ sql`
171
+ SELECT DISTINCT candidate.raw_actor_id AS actor_id
172
+ FROM actor_identity_values AS candidate
173
+ INNER JOIN actor_identity_values AS target
174
+ ON target.source = 1
175
+ AND target.actor_id = candidate.actor_id
176
+ WHERE candidate.source = 0
177
+ `,
178
+ );
179
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Normalize the deliberately-small set of cosmetic actor-IRI differences that
3
+ * the HTTP-signature boundary accepts.
4
+ *
5
+ * URL parsing lowercases the scheme/host and removes default ports. Fragments
6
+ * never identify an ActivityPub actor, and a single trailing slash is treated
7
+ * as cosmetic. Path case, query strings, and every other component remain
8
+ * identity-significant so sibling actors on one origin are never conflated.
9
+ */
10
+ export function normalizeActivityPubActorId(value: string): string | null {
11
+ try {
12
+ const url = new URL(value);
13
+ url.hash = "";
14
+ let normalized = `${url.protocol}//${url.host}${url.pathname}${url.search}`;
15
+ if (normalized.endsWith("/")) normalized = normalized.slice(0, -1);
16
+ return normalized;
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Compare two actor IRIs using exactly the equivalence accepted when binding
24
+ * an Activity actor to its verified HTTP-signature key owner.
25
+ */
26
+ export function isSameActivityPubActor(
27
+ left: string | null | undefined,
28
+ right: string | null | undefined,
29
+ ): boolean {
30
+ if (!left || !right) return false;
31
+ if (left === right) return true;
32
+ const normalizedLeft = normalizeActivityPubActorId(left);
33
+ const normalizedRight = normalizeActivityPubActorId(right);
34
+ return (
35
+ normalizedLeft !== null &&
36
+ normalizedRight !== null &&
37
+ normalizedLeft === normalizedRight
38
+ );
39
+ }
@@ -11,8 +11,11 @@
11
11
  * - ActivityPub is lenient about extension fields; parsers accept unknown
12
12
  * extra fields and copy through only the keys we care about.
13
13
  * - Required-but-wrong-type fields fail closed.
14
- * - Optional fields that are present but malformed are dropped silently
15
- * (treated as absent) rather than rejecting the whole document.
14
+ * - Optional display fields that are present but malformed are dropped
15
+ * silently (treated as absent) rather than rejecting the whole document.
16
+ * - Optional authority fields whose presence changes behavior may be preserved
17
+ * as `unknown` so their owning handler can reject them instead of confusing a
18
+ * malformed value with omission.
16
19
  */
17
20
 
18
21
  /** Error thrown when a remote ActivityPub document violates our contract. */
@@ -204,9 +207,13 @@ export interface ActivityObjectDocument {
204
207
  id?: string;
205
208
  type?: string | string[];
206
209
  object?: string;
207
- inReplyTo?: string;
210
+ /** Preserved raw so malformed explicit reply edges cannot become roots. */
211
+ inReplyTo?: unknown;
208
212
  to?: string[];
209
213
  cc?: string[];
214
+ bto?: string[];
215
+ bcc?: string[];
216
+ audience?: string[];
210
217
  conversation?: string;
211
218
  content?: string;
212
219
  summary?: string | null;
@@ -228,8 +235,17 @@ export interface ActivityDocument {
228
235
  type?: string;
229
236
  actor?: string;
230
237
  object?: string | ActivityObjectDocument;
238
+ /** Bounded IRI projection of scalar/embedded/array-valued AS2 `object`. */
239
+ objectIds?: string[];
231
240
  target?: string | ActivityObjectDocument;
241
+ /** Envelope-level Flag reason. */
242
+ content?: string;
232
243
  room?: string;
244
+ to?: string[];
245
+ cc?: string[];
246
+ bto?: string[];
247
+ bcc?: string[];
248
+ audience?: string[];
233
249
  }
234
250
 
235
251
  function parseActivityObjectFields(
@@ -240,9 +256,15 @@ function parseActivityObjectFields(
240
256
  id: getString(record, "id"),
241
257
  type: getStringOrStringArray(record, "type"),
242
258
  object: getString(record, "object"),
243
- inReplyTo: getString(record, "inReplyTo"),
259
+ // This is an authority-bearing thread edge. Preserve a malformed explicit
260
+ // value for the Note ingress validator instead of silently converting it
261
+ // to absence (which would persist a root post or allow a partial Update).
262
+ inReplyTo: record["inReplyTo"],
244
263
  to: getStringArray(record, "to"),
245
264
  cc: getStringArray(record, "cc"),
265
+ bto: getStringArray(record, "bto"),
266
+ bcc: getStringArray(record, "bcc"),
267
+ audience: getStringArray(record, "audience"),
246
268
  conversation: getString(record, "conversation"),
247
269
  content: getString(record, "content"),
248
270
  summary:
@@ -269,6 +291,35 @@ function parseActivityObjectOrIri(
269
291
  return undefined;
270
292
  }
271
293
 
294
+ // Below D1's 100-bound-parameter ceiling when a handler resolves all reported
295
+ // entities in one IN (...) query. The request body itself is bounded separately.
296
+ export const MAX_ACTIVITY_OBJECT_IDS = 64;
297
+
298
+ /**
299
+ * Normalize every usable `object` reference without widening the existing
300
+ * scalar/embedded `Activity.object` model. In particular, Mastodon-compatible
301
+ * Flag activities carry `[reportedPost, reportedActor]`; the old parser dropped
302
+ * that array completely, leaving an unactionable target-less report.
303
+ */
304
+ function parseActivityObjectIds(value: unknown): string[] | undefined {
305
+ const values = Array.isArray(value) ? value : [value];
306
+ const ids: string[] = [];
307
+ const seen = new Set<string>();
308
+ for (const item of values) {
309
+ const id =
310
+ typeof item === "string"
311
+ ? item
312
+ : isJsonRecord(item)
313
+ ? getString(item, "id")
314
+ : undefined;
315
+ if (!id || seen.has(id)) continue;
316
+ seen.add(id);
317
+ ids.push(id);
318
+ if (ids.length >= MAX_ACTIVITY_OBJECT_IDS) break;
319
+ }
320
+ return ids.length > 0 ? ids : undefined;
321
+ }
322
+
272
323
  /**
273
324
  * Narrow an `unknown` (an already-`JSON.parse`d inbox body) into an
274
325
  * {@link ActivityDocument}. Throws if the value is not a JSON object.
@@ -283,8 +334,15 @@ export function parseActivity(value: unknown, path = "$"): ActivityDocument {
283
334
  type: getString(record, "type"),
284
335
  actor: getString(record, "actor"),
285
336
  object: parseActivityObjectOrIri(record["object"]),
337
+ objectIds: parseActivityObjectIds(record["object"]),
286
338
  target: parseActivityObjectOrIri(record["target"]),
339
+ content: getString(record, "content"),
287
340
  room: getString(record, "room"),
341
+ to: getStringArray(record, "to"),
342
+ cc: getStringArray(record, "cc"),
343
+ bto: getStringArray(record, "bto"),
344
+ bcc: getStringArray(record, "bcc"),
345
+ audience: getStringArray(record, "audience"),
288
346
  };
289
347
  }
290
348
 
@@ -91,11 +91,41 @@ export function isLocal(apId: string, baseUrl: string): boolean {
91
91
  }
92
92
  }
93
93
 
94
- export function formatUsername(apId: string): string {
95
- const url = new URL(apId);
96
- const match = apId.match(/\/users\/([^\/]+)$/);
97
- if (match) {
98
- return `${match[1]}@${url.host}`;
94
+ function cleanPreferredUsername(value: string | null | undefined) {
95
+ const trimmed = value?.trim();
96
+ return trimmed ? trimmed : null;
97
+ }
98
+
99
+ /**
100
+ * Render an actor handle without treating the actor-id path as fresher than
101
+ * ActivityPub's preferredUsername. Actor ids are stable across a profile rename,
102
+ * so `/users/old-name` may legitimately describe `preferredUsername=new-name`.
103
+ */
104
+ export function formatUsername(
105
+ apId: string,
106
+ preferredUsername?: string | null,
107
+ ): string {
108
+ const preferred = cleanPreferredUsername(preferredUsername);
109
+ try {
110
+ const url = new URL(apId);
111
+ if (preferred) return `${preferred}@${url.host}`;
112
+ const match = url.pathname.match(/\/(?:users|groups)\/([^/]+)\/?$/u);
113
+ if (match) return `${match[1]}@${url.host}`;
114
+ } catch {
115
+ // Retained legacy rows must remain displayable/removable even if their id
116
+ // predates the current absolute-URL validation boundary.
99
117
  }
100
- return apId;
118
+ return preferred || apId;
119
+ }
120
+
121
+ /** Always return a non-empty primary label for an actor roster row. */
122
+ export function formatPreferredUsername(
123
+ apId: string,
124
+ preferredUsername?: string | null,
125
+ ): string {
126
+ const preferred = cleanPreferredUsername(preferredUsername);
127
+ if (preferred) return preferred;
128
+ const formatted = formatUsername(apId);
129
+ const separator = formatted.lastIndexOf("@");
130
+ return separator > 0 ? formatted.slice(0, separator) : formatted;
101
131
  }
@@ -21,7 +21,7 @@ import {
21
21
  } from "../federation-helpers.ts";
22
22
  import { tryParseRemoteActor } from "./activitypub-validators.ts";
23
23
  import {
24
- buildActorCacheFields,
24
+ cacheRemoteActorDocument,
25
25
  getInstanceFetchSignerByDb,
26
26
  } from "./activitypub-actor-cache.ts";
27
27
  import { logger } from "./logger.ts";
@@ -339,22 +339,12 @@ export async function fetchActorPublicKey(
339
339
  isSafeRemoteUrl(actorData.id) &&
340
340
  isSafeRemoteUrl(actorData.inbox)
341
341
  ) {
342
- // Reuse the already-fetched signature actor document and write it
343
- // through the ONE canonical superset cache shape, so this opportunistic
344
- // upsert populates `outbox` / `followersUrl` / `sharedInbox` identically
345
- // to every other entry path.
346
- const cacheFields = buildActorCacheFields(actorData);
347
- // Single atomic upsert. A check-then-insert/update is racy across
348
- // Worker isolates: two isolates racing the same cold actor can both
349
- // miss the existence check and then both INSERT, and the loser hits a
350
- // primary-key violation that would null out a successfully-fetched
351
- // key and spuriously reject a validly-signed activity.
352
- // `onConflictDoUpdate` collapses that to one race-safe statement
353
- // (same pattern as fetchAndCacheRemoteActor in queue-batching.ts).
354
- await db
355
- .insert(actorCache)
356
- .values({ apId: actorData.id, ...cacheFields })
357
- .onConflictDoUpdate({ target: actorCache.apId, set: cacheFields });
342
+ // Reuse the already-fetched signature actor document through the one
343
+ // successful persistence boundary. This atomically refreshes the
344
+ // canonical cache shape and clears any older fetch-failure authority.
345
+ await cacheRemoteActorDocument(db, actorUrl, rawActor, {
346
+ publicKey: "require-key",
347
+ });
358
348
  }
359
349
 
360
350
  return result.publicKeyPem;