@sdxc/activitypub 0.0.0-pre.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +711 -0
  3. package/dist/collections.d.ts +67 -0
  4. package/dist/collections.js +38 -0
  5. package/dist/conformance.d.ts +80 -0
  6. package/dist/conformance.js +313 -0
  7. package/dist/discovery.d.ts +46 -0
  8. package/dist/discovery.js +105 -0
  9. package/dist/errors.d.ts +115 -0
  10. package/dist/errors.js +124 -0
  11. package/dist/federation.d.ts +281 -0
  12. package/dist/federation.js +513 -0
  13. package/dist/fixtures/gotosocial.d.ts +114 -0
  14. package/dist/fixtures/gotosocial.js +115 -0
  15. package/dist/fixtures/index.d.ts +15 -0
  16. package/dist/fixtures/index.js +50 -0
  17. package/dist/fixtures/mastodon.d.ts +489 -0
  18. package/dist/fixtures/mastodon.js +334 -0
  19. package/dist/fixtures/misskey.d.ts +254 -0
  20. package/dist/fixtures/misskey.js +163 -0
  21. package/dist/fixtures/pixelfed.d.ts +118 -0
  22. package/dist/fixtures/pixelfed.js +126 -0
  23. package/dist/fixtures/shared.d.ts +14 -0
  24. package/dist/fixtures/shared.js +14 -0
  25. package/dist/fixtures/threads.d.ts +82 -0
  26. package/dist/fixtures/threads.js +88 -0
  27. package/dist/index.d.ts +33 -0
  28. package/dist/index.js +22 -0
  29. package/dist/keys.d.ts +100 -0
  30. package/dist/keys.js +147 -0
  31. package/dist/lib/availability.d.ts +37 -0
  32. package/dist/lib/availability.js +52 -0
  33. package/dist/lib/compact.d.ts +145 -0
  34. package/dist/lib/compact.js +514 -0
  35. package/dist/lib/constants.d.ts +35 -0
  36. package/dist/lib/constants.js +65 -0
  37. package/dist/lib/delivery-fan-out.d.ts +72 -0
  38. package/dist/lib/delivery-fan-out.js +202 -0
  39. package/dist/lib/delivery-post.d.ts +108 -0
  40. package/dist/lib/delivery-post.js +318 -0
  41. package/dist/lib/fetch.d.ts +64 -0
  42. package/dist/lib/fetch.js +103 -0
  43. package/dist/lib/inbox-error.d.ts +45 -0
  44. package/dist/lib/inbox-error.js +74 -0
  45. package/dist/lib/inbox-handle.d.ts +119 -0
  46. package/dist/lib/inbox-handle.js +587 -0
  47. package/dist/lib/inbox-receive.d.ts +117 -0
  48. package/dist/lib/inbox-receive.js +233 -0
  49. package/dist/lib/inbox-signature.d.ts +61 -0
  50. package/dist/lib/inbox-signature.js +156 -0
  51. package/dist/lib/inbox-summarize.d.ts +41 -0
  52. package/dist/lib/inbox-summarize.js +97 -0
  53. package/dist/lib/messages.d.ts +36 -0
  54. package/dist/lib/messages.js +35 -0
  55. package/dist/lib/parse.d.ts +42 -0
  56. package/dist/lib/parse.js +348 -0
  57. package/dist/lib/response.d.ts +45 -0
  58. package/dist/lib/response.js +59 -0
  59. package/dist/lib/stringify.d.ts +51 -0
  60. package/dist/lib/stringify.js +122 -0
  61. package/dist/lib/tombstone.d.ts +24 -0
  62. package/dist/lib/tombstone.js +15 -0
  63. package/dist/lib/types.d.ts +249 -0
  64. package/dist/lib/types.js +0 -0
  65. package/dist/memory.d.ts +77 -0
  66. package/dist/memory.js +146 -0
  67. package/dist/remote.d.ts +105 -0
  68. package/dist/remote.js +262 -0
  69. package/dist/store.d.ts +78 -0
  70. package/dist/store.js +8 -0
  71. package/dist/testing.d.ts +12 -0
  72. package/dist/testing.js +10 -0
  73. package/package.json +54 -0
