@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,336 @@
1
+ import { and, asc, eq, inArray, lte, sql } from "drizzle-orm";
2
+
3
+ import {
4
+ affectedRowCount,
5
+ deliveryFanouts,
6
+ type Database,
7
+ type D1Statement,
8
+ } from "../../../db/index.ts";
9
+ import type { IQueueProducer } from "../../runtime/queue.ts";
10
+ import type { Env } from "../../types.ts";
11
+ import type {
12
+ DeliveryFanoutCommunityMessageV1,
13
+ DeliveryFanoutFollowersMessageV1,
14
+ DeliveryQueueMessageV1,
15
+ } from "./types.ts";
16
+ import { sha256Hex } from "./transformers.ts";
17
+
18
+ const OUTBOX_SCAN_LIMIT = 50;
19
+ const TERMINAL_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
20
+ const TERMINAL_PURGE_LIMIT = 50;
21
+ const TERMINAL_STATUSES = ["completed", "failed", "discarded"] as const;
22
+
23
+ export type DeliveryFanoutIntent =
24
+ | {
25
+ readonly kind: "followers";
26
+ readonly activityId: string;
27
+ readonly targetApId: string;
28
+ }
29
+ | {
30
+ readonly kind: "community";
31
+ readonly activityId: string;
32
+ readonly targetApId: string;
33
+ readonly announceActivityId?: string;
34
+ };
35
+
36
+ export type DeliveryFanoutState = "missing" | "active" | "terminal";
37
+
38
+ export function deliveryFanoutIntentFromMessage(
39
+ message: DeliveryFanoutFollowersMessageV1 | DeliveryFanoutCommunityMessageV1,
40
+ ): DeliveryFanoutIntent {
41
+ if (message.type === "fanout_followers") {
42
+ return {
43
+ kind: "followers",
44
+ activityId: message.activityId,
45
+ targetApId: message.followeeApId,
46
+ };
47
+ }
48
+ return {
49
+ kind: "community",
50
+ activityId: message.activityId,
51
+ targetApId: message.communityApId,
52
+ ...(message.announceActivityId
53
+ ? { announceActivityId: message.announceActivityId }
54
+ : {}),
55
+ };
56
+ }
57
+
58
+ export async function computeDeliveryFanoutId(
59
+ intent: DeliveryFanoutIntent,
60
+ ): Promise<string> {
61
+ return await sha256Hex(
62
+ [
63
+ "fanout",
64
+ intent.kind,
65
+ intent.activityId,
66
+ intent.targetApId,
67
+ intent.kind === "community" ? (intent.announceActivityId ?? "") : "",
68
+ ].join("|"),
69
+ );
70
+ }
71
+
72
+ function buildFanoutMessage(row: {
73
+ readonly activityApId: string;
74
+ readonly kind: string;
75
+ readonly targetApId: string;
76
+ readonly announceActivityApId: string | null;
77
+ }): DeliveryFanoutFollowersMessageV1 | DeliveryFanoutCommunityMessageV1 {
78
+ const scheduledAt = new Date().toISOString();
79
+ if (row.kind === "followers") {
80
+ return {
81
+ version: 1,
82
+ type: "fanout_followers",
83
+ activityId: row.activityApId,
84
+ followeeApId: row.targetApId,
85
+ scheduledAt,
86
+ };
87
+ }
88
+ if (row.kind === "community") {
89
+ return {
90
+ version: 1,
91
+ type: "fanout_community",
92
+ activityId: row.activityApId,
93
+ communityApId: row.targetApId,
94
+ ...(row.announceActivityApId
95
+ ? { announceActivityId: row.announceActivityApId }
96
+ : {}),
97
+ scheduledAt,
98
+ };
99
+ }
100
+ throw new Error(`Unsupported delivery fanout kind: ${row.kind}`);
101
+ }
102
+
103
+ /**
104
+ * Build the deterministic fanout-ledger insert without executing it. Writers
105
+ * that commit the Activity and its local state can compose this statement into
106
+ * the same D1 batch, closing the crash window before the durable intent exists.
107
+ */
108
+ export async function prepareDeliveryFanoutJob(
109
+ db: Database,
110
+ intent: DeliveryFanoutIntent,
111
+ ): Promise<{ readonly id: string; readonly statement: D1Statement }> {
112
+ const id = await computeDeliveryFanoutId(intent);
113
+ const now = new Date().toISOString();
114
+ const statement = db
115
+ .insert(deliveryFanouts)
116
+ .values({
117
+ id,
118
+ activityApId: intent.activityId,
119
+ kind: intent.kind,
120
+ targetApId: intent.targetApId,
121
+ announceActivityApId:
122
+ intent.kind === "community"
123
+ ? (intent.announceActivityId ?? null)
124
+ : null,
125
+ status: "pending",
126
+ publications: 0,
127
+ createdAt: now,
128
+ updatedAt: now,
129
+ })
130
+ .onConflictDoNothing() as unknown as D1Statement;
131
+ return { id, statement };
132
+ }
133
+
134
+ /** Persist the semantic fanout intent before attempting any Queue RPC. */
135
+ export async function persistDeliveryFanoutJob(
136
+ db: Database,
137
+ intent: DeliveryFanoutIntent,
138
+ ): Promise<string> {
139
+ const prepared = await prepareDeliveryFanoutJob(db, intent);
140
+ await prepared.statement;
141
+ return prepared.id;
142
+ }
143
+
144
+ async function publishRows(
145
+ db: Database,
146
+ queue: IQueueProducer<DeliveryQueueMessageV1>,
147
+ rows: readonly {
148
+ readonly id: string;
149
+ readonly activityApId: string;
150
+ readonly kind: string;
151
+ readonly targetApId: string;
152
+ readonly announceActivityApId: string | null;
153
+ }[],
154
+ ): Promise<number> {
155
+ if (rows.length === 0) return 0;
156
+ await queue.sendBatch(rows.map((row) => ({ body: buildFanoutMessage(row) })));
157
+ await markRowsPublished(
158
+ db,
159
+ rows.map((row) => row.id),
160
+ );
161
+ return rows.length;
162
+ }
163
+
164
+ async function markRowsPublished(
165
+ db: Database,
166
+ ids: readonly string[],
167
+ ): Promise<void> {
168
+ if (ids.length === 0) return;
169
+ const now = new Date().toISOString();
170
+ await db
171
+ .update(deliveryFanouts)
172
+ .set({
173
+ status: "published",
174
+ publications: sql`${deliveryFanouts.publications} + 1`,
175
+ lastError: null,
176
+ updatedAt: now,
177
+ })
178
+ .where(
179
+ and(
180
+ inArray(deliveryFanouts.id, [...ids]),
181
+ inArray(deliveryFanouts.status, ["pending", "published"]),
182
+ ),
183
+ );
184
+ }
185
+
186
+ /** Persist one initial fanout intent and publish its wakeup when configured. */
187
+ export async function enqueueDeliveryFanoutJob(
188
+ db: Database,
189
+ queue: IQueueProducer<DeliveryQueueMessageV1> | undefined,
190
+ intent: DeliveryFanoutIntent,
191
+ ): Promise<number> {
192
+ const id = await persistDeliveryFanoutJob(db, intent);
193
+ if (!queue) return 0;
194
+ const row = await db
195
+ .select({
196
+ id: deliveryFanouts.id,
197
+ activityApId: deliveryFanouts.activityApId,
198
+ kind: deliveryFanouts.kind,
199
+ targetApId: deliveryFanouts.targetApId,
200
+ announceActivityApId: deliveryFanouts.announceActivityApId,
201
+ })
202
+ .from(deliveryFanouts)
203
+ .where(
204
+ and(eq(deliveryFanouts.id, id), eq(deliveryFanouts.status, "pending")),
205
+ )
206
+ .get();
207
+ if (!row) return 0;
208
+ // Preserve the original public producer behavior for the request path: one
209
+ // semantic fanout is one Queue send. Recovery uses bounded sendBatch below.
210
+ await queue.send(buildFanoutMessage(row));
211
+ await markRowsPublished(db, [row.id]);
212
+ return 1;
213
+ }
214
+
215
+ /** Recover pending fanout wakeups; Bun startup may also replay published rows. */
216
+ export async function enqueuePendingDeliveryFanoutJobs(
217
+ env: Env,
218
+ options: { readonly includePublished?: boolean } = {},
219
+ ): Promise<number> {
220
+ await purgeTerminalDeliveryFanoutJobs(env.DB_INSTANCE);
221
+ if (!env.DELIVERY_QUEUE || !env.DELIVERY_DLQ) return 0;
222
+ const statuses = options.includePublished
223
+ ? ["pending", "published"]
224
+ : ["pending"];
225
+ const rows = await env.DB_INSTANCE.select({
226
+ id: deliveryFanouts.id,
227
+ activityApId: deliveryFanouts.activityApId,
228
+ kind: deliveryFanouts.kind,
229
+ targetApId: deliveryFanouts.targetApId,
230
+ announceActivityApId: deliveryFanouts.announceActivityApId,
231
+ })
232
+ .from(deliveryFanouts)
233
+ .where(inArray(deliveryFanouts.status, statuses))
234
+ .orderBy(asc(deliveryFanouts.createdAt))
235
+ .limit(OUTBOX_SCAN_LIMIT);
236
+ return await publishRows(env.DB_INSTANCE, env.DELIVERY_QUEUE, rows);
237
+ }
238
+
239
+ export async function getDeliveryFanoutState(
240
+ db: Database,
241
+ intent: DeliveryFanoutIntent,
242
+ ): Promise<DeliveryFanoutState> {
243
+ const id = await computeDeliveryFanoutId(intent);
244
+ const row = await db
245
+ .select({ status: deliveryFanouts.status })
246
+ .from(deliveryFanouts)
247
+ .where(eq(deliveryFanouts.id, id))
248
+ .get();
249
+ if (!row) return "missing";
250
+ return (TERMINAL_STATUSES as readonly string[]).includes(row.status)
251
+ ? "terminal"
252
+ : "active";
253
+ }
254
+
255
+ async function setDeliveryFanoutStatus(
256
+ db: Database,
257
+ intent: DeliveryFanoutIntent,
258
+ status: "pending" | "completed" | "failed" | "discarded",
259
+ reason?: string,
260
+ ): Promise<boolean> {
261
+ const id = await computeDeliveryFanoutId(intent);
262
+ const terminal = status !== "pending";
263
+ const now = new Date().toISOString();
264
+ const result = await db
265
+ .update(deliveryFanouts)
266
+ .set({
267
+ status,
268
+ lastError: reason ?? null,
269
+ updatedAt: now,
270
+ completedAt: terminal ? now : null,
271
+ })
272
+ .where(
273
+ and(
274
+ eq(deliveryFanouts.id, id),
275
+ inArray(deliveryFanouts.status, ["pending", "published"]),
276
+ ),
277
+ );
278
+ return affectedRowCount(result) > 0;
279
+ }
280
+
281
+ export async function resetDeliveryFanoutJob(
282
+ db: Database,
283
+ intent: DeliveryFanoutIntent,
284
+ reason?: string,
285
+ ): Promise<boolean> {
286
+ return await setDeliveryFanoutStatus(db, intent, "pending", reason);
287
+ }
288
+
289
+ export async function completeDeliveryFanoutJob(
290
+ db: Database,
291
+ intent: DeliveryFanoutIntent,
292
+ status: "completed" | "discarded" = "completed",
293
+ reason?: string,
294
+ ): Promise<boolean> {
295
+ return await setDeliveryFanoutStatus(db, intent, status, reason);
296
+ }
297
+
298
+ export async function failDeliveryFanoutJob(
299
+ db: Database,
300
+ intent: DeliveryFanoutIntent,
301
+ reason: string,
302
+ ): Promise<boolean> {
303
+ return await setDeliveryFanoutStatus(db, intent, "failed", reason);
304
+ }
305
+
306
+ export async function purgeTerminalDeliveryFanoutJobs(
307
+ db: Database,
308
+ nowDate = new Date(),
309
+ ): Promise<number> {
310
+ const cutoff = new Date(
311
+ nowDate.getTime() - TERMINAL_RETENTION_MS,
312
+ ).toISOString();
313
+ const rows = await db
314
+ .select({ id: deliveryFanouts.id })
315
+ .from(deliveryFanouts)
316
+ .where(
317
+ and(
318
+ inArray(deliveryFanouts.status, [...TERMINAL_STATUSES]),
319
+ lte(deliveryFanouts.updatedAt, cutoff),
320
+ ),
321
+ )
322
+ .orderBy(asc(deliveryFanouts.updatedAt))
323
+ .limit(TERMINAL_PURGE_LIMIT);
324
+ if (rows.length === 0) return 0;
325
+ const result = await db.delete(deliveryFanouts).where(
326
+ and(
327
+ inArray(
328
+ deliveryFanouts.id,
329
+ rows.map((row) => row.id),
330
+ ),
331
+ inArray(deliveryFanouts.status, [...TERMINAL_STATUSES]),
332
+ lte(deliveryFanouts.updatedAt, cutoff),
333
+ ),
334
+ );
335
+ return affectedRowCount(result);
336
+ }
@@ -7,11 +7,12 @@ import {
7
7
  safeParseIsoTimeMs,
8
8
  } from "./transformers.ts";
