@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,97 @@
1
+ /**
2
+ * Turns a verified reply, mention, like or boost into the same summary a Webmention
3
+ * receiver stores, so an app keeps one moderation queue and one list of responses for
4
+ * both protocols, with remote HTML already sanitized for rendering beside its own.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import { HTML } from "@sdxc/html";
10
+ import { isFailure } from "@sdxc/result";
11
+ /**
12
+ * Summarizes a reply, mention, like or boost of local content. A Like with `content`
13
+ * (Misskey's emoji reaction) and Pleroma's `EmojiReact` read as `like`.
14
+ *
15
+ * @param inbound - What a `create`, `update`, `like` or `announce` handler received.
16
+ * @returns The summary, or `null` for any other activity or one with nothing local to attach to.
17
+ * @example
18
+ * let summary = summarize(inbound);
19
+ * if (summary) await filter.check({ content: summary.content?.html ?? "", format: "html", author: summary.author });
20
+ */
21
+ export function summarize(inbound) {
22
+ let { activity, actor, object, target } = inbound;
23
+ if (target === null)
24
+ return null;
25
+ let author = authorOf(actor);
26
+ if (activity.type === "Create" || activity.type === "Update") {
27
+ if (object === null)
28
+ return null;
29
+ return {
30
+ kind: object.inReplyTo === target ? "reply" : "mention",
31
+ id: object.id,
32
+ url: object.url ?? object.id,
33
+ target,
34
+ author,
35
+ content: contentOf(object.content, object.id),
36
+ published: object.published ?? activity.published,
37
+ };
38
+ }
39
+ let kind = reactionKind(activity.type);
40
+ if (kind === null)
41
+ return null;
42
+ return {
43
+ kind,
44
+ id: activity.id,
45
+ url: activity.url ?? activity.id,
46
+ target,
47
+ author,
48
+ content: kind === "like" ? contentOf(activity.content, activity.id) : null,
49
+ published: activity.published,
50
+ };
51
+ }
52
+ /**
53
+ * The kind a reaction reads as.
54
+ *
55
+ * @param type - The activity's type.
56
+ */
57
+ function reactionKind(type) {
58
+ if (type === "Like" || type === "EmojiReact")
59
+ return "like";
60
+ if (type === "Announce")
61
+ return "repost";
62
+ return null;
63
+ }
64
+ /**
65
+ * The byline of an actor.
66
+ *
67
+ * @param actor - The sender.
68
+ */
69
+ function authorOf(actor) {
70
+ let host = URL.parse(actor.id)?.host ?? "";
71
+ return {
72
+ id: actor.id,
73
+ handle: `@${actor.preferredUsername}@${host}`,
74
+ name: actor.name,
75
+ url: actor.url ?? actor.id,
76
+ photo: actor.icon?.url ?? null,
77
+ };
78
+ }
79
+ /**
80
+ * Remote HTML made safe to render, with its relative URLs resolved against the object, and
81
+ * the text a reader sees in it. `null` when there is no content or no text survives.
82
+ *
83
+ * @param html - The remote markup.
84
+ * @param baseUrl - The object's id.
85
+ */
86
+ function contentOf(html, baseUrl) {
87
+ if (html === null || html.trim() === "")
88
+ return null;
89
+ let sanitized = HTML.sanitize(html, { baseUrl });
90
+ if (isFailure(sanitized))
91
+ return null;
92
+ let parsed = HTML.parse(sanitized.data);
93
+ let text = isFailure(parsed) ? "" : parsed.data.text;
94
+ if (sanitized.data.trim() === "" && text === "")
95
+ return null;
96
+ return { html: sanitized.data, text };
97
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The one queue message shape federation work travels in: a verified inbox activity, a
3
+ * fan-out of a published activity, or one delivery to one inbox, told apart by `kind` so
4
+ * an app declares a single job and hands every message back to `Federation#process`.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { Schema } from "remix/data-schema";
10
+ import type { FanOutInput } from "./delivery-fan-out.js";
11
+ import type { DeliveryInput } from "./delivery-post.js";
12
+ import type { Received } from "./inbox-receive.js";
13
+ /** A verified activity the inbox accepted, processed by the app's handlers. */
14
+ export interface InboxMessage extends Received {
15
+ kind: "inbox";
16
+ }
17
+ /** A published activity, turned into one delivery per distinct inbox. */
18
+ export interface FanOutMessage extends FanOutInput {
19
+ kind: "fanOut";
20
+ }
21
+ /**
22
+ * One activity to one inbox. The activity travels whole, so every inbox receives the same
23
+ * bytes even after the post it describes changes.
24
+ */
25
+ export interface DeliverMessage extends DeliveryInput {
26
+ kind: "deliver";
27
+ }
28
+ /** Every message federation enqueues; each fits a 128 KB queue message. */
29
+ export type Message = InboxMessage | FanOutMessage | DeliverMessage;
30
+ /**
31
+ * The Standard Schema of every message, so a job declared with it validates what the queue
32
+ * hands back before `process` reads it.
33
+ *
34
+ * @example federation: job({ input: Federation.MESSAGE })
35
+ */
36
+ export declare const MESSAGE: Schema<unknown, Message>;
@@ -0,0 +1,35 @@
1
+ import * as s from "remix/data-schema";
2
+ import { url } from "remix/data-schema/checks";
3
+ import { isObject } from "./compact.js";
4
+ import { VERIFICATIONS } from "./inbox-receive.js";
5
+ /** A decoded JSON object, as an inbox message's `activity` carries one. */
6
+ const JSON_OBJECT = s.createSchema((value, context) => isObject(value) ? { value } : s.fail("The activity must be a JSON object.", context.path));
7
+ /**
8
+ * The Standard Schema of every message, so a job declared with it validates what the queue
9
+ * hands back before `process` reads it.
10
+ *
11
+ * @example federation: job({ input: Federation.MESSAGE })
12
+ */
13
+ export const MESSAGE = s.variant("kind", {
14
+ inbox: s.object({
15
+ kind: s.literal("inbox"),
16
+ activity: JSON_OBJECT,
17
+ actor: s.string(),
18
+ signer: s.string(),
19
+ keyId: s.string(),
20
+ origin: s.string(),
21
+ verification: s.enum_(VERIFICATIONS),
22
+ receivedAt: s.string(),
23
+ }),
24
+ fanOut: s.object({
25
+ kind: s.literal("fanOut"),
26
+ actor: s.string().pipe(url()),
27
+ activity: s.string(),
28
+ }),
29
+ deliver: s.object({
30
+ kind: s.literal("deliver"),
31
+ actor: s.string().pipe(url()),
32
+ activity: s.string(),
33
+ inbox: s.string().pipe(url()),
34
+ }),
35
+ });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Reads untrusted JSON into the `ActivityPub` vocabulary through `remix/data-schema`,
3
+ * one schema per document kind, so every inbound activity, actor, object and collection
4
+ * is validated and normalized before anything reads a member of it.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { Result } from "@sdxc/result";
10
+ import { ActivityPubParseError } from "../errors.js";
11
+ import type { ActivityPub } from "./types.js";
12
+ /**
13
+ * Reads an activity from decoded JSON. It must have an absolute `id`, a `type` and an
14
+ * `actor`; any type name is accepted, so a handler acknowledges one it does not model.
15
+ *
16
+ * @param json - The decoded body, as `JSON.parse` returns it.
17
+ * @example
18
+ * let activity = parseActivity(await request.json());
19
+ * if (isFailure(activity)) return new Response(activity.error.message, { status: 400 });
20
+ */
21
+ export declare function parseActivity(json: unknown): Result<ActivityPub.Activity, ActivityPubParseError>;
22
+ /**
23
+ * Reads an actor from decoded JSON. It must be an actor type with an absolute `id`, a
24
+ * `preferredUsername` and an `inbox`, since Mastodon cannot follow one without them.
25
+ *
26
+ * @param json - The decoded actor document.
27
+ */
28
+ export declare function parseActor(json: unknown): Result<ActivityPub.Actor, ActivityPubParseError>;
29
+ /**
30
+ * Reads any object from decoded JSON, keeping its `type` whatever it is; a deleted one
31
+ * reads with `type: "Tombstone"`.
32
+ *
33
+ * @param json - The decoded object document.
34
+ */
35
+ export declare function parseObject(json: unknown): Result<ActivityPub.Object, ActivityPubParseError>;
36
+ /**
37
+ * Reads a collection or a collection page from decoded JSON, with its `first` page
38
+ * embedded or as an IRI, and every embedded item read as what it is.
39
+ *
40
+ * @param json - The decoded collection document.
41
+ */
42
+ export declare function parseCollection(json: unknown): Result<ActivityPub.AnyCollection, ActivityPubParseError>;
@@ -0,0 +1,348 @@
1
+ import { failure, success } from "@sdxc/result";
2
+ import { createSchema, fail, object, parseSafe } from "remix/data-schema";
3
+ import { lazy } from "remix/data-schema/lazy";
4
+ import { ActivityPubParseError } from "../errors.js";
5
+ import { absoluteIri, attachments, optionalAbsoluteIri, count, date, documentType, endpoints, flag, htmlUrl, image, iri, iris, isObject, languageMap, multikeys, proofs, publicKey, runAt, tags, text, withMapFallback, } from "./compact.js";
6
+ import { ACTIVITY_TYPES, ACTOR_TYPES } from "./constants.js";
7
+ /** Actor types, for telling an embedded actor from any other embedded object. */
8
+ const ACTOR_TYPE_SET = new Set(ACTOR_TYPES);
9
+ /** The collection types, each read by its own schema. */
10
+ const COLLECTION_TYPES = new Set([
11
+ "Collection",
12
+ "CollectionPage",
13
+ "OrderedCollection",
14
+ "OrderedCollectionPage",
15
+ ]);
16
+ /**
17
+ * Types preferred when a sender lists several, so `["Note", "x:Custom"]` reads as `Note`
18
+ * whatever order the sender chose.
19
+ */
20
+ const KNOWN_TYPES = new Set([
21
+ ...ACTIVITY_TYPES,
22
+ ...ACTOR_TYPES,
23
+ ...COLLECTION_TYPES,
24
+ "Article",
25
+ "Audio",
26
+ "Document",
27
+ "Event",
28
+ "Image",
29
+ "Note",
30
+ "Page",
31
+ "Place",
32
+ "Profile",
33
+ "Relationship",
34
+ "Tombstone",
35
+ "Video",
36
+ ]);
37
+ /** The members every object shares, under their wire names. */
38
+ const OBJECT_SHAPE = {
39
+ id: absoluteIri("id"),
40
+ type: documentType(KNOWN_TYPES),
41
+ attributedTo: iris(),
42
+ to: iris({ addressing: true }),
43
+ cc: iris({ addressing: true }),
44
+ bto: iris({ addressing: true }),
45
+ bcc: iris({ addressing: true }),
46
+ name: text(),
47
+ nameMap: languageMap(),
48
+ summary: text(),
49
+ summaryMap: languageMap(),
50
+ content: text(),
51
+ contentMap: languageMap(),
52
+ url: htmlUrl(),
53
+ inReplyTo: iri(),
54
+ quote: iri(),
55
+ quoteUrl: iri(),
56
+ quoteUri: iri(),
57
+ _misskey_quote: iri(),
58
+ published: date(),
59
+ updated: date(),
60
+ sensitive: flag(),
61
+ tag: tags(),
62
+ attachment: attachments(),
63
+ proof: proofs(),
64
+ };
65
+ /**
66
+ * Folds the wire variations into the API shape: plain text from a language map when
67
+ * only the map was sent, and one `quote` from whichever of its four names arrived.
68
+ *
69
+ * @param wire - The validated members.
70
+ */
71
+ function toObject(wire) {
72
+ return {
73
+ id: wire.id,
74
+ type: wire.type,
75
+ attributedTo: wire.attributedTo,
76
+ to: wire.to,
77
+ cc: wire.cc,
78
+ bto: wire.bto,
79
+ bcc: wire.bcc,
80
+ name: withMapFallback(wire.name, wire.nameMap),
81
+ nameMap: wire.nameMap,
82
+ summary: withMapFallback(wire.summary, wire.summaryMap),
83
+ summaryMap: wire.summaryMap,
84
+ content: withMapFallback(wire.content, wire.contentMap),
85
+ contentMap: wire.contentMap,
86
+ url: wire.url,
87
+ inReplyTo: wire.inReplyTo,
88
+ quote: wire.quote ?? wire.quoteUrl ?? wire.quoteUri ?? wire._misskey_quote,
89
+ published: wire.published,
90
+ updated: wire.updated,
91
+ sensitive: wire.sensitive,
92
+ tag: wire.tag,
93
+ attachment: wire.attachment,
94
+ proof: wire.proof,
95
+ };
96
+ }
97
+ /** Any object, its type kept whatever it is. */
98
+ const OBJECT_SCHEMA = object(OBJECT_SHAPE).transform(toObject);
99
+ /**
100
+ * An embedded entry (an activity's `object`, a collection item) read as what it is: an
101
+ * IRI, an activity when it names an `actor`, an actor when its type is one, else an object.
102
+ */
103
+ const ITEM_SCHEMA = createSchema((value, context) => {
104
+ if (typeof value === "string")
105
+ return { value };
106
+ if (!isObject(value)) {
107
+ return fail("An entry must be an IRI or an embedded object.", context.path);
108
+ }
109
+ return schemaFor(value)["~run"](value, context);
110
+ });
111
+ /** An activity's `object`: the first entry when an array was sent, `null` when absent. */
112
+ const ACTIVITY_OBJECT_SCHEMA = createSchema((value, context) => {
113
+ if (value === undefined || value === null)
114
+ return { value: null };
115
+ if (!Array.isArray(value))
116
+ return ITEM_SCHEMA["~run"](value, context);
117
+ if (value.length === 0)
118
+ return { value: null };
119
+ return runAt(ITEM_SCHEMA, value[0], context, 0);
120
+ });
121
+ /** An activity. Its type may be any name, so an app can acknowledge one it does not handle. */
122
+ const ACTIVITY_SCHEMA = object({
123
+ ...OBJECT_SHAPE,
124
+ actor: iri({ required: true, label: "actor" }),
125
+ object: lazy(() => ACTIVITY_OBJECT_SCHEMA),
126
+ target: iri(),
127
+ _misskey_reaction: text(),
128
+ }).transform(({ actor, object, target, _misskey_reaction, ...wire }) => {
129
+ let base = toObject(wire);
130
+ return { ...base, content: base.content ?? _misskey_reaction, actor, object, target };
131
+ });
132
+ /** An actor, whose `inbox` and `preferredUsername` Mastodon requires to follow it. */
133
+ const ACTOR_SCHEMA = object({
134
+ ...OBJECT_SHAPE,
135
+ type: documentType(KNOWN_TYPES).refine((type) => ACTOR_TYPE_SET.has(type), `"type" must be one of ${ACTOR_TYPES.join(", ")}.`),
136
+ preferredUsername: createSchema((value, context) => typeof value === "string" && value !== ""
137
+ ? { value }
138
+ : fail('"preferredUsername" is required.', context.path)),
139
+ inbox: absoluteIri("inbox"),
140
+ outbox: iri(),
141
+ followers: iri(),
142
+ following: iri(),
143
+ featured: iri(),
144
+ endpoints: endpoints(),
145
+ publicKey: publicKey(),
146
+ assertionMethod: multikeys(),
147
+ alsoKnownAs: iris(),
148
+ movedTo: iri(),
149
+ manuallyApprovesFollowers: flag(),
150
+ discoverable: flag(),
151
+ indexable: flag(),
152
+ icon: image(),
153
+ image: image(),
154
+ }).transform(({ type, preferredUsername, inbox, outbox, followers, following, featured, endpoints, publicKey, assertionMethod, alsoKnownAs, movedTo, manuallyApprovesFollowers, discoverable, indexable, icon, image, ...wire }) => ({
155
+ ...toObject({ ...wire, type }),
156
+ type: type,
157
+ preferredUsername,
158
+ inbox,
159
+ outbox,
160
+ followers,
161
+ following,
162
+ featured,
163
+ endpoints,
164
+ publicKey,
165
+ assertionMethod,
166
+ alsoKnownAs,
167
+ movedTo,
168
+ manuallyApprovesFollowers,
169
+ discoverable,
170
+ indexable,
171
+ icon,
172
+ image,
173
+ }));
174
+ /**
175
+ * The schema an embedded object is read with. An actor type without an `inbox` reads
176
+ * as a plain object, since some servers embed a partial actor in an activity.
177
+ *
178
+ * @param value - The embedded object.
179
+ */
180
+ function schemaFor(value) {
181
+ if (value.actor !== undefined)
182
+ return ACTIVITY_SCHEMA;
183
+ let types = Array.isArray(value.type) ? value.type : [value.type];
184
+ if (types.some((type) => ACTOR_TYPE_SET.has(String(type))) && value.inbox !== undefined) {
185
+ return ACTOR_SCHEMA;
186
+ }
187
+ return OBJECT_SCHEMA;
188
+ }
189
+ /** Collection entries, sent as one entry or an array; each must read. */
190
+ const ITEMS_SCHEMA = createSchema((value, context) => {
191
+ if (value === undefined || value === null)
192
+ return { value: [] };
193
+ if (!Array.isArray(value)) {
194
+ let single = ITEM_SCHEMA["~run"](value, context);
195
+ return single.issues ? single : { value: [single.value] };
196
+ }
197
+ let items = [];
198
+ let issues = [];
199
+ for (let [index, entry] of value.entries()) {
200
+ let result = runAt(ITEM_SCHEMA, entry, context, index);
201
+ if (result.issues)
202
+ issues.push(...result.issues);
203
+ else
204
+ items.push(result.value);
205
+ }
206
+ return issues.length > 0 ? { issues } : { value: items };
207
+ });
208
+ /** The members a page of either kind has besides its items. */
209
+ const PAGE_SHAPE = {
210
+ id: optionalAbsoluteIri("id"),
211
+ partOf: iri(),
212
+ next: iri(),
213
+ prev: iri(),
214
+ totalItems: count(),
215
+ };
216
+ /** A collection's `first`: an IRI, or the first page embedded, as Mastodon does for replies. */
217
+ const FIRST_PAGE_SCHEMA = createSchema((value, context) => {
218
+ if (value === undefined || value === null)
219
+ return { value: null };
220
+ if (typeof value === "string")
221
+ return { value };
222
+ return PAGE_SCHEMA["~run"](value, context);
223
+ });
224
+ /** Either page shape, chosen by `type`. */
225
+ const PAGE_SCHEMA = createSchema((value, context) => {
226
+ if (!isObject(value))
227
+ return fail("A page must be an object.", context.path);
228
+ let type = Array.isArray(value.type) ? value.type : [value.type];
229
+ if (type.includes("OrderedCollectionPage")) {
230
+ return ORDERED_PAGE_SCHEMA["~run"](value, context);
231
+ }
232
+ if (type.includes("CollectionPage"))
233
+ return UNORDERED_PAGE_SCHEMA["~run"](value, context);
234
+ return fail('"type" must be CollectionPage or OrderedCollectionPage.', [
235
+ ...context.path,
236
+ "type",
237
+ ]);
238
+ });
239
+ /** An unordered page. */
240
+ const UNORDERED_PAGE_SCHEMA = object({
241
+ ...PAGE_SHAPE,
242
+ items: ITEMS_SCHEMA,
243
+ }).transform((page) => ({ ...page, type: "CollectionPage" }));
244
+ /** An ordered page. */
245
+ const ORDERED_PAGE_SCHEMA = object({
246
+ ...PAGE_SHAPE,
247
+ orderedItems: ITEMS_SCHEMA,
248
+ }).transform((page) => ({ ...page, type: "OrderedCollectionPage" }));
249
+ /** The members a collection of either kind has besides its items. */
250
+ const COLLECTION_SHAPE = {
251
+ id: absoluteIri("id"),
252
+ totalItems: count(),
253
+ first: FIRST_PAGE_SCHEMA,
254
+ last: iri(),
255
+ };
256
+ /** An unordered collection. */
257
+ const UNORDERED_COLLECTION_SCHEMA = object({
258
+ ...COLLECTION_SHAPE,
259
+ items: ITEMS_SCHEMA,
260
+ }).transform((collection) => ({ ...collection, type: "Collection" }));
261
+ /** An ordered collection. */
262
+ const ORDERED_COLLECTION_SCHEMA = object({
263
+ ...COLLECTION_SHAPE,
264
+ orderedItems: ITEMS_SCHEMA,
265
+ }).transform((collection) => ({ ...collection, type: "OrderedCollection" }));
266
+ /** Any of the four collection shapes, chosen by `type`. */
267
+ const COLLECTION_SCHEMA = createSchema((value, context) => {
268
+ if (!isObject(value))
269
+ return fail("A collection must be an object.", context.path);
270
+ let types = Array.isArray(value.type) ? value.type : [value.type];
271
+ if (types.includes("OrderedCollection")) {
272
+ return ORDERED_COLLECTION_SCHEMA["~run"](value, context);
273
+ }
274
+ if (types.includes("Collection"))
275
+ return UNORDERED_COLLECTION_SCHEMA["~run"](value, context);
276
+ if (types.includes("OrderedCollectionPage") || types.includes("CollectionPage")) {
277
+ return PAGE_SCHEMA["~run"](value, context);
278
+ }
279
+ return fail('"type" must be a collection or collection page type.', [...context.path, "type"]);
280
+ });
281
+ /**
282
+ * A JSON Pointer (RFC 6901) from a data-schema issue path, escaping `~` and `/` so a
283
+ * member name holding either still points at itself.
284
+ *
285
+ * @param path - The issue's path, outermost first.
286
+ */
287
+ function pointerOf(path) {
288
+ return (path ?? [])
289
+ .map((segment) => (typeof segment === "object" ? segment.key : segment))
290
+ .map((segment) => `/${String(segment).replace(/~/g, "~0").replace(/\//g, "~1")}`)
291
+ .join("");
292
+ }
293
+ /**
294
+ * Validates a decoded document, answering every issue with a pointer into it.
295
+ *
296
+ * @param schema - The document kind's schema.
297
+ * @param kind - The document kind, for the error.
298
+ * @param json - The decoded JSON.
299
+ * @template Output - What the schema produces.
300
+ */
301
+ function read(schema, kind, json) {
302
+ if (!isObject(json)) {
303
+ return failure(new ActivityPubParseError(kind, [{ at: "", message: "The document is not a JSON object." }]));
304
+ }
305
+ let result = parseSafe(schema, json);
306
+ if (result.success)
307
+ return success(result.value);
308
+ return failure(new ActivityPubParseError(kind, result.issues.map((issue) => ({ at: pointerOf(issue.path), message: issue.message }))));
309
+ }
310
+ /**
311
+ * Reads an activity from decoded JSON. It must have an absolute `id`, a `type` and an
312
+ * `actor`; any type name is accepted, so a handler acknowledges one it does not model.
313
+ *
314
+ * @param json - The decoded body, as `JSON.parse` returns it.
315
+ * @example
316
+ * let activity = parseActivity(await request.json());
317
+ * if (isFailure(activity)) return new Response(activity.error.message, { status: 400 });
318
+ */
319
+ export function parseActivity(json) {
320
+ return read(ACTIVITY_SCHEMA, "activity", json);
321
+ }
322
+ /**
323
+ * Reads an actor from decoded JSON. It must be an actor type with an absolute `id`, a
324
+ * `preferredUsername` and an `inbox`, since Mastodon cannot follow one without them.
325
+ *
326
+ * @param json - The decoded actor document.
327
+ */
328
+ export function parseActor(json) {
329
+ return read(ACTOR_SCHEMA, "actor", json);
330
+ }
331
+ /**
332
+ * Reads any object from decoded JSON, keeping its `type` whatever it is; a deleted one
333
+ * reads with `type: "Tombstone"`.
334
+ *
335
+ * @param json - The decoded object document.
336
+ */
337
+ export function parseObject(json) {
338
+ return read(OBJECT_SCHEMA, "object", json);
339
+ }
340
+ /**
341
+ * Reads a collection or a collection page from decoded JSON, with its `first` page
342
+ * embedded or as an IRI, and every embedded item read as what it is.
343
+ *
344
+ * @param json - The decoded collection document.
345
+ */
346
+ export function parseCollection(json) {
347
+ return read(COLLECTION_SCHEMA, "collection", json);
348
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Serving ActivityPub documents: telling an AS2 client from a browser on a URL that has
3
+ * both representations, and answering with the media type, cache headers and status a
4
+ * remote server expects, including `410` for something deleted.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { PolicyOptions } from "@sdxc/http/cache";
10
+ import type { ActivityPub } from "./types.js";
11
+ /** How a document is answered. */
12
+ export interface RespondOptions {
13
+ /** Enables a 304 when `If-None-Match` still matches, and an empty body for `HEAD`. */
14
+ request?: Request;
15
+ /** Adds `Vary: Accept`, which a URL that also serves HTML needs so caches keep both. */
16
+ vary?: boolean;
17
+ /** @default { visibility: "public", maxAge: "5 minutes" } */
18
+ cache?: PolicyOptions;
19
+ /** Extra headers, applied last so they override the defaults. */
20
+ headers?: HeadersInit;
21
+ /** @default 200, or 410 for a Tombstone */
22
+ status?: number;
23
+ }
24
+ /**
25
+ * Whether a request asks for the ActivityStreams representation: `true` only when
26
+ * `application/activity+json` or `application/ld+json` is preferred over `text/html`.
27
+ * A browser's wildcard and a request without `Accept` read as HTML.
28
+ *
29
+ * @param request - The incoming request.
30
+ * @example
31
+ * if (wantsActivity(request)) return respond(article, { request, vary: true });
32
+ */
33
+ export declare function wantsActivity(request: Request): boolean;
34
+ /**
35
+ * The document as a `Response`: `application/activity+json; charset=utf-8`, a
36
+ * `Cache-Control` from `policy()`, an `ETag` over the body, `Vary: Accept` when asked,
37
+ * and a 304 through `conditional()` when `request` is given. A `Tombstone` answers
38
+ * `410`, which is how a refetching server learns the object was deleted.
39
+ *
40
+ * @param document - Any document, parsed or authored.
41
+ * @param options - The request, `Vary`, the cache policy, extra headers and the status.
42
+ * @example
43
+ * return respond(actor, { request: ctx.request, vary: true });
44
+ */
45
+ export declare function respond(document: ActivityPub.Draft<ActivityPub.Document>, options?: RespondOptions): Promise<Response>;
@@ -0,0 +1,59 @@
1
+ import { conditional, etag, policy, vary } from "@sdxc/http/cache";
2
+ import { accepts } from "@sdxc/http/negotiate";
3
+ import { ACTIVITY_CONTENT_TYPE, ACTIVITY_JSON, LD_JSON } from "./constants.js";
4
+ import { stringify } from "./stringify.js";
5
+ /**
6
+ * The cache policy a document gets unless the caller states one: short enough that an
7
+ * edit reaches a refetching server within minutes.
8
+ */
9
+ const DEFAULT_CACHE = { visibility: "public", maxAge: "5 minutes" };
10
+ /**
11
+ * The candidates `wantsActivity` negotiates between. HTML is first, so a wildcard or a
12
+ * missing `Accept` resolves to the page a browser or crawler expects.
13
+ */
14
+ const NEGOTIATED_TYPES = ["text/html", ACTIVITY_JSON, LD_JSON];
15
+ /**
16
+ * Whether a request asks for the ActivityStreams representation: `true` only when
17
+ * `application/activity+json` or `application/ld+json` is preferred over `text/html`.
18
+ * A browser's wildcard and a request without `Accept` read as HTML.
19
+ *
20
+ * @param request - The incoming request.
21
+ * @example
22
+ * if (wantsActivity(request)) return respond(article, { request, vary: true });
23
+ */
24
+ export function wantsActivity(request) {
25
+ let preferred = accepts(request).preferred(...NEGOTIATED_TYPES);
26
+ return preferred === ACTIVITY_JSON || preferred === LD_JSON;
27
+ }
28
+ /**
29
+ * The document as a `Response`: `application/activity+json; charset=utf-8`, a
30
+ * `Cache-Control` from `policy()`, an `ETag` over the body, `Vary: Accept` when asked,
31
+ * and a 304 through `conditional()` when `request` is given. A `Tombstone` answers
32
+ * `410`, which is how a refetching server learns the object was deleted.
33
+ *
34
+ * @param document - Any document, parsed or authored.
35
+ * @param options - The request, `Vary`, the cache policy, extra headers and the status.
36
+ * @example
37
+ * return respond(actor, { request: ctx.request, vary: true });
38
+ */
39
+ export async function respond(document, options = {}) {
40
+ let body = stringify(document);
41
+ let headers = new Headers({
42
+ "Content-Type": ACTIVITY_CONTENT_TYPE,
43
+ "Cache-Control": policy(options.cache ?? DEFAULT_CACHE).toString(),
44
+ });
45
+ let tag = await etag(body);
46
+ if (tag.status === "success")
47
+ headers.set("ETag", tag.data);
48
+ if (options.vary)
49
+ vary(headers, "Accept");
50
+ for (let [name, value] of new Headers(options.headers))
51
+ headers.set(name, value);
52
+ let status = options.status ?? (document.type === "Tombstone" ? 410 : 200);
53
+ let request = options.request;
54
+ let isHead = request?.method.toUpperCase() === "HEAD";
55
+ let response = new Response(isHead ? null : body, { status, headers });
56
+ if (request === undefined)
57
+ return response;
58
+ return await conditional(request, response);
59
+ }