@dwk/activitypub 0.1.0-beta.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 (48) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +135 -0
  3. package/dist/as2.d.ts +117 -0
  4. package/dist/as2.d.ts.map +1 -0
  5. package/dist/as2.js +174 -0
  6. package/dist/as2.js.map +1 -0
  7. package/dist/config.d.ts +148 -0
  8. package/dist/config.d.ts.map +1 -0
  9. package/dist/config.js +142 -0
  10. package/dist/config.js.map +1 -0
  11. package/dist/delivery.d.ts +43 -0
  12. package/dist/delivery.d.ts.map +1 -0
  13. package/dist/delivery.js +131 -0
  14. package/dist/delivery.js.map +1 -0
  15. package/dist/handler.d.ts +21 -0
  16. package/dist/handler.d.ts.map +1 -0
  17. package/dist/handler.js +293 -0
  18. package/dist/handler.js.map +1 -0
  19. package/dist/index.d.ts +40 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +39 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/log.d.ts +57 -0
  24. package/dist/log.d.ts.map +1 -0
  25. package/dist/log.js +53 -0
  26. package/dist/log.js.map +1 -0
  27. package/dist/nodeinfo.d.ts +33 -0
  28. package/dist/nodeinfo.d.ts.map +1 -0
  29. package/dist/nodeinfo.js +61 -0
  30. package/dist/nodeinfo.js.map +1 -0
  31. package/dist/object.d.ts +21 -0
  32. package/dist/object.d.ts.map +1 -0
  33. package/dist/object.js +722 -0
  34. package/dist/object.js.map +1 -0
  35. package/dist/signature.d.ts +108 -0
  36. package/dist/signature.d.ts.map +1 -0
  37. package/dist/signature.js +234 -0
  38. package/dist/signature.js.map +1 -0
  39. package/package.json +50 -0
  40. package/src/as2.ts +257 -0
  41. package/src/config.ts +291 -0
  42. package/src/delivery.ts +155 -0
  43. package/src/handler.ts +370 -0
  44. package/src/index.ts +90 -0
  45. package/src/log.ts +62 -0
  46. package/src/nodeinfo.ts +91 -0
  47. package/src/object.ts +883 -0
  48. package/src/signature.ts +355 -0
