@fedify/vocab 2.0.0-dev.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/LICENSE +20 -0
  2. package/deno.json +31 -0
  3. package/dist/accept.yaml +15 -0
  4. package/dist/activity.yaml +98 -0
  5. package/dist/actor.test.d.ts +2 -0
  6. package/dist/actor.test.js +6095 -0
  7. package/dist/add.yaml +16 -0
  8. package/dist/announce.yaml +30 -0
  9. package/dist/application.yaml +324 -0
  10. package/dist/arrive.yaml +15 -0
  11. package/dist/article.yaml +46 -0
  12. package/dist/audio.yaml +11 -0
  13. package/dist/block.yaml +16 -0
  14. package/dist/chatmessage.yaml +50 -0
  15. package/dist/collection.yaml +154 -0
  16. package/dist/collectionpage.yaml +55 -0
  17. package/dist/create.yaml +28 -0
  18. package/dist/dataintegrityproof.yaml +56 -0
  19. package/dist/delete.yaml +27 -0
  20. package/dist/deno-B-ypIMwF.js +1282 -0
  21. package/dist/didservice.yaml +22 -0
  22. package/dist/dislike.yaml +14 -0
  23. package/dist/document.yaml +31 -0
  24. package/dist/emoji.yaml +12 -0
  25. package/dist/emojireact.yaml +17 -0
  26. package/dist/endpoints.yaml +85 -0
  27. package/dist/event.yaml +11 -0
  28. package/dist/export.yaml +9 -0
  29. package/dist/flag.yaml +15 -0
  30. package/dist/follow.yaml +19 -0
  31. package/dist/group.yaml +324 -0
  32. package/dist/hashtag.yaml +14 -0
  33. package/dist/ignore.yaml +14 -0
  34. package/dist/image.yaml +9 -0
  35. package/dist/intransitiveactivity.yaml +15 -0
  36. package/dist/invite.yaml +14 -0
  37. package/dist/join.yaml +14 -0
  38. package/dist/key.yaml +28 -0
  39. package/dist/leave.yaml +14 -0
  40. package/dist/like.yaml +16 -0
  41. package/dist/link.yaml +101 -0
  42. package/dist/listen.yaml +12 -0
  43. package/dist/lookup.test.d.ts +2 -0
  44. package/dist/lookup.test.js +690 -0
  45. package/dist/mention.yaml +9 -0
  46. package/dist/mod.cjs +42036 -0
  47. package/dist/mod.d.cts +15329 -0
  48. package/dist/mod.d.ts +15330 -0
  49. package/dist/mod.js +41936 -0
  50. package/dist/move.yaml +15 -0
  51. package/dist/multikey.yaml +36 -0
  52. package/dist/note.yaml +48 -0
  53. package/dist/object.yaml +404 -0
  54. package/dist/offer.yaml +15 -0
  55. package/dist/orderedcollection.yaml +39 -0
  56. package/dist/orderedcollectionpage.yaml +50 -0
  57. package/dist/organization.yaml +324 -0
  58. package/dist/page.yaml +11 -0
  59. package/dist/person.yaml +324 -0
  60. package/dist/place.yaml +75 -0
  61. package/dist/profile.yaml +26 -0
  62. package/dist/propertyvalue.yaml +32 -0
  63. package/dist/question.yaml +103 -0
  64. package/dist/read.yaml +13 -0
  65. package/dist/reject.yaml +14 -0
  66. package/dist/relationship.yaml +52 -0
  67. package/dist/remove.yaml +14 -0
  68. package/dist/service.yaml +324 -0
  69. package/dist/source.yaml +26 -0
  70. package/dist/tentativeaccept.yaml +14 -0
  71. package/dist/tentativereject.yaml +14 -0
  72. package/dist/tombstone.yaml +24 -0
  73. package/dist/travel.yaml +16 -0
  74. package/dist/type-CNuABalk.js +13 -0
  75. package/dist/type.test.d.ts +2 -0
  76. package/dist/type.test.js +24 -0
  77. package/dist/undo.yaml +26 -0
  78. package/dist/update.yaml +58 -0
  79. package/dist/utils-BSWXlrig.js +13 -0
  80. package/dist/video.yaml +11 -0
  81. package/dist/view.yaml +13 -0
  82. package/dist/vocab-DBispxj5.js +41603 -0
  83. package/dist/vocab.test.d.ts +2 -0
  84. package/dist/vocab.test.js +1304 -0
  85. package/package.json +79 -0
  86. package/scripts/codegen.ts +20 -0
  87. package/src/__snapshots__/vocab.test.ts.snap +7903 -0
  88. package/src/accept.yaml +15 -0
  89. package/src/activity.yaml +98 -0
  90. package/src/actor.test.ts +263 -0
  91. package/src/actor.ts +293 -0
  92. package/src/add.yaml +16 -0
  93. package/src/announce.yaml +30 -0
  94. package/src/application.yaml +324 -0
  95. package/src/arrive.yaml +15 -0
  96. package/src/article.yaml +46 -0
  97. package/src/audio.yaml +11 -0
  98. package/src/block.yaml +16 -0
  99. package/src/chatmessage.yaml +50 -0
  100. package/src/collection.yaml +154 -0
  101. package/src/collectionpage.yaml +55 -0
  102. package/src/constants.ts +11 -0
  103. package/src/create.yaml +28 -0
  104. package/src/dataintegrityproof.yaml +56 -0
  105. package/src/delete.yaml +27 -0
  106. package/src/didservice.yaml +22 -0
  107. package/src/dislike.yaml +14 -0
  108. package/src/document.yaml +31 -0
  109. package/src/emoji.yaml +12 -0
  110. package/src/emojireact.yaml +17 -0
  111. package/src/endpoints.yaml +85 -0
  112. package/src/event.yaml +11 -0
  113. package/src/export.yaml +9 -0
  114. package/src/flag.yaml +15 -0
  115. package/src/follow.yaml +19 -0
  116. package/src/group.yaml +324 -0
  117. package/src/handle.ts +104 -0
  118. package/src/hashtag.yaml +14 -0
  119. package/src/ignore.yaml +14 -0
  120. package/src/image.yaml +9 -0
  121. package/src/intransitiveactivity.yaml +15 -0
  122. package/src/invite.yaml +14 -0
  123. package/src/join.yaml +14 -0
  124. package/src/key.yaml +28 -0
  125. package/src/keys.ts +50 -0
  126. package/src/leave.yaml +14 -0
  127. package/src/like.yaml +16 -0
  128. package/src/link.yaml +101 -0
  129. package/src/listen.yaml +12 -0
  130. package/src/lookup.test.ts +681 -0
  131. package/src/lookup.ts +318 -0
  132. package/src/mention.yaml +9 -0
  133. package/src/mod.ts +57 -0
  134. package/src/move.yaml +15 -0
  135. package/src/multikey.yaml +36 -0
  136. package/src/note.yaml +48 -0
  137. package/src/object.yaml +404 -0
  138. package/src/offer.yaml +15 -0
  139. package/src/orderedcollection.yaml +39 -0
  140. package/src/orderedcollectionpage.yaml +50 -0
  141. package/src/organization.yaml +324 -0
  142. package/src/page.yaml +11 -0
  143. package/src/person.yaml +324 -0
  144. package/src/place.yaml +75 -0
  145. package/src/profile.yaml +26 -0
  146. package/src/propertyvalue.yaml +32 -0
  147. package/src/question.yaml +103 -0
  148. package/src/read.yaml +13 -0
  149. package/src/reject.yaml +14 -0
  150. package/src/relationship.yaml +52 -0
  151. package/src/remove.yaml +14 -0
  152. package/src/service.yaml +324 -0
  153. package/src/source.yaml +26 -0
  154. package/src/tentativeaccept.yaml +14 -0
  155. package/src/tentativereject.yaml +14 -0
  156. package/src/tombstone.yaml +24 -0
  157. package/src/travel.yaml +16 -0
  158. package/src/type.test.ts +20 -0
  159. package/src/type.ts +102 -0
  160. package/src/undo.yaml +26 -0
  161. package/src/update.yaml +58 -0
  162. package/src/utils.ts +9 -0
  163. package/src/video.yaml +11 -0
  164. package/src/view.yaml +13 -0
  165. package/src/vocab.bench.ts +204 -0
  166. package/src/vocab.test.ts +2014 -0
  167. package/tsdown.config.ts +65 -0
