@sdxc/activitypub 0.0.0-pre.1 → 2026.10.9

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.
package/README.md CHANGED
@@ -315,6 +315,25 @@ already serialized. An activity over 120 KB (`MAX_ACTIVITY_BYTES`) fails `too-la
315
315
  delivery message could carry it, and a failing queue fails a retryable `enqueue`. A retried
316
316
  fan-out may queue some deliveries twice, which receivers absorb by activity id.
317
317
 
318
+ #### `federation.approve(follower: string): Promise<Result<void, ActivityPubError>>`
319
+
320
+ Accepts a follower a `"pending"` decision held back: it is stored as `accepted`, so it joins the
321
+ followers collection and every later fan-out, and an `Accept` of its stored Follow is queued to
322
+ its inbox. Approving an accepted follower sends the same Accept again, so a retry is safe.
323
+
324
+ #### `federation.reject(follower: string): Promise<Result<void, ActivityPubError>>`
325
+
326
+ Refuses a follower, pending or accepted: a `Reject` of its stored Follow is queued to its inbox,
327
+ then the follower is removed. It stays stored until the Reject is queued, so retrying a failed
328
+ call still sends it.
329
+
330
+ Both answer `not-found` for an actor that does not follow the local actor, and a retryable
331
+ `store` or `enqueue` failure when the store or the queue fails.
332
+
333
+ ```typescript
334
+ let approved = await federation.approve("https://mastodon.social/users/alice");
335
+ ```
336
+
318
337
  #### `federation.lookup(handle: string): Promise<Result<ActivityPub.Actor, ActivityPubFetchError>>`
319
338
 
320
339
  Resolves `@user@host`, `user@host` or `acct:user@host` the way Mastodon does: WebFinger on the
@@ -551,7 +570,7 @@ Every error extends `ActivityPubError`, which carries a `code` and a `retryable`
551
570
  ### Storage
552
571
 
553
572
  The package keeps no state of its own. It calls four interfaces, exported as types, and the
554
- app implements them over its own database:
573
+ app implements them over its own database, or uses `CacheSeenActivities` for `SeenActivities`:
555
574
 
556
575
  - `FollowerStore`: followers keyed by `(actor, id)`. `put` inserts or replaces, so a repeated
557
576
  Follow refreshes the inbox and `followId`. `list`, `count` and `inboxes` see accepted
@@ -563,6 +582,33 @@ app implements them over its own database:
563
582
  - `LocalObjects`: `find(id)` answers the object the app serves under `id`, or `null`.
564
583
  - `KeyProvider`: `keysOf(actor)` answers a hosted actor's `ActorKeys`, or `null`.
565
584
 
585
+ ### `CacheSeenActivities`
586
+
587
+ A shipped `SeenActivities` over any `@sdxc/cache` `Cache`, so an app that already caches remote
588
+ documents in Workers KV can hold its claims there too.
589
+
590
+ ```typescript
591
+ import { CacheSeenActivities, Federation } from "@sdxc/activitypub";
592
+ import { WorkerKVCache } from "@sdxc/cache/worker-kv";
593
+
594
+ let cache = new WorkerKVCache(env.CACHE);
595
+
596
+ let federation = new Federation({
597
+ // ...
598
+ stores: { followers, seen: new CacheSeenActivities(cache), objects },
599
+ cache,
600
+ });
601
+ ```
602
+
603
+ - Claims live under `activitypub:seen:<id>`, apart from anything else the cache holds.
604
+ - A claim's TTL is rounded up to whole seconds and held for at least 60, the shortest
605
+ expiration Workers KV accepts, so a TTL under a minute is stretched rather than refused.
606
+ - `claim` fails with a `RangeError` for a TTL that is not a positive duration, and with the
607
+ cache's error when the key cannot be read. A refused write still answers `true`: the
608
+ activity is processed, and a redelivery is processed again.
609
+ - A claim reads the key and writes it when missing, so two deliveries racing through different
610
+ isolates can both win. Every handler is idempotent, which makes that harmless.
611
+
566
612
  ### `@sdxc/activitypub/testing`
567
613
 
568
614
  In-memory implementations of every storage interface, and Vitest suites an app registers
@@ -637,8 +683,8 @@ export default createJobHandler(definitions.federation, async (ctx) => {
637
683
  let processed = await federationFor(storesOf(ctx)).process(ctx.input, { attempts: ctx.attempts });
638
684
  if (isFailure(processed)) {
639
685
  if (processed.error.retryable)
640
- ctx.retry({ delay: processed.error.delay, cause: processed.error });
641
- ctx.ack(processed.error.message);
686
+ return ctx.retry({ delay: processed.error.delay, cause: processed.error });
687
+ return ctx.ack(processed.error.message);
642
688
  }
643
689
  });
644
690
  ```
