@takosjp/yurucommu-core 3.4.0 → 3.4.3

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 (39) 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/lib/api/notifications.ts +1 -0
  5. package/packages/api/src/lib/api/posts.ts +1 -0
  6. package/src/backend/index.ts +62 -12
  7. package/src/backend/lib/delivery/queue-batching.ts +53 -36
  8. package/src/backend/lib/delivery/queue-delivery.ts +3 -3
  9. package/src/backend/lib/delivery/queue.ts +13 -9
  10. package/src/backend/lib/delivery/types.ts +12 -0
  11. package/src/backend/lib/notification-push.ts +2 -2
  12. package/src/backend/lib/oauth-providers.ts +9 -0
  13. package/src/backend/lib/strip-image-metadata.ts +50 -30
  14. package/src/backend/middleware/bearer-auth.ts +24 -9
  15. package/src/backend/public.ts +38 -1
  16. package/src/backend/retention.ts +78 -0
  17. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +4 -3
  18. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +204 -5
  19. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +78 -53
  20. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +66 -2
  21. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +35 -12
  22. package/src/backend/routes/activitypub/inbox-addressing.ts +236 -0
  23. package/src/backend/routes/activitypub/inbox-types.ts +8 -0
  24. package/src/backend/routes/activitypub/inbox.ts +410 -205
  25. package/src/backend/routes/activitypub/outbox.ts +0 -0
  26. package/src/backend/routes/actors.ts +5 -5
  27. package/src/backend/routes/auth.ts +2 -1
  28. package/src/backend/routes/posts/post-helpers.ts +42 -23
  29. package/src/backend/routes/stories/routes.ts +5 -7
  30. package/src/backend/runtime/cloudflare.ts +63 -2
  31. package/src/backend/runtime/managed-relational.ts +197 -0
  32. package/src/backend/runtime/managed-runtime.ts +631 -0
  33. package/src/backend/runtime/queue.ts +40 -0
  34. package/src/backend/server.ts +15 -18
  35. package/src/backend/types.ts +9 -2
  36. package/src/db/d1-write.ts +270 -0
  37. package/src/db/index.ts +17 -0
  38. package/src/db/schema/federation.ts +19 -0
  39. package/src/db/schema/index.ts +1 -0
@@ -10,9 +10,10 @@
10
10
  * re-encoding the pixels, so the visible image is unchanged.
11
11
  *
12
12
  * Pure byte-surgery (no native image library): it walks the container structure
13
- * and drops only metadata segments/chunks, copying everything else verbatim. It
14
- * NEVER corrupts: on any structural surprise it returns the original bytes
15
- * unchanged (the magic-byte validation has already confirmed the declared type).
13
+ * and drops only metadata segments/chunks, copying everything else verbatim.
14
+ * Supported containers fail closed on structural surprises: returning their
15
+ * original bytes would silently publish the metadata the parser failed to
16
+ * understand.
16
17
  *
17
18
  * Covered: JPEG (APP1 EXIF/XMP, APP13 IPTC, COM), PNG (tEXt/zTXt/iTXt/eXIf/tIME),
