@takosjp/yurucommu-core 3.0.0

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 (185) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +82 -0
  3. package/migrations/0001_init.sql +495 -0
  4. package/migrations/0002_social_remote_actor_edges.sql +92 -0
  5. package/migrations/0003_activity_remote_object_edges.sql +68 -0
  6. package/migrations/0004_blocklist.sql +26 -0
  7. package/migrations/0005_story_community_scope.sql +13 -0
  8. package/migrations/0006_dm_community_read_status.sql +19 -0
  9. package/migrations/0007_moderation_reports.sql +22 -0
  10. package/migrations/0008_actor_fields_aka.sql +18 -0
  11. package/migrations/0009_object_tags.sql +13 -0
  12. package/migrations/0010_object_recipients_drop_actor_fk.sql +34 -0
  13. package/migrations/0011_drop_remote_actor_fks.sql +205 -0
  14. package/migrations/0012_objects_content_fts.sql +39 -0
  15. package/migrations/0013_efficiency_indexes.sql +13 -0
  16. package/migrations/0014_inbox_actor_created_idx.sql +15 -0
  17. package/migrations/0015_community_bans.sql +16 -0
  18. package/migrations/0016_namespace_takos_oidc_subject.sql +19 -0
  19. package/migrations/0017_mobile_push_registrations.sql +22 -0
  20. package/migrations/README.md +122 -0
  21. package/package.json +75 -0
  22. package/packages/api/LICENSE +16 -0
  23. package/packages/api/package.json +30 -0
  24. package/packages/api/src/index.ts +4 -0
  25. package/packages/api/src/lib/api/account.ts +20 -0
  26. package/packages/api/src/lib/api/actors.ts +149 -0
  27. package/packages/api/src/lib/api/auth.ts +46 -0
  28. package/packages/api/src/lib/api/communities.ts +329 -0
  29. package/packages/api/src/lib/api/dm.test.ts +67 -0
  30. package/packages/api/src/lib/api/dm.ts +236 -0
  31. package/packages/api/src/lib/api/fetch.ts +111 -0
  32. package/packages/api/src/lib/api/follow.ts +30 -0
  33. package/packages/api/src/lib/api/media.ts +100 -0
  34. package/packages/api/src/lib/api/moderation.ts +98 -0
  35. package/packages/api/src/lib/api/normalize.ts +71 -0
  36. package/packages/api/src/lib/api/notifications.test.ts +63 -0
  37. package/packages/api/src/lib/api/notifications.ts +61 -0
  38. package/packages/api/src/lib/api/posts.test.ts +110 -0
  39. package/packages/api/src/lib/api/posts.ts +181 -0
  40. package/packages/api/src/lib/api/recommendations.ts +22 -0
  41. package/packages/api/src/lib/api/search.ts +88 -0
  42. package/packages/api/src/lib/api/stories.ts +80 -0
  43. package/packages/api/src/lib/api.ts +15 -0
  44. package/packages/api/src/lib/fetch-with-timeout.ts +42 -0
  45. package/packages/api/src/lib/transport.ts +40 -0
  46. package/packages/api/src/social-server.ts +47 -0
  47. package/packages/api/src/types/index.ts +185 -0
  48. package/scripts/apply-takosumi-migrations.ts +621 -0
  49. package/src/backend/federation-helpers.ts +36 -0
  50. package/src/backend/index.ts +872 -0
  51. package/src/backend/lib/account-migration.ts +106 -0
  52. package/src/backend/lib/activitypub-actor-cache.ts +238 -0
  53. package/src/backend/lib/activitypub-helpers.ts +131 -0
  54. package/src/backend/lib/activitypub-validators.ts +323 -0
  55. package/src/backend/lib/ap-context.ts +16 -0
  56. package/src/backend/lib/ap-ids.ts +101 -0
  57. package/src/backend/lib/ap-response.ts +30 -0
  58. package/src/backend/lib/ap-signing.ts +87 -0
  59. package/src/backend/lib/ap-verify.ts +670 -0
  60. package/src/backend/lib/auth-lockout.ts +230 -0
  61. package/src/backend/lib/backend-paths.ts +34 -0
  62. package/src/backend/lib/base64.ts +30 -0
  63. package/src/backend/lib/blocklist-purge.ts +109 -0
  64. package/src/backend/lib/blocklist.ts +279 -0
  65. package/src/backend/lib/chunk.ts +33 -0
  66. package/src/backend/lib/client-ip.ts +169 -0
  67. package/src/backend/lib/community-visibility.ts +230 -0
  68. package/src/backend/lib/crypto.ts +424 -0
  69. package/src/backend/lib/delivery/circuit.ts +265 -0
  70. package/src/backend/lib/delivery/metrics.ts +30 -0
  71. package/src/backend/lib/delivery/planner.ts +190 -0
  72. package/src/backend/lib/delivery/queue-batching.ts +626 -0
  73. package/src/backend/lib/delivery/queue-delivery.ts +641 -0
  74. package/src/backend/lib/delivery/queue.ts +576 -0
  75. package/src/backend/lib/delivery/transformers.ts +56 -0
  76. package/src/backend/lib/delivery/types.ts +139 -0
  77. package/src/backend/lib/errors.ts +114 -0
  78. package/src/backend/lib/federation-fetch.ts +296 -0
  79. package/src/backend/lib/feed-cursor.ts +57 -0
  80. package/src/backend/lib/feed-exclude.ts +48 -0
  81. package/src/backend/lib/hex.ts +8 -0
  82. package/src/backend/lib/log-mask.ts +213 -0
  83. package/src/backend/lib/logger.ts +285 -0
  84. package/src/backend/lib/mobile-contract.ts +137 -0
  85. package/src/backend/lib/oauth-providers.ts +324 -0
  86. package/src/backend/lib/oauth-utils.ts +148 -0
  87. package/src/backend/lib/oidc-id-token.ts +151 -0
  88. package/src/backend/lib/parse-helpers.ts +31 -0
  89. package/src/backend/lib/post-visibility.ts +190 -0
  90. package/src/backend/lib/session-actor.ts +61 -0
  91. package/src/backend/lib/ssrf.ts +428 -0
  92. package/src/backend/lib/strip-image-metadata.ts +191 -0
  93. package/src/backend/middleware/bearer-auth.ts +70 -0
  94. package/src/backend/middleware/body-limit.ts +212 -0
  95. package/src/backend/middleware/cache.ts +429 -0
  96. package/src/backend/middleware/csrf.ts +130 -0
  97. package/src/backend/middleware/error-handler.ts +77 -0
  98. package/src/backend/middleware/rate-limit.ts +308 -0
  99. package/src/backend/public.ts +21 -0
  100. package/src/backend/routes/account-teardown.ts +430 -0
  101. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +354 -0
  102. package/src/backend/routes/activitypub/handlers/inbound-timestamp.ts +29 -0
  103. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1634 -0
  104. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +547 -0
  105. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +497 -0
  106. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +262 -0
  107. package/src/backend/routes/activitypub/handlers/user-inbox-handlers.ts +35 -0
  108. package/src/backend/routes/activitypub/inbox-types.ts +74 -0
  109. package/src/backend/routes/activitypub/inbox.ts +1191 -0
  110. package/src/backend/routes/activitypub/outbox.ts +0 -0
  111. package/src/backend/routes/activitypub/query-helpers.ts +227 -0
  112. package/src/backend/routes/activitypub.ts +616 -0
  113. package/src/backend/routes/actors-helpers.ts +487 -0
  114. package/src/backend/routes/actors.ts +1311 -0
  115. package/src/backend/routes/apps.ts +313 -0
  116. package/src/backend/routes/auth-helpers.ts +566 -0
  117. package/src/backend/routes/auth.ts +615 -0
  118. package/src/backend/routes/communities/membership-invites.ts +208 -0
  119. package/src/backend/routes/communities/membership-join.ts +335 -0
  120. package/src/backend/routes/communities/membership-members.ts +539 -0
  121. package/src/backend/routes/communities/membership-requests.ts +296 -0
  122. package/src/backend/routes/communities/membership-shared.ts +364 -0
  123. package/src/backend/routes/communities/messages.ts +479 -0
  124. package/src/backend/routes/communities/routes.ts +624 -0
  125. package/src/backend/routes/communities.ts +21 -0
  126. package/src/backend/routes/dm/contacts.ts +525 -0
  127. package/src/backend/routes/dm/conversations-helpers.ts +197 -0
  128. package/src/backend/routes/dm/conversations.ts +25 -0
  129. package/src/backend/routes/dm/messages.ts +658 -0
  130. package/src/backend/routes/dm/query-helpers.ts +85 -0
  131. package/src/backend/routes/dm/read-archive.ts +228 -0
  132. package/src/backend/routes/dm/requests.ts +222 -0
  133. package/src/backend/routes/dm/typing.ts +81 -0
  134. package/src/backend/routes/dm.ts +15 -0
  135. package/src/backend/routes/follow-helpers.ts +370 -0
  136. package/src/backend/routes/follow.ts +588 -0
  137. package/src/backend/routes/media.ts +692 -0
  138. package/src/backend/routes/mobile.ts +159 -0
  139. package/src/backend/routes/moderation.ts +373 -0
  140. package/src/backend/routes/notifications.ts +757 -0
  141. package/src/backend/routes/posts/delete-cascade.ts +330 -0
  142. package/src/backend/routes/posts/interactions.ts +795 -0
  143. package/src/backend/routes/posts/post-helpers.ts +847 -0
  144. package/src/backend/routes/posts/queries.ts +537 -0
  145. package/src/backend/routes/posts/routes.ts +865 -0
  146. package/src/backend/routes/posts/transformers.ts +161 -0
  147. package/src/backend/routes/posts.ts +17 -0
  148. package/src/backend/routes/recommendations.ts +88 -0
  149. package/src/backend/routes/search.ts +730 -0
  150. package/src/backend/routes/stories/interactions.ts +576 -0
  151. package/src/backend/routes/stories/query-helpers.ts +482 -0
  152. package/src/backend/routes/stories/routes.ts +906 -0
  153. package/src/backend/routes/stories.ts +13 -0
  154. package/src/backend/routes/takos-tools/dm.ts +249 -0
  155. package/src/backend/routes/takos-tools/follows.ts +225 -0
  156. package/src/backend/routes/takos-tools/posts.ts +292 -0
  157. package/src/backend/routes/takos-tools/search.ts +228 -0
  158. package/src/backend/routes/takos-tools/timeline.ts +132 -0
  159. package/src/backend/routes/takos-tools/types.ts +10 -0
  160. package/src/backend/routes/takos-tools-response.ts +178 -0
  161. package/src/backend/routes/takos-tools.ts +153 -0
  162. package/src/backend/routes/timeline.ts +755 -0
  163. package/src/backend/runtime/bun.ts +620 -0
  164. package/src/backend/runtime/cloudflare.ts +202 -0
  165. package/src/backend/runtime/compat-bun/types.ts +44 -0
  166. package/src/backend/runtime/memory-kv.ts +104 -0
  167. package/src/backend/runtime/shared.ts +142 -0
  168. package/src/backend/runtime/types.ts +205 -0
  169. package/src/backend/server.ts +636 -0
  170. package/src/backend/types.ts +143 -0
  171. package/src/db/index.ts +97 -0
  172. package/src/db/schema/actors.ts +129 -0
  173. package/src/db/schema/communities.ts +133 -0
  174. package/src/db/schema/date-utils.ts +17 -0
  175. package/src/db/schema/index.ts +17 -0
  176. package/src/db/schema/messaging.ts +241 -0
  177. package/src/db/schema/mobile.ts +37 -0
  178. package/src/db/schema/posts.ts +150 -0
  179. package/src/db/schema/relations.ts +266 -0
  180. package/src/db/schema/reports.ts +33 -0
  181. package/src/db/schema/social.ts +106 -0
  182. package/src/db/schema/stories.ts +70 -0
  183. package/src/db/schema.ts +15 -0
  184. package/src/plugin/public.ts +7 -0
  185. package/src/runtime/site-worker.ts +10 -0
