@takosjp/yurucommu-core 3.3.0 → 3.4.1

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 (52) hide show
  1. package/migrations/0022_inbound_dispatch_claims.sql +17 -0
  2. package/package.json +3 -2
  3. package/packages/api/package.json +1 -1
  4. package/packages/api/src/index.ts +1 -0
  5. package/packages/api/src/lib/api/notifications.ts +1 -0
  6. package/packages/api/src/lib/api/posts.ts +1 -0
  7. package/packages/api/src/lib/rtc-client.ts +1 -3
  8. package/packages/api/src/types/call.ts +2 -10
  9. package/packages/api/src/types/index.ts +3 -0
  10. package/packages/api/src/types/realtime.ts +139 -0
  11. package/src/backend/index.ts +54 -12
  12. package/src/backend/lib/delivery/queue-batching.ts +53 -36
  13. package/src/backend/lib/delivery/queue-delivery.ts +3 -3
  14. package/src/backend/lib/delivery/queue.ts +17 -9
  15. package/src/backend/lib/delivery/types.ts +12 -0
  16. package/src/backend/lib/notification-push.ts +2 -2
  17. package/src/backend/lib/oauth-providers.ts +9 -0
  18. package/src/backend/lib/strip-image-metadata.ts +50 -30
  19. package/src/backend/lib/unread-counts.ts +63 -2
  20. package/src/backend/middleware/bearer-auth.ts +24 -9
  21. package/src/backend/public.ts +25 -1
  22. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +4 -3
  23. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +204 -5
  24. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +78 -53
  25. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +66 -2
  26. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +35 -12
  27. package/src/backend/routes/activitypub/inbox-addressing.ts +236 -0
  28. package/src/backend/routes/activitypub/inbox-types.ts +8 -0
  29. package/src/backend/routes/activitypub/inbox.ts +410 -205
  30. package/src/backend/routes/activitypub/outbox.ts +0 -0
  31. package/src/backend/routes/auth.ts +2 -1
  32. package/src/backend/routes/communities/messages.ts +53 -17
  33. package/src/backend/routes/dm/messages.ts +47 -7
  34. package/src/backend/routes/dm/read-archive.ts +27 -0
  35. package/src/backend/routes/dm/typing.ts +16 -0
  36. package/src/backend/routes/notifications.ts +25 -0
  37. package/src/backend/routes/posts/post-helpers.ts +42 -23
  38. package/src/backend/routes/realtime/index.ts +67 -0
  39. package/src/backend/routes/rtc/index.ts +5 -1
  40. package/src/backend/runtime/call-hub-core.ts +13 -3
  41. package/src/backend/runtime/cloudflare.ts +63 -2
  42. package/src/backend/runtime/managed-relational.ts +197 -0
  43. package/src/backend/runtime/managed-runtime.ts +631 -0
  44. package/src/backend/runtime/queue.ts +40 -0
  45. package/src/backend/runtime/realtime-hub.ts +257 -0
  46. package/src/backend/runtime/realtime-stream-do.ts +323 -0
  47. package/src/backend/server.ts +15 -18
  48. package/src/backend/types.ts +13 -2
  49. package/src/db/d1-write.ts +270 -0
  50. package/src/db/index.ts +17 -0
  51. package/src/db/schema/federation.ts +19 -0
  52. package/src/db/schema/index.ts +1 -0
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Shared-inbox addressing: which LOCAL actors is an inbound activity for?
3
+ *
4
+ * The old model was an exception list. Six types were "recipient independent"
5
+ * and dispatched once; everything else fell through to "fan out to every local
6
+ * actor that follows the SENDER". That fallback is a guess, and it is wrong in
7
+ * both directions:
8
+ *
9
+ * - A DM or a Like from someone the recipient does not follow resolved to
10
+ * ZERO recipients. The route then committed `processed = 1`, so the
11
+ * activity was permanently un-redeliverable — a silent drop with a 202.
12
+ * Our own delivery planner always prefers `endpoints.sharedInbox`, and so
13
+ * does Mastodon, so this is the NORMAL inbound path, not an edge case.
14
+ * - `Like` / `Announce` were fanned out once PER LOCAL FOLLOWER even though
15
+ * their handlers take `_recipient` and resolve the target from the object's
16
+ * `attributedTo`. Every extra follower was one more redundant whole-row
17
+ * counter recompute.
18
+ *
19
+ * The replacement is a total function over the handled activity types. Every
20
+ * type declares its addressing class, `satisfies` makes the map exhaustive, and
21
+ * a new handler therefore cannot be added without declaring where it is
22
+ * addressed — the enforcement is `bunx tsc --noEmit`, not a lint script.
23
+ */
24
+
25
+ import { and, eq, inArray } from "drizzle-orm";
26
+ import type { Database } from "../../../db/index.ts";
27
+ import { actors, follows, inChunks } from "../../../db/index.ts";
28
+ import { isLocal } from "../../federation-helpers.ts";
29
+ import type { Activity } from "./inbox-types.ts";
30
+ import {
31
+ addressesPublic,
32
+ MAX_ADDRESS_ENTRIES,
33
+ } from "./handlers/inbox-content-handlers.ts";
34
+
35
+ export type ActorRow = typeof actors.$inferSelect;
36
+
37
+ /**
38
+ * How an inbound activity names its recipients.
39
+ *
40
+ * `audience` is never DECLARED — it is only ever the RESULT of resolving an
41
+ * `addressed` activity whose addressing actually contains a Public or
42
+ * followers-collection marker. That distinction is the whole point: the old
43
+ * code performed a follower fan-out because it had found no other recipient,
44
+ * rather than because the activity asked for one.
45
+ */
46
+ export type AddressingClass =
47
+ "instance" | "object-actor" | "addressed" | "audience";
48
+
49
+ export type DeclaredAddressingClass = Exclude<AddressingClass, "audience">;
50
+
51
+ /** Activity types `dispatchUserActivity` handles. */
52
+ export const HANDLED_ACTIVITY_TYPES = [
53
+ "Follow",
54
+ "Accept",
55
+ "Undo",
56
+ "Like",
57
+ "Create",
58
+ "Delete",
59
+ "Announce",
60
+ "Update",
61
+ "Reject",
62
+ "Add",
63
+ "Remove",
64
+ "Block",
65
+ "Flag",
66
+ "Move",
67
+ ] as const;
68
+
69
+ export type HandledActivityType = (typeof HANDLED_ACTIVITY_TYPES)[number];
70
+
71
+ /**
72
+ * The addressing declaration. `satisfies` keeps it total: adding a handler to
73
+ * {@link HANDLED_ACTIVITY_TYPES} without a class here is a type error.
74
+ *
75
+ * `Like` and `Announce` are `instance`: their handlers already ignore the
76
+ * recipient argument and resolve the affected object (and its owner) from
77
+ * `activity.object`, so dispatching them once is not a behaviour change — it
78
+ * only removes the per-follower duplication.
79
+ *
80
+ * `Undo` is declared `object-actor` because Undo(Follow|Block) must reach the
81
+ * followed/blocked actor. Undo(Like|Announce) carries no local actor target and
82
+ * is actor-keyed + idempotent, so the resolver downgrades it to `instance`.
83
+ */
84
+ export const ACTIVITY_ADDRESSING = {
85
+ Accept: "instance",
86
+ Delete: "instance",
87
+ Update: "instance",
88
+ Reject: "instance",
89
+ Flag: "instance",
90
+ Move: "instance",
91
+ Like: "instance",
92
+ Announce: "instance",
93
+
94
+ Follow: "object-actor",
95
+ Block: "object-actor",
96
+ Undo: "object-actor",
97
+
98
+ Create: "addressed",
99
+ Add: "addressed",
100
+ Remove: "addressed",
101
+ } satisfies Record<HandledActivityType, DeclaredAddressingClass>;
102
+
103
+ export function isHandledActivityType(
104
+ type: string,
105
+ ): type is HandledActivityType {
106
+ return type in ACTIVITY_ADDRESSING;
107
+ }
108
+
109
+ /**
110
+ * Every IRI the activity uses to name an audience, from the ENVELOPE and from
111
+ * an embedded object. The envelope fields used to be discarded by
112
+ * `parseActivity`, which is why a DM's addressing never reached routing.
113
+ *
114
+ * `bto`/`bcc` are included: a peer that strips them before delivery simply
115
+ * contributes nothing, and one that does not must still be honoured.
116
+ */
117
+ export function collectAddresses(activity: Activity): string[] {
118
+ const object = typeof activity.object === "string" ? null : activity.object;
119
+ const all = [
120
+ ...(activity.to ?? []),
121
+ ...(activity.cc ?? []),
122
+ ...(activity.bto ?? []),
123
+ ...(activity.bcc ?? []),
124
+ ...(activity.audience ?? []),
125
+ ...(object?.to ?? []),
126
+ ...(object?.cc ?? []),
127
+ ];
128
+ // Same bound the persisted addressing arrays use (policy defined once, in
129
+ // inbox-content-handlers): a remote must not be able to make one delivery
130
+ // cost an unbounded number of actor lookups.
131
+ return [...new Set(all)].slice(0, MAX_ADDRESS_ENTRIES);
132
+ }
133
+
134
+ /**
135
+ * A followers-collection IRI. Matched by suffix, exactly like the visibility
136
+ * classifier in inbox-content-handlers — resolving the sender's real
137
+ * `followers` URL would need a network fetch for a remote actor.
138
+ */
139
+ function addressesFollowersCollection(addresses: readonly string[]): boolean {
140
+ return addresses.some((a) => a.endsWith("/followers"));
141
+ }
142
+
143
+ export type AddressingResolution = {
144
+ /** The class actually applied (may be `audience`, which is never declared). */
145
+ readonly cls: AddressingClass;
146
+ readonly recipients: readonly ActorRow[];
147
+ /** Addressing IRIs read off the activity, for logging. */
148
+ readonly addresses: readonly string[];
149
+ };
150
+
151
+ /**
152
+ * Local actors explicitly named in the addressing.
153
+ */
154
+ async function resolveAddressedLocalActors(
155
+ db: Database,
156
+ addresses: readonly string[],
157
+ baseUrl: string,
158
+ ): Promise<ActorRow[]> {
159
+ const localIris = addresses.filter(
160
+ (a) => isLocal(a, baseUrl) && !addressesPublic([a]),
161
+ );
162
+ if (localIris.length === 0) return [];
163
+ // Bounded by MAX_ADDRESS_ENTRIES, but routed through inChunks anyway so the
164
+ // D1 parameter rule holds if that bound is ever raised.
165
+ return await inChunks(localIris, (chunk) =>
166
+ db.query.actors.findMany({ where: inArray(actors.apId, [...chunk]) }),
167
+ );
168
+ }
169
+
170
+ /**
171
+ * Local actors with an accepted follow of `senderApId`.
172
+ */
173
+ async function resolveLocalFollowers(
174
+ db: Database,
175
+ senderApId: string,
176
+ baseUrl: string,
177
+ limit: number,
178
+ ): Promise<ActorRow[]> {
179
+ const rows = await db
180
+ .select({ followerApId: follows.followerApId })
181
+ .from(follows)
182
+ .where(
183
+ and(
184
+ eq(follows.followingApId, senderApId),
185
+ eq(follows.status, "accepted"),
186
+ ),
187
+ )
188
+ .limit(limit);
189
+
190
+ const localFollowerApIds = rows
191
+ .map((row) => row.followerApId)
192
+ .filter((apId) => isLocal(apId, baseUrl));
193
+ if (localFollowerApIds.length === 0) return [];
194
+
195
+ return await inChunks(localFollowerApIds, (chunk) =>
196
+ db.query.actors.findMany({ where: inArray(actors.apId, [...chunk]) }),
197
+ );
198
+ }
199
+
200
+ /**
201
+ * Resolve an `addressed` activity's local recipients.
202
+ *
203
+ * A follower fan-out happens ONLY when the activity actually addresses Public
204
+ * or a followers collection (`audience`). An activity that names specific
205
+ * actors resolves to exactly those (`addressed`). An activity that names
206
+ * neither resolves to nothing, and the caller must treat that as UNDELIVERABLE
207
+ * rather than as a completed no-op — the old code's `processed = 1` on that
208
+ * branch is what made non-follower DMs unrecoverable.
209
+ */
210
+ export async function resolveAddressedRecipients(
211
+ db: Database,
212
+ activity: Activity,
213
+ senderApId: string,
214
+ baseUrl: string,
215
+ followerFanoutLimit: number,
216
+ ): Promise<AddressingResolution> {
217
+ const addresses = collectAddresses(activity);
218
+ const addressed = await resolveAddressedLocalActors(db, addresses, baseUrl);
219
+
220
+ const wantsAudience =
221
+ addressesPublic([...addresses]) || addressesFollowersCollection(addresses);
222
+
223
+ if (!wantsAudience) {
224
+ return { cls: "addressed", recipients: addressed, addresses };
225
+ }
226
+
227
+ const followers = await resolveLocalFollowers(
228
+ db,
229
+ senderApId,
230
+ baseUrl,
231
+ followerFanoutLimit,
232
+ );
233
+ const byApId = new Map<string, ActorRow>();
234
+ for (const row of [...addressed, ...followers]) byApId.set(row.apId, row);
235
+ return { cls: "audience", recipients: [...byApId.values()], addresses };
236
+ }
@@ -29,6 +29,14 @@ export type Activity = {
29
29
  object?: string | ActivityObject;
30
30
  target?: string | ActivityObject;
31
31
  room?: string;
32
+ // Envelope addressing, preserved by parseActivity. Shared-inbox routing is
33
+ // derived from these (inbox-addressing.ts); before they were parsed the
34
+ // route had to guess the recipients from the sender's follower graph.
35
+ to?: string[];
36
+ cc?: string[];
37
+ bto?: string[];
38
+ bcc?: string[];
39
+ audience?: string[];
32
40
  };
33
41
 
34
42
  export type RemoteActor = {