package/src/object.ts ADDED
@@ -0,0 +1,883 @@
1
+ /**
2
+ * The per-actor Durable Object: the single-threaded consistency authority for
3
+ * one ActivityPub actor.
4
+ *
5
+ * The stateless front door (`handler.ts`) verifies inbound HTTP signatures at
6
+ * the edge and hands the verified facts to this object via internal headers;
7
+ * everything that must be strongly consistent — activity-`id` dedup, the
8
+ * follower/following collections, the outbox, and the signed outbound delivery
9
+ * queue — happens here, where Cloudflare guarantees a single thread per actor.
10
+ * Delivery retries are driven by the DO **alarm** with exponential backoff.
11
+ * Consumers bind this class as a Durable Object namespace.
12
+ */
13
+
14
+ import { DurableObject } from "cloudflare:workers";
15
+
16
+ import {
17
+ AS2_CONTENT_TYPE,
18
+ PUBLIC_AUDIENCE,
19
+ actorIri,
20
+ buildCollection,
21
+ buildCollectionPage,
22
+ objectId,
23
+ objectType,
24
+ type ActivityObject,
25
+ type ActorIris,
26
+ type JsonValue,
27
+ } from "./as2";
28
+ import { ApOutcome, OUTCOME_ACTIVITY_HEADER, OUTCOME_HEADER } from "./log";
29
+ import { INTERNAL_HEADERS, type ForwardedConfig } from "./config";
30
+ import {
31
+ assertPublicHttpsTarget,
32
+ deliverActivity,
33
+ DeliveryBlockedError,
34
+ } from "./delivery";
35
+ import type { ActivityPubEnv } from "./config";
36
+
37
+ /** How long a seen activity `id` is remembered for dedup (7 days). */
38
+ const SEEN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
39
+ /** Max delivery rows processed per alarm wake. */
40
+ const DELIVERY_BATCH = 20;
41
+ /** Timeout (ms) bounding any single outbound fetch (actor lookup / delivery). */
42
+ const OUTBOUND_TIMEOUT_MS = 10_000;
43
+
44
+ function json(
45
+ status: number,
46
+ body: JsonValue,
47
+ headers: HeadersInit = {},
48
+ ): Response {
49
+ return new Response(JSON.stringify(body), {
50
+ status,
51
+ headers: { "content-type": AS2_CONTENT_TYPE, ...headers },
52
+ });
53
+ }
54
+
55
+ function text(status: number, body: string): Response {
56
+ return new Response(body, {
57
+ status,
58
+ headers: { "content-type": "text/plain; charset=utf-8" },
59
+ });
60
+ }
61
+
62
+ export class ActivityPubObject extends DurableObject<ActivityPubEnv> {
63
+ readonly #sql: SqlStorage;
64
+ #config: ForwardedConfig | null = null;
65
+
66
+ constructor(state: DurableObjectState, env: ActivityPubEnv) {
67
+ super(state, env);
68
+ this.#sql = state.storage.sql;
69
+ this.#sql.exec(
70
+ `CREATE TABLE IF NOT EXISTS kv (k TEXT PRIMARY KEY, v TEXT NOT NULL)`,
71
+ );
72
+ this.#sql.exec(
73
+ `CREATE TABLE IF NOT EXISTS followers (
74
+ actor TEXT PRIMARY KEY, inbox TEXT, added_at INTEGER NOT NULL)`,
75
+ );
76
+ this.#sql.exec(
77
+ `CREATE TABLE IF NOT EXISTS following (
78
+ actor TEXT PRIMARY KEY, state TEXT NOT NULL, added_at INTEGER NOT NULL)`,
79
+ );
80
+ this.#sql.exec(
81
+ `CREATE TABLE IF NOT EXISTS seen (id TEXT PRIMARY KEY, seen_at INTEGER NOT NULL)`,
82
+ );
83
+ this.#sql.exec(
84
+ `CREATE TABLE IF NOT EXISTS inbox (
85
+ seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT UNIQUE, json TEXT NOT NULL,
86
+ received_at INTEGER NOT NULL)`,
87
+ );
88
+ this.#sql.exec(
89
+ `CREATE TABLE IF NOT EXISTS outbox (
90
+ seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT UNIQUE, json TEXT NOT NULL,
91
+ published_at INTEGER NOT NULL)`,
92
+ );
93
+ this.#sql.exec(
94
+ `CREATE TABLE IF NOT EXISTS delivery (
95
+ seq INTEGER PRIMARY KEY AUTOINCREMENT, inbox TEXT NOT NULL, json TEXT NOT NULL,
96
+ attempts INTEGER NOT NULL DEFAULT 0, next_at INTEGER NOT NULL)`,
97
+ );
98
+ }
99
+
100
+ override async fetch(request: Request): Promise<Response> {
101
+ const config = this.#readConfig(request);
102
+ if (!config) return text(500, "missing internal config");
103
+ this.#config = config;
104
+ this.#persistDeliveryConfig(config);
105
+
106
+ const url = new URL(request.url);
107
+ const path = url.pathname;
108
+ const iris = config.iris;
109
+ const method = request.method.toUpperCase();
110
+
111
+ // Internal routes the front door constructs (never reachable externally).
112
+ if (path === `${pathOf(iris.id)}/__stats`) return this.#stats();
113
+ if (path === `${pathOf(iris.id)}/__deliver`) {
114
+ const due = await this.#processDeliveries();
115
+ return json(200, { processed: due });
116
+ }
117
+
118
+ if (path === pathOf(iris.followers)) {
119
+ return this.#serveCollection(request, iris.followers, "followers");
120
+ }
121
+ if (path === pathOf(iris.following)) {
122
+ return this.#serveCollection(request, iris.following, "following");
123
+ }
124
+ if (path === pathOf(iris.outbox)) {
125
+ if (method === "POST") return this.#publish(request);
126
+ return this.#serveCollection(request, iris.outbox, "outbox");
127
+ }
128
+ if (path === pathOf(iris.inbox)) {
129
+ if (method === "POST") return this.#handleInbox(request);
130
+ // The inbox is write-only to peers; reads are not part of S2S.
131
+ return text(405, "Method Not Allowed");
132
+ }
133
+ // The instance-level shared inbox (§7.1.3), when served, routes here too:
134
+ // the single actor is the only recipient, so it is handled like the inbox.
135
+ if (config.sharedInbox && path === pathOf(config.sharedInbox)) {
136
+ if (method === "POST") return this.#handleInbox(request);
137
+ return text(405, "Method Not Allowed");
138
+ }
139
+ return text(404, "Not Found");
140
+ }
141
+
142
+ // -- inbound ---------------------------------------------------------------
143
+
144
+ async #handleInbox(request: Request): Promise<Response> {
145
+ const config = this.#config!;
146
+ let activity: ActivityObject;
147
+ try {
148
+ activity = (await request.json()) as ActivityObject;
149
+ } catch {
150
+ return text(400, "Malformed activity JSON");
151
+ }
152
+ if (!activity || typeof activity !== "object") {
153
+ return text(400, "Malformed activity");
154
+ }
155
+
156
+ // The front door verified the HTTP signature and reports the signing actor.
157
+ // Refuse an activity attributed to anyone other than the verified signer:
158
+ // a validly-signed peer must not be able to inject activities on behalf of
159
+ // a different actor (impersonation). `Announce` is unaffected — its top-level
160
+ // `actor` is the announcer (the signer); only the wrapped object differs.
161
+ const signer = request.headers.get(INTERNAL_HEADERS.signedActor);
162
+ const author = actorIri(activity.actor);
163
+ if (signer && author && author !== signer) {
164
+ return text(403, "Activity actor does not match the signing actor");
165
+ }
166
+
167
+ const id = activity.id;
168
+ if (typeof id === "string" && id.length > 0) {
169
+ if (this.#alreadySeen(id)) {
170
+ return new Response(null, {
171
+ status: 202,
172
+ headers: { [OUTCOME_HEADER]: ApOutcome.InboxDuplicate },
173
+ });
174
+ }
175
+ this.#recordSeen(id);
176
+ }
177
+
178
+ // Reaching here means the activity id was not already seen (the dedup check
179
+ // above returns 202 early for a duplicate). A truthy `firstSeen` is what
180
+ // §7.1.2 requires before considering inbox forwarding.
181
+ const firstSeen = typeof id === "string" && id.length > 0;
182
+
183
+ const type = typeof activity.type === "string" ? activity.type : "";
184
+ switch (type) {
185
+ case "Follow":
186
+ await this.#onFollow(activity, config);
187
+ break;
188
+ case "Undo":
189
+ this.#onUndo(activity);
190
+ break;
191
+ case "Accept":
192
+ this.#onAccept(activity);
193
+ break;
194
+ case "Reject":
195
+ this.#onReject(activity);
196
+ break;
197
+ case "Delete":
198
+ this.#onDelete(activity);
199
+ break;
200
+ case "Create":
201
+ case "Update":
202
+ // Light content validation (§3 SHOULD): a peer signs as itself, so an
203
+ // embedded object it authors must be attributed to that same actor.
204
+ // Reject a `Create`/`Update` whose object names a *different*
205
+ // `attributedTo` — that is an impersonated object slipped past the
206
+ // top-level actor===signer check. `Announce`/`Like` legitimately wrap
207
+ // another account's object and are exempt (handled below).
208
+ if (!attributionMatches(activity)) {
209
+ return text(403, "Embedded object attributedTo does not match actor");
210
+ }
211
+ this.#storeInbox(activity);
212
+ await this.#maybeForward(activity, firstSeen, config);
213
+ break;
214
+ case "Like":
215
+ case "Announce":
216
+ this.#storeInbox(activity);
217
+ await this.#maybeForward(activity, firstSeen, config);
218
+ break;
219
+ default:
220
+ // Be liberal: an unknown activity is accepted (and ignored) so we do not
221
+ // reject future vocabulary a peer reasonably expects us to tolerate.
222
+ break;
223
+ }
224
+
225
+ return new Response(null, {
226
+ status: 202,
227
+ headers: {
228
+ [OUTCOME_HEADER]: ApOutcome.InboxAccepted,
229
+ [OUTCOME_ACTIVITY_HEADER]: type || "Unknown",
230
+ },
231
+ });
232
+ }
233
+
234
+ /**
235
+ * Record the follower and, unless the actor manually approves, auto-`Accept`
236
+ * by enqueuing a signed `Accept` delivered to the follower's inbox. The
237
+ * follower's inbox is resolved from its actor document.
238
+ */
239
+ async #onFollow(
240
+ activity: ActivityObject,
241
+ config: ForwardedConfig,
242
+ ): Promise<void> {
243
+ const follower = actorIri(activity.actor);
244
+ const target = objectId(activity.object);
245
+ // The Follow must target this actor; a misaddressed Follow is ignored.
246
+ if (!follower || target !== config.iris.id) return;
247
+
248
+ // Record the follower first (inbox filled in on the auto-accept path), so a
249
+ // manually-approved actor never triggers an outbound actor fetch here.
250
+ const now = Date.now();
251
+ this.#sql.exec(
252
+ `INSERT OR IGNORE INTO followers (actor, inbox, added_at) VALUES (?, NULL, ?)`,
253
+ follower,
254
+ now,
255
+ );
256
+
257
+ if (config.manuallyApprovesFollowers) return;
258
+
259
+ const inbox = await this.#resolveInbox(follower);
260
+ if (!inbox) return;
261
+ this.#sql.exec(
262
+ `UPDATE followers SET inbox = ? WHERE actor = ?`,
263
+ inbox,
264
+ follower,
265
+ );
266
+
267
+ const accept: Record<string, JsonValue> = {
268
+ "@context": "https://www.w3.org/ns/activitystreams",
269
+ id: `${config.iris.id}#accepts/${crypto.randomUUID()}`,
270
+ type: "Accept",
271
+ actor: config.iris.id,
272
+ object: activityAsObject(activity),
273
+ };
274
+ this.#enqueueDelivery(inbox, JSON.stringify(accept));
275
+ // Don't deliver inline — that would block the peer's POST on our outbound
276
+ // network. Arm the alarm; the single alarm worker is the only delivery
277
+ // driver, so retries never race a second concurrent pass.
278
+ await this.#armAlarm();
279
+ }
280
+
281
+ /** Handle `Undo` of a `Follow` (unfollow); other undos are ignored. */
282
+ #onUndo(activity: ActivityObject): void {
283
+ // Only an embedded `Follow` object is an unfollow. A bare string `object`
284
+ // is an activity IRI we cannot classify (we do not store inbound `Follow`s),
285
+ // so treating it as a `Follow` would let an `Undo Like`/`Undo Announce`
286
+ // carrying a string id silently drop a follower. Require the typed form.
287
+ if (objectType(activity.object) !== "Follow") return;
288
+ const follower = actorIri(activity.actor);
289
+ if (follower)
290
+ this.#sql.exec(`DELETE FROM followers WHERE actor = ?`, follower);
291
+ }
292
+
293
+ /** Handle a remote `Accept` of our `Follow`: mark that following confirmed. */
294
+ #onAccept(activity: ActivityObject): void {
295
+ const remote = actorIri(activity.actor);
296
+ if (remote) {
297
+ this.#sql.exec(
298
+ `UPDATE following SET state = 'accepted' WHERE actor = ?`,
299
+ remote,
300
+ );
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Handle a remote `Reject` of our `Follow`: drop the pending `following` row
306
+ * for the rejecting actor. A `Reject` of a `Follow` we sent leaves the row
307
+ * stuck `pending` forever otherwise, so the request never retries or clears.
308
+ */
309
+ #onReject(activity: ActivityObject): void {
310
+ // Only a `Reject` whose object is the `Follow` we sent concerns us; any
311
+ // other rejected object is for an activity we never tracked here.
312
+ if (objectType(activity.object) !== "Follow") return;
313
+ const remote = actorIri(activity.actor);
314
+ if (remote) {
315
+ this.#sql.exec(`DELETE FROM following WHERE actor = ?`, remote);
316
+ }
317
+ }
318
+
319
+ /** Handle `Delete` of an actor: drop it from followers if present. */
320
+ #onDelete(activity: ActivityObject): void {
321
+ const gone = objectId(activity.object);
322
+ if (gone) this.#sql.exec(`DELETE FROM followers WHERE actor = ?`, gone);
323
+ }
324
+
325
+ #storeInbox(activity: ActivityObject): void {
326
+ const id =
327
+ typeof activity.id === "string" ? activity.id : crypto.randomUUID();
328
+ this.#sql.exec(
329
+ `INSERT OR IGNORE INTO inbox (id, json, received_at) VALUES (?, ?, ?)`,
330
+ id,
331
+ JSON.stringify(activity),
332
+ Date.now(),
333
+ );
334
+ }
335
+
336
+ /**
337
+ * ActivityPub §7.1.2 inbox forwarding ("ghost replies"). When a remote
338
+ * activity (a) is freshly seen, (b) addresses a collection this actor owns —
339
+ * in practice our `followers` collection (directly or via the Public
340
+ * collection) — and (c) references via `object` / `target` / `inReplyTo` /
341
+ * `tag` an object WE own (i.e. an IRI under this actor's resources), we
342
+ * re-deliver the VERBATIM activity to our followers. Without this, a reply to
343
+ * one of our posts never reaches the followers who only saw the original
344
+ * through us ("ghost replies").
345
+ *
346
+ * Conservative by design: we forward only when we actually own the referenced
347
+ * local object, so a peer cannot use us to amplify arbitrary traffic. The
348
+ * §7.1.2 depth limit is satisfied implicitly — we forward only when we are the
349
+ * ORIGIN of the addressed local object (the referenced IRI is ours), never
350
+ * because some upstream server forwarded the activity to us.
351
+ */
352
+ async #maybeForward(
353
+ activity: ActivityObject,
354
+ firstSeen: boolean,
355
+ config: ForwardedConfig,
356
+ ): Promise<void> {
357
+ if (!firstSeen) return;
358
+ if (!this.#addressesFollowers(activity, config.iris)) return;
359
+ if (!this.#referencesLocalObject(activity, config.iris)) return;
360
+
361
+ // Re-deliver the activity exactly as received (verbatim) to our followers.
362
+ const body = JSON.stringify(activity);
363
+ let forwarded = false;
364
+ for (const row of this.#sql
365
+ .exec<{
366
+ inbox: string | null;
367
+ }>(`SELECT inbox FROM followers WHERE inbox IS NOT NULL`)
368
+ .toArray()) {
369
+ if (row.inbox) {
370
+ this.#enqueueDelivery(row.inbox, body);
371
+ forwarded = true;
372
+ }
373
+ }
374
+ if (forwarded) await this.#armAlarm();
375
+ }
376
+
377
+ /**
378
+ * Whether the activity's addressing (`to` / `cc` / `audience`) names a
379
+ * collection we own — our `followers` collection, either directly or via the
380
+ * special Public collection that fans out to followers.
381
+ */
382
+ #addressesFollowers(activity: ActivityObject, iris: ActorIris): boolean {
383
+ const recipients = new Set<string>();
384
+ for (const field of ["to", "cc", "audience", "bto", "bcc"] as const) {
385
+ for (const value of audienceValues(activity[field])) {
386
+ recipients.add(value);
387
+ }
388
+ }
389
+ return recipients.has(iris.followers) || recipients.has(PUBLIC_AUDIENCE);
390
+ }
391
+
392
+ /**
393
+ * Whether the activity references — via `object`, `target`, `inReplyTo`, or
394
+ * `tag` — an object WE own, i.e. an IRI under this actor's resources. The
395
+ * `inReplyTo` may sit on the wrapped object (e.g. a `Create`'s `Note`), so we
396
+ * inspect both the activity and its embedded object.
397
+ */
398
+ #referencesLocalObject(activity: ActivityObject, iris: ActorIris): boolean {
399
+ const refs: (JsonValue | undefined)[] = [
400
+ activity.object,
401
+ activity.target,
402
+ activity.inReplyTo,
403
+ activity.tag,
404
+ ];
405
+ const inner = activity.object;
406
+ if (inner && typeof inner === "object" && !Array.isArray(inner)) {
407
+ const obj = inner as Record<string, JsonValue>;
408
+ // The §7.1.2 reference fields, as they appear on the WRAPPED object (a
409
+ // `Create`'s `Note` carries the `inReplyTo`). The object's own `id` is not
410
+ // a reference to something we own, so it is intentionally excluded.
411
+ refs.push(obj.inReplyTo, obj.target, obj.tag);
412
+ }
413
+ for (const ref of refs) {
414
+ for (const iri of referenceIris(ref)) {
415
+ if (isLocalResource(iri, iris)) return true;
416
+ }
417
+ }
418
+ return false;
419
+ }
420
+
421
+ // -- publish (owner C2S seam) ----------------------------------------------
422
+
423
+ /**
424
+ * Publish an owner-supplied activity to the outbox and fan it out to every
425
+ * follower's inbox. A bare object (e.g. a `Note`) is wrapped in a `Create`.
426
+ * The front door has already authorized this request via the publish token.
427
+ */
428
+ async #publish(request: Request): Promise<Response> {
429
+ const config = this.#config!;
430
+ if (request.headers.get(INTERNAL_HEADERS.publish) !== "1") {
431
+ return text(403, "Publishing is not enabled");
432
+ }
433
+ let input: ActivityObject;
434
+ try {
435
+ input = (await request.json()) as ActivityObject;
436
+ } catch {
437
+ return text(400, "Malformed activity JSON");
438
+ }
439
+
440
+ const activity = this.#asOutboxActivity(input, config.iris);
441
+ const id = activity.id as string;
442
+ this.#sql.exec(
443
+ `INSERT OR IGNORE INTO outbox (id, json, published_at) VALUES (?, ?, ?)`,
444
+ id,
445
+ JSON.stringify(activity),
446
+ Date.now(),
447
+ );
448
+
449
+ const body = JSON.stringify(activity);
450
+ for (const row of this.#sql
451
+ .exec<{
452
+ inbox: string | null;
453
+ }>(`SELECT inbox FROM followers WHERE inbox IS NOT NULL`)
454
+ .toArray()) {
455
+ if (row.inbox) this.#enqueueDelivery(row.inbox, body);
456
+ }
457
+ // Fan-out runs in the background alarm worker, not inline, so a large
458
+ // follower set never slows the owner's publish response.
459
+ await this.#armAlarm();
460
+
461
+ return json(201, activity as JsonValue, { location: id });
462
+ }
463
+
464
+ /** Wrap a bare object in a `Create`, assign ids/audience, and timestamp it. */
465
+ #asOutboxActivity(
466
+ input: ActivityObject,
467
+ iris: ActorIris,
468
+ ): Record<string, JsonValue> {
469
+ const isActivity =
470
+ typeof input.type === "string" &&
471
+ ["Create", "Update", "Delete", "Announce", "Like", "Follow"].includes(
472
+ input.type,
473
+ );
474
+ const published = new Date().toISOString();
475
+ const activityId = `${iris.outbox}/${crypto.randomUUID()}`;
476
+
477
+ if (isActivity) {
478
+ // Per ActivityPub §6 / §3.1 the SERVER assigns the activity `id`; a
479
+ // client-supplied `id` is ignored/overwritten so a peer cannot dictate
480
+ // our IRI space (matching the bare-object wrap path below).
481
+ return {
482
+ "@context": "https://www.w3.org/ns/activitystreams",
483
+ ...(input as Record<string, JsonValue>),
484
+ id: activityId,
485
+ actor: iris.id,
486
+ published,
487
+ };
488
+ }
489
+ // A bare object: wrap it in a Create addressed to the public + followers.
490
+ return {
491
+ "@context": "https://www.w3.org/ns/activitystreams",
492
+ id: activityId,
493
+ type: "Create",
494
+ actor: iris.id,
495
+ published,
496
+ to: [PUBLIC_AUDIENCE],
497
+ cc: [iris.followers],
498
+ object: {
499
+ ...(input as Record<string, JsonValue>),
500
+ id: typeof input.id === "string" ? input.id : `${activityId}/object`,
501
+ attributedTo: iris.id,
502
+ published,
503
+ },
504
+ };
505
+ }
506
+
507
+ // -- collections -----------------------------------------------------------
508
+
509
+ #serveCollection(
510
+ request: Request,
511
+ collectionId: string,
512
+ kind: "followers" | "following" | "outbox",
513
+ ): Response {
514
+ const config = this.#config!;
515
+ const total = this.#count(kind);
516
+ const url = new URL(request.url);
517
+ const pageParam = url.searchParams.get("page");
518
+ if (pageParam === null) {
519
+ return json(200, buildCollection(collectionId, total, config.pageSize));
520
+ }
521
+ const page = Math.max(1, Number.parseInt(pageParam, 10) || 1);
522
+ const items = this.#pageItems(kind, page, config.pageSize);
523
+ return json(
524
+ 200,
525
+ buildCollectionPage(collectionId, page, config.pageSize, total, items),
526
+ );
527
+ }
528
+
529
+ #count(kind: "followers" | "following" | "outbox"): number {
530
+ const table =
531
+ kind === "outbox"
532
+ ? "outbox"
533
+ : kind === "followers"
534
+ ? "followers"
535
+ : "following";
536
+ const where = kind === "following" ? " WHERE state = 'accepted'" : "";
537
+ return this.#sql
538
+ .exec<{ n: number }>(`SELECT COUNT(*) AS n FROM ${table}${where}`)
539
+ .one().n;
540
+ }
541
+
542
+ #pageItems(
543
+ kind: "followers" | "following" | "outbox",
544
+ page: number,
545
+ pageSize: number,
546
+ ): JsonValue[] {
547
+ const offset = (page - 1) * pageSize;
548
+ if (kind === "outbox") {
549
+ return this.#sql
550
+ .exec<{ json: string }>(
551
+ `SELECT json FROM outbox ORDER BY seq DESC LIMIT ? OFFSET ?`,
552
+ pageSize,
553
+ offset,
554
+ )
555
+ .toArray()
556
+ .map((row) => JSON.parse(row.json) as JsonValue);
557
+ }
558
+ const table = kind === "followers" ? "followers" : "following";
559
+ const where = kind === "following" ? " WHERE state = 'accepted'" : "";
560
+ return this.#sql
561
+ .exec<{ actor: string }>(
562
+ `SELECT actor FROM ${table}${where} ORDER BY added_at DESC LIMIT ? OFFSET ?`,
563
+ pageSize,
564
+ offset,
565
+ )
566
+ .toArray()
567
+ .map((row) => row.actor as JsonValue);
568
+ }
569
+
570
+ // -- dedup -----------------------------------------------------------------
571
+
572
+ #alreadySeen(id: string): boolean {
573
+ return (
574
+ this.#sql
575
+ .exec<{ n: number }>(`SELECT COUNT(*) AS n FROM seen WHERE id = ?`, id)
576
+ .one().n > 0
577
+ );
578
+ }
579
+
580
+ #recordSeen(id: string): void {
581
+ const now = Date.now();
582
+ this.#sql.exec(`DELETE FROM seen WHERE seen_at < ?`, now - SEEN_TTL_MS);
583
+ this.#sql.exec(
584
+ `INSERT OR IGNORE INTO seen (id, seen_at) VALUES (?, ?)`,
585
+ id,
586
+ now,
587
+ );
588
+ }
589
+
590
+ // -- delivery --------------------------------------------------------------
591
+
592
+ #enqueueDelivery(inbox: string, json: string): void {
593
+ this.#sql.exec(
594
+ `INSERT INTO delivery (inbox, json, attempts, next_at) VALUES (?, ?, 0, ?)`,
595
+ inbox,
596
+ json,
597
+ Date.now(),
598
+ );
599
+ }
600
+
601
+ /**
602
+ * Process every due delivery row once: sign and `POST` it, deleting on
603
+ * success or permanent failure and rescheduling with exponential backoff on a
604
+ * retryable one. Returns how many rows it attempted. Re-arms the alarm for the
605
+ * next due row, if any.
606
+ */
607
+ async #processDeliveries(): Promise<number> {
608
+ const signer = this.#deliverySigner();
609
+ const now = Date.now();
610
+ const due = this.#sql
611
+ .exec<{
612
+ seq: number;
613
+ inbox: string;
614
+ json: string;
615
+ attempts: number;
616
+ }>(
617
+ `SELECT seq, inbox, json, attempts FROM delivery WHERE next_at <= ?
618
+ ORDER BY next_at ASC LIMIT ?`,
619
+ now,
620
+ DELIVERY_BATCH,
621
+ )
622
+ .toArray();
623
+
624
+ for (const row of due) {
625
+ if (!signer) {
626
+ // No signing key configured: we can never deliver. Drop the row.
627
+ this.#sql.exec(`DELETE FROM delivery WHERE seq = ?`, row.seq);
628
+ continue;
629
+ }
630
+ try {
631
+ const result = await deliverActivity(
632
+ row.inbox,
633
+ row.json,
634
+ signer,
635
+ fetch,
636
+ () => Date.now(),
637
+ );
638
+ if (result.ok || !result.retryable) {
639
+ this.#sql.exec(`DELETE FROM delivery WHERE seq = ?`, row.seq);
640
+ } else {
641
+ this.#rescheduleOrDrop(row.seq, row.attempts);
642
+ }
643
+ } catch (error) {
644
+ if (error instanceof DeliveryBlockedError) {
645
+ // Unsafe target — never reachable; drop it.
646
+ this.#sql.exec(`DELETE FROM delivery WHERE seq = ?`, row.seq);
647
+ } else {
648
+ this.#rescheduleOrDrop(row.seq, row.attempts);
649
+ }
650
+ }
651
+ }
652
+
653
+ await this.#armAlarm();
654
+ return due.length;
655
+ }
656
+
657
+ #rescheduleOrDrop(seq: number, attempts: number): void {
658
+ const next = attempts + 1;
659
+ const max = this.#deliveryPolicy("deliveryMaxAttempts", 8);
660
+ if (next >= max) {
661
+ this.#sql.exec(`DELETE FROM delivery WHERE seq = ?`, seq);
662
+ return;
663
+ }
664
+ const base = this.#deliveryPolicy("deliveryBaseDelayMs", 60_000);
665
+ const delay = base * 2 ** attempts;
666
+ this.#sql.exec(
667
+ `UPDATE delivery SET attempts = ?, next_at = ? WHERE seq = ?`,
668
+ next,
669
+ Date.now() + delay,
670
+ seq,
671
+ );
672
+ }
673
+
674
+ /** Schedule the alarm for the earliest pending delivery, if any. */
675
+ async #armAlarm(): Promise<void> {
676
+ const next = this.#sql
677
+ .exec<{
678
+ next_at: number | null;
679
+ }>(`SELECT MIN(next_at) AS next_at FROM delivery`)
680
+ .one().next_at;
681
+ if (next === null) return;
682
+ await this.ctx.storage.setAlarm(next);
683
+ }
684
+
685
+ override async alarm(): Promise<void> {
686
+ await this.#processDeliveries();
687
+ }
688
+
689
+ // -- helpers ---------------------------------------------------------------
690
+
691
+ #stats(): Response {
692
+ const localPosts = this.#count("outbox");
693
+ return json(200, { users: 1, localPosts } as JsonValue);
694
+ }
695
+
696
+ /** Resolve a remote actor's `inbox` URL (sharedInbox preferred), or `null`. */
697
+ async #resolveInbox(actor: string): Promise<string | null> {
698
+ try {
699
+ assertPublicHttpsTarget(actor);
700
+ } catch {
701
+ return null;
702
+ }
703
+ let response: Response;
704
+ try {
705
+ response = await fetch(actor, {
706
+ headers: { accept: "application/activity+json" },
707
+ // Bound the lookup so a slow/hung remote cannot pin the inbound request.
708
+ signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
709
+ });
710
+ } catch {
711
+ return null;
712
+ }
713
+ if (!response.ok) return null;
714
+ let doc: unknown;
715
+ try {
716
+ doc = await response.json();
717
+ } catch {
718
+ return null;
719
+ }
720
+ if (!doc || typeof doc !== "object") return null;
721
+ const record = doc as Record<string, unknown>;
722
+ const endpoints = record.endpoints;
723
+ if (endpoints && typeof endpoints === "object") {
724
+ const shared = (endpoints as Record<string, unknown>).sharedInbox;
725
+ if (typeof shared === "string") return shared;
726
+ }
727
+ return typeof record.inbox === "string" ? record.inbox : null;
728
+ }
729
+
730
+ #deliverySigner(): { keyId: string; privateKeyPem: string } | null {
731
+ const keyId = this.#kvGet("keyId");
732
+ const privateKeyPem = this.#kvGet("privateKeyPem");
733
+ if (keyId && privateKeyPem) return { keyId, privateKeyPem };
734
+ return null;
735
+ }
736
+
737
+ #persistDeliveryConfig(config: ForwardedConfig): void {
738
+ if (config.privateKeyPem) {
739
+ this.#kvPut("privateKeyPem", config.privateKeyPem);
740
+ this.#kvPut("keyId", config.keyId);
741
+ }
742
+ // Persist the retry policy too: an alarm can wake on a cold isolate where
743
+ // `#config` is null, and the backoff must still honor the configured policy
744
+ // rather than silently fall back to defaults.
745
+ this.#kvPut("deliveryMaxAttempts", String(config.deliveryMaxAttempts));
746
+ this.#kvPut("deliveryBaseDelayMs", String(config.deliveryBaseDelayMs));
747
+ }
748
+
749
+ /** A numeric delivery-policy value: live config first, then the persisted copy. */
750
+ #deliveryPolicy(
751
+ key: "deliveryMaxAttempts" | "deliveryBaseDelayMs",
752
+ fallback: number,
753
+ ): number {
754
+ const live = this.#config?.[key];
755
+ if (typeof live === "number") return live;
756
+ const stored = this.#kvGet(key);
757
+ const parsed = stored === null ? NaN : Number(stored);
758
+ return Number.isFinite(parsed) ? parsed : fallback;
759
+ }
760
+
761
+ #kvGet(key: string): string | null {
762
+ const row = this.#sql
763
+ .exec<{ v: string }>(`SELECT v FROM kv WHERE k = ?`, key)
764
+ .toArray();
765
+ return row.length > 0 ? (row[0] as { v: string }).v : null;
766
+ }
767
+
768
+ #kvPut(key: string, value: string): void {
769
+ this.#sql.exec(
770
+ `INSERT INTO kv (k, v) VALUES (?, ?)
771
+ ON CONFLICT(k) DO UPDATE SET v = excluded.v`,
772
+ key,
773
+ value,
774
+ );
775
+ }
776
+
777
+ #readConfig(request: Request): ForwardedConfig | null {
778
+ const raw = request.headers.get(INTERNAL_HEADERS.config);
779
+ if (!raw) return null;
780
+ try {
781
+ return JSON.parse(raw) as ForwardedConfig;
782
+ } catch {
783
+ return null;
784
+ }
785
+ }
786
+ }
787
+
788
+ /** The path portion (with query) of an IRI, for routing comparisons. */
789
+ function pathOf(iri: string): string {
790
+ return new URL(iri).pathname;
791
+ }
792
+
793
+ /**
794
+ * Whether a `Create`/`Update`'s embedded object(s) are attributed to the
795
+ * activity's own actor. Liberal: an absent object, a string-IRI object, or an
796
+ * object with no `attributedTo` all pass (nothing to contradict). Both `object`
797
+ * and `attributedTo` may be arrays in ActivityStreams, so *every* embedded
798
+ * object and *every* named attribution is checked — a present `attributedTo`
799
+ * that names a different actor fails even when wrapped in an array (closing an
800
+ * impersonation bypass).
801
+ */
802
+ function attributionMatches(activity: ActivityObject): boolean {
803
+ const author = actorIri(activity.actor);
804
+ if (!author) return true;
805
+ for (const object of asArray(activity.object)) {
806
+ if (!object || typeof object !== "object" || Array.isArray(object)) {
807
+ continue;
808
+ }
809
+ const attributedTo = (object as Record<string, JsonValue>).attributedTo;
810
+ for (const attribution of asArray(attributedTo)) {
811
+ const iri = actorIri(attribution);
812
+ if (iri !== undefined && iri !== author) return false;
813
+ }
814
+ }
815
+ return true;
816
+ }
817
+
818
+ /** Wrap a value as an array: empty for nullish, itself when already an array. */
819
+ function asArray(value: JsonValue | undefined): readonly JsonValue[] {
820
+ if (value === undefined || value === null) return [];
821
+ return Array.isArray(value) ? value : [value];
822
+ }
823
+
824
+ /** Flatten an addressing field (`to`/`cc`/…) to the set of IRI strings it names. */
825
+ function audienceValues(value: JsonValue | undefined): string[] {
826
+ if (typeof value === "string") return [value];
827
+ if (Array.isArray(value)) {
828
+ return value.filter((v): v is string => typeof v === "string");
829
+ }
830
+ return [];
831
+ }
832
+
833
+ /**
834
+ * Flatten a reference field (`object`/`target`/`inReplyTo`/`tag`) to the IRI
835
+ * strings it points at, whether it is a string, an embedded object (its `id`),
836
+ * or an array of either.
837
+ */
838
+ function referenceIris(value: JsonValue | undefined): string[] {
839
+ if (value === undefined || value === null) return [];
840
+ if (Array.isArray(value)) {
841
+ return value.flatMap((v) => referenceIris(v));
842
+ }
843
+ const id = objectId(value);
844
+ return id ? [id] : [];
845
+ }
846
+
847
+ /**
848
+ * Whether an IRI names a resource this actor owns: same origin as the actor IRI
849
+ * and a path under the actor's path prefix. Conservative on purpose — only an
850
+ * IRI clearly within our resource space counts as ours.
851
+ */
852
+ function isLocalResource(iri: string, iris: ActorIris): boolean {
853
+ let url: URL;
854
+ let actor: URL;
855
+ try {
856
+ url = new URL(iri);
857
+ actor = new URL(iris.id);
858
+ } catch {
859
+ return false;
860
+ }
861
+ if (url.origin !== actor.origin) return false;
862
+ // The actor IRI itself, or any path beneath it (e.g. `<actor>/outbox/<uuid>`,
863
+ // `<actor>/statuses/1`) is ours. Normalize the trailing slash so a
864
+ // root-hosted actor (pathname `/`) doesn't produce a `//` prefix that never
865
+ // matches — every same-origin resource is then correctly under it.
866
+ const prefix = actor.pathname.endsWith("/")
867
+ ? actor.pathname
868
+ : `${actor.pathname}/`;
869
+ return url.pathname === actor.pathname || url.pathname.startsWith(prefix);
870
+ }
871
+
872
+ /**
873
+ * Reduce an inbound activity to the object form embedded in an `Accept`: the
874
+ * full activity minus our internal/context noise, so the follower can match it
875
+ * to the `Follow` they sent.
876
+ */
877
+ function activityAsObject(activity: ActivityObject): JsonValue {
878
+ const copy: Record<string, JsonValue> = {};
879
+ for (const [key, value] of Object.entries(activity)) {
880
+ if (value !== undefined) copy[key] = value;
881
+ }
882
+ return copy;
883
+ }