9
9
  import { isSafeRemoteUrl } from "../../federation-helpers.ts";
10
- import { filterBlockedActorApIds } from "../blocklist.ts";
10
+ import { filterBlockedActorApIdsStrict } from "../blocklist.ts";
11
11
 
12
12
  export type PlannedEndpointGroup = {
13
13
  endpoint: string;
14
14
  recipientCount: number;
15
+ recipientActorApIds: string[];
15
16
  };
16
17
 
17
18
  export type PlanEndpointsResult = {
@@ -82,13 +83,16 @@ export async function planEndpointsFromActorCache(
82
83
  // Enforce the operator blocklist on the OUTBOUND side too: drop any
83
84
  // recipient whose actor AP-ID or hostname is defederated BEFORE grouping
84
85
  // endpoints, so a blocked domain/actor never receives our posts or DMs.
85
- // Uses the same isActorBlocked check (which also covers the hostname
86
- // transitively) that the inbox handler uses inbound.
86
+ // Uses the same actor + hostname authority as the single-actor path. A read
87
+ // failure propagates so fanout retries instead of bypassing defederation.
87
88
  const allowedRecipients: string[] = [];
88
89
  const blockedRecipients: string[] = [];
89
90
  // Batched blocklist filter (2 queries) instead of a serial isActorBlocked
90
91
  // per recipient (2 queries each → up to ~400 round-trips per fan-out page).
91
- const blockedSet = await filterBlockedActorApIds(db, recipientActorApIds);
92
+ const blockedSet = await filterBlockedActorApIdsStrict(
93
+ db,
94
+ recipientActorApIds,
95
+ );
92
96
  for (const apId of recipientActorApIds) {
93
97
  if (blockedSet.has(apId)) {
94
98
  blockedRecipients.push(apId);
@@ -141,7 +145,7 @@ export async function planEndpointsFromActorCache(
141
145
 
142
146
  const byApId = new Map(rows.map((r) => [r.apId, r as ActorCacheRow]));
143
147
  const unknownRecipients: string[] = [];
144
- const endpointCounts = new Map<string, number>();
148
+ const endpointRecipients = new Map<string, string[]>();
145
149
  let sharedInboxRecipients = 0;
146
150
 
147
151
  for (const apId of allowedRecipients) {
@@ -155,17 +159,17 @@ export async function planEndpointsFromActorCache(
155
159
  }
156
160
 
157
161
  if (chosen.usedSharedInbox) sharedInboxRecipients++;
158
- endpointCounts.set(
159
- chosen.endpoint,
160
- (endpointCounts.get(chosen.endpoint) ?? 0) + 1,
161
- );
162
+ const groupedRecipients = endpointRecipients.get(chosen.endpoint);
163
+ if (groupedRecipients) groupedRecipients.push(apId);
164
+ else endpointRecipients.set(chosen.endpoint, [apId]);
162
165
  }
163
166
 
164
167
  const groups: PlannedEndpointGroup[] = Array.from(
165
- endpointCounts.entries(),
166
- ).map(([endpoint, recipientCount]) => ({
168
+ endpointRecipients.entries(),
169
+ ).map(([endpoint, recipientActorApIds]) => ({
167
170
  endpoint,
168
- recipientCount,
171
+ recipientCount: recipientActorApIds.length,
172
+ recipientActorApIds,
169
173
  }));
170
174
 
171
175
  // Observability: sharedInbox aggregation ratio.