@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
@@ -14,14 +14,12 @@ import {
14
14
  or,
15
15
  sql,
16
16
  } from "drizzle-orm";
17
- import type { BatchItem } from "drizzle-orm/batch";
18
17
  import {
19
18
  activities,
20
19
  actorCache,
21
20
  actors,
22
21
  blocks,
23
22
  communities,
24
- deliveryQueue,
25
23
  follows,
26
24
  inbox,
27
25
  mediaUploads,
@@ -29,6 +27,8 @@ import {
29
27
  notDeleted,
30
28
  nowIso,
31
29
  objects,
30
+ runBatch,
31
+ type D1Statement,
32
32
  } from "../../db/index.ts";
33
33
  import type { Database } from "../../db/index.ts";
34
34
  import type { Env, Variables } from "../types.ts";
@@ -36,21 +36,33 @@ import {
36
36
  activityApId,
37
37
  formatUsername,
38
38
  generateId,
39
+ isLocal,
39
40
  isSafeRemoteUrl,
40
41
  parseLimit,
41
42
  parseOffset,
42
43
  safeJsonParse,
43
44
  } from "../federation-helpers.ts";
44
45
  import { enqueueFanoutToFollowers } from "../lib/delivery/queue.ts";
46
+ import { prepareDeliveryFanoutJob } from "../lib/delivery/fanout-outbox.ts";
45
47
  import {
46
48
  destinationDeclaresAlias,
47
49
  resolveMoveTarget,
48
50
  } from "../lib/account-migration.ts";
49
51
  import { getInstanceFetchSigner } from "./activitypub/query-helpers.ts";
50
- import { severFollowEdge } from "./activitypub/handlers/inbox-interaction-handlers.ts";
51
- import { teardownActor } from "./account-teardown.ts";
52
+ import { blockActorAndSeverFollowPair } from "../lib/follow-edge-mutations.ts";
53
+ import {
54
+ deletePersonalActorBlock,
55
+ deletePersonalActorMute,
56
+ personalActorIsBlockedBy,
57
+ resolveRetainedPersonalMuteTarget,
58
+ } from "../lib/personal-actor-moderation.ts";
59
+ import {
60
+ finalizeActorDeletionAndSessions,
61
+ teardownActor,
62
+ } from "./account-teardown.ts";
52
63
  import { CacheTags, CacheTTL, withCache } from "../middleware/cache.ts";
53
64
  import {
65
+ type AppContext,
54
66
  actorExists,
55
67
  createRelation,
56
68
  deleteRelation,
@@ -69,12 +81,56 @@ import {
69
81
  } from "./actors-helpers.ts";
70
82
  import { reapReplacedMediaUrl } from "./posts/delete-cascade.ts";
71
83
  import { safeUrlJoin } from "../lib/activitypub-helpers.ts";
84
+ import { isActorBlockedStrict } from "../lib/blocklist.ts";
72
85
  import { encodeFeedCursor, feedCursorWhere } from "../lib/feed-cursor.ts";
73
86
  import { chunkForInClause } from "../lib/chunk.ts";
74
87
  import { logger } from "../lib/logger.ts";
88
+ import { excludeModeratedActors } from "../lib/feed-exclude.ts";
89
+ import {
90
+ activityDeleteCascadeStatements,
91
+ activityDeliveryDrainedGuard,
92
+ deleteActivitiesCascade,
93
+ } from "../lib/activity-delete-cascade.ts";
94
+ import {
95
+ claimRemoteActorFetch,
96
+ fetchAndUpsertActorCache,
97
+ getRemoteActorFetchFailure,
98
+ recordRemoteActorFetchFailure,
99
+ type RemoteActorFetchFailureState,
100
+ } from "../lib/activitypub-actor-cache.ts";
75
101
 
76
102
  const log = logger.child({ component: "actors" });
77
103
 
104
+ function remoteActorFetchFailureResponse(
105
+ c: AppContext,
106
+ failure: RemoteActorFetchFailureState,
107
+ ): Response {
108
+ if (failure.kind === "gone") {
109
+ return c.json({ error: "Remote actor is gone", code: "ACTOR_GONE" }, 410);
110
+ }
111
+
112
+ const retryAfter = failure.retryAfterSeconds ?? 30;
113
+ c.header("Retry-After", retryAfter.toString());
114
+ if (failure.kind === "invalid") {
115
+ return c.json(
116
+ {
117
+ error: "Remote actor returned invalid data",
118
+ code: "ACTOR_INVALID",
119
+ retry_after: retryAfter,
120
+ },
121
+ 502,
122
+ );
123
+ }
124
+ return c.json(
125
+ {
126
+ error: "Remote actor is temporarily unavailable",
127
+ code: "ACTOR_UNAVAILABLE",
128
+ retry_after: retryAfter,
129
+ },
130
+ 503,
131
+ );
132
+ }
133
+
78
134
  // Mastodon-parity profile metadata limits. Mastodon caps profile fields at 4
79
135
  // rows with bounded name/value lengths; we mirror that to keep the served
80
136
  // actor document and federated Update(Person) bounded.
@@ -137,15 +193,21 @@ function fieldsToAttachments(
137
193
  const TOMBSTONE_REAP_AFTER_MS = 24 * 60 * 60 * 1000;
138
194
 
139
195
  /**
140
- * Hard-delete tombstoned local actors whose federation Delete has drained.
196
+ * Reap signing material for tombstoned local actors and communities whose
197
+ * federation work has drained.
141
198
  *
142
199
  * A tombstone is only reaped when (a) its `deletedAt` is older than
143
- * TOMBSTONE_REAP_AFTER_MS and (b) it has NO non-terminal (pending / processing
144
- * / failed / retry_wait) delivery_queue rows for any of its Delete activities —
145
- * i.e. nothing still needs the private key to sign a retry. The preserved Delete activity
146
- * rows are removed alongside the actor so they do not accumulate forever.
200
+ * TOMBSTONE_REAP_AFTER_MS and (b) it has NO non-terminal endpoint-delivery or
201
+ * actor-resolution rows for any of its Delete activities — i.e. nothing can
202
+ * still resolve into a delivery that needs the private key to sign a retry.
203
+ * The preserved Delete activity rows are removed alongside the actor so they
204
+ * do not accumulate forever.
147
205
  *
148
- * Returns the number of tombstones hard-deleted.
206
+ * Actor rows are hard-deleted as before. Community rows remain as permanent
207
+ * lifecycle tombstones so their scoped objects keep failing the read gate, but
208
+ * their private signing key is scrubbed after every Group activity projection
209
+ * is terminal. Returns the total number of actor rows deleted + community keys
210
+ * scrubbed.
149
211
  */
150
212
  export async function reapDrainedTombstones(db: Database): Promise<number> {
151
213
  const cutoff = new Date(Date.now() - TOMBSTONE_REAP_AFTER_MS).toISOString();
@@ -157,70 +219,83 @@ export async function reapDrainedTombstones(db: Database): Promise<number> {
157
219
  and(sql`${actors.deletedAt} IS NOT NULL`, lt(actors.deletedAt, cutoff)),
158
220
  )
159
221
  .limit(100);
160
- if (candidates.length === 0) return 0;
161
222
 
162
223
  let reaped = 0;
163
224
  for (const { apId } of candidates) {
164
- // Delete activities this actor authored (preserved through teardown for
165
- // the delivery signer). Outbound by construction.
166
- const deleteActivities = await db
167
- .select({ apId: activities.apId })
168
- .from(activities)
169
- .where(
170
- and(eq(activities.actorApId, apId), eq(activities.type, "Delete")),
171
- );
172
- const deleteActivityIds = deleteActivities.map((a) => a.apId);
173
-
174
- if (deleteActivityIds.length > 0) {
175
- // Any non-terminal delivery job for those Delete activities means the
176
- // signer may still need this actor's key — skip reaping for now. Chunked:
177
- // a prolific deleted actor can have >100 Delete activities, which would
178
- // blow D1's 100-bound-param cap and 500 this fire-and-forget reap, leaking
179
- // the tombstone (and its signing key) forever.
180
- let hasPendingDelivery = false;
181
- for (const chunk of chunkForInClause(deleteActivityIds)) {
182
- const pending = await db
183
- .select({ id: deliveryQueue.id })
184
- .from(deliveryQueue)
185
- .where(
186
- and(
187
- inArray(deliveryQueue.activityApId, chunk),
188
- // Non-terminal delivery states (anything other than the terminal
189
- // "delivered" / "dead_letter"). A "retry_wait" row is a Delete
190
- // between attempts and will be re-sent, so reaping the tombstone
191
- // (and its signing key) while one exists would strand the retry
192
- // unsigned. queue-delivery.ts writes: pending / processing /
193
- // failed / retry_wait / delivered / dead_letter.
194
- inArray(deliveryQueue.status, [
195
- "pending",
196
- "processing",
197
- "failed",
198
- "retry_wait",
199
- ]),
200
- ),
201
- )
202
- .limit(1)
203
- .get();
204
- if (pending) {
205
- hasPendingDelivery = true;
206
- break;
207
- }
208
- }
209
- if (hasPendingDelivery) continue;
210
- }
225
+ // Check the delivery authority on every statement in ONE batch. A
226
+ // resolution worker can materialize an endpoint while cleanup is starting;
227
+ // an unguarded cascade would delete that new job and then the signing key.
228
+ // D1 serializes this batch, so any active row that appears before or during
229
+ // it fences all subsequent cleanup statements.
230
+ const deleteActivityWhere = and(
231
+ eq(activities.actorApId, apId),
232
+ eq(activities.type, "Delete"),
233
+ )!;
234
+ const drainedGuard = activityDeliveryDrainedGuard(db, deleteActivityWhere);
235
+ await runBatch(db, [
236
+ ...activityDeleteCascadeStatements(db, deleteActivityWhere, {
237
+ guard: drainedGuard,
238
+ }),
239
+ db
240
+ .delete(actors)
241
+ .where(
242
+ and(
243
+ eq(actors.apId, apId),
244
+ isNotNull(actors.deletedAt),
245
+ lt(actors.deletedAt, cutoff),
246
+ drainedGuard,
247
+ ),
248
+ ) as D1Statement,
249
+ ]);
250
+ const actorRemains = await db
251
+ .select({ apId: actors.apId })
252
+ .from(actors)
253
+ .where(eq(actors.apId, apId))
254
+ .get();
255
+ if (!actorRemains) reaped += 1;
256
+ }
211
257
 
212
- // Drained: remove the terminal delivery_queue rows, the preserved Delete
213
- // activities, then the tombstone row itself (chunked for D1's param cap).
214
- if (deleteActivityIds.length > 0) {
215
- for (const chunk of chunkForInClause(deleteActivityIds)) {
216
- await db
217
- .delete(deliveryQueue)
218
- .where(inArray(deliveryQueue.activityApId, chunk));
219
- await db.delete(activities).where(inArray(activities.apId, chunk));
220
- }
221
- }
222
- await db.delete(actors).where(eq(actors.apId, apId));
223
- reaped += 1;
258
+ // A retired Group cannot be hard-deleted: its community row is the durable
259
+ // negative lifecycle fact used by direct object reads. Once all Group-authored
260
+ // activity projections have drained, remove those ledgers/projections and
261
+ // scrub only the private signing key.
262
+ const communityCandidates = await db
263
+ .select({ apId: communities.apId })
264
+ .from(communities)
265
+ .where(
266
+ and(
267
+ isNotNull(communities.deletedAt),
268
+ lt(communities.deletedAt, cutoff),
269
+ ne(communities.privateKeyPem, ""),
270
+ ),
271
+ )
272
+ .limit(100);
273
+ for (const { apId } of communityCandidates) {
274
+ const groupActivityWhere = eq(activities.actorApId, apId);
275
+ const drainedGuard = activityDeliveryDrainedGuard(db, groupActivityWhere);
276
+ await runBatch(db, [
277
+ ...activityDeleteCascadeStatements(db, groupActivityWhere, {
278
+ guard: drainedGuard,
279
+ }),
280
+ db
281
+ .update(communities)
282
+ .set({ privateKeyPem: "" })
283
+ .where(
284
+ and(
285
+ eq(communities.apId, apId),
286
+ isNotNull(communities.deletedAt),
287
+ lt(communities.deletedAt, cutoff),
288
+ ne(communities.privateKeyPem, ""),
289
+ drainedGuard,
290
+ ),
291
+ ) as D1Statement,
292
+ ]);
293
+ const communityAfter = await db
294
+ .select({ privateKeyPem: communities.privateKeyPem })
295
+ .from(communities)
296
+ .where(eq(communities.apId, apId))
297
+ .get();
298
+ if (communityAfter?.privateKeyPem === "") reaped += 1;
224
299
  }
225
300
 
226
301
  return reaped;
@@ -239,23 +314,10 @@ export async function reapDrainedTombstones(db: Database): Promise<number> {
239
314
  * delivery_queue rows AND the preserved Delete activity rows, so re-registration
240
315
  * starts clean and no half-signed Delete is sent.
241
316
  *
242
- * Mirrors the activity/queue cleanup `reapDrainedTombstones` performs, but is
243
- * unconditional (the revive supersedes the Delete) and only scoped to non-
244
- * terminal delivery rows; terminal rows are removed alongside the activity.
317
+ * Mirrors the activity/projection cleanup `reapDrainedTombstones` performs,
318
+ * but is unconditional because the revive supersedes the Delete.
245
319
  * Returns the number of Delete activities cancelled.
246
320
  */
247
- // D1 has no interactive transactions, but both the D1 and libsql drivers expose
248
- // `db.batch([...])`, which commits a list of prepared statements atomically. The
249
- // shared `Database` union aliases the abstract `BaseSQLiteDatabase` base (which
250
- // does not surface `batch`), so we narrow to the concrete batch surface here
251
- // rather than weakening the shared type (mirrors inbox-interaction-handlers.ts).
252
- type BatchStatement = BatchItem<"sqlite">;
253
- interface BatchableDb {
254
- batch(
255
- statements: readonly [BatchStatement, ...BatchStatement[]],
256
- ): Promise<unknown>;
257
- }
258
-
259
321
  export async function cancelTombstoneDelete(
260
322
  db: Database,
261
323
  apId: string,
@@ -267,20 +329,13 @@ export async function cancelTombstoneDelete(
267
329
  const deleteActivityIds = deleteActivities.map((a) => a.apId);
268
330
  if (deleteActivityIds.length === 0) return 0;
269
331
 
270
- // Drop every delivery_queue row for those Delete activities (any status — the
271
- // Delete is superseded, including in-flight retry_wait jobs) together with the
272
- // preserved Delete activity rows, so no signer can pick up a job referencing
273
- // an activity whose actor row has been rotated. Each chunk's two deletes stay
274
- // paired in one atomic batch; chunked because a prolific actor can have >100
275
- // Delete activities, which would blow D1's 100-bound-param cap and 500 the
276
- // revive.
332
+ // Drop every durable projection for those Delete activities (any queue status
333
+ // — the Delete is superseded, including in-flight retry_wait jobs) together
334
+ // with the activity rows, so no signer can pick up work referencing an
335
+ // activity whose actor row has been rotated. Each chunk stays in one atomic
336
+ // batch; chunking keeps the predicate below D1's 100-bound-param ceiling.
277
337
  for (const chunk of chunkForInClause(deleteActivityIds)) {
278
- await (db as unknown as BatchableDb).batch([
279
- db
280
- .delete(deliveryQueue)
281
- .where(inArray(deliveryQueue.activityApId, chunk)),
282
- db.delete(activities).where(inArray(activities.apId, chunk)),
283
- ]);
338
+ await deleteActivitiesCascade(db, inArray(activities.apId, chunk));
284
339
  }
285
340
 
286
341
  return deleteActivityIds.length;
@@ -359,7 +414,7 @@ actorsRoute.get(
359
414
  following_count: a.followingCount,
360
415
  post_count: a.postCount,
361
416
  created_at: a.createdAt,
362
- username: formatUsername(a.apId),
417
+ username: formatUsername(a.apId, a.preferredUsername),
363
418
  })),
364
419
  });
365
420
  },
@@ -394,20 +449,11 @@ actorsRoute.post("/me/blocked", async (c) => {
394
449
  c,
395
450
  "block",
396
451
  async (db, actorId, targetId) => {
397
- await db
398
- .insert(blocks)
399
- .values({ blockerApId: actorId, blockedApId: targetId })
400
- .onConflictDoNothing();
401
- // Sever BOTH follow edges + reconcile counts (mirrors the federated
402
- // handleBlock). Without this a blocked actor who was an accepted follower
403
- // stays in the fan-out set and keeps receiving the blocker's posts, and
404
- // both actors' follower/following counts stay inflated — defeating the
405
- // whole point of the block. severFollowEdge gates each decrement on an
406
- // EXISTS(... status='accepted') subquery so a pending/absent edge is a
407
- // clean no-op (no under-count). Pending follow-request rows are deleted by
408
- // the edge delete inside severFollowEdge regardless of status.
409
- await severFollowEdge(db, targetId, actorId); // target follows actor
410
- await severFollowEdge(db, actorId, targetId); // actor follows target
452
+ // The block row, both follow-edge removals, and all four counter
453
+ // reconciliations are one D1 commit. Without that boundary a failure
454
+ // between the two directions can leave follower-only delivery authority
455
+ // half alive even though the API reports failure.
456
+ await blockActorAndSeverFollowPair(db, actorId, targetId);
411
457
  },
412
458
  async (db, actorId) =>
413
459
  (
@@ -417,17 +463,14 @@ actorsRoute.post("/me/blocked", async (c) => {
417
463
  .where(eq(blocks.blockerApId, actorId))
418
464
  .get()
419
465
  )?.n ?? 0,
466
+ (db, actorId, targetId) => personalActorIsBlockedBy(db, actorId, targetId),
420
467
  );
421
468
  });
422
469
 
423
470
  // Unblock a user
424
471
  actorsRoute.delete("/me/blocked", async (c) => {
425
472
  return deleteRelation(c, "block", (db, actorId, targetId) =>
426
- db
427
- .delete(blocks)
428
- .where(
429
- and(eq(blocks.blockerApId, actorId), eq(blocks.blockedApId, targetId)),
430
- ),
473
+ deletePersonalActorBlock(db, actorId, targetId),
431
474
  );
432
475
  });
433
476
 
@@ -458,11 +501,20 @@ actorsRoute.post("/me/muted", async (c) => {
458
501
  return createRelation(
459
502
  c,
460
503
  "mute",
461
- (db, actorId, targetId) =>
462
- db
504
+ async (db, actorId, targetId) => {
505
+ const retainedTarget = await resolveRetainedPersonalMuteTarget(
506
+ db,
507
+ actorId,
508
+ targetId,
509
+ );
510
+ await db
463
511
  .insert(mutes)
464
- .values({ muterApId: actorId, mutedApId: targetId })
465
- .onConflictDoNothing(),
512
+ .values({
513
+ muterApId: actorId,
514
+ mutedApId: retainedTarget ?? targetId,
515
+ })
516
+ .onConflictDoNothing();
517
+ },
466
518
  async (db, actorId) =>
467
519
  (
468
520
  await db
@@ -471,15 +523,15 @@ actorsRoute.post("/me/muted", async (c) => {
471
523
  .where(eq(mutes.muterApId, actorId))
472
524
  .get()
473
525
  )?.n ?? 0,
526
+ async (db, actorId, targetId) =>
527
+ Boolean(await resolveRetainedPersonalMuteTarget(db, actorId, targetId)),
474
528
  );
475
529
  });
476
530
 
477
531
  // Unmute a user
478
532
  actorsRoute.delete("/me/muted", async (c) => {
479
533
  return deleteRelation(c, "mute", (db, actorId, targetId) =>
480
- db
481
- .delete(mutes)
482
- .where(and(eq(mutes.muterApId, actorId), eq(mutes.mutedApId, targetId))),
534
+ deletePersonalActorMute(db, actorId, targetId),
483
535
  );
484
536
  });
485
537
 
@@ -509,11 +561,17 @@ actorsRoute.post("/me/delete", async (c) => {
509
561
  // Delete(Actor) (snapshotting follower inboxes before the graph is dropped),
510
562
  // reconciles every counterparty's denormalized counters, deletes the actor's
511
563
  // edges / interactions / memberships / media / DM state, hands off sole-owned
512
- // communities to an heir, hard-deletes its authored objects, and
513
- // tombstones+scrubs the actor row. This is the EXACT cascade teardownActor
514
- // applies to each sub-account below — one chokepoint instead of a ~440-line
515
- // hand-rolled copy that had to be kept in sync.
516
- await teardownActor(db, c.env, baseUrl, actorApIdVal, actor.followers_url);
564
+ // communities to an heir, and hard-deletes its authored objects. The owner
565
+ // tombstone is finalized below after all sub-accounts succeed. This is the
566
+ // EXACT cascade teardownActor applies to each sub-account below — one
567
+ // chokepoint instead of a ~440-line hand-rolled copy that had to be kept in
568
+ // sync.
569
+ // Defer the owner's tombstone until every sub-account has completed. If a
570
+ // later media/DB step fails, the owner row and session remain live so the
571
+ // same authenticated request can retry the incomplete cascade.
572
+ await teardownActor(db, c.env, baseUrl, actorApIdVal, actor.followers_url, {
573
+ finalizeActor: false,
574
+ });
517
575
  // Now fully tear down each sub-account (same cascade the owner just received)
518
576
  // so no "deleted" sub-account content / edge / counter / membership / sole
519
577
  // ownership survives, and each federates its own Delete(Actor).
@@ -521,6 +579,12 @@ actorsRoute.post("/me/delete", async (c) => {
521
579
  await teardownActor(db, c.env, baseUrl, sub.apId, sub.followersUrl);
522
580
  }
523
581
 
582
+ // Finalize the owner only after all owner/sub-account teardown work has
583
+ // succeeded. Sessions are removed last so a failure above cannot discard
584
+ // the retry authority. Tombstoned actors fail closed in session-actor.ts.
585
+ const sessionActorIds = [actorApIdVal, ...subAccounts.map((s) => s.apId)];
586
+ await finalizeActorDeletionAndSessions(db, actorApIdVal, sessionActorIds);
587
+
524
588
  deleteCookie(c, "session");
525
589
 
526
590
  return c.json({ success: true });
@@ -577,6 +641,8 @@ actorsRoute.get("/:identifier/posts", async (c) => {
577
641
  conditions.push(eq(objects.visibility, "public"));
578
642
  conditions.push(eq(objects.audienceJson, "[]"));
579
643
  }
644
+ const excludeAuthor = excludeModeratedActors(currentActor?.ap_id ?? "");
645
+ if (excludeAuthor) conditions.push(excludeAuthor);
580
646
  // Composite (published, apId) cursor so posts sharing a published millisecond
581
647
  // aren't skipped at a page boundary (see lib/feed-cursor.ts).
582
648
  const profileCursor = feedCursorWhere(
@@ -634,7 +700,7 @@ actorsRoute.get("/:identifier/posts", async (c) => {
634
700
  type: p.type,
635
701
  author: {
636
702
  ap_id: p.attributedTo,
637
- username: formatUsername(p.attributedTo),
703
+ username: formatUsername(p.attributedTo, author?.preferredUsername),
638
704
  preferred_username: author?.preferredUsername || null,
639
705
  name: author?.name || null,
640
706
  icon_url: author?.iconUrl || null,
@@ -702,12 +768,82 @@ actorsRoute.get("/:identifier", async (c) => {
702
768
  .get();
703
769
 
704
770
  if (!localActor) {
705
- const cachedActor = await db
771
+ // A cached remote actor remains in storage so an operator unblock can be
772
+ // reversible, but it must not stay directly discoverable while its actor
773
+ // or domain is defederated. Apply the same complete actor/domain decision
774
+ // as ingress and outbound delivery before projecting the cached profile.
775
+ if (await isActorBlockedStrict(db, apId)) {
776
+ return c.json({ error: "Actor not found" }, 404);
777
+ }
778
+ let cachedActor = await db
706
779
  .select()
707
780
  .from(actorCache)
708
781
  .where(eq(actorCache.apId, apId))
709
782
  .get();
710
- if (!cachedActor) return c.json({ error: "Actor not found" }, 404);
783
+ if (!cachedActor) {
784
+ // A Follow edge can outlive the optional presentation cache (for
785
+ // example after cache eviction or an interrupted refresh). The profile
786
+ // link projected from that durable relationship must remain recoverable,
787
+ // but this public read route must not become an arbitrary remote-fetch
788
+ // proxy. Only an authenticated read of a safe non-local actor with a
789
+ // retained pending/accepted relationship may hydrate the missing row.
790
+ const retainedRelationship =
791
+ currentActor && !isLocal(apId, baseUrl) && isSafeRemoteUrl(apId)
792
+ ? await db
793
+ .select({ followerApId: follows.followerApId })
794
+ .from(follows)
795
+ .where(
796
+ and(
797
+ or(
798
+ eq(follows.followerApId, apId),
799
+ eq(follows.followingApId, apId),
800
+ ),
801
+ inArray(follows.status, ["pending", "accepted"]),
802
+ ),
803
+ )
804
+ .get()
805
+ : null;
806
+ if (!retainedRelationship) {
807
+ return c.json({ error: "Actor not found" }, 404);
808
+ }
809
+
810
+ const activeFailure = await getRemoteActorFetchFailure(db, apId);
811
+ if (activeFailure) {
812
+ return remoteActorFetchFailureResponse(c, activeFailure);
813
+ }
814
+
815
+ const fetchClaim = await claimRemoteActorFetch(db, apId);
816
+ if (!fetchClaim.owned) {
817
+ return remoteActorFetchFailureResponse(c, fetchClaim.failure);
818
+ }
819
+
820
+ const recovered = await fetchAndUpsertActorCache(db, apId, {
821
+ mode: "insert",
822
+ signer: await getInstanceFetchSigner(c),
823
+ });
824
+ if (!recovered.ok) {
825
+ const failure = await recordRemoteActorFetchFailure(
826
+ db,
827
+ apId,
828
+ recovered,
829
+ new Date(),
830
+ fetchClaim.token,
831
+ );
832
+ // Another entry path may have successfully cached the actor while this
833
+ // claimed network request was failing. The failure update is fenced by
834
+ // actor_cache existence; re-read before returning a stale 410/503.
835
+ cachedActor = await db
836
+ .select()
837
+ .from(actorCache)
838
+ .where(eq(actorCache.apId, apId))
839
+ .get();
840
+ if (!cachedActor) {
841
+ return remoteActorFetchFailureResponse(c, failure);
842
+ }
843
+ } else {
844
+ cachedActor = recovered.row;
845
+ }
846
+ }
711
847
 
712
848
  // Project the remote actor's AS Person document (cached `rawJson`) so the
713
849
  // client banner/fields work for REMOTE actors too: `attachment` ->
@@ -727,7 +863,10 @@ actorsRoute.get("/:identifier", async (c) => {
727
863
  name: cachedActor.name,
728
864
  summary: cachedActor.summary,
729
865
  icon_url: cachedActor.iconUrl,
730
- username: formatUsername(cachedActor.apId),
866
+ username: formatUsername(
867
+ cachedActor.apId,
868
+ cachedActor.preferredUsername,
869
+ ),
731
870
  fields: sanitizeProfileFields(rawAttachment),
732
871
  also_known_as: rawAlsoKnownAs.slice(0, MAX_ALSO_KNOWN_AS),
733
872
  moved_to: rawMovedTo,
@@ -784,7 +923,7 @@ actorsRoute.get("/:identifier", async (c) => {
784
923
  post_count: localActor.postCount,
785
924
  is_private: localActor.isPrivate,
786
925
  created_at: localActor.createdAt,
787
- username: formatUsername(localActor.apId),
926
+ username: formatUsername(localActor.apId, localActor.preferredUsername),
788
927
  fields: sanitizeProfileFields(safeJsonParse(localActor.fieldsJson, [])),
789
928
  also_known_as: safeJsonParse<string[]>(localActor.alsoKnownAsJson, []),
790
929
  moved_to: localActor.movedTo,
@@ -907,23 +1046,11 @@ actorsRoute.put("/me", async (c) => {
907
1046
  }
908
1047
 
909
1048
  const db = c.get("db");
910
- await db.update(actors).set(updates).where(eq(actors.apId, actor.ap_id));
911
-
912
- // A replaced avatar/header is attached to no object, so no GC path reclaims
913
- // the prior blob — reap it now if the old URL is a local /media upload no
914
- // longer referenced anywhere (best-effort; never fails the update).
915
- for (const [dbKey, oldUrl] of [
916
- ["iconUrl", actor.icon_url],
917
- ["headerUrl", actor.header_url],
918
- ] as const) {
919
- if (updates[dbKey] !== undefined && oldUrl && oldUrl !== updates[dbKey]) {
920
- await reapReplacedMediaUrl(db, oldUrl, actor.ap_id, c.env.MEDIA);
921
- }
922
- }
923
1049
 
924
1050
  // The `actor` context snapshot predates the new columns, so to federate a
925
- // faithful Person we read the current persisted values when the request did
926
- // not itself supply them.
1051
+ // faithful Person we read the currently persisted values when the request
1052
+ // does not itself supply them. This read deliberately precedes the atomic
1053
+ // profile + Activity + fanout-intent write below.
927
1054
  if (nextFields === undefined || nextAlsoKnownAs === undefined) {
928
1055
  const persisted = await db
929
1056
  .select({
@@ -1032,18 +1159,46 @@ actorsRoute.put("/me", async (c) => {
1032
1159
  object: personObject,
1033
1160
  };
1034
1161
 
1035
- try {
1036
- await db.insert(activities).values({
1162
+ const preparedFanout = await prepareDeliveryFanoutJob(db, {
1163
+ kind: "followers",
1164
+ activityId: updateActivityId,
1165
+ targetApId: actor.ap_id,
1166
+ });
1167
+ await runBatch(db, [
1168
+ db
1169
+ .update(actors)
1170
+ .set(updates)
1171
+ .where(eq(actors.apId, actor.ap_id)) as D1Statement,
1172
+ db.insert(activities).values({
1037
1173
  apId: updateActivityId,
1038
1174
  type: "Update",
1039
1175
  actorApId: actor.ap_id,
1040
1176
  objectApId: actor.ap_id,
1041
1177
  rawJson: JSON.stringify(updateActivity),
1042
1178
  direction: "outbound",
1043
- });
1179
+ }) as D1Statement,
1180
+ preparedFanout.statement,
1181
+ ]);
1182
+
1183
+ // A replaced avatar/header is attached to no object, so no GC path reclaims
1184
+ // the prior blob — reap it after the durable profile mutation commits if the
1185
+ // old URL is a local /media upload no longer referenced anywhere
1186
+ // (best-effort; never fails the update).
1187
+ for (const [dbKey, oldUrl] of [
1188
+ ["iconUrl", actor.icon_url],
1189
+ ["headerUrl", actor.header_url],
1190
+ ] as const) {
1191
+ if (updates[dbKey] !== undefined && oldUrl && oldUrl !== updates[dbKey]) {
1192
+ await reapReplacedMediaUrl(db, oldUrl, actor.ap_id, c.env.MEDIA);
1193
+ }
1194
+ }
1195
+
1196
+ try {
1044
1197
  await enqueueFanoutToFollowers(c.env, updateActivityId, actor.ap_id);
1045
1198
  } catch (err) {
1046
- // Federation is best-effort; the local profile update already succeeded.
1199
+ // The semantic fanout is already durable in the same commit as the profile
1200
+ // and Activity. Queue publication is only a best-effort wakeup; a later
1201
+ // request/startup/retention sweep republishes the pending row.
1047
1202
  log.error("Failed to enqueue profile Update federation", {
1048
1203
  event: "actors.profile.update_federation_failed",
1049
1204
  actor: actor.ap_id,
@@ -1110,11 +1265,6 @@ actorsRoute.post("/me/move", async (c) => {
1110
1265
  const db = c.get("db");
1111
1266
  const baseUrl = c.env.APP_URL;
1112
1267
 
1113
- await db
1114
- .update(actors)
1115
- .set({ movedTo: target })
1116
- .where(eq(actors.apId, actor.ap_id));
1117
-
1118
1268
  // Federate Move(actor) addressed to followers so they migrate their follow.
1119
1269
  const moveActivityId = activityApId(baseUrl, generateId());
1120
1270
  const moveActivity = {
@@ -1128,18 +1278,32 @@ actorsRoute.post("/me/move", async (c) => {
1128
1278
  cc: [actor.followers_url],
1129
1279
  };
1130
1280
 
1131
- try {
1132
- await db.insert(activities).values({
1281
+ const preparedFanout = await prepareDeliveryFanoutJob(db, {
1282
+ kind: "followers",
1283
+ activityId: moveActivityId,
1284
+ targetApId: actor.ap_id,
1285
+ });
1286
+ await runBatch(db, [
1287
+ db
1288
+ .update(actors)
1289
+ .set({ movedTo: target })
1290
+ .where(eq(actors.apId, actor.ap_id)) as D1Statement,
1291
+ db.insert(activities).values({
1133
1292
  apId: moveActivityId,
1134
1293
  type: "Move",
1135
1294
  actorApId: actor.ap_id,
1136
1295
  objectApId: actor.ap_id,
1137
1296
  rawJson: JSON.stringify(moveActivity),
1138
1297
  direction: "outbound",
1139
- });
1298
+ }) as D1Statement,
1299
+ preparedFanout.statement,
1300
+ ]);
1301
+
1302
+ try {
1140
1303
  await enqueueFanoutToFollowers(c.env, moveActivityId, actor.ap_id);
1141
1304
  } catch (err) {
1142
- // Federation is best-effort; the local moved_to marker already persisted.
1305
+ // The moved_to marker, Activity, and semantic fanout are already one atomic
1306
+ // durable commit. Queue publication is only a best-effort wakeup.
1143
1307
  log.error("Failed to enqueue account Move federation", {
1144
1308
  event: "actors.account.move_federation_failed",
1145
1309
  actor: actor.ap_id,
@@ -1241,7 +1405,7 @@ actorsRoute.get("/me/export", async (c) => {
1241
1405
  fields: sanitizeProfileFields(safeJsonParse(profileRow.fieldsJson, [])),
1242
1406
  also_known_as: safeJsonParse<string[]>(profileRow.alsoKnownAsJson, []),
1243
1407
  moved_to: profileRow.movedTo,
1244
- username: formatUsername(profileRow.apId),
1408
+ username: formatUsername(profileRow.apId, profileRow.preferredUsername),
1245
1409
  },
1246
1410
  outbox: {
1247
1411
  type: "OrderedCollection",