@@ -0,0 +1,1191 @@
1
+ import { Hono } from "hono";
2
+ import type { Context } from "hono";
3
+ import type { Env, Variables } from "../../types.ts";
4
+ import { and, eq, inArray } from "drizzle-orm";
5
+ import { activities, actorCache, actors, follows } from "../../../db/index.ts";
6
+ import { chunkForInClause } from "../../lib/chunk.ts";
7
+ import {
8
+ activityApId,
9
+ actorApId,
10
+ isLocal,
11
+ isSafeRemoteUrl,
12
+ } from "../../federation-helpers.ts";
13
+ import { sha256Hex } from "../../lib/delivery/transformers.ts";
14
+ import { getInstanceActor, loadFederatedCommunity } from "./query-helpers.ts";
15
+ import { communityApId, getDomain } from "../../lib/ap-ids.ts";
16
+ import type { Activity } from "./inbox-types.ts";
17
+ import {
18
+ getActivityObject,
19
+ getActivityObjectId,
20
+ typeIncludes,
21
+ } from "./inbox-types.ts";
22
+ import { findFollowByActivityId } from "./handlers/inbox-shared-helpers.ts";
23
+ import {
24
+ ActivityPubContractError,
25
+ parseActivity,
26
+ } from "../../lib/activitypub-validators.ts";
27
+ import {
28
+ fetchAndUpsertActorCache,
29
+ getInstanceFetchSignerByDb,
30
+ } from "../../lib/activitypub-actor-cache.ts";
31
+ import { logger } from "../../lib/logger.ts";
32
+ import { verifyHttpSignature } from "../../lib/ap-verify.ts";
33
+ import { isActorBlocked, isDomainBlocked } from "../../lib/blocklist.ts";
34
+ import {
35
+ consumeRateLimitProgrammatic,
36
+ RateLimitConfigs,
37
+ } from "../../middleware/rate-limit.ts";
38
+ import {
39
+ handleGroupCreate,
40
+ handleGroupFollow,
41
+ handleGroupUndo,
42
+ } from "./handlers/actor-inbox-handlers.ts";
43
+ import {
44
+ handleAccept,
45
+ handleAdd,
46
+ handleAnnounce,
47
+ handleBlock,
48
+ handleCreate,
49
+ handleDelete,
50
+ handleFlag,
51
+ handleFollow,
52
+ handleLike,
53
+ handleMove,
54
+ handleReject,
55
+ handleRemove,
56
+ handleUndo,
57
+ handleUpdate,
58
+ } from "./handlers/user-inbox-handlers.ts";
59
+
60
+ const log = logger.child({ component: "activitypub.inbox" });
61
+
62
+ type HonoContext = Context<{ Bindings: Env; Variables: Variables }>;
63
+
64
+ const MAX_PAYLOAD_BYTES = 512 * 1024;
65
+ const TEXT_DECODER = new TextDecoder("utf-8", { fatal: true });
66
+
67
+ type RequestBodyResult =
68
+ { ok: true; body: string } | { ok: false; status: 400 | 413; error: string };
69
+
70
+ async function readRequestBodyWithLimit(
71
+ request: Request,
72
+ maxBytes: number,
73
+ ): Promise<RequestBodyResult> {
74
+ const reader = request.body?.getReader();
75
+ if (!reader) return { ok: true, body: "" };
76
+
77
+ const chunks: Uint8Array[] = [];
78
+ let totalBytes = 0;
79
+
80
+ while (true) {
81
+ const { done, value } = await reader.read();
82
+ if (done) break;
83
+ if (!value) continue;
84
+
85
+ totalBytes += value.byteLength;
86
+ if (totalBytes > maxBytes) {
87
+ await reader.cancel();
88
+ return { ok: false, status: 413, error: "Payload too large" };
89
+ }
90
+ chunks.push(value);
91
+ }
92
+
93
+ const bodyBytes = new Uint8Array(totalBytes);
94
+ let offset = 0;
95
+ for (const chunk of chunks) {
96
+ bodyBytes.set(chunk, offset);
97
+ offset += chunk.byteLength;
98
+ }
99
+
100
+ try {
101
+ return { ok: true, body: TEXT_DECODER.decode(bodyBytes) };
102
+ } catch {
103
+ return { ok: false, status: 400, error: "Invalid UTF-8 body" };
104
+ }
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Shared inbox helpers
109
+ // ---------------------------------------------------------------------------
110
+
111
+ /**
112
+ * Extract the actor URL from a keyId (strips the fragment, e.g. "#main-key").
113
+ */
114
+ export function signingActorFromKeyId(
115
+ keyId: string | undefined,
116
+ ): string | undefined {
117
+ if (!keyId) return undefined;
118
+ return keyId.includes("#") ? keyId.split("#")[0] : keyId;
119
+ }
120
+
121
+ /**
122
+ * Normalize an actor URL for identity comparison: lowercase the host (host names
123
+ * are case-insensitive) and drop a single trailing slash + any fragment, leaving
124
+ * the (case-sensitive) path intact. Used to compare the signing-key owner with
125
+ * the activity actor without rejecting cosmetically-different-but-identical IRIs
126
+ * (trailing slash / host case) that conformant peers occasionally emit. Returns
127
+ * null for an unparseable URL.
128
+ */
129
+ function normalizeActorUrl(url: string): string | null {
130
+ try {
131
+ const u = new URL(url);
132
+ u.hash = "";
133
+ let normalized = `${u.protocol}//${u.host}${u.pathname}${u.search}`;
134
+ if (normalized.endsWith("/")) normalized = normalized.slice(0, -1);
135
+ return normalized;
136
+ } catch {
137
+ return null;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Returns true when the HTTP-signature signing key does NOT belong to exactly
143
+ * the activity actor (after URL normalization).
144
+ *
145
+ * SECURITY (#1 — same-host key-delegation impersonation): the signature is
146
+ * verified against the key published by the keyId's OWNER, and every per-type
147
+ * handler then authorizes purely on the activity `actor` string. So this binding
148
+ * is the ONLY thing tying the verified key to the claimed actor — it must be an
149
+ * EXACT identity match. An earlier version accepted any signer sharing the same
150
+ * URL host as the actor ("domain-level key delegation"). On a multi-user remote
151
+ * host that let an attacker who controls one key (`alice#main-key`) sign an
152
+ * activity claiming `actor=victim` on the same host and have it accepted AS the
153
+ * victim — cross-actor impersonation (forged Delete, DM-as-victim, Move-based
154
+ * follower theft, etc.). Mastodon/Lemmy bind keyId-owner === activity.actor
155
+ * exactly, so this matches the fediverse norm.
156
+ *
157
+ * The only legitimate cross-actor case is a remote instance/server actor (type
158
+ * Application/Service) signing a FORWARDED activity on behalf of one of its
159
+ * users (ActivityPub §7.1.2 inbox forwarding). That is safe ONLY when the
160
+ * forwarded object's integrity is independently re-verified (an LD-signature on
161
+ * the object, or re-fetching it from its origin). This codebase performs no such
162
+ * re-verification anywhere, so we reject cross-actor delegation outright rather
163
+ * than trust an unverified relayed envelope.
164
+ */
165
+ export function isActorMismatch(
166
+ signingActorUrl: string | undefined,
167
+ actor: string,
168
+ ): boolean {
169
+ if (!signingActorUrl) return true;
170
+ if (signingActorUrl === actor) return false;
171
+
172
+ const normalizedSigner = normalizeActorUrl(signingActorUrl);
173
+ const normalizedActor = normalizeActorUrl(actor);
174
+ if (
175
+ normalizedSigner !== null &&
176
+ normalizedActor !== null &&
177
+ normalizedSigner === normalizedActor
178
+ ) {
179
+ return false;
180
+ }
181
+ return true;
182
+ }
183
+
184
+ type ParsedActivity = {
185
+ activity: Activity;
186
+ activityId: string;
187
+ actor: string;
188
+ activityType: string;
189
+ activityObjectId: string | null;
190
+ };
191
+
192
+ /**
193
+ * Shared pipeline for both inbox endpoints: size check, signature verification,
194
+ * JSON parse, field extraction, and actor-mismatch check. Returns either a
195
+ * parsed result or a Response that should be returned immediately.
196
+ */
197
+ async function verifyAndParseInbox(
198
+ c: HonoContext,
199
+ baseUrl: string,
200
+ ): Promise<ParsedActivity | Response> {
201
+ const contentLengthHeader = c.req.header("content-length");
202
+ if (contentLengthHeader) {
203
+ const contentLength = Number(contentLengthHeader);
204
+ if (!Number.isInteger(contentLength) || contentLength < 0) {
205
+ return c.json({ error: "Invalid Content-Length" }, 400);
206
+ }
207
+ if (contentLength > MAX_PAYLOAD_BYTES) {
208
+ return c.json({ error: "Payload too large" }, 413);
209
+ }
210
+ }
211
+
212
+ const bodyResult = await readRequestBodyWithLimit(
213
+ c.req.raw,
214
+ MAX_PAYLOAD_BYTES,
215
+ );
216
+ if (!bodyResult.ok) {
217
+ return c.json({ error: bodyResult.error }, bodyResult.status);
218
+ }
219
+ const body = bodyResult.body;
220
+
221
+ const signatureResult = await verifyHttpSignature(
222
+ c.req.raw,
223
+ c.get("db"),
224
+ body,
225
+ );
226
+ if (!signatureResult.valid) {
227
+ log.warn("Signature verification failed", {
228
+ event: "ap.signature.verification_failed",
229
+ reason: signatureResult.error,
230
+ });
231
+ return c.json({ error: "Signature verification failed" }, 401);
232
+ }
233
+
234
+ let activity: Activity;
235
+ try {
236
+ const parsed: unknown = JSON.parse(body);
237
+ activity = parseActivity(parsed);
238
+ } catch (e) {
239
+ if (e instanceof ActivityPubContractError) {
240
+ log.warn("Rejected activity (contract error)", {
241
+ event: "ap.activity.contract_rejected",
242
+ reason: e.message,
243
+ });
244
+ return c.json({ error: "Invalid activity" }, 400);
245
+ }
246
+ return c.json({ error: "Invalid JSON" }, 400);
247
+ }
248
+
249
+ const actor = typeof activity.actor === "string" ? activity.actor : null;
250
+ const activityType = typeof activity.type === "string" ? activity.type : null;
251
+
252
+ if (!actor || !activityType) {
253
+ return c.json({ error: "Invalid activity" }, 400);
254
+ }
255
+
256
+ // The activity envelope `id` becomes the dedup ledger key (activities.apId).
257
+ // Only trust a remote-supplied id when it shares the (signature-bound) actor's
258
+ // origin and is not a local id; otherwise a remote could set `id` to ANOTHER
259
+ // instance's namespace and pre-occupy that row (processed=1), silently
260
+ // black-holing that instance's later legitimate redelivery of the same id
261
+ // (denial of federation / dedup-ledger poisoning). Mirrors the object-id
262
+ // origin guard (isObjectIdOriginMismatch) but for the envelope used by dedup.
263
+ // An untrustworthy id does NOT drop the (validly-signed) activity — it just
264
+ // gets deduped under a local deterministic synthetic id instead.
265
+ const rawActivityId = typeof activity.id === "string" ? activity.id : null;
266
+ let activityIdTrusted = false;
267
+ if (rawActivityId !== null && !isLocal(rawActivityId, baseUrl)) {
268
+ try {
269
+ activityIdTrusted = getDomain(rawActivityId) === getDomain(actor);
270
+ } catch {
271
+ activityIdTrusted = false;
272
+ }
273
+ }
274
+ const activityId =
275
+ rawActivityId !== null && activityIdTrusted
276
+ ? rawActivityId
277
+ : activityApId(
278
+ baseUrl,
279
+ // Deterministic synthetic id (local namespace) for an id-less OR
280
+ // untrusted-origin activity: a redelivery of the SAME logical action
281
+ // then dedups via the activities table instead of minting a fresh
282
+ // RANDOM id each time (which re-processed it on every retry).
283
+ // Defense-in-depth — the side-effect handlers are also independently
284
+ // idempotent.
285
+ `synthetic-${await sha256Hex(
286
+ `${actor}|${activityType}|${getActivityObjectId(activity) ?? ""}`,
287
+ )}`,
288
+ );
289
+
290
+ // Id-less activity (#8): stamp the deterministic synthetic id onto the envelope
291
+ // so the per-type handlers — which derive their notification / activities id
292
+ // from `activity.id` (`activity.id || activityApId(generateId())`) — converge
293
+ // on the SAME id across a concurrent dual-endpoint delivery or an in-flight
294
+ // re-claim, instead of each run minting a fresh RANDOM id and inserting
295
+ // DUPLICATE inbox/notification rows. A present (trusted OR untrusted) id is a
296
+ // stable string that already dedups, so only the absent-id case needs this.
297
+ if (rawActivityId === null) {
298
+ activity.id = activityId;
299
+ }
300
+
301
+ const signingActor = signingActorFromKeyId(signatureResult.keyId);
302
+ if (isActorMismatch(signingActor, actor)) {
303
+ log.warn("Actor mismatch between activity and signing key", {
304
+ event: "ap.signature.actor_mismatch",
305
+ actor,
306
+ signingActor,
307
+ keyId: signatureResult.keyId,
308
+ });
309
+ return c.json({ error: "Actor mismatch" }, 401);
310
+ }
311
+
312
+ // Central federation blocklist gate. Applied once here so every activity
313
+ // type (Follow / Like / Announce / Undo / content / group inbox / ...) is
314
+ // covered regardless of which handler dispatches it. Blocked traffic is
315
+ // silently discarded with a 202 ACK (never 4xx) — a 4xx would make the
316
+ // sending instance retry on a backoff and keep redelivering blocked
317
+ // traffic. The blocklist helpers fail open on a DB read error (see
318
+ // lib/blocklist.ts), so a transient DB fault never black-holes federation.
319
+ if (await isActivityBlocked(c, actor, activityType)) {
320
+ return c.body(null, 202);
321
+ }
322
+
323
+ return {
324
+ activity,
325
+ activityId,
326
+ actor,
327
+ activityType,
328
+ activityObjectId: getActivityObjectId(activity),
329
+ };
330
+ }
331
+
332
+ /**
333
+ * Return `true` when an inbound activity must be silently discarded because
334
+ * the sending actor (or its domain) is on the operator blocklist. Callers
335
+ * should 202-discard rather than 4xx — federation peers retry 4xx responses
336
+ * on a backoff and would otherwise keep redelivering blocked traffic.
337
+ */
338
+ async function isActivityBlocked(
339
+ c: HonoContext,
340
+ actor: string,
341
+ activityType: string,
342
+ ): Promise<boolean> {
343
+ const db = c.get("db");
344
+
345
+ if (await isActorBlocked(db, actor)) {
346
+ log.info("Discarding activity from blocked actor", {
347
+ event: "ap.blocklist.actor_discard",
348
+ actor,
349
+ activityType,
350
+ });
351
+ return true;
352
+ }
353
+
354
+ let hostname: string | null = null;
355
+ try {
356
+ hostname = new URL(actor).hostname;
357
+ } catch {
358
+ return false;
359
+ }
360
+
361
+ if (await isDomainBlocked(db, hostname)) {
362
+ log.info("Discarding activity from blocked domain", {
363
+ event: "ap.blocklist.domain_discard",
364
+ actor,
365
+ domain: hostname,
366
+ activityType,
367
+ });
368
+ return true;
369
+ }
370
+
371
+ return false;
372
+ }
373
+
374
+ // `processed` ledger values for an inbound activity row:
375
+ // 0 = stored, dispatch not yet committed (newly inserted, or a prior dispatch
376
+ // threw — such a row is RE-DISPATCHABLE so a peer retry completes it)
377
+ // 1 = dispatch effects committed successfully (terminal; suppresses re-dispatch)
378
+ const PROCESSED_UNPROCESSED = 0;
379
+ const PROCESSED_DONE = 1;
380
+
381
+ /**
382
+ * A request that owns dispatch for an inbound activity. After running the
383
+ * handler the caller MUST call `commitActivityDispatch` on success so a
384
+ * subsequent (re)delivery is suppressed. On handler failure the caller does
385
+ * nothing extra: the row stays `processed = 0`, so a peer retry re-dispatches
386
+ * and completes the effect instead of being permanently black-holed by the
387
+ * dedup row.
388
+ */
389
+ type ActivityDispatchClaim = {
390
+ activityId: string;
391
+ activityType: string;
392
+ actor: string;
393
+ };
394
+
395
+ /**
396
+ * Dedup + claim. Stores the inbound activity (idempotent on the `apId` primary
397
+ * key) and decides whether THIS request must dispatch. Returns either:
398
+ * - a `Response` (202) when the activity must NOT be dispatched (a concurrent
399
+ * delivery already created the row, or a prior delivery already committed
400
+ * `processed = 1`); or
401
+ * - an `ActivityDispatchClaim` when this request owns dispatch.
402
+ *
403
+ * Idempotency model: `apId` is the primary key, so `onConflictDoNothing` makes
404
+ * the dedup insert atomic — exactly one concurrent delivery of the same
405
+ * activity creates the row (and gets a non-null returned row → owns dispatch);
406
+ * the rest get a null row. This is what keeps a genuine concurrent double
407
+ * delivery (shared inbox + per-actor inbox racing) from applying the effect
408
+ * twice or 500'ing on a PK violation.
409
+ *
410
+ * Retry-after-failure fix (#9): the dedup row is no longer unconditionally
411
+ * suppressing. When this request LOST the insert (row already exists), we only
412
+ * suppress if that row is already `processed = 1` (a committed prior dispatch).
413
+ * If the existing row is still `processed = 0` — i.e. a prior dispatch threw
414
+ * mid-effect and never committed — we re-claim it so the peer's retry completes
415
+ * the half-applied activity exactly once (the commit flips it to `1`, after
416
+ * which any further redelivery is suppressed).
417
+ */
418
+ async function claimActivityForDispatch(
419
+ c: HonoContext,
420
+ {
421
+ activityId,
422
+ activityType,
423
+ actor,
424
+ activityObjectId,
425
+ activity,
426
+ }: ParsedActivity,
427
+ ): Promise<Response | ActivityDispatchClaim> {
428
+ const db = c.get("db");
429
+ const rawJson = JSON.stringify(activity);
430
+
431
+ // Atomic insert-or-skip. A non-null returned row means THIS request created
432
+ // the dedup row and owns dispatch; a null row means a concurrent or prior
433
+ // delivery already stored it.
434
+ const inserted = await db
435
+ .insert(activities)
436
+ .values({
437
+ apId: activityId,
438
+ type: activityType,
439
+ actorApId: actor,
440
+ objectApId: activityObjectId,
441
+ rawJson,
442
+ direction: "inbound",
443
+ processed: PROCESSED_UNPROCESSED,
444
+ })
445
+ .onConflictDoNothing()
446
+ .returning()
447
+ .get();
448
+
449
+ if (inserted) {
450
+ return { activityId, activityType, actor };
451
+ }
452
+
453
+ // Lost the insert: a row already exists. Suppress ONLY if it was already
454
+ // dispatched to completion (`processed = 1`). An existing `processed = 0` row
455
+ // means a prior dispatch threw without committing, so this redelivery must
456
+ // re-dispatch to finish it — otherwise the dedup row would permanently
457
+ // suppress re-dispatch of a half-applied activity (bug #9).
458
+ const existing = await db.query.activities.findFirst({
459
+ where: eq(activities.apId, activityId),
460
+ columns: { processed: true },
461
+ });
462
+
463
+ if (existing && existing.processed === PROCESSED_UNPROCESSED) {
464
+ log.info("Re-dispatching previously-uncommitted activity", {
465
+ event: "ap.activity.redispatch_uncommitted",
466
+ activityId,
467
+ activityType,
468
+ actor,
469
+ });
470
+ return { activityId, activityType, actor };
471
+ }
472
+
473
+ log.info("Duplicate activity skipped", {
474
+ event: "ap.activity.duplicate_skipped",
475
+ activityId,
476
+ activityType,
477
+ actor,
478
+ });
479
+ return c.body(null, 202);
480
+ }
481
+
482
+ /**
483
+ * Mark a claimed activity's dispatch as terminally complete. Called after the
484
+ * handler effects commit successfully so any subsequent (re)delivery is
485
+ * suppressed by `claimActivityForDispatch`.
486
+ */
487
+ async function commitActivityDispatch(
488
+ c: HonoContext,
489
+ activityId: string,
490
+ ): Promise<void> {
491
+ const db = c.get("db");
492
+ await db
493
+ .update(activities)
494
+ .set({ processed: PROCESSED_DONE })
495
+ .where(eq(activities.apId, activityId));
496
+ }
497
+
498
+ // ---------------------------------------------------------------------------
499
+ // Remote actor caching
500
+ // ---------------------------------------------------------------------------
501
+
502
+ async function cacheRemoteActor(
503
+ c: HonoContext,
504
+ actorApIdUrl: string,
505
+ baseUrl: string,
506
+ ): Promise<void> {
507
+ if (isLocal(actorApIdUrl, baseUrl)) return;
508
+
509
+ const db = c.get("db");
510
+
511
+ const cached = await db.query.actorCache.findFirst({
512
+ where: eq(actorCache.apId, actorApIdUrl),
513
+ columns: { apId: true },
514
+ });
515
+ if (cached) return;
516
+
517
+ if (!isSafeRemoteUrl(actorApIdUrl)) {
518
+ log.warn("Blocked unsafe actor fetch", {
519
+ event: "ap.actor.unsafe_fetch_blocked",
520
+ actor: actorApIdUrl,
521
+ });
522
+ return;
523
+ }
524
+
525
+ // `mode: "insert"` keeps this cache-when-absent and race-safe: the early
526
+ // `cached` check above is best-effort only, so two isolates racing the same
527
+ // cold actor can both reach the insert, and `onConflictDoNothing` avoids a
528
+ // spurious primary-key-violation error.
529
+ const result = await fetchAndUpsertActorCache(db, actorApIdUrl, {
530
+ timeout: 15000,
531
+ mode: "insert",
532
+ publicKey: "require-key",
533
+ // Sign as the instance actor so a secure-mode remote serves its doc.
534
+ signer: (await getInstanceFetchSignerByDb(db)) ?? undefined,
535
+ });
536
+ if (result.ok) return;
537
+
538
+ switch (result.reason) {
539
+ case "invalid_document":
540
+ log.warn("Skipping actor cache: invalid actor document", {
541
+ event: "ap.actor.cache_invalid_document",
542
+ actor: actorApIdUrl,
543
+ });
544
+ break;
545
+ case "id_mismatch":
546
+ log.warn("Actor ID mismatch during cache", {
547
+ event: "ap.actor.cache_id_mismatch",
548
+ actor: actorApIdUrl,
549
+ });
550
+ break;
551
+ case "missing_public_key":
552
+ log.warn("Skipping actor cache: missing public key", {
553
+ event: "ap.actor.cache_missing_public_key",
554
+ actor: actorApIdUrl,
555
+ });
556
+ break;
557
+ case "fetch_failed":
558
+ log.error("Failed to cache remote actor", {
559
+ event: "ap.actor.cache_failed",
560
+ actor: actorApIdUrl,
561
+ });
562
+ break;
563
+ // `fetch_not_ok` and `missing_inbox` were silently skipped before.
564
+ default:
565
+ break;
566
+ }
567
+ }
568
+
569
+ // ---------------------------------------------------------------------------
570
+ // User inbox activity dispatch
571
+ // ---------------------------------------------------------------------------
572
+
573
+ /** The Drizzle row type for actors table */
574
+ type ActorRow = typeof actors.$inferSelect;
575
+
576
+ type UserInboxHandler = {
577
+ recipient: ActorRow;
578
+ actor: string;
579
+ baseUrl: string;
580
+ };
581
+
582
+ async function dispatchUserActivity(
583
+ c: HonoContext,
584
+ activityType: string,
585
+ activity: Activity,
586
+ { recipient, actor, baseUrl }: UserInboxHandler,
587
+ ): Promise<void> {
588
+ switch (activityType) {
589
+ case "Follow":
590
+ await handleFollow(c, activity, recipient, actor, baseUrl);
591
+ break;
592
+ case "Accept":
593
+ await handleAccept(c, activity, actor);
594
+ break;
595
+ case "Undo":
596
+ await handleUndo(c, activity, recipient, actor, baseUrl);
597
+ break;
598
+ case "Like":
599
+ await handleLike(c, activity, recipient, actor, baseUrl);
600
+ break;
601
+ case "Create":
602
+ await handleCreate(c, activity, recipient, actor, baseUrl);
603
+ break;
604
+ case "Delete":
605
+ await handleDelete(c, activity);
606
+ break;
607
+ case "Announce":
608
+ await handleAnnounce(c, activity, recipient, actor, baseUrl);
609
+ break;
610
+ case "Update":
611
+ await handleUpdate(c, activity, actor);
612
+ break;
613
+ case "Reject":
614
+ await handleReject(c, activity, actor);
615
+ break;
616
+ case "Add":
617
+ await handleAdd(c, activity, recipient, actor);
618
+ break;
619
+ case "Remove":
620
+ await handleRemove(c, activity, recipient, actor);
621
+ break;
622
+ case "Block":
623
+ await handleBlock(c, activity, recipient, actor);
624
+ break;
625
+ case "Flag":
626
+ await handleFlag(c, activity, actor);
627
+ break;
628
+ case "Move":
629
+ await handleMove(c, activity, actor);
630
+ break;
631
+ default:
632
+ log.warn("Unhandled activity type", {
633
+ event: "ap.activity.unhandled_type",
634
+ activityType,
635
+ actor,
636
+ });
637
+ }
638
+ }
639
+
640
+ // ---------------------------------------------------------------------------
641
+ // Per-domain inbox throttling
642
+ // ---------------------------------------------------------------------------
643
+
644
+ /**
645
+ * Apply a per-domain rate limit to an already-parsed inbox activity. This
646
+ * runs after signature verification so the bucket key is derived from the
647
+ * authenticated actor hostname rather than a spoofable header. Returns
648
+ * a 429 Response when the domain budget is exhausted.
649
+ */
650
+ async function applyInboxDomainRateLimit(
651
+ c: HonoContext,
652
+ actor: string,
653
+ ): Promise<Response | null> {
654
+ let domain: string;
655
+ try {
656
+ domain = new URL(actor).hostname.toLowerCase();
657
+ } catch {
658
+ return null;
659
+ }
660
+ if (!domain) return null;
661
+
662
+ const { entry, limited, retryAfter } = await consumeRateLimitProgrammatic(
663
+ c.env.KV,
664
+ RateLimitConfigs.inboxDomain,
665
+ domain,
666
+ );
667
+
668
+ c.header(
669
+ "X-RateLimit-Domain-Limit",
670
+ RateLimitConfigs.inboxDomain.maxRequests.toString(),
671
+ );
672
+ c.header(
673
+ "X-RateLimit-Domain-Remaining",
674
+ Math.max(
675
+ 0,
676
+ RateLimitConfigs.inboxDomain.maxRequests - entry.count,
677
+ ).toString(),
678
+ );
679
+
680
+ if (limited) {
681
+ log.warn("Per-domain inbox throttle exceeded", {
682
+ event: "ap.inbox.domain_rate_limited",
683
+ domain,
684
+ retryAfter,
685
+ });
686
+ c.header("Retry-After", retryAfter.toString());
687
+ return c.json(
688
+ {
689
+ error: "Too many requests from this domain",
690
+ retry_after: retryAfter,
691
+ },
692
+ 429,
693
+ );
694
+ }
695
+ return null;
696
+ }
697
+
698
+ // ---------------------------------------------------------------------------
699
+ // Routes
700
+ // ---------------------------------------------------------------------------
701
+
702
+ const ap = new Hono<{ Bindings: Env; Variables: Variables }>();
703
+
704
+ ap.post("/ap/actor/inbox", async (c) => {
705
+ const instActor = await getInstanceActor(c);
706
+ const baseUrl = c.env.APP_URL;
707
+
708
+ const result = await verifyAndParseInbox(c, baseUrl);
709
+ if (result instanceof Response) return result;
710
+
711
+ const throttled = await applyInboxDomainRateLimit(c, result.actor);
712
+ if (throttled) return throttled;
713
+
714
+ const claim = await claimActivityForDispatch(c, result);
715
+ if (claim instanceof Response) return claim;
716
+
717
+ const { activity, activityType, actor } = result;
718
+
719
+ // The activity row is stored (processed = 0) before group dispatch. A thrown
720
+ // handler is isolated and logged WITHOUT committing, so the row stays
721
+ // `processed = 0` and a peer retry re-dispatches to complete the effect rather
722
+ // than being permanently suppressed by the dedup row (#9). A successful
723
+ // dispatch commits (processed = 1) so retries are skipped. Either way we ACK
724
+ // 202 — a 5xx would make the remote retry, and a committed-too-early dedup row
725
+ // would black-hole the half-applied activity.
726
+ try {
727
+ switch (activityType) {
728
+ case "Follow":
729
+ await handleGroupFollow(
730
+ c,
731
+ activity,
732
+ instActor,
733
+ actor,
734
+ baseUrl,
735
+ result.activityId,
736
+ );
737
+ break;
738
+ case "Undo":
739
+ await handleGroupUndo(c, activity, instActor, actor);
740
+ break;
741
+ case "Create":
742
+ await handleGroupCreate(c, activity, instActor, actor, baseUrl);
743
+ break;
744
+ }
745
+ await commitActivityDispatch(c, claim.activityId);
746
+ } catch (e) {
747
+ log.error("Actor-inbox dispatch failed", {
748
+ event: "ap.actor_inbox.dispatch_error",
749
+ activityType,
750
+ actor,
751
+ error: e,
752
+ });
753
+ }
754
+
755
+ return c.body(null, 202);
756
+ });
757
+
758
+ // Community (Group) inbox — a remote joins a community by POSTing a Follow
759
+ // here; we Accept (signed by the community key) per joinPolicy, and Undo
760
+ // removes the membership/follow. Mirrors the instance-actor inbox, reusing the
761
+ // shared Group handlers. Only PUBLIC communities are followable (the loader
762
+ // returns null for private/deleted → 404).
763
+ ap.post("/ap/groups/:name/inbox", async (c) => {
764
+ const db = c.get("db");
765
+ const baseUrl = c.env.APP_URL;
766
+ const name = c.req.param("name");
767
+ const community = await loadFederatedCommunity(
768
+ db,
769
+ communityApId(baseUrl.replace(/\/+$/, ""), name),
770
+ );
771
+ if (!community) return c.json({ error: "Community not found" }, 404);
772
+
773
+ const result = await verifyAndParseInbox(c, baseUrl);
774
+ if (result instanceof Response) return result;
775
+
776
+ const throttled = await applyInboxDomainRateLimit(c, result.actor);
777
+ if (throttled) return throttled;
778
+
779
+ const claim = await claimActivityForDispatch(c, result);
780
+ if (claim instanceof Response) return claim;
781
+
782
+ const { activity, activityType, actor } = result;
783
+
784
+ try {
785
+ switch (activityType) {
786
+ case "Follow":
787
+ await handleGroupFollow(
788
+ c,
789
+ activity,
790
+ community,
791
+ actor,
792
+ baseUrl,
793
+ result.activityId,
794
+ );
795
+ break;
796
+ case "Undo":
797
+ await handleGroupUndo(c, activity, community, actor);
798
+ break;
799
+ }
800
+ await commitActivityDispatch(c, claim.activityId);
801
+ } catch (e) {
802
+ log.error("Community-inbox dispatch failed", {
803
+ event: "ap.community_inbox.dispatch_error",
804
+ activityType,
805
+ actor,
806
+ community: community.apId,
807
+ error: e,
808
+ });
809
+ }
810
+
811
+ return c.body(null, 202);
812
+ });
813
+
814
+ ap.post("/ap/users/:username/inbox", async (c) => {
815
+ const db = c.get("db");
816
+ const username = c.req.param("username");
817
+ const baseUrl = c.env.APP_URL;
818
+ const apId = actorApId(baseUrl, username);
819
+
820
+ const recipient = await db.query.actors.findFirst({
821
+ where: eq(actors.apId, apId),
822
+ });
823
+ if (!recipient) return c.json({ error: "Actor not found" }, 404);
824
+
825
+ const result = await verifyAndParseInbox(c, baseUrl);
826
+ if (result instanceof Response) return result;
827
+
828
+ const throttled = await applyInboxDomainRateLimit(c, result.actor);
829
+ if (throttled) return throttled;
830
+
831
+ const claim = await claimActivityForDispatch(c, result);
832
+ if (claim instanceof Response) return claim;
833
+
834
+ const { activity, activityType, actor } = result;
835
+
836
+ await cacheRemoteActor(c, actor, baseUrl);
837
+
838
+ // The activity row is stored (processed = 0) before dispatch. If a handler
839
+ // throws we leave it uncommitted so a peer retry re-dispatches and completes
840
+ // the effect, instead of the dedup row permanently suppressing it (#9); on
841
+ // success we commit (processed = 1) so retries are skipped. We ACK 202
842
+ // regardless so a retry is not provoked into a 5xx loop.
843
+ try {
844
+ await dispatchUserActivity(c, activityType, activity, {
845
+ recipient,
846
+ actor,
847
+ baseUrl,
848
+ });
849
+ await commitActivityDispatch(c, claim.activityId);
850
+ } catch (e) {
851
+ log.error("User-inbox dispatch failed", {
852
+ event: "ap.user_inbox.dispatch_error",
853
+ activityType,
854
+ actor,
855
+ recipient: recipient.apId,
856
+ error: e,
857
+ });
858
+ }
859
+
860
+ return c.body(null, 202);
861
+ });
862
+
863
+ // ---------------------------------------------------------------------------
864
+ // Shared inbox (Mastodon convention)
865
+ // ---------------------------------------------------------------------------
866
+ //
867
+ // Both the user actor and the group/instance actor advertise
868
+ // `endpoints.sharedInbox = <baseUrl>/ap/inbox`. Mastodon and most large
869
+ // servers use sharedInbox as the PRIMARY fan-out delivery target, so a
870
+ // federated peer following a yurucommu user delivers Create/Like/Announce/
871
+ // Follow/Undo here. This endpoint runs the SAME verify/dedup/store pipeline as
872
+ // the per-actor inbox (`verifyAndParseInbox`) and then routes the activity to
873
+ // the appropriate local recipients, instead of black-holing it with a bare
874
+ // 202.
875
+ //
876
+ // Recipient resolution: the parsed activity envelope does not carry
877
+ // `to`/`cc`/`audience`, so for recipient-scoped activity types we fan out to
878
+ // every LOCAL actor that follows the activity actor (the standard sharedInbox
879
+ // semantic — the sending server delivers once and the receiving server
880
+ // distributes to its own followers). Recipient-independent types (Accept,
881
+ // Delete, Update, Reject, Flag, Move) are dispatched exactly once.
882
+
883
+ // Bound on the number of local followers fanned out per shared-inbox activity,
884
+ // so a single delivery cannot trigger an unbounded number of handler runs in
885
+ // one request. Local follower sets are small (this is a single-instance
886
+ // community app), so this ceiling is generous.
887
+ const MAX_SHARED_INBOX_FANOUT = 1000;
888
+
889
+ // Activity types whose handlers do not depend on the recipient actor; these
890
+ // are dispatched once rather than per local follower.
891
+ const RECIPIENT_INDEPENDENT_TYPES = new Set([
892
+ "Accept",
893
+ "Delete",
894
+ "Update",
895
+ "Reject",
896
+ "Flag",
897
+ "Move",
898
+ ]);
899
+
900
+ /**
901
+ * Resolve the local actor rows that follow `actorApIdValue` (an accepted
902
+ * follow), capped at MAX_SHARED_INBOX_FANOUT. Used to fan a shared-inbox
903
+ * activity out to the local recipients that subscribed to the sending actor.
904
+ */
905
+ async function resolveLocalFollowerRecipients(
906
+ c: HonoContext,
907
+ actorApIdValue: string,
908
+ baseUrl: string,
909
+ ): Promise<ActorRow[]> {
910
+ const db = c.get("db");
911
+
912
+ const followerRows = await db
913
+ .select({
914
+ followerApId: follows.followerApId,
915
+ })
916
+ .from(follows)
917
+ .where(
918
+ and(
919
+ eq(follows.followingApId, actorApIdValue),
920
+ eq(follows.status, "accepted"),
921
+ ),
922
+ )
923
+ .limit(MAX_SHARED_INBOX_FANOUT);
924
+
925
+ const localFollowerApIds = followerRows
926
+ .map((row) => row.followerApId)
927
+ .filter((apId) => isLocal(apId, baseUrl));
928
+ if (localFollowerApIds.length === 0) return [];
929
+
930
+ // Chunk the IN(...) lookup: the fan-out is capped at MAX_SHARED_INBOX_FANOUT
931
+ // (1000) and D1 allows at most 100 bound parameters per query. The id slices
932
+ // are disjoint, so flattening the per-chunk actor rows is collision-free.
933
+ const chunks = await Promise.all(
934
+ chunkForInClause(localFollowerApIds).map((ids) =>
935
+ db.query.actors.findMany({ where: inArray(actors.apId, ids) }),
936
+ ),
937
+ );
938
+ return chunks.flat();
939
+ }
940
+
941
+ /**
942
+ * Resolve the LOCAL actor named by `activity.object` (an actor IRI). Used for
943
+ * object-actor-scoped activities (e.g. `Follow`) delivered to the SHARED inbox:
944
+ * their recipient is the actor in `object`, not the followers of the sender, so
945
+ * they must not go through the follower fan-out. Returns null when the object
946
+ * is missing, remote, or not a known local actor.
947
+ */
948
+ async function resolveLocalActorFromObject(
949
+ c: HonoContext,
950
+ activity: Activity,
951
+ baseUrl: string,
952
+ ): Promise<ActorRow | null> {
953
+ const objectId = getActivityObjectId(activity);
954
+ if (!objectId || !isLocal(objectId, baseUrl)) return null;
955
+ const db = c.get("db");
956
+ const row = await db.query.actors.findFirst({
957
+ where: eq(actors.apId, objectId),
958
+ });
959
+ return row ?? null;
960
+ }
961
+
962
+ async function findLocalActorByApId(
963
+ c: HonoContext,
964
+ apId: string,
965
+ baseUrl: string,
966
+ ): Promise<ActorRow | null> {
967
+ if (!isLocal(apId, baseUrl)) return null;
968
+ const row = await c.get("db").query.actors.findFirst({
969
+ where: eq(actors.apId, apId),
970
+ });
971
+ return row ?? null;
972
+ }
973
+
974
+ /**
975
+ * Classify a shared-inbox activity whose recipient is an ACTOR named by the
976
+ * activity (not the followers of the sender), and resolve that local target:
977
+ * - `Follow` / `Block`: the target is `activity.object` (the followed/blocked
978
+ * actor). handleFollow/handleBlock key off `recipient`, so it MUST be that
979
+ * actor, never a follower of the sender.
980
+ * - `Undo(Follow|Block)`: undoFollow decrements `recipient`'s followerCount, so
981
+ * the recipient must be the followed actor. Resolve it from the wrapped
982
+ * activity's object (typed inner) or by looking up the referenced follow edge
983
+ * (bare-string inner). Undo(Like|Announce) is actor-keyed + idempotent, so it
984
+ * is NOT actor-scoped and keeps the follower fan-out (`scoped: false`).
985
+ * `scoped: true` with `target: null` = an actor-scoped activity that names no
986
+ * known LOCAL actor → an honest no-op (do not fan out to the sender's followers).
987
+ */
988
+ async function resolveObjectActorTarget(
989
+ c: HonoContext,
990
+ activityType: string,
991
+ activity: Activity,
992
+ baseUrl: string,
993
+ ): Promise<{ scoped: boolean; target: ActorRow | null }> {
994
+ if (activityType === "Follow" || activityType === "Block") {
995
+ return {
996
+ scoped: true,
997
+ target: await resolveLocalActorFromObject(c, activity, baseUrl),
998
+ };
999
+ }
1000
+ if (activityType === "Undo") {
1001
+ const inner = getActivityObject(activity) as {
1002
+ type?: string | string[];
1003
+ object?: unknown;
1004
+ } | null;
1005
+ const innerObjectId = inner
1006
+ ? typeof inner.object === "string"
1007
+ ? inner.object
1008
+ : ((inner.object as { id?: string } | undefined)?.id ?? null)
1009
+ : null;
1010
+
1011
+ // Undo(Block): the target is the blocked actor named in `inner.object`.
1012
+ // Block has no activity-id-keyed edge, so an absent object is a null no-op.
1013
+ if (typeIncludes(inner?.type, "Block")) {
1014
+ return {
1015
+ scoped: true,
1016
+ target: innerObjectId
1017
+ ? await findLocalActorByApId(c, innerObjectId, baseUrl)
1018
+ : null,
1019
+ };
1020
+ }
1021
+
1022
+ // Undo(Follow): the followed actor — undoFollow keys the followerCount
1023
+ // decrement on `recipient`, so the recipient MUST be the followed actor.
1024
+ // Resolve it from `inner.object` if present, else from the referenced follow
1025
+ // EDGE (a typed inner that carries only its own id, a bare-string activity
1026
+ // id, OR a typeless object inner — all mirror the per-user inbox's
1027
+ // findFollowByActivityId path). An inner WITHOUT an explicit "Follow" type
1028
+ // (bare-string or typeless object) is treated as a POSSIBLE Undo(Follow); if
1029
+ // it resolves no local follow edge it is left to the fan-out, because it may
1030
+ // be an Undo(Like|Announce) by id whose actor-keyed handler must still run.
1031
+ if (
1032
+ typeIncludes(inner?.type, "Follow") ||
1033
+ inner == null ||
1034
+ inner.type == null
1035
+ ) {
1036
+ if (innerObjectId && isLocal(innerObjectId, baseUrl)) {
1037
+ return {
1038
+ scoped: true,
1039
+ target: await findLocalActorByApId(c, innerObjectId, baseUrl),
1040
+ };
1041
+ }
1042
+ const innerId = getActivityObjectId(activity);
1043
+ if (innerId) {
1044
+ const follow = await findFollowByActivityId(c.get("db"), innerId);
1045
+ if (follow && isLocal(follow.followingApId, baseUrl)) {
1046
+ return {
1047
+ scoped: true,
1048
+ target: await findLocalActorByApId(
1049
+ c,
1050
+ follow.followingApId,
1051
+ baseUrl,
1052
+ ),
1053
+ };
1054
+ }
1055
+ }
1056
+ // A typed Follow inner is object-scoped even with an unresolvable edge
1057
+ // (commit a no-op; do NOT fan out to the sender's followers). An inner with
1058
+ // no explicit Follow type that resolved no follow edge keeps the fan-out —
1059
+ // it may be an Undo(Like|Announce) whose decrement the handler must apply.
1060
+ return inner?.type === "Follow"
1061
+ ? { scoped: true, target: null }
1062
+ : { scoped: false, target: null };
1063
+ }
1064
+
1065
+ // Undo(Like|Announce|…) — actor-keyed + idempotent; keep the follower fan-out.
1066
+ return { scoped: false, target: null };
1067
+ }
1068
+ return { scoped: false, target: null };
1069
+ }
1070
+
1071
+ ap.post("/ap/inbox", async (c) => {
1072
+ const baseUrl = c.env.APP_URL;
1073
+
1074
+ const result = await verifyAndParseInbox(c, baseUrl);
1075
+ if (result instanceof Response) return result;
1076
+
1077
+ const throttled = await applyInboxDomainRateLimit(c, result.actor);
1078
+ if (throttled) return throttled;
1079
+
1080
+ const claim = await claimActivityForDispatch(c, result);
1081
+ if (claim instanceof Response) return claim;
1082
+
1083
+ const { activity, activityType, actor } = result;
1084
+
1085
+ // The fan-out below may throw before any dispatch runs (e.g. actor cache or
1086
+ // follower resolution faults). On such a failure we leave the row uncommitted
1087
+ // (processed = 0) so a peer retry re-dispatches and completes delivery rather
1088
+ // than being suppressed by the dedup row (#9); we commit it once the fan-out
1089
+ // has run so retries are skipped.
1090
+ try {
1091
+ await cacheRemoteActor(c, actor, baseUrl);
1092
+
1093
+ if (RECIPIENT_INDEPENDENT_TYPES.has(activityType)) {
1094
+ // These handlers ignore the recipient; dispatch once. We pass a synthetic
1095
+ // recipient context derived from the activity actor so the handler
1096
+ // signature is satisfied without implying a specific local target.
1097
+ await dispatchUserActivity(c, activityType, activity, {
1098
+ recipient: { apId: actor } as ActorRow,
1099
+ actor,
1100
+ baseUrl,
1101
+ });
1102
+ await commitActivityDispatch(c, claim.activityId);
1103
+ return c.body(null, 202);
1104
+ }
1105
+
1106
+ // Object-actor-scoped activities (Follow / Block / Undo(Follow|Block)) are
1107
+ // addressed to the actor NAMED by the activity, NOT to followers of the
1108
+ // sender. Routing them through the follower fan-out below would make the
1109
+ // handler key off the wrong actor (bogus edge / Accept from the wrong actor
1110
+ // / followerCount drift on the wrong actor) or — when the sender has no
1111
+ // local followers — silently DROP the request entirely. Resolve the target
1112
+ // and dispatch once (mirrors the per-user inbox). Correctly-addressed peers
1113
+ // hit /ap/users/:username/inbox; this guards peers that point them here.
1114
+ const objectScoped = await resolveObjectActorTarget(
1115
+ c,
1116
+ activityType,
1117
+ activity,
1118
+ baseUrl,
1119
+ );
1120
+ if (objectScoped.scoped) {
1121
+ if (objectScoped.target) {
1122
+ await dispatchUserActivity(c, activityType, activity, {
1123
+ recipient: objectScoped.target,
1124
+ actor,
1125
+ baseUrl,
1126
+ });
1127
+ } else {
1128
+ log.info("Shared-inbox object-actor activity names no local target", {
1129
+ event: "ap.shared_inbox.object_actor_no_target",
1130
+ activityType,
1131
+ actor,
1132
+ object: getActivityObjectId(activity),
1133
+ });
1134
+ }
1135
+ await commitActivityDispatch(c, claim.activityId);
1136
+ return c.body(null, 202);
1137
+ }
1138
+
1139
+ // Recipient-scoped: fan out to every local follower of the sending actor.
1140
+ const recipients = await resolveLocalFollowerRecipients(c, actor, baseUrl);
1141
+ if (recipients.length === 0) {
1142
+ // No local subscribers for this actor — an honest no-op delivery. Commit
1143
+ // the claim so the no-op is not retried indefinitely.
1144
+ await commitActivityDispatch(c, claim.activityId);
1145
+ log.info("Shared-inbox activity had no local recipients", {
1146
+ event: "ap.shared_inbox.no_recipients",
1147
+ activityType,
1148
+ actor,
1149
+ });
1150
+ return c.body(null, 202);
1151
+ }
1152
+
1153
+ for (const recipient of recipients) {
1154
+ // Isolate per-recipient failures: a single local recipient whose handler
1155
+ // throws must not abort fan-out to the others or turn the whole shared
1156
+ // delivery into a 5xx (which would make the sending peer retry and
1157
+ // redeliver to every recipient).
1158
+ try {
1159
+ await dispatchUserActivity(c, activityType, activity, {
1160
+ recipient,
1161
+ actor,
1162
+ baseUrl,
1163
+ });
1164
+ } catch (e) {
1165
+ log.error("Shared-inbox dispatch failed for one recipient", {
1166
+ event: "ap.shared_inbox.dispatch_error",
1167
+ activityType,
1168
+ actor,
1169
+ recipient: recipient.apId,
1170
+ error: e,
1171
+ });
1172
+ }
1173
+ }
1174
+
1175
+ // Fan-out attempted for every resolved recipient (per-recipient failures
1176
+ // are isolated above). Commit the claim so a peer retry does not redeliver
1177
+ // to every local follower.
1178
+ await commitActivityDispatch(c, claim.activityId);
1179
+ } catch (e) {
1180
+ log.error("Shared-inbox dispatch failed", {
1181
+ event: "ap.shared_inbox.dispatch_error",
1182
+ activityType,
1183
+ actor,
1184
+ error: e,
1185
+ });
1186
+ }
1187
+
1188
+ return c.body(null, 202);
1189
+ });
1190
+
1191
+ export default ap;