@@ -0,0 +1,202 @@
1
+ import { failure, isFailure, success } from "@sdxc/result";
2
+ import { ActivityPubError } from "../errors.js";
3
+ import { isUnavailable } from "./availability.js";
4
+ import { PUBLIC } from "./constants.js";
5
+ import { parseActivity } from "./parse.js";
6
+ /** Leaves room for the inbox, the actor and the job envelope inside a 128 KB queue message. */
7
+ export const MAX_ACTIVITY_BYTES = 120 * 1024;
8
+ /** The members ActivityPub §6 strips before delivery, on the activity and its embedded object. */
9
+ const BLIND_MEMBERS = ["bto", "bcc"];
10
+ /**
11
+ * Enqueues one delivery per distinct inbox the activity is addressed to. The sender's
12
+ * followers collection in `to`, `cc`, `bto` or `bcc` (of the activity or its embedded
13
+ * object) pages through `FollowerStore.inboxes`; every other IRI except `PUBLIC` resolves
14
+ * to its actor's `sharedInbox ?? inbox`, and one that fails to resolve is skipped and
15
+ * counted, so one unreachable mention never holds back delivery to every follower.
16
+ *
17
+ * A batch closes at `pageSize` deliveries. A retried fan-out may enqueue some deliveries
18
+ * twice, which receivers absorb by activity id.
19
+ *
20
+ * @param input - The sending actor and the activity.
21
+ * @param options - The stores, the moderation callback and the job writer.
22
+ * @returns The counts, or `too-large` for an activity over 120 KB, `invalid-document`
23
+ * for one that does not parse, or a retryable `store` or `enqueue` failure.
24
+ * @example let planned = await fanOut(ctx.input, { actor: ACTOR, followers, resolver, blocked, cache, enqueue });
25
+ */
26
+ export async function fanOut(input, options) {
27
+ if (options.actor.id !== input.actor) {
28
+ return failure(new ActivityPubError("invalid-input", `The fan-out is for ${input.actor} but the actor document is ${options.actor.id}`));
29
+ }
30
+ let json = decode(input.activity);
31
+ if (json === null) {
32
+ return failure(new ActivityPubError("invalid-document", "The activity is not a JSON object"));
33
+ }
34
+ let activity = parseActivity(json);
35
+ if (isFailure(activity))
36
+ return activity;
37
+ let payload = withoutBlindMembers(json) ?? input.activity;
38
+ let size = new TextEncoder().encode(payload).byteLength;
39
+ if (size > MAX_ACTIVITY_BYTES) {
40
+ return failure(new ActivityPubError("too-large", `The activity is ${size} bytes, over the ${MAX_ACTIVITY_BYTES} a delivery can carry`));
41
+ }
42
+ let pageSize = options.pageSize ?? 100;
43
+ let now = options.now ?? (() => new Date());
44
+ let seen = new Set();
45
+ let hosts = new Map();
46
+ let origins = new Map();
47
+ let batch = [];
48
+ let enqueued = 0;
49
+ let skipped = 0;
50
+ /** Whether `host` is blocked, asking the app once per host. */
51
+ async function isBlocked(host) {
52
+ let known = hosts.get(host);
53
+ if (known !== undefined)
54
+ return known;
55
+ let answer = await options.blocked(host);
56
+ hosts.set(host, answer);
57
+ return answer;
58
+ }
59
+ /** Whether `origin` has been failing past the window, asking the cache once per origin. */
60
+ async function isDown(origin) {
61
+ let known = origins.get(origin);
62
+ if (known !== undefined)
63
+ return known;
64
+ let answer = await isUnavailable(options.cache, origin, now().getTime());
65
+ origins.set(origin, answer);
66
+ return answer;
67
+ }
68
+ /** Writes the pending batch, mapping a failure or a rejection to a retryable `enqueue`. */
69
+ async function flush() {
70
+ if (batch.length === 0)
71
+ return success(undefined);
72
+ let deliveries = batch;
73
+ batch = [];
74
+ let written;
75
+ try {
76
+ written = await options.enqueue(deliveries);
77
+ }
78
+ catch (cause) {
79
+ return failure(enqueueFailed(cause));
80
+ }
81
+ if (written !== undefined && isFailure(written))
82
+ return failure(enqueueFailed(written.error));
83
+ enqueued += deliveries.length;
84
+ return success(undefined);
85
+ }
86
+ /** Adds one target unless it repeats, is not a URL, or sits on a blocked or failing server. */
87
+ async function add(inbox) {
88
+ let url = URL.parse(inbox);
89
+ if (url === null) {
90
+ skipped++;
91
+ return success(undefined);
92
+ }
93
+ if (seen.has(url.href))
94
+ return success(undefined);
95
+ seen.add(url.href);
96
+ if ((await isBlocked(url.hostname)) || (await isDown(url.origin))) {
97
+ skipped++;
98
+ return success(undefined);
99
+ }
100
+ batch.push({ actor: input.actor, activity: payload, inbox: url.href });
101
+ if (batch.length >= pageSize)
102
+ return flush();
103
+ return success(undefined);
104
+ }
105
+ for (let addressee of addresseesOf(activity.data)) {
106
+ if (addressee === PUBLIC || addressee === options.actor.id)
107
+ continue;
108
+ if (addressee === options.actor.followers) {
109
+ let cursor = null;
110
+ do {
111
+ let page = await options.followers.inboxes(input.actor, { cursor, limit: pageSize });
112
+ if (isFailure(page)) {
113
+ return failure(new ActivityPubError("store", `Could not list followers: ${page.error.message}`, {
114
+ retryable: true,
115
+ cause: page.error,
116
+ }));
117
+ }
118
+ for (let inbox of page.data.items) {
119
+ let added = await add(inbox);
120
+ if (isFailure(added))
121
+ return added;
122
+ }
123
+ cursor = page.data.next;
124
+ } while (cursor !== null);
125
+ continue;
126
+ }
127
+ let url = URL.parse(addressee);
128
+ if (url === null || (await isBlocked(url.hostname))) {
129
+ skipped++;
130
+ continue;
131
+ }
132
+ let actor = await options.resolver.actor(addressee);
133
+ if (isFailure(actor)) {
134
+ skipped++;
135
+ continue;
136
+ }
137
+ let added = await add(actor.data.endpoints.sharedInbox ?? actor.data.inbox);
138
+ if (isFailure(added))
139
+ return added;
140
+ }
141
+ let flushed = await flush();
142
+ if (isFailure(flushed))
143
+ return flushed;
144
+ return success({ inboxes: enqueued, skipped });
145
+ }
146
+ /** Every IRI the activity and its embedded object address, each once, in the order written. */
147
+ function addresseesOf(activity) {
148
+ let addressees = new Set();
149
+ let sources = [activity];
150
+ if (typeof activity.object === "object" && activity.object !== null) {
151
+ sources.push(activity.object);
152
+ }
153
+ for (let source of sources) {
154
+ for (let iri of [...source.to, ...source.cc, ...source.bto, ...source.bcc])
155
+ addressees.add(iri);
156
+ }
157
+ return addressees;
158
+ }
159
+ /** The JSON object `text` holds, or `null` for anything else. */
160
+ function decode(text) {
161
+ let json;
162
+ try {
163
+ json = JSON.parse(text);
164
+ }
165
+ catch {
166
+ return null;
167
+ }
168
+ if (typeof json !== "object" || json === null || Array.isArray(json))
169
+ return null;
170
+ return json;
171
+ }
172
+ /**
173
+ * The activity re-serialized without `bto` and `bcc` on it or its embedded object, or
174
+ * `null` when it carries neither, so the text the app wrote is delivered byte for byte.
175
+ */
176
+ function withoutBlindMembers(json) {
177
+ let object = json.object;
178
+ let embedded = typeof object === "object" && object !== null && !Array.isArray(object)
179
+ ? object
180
+ : null;
181
+ let hasBlind = (record) => BLIND_MEMBERS.some((member) => member in record);
182
+ if (!hasBlind(json) && (embedded === null || !hasBlind(embedded)))
183
+ return null;
184
+ let stripped = { ...json };
185
+ for (let member of BLIND_MEMBERS)
186
+ delete stripped[member];
187
+ if (embedded !== null) {
188
+ let strippedObject = { ...embedded };
189
+ for (let member of BLIND_MEMBERS)
190
+ delete strippedObject[member];
191
+ stripped.object = strippedObject;
192
+ }
193
+ return JSON.stringify(stripped);
194
+ }
195
+ /** A retryable `enqueue` failure wrapping what the job writer raised. */
196
+ function enqueueFailed(cause) {
197
+ let reason = cause instanceof Error ? cause.message : String(cause);
198
+ return new ActivityPubError("enqueue", `Could not enqueue deliveries: ${reason}`, {
199
+ retryable: true,
200
+ cause,
201
+ });
202
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Signs and POSTs one activity to one inbox, knocking first with the scheme the origin
3
+ * accepted last time and falling back to the other one, and maps the answer to a
4
+ * `DeliveryError` whose `retryable` and `retryAfter` drive the job's retry.
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 { Scheme } from "@sdxc/http-signatures";
12
+ import type { Result } from "@sdxc/result";
13
+ import type { KeyProvider } from "../store.js";
14
+ import { ActivityPubError } from "../errors.js";
15
+ /**
16
+ * Waits after each failed delivery, about 21 hours over five retries, so a server down
17
+ * for a day still receives the activity. A delivery whose inbox names `Retry-After` waits
18
+ * at least that long.
19
+ *
20
+ * @example let delay = DELIVERY_BACKOFF.delay(attempts);
21
+ */
22
+ export declare const DELIVERY_BACKOFF: import("@sdxc/backoff").Backoff;
23
+ /**
24
+ * Why a delivery did not land. `gone` means the inbox answered `410`, so the caller drops
25
+ * the followers reached through it; `rate-limited`, `server`, `timeout`, `network` and
26
+ * `keys-unavailable` are the outcomes a later attempt can change.
27
+ */
28
+ export type DeliveryErrorCode = "gone" | "unauthorized" | "rate-limited" | "rejected" | "server" | "timeout" | "network" | "refused-url" | "missing-keys" | "keys-unavailable" | "unsignable";
29
+ /**
30
+ * A delivery that failed. A job retries it when `retryable`, waiting at least `retryAfter`
31
+ * milliseconds when the inbox named a time, and acknowledges it otherwise.
32
+ */
33
+ export declare class DeliveryError extends ActivityPubError<DeliveryErrorCode> {
34
+ name: string;
35
+ readonly inbox: string;
36
+ /** The inbox's last answer, or `null` when none arrived. */
37
+ readonly status: number | null;
38
+ /** Milliseconds the inbox asked to wait through `Retry-After`, or `null` when it named none. */
39
+ readonly retryAfter: number | null;
40
+ /**
41
+ * `retryable` follows from the code: rate limits, server errors, deadlines, dropped
42
+ * connections and an unavailable key store.
43
+ *
44
+ * @param code - Why the delivery failed.
45
+ * @param inbox - The inbox it was addressed to.
46
+ * @param message - The explanation a log shows.
47
+ * @param options - The status, the requested wait, and the underlying error.
48
+ */
49
+ constructor(code: DeliveryErrorCode, inbox: string, message: string, options?: DeliveryError.Options);
50
+ }
51
+ export declare namespace DeliveryError {
52
+ /** What a delivery failure carries beyond its code. */
53
+ interface Options {
54
+ status?: number | null;
55
+ retryAfter?: number | null;
56
+ cause?: unknown;
57
+ }
58
+ }
59
+ /** One delivery: a serialized activity, the inbox it goes to, and the local actor who signs it. */
60
+ export interface DeliveryInput {
61
+ /** The local actor whose keys sign the POST. */
62
+ actor: string;
63
+ /** The activity as JSON text, POSTed byte for byte. */
64
+ activity: string;
65
+ inbox: string;
66
+ }
67
+ /** What `deliver` needs. */
68
+ export interface DeliverOptions {
69
+ /** The signing keys of `input.actor`. */
70
+ keys: KeyProvider;
71
+ /** Keeps each origin's accepted scheme and its failure window; a failing cache costs a knock. */
72
+ cache: Cache;
73
+ /** Sent on every POST; some instances refuse requests without one. */
74
+ userAgent: string;
75
+ /** The deadline of each POST, the second knock getting its own. @default "15 seconds" */
76
+ timeout?: DurationInput;
77
+ /** The signing time and the clock `Retry-After` dates are read against. @default () => new Date() */
78
+ now?: () => Date;
79
+ }
80
+ /** A delivery the inbox accepted. */
81
+ export interface Delivered {
82
+ /** The `2xx` the inbox answered. */
83
+ status: number;
84
+ /** The scheme the accepted POST was signed with, which the origin is now remembered for. */
85
+ scheme: Scheme;
86
+ }
87
+ /**
88
+ * Signs and POSTs `input.activity` to `input.inbox`. Each POST is signed when it is sent,
89
+ * so `Date` is fresh on a retry, and is sent once with `redirect: "manual"`. A `400`, `401`
90
+ * or `403` is retried at once with the other scheme, and a `401` with `Accept-Signature` is
91
+ * honored once with the components it asks for; the scheme that lands is remembered per
92
+ * origin for 30 days. A retryable failure starts or extends the origin's failure window.
93
+ *
94
+ * @param input - The activity, the inbox and the signing actor.
95
+ * @param options - The keys, the cache, the `User-Agent`, the deadline and the clock.
96
+ * @returns The status and scheme, or a `DeliveryError` saying whether to retry.
97
+ * @example let sent = await deliver(ctx.input, { keys, cache, userAgent: USER_AGENT });
98
+ */
99
+ export declare function deliver(input: DeliveryInput, options: DeliverOptions): Promise<Result<Delivered, DeliveryError>>;
100
+ /**
101
+ * Reads `Retry-After` as milliseconds from `now`: delay-seconds or an HTTP date, clamped to
102
+ * `0` through `MAX_RETRY_AFTER_MS`, so an inbox can postpone a retry no longer than the
103
+ * longest backoff step. Anything else reads as `null`.
104
+ *
105
+ * @param value - The header value.
106
+ * @param now - The time the answer arrived.
107
+ */
108
+ export declare function parseRetryAfter(value: string | null, now: Date): number | null;
@@ -0,0 +1,318 @@
1
+ import { createBackoff } from "@sdxc/backoff";
2
+ import { toMs } from "@sdxc/duration";
3
+ import { parseAcceptSignature, sign } from "@sdxc/http-signatures";
4
+ import { checkUrl, release, resolveHost } from "@sdxc/outbound";
5
+ import { failure, isFailure, isSuccess, success } from "@sdxc/result";
6
+ import { ActivityPubError } from "../errors.js";
7
+ import { recordFailure, recordSuccess } from "./availability.js";
8
+ import { ACTIVITY_ACCEPT, ACTIVITY_JSON } from "./constants.js";
9
+ /**
10
+ * Waits after each failed delivery, about 21 hours over five retries, so a server down
11
+ * for a day still receives the activity. A delivery whose inbox names `Retry-After` waits
12
+ * at least that long.
13
+ *
14
+ * @example let delay = DELIVERY_BACKOFF.delay(attempts);
15
+ */
16
+ export const DELIVERY_BACKOFF = createBackoff({
17
+ steps: ["5 minutes", "30 minutes", "2 hours", "6 hours", "12 hours"],
18
+ jitter: 0.2,
19
+ });
20
+ /** Long enough for a busy instance to answer a POST, short enough to keep a consumer moving. */
21
+ const DEFAULT_TIMEOUT = "15 seconds";
22
+ /** How long an origin's accepted scheme is reused, so it pays for a second knock about monthly. */
23
+ const SCHEME_TTL = "30 days";
24
+ /** How long a signature honoring an `Accept-Signature` request with `expires` stays valid. */
25
+ const REQUESTED_EXPIRY_MS = toMs("5 minutes");
26
+ /**
27
+ * The longest wait a `Retry-After` can ask for, equal to the longest `DELIVERY_BACKOFF` step,
28
+ * so a hostile inbox cannot hold a delivery past the job's retry schedule.
29
+ */
30
+ const MAX_RETRY_AFTER_MS = toMs("12 hours");
31
+ /** The answers that mean "not with this signature", which a knock with another scheme can change. */
32
+ const REFUSED_STATUSES = new Set([400, 401, 403]);
33
+ /**
34
+ * What every delivery signature covers whatever an inbox asks for, so `Accept-Signature`
35
+ * can never obtain this actor's signature over less than the target, the method and the body.
36
+ */
37
+ const REQUIRED_COMPONENTS = ["@method", "@target-uri", "content-digest"];
38
+ /** The schemes in the order a first delivery to an origin tries them. */
39
+ const DEFAULT_ORDER = ["rfc9421", "draft-cavage"];
40
+ /** The codes a retry can clear; every other one repeats the same answer. */
41
+ const RETRYABLE_CODES = new Set([
42
+ "rate-limited",
43
+ "server",
44
+ "timeout",
45
+ "network",
46
+ "keys-unavailable",
47
+ ]);
48
+ /**
49
+ * A delivery that failed. A job retries it when `retryable`, waiting at least `retryAfter`
50
+ * milliseconds when the inbox named a time, and acknowledges it otherwise.
51
+ */
52
+ export class DeliveryError extends ActivityPubError {
53
+ name = "DeliveryError";
54
+ inbox;
55
+ /** The inbox's last answer, or `null` when none arrived. */
56
+ status;
57
+ /** Milliseconds the inbox asked to wait through `Retry-After`, or `null` when it named none. */
58
+ retryAfter;
59
+ /**
60
+ * `retryable` follows from the code: rate limits, server errors, deadlines, dropped
61
+ * connections and an unavailable key store.
62
+ *
63
+ * @param code - Why the delivery failed.
64
+ * @param inbox - The inbox it was addressed to.
65
+ * @param message - The explanation a log shows.
66
+ * @param options - The status, the requested wait, and the underlying error.
67
+ */
68
+ constructor(code, inbox, message, options = {}) {
69
+ super(code, message, { retryable: RETRYABLE_CODES.has(code), cause: options.cause });
70
+ this.inbox = inbox;
71
+ this.status = options.status ?? null;
72
+ this.retryAfter = options.retryAfter ?? null;
73
+ }
74
+ }
75
+ /**
76
+ * Signs and POSTs `input.activity` to `input.inbox`. Each POST is signed when it is sent,
77
+ * so `Date` is fresh on a retry, and is sent once with `redirect: "manual"`. A `400`, `401`
78
+ * or `403` is retried at once with the other scheme, and a `401` with `Accept-Signature` is
79
+ * honored once with the components it asks for; the scheme that lands is remembered per
80
+ * origin for 30 days. A retryable failure starts or extends the origin's failure window.
81
+ *
82
+ * @param input - The activity, the inbox and the signing actor.
83
+ * @param options - The keys, the cache, the `User-Agent`, the deadline and the clock.
84
+ * @returns The status and scheme, or a `DeliveryError` saying whether to retry.
85
+ * @example let sent = await deliver(ctx.input, { keys, cache, userAgent: USER_AGENT });
86
+ */
87
+ export async function deliver(input, options) {
88
+ let now = options.now ?? (() => new Date());
89
+ let timeout = toMs(options.timeout ?? DEFAULT_TIMEOUT);
90
+ let inbox = input.inbox;
91
+ let checked = checkUrl(inbox);
92
+ if (isFailure(checked)) {
93
+ return failure(new DeliveryError("refused-url", inbox, checked.error.message, { cause: checked.error }));
94
+ }
95
+ let url = checked.data;
96
+ let origin = url.origin;
97
+ let resolved = await resolveHost(url, { signal: AbortSignal.timeout(timeout) });
98
+ if (isFailure(resolved)) {
99
+ let code = resolved.error.code;
100
+ if (code !== "timeout" && code !== "network") {
101
+ return failure(new DeliveryError("refused-url", inbox, resolved.error.message, { cause: resolved.error }));
102
+ }
103
+ return failed(options.cache, origin, now, new DeliveryError(code, inbox, resolved.error.message, { cause: resolved.error }));
104
+ }
105
+ let keys = await options.keys.keysOf(input.actor);
106
+ if (isFailure(keys)) {
107
+ return failure(new DeliveryError("keys-unavailable", inbox, `Could not read the keys of ${input.actor}: ${keys.error.message}`, { cause: keys.error }));
108
+ }
109
+ if (keys.data === null) {
110
+ return failure(new DeliveryError("missing-keys", inbox, `${input.actor} has no signing keys here`));
111
+ }
112
+ let body = new TextEncoder().encode(input.activity);
113
+ let remembered = await rememberedScheme(options.cache, origin);
114
+ let knocks = orderFor(remembered).map((scheme) => ({ scheme, requested: null }));
115
+ let honored = false;
116
+ let last = null;
117
+ let signingError = null;
118
+ for (let index = 0; index < knocks.length; index++) {
119
+ let knock = knocks[index];
120
+ if (knock === undefined)
121
+ break;
122
+ let signed = await signFor(url, body, keys.data, knock, options.userAgent, now());
123
+ if (isFailure(signed)) {
124
+ signingError = signed.error;
125
+ continue;
126
+ }
127
+ let sent = await post(signed.data, body, timeout);
128
+ if (isFailure(sent))
129
+ return failed(options.cache, origin, now, sent.error);
130
+ if (last !== null)
131
+ release(last.response.body);
132
+ last = { response: sent.data, scheme: knock.scheme };
133
+ if (!REFUSED_STATUSES.has(sent.data.status))
134
+ break;
135
+ if (sent.data.status === 401 && !honored) {
136
+ let requested = acceptSignatureOf(sent.data);
137
+ if (requested !== null) {
138
+ honored = true;
139
+ knocks.splice(index + 1, 0, { scheme: "rfc9421", requested });
140
+ }
141
+ }
142
+ }
143
+ if (last === null) {
144
+ return failure(new DeliveryError("unsignable", inbox, `Could not sign for ${inbox}`, {
145
+ cause: signingError,
146
+ }));
147
+ }
148
+ let { response, scheme } = last;
149
+ release(response.body);
150
+ let status = response.status;
151
+ if (status >= 200 && status < 300) {
152
+ await recordSuccess(options.cache, origin).catch(() => undefined);
153
+ if (remembered !== scheme) {
154
+ await options.cache
155
+ .write(schemeKey(origin), scheme, { ttl: SCHEME_TTL })
156
+ .catch(() => undefined);
157
+ }
158
+ return success({ status, scheme });
159
+ }
160
+ let error = errorFor(inbox, response, now());
161
+ if (error.retryable)
162
+ return failed(options.cache, origin, now, error);
163
+ return failure(error);
164
+ }
165
+ /**
166
+ * Starts or extends the origin's failure window, then answers the failure. A cache error
167
+ * leaves the window as it was.
168
+ */
169
+ async function failed(cache, origin, now, error) {
170
+ await recordFailure(cache, origin, now().getTime()).catch(() => undefined);
171
+ return failure(error);
172
+ }
173
+ /** The cache entry of an origin's accepted scheme. */
174
+ function schemeKey(origin) {
175
+ return `activitypub:scheme:${origin}`;
176
+ }
177
+ /** The scheme `origin` last accepted, or `null` when none is remembered or the cache fails. */
178
+ async function rememberedScheme(cache, origin) {
179
+ let read = await cache.read(schemeKey(origin)).catch(() => null);
180
+ if (read === null || !isSuccess(read))
181
+ return null;
182
+ return read.data === "rfc9421" || read.data === "draft-cavage" ? read.data : null;
183
+ }
184
+ /** The remembered scheme first, then the other; RFC 9421 first for an origin never reached. */
185
+ function orderFor(remembered) {
186
+ if (remembered === null)
187
+ return [...DEFAULT_ORDER];
188
+ return [remembered, ...DEFAULT_ORDER.filter((scheme) => scheme !== remembered)];
189
+ }
190
+ /**
191
+ * Builds and signs one POST. A knock answering `Accept-Signature` covers what was asked,
192
+ * under the asked label, nonce and tag, and with an `expires` when one was asked.
193
+ */
194
+ async function signFor(url, body, keys, knock, userAgent, created) {
195
+ let request = new Request(url, {
196
+ method: "POST",
197
+ headers: {
198
+ accept: ACTIVITY_ACCEPT,
199
+ "content-type": ACTIVITY_JSON,
200
+ "user-agent": userAgent,
201
+ },
202
+ });
203
+ let signOptions = {
204
+ scheme: knock.scheme,
205
+ key: { id: keys.rsa.id, privateKey: keys.rsa.privateKey },
206
+ body,
207
+ created,
208
+ };
209
+ let requested = knock.requested;
210
+ if (requested !== null) {
211
+ signOptions.components = withRequired(requested.components);
212
+ signOptions.label = requested.label;
213
+ if (requested.params.nonce !== undefined)
214
+ signOptions.nonce = requested.params.nonce;
215
+ if (requested.params.tag !== undefined)
216
+ signOptions.tag = requested.params.tag;
217
+ if (requested.params.expires === true) {
218
+ signOptions.expires = new Date(created.getTime() + REQUESTED_EXPIRY_MS);
219
+ }
220
+ }
221
+ return sign(request, signOptions);
222
+ }
223
+ /**
224
+ * The components an inbox asked for, followed by each required one it left out. Only the
225
+ * whole field counts as covering `content-digest`, never one member of it.
226
+ *
227
+ * @param requested - The components `Accept-Signature` named.
228
+ */
229
+ function withRequired(requested) {
230
+ let missing = REQUIRED_COMPONENTS.filter((name) => !requested.some((component) => component.name === name && !component.params));
231
+ return [...requested, ...missing.map((name) => ({ name }))];
232
+ }
233
+ /**
234
+ * Sends one signed POST within its own deadline. Redirects come back as the answer, since
235
+ * a body is never replayed to a URL nobody checked.
236
+ */
237
+ async function post(request, body, timeout) {
238
+ try {
239
+ return success(await fetch(request.url, {
240
+ method: "POST",
241
+ headers: [...request.headers],
242
+ body,
243
+ redirect: "manual",
244
+ credentials: "omit",
245
+ signal: AbortSignal.timeout(timeout),
246
+ }));
247
+ }
248
+ catch (cause) {
249
+ let timedOut = typeof cause === "object" &&
250
+ cause !== null &&
251
+ "name" in cause &&
252
+ cause.name === "TimeoutError";
253
+ let reason = cause instanceof Error ? cause.message : String(cause);
254
+ return failure(timedOut
255
+ ? new DeliveryError("timeout", request.url, `${request.url} did not answer in time`, {
256
+ cause,
257
+ })
258
+ : new DeliveryError("network", request.url, `Could not reach ${request.url}: ${reason}`, {
259
+ cause,
260
+ }));
261
+ }
262
+ }
263
+ /**
264
+ * The first signature a `401` asks for through `Accept-Signature` (RFC 9421 §5), or `null`
265
+ * when it asks for none or the field cannot be read.
266
+ */
267
+ function acceptSignatureOf(response) {
268
+ let text = response.headers.get("accept-signature");
269
+ if (text === null)
270
+ return null;
271
+ let parsed = parseAcceptSignature(text);
272
+ if (isFailure(parsed))
273
+ return null;
274
+ let first = Object.entries(parsed.data)[0];
275
+ if (first === undefined)
276
+ return null;
277
+ return { label: first[0], ...first[1] };
278
+ }
279
+ /** The failure a non-`2xx` final answer maps to, per the delivery outcomes table. */
280
+ function errorFor(inbox, response, now) {
281
+ let status = response.status;
282
+ let options = { status };
283
+ if (status === 410)
284
+ return new DeliveryError("gone", inbox, `${inbox} is gone`, options);
285
+ if (status === 401 || status === 403) {
286
+ return new DeliveryError("unauthorized", inbox, `${inbox} refused every signature with ${status}`, options);
287
+ }
288
+ if (status === 429) {
289
+ return new DeliveryError("rate-limited", inbox, `${inbox} is rate limiting`, {
290
+ status,
291
+ retryAfter: parseRetryAfter(response.headers.get("retry-after"), now),
292
+ });
293
+ }
294
+ if (status >= 500) {
295
+ return new DeliveryError("server", inbox, `${inbox} answered ${status}`, {
296
+ status,
297
+ retryAfter: parseRetryAfter(response.headers.get("retry-after"), now),
298
+ });
299
+ }
300
+ return new DeliveryError("rejected", inbox, `${inbox} rejected the activity with ${status}`, options);
301
+ }
302
+ /**
303
+ * Reads `Retry-After` as milliseconds from `now`: delay-seconds or an HTTP date, clamped to
304
+ * `0` through `MAX_RETRY_AFTER_MS`, so an inbox can postpone a retry no longer than the
305
+ * longest backoff step. Anything else reads as `null`.
306
+ *
307
+ * @param value - The header value.
308
+ * @param now - The time the answer arrived.
309
+ */
310
+ export function parseRetryAfter(value, now) {
311
+ if (value === null)
312
+ return null;
313
+ let text = value.trim();
314
+ let delay = /^-?\d+$/u.test(text) ? Number(text) * 1000 : Date.parse(text) - now.getTime();
315
+ if (Number.isNaN(delay))
316
+ return null;
317
+ return Math.min(MAX_RETRY_AFTER_MS, Math.max(0, delay));
318
+ }