@@ -0,0 +1,690 @@
1
+
2
+ import { Temporal } from "@js-temporal/polyfill";
3
+ globalThis.addEventListener = () => {};
4
+
5
+ import { Collection, Note, Object as Object$1, Person } from "./vocab-DBispxj5.js";
6
+ import { deno_default, esm_default } from "./deno-B-ypIMwF.js";
7
+ import { getTypeId } from "./type-CNuABalk.js";
8
+ import { assertInstanceOf } from "./utils-BSWXlrig.js";
9
+ import { createTestTracerProvider, mockDocumentLoader, test } from "@fedify/fixture";
10
+ import { deepStrictEqual, equal, ok, rejects } from "node:assert/strict";
11
+ import { lookupWebFinger } from "@fedify/webfinger";
12
+ import { SpanStatusCode, trace } from "@opentelemetry/api";
13
+ import { getLogger } from "@logtape/logtape";
14
+ import { getDocumentLoader } from "@fedify/vocab-runtime";
15
+ import { delay } from "es-toolkit";
16
+
17
+ //#region src/handle.ts
18
+ /**
19
+ * Regular expression to match a fediverse handle in the format `@user@server`
20
+ * or `user@server`. The `user` part can contain alphanumeric characters and
21
+ * some special characters except `@`. The `server` part is all characters
22
+ * after the `@` symbol in the middle.
23
+ */
24
+ const handleRegexp = /^@?((?:[-A-Za-z0-9._~!$&'()*+,;=]|%[A-Fa-f0-9]{2})+)@([^@]+)$/;
25
+ /**
26
+ * Parses a fediverse handle in the format `@user@server` or `user@server`.
27
+ * The `user` part can contain alphanumeric characters and some special
28
+ * characters except `@`. The `server` part is all characters after the `@`
29
+ * symbol in the middle.
30
+ *
31
+ * @example
32
+ * ```typescript
33
+ * const handle = parseFediverseHandle("@username@example.com");
34
+ * console.log(handle?.username); // "username"
35
+ * console.log(handle?.host); // "example.com"
36
+ * ```
37
+ *
38
+ * @param handle - The fediverse handle string to parse.
39
+ * @returns A {@link FediverseHandle} object with `username` and `host`
40
+ * if the input is valid; otherwise `null`.
41
+ * @since 1.8.0
42
+ */
43
+ function parseFediverseHandle(handle) {
44
+ const match = handleRegexp.exec(handle);
45
+ if (match) return {
46
+ username: match[1],
47
+ host: match[2]
48
+ };
49
+ return null;
50
+ }
51
+ /**
52
+ * Converts a fediverse handle in the format `@user@server` or `user@server`
53
+ * to an `acct:` URI, which is a URL-like identifier for ActivityPub actors.
54
+ *
55
+ * @example
56
+ * ```typescript
57
+ * const identifier = toAcctUrl("@username@example.com");
58
+ * console.log(identifier?.href); // "acct:username@example.com"
59
+ * ```
60
+ *
61
+ * @param handle - The fediverse handle string to convert.
62
+ * @returns A `URL` object representing the `acct:` URI if conversion succeeds;
63
+ * otherwise `null`.
64
+ * @since 1.8.0
65
+ */
66
+ function toAcctUrl(handle) {
67
+ const parsed = parseFediverseHandle(handle);
68
+ if (!parsed) return null;
69
+ const identifier = new URL(`acct:${parsed.username}@${parsed.host}`);
70
+ return identifier;
71
+ }
72
+
73
+ //#endregion
74
+ //#region src/lookup.ts
75
+ const logger = getLogger([
76
+ "fedify",
77
+ "vocab",
78
+ "lookup"
79
+ ]);
80
+ /**
81
+ * Looks up an ActivityStreams object by its URI (including `acct:` URIs)
82
+ * or a fediverse handle (e.g., `@user@server` or `user@server`).
83
+ *
84
+ * @example
85
+ * ``` typescript
86
+ * // Look up an actor by its fediverse handle:
87
+ * await lookupObject("@hongminhee@fosstodon.org");
88
+ * // returning a `Person` object.
89
+ *
90
+ * // A fediverse handle can omit the leading '@':
91
+ * await lookupObject("hongminhee@fosstodon.org");
92
+ * // returning a `Person` object.
93
+ *
94
+ * // A `acct:` URI can be used as well:
95
+ * await lookupObject("acct:hongminhee@fosstodon.org");
96
+ * // returning a `Person` object.
97
+ *
98
+ * // Look up an object by its URI:
99
+ * await lookupObject("https://todon.eu/@hongminhee/112060633798771581");
100
+ * // returning a `Note` object.
101
+ *
102
+ * // It can be a `URL` object as well:
103
+ * await lookupObject(new URL("https://todon.eu/@hongminhee/112060633798771581"));
104
+ * // returning a `Note` object.
105
+ * ```
106
+ *
107
+ * @param identifier The URI or fediverse handle to look up.
108
+ * @param options Lookup options.
109
+ * @returns The object, or `null` if not found.
110
+ * @since 0.2.0
111
+ */
112
+ async function lookupObject(identifier, options = {}) {
113
+ const tracerProvider = options.tracerProvider ?? trace.getTracerProvider();
114
+ const tracer = tracerProvider.getTracer(deno_default.name, deno_default.version);
115
+ return await tracer.startActiveSpan("activitypub.lookup_object", async (span) => {
116
+ try {
117
+ const result = await lookupObjectInternal(identifier, options);
118
+ if (result == null) span.setStatus({ code: SpanStatusCode.ERROR });
119
+ else {
120
+ if (result.id != null) span.setAttribute("activitypub.object.id", result.id.href);
121
+ span.setAttribute("activitypub.object.type", getTypeId(result).href);
122
+ if (result.replyTargetIds.length > 0) span.setAttribute("activitypub.object.in_reply_to", result.replyTargetIds.map((id) => id.href));
123
+ span.addEvent("activitypub.object.fetched", {
124
+ "activitypub.object.type": getTypeId(result).href,
125
+ "activitypub.object.json": JSON.stringify(await result.toJsonLd(options))
126
+ });
127
+ }
128
+ return result;
129
+ } catch (error) {
130
+ span.recordException(error);
131
+ span.setStatus({
132
+ code: SpanStatusCode.ERROR,
133
+ message: String(error)
134
+ });
135
+ throw error;
136
+ } finally {
137
+ span.end();
138
+ }
139
+ });
140
+ }
141
+ async function lookupObjectInternal(identifier, options = {}) {
142
+ const documentLoader = options.documentLoader ?? getDocumentLoader({ userAgent: options.userAgent });
143
+ if (typeof identifier === "string") identifier = toAcctUrl(identifier) ?? new URL(identifier);
144
+ let remoteDoc = null;
145
+ if (identifier.protocol === "http:" || identifier.protocol === "https:") try {
146
+ remoteDoc = await documentLoader(identifier.href, { signal: options.signal });
147
+ } catch (error) {
148
+ logger.debug("Failed to fetch remote document:\n{error}", { error });
149
+ }
150
+ if (remoteDoc == null) {
151
+ const jrd = await lookupWebFinger(identifier, {
152
+ userAgent: options.userAgent,
153
+ tracerProvider: options.tracerProvider,
154
+ allowPrivateAddress: "allowPrivateAddress" in options && options.allowPrivateAddress === true,
155
+ signal: options.signal
156
+ });
157
+ if (jrd?.links == null) return null;
158
+ for (const l of jrd.links) {
159
+ if (l.type !== "application/activity+json" && !l.type?.match(/application\/ld\+json;\s*profile="https:\/\/www.w3.org\/ns\/activitystreams"/) || l.rel !== "self" || l.href == null) continue;
160
+ try {
161
+ remoteDoc = await documentLoader(l.href, { signal: options.signal });
162
+ break;
163
+ } catch (error) {
164
+ logger.debug("Failed to fetch remote document:\n{error}", { error });
165
+ continue;
166
+ }
167
+ }
168
+ }
169
+ if (remoteDoc == null) return null;
170
+ let object;
171
+ try {
172
+ object = await Object$1.fromJsonLd(remoteDoc.document, {
173
+ documentLoader,
174
+ contextLoader: options.contextLoader,
175
+ tracerProvider: options.tracerProvider,
176
+ baseUrl: new URL(remoteDoc.documentUrl)
177
+ });
178
+ } catch (error) {
179
+ if (error instanceof TypeError) {
180
+ logger.debug("Failed to parse JSON-LD document: {error}\n{document}", {
181
+ ...remoteDoc,
182
+ error
183
+ });
184
+ return null;
185
+ }
186
+ throw error;
187
+ }
188
+ if (options.crossOrigin !== "trust" && object.id != null && object.id.origin !== new URL(remoteDoc.documentUrl).origin) {
189
+ if (options.crossOrigin === "throw") throw new Error(`The object's @id (${object.id.href}) has a different origin than the document URL (${remoteDoc.documentUrl}); refusing to return the object. If you want to bypass this check and are aware of the security implications, set the crossOrigin option to "trust".`);
190
+ logger.warn("The object's @id ({objectId}) has a different origin than the document URL ({documentUrl}); refusing to return the object. If you want to bypass this check and are aware of the security implications, set the crossOrigin option to \"trust\".", {
191
+ ...remoteDoc,
192
+ objectId: object.id.href
193
+ });
194
+ return null;
195
+ }
196
+ return object;
197
+ }
198
+ /**
199
+ * Traverses a collection, yielding each item in the collection.
200
+ * If the collection is paginated, it will fetch the next page
201
+ * automatically.
202
+ *
203
+ * @example
204
+ * ``` typescript
205
+ * const collection = await lookupObject(collectionUrl);
206
+ * if (collection instanceof Collection) {
207
+ * for await (const item of traverseCollection(collection)) {
208
+ * console.log(item.id?.href);
209
+ * }
210
+ * }
211
+ * ```
212
+ *
213
+ * @param collection The collection to traverse.
214
+ * @param options Options for traversing the collection.
215
+ * @returns An async iterable of each item in the collection.
216
+ * @since 1.1.0
217
+ */
218
+ async function* traverseCollection(collection, options = {}) {
219
+ if (collection.firstId == null) for await (const item of collection.getItems(options)) yield item;
220
+ else {
221
+ const interval = Temporal.Duration.from(options.interval ?? { seconds: 0 }).total("millisecond");
222
+ let page = await collection.getFirst(options);
223
+ while (page != null) {
224
+ for await (const item of page.getItems(options)) yield item;
225
+ if (interval > 0) await delay(interval);
226
+ page = await page.getNext(options);
227
+ }
228
+ }
229
+ }
230
+
231
+ //#endregion
232
+ //#region src/lookup.test.ts
233
+ test("lookupObject()", {
234
+ sanitizeResources: false,
235
+ sanitizeOps: false
236
+ }, async (t) => {
237
+ esm_default.spyGlobal();
238
+ esm_default.get("begin:https://example.com/.well-known/webfinger", {
239
+ subject: "acct:johndoe@example.com",
240
+ links: [
241
+ {
242
+ rel: "alternate",
243
+ href: "https://example.com/object",
244
+ type: "application/activity+json"
245
+ },
246
+ {
247
+ rel: "self",
248
+ href: "https://example.com/html/person",
249
+ type: "text/html"
250
+ },
251
+ {
252
+ rel: "self",
253
+ href: "https://example.com/person",
254
+ type: "application/activity+json"
255
+ }
256
+ ]
257
+ });
258
+ const options = {
259
+ documentLoader: mockDocumentLoader,
260
+ contextLoader: mockDocumentLoader
261
+ };
262
+ await t.step("actor", async () => {
263
+ const person = await lookupObject("@johndoe@example.com", options);
264
+ assertInstanceOf(person, Person);
265
+ deepStrictEqual(person.id, new URL("https://example.com/person"));
266
+ equal(person.name, "John Doe");
267
+ const person2 = await lookupObject("johndoe@example.com", options);
268
+ deepStrictEqual(person2, person);
269
+ const person3 = await lookupObject("acct:johndoe@example.com", options);
270
+ deepStrictEqual(person3, person);
271
+ });
272
+ await t.step("object", async () => {
273
+ const object = await lookupObject("https://example.com/object", options);
274
+ assertInstanceOf(object, Object$1);
275
+ deepStrictEqual(object, new Object$1({
276
+ id: new URL("https://example.com/object"),
277
+ name: "Fetched object"
278
+ }));
279
+ const person = await lookupObject("https://example.com/hong-gildong", options);
280
+ assertInstanceOf(person, Person);
281
+ deepStrictEqual(person, new Person({
282
+ id: new URL("https://example.com/hong-gildong"),
283
+ name: "Hong Gildong"
284
+ }));
285
+ });
286
+ esm_default.removeRoutes();
287
+ esm_default.get("begin:https://example.com/.well-known/webfinger", {
288
+ subject: "acct:janedoe@example.com",
289
+ links: [{
290
+ rel: "self",
291
+ href: "https://example.com/404",
292
+ type: "application/activity+json"
293
+ }]
294
+ });
295
+ await t.step("not found", async () => {
296
+ deepStrictEqual(await lookupObject("janedoe@example.com", options), null);
297
+ deepStrictEqual(await lookupObject("https://example.com/404", options), null);
298
+ });
299
+ esm_default.removeRoutes();
300
+ esm_default.get("begin:https://example.com/.well-known/webfinger", () => new Promise((resolve) => {
301
+ setTimeout(() => {
302
+ resolve({
303
+ subject: "acct:johndoe@example.com",
304
+ links: [{
305
+ rel: "self",
306
+ href: "https://example.com/person",
307
+ type: "application/activity+json"
308
+ }]
309
+ });
310
+ }, 1e3);
311
+ }));
312
+ await t.step("request cancellation", async () => {
313
+ const controller = new AbortController();
314
+ const promise = lookupObject("johndoe@example.com", {
315
+ ...options,
316
+ signal: controller.signal
317
+ });
318
+ controller.abort();
319
+ deepStrictEqual(await promise, null);
320
+ });
321
+ esm_default.removeRoutes();
322
+ esm_default.get("begin:https://example.com/.well-known/webfinger", {
323
+ subject: "acct:johndoe@example.com",
324
+ links: [{
325
+ rel: "self",
326
+ href: "https://example.com/person",
327
+ type: "application/activity+json"
328
+ }]
329
+ });
330
+ await t.step("successful request with signal", async () => {
331
+ const controller = new AbortController();
332
+ const person = await lookupObject("johndoe@example.com", {
333
+ ...options,
334
+ signal: controller.signal
335
+ });
336
+ assertInstanceOf(person, Person);
337
+ deepStrictEqual(person.id, new URL("https://example.com/person"));
338
+ });
339
+ esm_default.removeRoutes();
340
+ esm_default.get("begin:https://example.com/.well-known/webfinger", () => new Promise((resolve) => {
341
+ setTimeout(() => {
342
+ resolve({
343
+ subject: "acct:johndoe@example.com",
344
+ links: [{
345
+ rel: "self",
346
+ href: "https://example.com/person",
347
+ type: "application/activity+json"
348
+ }]
349
+ });
350
+ }, 500);
351
+ }));
352
+ await t.step("cancellation with immediate abort", async () => {
353
+ const controller = new AbortController();
354
+ controller.abort();
355
+ const result = await lookupObject("johndoe@example.com", {
356
+ ...options,
357
+ signal: controller.signal
358
+ });
359
+ deepStrictEqual(result, null);
360
+ });
361
+ esm_default.removeRoutes();
362
+ esm_default.get("https://example.com/slow-object", () => new Promise((resolve) => {
363
+ setTimeout(() => {
364
+ resolve({
365
+ status: 200,
366
+ headers: { "Content-Type": "application/activity+json" },
367
+ body: {
368
+ "@context": "https://www.w3.org/ns/activitystreams",
369
+ type: "Note",
370
+ content: "Slow response"
371
+ }
372
+ });
373
+ }, 1e3);
374
+ }));
375
+ await t.step("direct object fetch cancellation", async () => {
376
+ const controller = new AbortController();
377
+ const promise = lookupObject("https://example.com/slow-object", {
378
+ contextLoader: mockDocumentLoader,
379
+ signal: controller.signal
380
+ });
381
+ controller.abort();
382
+ deepStrictEqual(await promise, null);
383
+ });
384
+ esm_default.hardReset();
385
+ esm_default.removeRoutes();
386
+ });
387
+ test("traverseCollection()", {
388
+ sanitizeResources: false,
389
+ sanitizeOps: false
390
+ }, async () => {
391
+ const options = {
392
+ documentLoader: mockDocumentLoader,
393
+ contextLoader: mockDocumentLoader
394
+ };
395
+ const collection = await lookupObject("https://example.com/collection", options);
396
+ assertInstanceOf(collection, Collection);
397
+ deepStrictEqual(await Array.fromAsync(traverseCollection(collection, options)), [
398
+ new Note({ content: "This is a simple note" }),
399
+ new Note({ content: "This is another simple note" }),
400
+ new Note({ content: "This is a third simple note" })
401
+ ]);
402
+ const pagedCollection = await lookupObject("https://example.com/paged-collection", options);
403
+ assertInstanceOf(pagedCollection, Collection);
404
+ deepStrictEqual(await Array.fromAsync(traverseCollection(pagedCollection, options)), [
405
+ new Note({ content: "This is a simple note" }),
406
+ new Note({ content: "This is another simple note" }),
407
+ new Note({ content: "This is a third simple note" })
408
+ ]);
409
+ deepStrictEqual(await Array.fromAsync(traverseCollection(pagedCollection, {
410
+ ...options,
411
+ interval: { milliseconds: 250 }
412
+ })), [
413
+ new Note({ content: "This is a simple note" }),
414
+ new Note({ content: "This is another simple note" }),
415
+ new Note({ content: "This is a third simple note" })
416
+ ]);
417
+ });
418
+ test("FEP-fe34: lookupObject() cross-origin security", {
419
+ sanitizeResources: false,
420
+ sanitizeOps: false
421
+ }, async (t) => {
422
+ await t.step("crossOrigin: ignore (default) - returns null for cross-origin objects", async () => {
423
+ const crossOriginDocumentLoader = async (url) => {
424
+ if (url === "https://example.com/note") return {
425
+ documentUrl: url,
426
+ contextUrl: null,
427
+ document: {
428
+ "@context": "https://www.w3.org/ns/activitystreams",
429
+ type: "Note",
430
+ id: "https://malicious.com/fake-note",
431
+ content: "This is a spoofed note from a different origin"
432
+ }
433
+ };
434
+ throw new Error(`Unexpected URL: ${url}`);
435
+ };
436
+ const result = await lookupObject("https://example.com/note", {
437
+ documentLoader: crossOriginDocumentLoader,
438
+ contextLoader: mockDocumentLoader
439
+ });
440
+ deepStrictEqual(result, null);
441
+ });
442
+ await t.step("crossOrigin: throw - throws error for cross-origin objects", async () => {
443
+ const crossOriginDocumentLoader = async (url) => {
444
+ if (url === "https://example.com/note") return {
445
+ documentUrl: url,
446
+ contextUrl: null,
447
+ document: {
448
+ "@context": "https://www.w3.org/ns/activitystreams",
449
+ type: "Note",
450
+ id: "https://malicious.com/fake-note",
451
+ content: "This is a spoofed note from a different origin"
452
+ }
453
+ };
454
+ throw new Error(`Unexpected URL: ${url}`);
455
+ };
456
+ await rejects(() => lookupObject("https://example.com/note", {
457
+ documentLoader: crossOriginDocumentLoader,
458
+ contextLoader: mockDocumentLoader,
459
+ crossOrigin: "throw"
460
+ }), Error, "The object's @id (https://malicious.com/fake-note) has a different origin than the document URL (https://example.com/note)");
461
+ });
462
+ await t.step("crossOrigin: trust - allows cross-origin objects", async () => {
463
+ const crossOriginDocumentLoader = async (url) => {
464
+ if (url === "https://example.com/note") return {
465
+ documentUrl: url,
466
+ contextUrl: null,
467
+ document: {
468
+ "@context": "https://www.w3.org/ns/activitystreams",
469
+ type: "Note",
470
+ id: "https://malicious.com/fake-note",
471
+ content: "This is a spoofed note from a different origin"
472
+ }
473
+ };
474
+ throw new Error(`Unexpected URL: ${url}`);
475
+ };
476
+ const result = await lookupObject("https://example.com/note", {
477
+ documentLoader: crossOriginDocumentLoader,
478
+ contextLoader: mockDocumentLoader,
479
+ crossOrigin: "trust"
480
+ });
481
+ assertInstanceOf(result, Note);
482
+ deepStrictEqual(result.id, new URL("https://malicious.com/fake-note"));
483
+ deepStrictEqual(result.content, "This is a spoofed note from a different origin");
484
+ });
485
+ await t.step("same-origin objects are always trusted", async () => {
486
+ const sameOriginDocumentLoader = async (url) => {
487
+ if (url === "https://example.com/note") return {
488
+ documentUrl: url,
489
+ contextUrl: null,
490
+ document: {
491
+ "@context": "https://www.w3.org/ns/activitystreams",
492
+ type: "Note",
493
+ id: "https://example.com/note",
494
+ content: "This is a legitimate note from the same origin"
495
+ }
496
+ };
497
+ throw new Error(`Unexpected URL: ${url}`);
498
+ };
499
+ const result = await lookupObject("https://example.com/note", {
500
+ documentLoader: sameOriginDocumentLoader,
501
+ contextLoader: mockDocumentLoader
502
+ });
503
+ assertInstanceOf(result, Note);
504
+ deepStrictEqual(result.id, new URL("https://example.com/note"));
505
+ deepStrictEqual(result.content, "This is a legitimate note from the same origin");
506
+ });
507
+ await t.step("objects without @id are trusted", async () => {
508
+ const noIdDocumentLoader = async (url) => {
509
+ if (url === "https://example.com/note") return {
510
+ documentUrl: url,
511
+ contextUrl: null,
512
+ document: {
513
+ "@context": "https://www.w3.org/ns/activitystreams",
514
+ type: "Note",
515
+ content: "This is a note without an ID"
516
+ }
517
+ };
518
+ throw new Error(`Unexpected URL: ${url}`);
519
+ };
520
+ const result = await lookupObject("https://example.com/note", {
521
+ documentLoader: noIdDocumentLoader,
522
+ contextLoader: mockDocumentLoader
523
+ });
524
+ assertInstanceOf(result, Note);
525
+ deepStrictEqual(result.id, null);
526
+ deepStrictEqual(result.content, "This is a note without an ID");
527
+ });
528
+ await t.step("WebFinger lookup with cross-origin actor URL", async () => {
529
+ esm_default.spyGlobal();
530
+ esm_default.get("begin:https://example.com/.well-known/webfinger", {
531
+ subject: "acct:user@example.com",
532
+ links: [{
533
+ rel: "self",
534
+ href: "https://different-origin.com/actor",
535
+ type: "application/activity+json"
536
+ }]
537
+ });
538
+ const webfingerDocumentLoader = async (url) => {
539
+ if (url === "https://different-origin.com/actor") return {
540
+ documentUrl: url,
541
+ contextUrl: null,
542
+ document: {
543
+ "@context": "https://www.w3.org/ns/activitystreams",
544
+ type: "Person",
545
+ id: "https://malicious.com/fake-actor",
546
+ name: "Fake Actor"
547
+ }
548
+ };
549
+ throw new Error(`Unexpected URL: ${url}`);
550
+ };
551
+ const result1 = await lookupObject("@user@example.com", {
552
+ documentLoader: webfingerDocumentLoader,
553
+ contextLoader: mockDocumentLoader
554
+ });
555
+ deepStrictEqual(result1, null);
556
+ await rejects(() => lookupObject("@user@example.com", {
557
+ documentLoader: webfingerDocumentLoader,
558
+ contextLoader: mockDocumentLoader,
559
+ crossOrigin: "throw"
560
+ }), Error, "The object's @id (https://malicious.com/fake-actor) has a different origin than the document URL (https://different-origin.com/actor)");
561
+ const result2 = await lookupObject("@user@example.com", {
562
+ documentLoader: webfingerDocumentLoader,
563
+ contextLoader: mockDocumentLoader,
564
+ crossOrigin: "trust"
565
+ });
566
+ assertInstanceOf(result2, Person);
567
+ deepStrictEqual(result2.id, new URL("https://malicious.com/fake-actor"));
568
+ esm_default.removeRoutes();
569
+ esm_default.hardReset();
570
+ });
571
+ await t.step("subdomain same-origin check", async () => {
572
+ const subdomainDocumentLoader = async (url) => {
573
+ if (url === "https://api.example.com/note") return {
574
+ documentUrl: url,
575
+ contextUrl: null,
576
+ document: {
577
+ "@context": "https://www.w3.org/ns/activitystreams",
578
+ type: "Note",
579
+ id: "https://www.example.com/note",
580
+ content: "Cross-subdomain note"
581
+ }
582
+ };
583
+ throw new Error(`Unexpected URL: ${url}`);
584
+ };
585
+ const result = await lookupObject("https://api.example.com/note", {
586
+ documentLoader: subdomainDocumentLoader,
587
+ contextLoader: mockDocumentLoader
588
+ });
589
+ deepStrictEqual(result, null);
590
+ });
591
+ await t.step("different port same-origin check", async () => {
592
+ const differentPortDocumentLoader = async (url) => {
593
+ if (url === "https://example.com:8080/note") return {
594
+ documentUrl: url,
595
+ contextUrl: null,
596
+ document: {
597
+ "@context": "https://www.w3.org/ns/activitystreams",
598
+ type: "Note",
599
+ id: "https://example.com:9090/note",
600
+ content: "Cross-port note"
601
+ }
602
+ };
603
+ throw new Error(`Unexpected URL: ${url}`);
604
+ };
605
+ const result = await lookupObject("https://example.com:8080/note", {
606
+ documentLoader: differentPortDocumentLoader,
607
+ contextLoader: mockDocumentLoader
608
+ });
609
+ deepStrictEqual(result, null);
610
+ });
611
+ await t.step("protocol difference same-origin check", async () => {
612
+ const differentProtocolDocumentLoader = async (url) => {
613
+ if (url === "https://example.com/note") return {
614
+ documentUrl: url,
615
+ contextUrl: null,
616
+ document: {
617
+ "@context": "https://www.w3.org/ns/activitystreams",
618
+ type: "Note",
619
+ id: "http://example.com/note",
620
+ content: "Cross-protocol note"
621
+ }
622
+ };
623
+ throw new Error(`Unexpected URL: ${url}`);
624
+ };
625
+ const result = await lookupObject("https://example.com/note", {
626
+ documentLoader: differentProtocolDocumentLoader,
627
+ contextLoader: mockDocumentLoader
628
+ });
629
+ deepStrictEqual(result, null);
630
+ });
631
+ await t.step("error handling with crossOrigin throw option", async () => {
632
+ const errorDocumentLoader = async (_url) => {
633
+ throw new Error("Network error");
634
+ };
635
+ const result = await lookupObject("https://example.com/note", {
636
+ documentLoader: errorDocumentLoader,
637
+ contextLoader: mockDocumentLoader,
638
+ crossOrigin: "throw"
639
+ });
640
+ deepStrictEqual(result, null);
641
+ });
642
+ await t.step("malformed JSON handling with cross-origin policy", async () => {
643
+ const malformedJsonDocumentLoader = async (url) => {
644
+ if (url === "https://example.com/note") return {
645
+ documentUrl: url,
646
+ contextUrl: null,
647
+ document: "invalid json"
648
+ };
649
+ throw new Error(`Unexpected URL: ${url}`);
650
+ };
651
+ deepStrictEqual(await lookupObject("https://example.com/note", {
652
+ documentLoader: malformedJsonDocumentLoader,
653
+ contextLoader: mockDocumentLoader,
654
+ crossOrigin: "ignore"
655
+ }), null);
656
+ deepStrictEqual(await lookupObject("https://example.com/note", {
657
+ documentLoader: malformedJsonDocumentLoader,
658
+ contextLoader: mockDocumentLoader,
659
+ crossOrigin: "throw"
660
+ }), null);
661
+ deepStrictEqual(await lookupObject("https://example.com/note", {
662
+ documentLoader: malformedJsonDocumentLoader,
663
+ contextLoader: mockDocumentLoader,
664
+ crossOrigin: "trust"
665
+ }), null);
666
+ });
667
+ });
668
+ test("lookupObject() records OpenTelemetry span events", async () => {
669
+ const [tracerProvider, exporter] = createTestTracerProvider();
670
+ const object = await lookupObject("https://example.com/object", {
671
+ documentLoader: mockDocumentLoader,
672
+ contextLoader: mockDocumentLoader,
673
+ tracerProvider
674
+ });
675
+ assertInstanceOf(object, Object$1);
676
+ const spans = exporter.getSpans("activitypub.lookup_object");
677
+ deepStrictEqual(spans.length, 1);
678
+ const span = spans[0];
679
+ deepStrictEqual(span.attributes["activitypub.object.id"], "https://example.com/object");
680
+ const events = exporter.getEvents("activitypub.lookup_object", "activitypub.object.fetched");
681
+ deepStrictEqual(events.length, 1);
682
+ const event = events[0];
683
+ ok(event.attributes != null);
684
+ ok(typeof event.attributes["activitypub.object.type"] === "string");
685
+ ok(typeof event.attributes["activitypub.object.json"] === "string");
686
+ const recordedObject = JSON.parse(event.attributes["activitypub.object.json"]);
687
+ deepStrictEqual(recordedObject.id, "https://example.com/object");
688
+ });
689
+
690
+ //#endregion
@@ -0,0 +1,9 @@
1
+ $schema: ../../vocab-tools/schema.yaml
2
+ name: Mention
3
+ compactName: Mention
4
+ uri: "https://www.w3.org/ns/activitystreams#Mention"
5
+ extends: "https://www.w3.org/ns/activitystreams#Link"
6
+ entity: false
7
+ description: A specialized {@link Link} that represents an @mention.
8
+ defaultContext: "https://www.w3.org/ns/activitystreams"
9
+ properties: []