@@ -130,6 +130,28 @@ export declare class Federation {
130
130
  * @example await federation.publish({ id: `${post.url}#create`, type: "Create", actor: ACTOR_ID, object: article, to: [PUBLIC], cc: [FOLLOWERS_ID] });
131
131
  */
132
132
  publish(activity: ActivityPub.Draft<ActivityPub.Activity> | string): Promise<Result<void, ActivityPubError>>;
133
+ /**
134
+ * Accepts a follower held `pending` by a manual-approval decision: it joins the
135
+ * followers collection and fan-outs, and an `Accept` of its stored Follow is queued to
136
+ * its inbox. Approving an accepted follower re-sends the same Accept, so a retry is safe.
137
+ *
138
+ * @param follower - The remote actor's id.
139
+ * @returns `not-found` when the actor does not follow the local actor, or a retryable
140
+ * `store` or `enqueue` failure.
141
+ * @example let approved = await federation.approve("https://mastodon.social/users/alice");
142
+ */
143
+ approve(follower: string): Promise<Result<void, ActivityPubError>>;
144
+ /**
145
+ * Refuses a follower, pending or accepted: a `Reject` of its stored Follow is queued to
146
+ * its inbox, then the follower is forgotten. The follower stays stored until the Reject
147
+ * is queued, so retrying a failed call still sends it.
148
+ *
149
+ * @param follower - The remote actor's id.
150
+ * @returns `not-found` when the actor does not follow the local actor, or a retryable
151
+ * `store` or `enqueue` failure.
152
+ * @example let rejected = await federation.reject("https://mastodon.social/users/alice");
153
+ */
154
+ reject(follower: string): Promise<Result<void, ActivityPubError>>;
133
155
  /**
134
156
  * Resolves a handle to its actor the way Mastodon does: WebFinger on the handle's host,
135
157
  * the `self` link typed as ActivityStreams, then the actor, whose own host must confirm
@@ -5,7 +5,7 @@ import { lookup } from "./discovery.js";
5
5
  import { ActivityPubError, FederationError } from "./errors.js";
6
6
  import { fanOut, MAX_ACTIVITY_BYTES } from "./lib/delivery-fan-out.js";
7
7
  import { deliver, DELIVERY_BACKOFF } from "./lib/delivery-post.js";
8
- import { handle } from "./lib/inbox-handle.js";
8
+ import { answerTo, handle, store } from "./lib/inbox-handle.js";
9
9
  import { receive, verifyFetch } from "./lib/inbox-receive.js";
10
10
  import { summarize } from "./lib/inbox-summarize.js";
11
11
  import { MESSAGE } from "./lib/messages.js";
@@ -213,6 +213,45 @@ export class Federation {
213
213
  }
214
214
  return this.#enqueue([{ kind: "fanOut", actor: this.#options.actor.id, activity: text }]);
215
215
  }
216
+ /**
217
+ * Accepts a follower held `pending` by a manual-approval decision: it joins the
218
+ * followers collection and fan-outs, and an `Accept` of its stored Follow is queued to
219
+ * its inbox. Approving an accepted follower re-sends the same Accept, so a retry is safe.
220
+ *
221
+ * @param follower - The remote actor's id.
222
+ * @returns `not-found` when the actor does not follow the local actor, or a retryable
223
+ * `store` or `enqueue` failure.
224
+ * @example let approved = await federation.approve("https://mastodon.social/users/alice");
225
+ */
226
+ async approve(follower) {
227
+ let found = await this.#follower(follower);
228
+ if (isFailure(found))
229
+ return found;
230
+ let put = store(await this.#options.stores.followers.put({ ...found.data, state: "accepted" }));
231
+ if (isFailure(put))
232
+ return put;
233
+ return this.#answer("Accept", found.data);
234
+ }
235
+ /**
236
+ * Refuses a follower, pending or accepted: a `Reject` of its stored Follow is queued to
237
+ * its inbox, then the follower is forgotten. The follower stays stored until the Reject
238
+ * is queued, so retrying a failed call still sends it.
239
+ *
240
+ * @param follower - The remote actor's id.
241
+ * @returns `not-found` when the actor does not follow the local actor, or a retryable
242
+ * `store` or `enqueue` failure.
243
+ * @example let rejected = await federation.reject("https://mastodon.social/users/alice");
244
+ */
245
+ async reject(follower) {
246
+ let found = await this.#follower(follower);
247
+ if (isFailure(found))
248
+ return found;
249
+ let answered = await this.#answer("Reject", found.data);
250
+ if (isFailure(answered))
251
+ return answered;
252
+ let actor = this.#options.actor.id;
253
+ return store(await this.#options.stores.followers.remove(actor, follower));
254
+ }
216
255
  /**
217
256
  * Resolves a handle to its actor the way Mastodon does: WebFinger on the handle's host,
218
257
  * the `self` link typed as ActivityStreams, then the actor, whose own host must confirm
@@ -403,6 +442,23 @@ export class Federation {
403
442
  let delay = Math.max(retryAfter ?? 0, this.#backoff.delay(attempts));
404
443
  return failure(new FederationError(kind, error, delay));
405
444
  }
445
+ /** The local actor's stored follower `id`, or `not-found`. */
446
+ async #follower(id) {
447
+ let found = store(await this.#options.stores.followers.get(this.#options.actor.id, id));
448
+ if (isFailure(found))
449
+ return found;
450
+ if (found.data === null) {
451
+ return failure(new ActivityPubError("not-found", `${id} does not follow the local actor`));
452
+ }
453
+ return success(found.data);
454
+ }
455
+ /** Queues an answer to the follower's stored Follow, delivered to its own inbox. */
456
+ async #answer(type, follower) {
457
+ let actor = this.#options.actor.id;
458
+ let follow = { id: follower.followId, type: "Follow", actor: follower.id, object: actor };
459
+ let activity = stringify(answerTo(type, actor, follow));
460
+ return this.#enqueue([{ kind: "deliver", actor, activity, inbox: follower.inbox }]);
461
+ }
406
462
  /**
407
463
  * Writes messages to the app's queue, through `enqueueMany` when it has one. A failure,
408
464
  * or an exception, answers a retryable `enqueue` error.
package/dist/index.d.ts CHANGED
@@ -31,3 +31,4 @@ export { wantsActivity } from "./lib/response.js";
31
31
  export { EXTENSION_CONTEXT, stringify } from "./lib/stringify.js";
32
32
  export { tombstone } from "./lib/tombstone.js";
33
33
  export { RemoteResolver } from "./remote.js";
34
+ export { CacheSeenActivities } from "./seen-activities.js";
package/dist/index.js CHANGED
@@ -20,3 +20,4 @@ export { wantsActivity } from "./lib/response.js";
20
20
  export { EXTENSION_CONTEXT, stringify } from "./lib/stringify.js";
21
21
  export { tombstone } from "./lib/tombstone.js";
22
22
  export { RemoteResolver } from "./remote.js";
23
+ export { CacheSeenActivities } from "./seen-activities.js";
@@ -117,3 +117,21 @@ export interface HandleOutcome {
117
117
  * if (isFailure(outcome) && outcome.error.retryable) return ctx.retry({ cause: outcome.error });
118
118
  */
119
119
  export declare function handle(input: Received, options: HandleOptions): Promise<Result<HandleOutcome, ActivityPubError>>;
120
+ /**
121
+ * An `Accept` or `Reject` of a Follow, embedding it, since some servers match the answer
122
+ * on the embedded Follow rather than its id. The id derives from the Follow's, so a
123
+ * re-sent answer to the same Follow is the same activity.
124
+ *
125
+ * @param type - Which answer.
126
+ * @param local - The local actor answering.
127
+ * @param follow - The Follow answered.
128
+ */
129
+ export declare function answerTo(type: "Accept" | "Reject", local: string, follow: ActivityPub.Draft<ActivityPub.Activity>): ActivityPub.Draft<ActivityPub.Activity>;
130
+ /**
131
+ * A store's answer, its failure made retryable, since a store that failed once can answer
132
+ * the next attempt.
133
+ *
134
+ * @param result - What the store answered.
135
+ * @template T - What the store answers.
136
+ */
137
+ export declare function store<T>(result: Result<T, Error>): Result<T, ActivityPubError>;
@@ -528,7 +528,7 @@ class Run {
528
528
  * @param local - The local actor answering.
529
529
  * @param follow - The Follow answered.
530
530
  */
531
- function answerTo(type, local, follow) {
531
+ export function answerTo(type, local, follow) {
532
532
  let path = type === "Accept" ? "accepts" : "rejects";
533
533
  return {
534
534
  id: `${local}#${path}/${encodeURIComponent(follow.id)}`,
@@ -562,7 +562,7 @@ async function settle(called) {
562
562
  * @param result - What the store answered.
563
563
  * @template T - What the store answers.
564
564
  */
565
- function store(result) {
565
+ export function store(result) {
566
566
  if (!isFailure(result))
567
567
  return result;
568
568
  return failure(new ActivityPubError("store", result.error.message, { retryable: true, cause: result.error }));
@@ -0,0 +1,30 @@
1
+ /**
2
+ * A `SeenActivities` store over any `@sdxc/cache` `Cache`, so an app whose remote-document
3
+ * cache already sits on Workers KV can remember processed activity ids in the same place.
4
+ * Best effort by contract: every handler is idempotent, so a lost claim only costs work.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { Cache } from "@sdxc/cache";
10
+ import type { DurationInput } from "@sdxc/duration";
11
+ import type { Result } from "@sdxc/result";
12
+ import type { SeenActivities } from "./store.js";
13
+ /**
14
+ * Claims activity ids in a `Cache` under `activitypub:seen:<id>`. A claim reads the key and
15
+ * writes it when missing, so two deliveries racing through different isolates can both win;
16
+ * the handlers' idempotency covers that.
17
+ *
18
+ * @example new CacheSeenActivities(new WorkerKVCache(env.CACHE))
19
+ */
20
+ export declare class CacheSeenActivities implements SeenActivities {
21
+ #private;
22
+ /** @param cache Where claims are held until their TTL passes. */
23
+ constructor(cache: Cache);
24
+ /**
25
+ * `true` when `id` was not claimed within its TTL, held from now for `ttl` rounded up to
26
+ * whole seconds and at least a minute. A cache that cannot be read is a failure; one that
27
+ * refuses the write still answers `true`, since processing again is harmless.
28
+ */
29
+ claim(id: string, ttl: DurationInput): Promise<Result<boolean, Error>>;
30
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * A `SeenActivities` store over any `@sdxc/cache` `Cache`, so an app whose remote-document
3
+ * cache already sits on Workers KV can remember processed activity ids in the same place.
4
+ * Best effort by contract: every handler is idempotent, so a lost claim only costs work.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import { toMs } from "@sdxc/duration";
10
+ import { failure, isFailure, success } from "@sdxc/result";
11
+ /** Keeps the claims apart from anything else the cache holds. */
12
+ const KEY_PREFIX = "activitypub:seen:";
13
+ /** Workers KV refuses an expiration under a minute, so a shorter claim is held for one. */
14
+ const MIN_TTL_SECONDS = 60;
15
+ /**
16
+ * Claims activity ids in a `Cache` under `activitypub:seen:<id>`. A claim reads the key and
17
+ * writes it when missing, so two deliveries racing through different isolates can both win;
18
+ * the handlers' idempotency covers that.
19
+ *
20
+ * @example new CacheSeenActivities(new WorkerKVCache(env.CACHE))
21
+ */
22
+ export class CacheSeenActivities {
23
+ #cache;
24
+ /** @param cache Where claims are held until their TTL passes. */
25
+ constructor(cache) {
26
+ this.#cache = cache;
27
+ }
28
+ /**
29
+ * `true` when `id` was not claimed within its TTL, held from now for `ttl` rounded up to
30
+ * whole seconds and at least a minute. A cache that cannot be read is a failure; one that
31
+ * refuses the write still answers `true`, since processing again is harmless.
32
+ */
33
+ async claim(id, ttl) {
34
+ let ms = toMs(ttl);
35
+ if (!Number.isFinite(ms) || ms <= 0) {
36
+ return failure(new RangeError(`"${String(ttl)}" is not a duration a claim can last.`));
37
+ }
38
+ let key = `${KEY_PREFIX}${id}`;
39
+ let held = await this.#cache.read(key);
40
+ if (isFailure(held))
41
+ return held;
42
+ if (held.data !== null)
43
+ return success(false);
44
+ let seconds = Math.max(MIN_TTL_SECONDS, Math.ceil(ms / 1000));
45
+ await this.#cache.write(key, Date.now(), { ttl: seconds });
46
+ return success(true);
47
+ }
48
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdxc/activitypub",
3
- "version": "0.0.0-pre.1",
3
+ "version": "2026.10.9",
4
4
  "description": "ActivityPub protocol logic: vocabulary, actors, discovery, a verified inbox and signed delivery",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -10,18 +10,18 @@
10
10
  },
11
11
  "dependencies": {
12
12
  "@sdxc/backoff": "2026.10.6",
13
- "@sdxc/cache": "2026.10.6",
14
- "@sdxc/crypto": "2026.10.6",
15
- "@sdxc/digest-fields": "0.0.0-pre.1",
13
+ "@sdxc/cache": "2026.10.9",
14
+ "@sdxc/crypto": "2026.10.9",
15
+ "@sdxc/digest-fields": "2026.10.9",
16
16
  "@sdxc/duration": "2026.10.6",
17
17
  "@sdxc/html": "2026.10.6",
18
- "@sdxc/http": "2026.10.8",
19
- "@sdxc/http-signatures": "0.0.0-pre.1",
20
- "@sdxc/logger": "2026.10.6",
21
- "@sdxc/outbound": "2026.10.6",
18
+ "@sdxc/http": "2026.10.9",
19
+ "@sdxc/http-signatures": "2026.10.9",
20
+ "@sdxc/logger": "2026.10.9",
21
+ "@sdxc/outbound": "2026.10.9",
22
22
  "@sdxc/result": "2026.10.6",
23
23
  "@sdxc/validate": "2026.10.6",
24
- "@sdxc/well-known": "2026.10.8",
24
+ "@sdxc/well-known": "2026.10.9",
25
25
  "@standard-schema/spec": "^1.1.0",
26
26
  "remix": "3.0.0"
27
27
  },
@@ -33,7 +33,7 @@
33
33
  "optional": true
34
34
  }
35
35
  },
36
- "gitHead": "5b83053d6073199445712cf5d225e8a8b1667992",
36
+ "gitHead": "64fa732ed0a8ca8693a35aeef6345ae5be2534ef",
37
37
  "publishConfig": {
38
38
  "access": "public"
39
39
  },