18
19
  * WebP (EXIF / XMP chunks). GIF and video are passed through unchanged (GIF
@@ -24,36 +25,37 @@ export function stripImageMetadata(
24
25
  bytes: Uint8Array,
25
26
  mimeType: string,
26
27
  ): Uint8Array {
27
- try {
28
- switch (mimeType) {
29
- case "image/jpeg":
30
- return stripJpeg(bytes);
31
- case "image/png":
32
- return stripPng(bytes);
33
- case "image/webp":
34
- return stripWebp(bytes);
35
- default:
36
- return bytes; // gif / video / unknown: unchanged
37
- }
38
- } catch {
39
- // Never let a parsing surprise corrupt or drop the upload.
40
- return bytes;
28
+ switch (mimeType) {
29
+ case "image/jpeg":
30
+ return stripJpeg(bytes);
31
+ case "image/png":
32
+ return stripPng(bytes);
33
+ case "image/webp":
34
+ return stripWebp(bytes);
35
+ default:
36
+ return bytes; // gif / video / unknown: unchanged
41
37
  }
42
38
  }
43
39
 
40
+ function malformed(format: "JPEG" | "PNG" | "WebP"): never {
41
+ throw new Error(`Malformed ${format} image`);
42
+ }
43
+
44
44
  // ---------------------------------------------------------------------------
45
45
  // JPEG: a stream of marker segments. Drop APP1 (EXIF + XMP), APP13 (IPTC /
46
46
  // Photoshop) and COM (comment); keep APP0 (JFIF), APP2 (ICC), APP14 (Adobe
47
47
  // color transform), the quantization/Huffman tables, and the scan data verbatim.
48
48
  // ---------------------------------------------------------------------------
49
49
  function stripJpeg(bytes: Uint8Array): Uint8Array {
50
- if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return bytes;
50
+ if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) {
51
+ return malformed("JPEG");
52
+ }
51
53
 
52
54
  const out: number[] = [0xff, 0xd8]; // SOI
53
55
  let i = 2;
54
56
 
55
57
  while (i + 1 < bytes.length) {
56
- if (bytes[i] !== 0xff) return bytes; // not at a marker — bail unchanged
58
+ if (bytes[i] !== 0xff) return malformed("JPEG");
57
59
  const marker = bytes[i + 1];
58
60
 
59
61
  // Start of Scan: entropy-coded data runs to EOI — copy the rest verbatim.
@@ -72,9 +74,9 @@ function stripJpeg(bytes: Uint8Array): Uint8Array {
72
74
  i += 2;
73
75
  continue;
74
76
  }
75
- if (i + 3 >= bytes.length) return bytes; // truncated length — bail unchanged
77
+ if (i + 3 >= bytes.length) return malformed("JPEG");
76
78
  const len = (bytes[i + 2] << 8) | bytes[i + 3]; // includes the 2 length bytes
77
- if (len < 2 || i + 2 + len > bytes.length) return bytes; // malformed — bail
79
+ if (len < 2 || i + 2 + len > bytes.length) return malformed("JPEG");
78
80
 
79
81
  const drop =
80
82
  marker === 0xe1 || // APP1: EXIF + XMP (the GPS carriers)
@@ -85,7 +87,7 @@ function stripJpeg(bytes: Uint8Array): Uint8Array {
85
87
  }
86
88
  i += 2 + len;
87
89
  }
88
- return Uint8Array.from(out);
90
+ return malformed("JPEG");
89
91
  }
90
92
 
91
93
  // ---------------------------------------------------------------------------
@@ -96,8 +98,10 @@ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
96
98
  const PNG_DROP_CHUNKS = new Set(["tEXt", "zTXt", "iTXt", "eXIf", "tIME"]);
97
99
 
98
100
  function stripPng(bytes: Uint8Array): Uint8Array {
99
- if (bytes.length < 8) return bytes;
100
- for (let i = 0; i < 8; i++) if (bytes[i] !== PNG_SIGNATURE[i]) return bytes;
101
+ if (bytes.length < 8) return malformed("PNG");
102
+ for (let i = 0; i < 8; i++) {
103
+ if (bytes[i] !== PNG_SIGNATURE[i]) return malformed("PNG");
104
+ }
101
105
 
102
106
  const out: number[] = [...PNG_SIGNATURE];
103
107
  let i = 8;
@@ -107,7 +111,7 @@ function stripPng(bytes: Uint8Array): Uint8Array {
107
111
  (bytes[i + 1] << 16) |
108
112
  (bytes[i + 2] << 8) |
109
113
  bytes[i + 3];
110
- if (len < 0) return bytes; // overflow / malformed — bail unchanged
114
+ if (len < 0) return malformed("PNG");
111
115
  const type = String.fromCharCode(
112
116
  bytes[i + 4],
113
117
  bytes[i + 5],
@@ -115,7 +119,7 @@ function stripPng(bytes: Uint8Array): Uint8Array {
115
119
  bytes[i + 7],
116
120
  );
117
121
  const chunkEnd = i + 12 + len; // length(4) + type(4) + data(len) + crc(4)
118
- if (chunkEnd > bytes.length) return bytes; // truncated — bail unchanged
122
+ if (chunkEnd > bytes.length) return malformed("PNG");
119
123
 
120
124
  if (!PNG_DROP_CHUNKS.has(type)) {
121
125
  for (let k = i; k < chunkEnd; k++) out.push(bytes[k]);
@@ -123,9 +127,22 @@ function stripPng(bytes: Uint8Array): Uint8Array {
123
127
  i = chunkEnd;
124
128
  if (type === "IEND") break;
125
129
  }
130
+ if (i !== bytes.length || !containsPngChunk(out, "IEND")) {
131
+ return malformed("PNG");
132
+ }
126
133
  return Uint8Array.from(out);
127
134
  }
128
135
 
136
+ function containsPngChunk(bytes: readonly number[], type: string): boolean {
137
+ const needle = [...type].map((character) => character.charCodeAt(0));
138
+ for (let i = 8; i + needle.length <= bytes.length; i++) {
139
+ if (needle.every((value, offset) => bytes[i + offset] === value)) {
140
+ return true;
141
+ }
142
+ }
143
+ return false;
144
+ }
145
+
129
146
  // ---------------------------------------------------------------------------
130
147
  // WebP: a RIFF container ("RIFF" <size> "WEBP" <chunks>). Drop the "EXIF" and
131
148
  // "XMP " chunks, clear the matching VP8X feature-flag bits, and rewrite the RIFF
@@ -142,8 +159,10 @@ function fourCC(bytes: Uint8Array, off: number): string {
142
159
  }
143
160
 
144
161
  function stripWebp(bytes: Uint8Array): Uint8Array {
145
- if (bytes.length < 16) return bytes;
146
- if (fourCC(bytes, 0) !== "RIFF" || fourCC(bytes, 8) !== "WEBP") return bytes;
162
+ if (bytes.length < 16) return malformed("WebP");
163
+ if (fourCC(bytes, 0) !== "RIFF" || fourCC(bytes, 8) !== "WEBP") {
164
+ return malformed("WebP");
165
+ }
147
166
 
148
167
  const head: number[] = [];
149
168
  for (let k = 0; k < 12; k++) head.push(bytes[k]); // RIFF + size + WEBP
@@ -158,10 +177,10 @@ function stripWebp(bytes: Uint8Array): Uint8Array {
158
177
  (bytes[i + 5] << 8) |
159
178
  (bytes[i + 6] << 16) |
160
179
  (bytes[i + 7] << 24);
161
- if (size < 0) return bytes; // overflow — bail unchanged
180
+ if (size < 0) return malformed("WebP");
162
181
  const padded = size + (size & 1); // chunks pad to an even length
163
182
  const chunkEnd = i + 8 + padded;
164
- if (chunkEnd > bytes.length) return bytes; // truncated — bail unchanged
183
+ if (chunkEnd > bytes.length) return malformed("WebP");
165
184
 
166
185
  if (cc === "EXIF" || cc === "XMP ") {
167
186
  removed = true; // skip this chunk entirely
@@ -170,6 +189,7 @@ function stripWebp(bytes: Uint8Array): Uint8Array {
170
189
  }
171
190
  i = chunkEnd;
172
191
  }
192
+ if (i !== bytes.length) return malformed("WebP");
173
193
  if (!removed) return bytes; // nothing to strip — keep original bytes
174
194
 
175
195
  // Clear the EXIF (bit 3) / XMP (bit 2) feature flags in a VP8X header so the
@@ -6,6 +6,8 @@ import {
6
6
  issuerEndpoint,
7
7
  } from "../lib/oauth-providers.ts";
8
8
 
9
+ const INTROSPECTION_TIMEOUT_MS = 5_000;
10
+
9
11
  export function requireBearerAuth(
10
12
  requiredScope: string,
11
13
  ): MiddlewareHandler<{ Bindings: Env; Variables: Variables }> {
@@ -27,15 +29,28 @@ export function requireBearerAuth(
27
29
  );
28
30
  }
29
31
 
30
- const res = await fetch(issuerEndpoint(issuer, "/oauth/introspect"), {
31
- method: "POST",
32
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
33
- body: new URLSearchParams({
34
- token,
35
- client_id: clientId,
36
- client_secret: clientSecret,
37
- }).toString(),
38
- });
32
+ let res: Response;
33
+ try {
34
+ res = await fetch(issuerEndpoint(issuer, "/oauth/introspect"), {
35
+ method: "POST",
36
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
37
+ body: new URLSearchParams({
38
+ token,
39
+ client_id: clientId,
40
+ client_secret: clientSecret,
41
+ }).toString(),
42
+ signal: AbortSignal.timeout(INTROSPECTION_TIMEOUT_MS),
43
+ });
44
+ } catch {
45
+ c.header("Retry-After", "5");
46
+ return c.json(
47
+ {
48
+ error: "temporarily_unavailable",
49
+ error_description: "Introspection request failed",
50
+ },
51
+ 503,
52
+ );
53
+ }
39
54
  if (!res.ok) {
40
55
  return c.json(
41
56
  {
@@ -9,10 +9,47 @@ export {
9
9
  type YurucommuBackendDiscoveryOptionsV1,
10
10
  type YurucommuBackendPluginV1,
11
11
  } from "./index.ts";
12
+ export {
13
+ runYurucommuRetention,
14
+ YurucommuRetentionError,
15
+ type YurucommuRetentionResult,
16
+ type YurucommuRetentionStep,
17
+ } from "./retention.ts";
12
18
  export { default } from "./index.ts";
13
19
  export { default as app } from "./index.ts";
14
20
  export { type Database, getDb, getDbSQLite } from "../db/index.ts";
15
- export { wrapCloudflareBindings } from "./runtime/cloudflare.ts";
21
+ export {
22
+ wrapCloudflareBindings,
23
+ wrapCloudflareMessageBatch,
24
+ wrapCloudflareQueue,
25
+ } from "./runtime/cloudflare.ts";
26
+ export {
27
+ ManagedRuntimeGatewayError,
28
+ createManagedRuntimeKeyValueStore,
29
+ createManagedRuntimeObjectStorage,
30
+ createManagedRuntimeQueueProducer,
31
+ type ManagedRuntimeDataAdapterOptions,
32
+ type ManagedRuntimeGateway,
33
+ type ManagedRuntimeQueueProducerOptions,
34
+ } from "./runtime/managed-runtime.ts";
35
+ export {
36
+ createManagedRelationalDatabase,
37
+ type ManagedRelationalDatabaseOptions,
38
+ } from "./runtime/managed-relational.ts";
39
+ export type {
40
+ IKeyValueStore,
41
+ IObjectStorage,
42
+ ListObjectsResult,
43
+ ObjectMetadata,
44
+ StorageObject,
45
+ } from "./runtime/types.ts";
46
+ export type {
47
+ IQueueBatch,
48
+ IQueueMessage,
49
+ IQueueProducer,
50
+ QueueBatchItem,
51
+ QueueSendOptions,
52
+ } from "./runtime/queue.ts";
16
53
  // Call feature: the signaling Durable Object class each product's generated
17
54
  // worker entry must re-export so Wrangler can bind CALL_SIGNALING to it.
18
55
  export { CallSignalingDurableObject } from "./runtime/call-signaling-do.ts";
@@ -0,0 +1,78 @@
1
+ import type { Env } from "./types.ts";
2
+ import { enqueuePendingNotificationPushJobs } from "./lib/notification-push.ts";
3
+ import { reapDrainedTombstones } from "./routes/actors.ts";
4
+ import { cleanupExpiredStories } from "./routes/stories/query-helpers.ts";
5
+
6
+ export type YurucommuRetentionStep =
7
+ "expired_stories" | "drained_tombstones" | "notification_push";
8
+
9
+ export interface YurucommuRetentionResult {
10
+ readonly expiredStories: number;
11
+ readonly reapedTombstones: number;
12
+ readonly enqueuedNotificationPushJobs: number;
13
+ }
14
+
15
+ /**
16
+ * Identifies the exact bounded retention step that failed. Scheduled callers
17
+ * must reject the invocation instead of treating a partial or skipped sweep as
18
+ * success; the original database/runtime error remains available as `cause`.
19
+ */
20
+ export class YurucommuRetentionError extends Error {
21
+ constructor(
22
+ readonly step: YurucommuRetentionStep,
23
+ cause: unknown,
24
+ ) {
25
+ super(`yurucommu retention failed at ${step}`, { cause });
26
+ this.name = "YurucommuRetentionError";
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Run one bounded retention pass against the already-materialized runtime.
32
+ *
33
+ * This deliberately reuses the same race-safe cleanup paths as request/queue
34
+ * handling:
35
+ * - Story expiry uses the canonical object cascade and purges its media last.
36
+ * - Tombstones remain until every Delete delivery has drained.
37
+ * - Notification push performs bounded pusher/job retention, stale-job
38
+ * recovery, and enqueues due durable outbox rows when a queue is available.
39
+ *
40
+ * Steps are awaited sequentially because D1 is the shared authority. Any
41
+ * failure rejects with its exact step; callers must retry the cron invocation
42
+ * rather than silently acknowledging incomplete retention.
43
+ */
44
+ export async function runYurucommuRetention(
45
+ env: Env,
46
+ ): Promise<YurucommuRetentionResult> {
47
+ if (!env?.DB_INSTANCE) {
48
+ throw new TypeError("Yurucommu retention requires DB_INSTANCE");
49
+ }
50
+
51
+ const expiredStories = await retentionStep("expired_stories", () =>
52
+ cleanupExpiredStories(env.DB_INSTANCE, env.MEDIA),
53
+ );
54
+ const reapedTombstones = await retentionStep("drained_tombstones", () =>
55
+ reapDrainedTombstones(env.DB_INSTANCE),
56
+ );
57
+ const enqueuedNotificationPushJobs = await retentionStep(
58
+ "notification_push",
59
+ () => enqueuePendingNotificationPushJobs(env),
60
+ );
61
+
62
+ return {
63
+ expiredStories,
64
+ reapedTombstones,
65
+ enqueuedNotificationPushJobs,
66
+ };
67
+ }
68
+
69
+ async function retentionStep<T>(
70
+ step: YurucommuRetentionStep,
71
+ run: () => Promise<T>,
72
+ ): Promise<T> {
73
+ try {
74
+ return await run();
75
+ } catch (cause) {
76
+ throw new YurucommuRetentionError(step, cause);
77
+ }
78
+ }
@@ -59,6 +59,7 @@ export async function handleGroupFollow(
59
59
  actorApIdStr: string,
60
60
  baseUrl: string,
61
61
  activityId: string,
62
+ sourceActivityId: string = activityId,
62
63
  ) {
63
64
  const db = c.get("db");
64
65
  const followerKey = {
@@ -130,7 +131,7 @@ export async function handleGroupFollow(
130
131
  where: and(
131
132
  eq(activities.type, responseType),
132
133
  eq(activities.actorApId, group.apId),
133
- eq(activities.objectApId, activityId),
134
+ eq(activities.objectApId, sourceActivityId),
134
135
  ),
135
136
  });
136
137
  if (existingResponse) {
@@ -144,14 +145,14 @@ export async function handleGroupFollow(
144
145
  id: responseId,
145
146
  type: responseType,
146
147
  actor: group.apId,
147
- object: activityId,
148
+ object: sourceActivityId,
148
149
  };
149
150
 
150
151
  await db.insert(activities).values({
151
152
  apId: responseId,
152
153
  type: responseType,
153
154
  actorApId: group.apId,
154
- objectApId: activityId,
155
+ objectApId: sourceActivityId,
155
156
  rawJson: JSON.stringify(responseActivity),
156
157
  direction: "outbound",
157
158
  });
@@ -1,5 +1,15 @@
1
1
  import type { Database } from "../../../../db/index.ts";
2
- import { and, count, eq, gt, inArray, isNotNull, or, sql } from "drizzle-orm";
2
+ import {
3
+ and,
4
+ count,
5
+ eq,
6
+ gt,
7
+ inArray,
8
+ isNotNull,
9
+ isNull,
10
+ or,
11
+ sql,
12
+ } from "drizzle-orm";
3
13
  import type { BatchItem } from "drizzle-orm/batch";
4
14
  import {
5
15
  activities,
@@ -8,6 +18,7 @@ import {
8
18
  announces,
9
19
  blocks,
10
20
  bookmarks,
21
+ communities,
11
22
  follows,
12
23
  inbox as inboxTable,
13
24
  likes,
@@ -45,6 +56,8 @@ import {
45
56
  fetchAndUpsertActorCache,
46
57
  getInstanceFetchSignerByDb,
47
58
  } from "../../../lib/activitypub-actor-cache.ts";
59
+ import { fetchWithTimeout } from "../../../lib/federation-fetch.ts";
60
+ import { signRequest } from "../../../lib/ap-signing.ts";
48
61
  import { enqueueDeliveryToActor } from "../../../lib/delivery/queue.ts";
49
62
  import { destinationDeclaresAlias } from "../../../lib/account-migration.ts";
50
63
  import { chunkForInClause } from "../../../lib/chunk.ts";
@@ -132,13 +145,13 @@ function isActorTypeUpdate(type: string | string[] | undefined): boolean {
132
145
 
133
146
  // The ActivityStreams public-collection magic value, including the legacy
134
147
  // short forms some implementations still emit.
135
- const PUBLIC_COLLECTION = new Set([
148
+ export const PUBLIC_COLLECTION = new Set([
136
149
  "https://www.w3.org/ns/activitystreams#Public",
137
150
  "as:Public",
138
151
  "Public",
139
152
  ]);
140
153
 
141
- function addressesPublic(addresses: string[]): boolean {
154
+ export function addressesPublic(addresses: string[]): boolean {
142
155
  return addresses.some((a) => PUBLIC_COLLECTION.has(a));
143
156
  }
144
157
 
@@ -146,7 +159,7 @@ function addressesPublic(addresses: string[]): boolean {
146
159
  // and NOT to Public is a followers-only post. We match any `/followers`
147
160
  // collection by suffix (mirrors isDirectNote), which covers the author's
148
161
  // collection without needing to resolve it.
149
- function addressesFollowers(addresses: string[]): boolean {
162
+ export function addressesFollowers(addresses: string[]): boolean {
150
163
  return addresses.some((a) => a.endsWith("/followers"));
151
164
  }
152
165
 
@@ -194,7 +207,7 @@ function isDirectShapedNote(object: { to?: string[]; cc?: string[] }): boolean {
194
207
  // Cap persisted addressing arrays so a remote cannot bloat a row with a huge
195
208
  // to/cc list; 64 entries is far beyond any real audience and keeps the explicit-
196
209
  // recipient (mention) gate working.
197
- const MAX_ADDRESS_ENTRIES = 64;
210
+ export const MAX_ADDRESS_ENTRIES = 64;
198
211
  function boundAddressJson(addresses: string[] | undefined): string {
199
212
  if (!Array.isArray(addresses) || addresses.length === 0) return "[]";
200
213
  return JSON.stringify(
@@ -204,6 +217,21 @@ function boundAddressJson(addresses: string[] | undefined): string {
204
217
  );
205
218
  }
206
219
 
220
+ /**
221
+ * Normalize an AS2 addressing field (`to` / `cc` / `audience`) to a bounded list
222
+ * of strings. The field may be absent, a bare string, or an array mixing
223
+ * strings and embedded objects; only the string forms are usable as a
224
+ * collection id, and the same 64-entry cap applies so a remote cannot force a
225
+ * huge `IN (...)` lookup.
226
+ */
227
+ function addressList(value: unknown): string[] {
228
+ if (typeof value === "string") return [value];
229
+ if (!Array.isArray(value)) return [];
230
+ return value
231
+ .filter((a): a is string => typeof a === "string")
232
+ .slice(0, MAX_ADDRESS_ENTRIES);
233
+ }
234
+
207
235
  /**
208
236
  * Reject an inbound object whose `object.id` is asserted under a host the
209
237
  * delivering actor does not control (object-ID squatting / cross-origin
@@ -888,6 +916,35 @@ export async function handleCreateStory(
888
916
  storyDataJson = JSON.stringify({ ...attachmentData, overlays: undefined });
889
917
  }
890
918
 
919
+ // Carry the community scope across the federation boundary. A story that
920
+ // arrived through community fanout is addressed to the community's followers
921
+ // collection, not the author's; storing it with no scope made the local read
922
+ // gate treat it as a personal story and serve it to every local follower of
923
+ // the author, member or not. Resolve the addressed collection against the
924
+ // communities this instance actually knows and only then mark the scope —
925
+ // an unresolvable audience is left unscoped rather than trusted, and the
926
+ // membership gate is still evaluated locally against `community_members`.
927
+ const addressedCollections = [
928
+ ...addressList((activity as { audience?: unknown }).audience),
929
+ ...addressList(object.to),
930
+ ...addressList((object as { audience?: unknown }).audience),
931
+ ];
932
+ const community = addressedCollections.length
933
+ ? await db
934
+ .select({ apId: communities.apId })
935
+ .from(communities)
936
+ .where(
937
+ and(
938
+ or(
939
+ inArray(communities.followersUrl, addressedCollections),
940
+ inArray(communities.apId, addressedCollections),
941
+ ),
942
+ isNull(communities.deletedAt),
943
+ ),
944
+ )
945
+ .get()
946
+ : undefined;
947
+
891
948
  const inserted = await db
892
949
  .insert(objects)
893
950
  .values({
@@ -896,6 +953,12 @@ export async function handleCreateStory(
896
953
  attributedTo: actor,
897
954
  content: "",
898
955
  attachmentsJson: storyDataJson,
956
+ ...(community
957
+ ? {
958
+ communityApId: community.apId,
959
+ audienceJson: JSON.stringify([community.apId]),
960
+ }
961
+ : {}),
899
962
  endTime,
900
963
  published: publishedAt,
901
964
  isLocal: 0,
@@ -907,6 +970,142 @@ export async function handleCreateStory(
907
970
  if (!inserted) return; // duplicate
908
971
  }
909
972
 
973
+ // ---------------------------------------------------------------------------
974
+ // Announce target resolution (fetch-and-store a boosted remote Note)
975
+ // ---------------------------------------------------------------------------
976
+
977
+ // Upper bound on the fetch of a boosted remote object. Mirrors the actor-cache
978
+ // fetch timeout; the body size is already capped by fetchWithTimeout's wrapper.
979
+ const ANNOUNCED_OBJECT_FETCH_TIMEOUT_MS = 15_000;
980
+
981
+ /**
982
+ * Resolve an inbound Announce whose target this instance has never seen: fetch
983
+ * the boosted object from its origin, validate it, and persist it as a remote
984
+ * Note so the announce edge recorded by handleAnnounce surfaces in feeds
985
+ * ("reposted by X") instead of dangling on an unknown ap_id.
986
+ *
987
+ * Reuses the SSRF-guarded federation fetch discipline end to end:
988
+ * `fetchWithTimeout` (resolver-pinned DNS validation, no redirects, capped +
989
+ * time-bounded body) with the GET signed as the instance actor so a
990
+ * secure-mode remote serves the document (mirrors fetchAndUpsertActorCache).
991
+ *
992
+ * Validation mirrors the inbound Create(Note) gates:
993
+ * - the document's `id` must equal the fetched URL (no id squatting);
994
+ * - `attributedTo` must be same-origin with the object id (a remote author
995
+ * may only own objects under its own host — mirrors
996
+ * isObjectIdOriginMismatch, and a local-origin object is never fetched);
997
+ * - type must include "Note"; direct/DM-shaped addressing is refused;
998
+ * - only world-readable classifications (public / unlisted) are persisted —
999
+ * a boost must never widen a followers-only/direct object's audience, and
1000
+ * this instance cannot verify a remote author's follower audience.
1001
+ *
1002
+ * Depth cap: the object's `inReplyTo` is stored verbatim but NEVER resolved —
1003
+ * a single Announce triggers at most one object fetch (plus a best-effort
1004
+ * author-profile cache fill), not a thread walk.
1005
+ *
1006
+ * Best-effort by contract: every failure returns false and the Announce is
1007
+ * dropped exactly as it was before this path existed.
1008
+ */
1009
+ export async function fetchAndPersistAnnouncedNote(
1010
+ db: Database,
1011
+ objectId: string,
1012
+ baseUrl: string,
1013
+ ): Promise<boolean> {
1014
+ // Never fetch a local id (a local object that does not exist is just gone)
1015
+ // and never fetch an unsafe URL (non-http(s), credentials, blocked host…).
1016
+ if (isLocal(objectId, baseUrl) || !isSafeRemoteUrl(objectId)) return false;
1017
+
1018
+ let note: ActivityObject & { attributedTo?: unknown };
1019
+ try {
1020
+ const headers: Record<string, string> = {
1021
+ Accept: "application/activity+json, application/ld+json",
1022
+ };
1023
+ const signer = await getInstanceFetchSignerByDb(db);
1024
+ if (signer) {
1025
+ Object.assign(
1026
+ headers,
1027
+ await signRequest(signer.privateKeyPem, signer.keyId, "GET", objectId),
1028
+ );
1029
+ }
1030
+ const res = await fetchWithTimeout(objectId, {
1031
+ headers,
1032
+ timeout: ANNOUNCED_OBJECT_FETCH_TIMEOUT_MS,
1033
+ });
1034
+ if (!res.ok) return false;
1035
+ const raw: unknown = await res.json();
1036
+ if (!raw || typeof raw !== "object") return false;
1037
+ note = raw as ActivityObject & { attributedTo?: unknown };
1038
+ } catch {
1039
+ // Unresolvable / oversized / timed-out fetch: drop silently (the caller
1040
+ // logs at debug level), matching the pre-existing unknown-object behavior.
1041
+ return false;
1042
+ }
1043
+
1044
+ if (note.id !== objectId) return false;
1045
+ if (!typeIncludes(note.type, "Note")) return false;
1046
+
1047
+ const attributedTo =
1048
+ typeof note.attributedTo === "string" ? note.attributedTo : null;
1049
+ if (!attributedTo || !isSafeRemoteUrl(attributedTo)) return false;
1050
+ try {
1051
+ if (getDomain(attributedTo) !== getDomain(objectId)) return false;
1052
+ } catch {
1053
+ return false;
1054
+ }
1055
+
1056
+ // Addressing gates: a DM-shaped object must never be stored world-readable,
1057
+ // and a non-public classification is refused outright (see doc comment).
1058
+ if (isDirectShapedNote(note)) return false;
1059
+ const visibility = classifyInboundNoteVisibility(note);
1060
+ if (visibility !== "public" && visibility !== "unlisted") return false;
1061
+
1062
+ // Best-effort author profile fill so the surfaced boost renders with the
1063
+ // author's name/icon. Cache-when-absent; a failure never blocks the persist.
1064
+ const cachedAuthor = await db
1065
+ .select({ apId: actorCache.apId })
1066
+ .from(actorCache)
1067
+ .where(eq(actorCache.apId, attributedTo))
1068
+ .get();
1069
+ if (!cachedAuthor) {
1070
+ try {
1071
+ await fetchAndUpsertActorCache(db, attributedTo, {
1072
+ timeout: ANNOUNCED_OBJECT_FETCH_TIMEOUT_MS,
1073
+ mode: "insert",
1074
+ signer: (await getInstanceFetchSignerByDb(db)) ?? undefined,
1075
+ });
1076
+ } catch {
1077
+ /* best-effort */
1078
+ }
1079
+ }
1080
+
1081
+ const attachments = note.attachment ? JSON.stringify(note.attachment) : "[]";
1082
+ await db
1083
+ .insert(objects)
1084
+ .values({
1085
+ apId: objectId,
1086
+ type: "Note",
1087
+ attributedTo,
1088
+ content: boundInboundContent(note.content),
1089
+ summary: boundInboundSummary(note.summary),
1090
+ attachmentsJson: boundAttachmentsJson(attachments),
1091
+ // Stored verbatim, never resolved (depth cap): a boosted reply keeps its
1092
+ // honest thread link even though the parent may stay unknown here.
1093
+ inReplyTo: note.inReplyTo || null,
1094
+ visibility,
1095
+ toJson: boundAddressJson(note.to),
1096
+ ccJson: boundAddressJson(note.cc),
1097
+ communityApId: null,
1098
+ published: normalizeInboundTimestamp(
1099
+ note.published,
1100
+ new Date().toISOString(),
1101
+ ),
1102
+ isLocal: 0,
1103
+ })
1104
+ .onConflictDoNothing();
1105
+
1106
+ return true;
1107
+ }
1108
+
910
1109
  // ---------------------------------------------------------------------------
911
1110
  // Delete handler
912
1111
  // ---------------------------------------------------------------------------