@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,117 @@
1
+ /**
2
+ * The synchronous half of an inbox: every check a POSTed activity must pass before it is
3
+ * acknowledged, in the order that refuses the cheapest failures first and never fetches
4
+ * for a blocked server, answering a job-ready `Received` or the status to refuse with.
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 { Resolver } from "../remote.js";
13
+ import { InboxError } from "./inbox-error.js";
14
+ /** Leaves room for the rest of `Received` inside a 128 KB queue message. */
15
+ export declare const DEFAULT_INBOX_MAX_BYTES: number;
16
+ /** Mastodon signs at delivery time, so an hour covers its retries' clock and queueing. */
17
+ export declare const DEFAULT_INBOX_MAX_AGE: DurationInput;
18
+ /** The ways an activity can be trusted as its actor's. */
19
+ export declare const VERIFICATIONS: readonly ["signature", "proof", "refetched"];
20
+ /**
21
+ * How an activity was established as its actor's: the actor's own HTTP signature, an
22
+ * FEP-8b32 proof by the actor on a forwarded activity (not yet produced by `receive`), or
23
+ * a copy fetched from the actor's origin after someone else forwarded it.
24
+ */
25
+ export type Verification = (typeof VERIFICATIONS)[number];
26
+ /**
27
+ * Says whether requests from a host are refused. The same callback serves `receive`,
28
+ * `handle` and fan-out, so a blocked server can neither deliver, follow nor receive.
29
+ */
30
+ export interface BlockedCheck {
31
+ (host: string): boolean | Promise<boolean>;
32
+ }
33
+ /** What `receive` needs. */
34
+ export interface ReceiveOptions {
35
+ /** Resolves the signature's key; its cache is what keeps a known server's request fast. */
36
+ resolver: Resolver;
37
+ blocked: BlockedCheck;
38
+ /** Bodies past this answer `413`. @default 102400 */
39
+ maxBytes?: number;
40
+ /** How old a signature may be, plus five minutes of clock skew. @default "1 hour" */
41
+ maxAge?: DurationInput;
42
+ /** The cache delivery tracks failing origins in; a verified activity clears its origin. */
43
+ cache?: Cache;
44
+ /** @default new Date() */
45
+ now?: Date;
46
+ }
47
+ /**
48
+ * A verified activity, ready to enqueue: plain JSON that survives a queue round trip, and
49
+ * what `handle` reads back from the queued inbox message.
50
+ */
51
+ export interface Received {
52
+ /** The activity as received, or as refetched from its actor's origin, undecoded. */
53
+ activity: Record<string, unknown>;
54
+ /** The activity's actor, which verification established. */
55
+ actor: string;
56
+ /** The actor whose key signed the request: `actor`, or the server that forwarded it. */
57
+ signer: string;
58
+ keyId: string;
59
+ /** The origin of `actor`. */
60
+ origin: string;
61
+ verification: Verification;
62
+ /** ISO 8601. */
63
+ receivedAt: string;
64
+ }
65
+ /**
66
+ * Verifies a POST to an inbox. The steps run in order and stop at the first failure: the
67
+ * media type, the size, the activity's shape, the blocked hosts of the key and the actor,
68
+ * the signature, its digest and age, the signer's blocked host, then the actor's ownership.
69
+ *
70
+ * A forwarded activity, signed by someone other than its actor, is accepted when its id is
71
+ * on the actor's origin and a fresh copy fetched from there names the same actor; that copy
72
+ * is what `Received` carries. A `Delete` of an account whose key now answers `410` fails
73
+ * `ignored`, which `toResponse` answers with `202` so the sender stops retrying.
74
+ *
75
+ * @param request - The inbox request, its body unread.
76
+ * @param options - The resolver, the blocked check, the limits and the cache.
77
+ * @returns The verified activity, or an `InboxError` whose `status` is the response's.
78
+ * @example
79
+ * let received = await receive(ctx.request, { resolver, blocked });
80
+ * if (isFailure(received)) return received.error.toResponse();
81
+ */
82
+ export declare function receive(request: Request, options: ReceiveOptions): Promise<Result<Received, InboxError>>;
83
+ /** What `verifyFetch` needs. */
84
+ export interface VerifyFetchOptions {
85
+ resolver: Resolver;
86
+ blocked: BlockedCheck;
87
+ /** @default "1 hour" */
88
+ maxAge?: DurationInput;
89
+ /**
90
+ * The ids of local actors, whose documents (and the keys inside them) answer unsigned,
91
+ * because a remote server fetches that key to verify its own signed fetch.
92
+ */
93
+ actors?: string[];
94
+ /** Other URLs that answer unsigned, such as WebFinger and NodeInfo. */
95
+ exempt?: (url: URL) => boolean;
96
+ /** @default new Date() */
97
+ now?: Date;
98
+ }
99
+ /** Who signed a GET, or nobody when the URL is exempt. */
100
+ export interface VerifiedFetch {
101
+ /** The actor whose key signed the request; `null` for an exempt URL. */
102
+ signer: string | null;
103
+ keyId: string | null;
104
+ }
105
+ /**
106
+ * Verifies the signature on a GET, for an app that serves secure-mode (authorized) fetches:
107
+ * the blocked host of the key, the signature's age, and the key, refetched once when it no
108
+ * longer verifies. Local actor documents and `exempt` URLs pass unsigned.
109
+ *
110
+ * @param request - The GET.
111
+ * @param options - The resolver, the blocked check, the exemptions and the window.
112
+ * @returns The signer, or an `InboxError` whose `status` is the response's.
113
+ * @example
114
+ * let fetched = await verifyFetch(ctx.request, { resolver, blocked, actors: [ACTOR_ID] });
115
+ * if (isFailure(fetched)) return fetched.error.toResponse();
116
+ */
117
+ export declare function verifyFetch(request: Request, options: VerifyFetchOptions): Promise<Result<VerifiedFetch, InboxError>>;
@@ -0,0 +1,233 @@
1
+ import { readBytes } from "@sdxc/outbound";
2
+ import { failure, isFailure, success } from "@sdxc/result";
3
+ import { ActivityPubFetchError } from "../errors.js";
4
+ import { recordSuccess } from "./availability.js";
5
+ import { isObject } from "./compact.js";
6
+ import { ACTIVITY_JSON, LD_JSON } from "./constants.js";
7
+ import { InboxError } from "./inbox-error.js";
8
+ import { readSignatureHeader, requireSignedDigest, verifySigned } from "./inbox-signature.js";
9
+ import { parseActivity } from "./parse.js";
10
+ /** Leaves room for the rest of `Received` inside a 128 KB queue message. */
11
+ export const DEFAULT_INBOX_MAX_BYTES = 100 * 1024;
12
+ /** Mastodon signs at delivery time, so an hour covers its retries' clock and queueing. */
13
+ export const DEFAULT_INBOX_MAX_AGE = "1 hour";
14
+ /** The ways an activity can be trusted as its actor's. */
15
+ export const VERIFICATIONS = ["signature", "proof", "refetched"];
16
+ /** The media types ActivityPub §7 lets an inbox accept; parameters are ignored. */
17
+ const ACCEPTED_MEDIA_TYPES = new Set([ACTIVITY_JSON, LD_JSON]);
18
+ /**
19
+ * Verifies a POST to an inbox. The steps run in order and stop at the first failure: the
20
+ * media type, the size, the activity's shape, the blocked hosts of the key and the actor,
21
+ * the signature, its digest and age, the signer's blocked host, then the actor's ownership.
22
+ *
23
+ * A forwarded activity, signed by someone other than its actor, is accepted when its id is
24
+ * on the actor's origin and a fresh copy fetched from there names the same actor; that copy
25
+ * is what `Received` carries. A `Delete` of an account whose key now answers `410` fails
26
+ * `ignored`, which `toResponse` answers with `202` so the sender stops retrying.
27
+ *
28
+ * @param request - The inbox request, its body unread.
29
+ * @param options - The resolver, the blocked check, the limits and the cache.
30
+ * @returns The verified activity, or an `InboxError` whose `status` is the response's.
31
+ * @example
32
+ * let received = await receive(ctx.request, { resolver, blocked });
33
+ * if (isFailure(received)) return received.error.toResponse();
34
+ */
35
+ export async function receive(request, options) {
36
+ let now = options.now ?? new Date();
37
+ if (!ACCEPTED_MEDIA_TYPES.has(essence(request.headers.get("content-type")))) {
38
+ return failure(new InboxError("unsupported-media-type", `Content-Type ${request.headers.get("content-type") ?? "(none)"} is not ActivityStreams`));
39
+ }
40
+ let read = await readBytes(request, { maxBytes: options.maxBytes ?? DEFAULT_INBOX_MAX_BYTES });
41
+ if (isFailure(read)) {
42
+ let code = read.error.code === "too-large" ? "too-large" : "invalid-activity";
43
+ return failure(new InboxError(code, read.error.message, { cause: read.error }));
44
+ }
45
+ let body = read.data.data;
46
+ let json = decodeJson(body);
47
+ if (isFailure(json))
48
+ return json;
49
+ let activity = parseActivity(json.data);
50
+ if (isFailure(activity)) {
51
+ return failure(new InboxError("invalid-activity", activity.error.message, { cause: activity.error }));
52
+ }
53
+ let signature = readSignatureHeader(request.headers);
54
+ let hosts = [hostOf(activity.data.actor)];
55
+ if (!isFailure(signature) && signature.data !== null)
56
+ hosts.push(hostOf(signature.data.keyId));
57
+ for (let host of hosts) {
58
+ if (host !== null && (await options.blocked(host))) {
59
+ return failure(new InboxError("blocked", `${host} is blocked`));
60
+ }
61
+ }
62
+ if (isFailure(signature))
63
+ return signature;
64
+ if (signature.data === null) {
65
+ return failure(new InboxError("unsigned", "The request carries no signature"));
66
+ }
67
+ let digest = requireSignedDigest(request.headers, signature.data);
68
+ if (isFailure(digest))
69
+ return digest;
70
+ let signed = await verifySigned(request, {
71
+ resolver: options.resolver,
72
+ body,
73
+ maxAge: options.maxAge ?? DEFAULT_INBOX_MAX_AGE,
74
+ now,
75
+ });
76
+ if (isFailure(signed)) {
77
+ if (isDeletedAccount(activity.data, signed.error)) {
78
+ return failure(new InboxError("ignored", `${activity.data.actor} was deleted with its key`, {
79
+ cause: signed.error,
80
+ }));
81
+ }
82
+ return signed;
83
+ }
84
+ let { key, verified } = signed.data;
85
+ let signer = key.owner;
86
+ let signerHost = hostOf(signer);
87
+ if (signerHost !== null && (await options.blocked(signerHost))) {
88
+ return failure(new InboxError("blocked", `${signerHost} is blocked`));
89
+ }
90
+ let actor = activity.data.actor;
91
+ let document = json.data;
92
+ let verification = "signature";
93
+ if (signer !== actor) {
94
+ let refetched = await refetch(activity.data, options.resolver);
95
+ if (isFailure(refetched))
96
+ return refetched;
97
+ document = refetched.data;
98
+ verification = "refetched";
99
+ }
100
+ if (options.cache !== undefined) {
101
+ let origin = originOf(signer);
102
+ if (origin !== null)
103
+ await recordSuccess(options.cache, origin);
104
+ }
105
+ return success({
106
+ activity: document,
107
+ actor,
108
+ signer,
109
+ keyId: verified.keyId,
110
+ origin: originOf(actor) ?? actor,
111
+ verification,
112
+ receivedAt: now.toISOString(),
113
+ });
114
+ }
115
+ /**
116
+ * Verifies the signature on a GET, for an app that serves secure-mode (authorized) fetches:
117
+ * the blocked host of the key, the signature's age, and the key, refetched once when it no
118
+ * longer verifies. Local actor documents and `exempt` URLs pass unsigned.
119
+ *
120
+ * @param request - The GET.
121
+ * @param options - The resolver, the blocked check, the exemptions and the window.
122
+ * @returns The signer, or an `InboxError` whose `status` is the response's.
123
+ * @example
124
+ * let fetched = await verifyFetch(ctx.request, { resolver, blocked, actors: [ACTOR_ID] });
125
+ * if (isFailure(fetched)) return fetched.error.toResponse();
126
+ */
127
+ export async function verifyFetch(request, options) {
128
+ let url = new URL(request.url);
129
+ let page = `${url.origin}${url.pathname}`;
130
+ let isActor = (options.actors ?? []).some((actor) => withoutFragment(actor) === page);
131
+ if (isActor || options.exempt?.(url) === true)
132
+ return success({ signer: null, keyId: null });
133
+ let signature = readSignatureHeader(request.headers);
134
+ if (!isFailure(signature) && signature.data !== null) {
135
+ let host = hostOf(signature.data.keyId);
136
+ if (host !== null && (await options.blocked(host))) {
137
+ return failure(new InboxError("blocked", `${host} is blocked`));
138
+ }
139
+ }
140
+ if (isFailure(signature))
141
+ return signature;
142
+ if (signature.data === null) {
143
+ return failure(new InboxError("unsigned", "The request carries no signature"));
144
+ }
145
+ let signed = await verifySigned(request, {
146
+ resolver: options.resolver,
147
+ maxAge: options.maxAge ?? DEFAULT_INBOX_MAX_AGE,
148
+ ...(options.now === undefined ? {} : { now: options.now }),
149
+ });
150
+ if (isFailure(signed))
151
+ return signed;
152
+ return success({ signer: signed.data.key.owner, keyId: signed.data.verified.keyId });
153
+ }
154
+ /**
155
+ * Fetches a forwarded activity from its actor's origin, which is the only server that can
156
+ * vouch for it, and fails `actor-mismatch` unless that copy is the same activity by the
157
+ * same actor.
158
+ *
159
+ * @param activity - The activity as the forwarder sent it.
160
+ * @param resolver - Fetches the copy, bypassing the cache.
161
+ */
162
+ async function refetch(activity, resolver) {
163
+ let origin = originOf(activity.actor);
164
+ if (origin === null || originOf(activity.id) !== origin) {
165
+ return failure(new InboxError("actor-mismatch", `${activity.id} is signed by someone other than ${activity.actor} and is not on its origin`));
166
+ }
167
+ let fetched = await resolver.document(activity.id, { fresh: true });
168
+ if (isFailure(fetched)) {
169
+ return failure(new InboxError("actor-mismatch", `Could not refetch ${activity.id}: ${fetched.error.message}`, {
170
+ cause: fetched.error,
171
+ }));
172
+ }
173
+ let copy = parseActivity(fetched.data);
174
+ if (isFailure(copy) || copy.data.id !== activity.id || copy.data.actor !== activity.actor) {
175
+ return failure(new InboxError("actor-mismatch", `${activity.id} as its origin serves it is another activity`));
176
+ }
177
+ return success(fetched.data);
178
+ }
179
+ /**
180
+ * Whether a failed verification is a deleted account's `Delete` of itself: its key lookup
181
+ * answered `gone`, so the activity can never verify.
182
+ *
183
+ * @param activity - The activity.
184
+ * @param error - The verification failure.
185
+ */
186
+ function isDeletedAccount(activity, error) {
187
+ if (activity.type !== "Delete" || error.code !== "key-unavailable")
188
+ return false;
189
+ if (!(error.cause instanceof ActivityPubFetchError) || error.cause.code !== "gone")
190
+ return false;
191
+ let object = activity.object;
192
+ let objectId = typeof object === "string" ? object : object?.id;
193
+ return objectId === activity.actor;
194
+ }
195
+ /**
196
+ * Decodes the body as one JSON object.
197
+ *
198
+ * @param body - The bytes received.
199
+ */
200
+ function decodeJson(body) {
201
+ let json;
202
+ try {
203
+ json = JSON.parse(new TextDecoder().decode(body));
204
+ }
205
+ catch (cause) {
206
+ return failure(new InboxError("invalid-activity", "The body is not JSON", { cause }));
207
+ }
208
+ if (!isObject(json)) {
209
+ return failure(new InboxError("invalid-activity", "The body is not a JSON object"));
210
+ }
211
+ return success(json);
212
+ }
213
+ /**
214
+ * The media type of a `Content-Type`, lowercase and without parameters.
215
+ *
216
+ * @param header - The header value.
217
+ */
218
+ function essence(header) {
219
+ return (header ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
220
+ }
221
+ /** The host of an IRI, or `null` for a string that is not a URL. */
222
+ function hostOf(iri) {
223
+ return URL.parse(iri)?.hostname ?? null;
224
+ }
225
+ /** The origin of an IRI, or `null` for a string that is not a URL. */
226
+ function originOf(iri) {
227
+ return URL.parse(iri)?.origin ?? null;
228
+ }
229
+ /** An IRI without its fragment, which is the document a request for it reaches. */
230
+ function withoutFragment(iri) {
231
+ let hash = iri.indexOf("#");
232
+ return hash === -1 ? iri : iri.slice(0, hash);
233
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The signature half of inbox verification, shared by POSTed activities and signed GETs:
3
+ * the `keyId` read before any fetch so a blocked server costs nothing, the key resolved
4
+ * through the resolver and refetched once when it no longer verifies, as after a rotation.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { DurationInput } from "@sdxc/duration";
10
+ import type { Verified } from "@sdxc/http-signatures";
11
+ import type { Result } from "@sdxc/result";
12
+ import type { ResolvedKey, Resolver } from "../remote.js";
13
+ import { InboxError } from "./inbox-error.js";
14
+ /** What a signature names before it is verified. */
15
+ export interface SignatureHeader {
16
+ keyId: string;
17
+ /** Covered components or cavage header names, lowercase. */
18
+ covered: string[];
19
+ }
20
+ /**
21
+ * Reads the key id and coverage of the request's signature: the first RFC 9421 label that
22
+ * has both a `Signature-Input` member and a `Signature`, else the cavage `Signature` or
23
+ * `Authorization: Signature`. `null` means the request is unsigned.
24
+ *
25
+ * @param headers - The request headers.
26
+ * @returns The signature's key id and coverage, or `invalid-signature` for a field that
27
+ * breaks its grammar.
28
+ */
29
+ export declare function readSignatureHeader(headers: Headers): Result<SignatureHeader | null, InboxError>;
30
+ /**
31
+ * Fails `digest-mismatch` unless the signature covers a digest header the request carries,
32
+ * which is what binds a POSTed body to the signature.
33
+ *
34
+ * @param headers - The request headers.
35
+ * @param signature - What the signature covers.
36
+ */
37
+ export declare function requireSignedDigest(headers: Headers, signature: SignatureHeader): Result<void, InboxError>;
38
+ /** How a signature is checked. */
39
+ export interface VerifySignedOptions {
40
+ resolver: Resolver;
41
+ /** The bytes received, already read off the request. */
42
+ body?: Uint8Array;
43
+ maxAge: DurationInput;
44
+ now?: Date;
45
+ }
46
+ /** A verified signature and the key that verified it. */
47
+ export interface SignedBy {
48
+ verified: Verified;
49
+ key: ResolvedKey;
50
+ }
51
+ /**
52
+ * Verifies the request's signature with the key its `keyId` resolves to. A signature that
53
+ * does not verify with the cached key is tried once more with the key fetched fresh, so a
54
+ * sender that rotated its key verifies on the first request signed with the new one.
55
+ *
56
+ * @param request - The request, whose body may already be consumed.
57
+ * @param options - The resolver, the body bytes and the time window.
58
+ * @returns The verified signature and key. A failed lookup answers `key-unavailable` whose
59
+ * `cause` is the resolver's `ActivityPubFetchError`.
60
+ */
61
+ export declare function verifySigned(request: Request, options: VerifySignedOptions): Promise<Result<SignedBy, InboxError>>;
@@ -0,0 +1,156 @@
1
+ import { HttpSignatureError, parseCavageSignature, parseSignature, parseSignatureInput, verify, } from "@sdxc/http-signatures";
2
+ import { failure, isFailure, success } from "@sdxc/result";
3
+ import { InboxError } from "./inbox-error.js";
4
+ /** The digest headers a signature can cover to bind a body, and their spelling on the wire. */
5
+ const DIGEST_HEADERS = ["content-digest", "repr-digest", "digest"];
6
+ /**
7
+ * Reads the key id and coverage of the request's signature: the first RFC 9421 label that
8
+ * has both a `Signature-Input` member and a `Signature`, else the cavage `Signature` or
9
+ * `Authorization: Signature`. `null` means the request is unsigned.
10
+ *
11
+ * @param headers - The request headers.
12
+ * @returns The signature's key id and coverage, or `invalid-signature` for a field that
13
+ * breaks its grammar.
14
+ */
15
+ export function readSignatureHeader(headers) {
16
+ let input = headers.get("signature-input");
17
+ let signature = headers.get("signature");
18
+ if (input !== null) {
19
+ if (signature === null)
20
+ return success(null);
21
+ let inputs = parseSignatureInput(input);
22
+ if (isFailure(inputs))
23
+ return failure(malformed(inputs.error));
24
+ let signatures = parseSignature(signature);
25
+ if (isFailure(signatures))
26
+ return failure(malformed(signatures.error));
27
+ let label = Object.keys(inputs.data).find((key) => key in signatures.data);
28
+ let member = label === undefined ? undefined : inputs.data[label];
29
+ if (member === undefined)
30
+ return success(null);
31
+ let keyId = member.params.keyid;
32
+ if (keyId === undefined) {
33
+ return failure(new InboxError("invalid-signature", "The signature names no keyid"));
34
+ }
35
+ return success({ keyId, covered: member.components.map((component) => component.name) });
36
+ }
37
+ let cavage = signature ?? authorizationSignature(headers);
38
+ if (cavage === null)
39
+ return success(null);
40
+ let parsed = parseCavageSignature(cavage);
41
+ if (isFailure(parsed))
42
+ return failure(malformed(parsed.error));
43
+ let covered = parsed.data.headers ?? [parsed.data.created === null ? "date" : "(created)"];
44
+ return success({ keyId: parsed.data.keyId, covered });
45
+ }
46
+ /**
47
+ * Fails `digest-mismatch` unless the signature covers a digest header the request carries,
48
+ * which is what binds a POSTed body to the signature.
49
+ *
50
+ * @param headers - The request headers.
51
+ * @param signature - What the signature covers.
52
+ */
53
+ export function requireSignedDigest(headers, signature) {
54
+ let field = DIGEST_HEADERS.find((name) => signature.covered.includes(name));
55
+ if (field === undefined) {
56
+ return failure(new InboxError("digest-mismatch", "The signature covers no body digest"));
57
+ }
58
+ if (!headers.has(field)) {
59
+ return failure(new InboxError("digest-mismatch", `The signed ${field} header is missing`));
60
+ }
61
+ return success(undefined);
62
+ }
63
+ /**
64
+ * Verifies the request's signature with the key its `keyId` resolves to. A signature that
65
+ * does not verify with the cached key is tried once more with the key fetched fresh, so a
66
+ * sender that rotated its key verifies on the first request signed with the new one.
67
+ *
68
+ * @param request - The request, whose body may already be consumed.
69
+ * @param options - The resolver, the body bytes and the time window.
70
+ * @returns The verified signature and key. A failed lookup answers `key-unavailable` whose
71
+ * `cause` is the resolver's `ActivityPubFetchError`.
72
+ */
73
+ export async function verifySigned(request, options) {
74
+ let attempt = await verifyOnce(request, options, false);
75
+ if (isFailure(attempt) && isSignatureMismatch(attempt.error)) {
76
+ attempt = await verifyOnce(request, options, true);
77
+ }
78
+ return attempt;
79
+ }
80
+ /**
81
+ * Whether the signature itself failed against the key, the one failure a fresh key can fix.
82
+ *
83
+ * @param error - The failed attempt.
84
+ */
85
+ function isSignatureMismatch(error) {
86
+ return error.cause instanceof HttpSignatureError && error.cause.code === "invalid-signature";
87
+ }
88
+ /**
89
+ * One verification against the key as the resolver answers it, fresh or cached.
90
+ *
91
+ * @param request - The request.
92
+ * @param options - The resolver, the body bytes and the time window.
93
+ * @param fresh - Whether the key bypasses the resolver's cache.
94
+ */
95
+ async function verifyOnce(request, options, fresh) {
96
+ let lookup = { key: null, error: null };
97
+ let verified = await verify(request, {
98
+ ...(options.body === undefined ? {} : { body: options.body }),
99
+ maxAge: options.maxAge,
100
+ ...(options.now === undefined ? {} : { now: options.now }),
101
+ async key(keyId) {
102
+ let key = await options.resolver.key(keyId, { fresh });
103
+ if (isFailure(key)) {
104
+ lookup.error = key.error;
105
+ return key;
106
+ }
107
+ lookup.key = key.data;
108
+ return success(key.data.publicKey);
109
+ },
110
+ });
111
+ if (isFailure(verified)) {
112
+ let code = verified.error.code;
113
+ let cause = code === "key-unavailable" ? (lookup.error ?? verified.error) : verified.error;
114
+ return failure(new InboxError(inboxCode(code), verified.error.message, { cause }));
115
+ }
116
+ if (lookup.key === null) {
117
+ return failure(new InboxError("key-unavailable", "The signature verified without a key"));
118
+ }
119
+ return success({ verified: verified.data, key: lookup.key });
120
+ }
121
+ /**
122
+ * The inbox code a signature failure answers with. Every failure past the digest, the time
123
+ * and the key reads as `invalid-signature`, since a sender acts on each the same way.
124
+ *
125
+ * @param code - The signature package's code.
126
+ */
127
+ function inboxCode(code) {
128
+ if (code === "unsigned")
129
+ return "unsigned";
130
+ if (code === "digest-mismatch")
131
+ return "digest-mismatch";
132
+ if (code === "stale-signature")
133
+ return "stale-signature";
134
+ if (code === "key-unavailable")
135
+ return "key-unavailable";
136
+ return "invalid-signature";
137
+ }
138
+ /**
139
+ * The cavage parameters of `Authorization: Signature …`, the other place draft-cavage lets
140
+ * a signature travel.
141
+ *
142
+ * @param headers - The request headers.
143
+ */
144
+ function authorizationSignature(headers) {
145
+ let authorization = headers.get("authorization");
146
+ let match = authorization === null ? null : /^Signature\s+(.+)$/is.exec(authorization);
147
+ return match?.[1] ?? null;
148
+ }
149
+ /**
150
+ * A signature field that breaks its grammar.
151
+ *
152
+ * @param cause - The parser's failure.
153
+ */
154
+ function malformed(cause) {
155
+ return new InboxError("invalid-signature", cause.message, { cause });
156
+ }
@@ -0,0 +1,41 @@
1
+ import type { Inbound } from "./inbox-handle.js";
2
+ /** A response to local content, in the shape a Webmention is stored in. */
3
+ export interface Summary {
4
+ /** A quote reads as a `mention`, the kind Webmention gives a post that links without replying. */
5
+ kind: "reply" | "like" | "repost" | "mention";
6
+ /** The remote post for a reply or mention, the activity for a like or repost. */
7
+ id: string;
8
+ /** The page a reader opens: the post's HTML `url`, else its id. */
9
+ url: string;
10
+ /** The local object or actor the response attaches to. */
11
+ target: string;
12
+ author: SummaryAuthor;
13
+ /** Sanitized against the remote object's id; a Misskey or Pleroma reaction carries its emoji. */
14
+ content: {
15
+ html: string;
16
+ text: string;
17
+ } | null;
18
+ published: Date | null;
19
+ }
20
+ /** Who responded, as a byline shows them. */
21
+ export interface SummaryAuthor {
22
+ id: string;
23
+ /** `@user@host`, the form fediverse software displays and resolves. */
24
+ handle: string;
25
+ name: string | null;
26
+ /** The profile page, else the actor's id. */
27
+ url: string;
28
+ /** The avatar. */
29
+ photo: string | null;
30
+ }
31
+ /**
32
+ * Summarizes a reply, mention, like or boost of local content. A Like with `content`
33
+ * (Misskey's emoji reaction) and Pleroma's `EmojiReact` read as `like`.
34
+ *
35
+ * @param inbound - What a `create`, `update`, `like` or `announce` handler received.
36
+ * @returns The summary, or `null` for any other activity or one with nothing local to attach to.
37
+ * @example
38
+ * let summary = summarize(inbound);
39
+ * if (summary) await filter.check({ content: summary.content?.html ?? "", format: "html", author: summary.author });
40
+ */
41
+ export declare function summarize(inbound: Inbound): Summary | null;