@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
@@ -1,13 +1,14 @@
1
1
  import type { Context } from "hono";
2
2
  import { and, eq, sql } from "drizzle-orm";
3
3
  import type { Env, Variables } from "../types.ts";
4
- import type { Database } from "../../db/index.ts";
4
+ import type { D1Statement, Database } from "../../db/index.ts";
5
5
  import {
6
6
  activities,
7
7
  actorCache,
8
8
  actors,
9
9
  follows,
10
10
  inbox,
11
+ runBatch,
11
12
  } from "../../db/index.ts";
12
13
  import {
13
14
  activityApId,
@@ -16,6 +17,9 @@ import {
16
17
  isSafeRemoteUrl,
17
18
  } from "../federation-helpers.ts";
18
19
  import { enqueueDeliveryToActor } from "../lib/delivery/queue.ts";
20
+ import { prepareDeliveryResolutionJobs } from "../lib/delivery/resolution-outbox.ts";
21
+ import { isActorBlockedStrict } from "../lib/blocklist.ts";
22
+ import { isTrustedRemoteActivityId } from "../lib/remote-activity-id.ts";
19
23
  import {
20
24
  isUniqueConstraintError,
21
25
  parseJsonObject,
@@ -29,10 +33,6 @@ import { logger } from "../lib/logger.ts";
29
33
 
30
34
  const log = logger.child({ component: "follow.helpers" });
31
35
 
32
- // `.batch` lives only on the concrete D1/libsql subclasses, not the Database
33
- // union; reach it through a narrow structural cast (matching the other routes).
34
- type Batchable = { batch: (stmts: unknown[]) => Promise<unknown> };
35
-
36
36
  const REMOTE_FETCH_TIMEOUT_MS = 10000;
37
37
 
38
38
  export type HonoContext = Context<{ Bindings: Env; Variables: Variables }>;
@@ -121,9 +121,16 @@ export function isResponse(
121
121
  // ---------------------------------------------------------------------------
122
122
 
123
123
  /**
124
- * Creates an outbound AP activity record and enqueues it for delivery.
124
+ * Prepare an outbound Activity and its durable first-hop delivery intent so a
125
+ * relationship mutation can co-commit them in one D1 batch.
125
126
  */
126
- export async function createAndDeliverActivity(
127
+ export type PreparedActivityDelivery = {
128
+ readonly id: string;
129
+ readonly statements: readonly [D1Statement, ...D1Statement[]];
130
+ publish(): Promise<void>;
131
+ };
132
+
133
+ export async function prepareActivityDelivery(
127
134
  env: Env,
128
135
  db: Database,
129
136
  baseUrl: string,
@@ -132,20 +139,119 @@ export async function createAndDeliverActivity(
132
139
  object: unknown,
133
140
  recipientApId: string,
134
141
  objectApId?: string | null,
135
- ): Promise<void> {
142
+ ): Promise<PreparedActivityDelivery> {
136
143
  const id = activityApId(baseUrl, generateId());
137
144
  const activity = buildApActivity(type, actorId, object, id);
145
+ const recipientBlocked = await isActorBlockedStrict(db, recipientApId);
146
+ const preparedResolution = recipientBlocked
147
+ ? { statements: [] }
148
+ : await prepareDeliveryResolutionJobs(db, [
149
+ { activityId: id, recipientActorApId: recipientApId },
150
+ ]);
138
151
 
139
- await db.insert(activities).values({
140
- apId: id,
152
+ return {
153
+ id,
154
+ statements: [
155
+ db.insert(activities).values({
156
+ apId: id,
157
+ type,
158
+ actorApId: actorId,
159
+ objectApId: objectApId || undefined,
160
+ rawJson: JSON.stringify(activity),
161
+ direction: "outbound",
162
+ }) as D1Statement,
163
+ ...preparedResolution.statements,
164
+ ],
165
+ async publish() {
166
+ if (recipientBlocked) return;
167
+ try {
168
+ await enqueueDeliveryToActor(env, id, recipientApId);
169
+ } catch (error) {
170
+ log.error("Failed to publish prepared activity delivery wakeup", {
171
+ event: "follow.activity.delivery_wakeup_failed",
172
+ activityType: type,
173
+ activityId: id,
174
+ recipient: recipientApId,
175
+ error,
176
+ });
177
+ }
178
+ },
179
+ };
180
+ }
181
+
182
+ /** Creates an outbound AP activity and durable delivery intent. */
183
+ export async function createAndDeliverActivity(
184
+ env: Env,
185
+ db: Database,
186
+ baseUrl: string,
187
+ type: string,
188
+ actorId: string,
189
+ object: unknown,
190
+ recipientApId: string,
191
+ objectApId?: string | null,
192
+ ): Promise<void> {
193
+ const prepared = await prepareActivityDelivery(
194
+ env,
195
+ db,
196
+ baseUrl,
141
197
  type,
142
- actorApId: actorId,
143
- objectApId: objectApId || undefined,
144
- rawJson: JSON.stringify(activity),
145
- direction: "outbound",
146
- });
198
+ actorId,
199
+ object,
200
+ recipientApId,
201
+ objectApId,
202
+ );
203
+ await runBatch(db, prepared.statements);
204
+ await prepared.publish();
205
+ }
147
206
 
148
- await enqueueDeliveryToActor(env, id, recipientApId);
207
+ /**
208
+ * Resolve the peer-facing id for a follow edge's stored activity id.
209
+ *
210
+ * Inbound follow edges persist the origin-bound INTERNAL activity id (the
211
+ * inbox stamps `activity.id` before dispatch so no remote string escapes into
212
+ * internal keys). An Accept/Reject crosses the boundary in the OTHER
213
+ * direction: the remote peer matches `object` against the Follow id IT
214
+ * minted, so echoing our internal `.../inbound-<hash>` id leaves the peer's
215
+ * edge pending forever. The original envelope survives in
216
+ * activities.raw_json — echo its id, but only when it is a bounded id on the
217
+ * requester's own origin (the same trust rule the inbox applies on the way
218
+ * in). Anything else falls back to the stored id, which is already correct
219
+ * for local requesters and for the outbound-follow edges we minted ourselves.
220
+ */
221
+ export async function resolvePeerFollowActivityId(
222
+ db: Database,
223
+ requesterApId: string,
224
+ storedActivityApId: string | null,
225
+ ): Promise<string | null> {
226
+ if (!storedActivityApId) return storedActivityApId;
227
+ let rawJson: string | null | undefined;
228
+ try {
229
+ const row = await db
230
+ .select({ rawJson: activities.rawJson })
231
+ .from(activities)
232
+ .where(eq(activities.apId, storedActivityApId))
233
+ .get();
234
+ rawJson = row?.rawJson;
235
+ } catch {
236
+ return storedActivityApId;
237
+ }
238
+ if (!rawJson) return storedActivityApId;
239
+ let wireId: string;
240
+ try {
241
+ const parsed: unknown = JSON.parse(rawJson);
242
+ const candidate =
243
+ parsed && typeof parsed === "object"
244
+ ? (parsed as { id?: unknown }).id
245
+ : undefined;
246
+ if (typeof candidate !== "string") return storedActivityApId;
247
+ wireId = candidate;
248
+ } catch {
249
+ return storedActivityApId;
250
+ }
251
+ if (!isTrustedRemoteActivityId(wireId, requesterApId)) {
252
+ return storedActivityApId;
253
+ }
254
+ return wireId;
149
255
  }
150
256
 
151
257
  /**
@@ -161,17 +267,45 @@ export async function deliverResponseIfRemote(
161
267
  requesterApId: string,
162
268
  originalActivityApId: string | null,
163
269
  ): Promise<void> {
164
- if (isLocal(requesterApId, baseUrl)) return;
165
- await createAndDeliverActivity(
270
+ const prepared = await prepareResponseIfRemote(
166
271
  env,
167
272
  db,
168
273
  baseUrl,
169
274
  type,
170
275
  actorId,
276
+ requesterApId,
171
277
  originalActivityApId,
278
+ );
279
+ if (!prepared) return;
280
+ await runBatch(db, prepared.statements);
281
+ await prepared.publish();
282
+ }
283
+
284
+ export async function prepareResponseIfRemote(
285
+ env: Env,
286
+ db: Database,
287
+ baseUrl: string,
288
+ type: "Accept" | "Reject",
289
+ actorId: string,
290
+ requesterApId: string,
291
+ originalActivityApId: string | null,
292
+ ): Promise<PreparedActivityDelivery | null> {
293
+ if (isLocal(requesterApId, baseUrl)) return null;
294
+ const peerActivityApId = await resolvePeerFollowActivityId(
295
+ db,
172
296
  requesterApId,
173
297
  originalActivityApId,
174
298
  );
299
+ return await prepareActivityDelivery(
300
+ env,
301
+ db,
302
+ baseUrl,
303
+ type,
304
+ actorId,
305
+ peerActivityApId,
306
+ requesterApId,
307
+ peerActivityApId,
308
+ );
175
309
  }
176
310
 
177
311
  // ---------------------------------------------------------------------------
@@ -238,17 +372,31 @@ export async function handleLocalFollow(
238
372
  activityApId: id,
239
373
  acceptedAt: status === "accepted" ? now : null,
240
374
  });
375
+ const activityInsert = db.insert(activities).values({
376
+ apId: id,
377
+ type: "Follow",
378
+ actorApId: actor.ap_id,
379
+ objectApId: targetApId,
380
+ rawJson: JSON.stringify(followActivity),
381
+ direction: "local",
382
+ });
383
+ const inboxInsert = db.insert(inbox).values({
384
+ actorApId: targetApId,
385
+ activityApId: id,
386
+ read: 0,
387
+ });
241
388
 
242
389
  if (status === "accepted") {
243
- // Co-commit the edge insert + both increments in ONE batch so a crash
244
- // between them can't leave the edge accepted with un-bumped counts (the
245
- // retry would see the existing edge and skip the increment → permanent
246
- // under-count). Increments guarded by NOT EXISTS(edge) evaluated before
247
- // the in-batch insert so a concurrent duplicate can't double-count; the
248
- // bare insert still throws on a true duplicate, rolling back the whole
249
- // batch so the catch below returns the "Already following" 400.
390
+ // Co-commit the edge, both increments, ActivityPub ledger row, and target
391
+ // inbox notification in ONE batch. A later activity/inbox failure used to
392
+ // return 500 after the accepted relationship and counts had already
393
+ // committed; the route's existing-edge guard then made every retry a 400,
394
+ // permanently hiding the missing activity/notification. The whole local
395
+ // follow is one authority mutation, so a failure leaves nothing behind.
396
+ // Increments stay guarded by NOT EXISTS(edge), evaluated before the
397
+ // in-batch insert, so a concurrent duplicate cannot double-count.
250
398
  const edgeAbsent = sql`NOT EXISTS (SELECT 1 FROM ${follows} WHERE ${follows.followerApId} = ${actor.ap_id} AND ${follows.followingApId} = ${targetApId})`;
251
- await (db as unknown as Batchable).batch([
399
+ await runBatch(db, [
252
400
  db
253
401
  .update(actors)
254
402
  .set({ followingCount: sql`${actors.followingCount} + 1` })
@@ -258,25 +406,15 @@ export async function handleLocalFollow(
258
406
  .set({ followerCount: sql`${actors.followerCount} + 1` })
259
407
  .where(and(eq(actors.apId, targetApId), edgeAbsent)),
260
408
  followInsert,
409
+ activityInsert,
410
+ inboxInsert,
261
411
  ]);
262
412
  } else {
263
- await followInsert;
413
+ // A private-account request is the same atomic authority mutation minus
414
+ // accepted-edge counters: never retain an invisible pending request when
415
+ // its activity or inbox notification could not be stored.
416
+ await runBatch(db, [followInsert, activityInsert, inboxInsert]);
264
417
  }
265
-
266
- await db.insert(activities).values({
267
- apId: id,
268
- type: "Follow",
269
- actorApId: actor.ap_id,
270
- objectApId: targetApId,
271
- rawJson: JSON.stringify(followActivity),
272
- direction: "local",
273
- });
274
-
275
- await db.insert(inbox).values({
276
- actorApId: targetApId,
277
- activityApId: id,
278
- read: 0,
279
- });
280
418
  } catch (error) {
281
419
  if (isUniqueConstraintError(error)) {
282
420
  return c.json({ error: "Already following or pending" }, 400);
@@ -336,25 +474,27 @@ export async function handleRemoteFollow(
336
474
  return c.json({ error: "Invalid inbox URL" }, 400);
337
475
  }
338
476
 
339
- const id = activityApId(baseUrl, generateId());
340
- const followActivity = buildApActivity("Follow", actor.ap_id, targetApId, id);
477
+ const preparedDelivery = await prepareActivityDelivery(
478
+ c.env,
479
+ db,
480
+ baseUrl,
481
+ "Follow",
482
+ actor.ap_id,
483
+ targetApId,
484
+ targetApId,
485
+ targetApId,
486
+ );
341
487
 
342
488
  try {
343
- await db.insert(follows).values({
344
- followerApId: actor.ap_id,
345
- followingApId: targetApId,
346
- status: "pending",
347
- activityApId: id,
348
- });
349
-
350
- await db.insert(activities).values({
351
- apId: id,
352
- type: "Follow",
353
- actorApId: actor.ap_id,
354
- objectApId: targetApId,
355
- rawJson: JSON.stringify(followActivity),
356
- direction: "outbound",
357
- });
489
+ await runBatch(db, [
490
+ db.insert(follows).values({
491
+ followerApId: actor.ap_id,
492
+ followingApId: targetApId,
493
+ status: "pending",
494
+ activityApId: preparedDelivery.id,
495
+ }),
496
+ ...preparedDelivery.statements,
497
+ ]);
358
498
  } catch (e) {
359
499
  log.error("Failed to create remote follow", {
360
500
  event: "follow.remote.create_failed",
@@ -365,6 +505,6 @@ export async function handleRemoteFollow(
365
505
  return c.json({ error: "Failed to follow remote actor" }, 500);
366
506
  }
367
507
 
368
- await enqueueDeliveryToActor(c.env, id, targetApId);
508
+ await preparedDelivery.publish();
369
509
  return c.json({ success: true, status: "pending" });
370
510
  }