@fedify/vocab 2.4.0-pr.934.40 → 2.4.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 (58) hide show
  1. package/deno.json +1 -1
  2. package/dist/mod.cjs +31713 -8698
  3. package/dist/mod.d.cts +4708 -7
  4. package/dist/mod.d.ts +4708 -7
  5. package/dist/mod.js +31606 -8598
  6. package/dist-tests/actor-D7_iX4I4.mjs +276 -0
  7. package/dist-tests/actor.test.mjs +55 -49
  8. package/dist-tests/application.yaml +15 -0
  9. package/dist-tests/audio.yaml +2 -1
  10. package/dist-tests/document.yaml +14 -1
  11. package/dist-tests/{actor-CnnyTI7i.mjs → esm-DP2Spt0u.mjs} +15 -211
  12. package/dist-tests/gateway.test.mjs +1906 -0
  13. package/dist-tests/group.yaml +15 -0
  14. package/dist-tests/image.yaml +2 -1
  15. package/dist-tests/link.yaml +14 -1
  16. package/dist-tests/lookup-IiNFWuXL.mjs +480 -0
  17. package/dist-tests/lookup.test.mjs +10 -268
  18. package/dist-tests/object.yaml +15 -0
  19. package/dist-tests/organization.yaml +15 -0
  20. package/dist-tests/page.yaml +2 -1
  21. package/dist-tests/person.yaml +15 -0
  22. package/dist-tests/portable-lookup.test.mjs +653 -0
  23. package/dist-tests/service.yaml +15 -0
  24. package/dist-tests/signed-representation.test.mjs +189 -0
  25. package/dist-tests/translation.test.mjs +404 -0
  26. package/dist-tests/translation.yaml +91 -0
  27. package/dist-tests/type.test.mjs +2 -2
  28. package/dist-tests/video.yaml +2 -1
  29. package/dist-tests/{vocab-OU5GYbNg.mjs → vocab-COZcxf00.mjs} +31227 -8501
  30. package/dist-tests/vocab.test.mjs +824 -6
  31. package/package.json +5 -5
  32. package/src/__snapshots__/vocab.test.ts.snap +345 -30
  33. package/src/actor.test.ts +41 -40
  34. package/src/actor.ts +120 -1
  35. package/src/application.yaml +15 -0
  36. package/src/audio.yaml +2 -1
  37. package/src/document.yaml +14 -1
  38. package/src/gateway.test.ts +2637 -0
  39. package/src/group.yaml +15 -0
  40. package/src/image.yaml +2 -1
  41. package/src/link.yaml +14 -1
  42. package/src/lookup.test.ts +6 -5
  43. package/src/lookup.ts +460 -33
  44. package/src/mod.ts +9 -0
  45. package/src/object.yaml +15 -0
  46. package/src/organization.yaml +15 -0
  47. package/src/page.yaml +2 -1
  48. package/src/person.yaml +15 -0
  49. package/src/portable-lookup.test.ts +889 -0
  50. package/src/preprocessors.ts +4 -2
  51. package/src/service.yaml +15 -0
  52. package/src/signed-representation.test.ts +277 -0
  53. package/src/translation.test.ts +518 -0
  54. package/src/translation.yaml +91 -0
  55. package/src/video.yaml +2 -1
  56. package/src/vocab.test.ts +1155 -2
  57. /package/dist-tests/{type-_BlsfT3S.mjs → type-BMdjm4Eg.mjs} +0 -0
  58. /package/dist-tests/{utils-CQjrpUkt.mjs → utils-Df2odC27.mjs} +0 -0
@@ -0,0 +1,2637 @@
1
+ import {
2
+ createTestTracerProvider,
3
+ mockDocumentLoader,
4
+ test,
5
+ } from "@fedify/fixture";
6
+ import {
7
+ type DocumentLoader,
8
+ FetchError,
9
+ getDocumentLoader,
10
+ parseIri,
11
+ type PortableObjectVerification,
12
+ type PortableObjectVerifier,
13
+ type PortableObjectVerifierOptions,
14
+ type RemoteDocument,
15
+ withGatewayHints,
16
+ } from "@fedify/vocab-runtime";
17
+ import fetchMock from "fetch-mock";
18
+ import { deepStrictEqual, ok, rejects } from "node:assert/strict";
19
+ import { lookupObject, traverseCollection } from "./lookup.ts";
20
+ import { assertInstanceOf } from "./utils.ts";
21
+ import {
22
+ Collection,
23
+ CollectionPage,
24
+ Create,
25
+ Image,
26
+ Note,
27
+ OrderedCollection,
28
+ Person,
29
+ } from "./vocab.ts";
30
+
31
+ const did = "did:key:z6Mkabc";
32
+ const objectId = `ap+ef61://${did}/objects/1`;
33
+ const gatewayPath = `/.well-known/apgateway/${did}/objects/1`;
34
+
35
+ function note(id: string = `ap://${did}/objects/1`): Record<string, unknown> {
36
+ return {
37
+ "@context": "https://www.w3.org/ns/activitystreams",
38
+ id,
39
+ type: "Note",
40
+ content: "Portable note",
41
+ };
42
+ }
43
+
44
+ type Response = Record<string, unknown> | unknown[] | number | Error;
45
+
46
+ function createLoader(
47
+ responses: Record<string, Response>,
48
+ ): DocumentLoader & { readonly fetched: string[] } {
49
+ const fetched: string[] = [];
50
+ // deno-lint-ignore require-await
51
+ const loader = async (url: string): Promise<RemoteDocument> => {
52
+ fetched.push(url);
53
+ const response = responses[url];
54
+ if (response == null) {
55
+ throw new FetchError(
56
+ url,
57
+ "HTTP 404",
58
+ new globalThis.Response(null, {
59
+ status: 404,
60
+ }),
61
+ );
62
+ }
63
+ if (response instanceof Error) throw response;
64
+ if (typeof response === "number") {
65
+ throw new FetchError(
66
+ url,
67
+ `HTTP ${response}`,
68
+ new globalThis.Response(null, { status: response }),
69
+ );
70
+ }
71
+ return { contextUrl: null, documentUrl: url, document: response };
72
+ };
73
+ return Object.assign(loader, { fetched });
74
+ }
75
+
76
+ function createVerifier(
77
+ verified = true,
78
+ ): PortableObjectVerifier & { readonly documents: unknown[] } {
79
+ const documents: unknown[] = [];
80
+ // deno-lint-ignore require-await
81
+ const verifier = async (document: unknown) => {
82
+ documents.push(document);
83
+ return { verified };
84
+ };
85
+ return Object.assign(verifier, { documents });
86
+ }
87
+
88
+ function createActivity(object: string = objectId): Promise<Create> {
89
+ return Create.fromJsonLd({
90
+ "@context": "https://www.w3.org/ns/activitystreams",
91
+ type: "Create",
92
+ id: `ap://${did}/activities/1`,
93
+ object,
94
+ }, { contextLoader: mockDocumentLoader });
95
+ }
96
+
97
+ test("getObject() dereferences a portable IRI through explicit gateways", async () => {
98
+ const document = note();
99
+ const documentLoader = createLoader({
100
+ [`https://gw1.example${gatewayPath}`]: document,
101
+ });
102
+ const verifyPortableObject = createVerifier();
103
+ const activity = await createActivity();
104
+ const object = await activity.getObject({
105
+ documentLoader,
106
+ contextLoader: mockDocumentLoader,
107
+ gateways: ["https://gw1.example", new URL("https://gw2.example/")],
108
+ verifyPortableObject,
109
+ });
110
+ assertInstanceOf(object, Note);
111
+ deepStrictEqual(
112
+ object.id,
113
+ new URL("ap+ef61://did%3Akey%3Az6Mkabc/objects/1"),
114
+ );
115
+ deepStrictEqual(object.content, "Portable note");
116
+ deepStrictEqual(documentLoader.fetched, [
117
+ `https://gw1.example${gatewayPath}`,
118
+ ]);
119
+ // The verifier receives the fetched JSON as is:
120
+ deepStrictEqual(verifyPortableObject.documents, [document]);
121
+
122
+ // The fetched object is cached like any other dereferenced object:
123
+ deepStrictEqual(
124
+ (await activity.getObject({
125
+ documentLoader,
126
+ contextLoader: mockDocumentLoader,
127
+ }))?.content,
128
+ "Portable note",
129
+ );
130
+ deepStrictEqual(documentLoader.fetched.length, 1);
131
+ });
132
+
133
+ test("getObject() keeps non-hint query parameters for gateways", async () => {
134
+ const documentLoader = createLoader({
135
+ [`https://gw.example${gatewayPath}?page=2`]: note(
136
+ `ap://${did}/objects/1?page=2`,
137
+ ),
138
+ });
139
+ const activity = await createActivity(
140
+ `${objectId}?page=2&@gateway=https%3A%2F%2Fgw.example`,
141
+ );
142
+ const object = await activity.getObject({
143
+ documentLoader,
144
+ contextLoader: mockDocumentLoader,
145
+ verifyPortableObject: createVerifier(),
146
+ });
147
+ assertInstanceOf(object, Note);
148
+ deepStrictEqual(documentLoader.fetched, [
149
+ `https://gw.example${gatewayPath}?page=2`,
150
+ ]);
151
+ });
152
+
153
+ test("getObject() tries the next gateway after failures", async () => {
154
+ const documentLoader = createLoader({
155
+ [`https://gw2.example${gatewayPath}`]: new TypeError("Network error"),
156
+ [`https://gw3.example${gatewayPath}`]: 500,
157
+ [`https://gw4.example${gatewayPath}`]: note(),
158
+ [`https://gw5.example${gatewayPath}`]: note(),
159
+ });
160
+ const [tracerProvider, exporter] = createTestTracerProvider();
161
+ const activity = await createActivity();
162
+ const object = await activity.getObject({
163
+ documentLoader,
164
+ contextLoader: mockDocumentLoader,
165
+ tracerProvider,
166
+ gateways: [
167
+ "https://gw1.example", // 404 Not Found
168
+ "https://gw2.example", // network error
169
+ "https://gw3.example", // 500 Internal Server Error
170
+ "https://gw4.example",
171
+ "https://gw5.example",
172
+ ],
173
+ verifyPortableObject: createVerifier(),
174
+ });
175
+ assertInstanceOf(object, Note);
176
+ // Stops at the first gateway that serves a valid object:
177
+ deepStrictEqual(documentLoader.fetched, [
178
+ `https://gw1.example${gatewayPath}`,
179
+ `https://gw2.example${gatewayPath}`,
180
+ `https://gw3.example${gatewayPath}`,
181
+ `https://gw4.example${gatewayPath}`,
182
+ ]);
183
+ const spans = exporter.getSpans("activitypub.lookup_object");
184
+ deepStrictEqual(spans.length, 1);
185
+ deepStrictEqual(spans[0].status.code, 0); // UNSET
186
+ deepStrictEqual(
187
+ spans[0].attributes["activitypub.gateway"],
188
+ "https://gw4.example/",
189
+ );
190
+ deepStrictEqual(
191
+ spans[0].attributes["activitypub.object.type"],
192
+ "https://www.w3.org/ns/activitystreams#Note",
193
+ );
194
+ });
195
+
196
+ test("getObject() reports errors when every gateway fails", async () => {
197
+ const single = createLoader({ [`https://gw1.example${gatewayPath}`]: 500 });
198
+ await rejects(
199
+ async () =>
200
+ await (await createActivity()).getObject({
201
+ documentLoader: single,
202
+ contextLoader: mockDocumentLoader,
203
+ gateways: ["https://gw1.example"],
204
+ verifyPortableObject: createVerifier(),
205
+ }),
206
+ FetchError,
207
+ );
208
+
209
+ const multiple = createLoader({
210
+ [`https://gw1.example${gatewayPath}`]: 500,
211
+ [`https://gw2.example${gatewayPath}`]: new TypeError("Network error"),
212
+ });
213
+ const options = {
214
+ documentLoader: multiple,
215
+ contextLoader: mockDocumentLoader,
216
+ gateways: ["https://gw1.example", "https://gw2.example"],
217
+ verifyPortableObject: createVerifier(),
218
+ };
219
+ await rejects(
220
+ async () => await (await createActivity()).getObject(options),
221
+ (error) => {
222
+ assertInstanceOf(error, AggregateError);
223
+ deepStrictEqual(error.errors.length, 2);
224
+ assertInstanceOf(error.errors[0], FetchError);
225
+ assertInstanceOf(error.errors[1], TypeError);
226
+ return true;
227
+ },
228
+ );
229
+
230
+ const [tracerProvider, exporter] = createTestTracerProvider();
231
+ deepStrictEqual(
232
+ await (await createActivity()).getObject({
233
+ ...options,
234
+ suppressError: true,
235
+ tracerProvider,
236
+ }),
237
+ null,
238
+ );
239
+ const spans = exporter.getSpans("activitypub.lookup_object");
240
+ deepStrictEqual(spans.length, 1);
241
+ deepStrictEqual(spans[0].status.code, 2); // ERROR
242
+
243
+ // A document that cannot be parsed is an error as well:
244
+ const unparsable = createLoader({
245
+ [`https://gw1.example${gatewayPath}`]: {
246
+ ...note(),
247
+ "@context": "https://example.com/unknown-context",
248
+ },
249
+ });
250
+ await rejects(
251
+ async () =>
252
+ await (await createActivity()).getObject({
253
+ documentLoader: unparsable,
254
+ contextLoader: mockDocumentLoader,
255
+ gateways: ["https://gw1.example"],
256
+ verifyPortableObject: createVerifier(),
257
+ }),
258
+ );
259
+ });
260
+
261
+ test("getObject() rejects objects with a mismatching portable ID", async (t) => {
262
+ const mismatches = {
263
+ "another DID": `ap://did:key:z6Mkdef/objects/1`,
264
+ "another path": `ap://${did}/objects/2`,
265
+ "a dot segment": `ap://${did}/objects/x/../1`,
266
+ "a fragment": `ap://${did}/objects/1#fragment`,
267
+ "a compatible identifier of another DID":
268
+ `https://gw1.example/.well-known/apgateway/did:key:z6Mkdef/objects/1`,
269
+ "a compatible identifier of another path":
270
+ `https://gw1.example/.well-known/apgateway/${did}/objects/2`,
271
+ "an HTTP(S) ID": "https://gw1.example/objects/1",
272
+ };
273
+ for (const [name, id] of globalThis.Object.entries(mismatches)) {
274
+ await t.step(name, async () => {
275
+ const documentLoader = createLoader({
276
+ [`https://gw1.example${gatewayPath}`]: note(id),
277
+ });
278
+ const options = {
279
+ documentLoader,
280
+ contextLoader: mockDocumentLoader,
281
+ gateways: ["https://gw1.example"],
282
+ verifyPortableObject: createVerifier(),
283
+ };
284
+ deepStrictEqual(await (await createActivity()).getObject(options), null);
285
+ deepStrictEqual(
286
+ await (await createActivity()).getObject({
287
+ ...options,
288
+ crossOrigin: "trust",
289
+ }),
290
+ null,
291
+ );
292
+ deepStrictEqual(
293
+ await (await createActivity()).getObject({
294
+ ...options,
295
+ crossOrigin: "throw",
296
+ suppressError: true,
297
+ }),
298
+ null,
299
+ );
300
+ await rejects(
301
+ async () =>
302
+ await (await createActivity()).getObject({
303
+ ...options,
304
+ crossOrigin: "throw",
305
+ }),
306
+ /No gateway returned a valid portable object/,
307
+ );
308
+ });
309
+ }
310
+
311
+ await t.step("equivalent spellings", async () => {
312
+ for (
313
+ const id of [
314
+ `ap+ef61://${did}/objects/1`,
315
+ `ap://did%3Akey%3Az6Mkabc/objects/1`,
316
+ `ap://${did}/objects/1?@gateway=https%3A%2F%2Fother.example`,
317
+ ]
318
+ ) {
319
+ const documentLoader = createLoader({
320
+ [`https://gw1.example${gatewayPath}`]: note(id),
321
+ });
322
+ const object = await (await createActivity()).getObject({
323
+ documentLoader,
324
+ contextLoader: mockDocumentLoader,
325
+ gateways: ["https://gw1.example"],
326
+ verifyPortableObject: createVerifier(),
327
+ });
328
+ assertInstanceOf(object, Note);
329
+ }
330
+ });
331
+
332
+ await t.step("a mismatch does not stop other gateways", async () => {
333
+ const documentLoader = createLoader({
334
+ [`https://gw1.example${gatewayPath}`]: note(`ap://${did}/objects/2`),
335
+ [`https://gw2.example${gatewayPath}`]: 500,
336
+ [`https://gw3.example${gatewayPath}`]: note(),
337
+ });
338
+ const object = await (await createActivity()).getObject({
339
+ documentLoader,
340
+ contextLoader: mockDocumentLoader,
341
+ gateways: [
342
+ "https://gw1.example",
343
+ "https://gw2.example",
344
+ "https://gw3.example",
345
+ ],
346
+ verifyPortableObject: createVerifier(),
347
+ crossOrigin: "throw",
348
+ });
349
+ assertInstanceOf(object, Note);
350
+ });
351
+
352
+ await t.step(
353
+ "a mismatch among errors is reported as a mismatch",
354
+ async () => {
355
+ const documentLoader = createLoader({
356
+ [`https://gw1.example${gatewayPath}`]: 500,
357
+ [`https://gw2.example${gatewayPath}`]: note(`ap://${did}/objects/2`),
358
+ });
359
+ deepStrictEqual(
360
+ await (await createActivity()).getObject({
361
+ documentLoader,
362
+ contextLoader: mockDocumentLoader,
363
+ gateways: ["https://gw1.example", "https://gw2.example"],
364
+ verifyPortableObject: createVerifier(),
365
+ }),
366
+ null,
367
+ );
368
+ },
369
+ );
370
+ });
371
+
372
+ test("getObject() rejects documents with other shapes", async () => {
373
+ for (
374
+ const document of [
375
+ [note()],
376
+ {
377
+ "@context": "https://www.w3.org/ns/activitystreams",
378
+ "@graph": [note(), note(`ap://${did}/objects/2`)],
379
+ },
380
+ ]
381
+ ) {
382
+ const documentLoader = createLoader({
383
+ [`https://gw1.example${gatewayPath}`]: document,
384
+ });
385
+ deepStrictEqual(
386
+ await (await createActivity()).getObject({
387
+ documentLoader,
388
+ contextLoader: mockDocumentLoader,
389
+ gateways: ["https://gw1.example"],
390
+ verifyPortableObject: createVerifier(),
391
+ }),
392
+ null,
393
+ );
394
+ }
395
+ });
396
+
397
+ test("getObject() leaves documents without @id to the verifier", async () => {
398
+ const document = {
399
+ "@context": "https://www.w3.org/ns/activitystreams",
400
+ type: "Note",
401
+ content: "No ID",
402
+ };
403
+ const documentLoader = createLoader({
404
+ [`https://gw1.example${gatewayPath}`]: document,
405
+ });
406
+ const options = {
407
+ documentLoader,
408
+ contextLoader: mockDocumentLoader,
409
+ gateways: ["https://gw1.example"],
410
+ };
411
+ const rejecting = createVerifier(false);
412
+ deepStrictEqual(
413
+ await (await createActivity()).getObject({
414
+ ...options,
415
+ verifyPortableObject: rejecting,
416
+ }),
417
+ null,
418
+ );
419
+ deepStrictEqual(rejecting.documents, [document]);
420
+ const object = await (await createActivity()).getObject({
421
+ ...options,
422
+ verifyPortableObject: createVerifier(),
423
+ });
424
+ assertInstanceOf(object, Note);
425
+ deepStrictEqual(object.id, null);
426
+ });
427
+
428
+ test("getObject() applies the portable object verifier", async () => {
429
+ const gateways = ["https://gw1.example", "https://gw2.example"];
430
+ const documentLoader = createLoader({
431
+ [`https://gw1.example${gatewayPath}`]: note(),
432
+ [`https://gw2.example${gatewayPath}`]: note(),
433
+ });
434
+ const failing = createVerifier(false);
435
+ deepStrictEqual(
436
+ await (await createActivity()).getObject({
437
+ documentLoader,
438
+ contextLoader: mockDocumentLoader,
439
+ gateways,
440
+ verifyPortableObject: failing,
441
+ crossOrigin: "trust",
442
+ }),
443
+ null,
444
+ );
445
+ deepStrictEqual(failing.documents.length, 2);
446
+ await rejects(
447
+ async () =>
448
+ await (await createActivity()).getObject({
449
+ documentLoader,
450
+ contextLoader: mockDocumentLoader,
451
+ gateways,
452
+ verifyPortableObject: failing,
453
+ crossOrigin: "throw",
454
+ }),
455
+ /No gateway returned a valid portable object/,
456
+ );
457
+
458
+ deepStrictEqual(
459
+ await (await createActivity()).getObject({
460
+ documentLoader,
461
+ contextLoader: mockDocumentLoader,
462
+ gateways,
463
+ // deno-lint-ignore require-await
464
+ verifyPortableObject: async () => {
465
+ throw new Error("Verifier failure");
466
+ },
467
+ }),
468
+ null,
469
+ );
470
+
471
+ // A failed dereference is not cached; a later call retries:
472
+ const activity = await createActivity();
473
+ deepStrictEqual(
474
+ await activity.getObject({
475
+ documentLoader,
476
+ contextLoader: mockDocumentLoader,
477
+ gateways,
478
+ verifyPortableObject: failing,
479
+ }),
480
+ null,
481
+ );
482
+ assertInstanceOf(
483
+ await activity.getObject({
484
+ documentLoader,
485
+ contextLoader: mockDocumentLoader,
486
+ gateways,
487
+ verifyPortableObject: createVerifier(),
488
+ }),
489
+ Note,
490
+ );
491
+ });
492
+
493
+ test("getObject() requires a verifier before fetching portable IRIs", async () => {
494
+ const documentLoader = createLoader({
495
+ [`https://gw1.example${gatewayPath}`]: note(),
496
+ });
497
+ await rejects(
498
+ async () =>
499
+ await (await createActivity()).getObject({
500
+ documentLoader,
501
+ contextLoader: mockDocumentLoader,
502
+ gateways: ["https://gw1.example"],
503
+ }),
504
+ (error) => {
505
+ assertInstanceOf(error, TypeError);
506
+ ok(error.message.includes("verifyPortableObject"));
507
+ return true;
508
+ },
509
+ );
510
+ deepStrictEqual(
511
+ await (await createActivity()).getObject({
512
+ documentLoader,
513
+ contextLoader: mockDocumentLoader,
514
+ gateways: ["https://gw1.example"],
515
+ suppressError: true,
516
+ }),
517
+ null,
518
+ );
519
+ deepStrictEqual(documentLoader.fetched, []);
520
+ });
521
+
522
+ test("getObject() suppresses errors for malformed portable IRIs", async () => {
523
+ const activity = await createActivity(`ap://${did}/objects/%ZZ`);
524
+ for (const gateways of [[], ["https://gw1.example"]]) {
525
+ const documentLoader = createLoader({});
526
+ deepStrictEqual(
527
+ await activity.getObject({
528
+ documentLoader,
529
+ contextLoader: mockDocumentLoader,
530
+ gateways,
531
+ verifyPortableObject: createVerifier(),
532
+ suppressError: true,
533
+ }),
534
+ null,
535
+ );
536
+ deepStrictEqual(documentLoader.fetched, []);
537
+ await rejects(async () =>
538
+ await activity.getObject({
539
+ documentLoader,
540
+ contextLoader: mockDocumentLoader,
541
+ gateways,
542
+ verifyPortableObject: createVerifier(),
543
+ }), TypeError);
544
+ }
545
+ });
546
+
547
+ test("getObject() rejects invalid gateways before fetching", async () => {
548
+ const documentLoader = createLoader({});
549
+ for (
550
+ const gateway of [
551
+ "https://gw.example/path",
552
+ "https://gw.example/?query",
553
+ "https://user@gw.example",
554
+ "ftp://gw.example",
555
+ "not a URL",
556
+ ]
557
+ ) {
558
+ await rejects(
559
+ async () =>
560
+ await (await createActivity()).getObject({
561
+ documentLoader,
562
+ contextLoader: mockDocumentLoader,
563
+ gateways: [gateway],
564
+ verifyPortableObject: createVerifier(),
565
+ suppressError: true,
566
+ }),
567
+ TypeError,
568
+ );
569
+ }
570
+ deepStrictEqual(documentLoader.fetched, []);
571
+ });
572
+
573
+ test("getObject() uses @gateway location hints", async (t) => {
574
+ await t.step("in order, skipping invalid and duplicate hints", async () => {
575
+ const documentLoader = createLoader({
576
+ [`https://gw2.example${gatewayPath}`]: note(),
577
+ });
578
+ const activity = await createActivity(
579
+ `${objectId}?@gateway=https%3A%2F%2Fgw1.example` +
580
+ "&@gateway=https%3A%2F%2Fgw1.example%2F" +
581
+ "&@gateway=https%3A%2F%2Finvalid.example%2Fpath" +
582
+ "&%40gateway=https%3A%2F%2Fgw2.example" +
583
+ "&gateways=https%3A%2F%2Flegacy.example",
584
+ );
585
+ const object = await activity.getObject({
586
+ documentLoader,
587
+ contextLoader: mockDocumentLoader,
588
+ verifyPortableObject: createVerifier(),
589
+ });
590
+ assertInstanceOf(object, Note);
591
+ deepStrictEqual(documentLoader.fetched, [
592
+ `https://gw1.example${gatewayPath}`,
593
+ `https://gw2.example${gatewayPath}`,
594
+ ]);
595
+ });
596
+
597
+ await t.step("up to five hints", async () => {
598
+ const documentLoader = createLoader({});
599
+ const hints = [1, 2, 3, 4, 5, 6, 7].map((i) =>
600
+ `@gateway=https%3A%2F%2Fgw${i}.example`
601
+ );
602
+ const activity = await createActivity(`${objectId}?${hints.join("&")}`);
603
+ await rejects(async () =>
604
+ await activity.getObject({
605
+ documentLoader,
606
+ contextLoader: mockDocumentLoader,
607
+ verifyPortableObject: createVerifier(),
608
+ })
609
+ );
610
+ deepStrictEqual(
611
+ documentLoader.fetched,
612
+ [1, 2, 3, 4, 5].map((i) => `https://gw${i}.example${gatewayPath}`),
613
+ );
614
+ });
615
+
616
+ await t.step("replaced by explicit gateways", async () => {
617
+ const documentLoader = createLoader({
618
+ [`https://explicit.example${gatewayPath}`]: note(),
619
+ });
620
+ const activity = await createActivity(
621
+ `${objectId}?@gateway=https%3A%2F%2Fhint.example`,
622
+ );
623
+ const object = await activity.getObject({
624
+ documentLoader,
625
+ contextLoader: mockDocumentLoader,
626
+ gateways: ["https://explicit.example"],
627
+ verifyPortableObject: createVerifier(),
628
+ });
629
+ assertInstanceOf(object, Note);
630
+ deepStrictEqual(documentLoader.fetched, [
631
+ `https://explicit.example${gatewayPath}`,
632
+ ]);
633
+ });
634
+
635
+ await t.step("disabled by an empty gateway list", async () => {
636
+ const documentLoader = createLoader({});
637
+ const activity = await createActivity(
638
+ `${objectId}?@gateway=https%3A%2F%2Fhint.example`,
639
+ );
640
+ deepStrictEqual(
641
+ await activity.getObject({
642
+ documentLoader,
643
+ contextLoader: mockDocumentLoader,
644
+ gateways: [],
645
+ verifyPortableObject: createVerifier(),
646
+ suppressError: true,
647
+ }),
648
+ null,
649
+ );
650
+ deepStrictEqual(documentLoader.fetched, [
651
+ `ap+ef61://${did}/objects/1?@gateway=https%3A%2F%2Fhint.example`,
652
+ ]);
653
+ });
654
+ });
655
+
656
+ test("getObject() lets the document loader handle portable IRIs without gateways", async () => {
657
+ const documentLoader = createLoader({ [objectId]: note() });
658
+ const activity = await createActivity();
659
+ const object = await activity.getObject({
660
+ documentLoader,
661
+ contextLoader: mockDocumentLoader,
662
+ verifyPortableObject: createVerifier(),
663
+ });
664
+ assertInstanceOf(object, Note);
665
+ deepStrictEqual(documentLoader.fetched, [objectId]);
666
+
667
+ // The same validation applies:
668
+ const spoofing = createLoader({ [objectId]: note(`ap://${did}/objects/2`) });
669
+ deepStrictEqual(
670
+ await (await createActivity()).getObject({
671
+ documentLoader: spoofing,
672
+ contextLoader: mockDocumentLoader,
673
+ verifyPortableObject: createVerifier(),
674
+ }),
675
+ null,
676
+ );
677
+ deepStrictEqual(
678
+ await (await createActivity()).getObject({
679
+ documentLoader,
680
+ contextLoader: mockDocumentLoader,
681
+ verifyPortableObject: createVerifier(false),
682
+ }),
683
+ null,
684
+ );
685
+ });
686
+
687
+ test("getObject() shares context documents within a dereference", async (t) => {
688
+ const contextUrl = "https://example.com/portable-context";
689
+ const document = {
690
+ "@context": ["https://www.w3.org/ns/activitystreams", contextUrl],
691
+ id: `ap://${did}/objects/1`,
692
+ type: "Note",
693
+ content: "Portable note",
694
+ };
695
+
696
+ await t.step("custom contexts are loaded once", async () => {
697
+ const loaded: string[] = [];
698
+ let version = 0;
699
+ const contextLoader: DocumentLoader = async (url) => {
700
+ loaded.push(url);
701
+ if (url === contextUrl) {
702
+ version++;
703
+ return {
704
+ contextUrl: null,
705
+ documentUrl: url,
706
+ document: { "@context": { version: `urn:version:${version}` } },
707
+ };
708
+ }
709
+ return await mockDocumentLoader(url);
710
+ };
711
+ const contexts: unknown[] = [];
712
+ const verifyPortableObject: PortableObjectVerifier = async (
713
+ _,
714
+ { contextLoader },
715
+ ) => {
716
+ contexts.push((await contextLoader!(contextUrl)).document);
717
+ contexts.push((await contextLoader!(contextUrl)).document);
718
+ return { verified: true };
719
+ };
720
+ const object = await (await createActivity()).getObject({
721
+ documentLoader: createLoader({
722
+ [`https://gw1.example${gatewayPath}`]: document,
723
+ }),
724
+ contextLoader,
725
+ gateways: ["https://gw1.example"],
726
+ verifyPortableObject,
727
+ });
728
+ assertInstanceOf(object, Note);
729
+ deepStrictEqual(loaded.filter((url) => url === contextUrl).length, 1);
730
+ deepStrictEqual(contexts, [
731
+ { "@context": { version: "urn:version:1" } },
732
+ { "@context": { version: "urn:version:1" } },
733
+ ]);
734
+ });
735
+
736
+ await t.step("baseline contexts use built-in copies", async () => {
737
+ const loaded: string[] = [];
738
+ const contextLoader: DocumentLoader = async (url) => {
739
+ loaded.push(url);
740
+ return await mockDocumentLoader(url);
741
+ };
742
+ const object = await (await createActivity()).getObject({
743
+ documentLoader: createLoader({
744
+ [`https://gw1.example${gatewayPath}`]: note(),
745
+ }),
746
+ contextLoader,
747
+ gateways: ["https://gw1.example"],
748
+ verifyPortableObject: createVerifier(),
749
+ });
750
+ assertInstanceOf(object, Note);
751
+ deepStrictEqual(loaded, []);
752
+ });
753
+
754
+ await t.step("the snapshot ends with the dereference", async () => {
755
+ const loaded: string[] = [];
756
+ const contextLoader: DocumentLoader = async (url) => {
757
+ loaded.push(url);
758
+ if (url === contextUrl) {
759
+ return {
760
+ contextUrl: null,
761
+ documentUrl: url,
762
+ document: { "@context": {} },
763
+ };
764
+ }
765
+ return await mockDocumentLoader(url);
766
+ };
767
+ const object = await (await createActivity()).getObject({
768
+ documentLoader: createLoader({
769
+ [`https://gw1.example${gatewayPath}`]: {
770
+ ...document,
771
+ attributedTo: "https://example.com/person",
772
+ },
773
+ }),
774
+ contextLoader,
775
+ gateways: ["https://gw1.example"],
776
+ verifyPortableObject: createVerifier(),
777
+ });
778
+ assertInstanceOf(object, Note);
779
+ deepStrictEqual(loaded, [contextUrl]);
780
+ // Later dereferences on the returned object do not reuse the snapshot,
781
+ // so they load the custom context through the caller's loader again:
782
+ const person = await object.getAttribution({
783
+ documentLoader: createLoader({
784
+ "https://example.com/person": {
785
+ "@context": ["https://www.w3.org/ns/activitystreams", contextUrl],
786
+ id: "https://example.com/person",
787
+ type: "Person",
788
+ },
789
+ }),
790
+ });
791
+ deepStrictEqual(person?.id, new URL("https://example.com/person"));
792
+ deepStrictEqual(loaded, [contextUrl, contextUrl]);
793
+ });
794
+
795
+ await t.step("failed context loads are retried", async () => {
796
+ let failures = 1;
797
+ const contextLoader: DocumentLoader = async (url) => {
798
+ if (url === contextUrl && failures-- > 0) {
799
+ throw new TypeError("Temporary failure");
800
+ }
801
+ if (url === contextUrl) {
802
+ return {
803
+ contextUrl: null,
804
+ documentUrl: url,
805
+ document: { "@context": {} },
806
+ };
807
+ }
808
+ return await mockDocumentLoader(url);
809
+ };
810
+ const object = await (await createActivity()).getObject({
811
+ documentLoader: createLoader({
812
+ [`https://gw1.example${gatewayPath}`]: document,
813
+ [`https://gw2.example${gatewayPath}`]: document,
814
+ }),
815
+ contextLoader,
816
+ gateways: ["https://gw1.example", "https://gw2.example"],
817
+ verifyPortableObject: createVerifier(),
818
+ });
819
+ assertInstanceOf(object, Note);
820
+ });
821
+ });
822
+
823
+ test("getObjects() dereferences each portable IRI separately", async () => {
824
+ const activity = await Create.fromJsonLd({
825
+ "@context": "https://www.w3.org/ns/activitystreams",
826
+ type: "Create",
827
+ id: `ap://${did}/activities/1`,
828
+ object: [objectId, `ap://${did}/objects/2`],
829
+ }, { contextLoader: mockDocumentLoader });
830
+ const documentLoader = createLoader({
831
+ [`https://gw.example${gatewayPath}`]: note(),
832
+ [`https://gw.example/.well-known/apgateway/${did}/objects/2`]: note(),
833
+ });
834
+ const objects = await Array.fromAsync(activity.getObjects({
835
+ documentLoader,
836
+ contextLoader: mockDocumentLoader,
837
+ gateways: ["https://gw.example"],
838
+ verifyPortableObject: createVerifier(),
839
+ }));
840
+ // The second one is rejected because its @id does not match:
841
+ deepStrictEqual(objects.length, 1);
842
+ deepStrictEqual(
843
+ objects[0].id?.href,
844
+ "ap+ef61://did%3Akey%3Az6Mkabc/objects/1",
845
+ );
846
+ });
847
+
848
+ test("getObject() ignores gateway options for HTTP(S) IRIs", async () => {
849
+ const activity = new Create({
850
+ object: new URL("https://example.com/object"),
851
+ });
852
+ const object = await activity.getObject({
853
+ documentLoader: mockDocumentLoader,
854
+ contextLoader: mockDocumentLoader,
855
+ gateways: ["https://gw.example"],
856
+ });
857
+ deepStrictEqual(object?.id, new URL("https://example.com/object"));
858
+ });
859
+
860
+ test("getObject() sends the ActivityStreams Accept header to gateways", {
861
+ sanitizeResources: false,
862
+ sanitizeOps: false,
863
+ }, async () => {
864
+ fetchMock.spyGlobal();
865
+ let accept: string | null = null;
866
+ fetchMock.get(`https://example.com${gatewayPath}`, (callLog) => {
867
+ accept = callLog.request?.headers.get("Accept") ??
868
+ new Headers(callLog.options.headers).get("Accept");
869
+ return {
870
+ headers: {
871
+ "Content-Type":
872
+ 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
873
+ },
874
+ body: note(),
875
+ };
876
+ });
877
+ try {
878
+ const object = await (await createActivity()).getObject({
879
+ documentLoader: getDocumentLoader(),
880
+ contextLoader: mockDocumentLoader,
881
+ gateways: ["https://example.com"],
882
+ verifyPortableObject: createVerifier(),
883
+ });
884
+ assertInstanceOf(object, Note);
885
+ ok(
886
+ accept != null &&
887
+ (accept as string).includes(
888
+ 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
889
+ ),
890
+ `Unexpected Accept header: ${accept}`,
891
+ );
892
+ } finally {
893
+ fetchMock.hardReset();
894
+ }
895
+ });
896
+
897
+ const AS = "https://www.w3.org/ns/activitystreams#";
898
+
899
+ function gatewayUrl(gateway: string, path: string): string {
900
+ return `${gateway}/.well-known/apgateway/${did}${path}`;
901
+ }
902
+
903
+ function createRecordingVerifier(
904
+ decide: (
905
+ document: unknown,
906
+ options: PortableObjectVerifierOptions,
907
+ ) => PortableObjectVerification = () => ({ verified: true }),
908
+ ): PortableObjectVerifier & {
909
+ readonly calls: {
910
+ document: unknown;
911
+ options: PortableObjectVerifierOptions;
912
+ }[];
913
+ } {
914
+ const calls: {
915
+ document: unknown;
916
+ options: PortableObjectVerifierOptions;
917
+ }[] = [];
918
+ // deno-lint-ignore require-await
919
+ const verifier = async (
920
+ document: unknown,
921
+ options: PortableObjectVerifierOptions,
922
+ ) => {
923
+ calls.push({ document, options });
924
+ return decide(document, options);
925
+ };
926
+ return Object.assign(verifier, { calls });
927
+ }
928
+
929
+ function portableCollection(
930
+ path: string,
931
+ extra: Record<string, unknown> = {},
932
+ ): Record<string, unknown> {
933
+ return {
934
+ "@context": "https://www.w3.org/ns/activitystreams",
935
+ id: `ap://${did}${path}`,
936
+ type: "OrderedCollection",
937
+ totalItems: 0,
938
+ ...extra,
939
+ };
940
+ }
941
+
942
+ test("getOutbox() passes the fetch source and referrer to the verifier", async () => {
943
+ const person = await Person.fromJsonLd({
944
+ "@context": "https://www.w3.org/ns/activitystreams",
945
+ id: `ap://${did}/actor`,
946
+ type: "Person",
947
+ inbox: `ap://${did}/actor/inbox`,
948
+ outbox: `ap://${did}/actor/outbox?@gateway=${
949
+ encodeURIComponent("https://hint.example")
950
+ }`,
951
+ }, { contextLoader: mockDocumentLoader });
952
+ const documentLoader = createLoader({
953
+ [gatewayUrl("https://hint.example", "/actor/outbox")]: portableCollection(
954
+ "/actor/outbox",
955
+ ),
956
+ });
957
+ const verifier = createRecordingVerifier();
958
+ const outbox = await person.getOutbox({
959
+ documentLoader,
960
+ contextLoader: mockDocumentLoader,
961
+ verifyPortableObject: verifier,
962
+ });
963
+ assertInstanceOf(outbox, Collection);
964
+ deepStrictEqual(verifier.calls.length, 1);
965
+ const { options } = verifier.calls[0];
966
+ deepStrictEqual(
967
+ options.documentUrl,
968
+ new URL(gatewayUrl("https://hint.example", "/actor/outbox")),
969
+ );
970
+ deepStrictEqual(options.gateways, undefined);
971
+ deepStrictEqual(options.gatewayHints, [new URL("https://hint.example")]);
972
+ ok(options.referrer?.object === person);
973
+ deepStrictEqual(options.referrer?.property, `${AS}outbox`);
974
+ deepStrictEqual(options.referrer?.acceptance, undefined);
975
+
976
+ // Explicit gateways are passed as such:
977
+ const person2 = await Person.fromJsonLd({
978
+ "@context": "https://www.w3.org/ns/activitystreams",
979
+ id: `ap://${did}/actor`,
980
+ type: "Person",
981
+ inbox: `ap://${did}/actor/inbox`,
982
+ outbox: `ap://${did}/actor/outbox`,
983
+ }, { contextLoader: mockDocumentLoader });
984
+ const verifier2 = createRecordingVerifier();
985
+ await person2.getOutbox({
986
+ documentLoader: createLoader({
987
+ [gatewayUrl("https://gw.example", "/actor/outbox")]: portableCollection(
988
+ "/actor/outbox",
989
+ ),
990
+ }),
991
+ contextLoader: mockDocumentLoader,
992
+ gateways: ["https://gw.example"],
993
+ verifyPortableObject: verifier2,
994
+ });
995
+ deepStrictEqual(verifier2.calls[0].options.gateways, [
996
+ new URL("https://gw.example"),
997
+ ]);
998
+ deepStrictEqual(verifier2.calls[0].options.gatewayHints, undefined);
999
+ });
1000
+
1001
+ test("getFirst() passes the referrer chain and collection context", async () => {
1002
+ const context = Object.freeze({});
1003
+ const documentLoader = createLoader({
1004
+ [gatewayUrl("https://gw.example", "/actor")]: {
1005
+ "@context": "https://www.w3.org/ns/activitystreams",
1006
+ id: `ap://${did}/actor`,
1007
+ type: "Person",
1008
+ inbox: `ap://${did}/actor/inbox`,
1009
+ outbox: `ap://${did}/actor/outbox`,
1010
+ },
1011
+ [gatewayUrl("https://gw.example", "/actor/outbox")]: portableCollection(
1012
+ "/actor/outbox",
1013
+ {
1014
+ first: {
1015
+ id: `ap://${did}/actor/outbox?page=1`,
1016
+ type: "OrderedCollectionPage",
1017
+ next: `ap://${did}/actor/outbox?page=2`,
1018
+ },
1019
+ },
1020
+ ),
1021
+ [gatewayUrl("https://gw.example", "/actor/outbox?page=2")]: {
1022
+ "@context": "https://www.w3.org/ns/activitystreams",
1023
+ id: `ap://${did}/actor/outbox?page=2`,
1024
+ type: "OrderedCollectionPage",
1025
+ orderedItems: [],
1026
+ },
1027
+ });
1028
+ // The collection is signed in this scenario (verified without the
1029
+ // unsecured flag), so its embedded first page is trusted by origin:
1030
+ const verifier = createRecordingVerifier((document) =>
1031
+ (document as Record<string, unknown>).type === "OrderedCollection"
1032
+ ? { verified: true, collectionContext: context }
1033
+ : { verified: true }
1034
+ );
1035
+ const options = {
1036
+ documentLoader,
1037
+ contextLoader: mockDocumentLoader,
1038
+ gateways: ["https://gw.example"],
1039
+ verifyPortableObject: verifier,
1040
+ };
1041
+ const create = await createActivity(`ap://${did}/actor`);
1042
+ const actor = await create.getObject(options);
1043
+ assertInstanceOf(actor, Person);
1044
+ const outbox = await actor.getOutbox(options);
1045
+ assertInstanceOf(outbox, Collection);
1046
+ const first = await outbox.getFirst(options);
1047
+ assertInstanceOf(first, CollectionPage);
1048
+ const next = await first.getNext(options);
1049
+ assertInstanceOf(next, CollectionPage);
1050
+ const { referrer } = verifier.calls[verifier.calls.length - 1].options;
1051
+ // next ← (embedded) first ← outbox ← actor ← create:
1052
+ ok(referrer?.object === first);
1053
+ deepStrictEqual(referrer?.property, `${AS}next`);
1054
+ deepStrictEqual(referrer?.acceptance, undefined);
1055
+ ok(referrer?.referrer?.object === outbox);
1056
+ deepStrictEqual(referrer?.referrer?.property, `${AS}first`);
1057
+ deepStrictEqual(referrer?.referrer?.acceptance, "verified");
1058
+ ok(referrer?.referrer?.collectionContext === context);
1059
+ ok(referrer?.referrer?.referrer?.object === actor);
1060
+ deepStrictEqual(referrer?.referrer?.referrer?.property, `${AS}outbox`);
1061
+ deepStrictEqual(referrer?.referrer?.referrer?.acceptance, "verified");
1062
+ ok(referrer?.referrer?.referrer?.referrer?.object === create);
1063
+ });
1064
+
1065
+ test("accessors do not trust objects embedded in unsecured collections", async () => {
1066
+ const itemPath = "/objects/1";
1067
+ const documentLoader = createLoader({
1068
+ [gatewayUrl("https://gw.example", "/actor/outbox")]: portableCollection(
1069
+ "/actor/outbox",
1070
+ {
1071
+ orderedItems: [
1072
+ // A forged copy of a portable object:
1073
+ { id: `ap://${did}${itemPath}`, type: "Note", content: "Forged" },
1074
+ // An object that cannot be verified on its own:
1075
+ { type: "Note", content: "Anonymous" },
1076
+ ],
1077
+ },
1078
+ ),
1079
+ [gatewayUrl("https://gw.example", itemPath)]: note(),
1080
+ });
1081
+ const verifier = createRecordingVerifier((document) =>
1082
+ (document as Record<string, unknown>).type === "OrderedCollection"
1083
+ ? { verified: true, unsecured: true }
1084
+ : { verified: true }
1085
+ );
1086
+ const options = {
1087
+ documentLoader,
1088
+ contextLoader: mockDocumentLoader,
1089
+ gateways: ["https://gw.example"],
1090
+ verifyPortableObject: verifier,
1091
+ };
1092
+ const createPerson = () =>
1093
+ Person.fromJsonLd({
1094
+ "@context": "https://www.w3.org/ns/activitystreams",
1095
+ id: `ap://${did}/actor`,
1096
+ type: "Person",
1097
+ inbox: `ap://${did}/actor/inbox`,
1098
+ outbox: `ap://${did}/actor/outbox`,
1099
+ }, { contextLoader: mockDocumentLoader });
1100
+ const outbox = await (await createPerson()).getOutbox(options);
1101
+ assertInstanceOf(outbox, Collection);
1102
+ const items = await Array.fromAsync(outbox.getItems(options));
1103
+ deepStrictEqual(
1104
+ items.map((item) => item instanceof Note ? item.content : null),
1105
+ ["Portable note"],
1106
+ );
1107
+ // Even crossOrigin: "trust" does not make them trusted:
1108
+ const outbox2 = await (await createPerson()).getOutbox(options);
1109
+ assertInstanceOf(outbox2, Collection);
1110
+ const items2 = await Array.fromAsync(
1111
+ outbox2.getItems({ ...options, crossOrigin: "trust" }),
1112
+ );
1113
+ deepStrictEqual(items2.length, 1);
1114
+
1115
+ // A clone keeps the restriction, so its embedded objects are fetched
1116
+ // again (and fail here, since nothing can be fetched):
1117
+ const outbox3 = await (await createPerson()).getOutbox(options);
1118
+ assertInstanceOf(outbox3, Collection);
1119
+ const items3 = await Array.fromAsync(
1120
+ outbox3.clone().getItems({
1121
+ ...options,
1122
+ documentLoader: createLoader({}),
1123
+ suppressError: true,
1124
+ }),
1125
+ );
1126
+ deepStrictEqual(items3, []);
1127
+ });
1128
+
1129
+ function createRedirectingLoader(
1130
+ responses: Record<string, { documentUrl: string; document: unknown }>,
1131
+ ): DocumentLoader & { readonly fetched: string[] } {
1132
+ const fetched: string[] = [];
1133
+ // deno-lint-ignore require-await
1134
+ const loader = async (url: string): Promise<RemoteDocument> => {
1135
+ fetched.push(url);
1136
+ const response = responses[url];
1137
+ if (response == null) {
1138
+ throw new FetchError(
1139
+ url,
1140
+ "HTTP 404",
1141
+ new globalThis.Response(null, { status: 404 }),
1142
+ );
1143
+ }
1144
+ return { contextUrl: null, ...response };
1145
+ };
1146
+ return Object.assign(loader, { fetched });
1147
+ }
1148
+
1149
+ function createHttpActivity(object: unknown): Promise<Create> {
1150
+ return Create.fromJsonLd({
1151
+ "@context": "https://www.w3.org/ns/activitystreams",
1152
+ type: "Create",
1153
+ id: "https://example.com/activities/1",
1154
+ object,
1155
+ }, { contextLoader: mockDocumentLoader });
1156
+ }
1157
+
1158
+ const compatibleNoteId = gatewayUrl("https://gw.example", "/objects/1");
1159
+
1160
+ test("accessors dereference compatible identifiers through their gateways", async (t) => {
1161
+ await t.step("through the named gateway", async () => {
1162
+ const documentLoader = createLoader({ [compatibleNoteId]: note() });
1163
+ const verifier = createRecordingVerifier();
1164
+ const activity = await createHttpActivity(compatibleNoteId);
1165
+ const object = await activity.getObject({
1166
+ documentLoader,
1167
+ contextLoader: mockDocumentLoader,
1168
+ verifyPortableObject: verifier,
1169
+ });
1170
+ assertInstanceOf(object, Note);
1171
+ deepStrictEqual(object.id, parseIri(objectId));
1172
+ deepStrictEqual(documentLoader.fetched, [compatibleNoteId]);
1173
+ deepStrictEqual(verifier.calls.length, 1);
1174
+ const { options } = verifier.calls[0];
1175
+ deepStrictEqual(options.documentUrl, new URL(compatibleNoteId));
1176
+ deepStrictEqual(options.gateways, undefined);
1177
+ deepStrictEqual(options.gatewayHints, [new URL("https://gw.example")]);
1178
+ ok(options.referrer?.object === activity);
1179
+ // The verified object is cached:
1180
+ ok(
1181
+ await activity.getObject({ documentLoader: createLoader({}) }) === object,
1182
+ );
1183
+ });
1184
+
1185
+ await t.step("then through explicit gateways", async () => {
1186
+ const documentLoader = createLoader({
1187
+ [gatewayUrl("https://gw2.example", "/objects/1")]: note(),
1188
+ });
1189
+ const verifier = createRecordingVerifier();
1190
+ const object = await (await createHttpActivity(compatibleNoteId))
1191
+ .getObject({
1192
+ documentLoader,
1193
+ contextLoader: mockDocumentLoader,
1194
+ gateways: ["https://gw2.example", "https://gw.example"],
1195
+ verifyPortableObject: verifier,
1196
+ });
1197
+ assertInstanceOf(object, Note);
1198
+ deepStrictEqual(documentLoader.fetched, [
1199
+ compatibleNoteId,
1200
+ gatewayUrl("https://gw2.example", "/objects/1"),
1201
+ ]);
1202
+ deepStrictEqual(verifier.calls[0].options.gateways, [
1203
+ new URL("https://gw2.example"),
1204
+ new URL("https://gw.example"),
1205
+ ]);
1206
+ deepStrictEqual(verifier.calls[0].options.gatewayHints, undefined);
1207
+
1208
+ // An empty list still asks the gateway that the identifier names:
1209
+ const documentLoader2 = createLoader({ [compatibleNoteId]: note() });
1210
+ const object2 = await (await createHttpActivity(compatibleNoteId))
1211
+ .getObject({
1212
+ documentLoader: documentLoader2,
1213
+ contextLoader: mockDocumentLoader,
1214
+ gateways: [],
1215
+ verifyPortableObject: createVerifier(),
1216
+ });
1217
+ assertInstanceOf(object2, Note);
1218
+ deepStrictEqual(documentLoader2.fetched, [compatibleNoteId]);
1219
+ });
1220
+
1221
+ await t.step("in plural accessors", async () => {
1222
+ const documentLoader = createLoader({ [compatibleNoteId]: note() });
1223
+ const objects = await Array.fromAsync(
1224
+ (await createHttpActivity([compatibleNoteId])).getObjects({
1225
+ documentLoader,
1226
+ contextLoader: mockDocumentLoader,
1227
+ verifyPortableObject: createVerifier(),
1228
+ }),
1229
+ );
1230
+ deepStrictEqual(objects.map((o) => o.id), [parseIri(objectId)]);
1231
+ deepStrictEqual(documentLoader.fetched, [compatibleNoteId]);
1232
+ });
1233
+ });
1234
+
1235
+ test("accessors reject forged objects at compatible identifiers", async () => {
1236
+ const forgedId = gatewayUrl("https://evil.example", "/objects/1");
1237
+ // The unsigned object from the issue, identified by the compatible
1238
+ // identifier itself:
1239
+ const forged = {
1240
+ "@context": "https://www.w3.org/ns/activitystreams",
1241
+ id: forgedId,
1242
+ type: "Note",
1243
+ attributedTo: gatewayUrl("https://evil.example", "/actor"),
1244
+ content: "Forged",
1245
+ };
1246
+ for (const crossOrigin of [undefined, "ignore", "trust"] as const) {
1247
+ // Its @id, the compatible identifier itself, stands for the requested
1248
+ // portable object, so the proof policy decides, which rejects it for
1249
+ // lacking a proof:
1250
+ const verifier = createRecordingVerifier(() => ({ verified: false }));
1251
+ deepStrictEqual(
1252
+ await (await createHttpActivity(forgedId)).getObject({
1253
+ documentLoader: createLoader({ [forgedId]: forged }),
1254
+ contextLoader: mockDocumentLoader,
1255
+ verifyPortableObject: verifier,
1256
+ crossOrigin,
1257
+ }),
1258
+ null,
1259
+ );
1260
+ deepStrictEqual(verifier.calls.map((c) => c.document), [forged]);
1261
+ // With a portable ID, it fails the proof policy:
1262
+ deepStrictEqual(
1263
+ await (await createHttpActivity(forgedId)).getObject({
1264
+ documentLoader: createLoader({ [forgedId]: note() }),
1265
+ contextLoader: mockDocumentLoader,
1266
+ verifyPortableObject: createVerifier(false),
1267
+ crossOrigin,
1268
+ }),
1269
+ null,
1270
+ );
1271
+ }
1272
+ await rejects(
1273
+ async () =>
1274
+ await (await createHttpActivity(forgedId)).getObject({
1275
+ documentLoader: createLoader({ [forgedId]: note() }),
1276
+ contextLoader: mockDocumentLoader,
1277
+ verifyPortableObject: createVerifier(false),
1278
+ crossOrigin: "throw",
1279
+ }),
1280
+ /No gateway returned a valid portable object/,
1281
+ );
1282
+ });
1283
+
1284
+ test("accessors reject malformed compatible identifiers", async () => {
1285
+ const malformed = [
1286
+ // Location hints are not allowed in compatible identifiers:
1287
+ `${compatibleNoteId}?@gateway=https%3A%2F%2Fgw2.example`,
1288
+ // Nor are credentials:
1289
+ compatibleNoteId.replace("https://", "https://user:pass@"),
1290
+ ];
1291
+ for (const url of malformed) {
1292
+ const documentLoader = createLoader({ [url]: note() });
1293
+ const verifier = createRecordingVerifier();
1294
+ const activity = await createHttpActivity(url);
1295
+ deepStrictEqual(
1296
+ await activity.getObject({
1297
+ documentLoader,
1298
+ contextLoader: mockDocumentLoader,
1299
+ verifyPortableObject: verifier,
1300
+ }),
1301
+ null,
1302
+ );
1303
+ deepStrictEqual(
1304
+ await activity.getObject({
1305
+ documentLoader,
1306
+ contextLoader: mockDocumentLoader,
1307
+ verifyPortableObject: verifier,
1308
+ crossOrigin: "throw",
1309
+ suppressError: true,
1310
+ }),
1311
+ null,
1312
+ );
1313
+ await rejects(
1314
+ async () =>
1315
+ await activity.getObject({
1316
+ documentLoader,
1317
+ contextLoader: mockDocumentLoader,
1318
+ verifyPortableObject: verifier,
1319
+ crossOrigin: "throw",
1320
+ }),
1321
+ /malformed FEP-ef61 compatible identifier/,
1322
+ );
1323
+ deepStrictEqual(documentLoader.fetched, []);
1324
+ deepStrictEqual(verifier.calls, []);
1325
+ }
1326
+ });
1327
+
1328
+ test("accessors keep fetching compatible identifiers as HTTP(S) URLs without verifyPortableObject", async (t) => {
1329
+ const document = { ...note(), id: compatibleNoteId };
1330
+
1331
+ await t.step("singular accessors", async () => {
1332
+ const documentLoader = createLoader({ [compatibleNoteId]: document });
1333
+ const activity = await createHttpActivity(compatibleNoteId);
1334
+ const object = await activity.getObject({
1335
+ documentLoader,
1336
+ contextLoader: mockDocumentLoader,
1337
+ });
1338
+ assertInstanceOf(object, Note);
1339
+ deepStrictEqual(object.id, new URL(compatibleNoteId));
1340
+ // It is not cached, so the portable object policy applies later:
1341
+ deepStrictEqual(
1342
+ await activity.getObject({
1343
+ documentLoader,
1344
+ contextLoader: mockDocumentLoader,
1345
+ verifyPortableObject: createVerifier(false),
1346
+ }),
1347
+ null,
1348
+ );
1349
+ deepStrictEqual(documentLoader.fetched, [
1350
+ compatibleNoteId,
1351
+ compatibleNoteId,
1352
+ ]);
1353
+ });
1354
+
1355
+ await t.step("plural accessors", async () => {
1356
+ const documentLoader = createLoader({ [compatibleNoteId]: document });
1357
+ const activity = await createHttpActivity([compatibleNoteId]);
1358
+ const objects = await Array.fromAsync(activity.getObjects({
1359
+ documentLoader,
1360
+ contextLoader: mockDocumentLoader,
1361
+ }));
1362
+ deepStrictEqual(objects.map((o) => o.id), [new URL(compatibleNoteId)]);
1363
+ deepStrictEqual(
1364
+ await Array.fromAsync(activity.getObjects({
1365
+ documentLoader,
1366
+ contextLoader: mockDocumentLoader,
1367
+ verifyPortableObject: createVerifier(false),
1368
+ })),
1369
+ [],
1370
+ );
1371
+ });
1372
+
1373
+ await t.step("redirects to ordinary objects", async () => {
1374
+ const plainUrl = "https://gw.example/notes/1";
1375
+ const documentLoader = createRedirectingLoader({
1376
+ [compatibleNoteId]: {
1377
+ documentUrl: plainUrl,
1378
+ document: { ...note(), id: plainUrl },
1379
+ },
1380
+ });
1381
+ for (const activity of [await createHttpActivity(compatibleNoteId)]) {
1382
+ const object = await activity.getObject({
1383
+ documentLoader,
1384
+ contextLoader: mockDocumentLoader,
1385
+ });
1386
+ deepStrictEqual(object?.id, new URL(plainUrl));
1387
+ deepStrictEqual(
1388
+ await activity.getObject({
1389
+ documentLoader,
1390
+ contextLoader: mockDocumentLoader,
1391
+ verifyPortableObject: createVerifier(),
1392
+ }),
1393
+ null,
1394
+ );
1395
+ }
1396
+ const activity = await createHttpActivity([compatibleNoteId]);
1397
+ deepStrictEqual(
1398
+ (await Array.fromAsync(activity.getObjects({
1399
+ documentLoader,
1400
+ contextLoader: mockDocumentLoader,
1401
+ }))).length,
1402
+ 1,
1403
+ );
1404
+ deepStrictEqual(
1405
+ await Array.fromAsync(activity.getObjects({
1406
+ documentLoader,
1407
+ contextLoader: mockDocumentLoader,
1408
+ verifyPortableObject: createVerifier(),
1409
+ })),
1410
+ [],
1411
+ );
1412
+ });
1413
+
1414
+ await t.step("embedded objects", async () => {
1415
+ const activity = await createHttpActivity({
1416
+ type: "Note",
1417
+ id: gatewayUrl("https://example.com", "/objects/1"),
1418
+ content: "Embedded",
1419
+ });
1420
+ const object = await activity.getObject({
1421
+ documentLoader: createLoader({}),
1422
+ contextLoader: mockDocumentLoader,
1423
+ });
1424
+ deepStrictEqual(
1425
+ object?.id,
1426
+ new URL(gatewayUrl("https://example.com", "/objects/1")),
1427
+ );
1428
+ });
1429
+ });
1430
+
1431
+ test("accessors dereference compatible identifiers from portable objects", async () => {
1432
+ const compatibleOutbox = gatewayUrl("https://gw.example", "/actor/outbox");
1433
+ const person = (outbox: unknown) =>
1434
+ Person.fromJsonLd({
1435
+ "@context": "https://www.w3.org/ns/activitystreams",
1436
+ id: `ap://${did}/actor`,
1437
+ type: "Person",
1438
+ inbox: `ap://${did}/actor/inbox`,
1439
+ outbox,
1440
+ }, { contextLoader: mockDocumentLoader });
1441
+ const documentLoader = createRedirectingLoader({
1442
+ [compatibleOutbox]: {
1443
+ documentUrl: compatibleOutbox,
1444
+ document: portableCollection("/actor/outbox"),
1445
+ },
1446
+ "https://example.com/outbox": {
1447
+ documentUrl: compatibleOutbox,
1448
+ document: portableCollection("/actor/outbox"),
1449
+ },
1450
+ });
1451
+ for (const crossOrigin of [undefined, "trust"] as const) {
1452
+ for (
1453
+ const outbox of [
1454
+ // A compatible identifier as the reference:
1455
+ compatibleOutbox,
1456
+ // A redirect to a compatible identifier:
1457
+ "https://example.com/outbox",
1458
+ // An embedded object with a compatible identifier:
1459
+ { ...portableCollection("/actor/outbox"), id: compatibleOutbox },
1460
+ ]
1461
+ ) {
1462
+ const verifier = createRecordingVerifier();
1463
+ const collection = await (await person(outbox)).getOutbox({
1464
+ documentLoader,
1465
+ contextLoader: mockDocumentLoader,
1466
+ verifyPortableObject: verifier,
1467
+ crossOrigin,
1468
+ });
1469
+ assertInstanceOf(collection, OrderedCollection);
1470
+ deepStrictEqual(collection.id, parseIri(`ap://${did}/actor/outbox`));
1471
+ deepStrictEqual(verifier.calls.length, 1);
1472
+ deepStrictEqual(
1473
+ verifier.calls[0].options.documentUrl,
1474
+ new URL(compatibleOutbox),
1475
+ );
1476
+ deepStrictEqual(verifier.calls[0].options.gatewayHints, [
1477
+ new URL("https://gw.example"),
1478
+ ]);
1479
+ }
1480
+ }
1481
+ // Without verifyPortableObject, they are handled like portable IRIs:
1482
+ await rejects(
1483
+ async () =>
1484
+ await (await person(compatibleOutbox)).getOutbox({
1485
+ documentLoader,
1486
+ contextLoader: mockDocumentLoader,
1487
+ }),
1488
+ TypeError,
1489
+ );
1490
+ deepStrictEqual(
1491
+ await (await person(compatibleOutbox)).getOutbox({
1492
+ documentLoader,
1493
+ contextLoader: mockDocumentLoader,
1494
+ suppressError: true,
1495
+ }),
1496
+ null,
1497
+ );
1498
+ });
1499
+
1500
+ test("accessors verify fetched documents that stand for portable objects", async (t) => {
1501
+ const plainUrl = "https://example.com/notes/1";
1502
+
1503
+ await t.step("a redirect to a compatible identifier", async () => {
1504
+ const documentLoader = createRedirectingLoader({
1505
+ [plainUrl]: { documentUrl: compatibleNoteId, document: note() },
1506
+ });
1507
+ const verifier = createRecordingVerifier();
1508
+ const object = await (await createHttpActivity(plainUrl)).getObject({
1509
+ documentLoader,
1510
+ contextLoader: mockDocumentLoader,
1511
+ verifyPortableObject: verifier,
1512
+ });
1513
+ assertInstanceOf(object, Note);
1514
+ deepStrictEqual(object.id, parseIri(objectId));
1515
+ // Verified in place, without another request:
1516
+ deepStrictEqual(documentLoader.fetched, [plainUrl]);
1517
+ deepStrictEqual(verifier.calls.length, 1);
1518
+ deepStrictEqual(
1519
+ verifier.calls[0].options.documentUrl,
1520
+ new URL(compatibleNoteId),
1521
+ );
1522
+ deepStrictEqual(verifier.calls[0].options.gatewayHints, [
1523
+ new URL("https://gw.example"),
1524
+ ]);
1525
+ // It is rejected if the verifier rejects it:
1526
+ deepStrictEqual(
1527
+ await (await createHttpActivity(plainUrl)).getObject({
1528
+ documentLoader,
1529
+ contextLoader: mockDocumentLoader,
1530
+ verifyPortableObject: createVerifier(false),
1531
+ }),
1532
+ null,
1533
+ );
1534
+ await rejects(
1535
+ async () =>
1536
+ await (await createHttpActivity(plainUrl)).getObject({
1537
+ documentLoader,
1538
+ contextLoader: mockDocumentLoader,
1539
+ verifyPortableObject: createVerifier(false),
1540
+ crossOrigin: "throw",
1541
+ }),
1542
+ /No gateway returned a valid portable object/,
1543
+ );
1544
+ });
1545
+
1546
+ await t.step("a document that claims another object", async () => {
1547
+ for (
1548
+ const id of [
1549
+ `ap://${did}/objects/2`,
1550
+ gatewayUrl("https://gw.example", "/objects/2"),
1551
+ "https://gw.example/notes/1",
1552
+ ]
1553
+ ) {
1554
+ const verifier = createRecordingVerifier();
1555
+ deepStrictEqual(
1556
+ await (await createHttpActivity(plainUrl)).getObject({
1557
+ documentLoader: createRedirectingLoader({
1558
+ [plainUrl]: { documentUrl: compatibleNoteId, document: note(id) },
1559
+ }),
1560
+ contextLoader: mockDocumentLoader,
1561
+ verifyPortableObject: verifier,
1562
+ }),
1563
+ null,
1564
+ );
1565
+ deepStrictEqual(verifier.calls, []);
1566
+ }
1567
+ });
1568
+
1569
+ await t.step("a portable @id", async () => {
1570
+ const verifier = createRecordingVerifier();
1571
+ const object = await (await createHttpActivity(plainUrl)).getObject({
1572
+ documentLoader: createLoader({ [plainUrl]: note() }),
1573
+ contextLoader: mockDocumentLoader,
1574
+ verifyPortableObject: verifier,
1575
+ // Does not skip the portable object policy:
1576
+ crossOrigin: "trust",
1577
+ });
1578
+ assertInstanceOf(object, Note);
1579
+ deepStrictEqual(verifier.calls.length, 1);
1580
+ deepStrictEqual(verifier.calls[0].options.documentUrl, new URL(plainUrl));
1581
+ deepStrictEqual(
1582
+ await (await createHttpActivity(plainUrl)).getObject({
1583
+ documentLoader: createLoader({ [plainUrl]: note() }),
1584
+ contextLoader: mockDocumentLoader,
1585
+ verifyPortableObject: createVerifier(false),
1586
+ crossOrigin: "trust",
1587
+ }),
1588
+ null,
1589
+ );
1590
+ });
1591
+
1592
+ await t.step("a compatible @id", async () => {
1593
+ // The compatible identifier stands for the portable object, so the
1594
+ // document is verified as that object, keeping its own @id:
1595
+ const verifier = createRecordingVerifier();
1596
+ const url = "https://gw.example/notes/1";
1597
+ const documentLoader = createLoader({ [url]: note(compatibleNoteId) });
1598
+ const object = await (await createHttpActivity(url)).getObject({
1599
+ documentLoader,
1600
+ contextLoader: mockDocumentLoader,
1601
+ verifyPortableObject: verifier,
1602
+ });
1603
+ assertInstanceOf(object, Note);
1604
+ deepStrictEqual(object.id, new URL(compatibleNoteId));
1605
+ deepStrictEqual(verifier.calls.length, 1);
1606
+ deepStrictEqual(
1607
+ await (await createHttpActivity(url)).getObject({
1608
+ documentLoader,
1609
+ contextLoader: mockDocumentLoader,
1610
+ verifyPortableObject: createVerifier(false),
1611
+ }),
1612
+ null,
1613
+ );
1614
+ });
1615
+
1616
+ await t.step("a portable final URL", async () => {
1617
+ for (
1618
+ const [id, expected] of [
1619
+ [objectId, true],
1620
+ [`ap://${did}/objects/2`, false],
1621
+ ] as const
1622
+ ) {
1623
+ const object = await (await createHttpActivity(plainUrl)).getObject({
1624
+ documentLoader: createRedirectingLoader({
1625
+ [plainUrl]: {
1626
+ documentUrl: `ap://${did}/objects/1`,
1627
+ document: note(id),
1628
+ },
1629
+ }),
1630
+ contextLoader: mockDocumentLoader,
1631
+ verifyPortableObject: createVerifier(),
1632
+ });
1633
+ deepStrictEqual(object instanceof Note, expected);
1634
+ }
1635
+ // A final URL that URL parsing would turn into another ID is refused:
1636
+ await rejects(
1637
+ () =>
1638
+ createHttpActivity(plainUrl).then((activity) =>
1639
+ activity.getObject({
1640
+ documentLoader: createRedirectingLoader({
1641
+ [plainUrl]: {
1642
+ documentUrl: `ap://${did}/objects/x/../1`,
1643
+ document: note(),
1644
+ },
1645
+ }),
1646
+ contextLoader: mockDocumentLoader,
1647
+ verifyPortableObject: createVerifier(),
1648
+ })
1649
+ ),
1650
+ TypeError,
1651
+ );
1652
+ });
1653
+
1654
+ await t.step("a malformed compatible final URL", async () => {
1655
+ const malformed = `${compatibleNoteId}?@gateway=https%3A%2F%2Fgw2.example`;
1656
+ const documentLoader = createRedirectingLoader({
1657
+ [plainUrl]: { documentUrl: malformed, document: note() },
1658
+ });
1659
+ deepStrictEqual(
1660
+ await (await createHttpActivity(plainUrl)).getObject({
1661
+ documentLoader,
1662
+ contextLoader: mockDocumentLoader,
1663
+ verifyPortableObject: createVerifier(),
1664
+ }),
1665
+ null,
1666
+ );
1667
+ await rejects(
1668
+ async () =>
1669
+ await (await createHttpActivity(plainUrl)).getObject({
1670
+ documentLoader,
1671
+ contextLoader: mockDocumentLoader,
1672
+ verifyPortableObject: createVerifier(),
1673
+ crossOrigin: "throw",
1674
+ }),
1675
+ /malformed FEP-ef61 compatible identifier/,
1676
+ );
1677
+ });
1678
+
1679
+ await t.step("context documents are shared", async () => {
1680
+ const contextUrl = "https://example.com/portable-context";
1681
+ let version = 0;
1682
+ const contextLoader: DocumentLoader = async (url) => {
1683
+ if (url === contextUrl) {
1684
+ version++;
1685
+ return {
1686
+ contextUrl: null,
1687
+ documentUrl: url,
1688
+ document: { "@context": { version: `urn:version:${version}` } },
1689
+ };
1690
+ }
1691
+ return await mockDocumentLoader(url);
1692
+ };
1693
+ const contexts: unknown[] = [];
1694
+ const object = await (await createHttpActivity(plainUrl)).getObject({
1695
+ documentLoader: createRedirectingLoader({
1696
+ [plainUrl]: {
1697
+ documentUrl: compatibleNoteId,
1698
+ document: {
1699
+ ...note(),
1700
+ "@context": ["https://www.w3.org/ns/activitystreams", contextUrl],
1701
+ },
1702
+ },
1703
+ }),
1704
+ contextLoader,
1705
+ verifyPortableObject: async (_, { contextLoader }) => {
1706
+ contexts.push((await contextLoader!(contextUrl)).document);
1707
+ return { verified: true };
1708
+ },
1709
+ });
1710
+ assertInstanceOf(object, Note);
1711
+ deepStrictEqual(version, 1);
1712
+ deepStrictEqual(contexts, [{ "@context": { version: "urn:version:1" } }]);
1713
+ });
1714
+
1715
+ await t.step("without verifyPortableObject", async () => {
1716
+ // Fetched as before, i.e., refused as a cross-origin object:
1717
+ deepStrictEqual(
1718
+ await (await createHttpActivity(plainUrl)).getObject({
1719
+ documentLoader: createLoader({ [plainUrl]: note() }),
1720
+ contextLoader: mockDocumentLoader,
1721
+ }),
1722
+ null,
1723
+ );
1724
+ // A redirect to a compatible identifier of the same origin:
1725
+ const object = await (await createHttpActivity(plainUrl)).getObject({
1726
+ documentLoader: createRedirectingLoader({
1727
+ [plainUrl]: {
1728
+ documentUrl: gatewayUrl("https://example.com", "/objects/1"),
1729
+ document: note(gatewayUrl("https://example.com", "/objects/1")),
1730
+ },
1731
+ }),
1732
+ contextLoader: mockDocumentLoader,
1733
+ });
1734
+ assertInstanceOf(object, Note);
1735
+ });
1736
+ });
1737
+
1738
+ test("accessors verify embedded objects that stand for portable objects", async () => {
1739
+ const forgedId = gatewayUrl("https://example.com", "/objects/1");
1740
+ const forged = { type: "Note", id: forgedId, content: "Forged" };
1741
+ for (const crossOrigin of [undefined, "trust"] as const) {
1742
+ // A compatible identifier of the parent's origin:
1743
+ const documentLoader = createLoader({ [forgedId]: note() });
1744
+ const verifier = createRecordingVerifier(() => ({ verified: false }));
1745
+ deepStrictEqual(
1746
+ await (await createHttpActivity(forged)).getObject({
1747
+ documentLoader,
1748
+ contextLoader: mockDocumentLoader,
1749
+ verifyPortableObject: verifier,
1750
+ crossOrigin,
1751
+ }),
1752
+ null,
1753
+ );
1754
+ deepStrictEqual(documentLoader.fetched, [forgedId]);
1755
+ deepStrictEqual(verifier.calls.length, 1);
1756
+ deepStrictEqual(
1757
+ await Array.fromAsync(
1758
+ (await createHttpActivity([forged])).getObjects({
1759
+ documentLoader,
1760
+ contextLoader: mockDocumentLoader,
1761
+ verifyPortableObject: verifier,
1762
+ crossOrigin,
1763
+ }),
1764
+ ),
1765
+ [],
1766
+ );
1767
+
1768
+ // A portable IRI embedded in a non-portable object:
1769
+ const documentLoader2 = createLoader({
1770
+ [gatewayUrl("https://gw.example", "/objects/1")]: note(),
1771
+ });
1772
+ const verifier2 = createRecordingVerifier();
1773
+ const object = await (await createHttpActivity({
1774
+ type: "Note",
1775
+ id: objectId,
1776
+ content: "Forged",
1777
+ })).getObject({
1778
+ documentLoader: documentLoader2,
1779
+ contextLoader: mockDocumentLoader,
1780
+ gateways: ["https://gw.example"],
1781
+ verifyPortableObject: verifier2,
1782
+ crossOrigin,
1783
+ });
1784
+ assertInstanceOf(object, Note);
1785
+ deepStrictEqual(object.content, "Portable note");
1786
+ deepStrictEqual(verifier2.calls.length, 1);
1787
+ }
1788
+
1789
+ // A portable object with another DID embedded in a portable object:
1790
+ const otherDid = "did:key:z6Mkother";
1791
+ const otherId = `ap://${otherDid}/objects/1`;
1792
+ const documentLoader = createLoader({
1793
+ [`https://gw.example/.well-known/apgateway/${otherDid}/objects/1`]: note(
1794
+ otherId,
1795
+ ),
1796
+ });
1797
+ const verifier = createRecordingVerifier();
1798
+ const crossDid = await (await createActivity({
1799
+ type: "Note",
1800
+ id: otherId,
1801
+ content: "Forged",
1802
+ } as unknown as string)).getObject({
1803
+ documentLoader,
1804
+ contextLoader: mockDocumentLoader,
1805
+ gateways: ["https://gw.example"],
1806
+ verifyPortableObject: verifier,
1807
+ crossOrigin: "trust",
1808
+ });
1809
+ assertInstanceOf(crossDid, Note);
1810
+ deepStrictEqual(crossDid.content, "Portable note");
1811
+ deepStrictEqual(verifier.calls.length, 1);
1812
+
1813
+ // A portable object embedded in a portable object with the same DID is
1814
+ // still trusted as embedded:
1815
+ const activity = await createActivity({
1816
+ type: "Note",
1817
+ id: objectId,
1818
+ content: "Embedded",
1819
+ } as unknown as string);
1820
+ const object = await activity.getObject({
1821
+ documentLoader: createLoader({}),
1822
+ contextLoader: mockDocumentLoader,
1823
+ verifyPortableObject: createVerifier(false),
1824
+ });
1825
+ assertInstanceOf(object, Note);
1826
+ deepStrictEqual(object.content, "Embedded");
1827
+ });
1828
+
1829
+ test("singular accessors drop anonymous objects embedded in unsecured collections", async () => {
1830
+ const documentLoader = createLoader({
1831
+ [gatewayUrl("https://gw.example", "/actor/outbox")]: portableCollection(
1832
+ "/actor/outbox",
1833
+ {
1834
+ // An anonymous page cannot be fetched and verified on its own:
1835
+ first: {
1836
+ type: "OrderedCollectionPage",
1837
+ orderedItems: [{ type: "Note", content: "Anonymous" }],
1838
+ },
1839
+ },
1840
+ ),
1841
+ });
1842
+ const options = {
1843
+ documentLoader,
1844
+ contextLoader: mockDocumentLoader,
1845
+ gateways: ["https://gw.example"],
1846
+ verifyPortableObject: createRecordingVerifier(() => ({
1847
+ verified: true,
1848
+ unsecured: true,
1849
+ })),
1850
+ };
1851
+ const person = await Person.fromJsonLd({
1852
+ "@context": "https://www.w3.org/ns/activitystreams",
1853
+ id: `ap://${did}/actor`,
1854
+ type: "Person",
1855
+ inbox: `ap://${did}/actor/inbox`,
1856
+ outbox: `ap://${did}/actor/outbox`,
1857
+ }, { contextLoader: mockDocumentLoader });
1858
+ const outbox = await person.getOutbox(options);
1859
+ assertInstanceOf(outbox, OrderedCollection);
1860
+ deepStrictEqual(await outbox.getFirst(options), null);
1861
+ deepStrictEqual(
1862
+ await Array.fromAsync(traverseCollection(outbox, options)),
1863
+ [],
1864
+ );
1865
+ });
1866
+
1867
+ function createCompatibleActivity(
1868
+ object: unknown,
1869
+ id: string = gatewayUrl("https://gw.example", "/activities/1"),
1870
+ options: { verifyPortableObject?: PortableObjectVerifier } = {},
1871
+ ): Promise<Create> {
1872
+ return Create.fromJsonLd({
1873
+ "@context": "https://www.w3.org/ns/activitystreams",
1874
+ type: "Create",
1875
+ id,
1876
+ object,
1877
+ }, { contextLoader: mockDocumentLoader, ...options });
1878
+ }
1879
+
1880
+ test("accessors of objects with compatible identifiers dereference references as portable objects", async (t) => {
1881
+ await t.step("verifying the object at the reference", async () => {
1882
+ const documentLoader = createLoader({
1883
+ [compatibleNoteId]: { ...note(), id: compatibleNoteId },
1884
+ });
1885
+ const verifier = createRecordingVerifier();
1886
+ const activity = await createCompatibleActivity(compatibleNoteId);
1887
+ const object = await activity.getObject({
1888
+ documentLoader,
1889
+ contextLoader: mockDocumentLoader,
1890
+ verifyPortableObject: verifier,
1891
+ });
1892
+ assertInstanceOf(object, Note);
1893
+ deepStrictEqual(object.id, new URL(compatibleNoteId));
1894
+ deepStrictEqual(documentLoader.fetched, [compatibleNoteId]);
1895
+ deepStrictEqual(verifier.calls.length, 1);
1896
+ deepStrictEqual(verifier.calls[0].options.gatewayHints, [
1897
+ new URL("https://gw.example"),
1898
+ ]);
1899
+ ok(verifier.calls[0].options.referrer?.object === activity);
1900
+ // The root itself is not marked as verified:
1901
+ deepStrictEqual(verifier.calls[0].options.referrer?.acceptance, undefined);
1902
+ });
1903
+
1904
+ await t.step("rejecting an unsigned object at the reference", async () => {
1905
+ const documentLoader = createLoader({
1906
+ [compatibleNoteId]: { ...note(), id: compatibleNoteId },
1907
+ });
1908
+ const activity = await createCompatibleActivity(compatibleNoteId);
1909
+ deepStrictEqual(
1910
+ await activity.getObject({
1911
+ documentLoader,
1912
+ contextLoader: mockDocumentLoader,
1913
+ verifyPortableObject: createVerifier(false),
1914
+ }),
1915
+ null,
1916
+ );
1917
+ // Even with crossOrigin: "trust":
1918
+ deepStrictEqual(
1919
+ await Array.fromAsync(
1920
+ (await createCompatibleActivity([compatibleNoteId])).getObjects({
1921
+ documentLoader,
1922
+ contextLoader: mockDocumentLoader,
1923
+ verifyPortableObject: createVerifier(false),
1924
+ crossOrigin: "trust",
1925
+ }),
1926
+ ),
1927
+ [],
1928
+ );
1929
+ });
1930
+
1931
+ await t.step("without verifyPortableObject", async () => {
1932
+ const documentLoader = createLoader({
1933
+ [compatibleNoteId]: { ...note(), id: compatibleNoteId },
1934
+ });
1935
+ const activity = await createCompatibleActivity(compatibleNoteId);
1936
+ await rejects(
1937
+ async () =>
1938
+ await activity.getObject({
1939
+ documentLoader,
1940
+ contextLoader: mockDocumentLoader,
1941
+ }),
1942
+ TypeError,
1943
+ );
1944
+ deepStrictEqual(
1945
+ await activity.getObject({
1946
+ documentLoader,
1947
+ contextLoader: mockDocumentLoader,
1948
+ suppressError: true,
1949
+ }),
1950
+ null,
1951
+ );
1952
+ deepStrictEqual(documentLoader.fetched, []);
1953
+ });
1954
+
1955
+ await t.step("embedded objects with compatible identifiers", async () => {
1956
+ // Even on the same gateway and with the same DID, an embedded object
1957
+ // with a compatible identifier is verified on its own:
1958
+ const documentLoader = createLoader({ [compatibleNoteId]: note() });
1959
+ const verifier = createRecordingVerifier();
1960
+ const activity = await createCompatibleActivity({
1961
+ type: "Note",
1962
+ id: compatibleNoteId,
1963
+ content: "Embedded",
1964
+ });
1965
+ const object = await activity.getObject({
1966
+ documentLoader,
1967
+ contextLoader: mockDocumentLoader,
1968
+ verifyPortableObject: verifier,
1969
+ });
1970
+ assertInstanceOf(object, Note);
1971
+ deepStrictEqual(object.content, "Portable note");
1972
+ deepStrictEqual(verifier.calls.length, 1);
1973
+ });
1974
+
1975
+ await t.step("objects with malformed compatible identifiers", async () => {
1976
+ const malformedId = `${
1977
+ gatewayUrl("https://gw.example", "/activities/1")
1978
+ }?@gateway=https%3A%2F%2Fgw2.example`;
1979
+ // Portable references need a verifier:
1980
+ const activity = await createCompatibleActivity(objectId, malformedId);
1981
+ await rejects(
1982
+ async () =>
1983
+ await activity.getObject({
1984
+ documentLoader: createLoader({}),
1985
+ contextLoader: mockDocumentLoader,
1986
+ }),
1987
+ TypeError,
1988
+ );
1989
+ // Ordinary HTTP(S) references are still fetched without one:
1990
+ const plainUrl = "https://example.com/notes/1";
1991
+ const plainLoader = createLoader({
1992
+ [plainUrl]: { ...note(), id: plainUrl },
1993
+ });
1994
+ const plain = await (await createCompatibleActivity(plainUrl, malformedId))
1995
+ .getObject({
1996
+ documentLoader: plainLoader,
1997
+ contextLoader: mockDocumentLoader,
1998
+ });
1999
+ deepStrictEqual(plain?.id, new URL(plainUrl));
2000
+ // ...unless the response turns out to be a portable object:
2001
+ const claimLoader = createLoader({ [plainUrl]: note() });
2002
+ await rejects(
2003
+ async () =>
2004
+ await (await createCompatibleActivity(plainUrl, malformedId))
2005
+ .getObject({
2006
+ documentLoader: claimLoader,
2007
+ contextLoader: mockDocumentLoader,
2008
+ }),
2009
+ TypeError,
2010
+ );
2011
+ });
2012
+
2013
+ await t.step("objects with ordinary HTTP(S) identifiers", async () => {
2014
+ const documentLoader = createLoader({
2015
+ [compatibleNoteId]: { ...note(), id: compatibleNoteId },
2016
+ });
2017
+ const object = await (await createHttpActivity(compatibleNoteId))
2018
+ .getObject({ documentLoader, contextLoader: mockDocumentLoader });
2019
+ deepStrictEqual(object?.id, new URL(compatibleNoteId));
2020
+ });
2021
+ });
2022
+
2023
+ test("objects use the verifyPortableObject option they were parsed with by default", async (t) => {
2024
+ const attributedNote = (id: string) => ({
2025
+ ...note(id),
2026
+ attributedTo: `ap://${did}/actor`,
2027
+ });
2028
+ const actor = {
2029
+ "@context": "https://www.w3.org/ns/activitystreams",
2030
+ id: `ap://${did}/actor`,
2031
+ type: "Person",
2032
+ inbox: `ap://${did}/actor/inbox`,
2033
+ };
2034
+ const documentLoader = createLoader({
2035
+ [compatibleNoteId]: attributedNote(compatibleNoteId),
2036
+ [gatewayUrl("https://gw.example", "/actor")]: actor,
2037
+ });
2038
+
2039
+ await t.step("fromJsonLd()", async () => {
2040
+ const verifier = createRecordingVerifier();
2041
+ const activity = await createCompatibleActivity(
2042
+ compatibleNoteId,
2043
+ undefined,
2044
+ { verifyPortableObject: verifier },
2045
+ );
2046
+ const object = await activity.getObject({
2047
+ documentLoader,
2048
+ contextLoader: mockDocumentLoader,
2049
+ });
2050
+ assertInstanceOf(object, Note);
2051
+ deepStrictEqual(verifier.calls.length, 1);
2052
+ // Fetched objects inherit the default:
2053
+ const author = await object.getAttribution({
2054
+ documentLoader,
2055
+ contextLoader: mockDocumentLoader,
2056
+ gateways: ["https://gw.example"],
2057
+ });
2058
+ assertInstanceOf(author, Person);
2059
+ deepStrictEqual(verifier.calls.length, 2);
2060
+ // Embedded objects too:
2061
+ const embedding = await createCompatibleActivity(
2062
+ attributedNote(`ap://${did}/objects/2`),
2063
+ `ap://${did}/activities/1`,
2064
+ { verifyPortableObject: verifier },
2065
+ );
2066
+ const embedded = await embedding.getObject({
2067
+ documentLoader,
2068
+ contextLoader: mockDocumentLoader,
2069
+ });
2070
+ assertInstanceOf(embedded, Note);
2071
+ assertInstanceOf(
2072
+ await embedded.getAttribution({
2073
+ documentLoader,
2074
+ contextLoader: mockDocumentLoader,
2075
+ gateways: ["https://gw.example"],
2076
+ }),
2077
+ Person,
2078
+ );
2079
+ deepStrictEqual(verifier.calls.length, 3);
2080
+ });
2081
+
2082
+ await t.step("the constructor and clone()", async () => {
2083
+ const verifier = createRecordingVerifier();
2084
+ const activity = new Create({
2085
+ id: new URL(gatewayUrl("https://gw.example", "/activities/1")),
2086
+ object: new URL(compatibleNoteId),
2087
+ }, { contextLoader: mockDocumentLoader, verifyPortableObject: verifier });
2088
+ assertInstanceOf(
2089
+ await activity.getObject({
2090
+ documentLoader,
2091
+ contextLoader: mockDocumentLoader,
2092
+ }),
2093
+ Note,
2094
+ );
2095
+ deepStrictEqual(verifier.calls.length, 1);
2096
+ // A clone does not inherit it, but takes it as an option:
2097
+ const clone = activity.clone({ object: new URL(compatibleNoteId) });
2098
+ await rejects(
2099
+ async () =>
2100
+ await clone.getObject({
2101
+ documentLoader,
2102
+ contextLoader: mockDocumentLoader,
2103
+ }),
2104
+ TypeError,
2105
+ );
2106
+ const clone2 = activity.clone(
2107
+ { object: new URL(compatibleNoteId) },
2108
+ { contextLoader: mockDocumentLoader, verifyPortableObject: verifier },
2109
+ );
2110
+ assertInstanceOf(
2111
+ await clone2.getObject({
2112
+ documentLoader,
2113
+ contextLoader: mockDocumentLoader,
2114
+ }),
2115
+ Note,
2116
+ );
2117
+ deepStrictEqual(verifier.calls.length, 2);
2118
+ });
2119
+
2120
+ await t.step(
2121
+ "an explicit option is passed on to fetched objects",
2122
+ async () => {
2123
+ const stored = createRecordingVerifier();
2124
+ const given = createRecordingVerifier();
2125
+ const activity = await createCompatibleActivity(
2126
+ compatibleNoteId,
2127
+ undefined,
2128
+ { verifyPortableObject: stored },
2129
+ );
2130
+ const object = await activity.getObject({
2131
+ documentLoader,
2132
+ contextLoader: mockDocumentLoader,
2133
+ verifyPortableObject: given,
2134
+ });
2135
+ assertInstanceOf(object, Note);
2136
+ deepStrictEqual([stored.calls.length, given.calls.length], [0, 1]);
2137
+ // The fetched object uses the verifier given to the call by default:
2138
+ await object.getAttribution({
2139
+ documentLoader,
2140
+ contextLoader: mockDocumentLoader,
2141
+ gateways: ["https://gw.example"],
2142
+ });
2143
+ deepStrictEqual([stored.calls.length, given.calls.length], [0, 2]);
2144
+ // ...but the parent keeps its own default:
2145
+ ok(getDefaultVerifier(activity) === stored);
2146
+ },
2147
+ );
2148
+
2149
+ await t.step("lookupObject()", async () => {
2150
+ const verifier = createRecordingVerifier();
2151
+ const object = await lookupObject(compatibleNoteId, {
2152
+ documentLoader,
2153
+ contextLoader: mockDocumentLoader,
2154
+ verifyPortableObject: verifier,
2155
+ });
2156
+ assertInstanceOf(object, Note);
2157
+ deepStrictEqual(verifier.calls.length, 1);
2158
+ assertInstanceOf(
2159
+ await object.getAttribution({
2160
+ documentLoader,
2161
+ contextLoader: mockDocumentLoader,
2162
+ gateways: ["https://gw.example"],
2163
+ }),
2164
+ Person,
2165
+ );
2166
+ deepStrictEqual(verifier.calls.length, 2);
2167
+ });
2168
+
2169
+ await t.step("property preprocessors", async () => {
2170
+ const verifier = createVerifier();
2171
+ const person = await Person.fromJsonLd({
2172
+ "@context": "https://www.w3.org/ns/activitystreams",
2173
+ id: `ap://${did}/actor`,
2174
+ type: "Person",
2175
+ icon: { type: "Link", href: "https://example.com/icon.png" },
2176
+ }, { contextLoader: mockDocumentLoader, verifyPortableObject: verifier });
2177
+ const icon = await person.getIcon({ documentLoader });
2178
+ assertInstanceOf(icon, Image);
2179
+ ok(getDefaultVerifier(icon) === verifier);
2180
+ });
2181
+ });
2182
+
2183
+ function getDefaultVerifier(
2184
+ object: object,
2185
+ ): PortableObjectVerifier | undefined {
2186
+ return (object as unknown as {
2187
+ _verifyPortableObject?: PortableObjectVerifier;
2188
+ })
2189
+ ._verifyPortableObject;
2190
+ }
2191
+
2192
+ test("accessors pass the verifier of a call on to the objects they fetch", async (t) => {
2193
+ const actorId = `ap://${did}/actor`;
2194
+ const attributedNote = (id: string) => ({
2195
+ ...note(id),
2196
+ attributedTo: actorId,
2197
+ });
2198
+ const actor = {
2199
+ "@context": "https://www.w3.org/ns/activitystreams",
2200
+ id: actorId,
2201
+ type: "Person",
2202
+ inbox: `${actorId}/inbox`,
2203
+ };
2204
+ const actorUrl = gatewayUrl("https://gw.example", "/actor");
2205
+ const plainNoteUrl = "https://example.com/notes/1";
2206
+ const plainActivityUrl = "https://example.com/activities/1";
2207
+ const documentLoader = createLoader({
2208
+ [compatibleNoteId]: attributedNote(compatibleNoteId),
2209
+ [actorUrl]: actor,
2210
+ [plainNoteUrl]: { ...attributedNote(plainNoteUrl), id: plainNoteUrl },
2211
+ [plainActivityUrl]: {
2212
+ "@context": "https://www.w3.org/ns/activitystreams",
2213
+ type: "Create",
2214
+ id: plainActivityUrl,
2215
+ object: compatibleNoteId,
2216
+ },
2217
+ });
2218
+ const options = { documentLoader, contextLoader: mockDocumentLoader };
2219
+ const secondHop = { ...options, gateways: ["https://gw.example"] };
2220
+
2221
+ await t.step("from objects without a default", async () => {
2222
+ const roots: [string, () => Promise<Create>][] = [
2223
+ [
2224
+ "constructed",
2225
+ // deno-lint-ignore require-await
2226
+ async () =>
2227
+ new Create({
2228
+ id: new URL(gatewayUrl("https://gw.example", "/activities/1")),
2229
+ object: new URL(compatibleNoteId),
2230
+ }, { contextLoader: mockDocumentLoader }),
2231
+ ],
2232
+ ["parsed", () => createCompatibleActivity(compatibleNoteId)],
2233
+ [
2234
+ "looked up",
2235
+ async () => {
2236
+ const found = await lookupObject(plainActivityUrl, options);
2237
+ assertInstanceOf(found, Create);
2238
+ return found;
2239
+ },
2240
+ ],
2241
+ ];
2242
+ for (const [name, createRoot] of roots) {
2243
+ // Singular accessors:
2244
+ const verifier = createRecordingVerifier();
2245
+ const root = await createRoot();
2246
+ ok(getDefaultVerifier(root) == null, name);
2247
+ const object = await root.getObject({
2248
+ ...options,
2249
+ verifyPortableObject: verifier,
2250
+ });
2251
+ assertInstanceOf(object, Note);
2252
+ deepStrictEqual(verifier.calls.length, 1, name);
2253
+ assertInstanceOf(await object.getAttribution(secondHop), Person);
2254
+ deepStrictEqual(verifier.calls.length, 2, name);
2255
+ ok(getDefaultVerifier(root) == null, name);
2256
+
2257
+ // Plural accessors:
2258
+ const verifier2 = createRecordingVerifier();
2259
+ const objects = await Array.fromAsync((await createRoot()).getObjects({
2260
+ ...options,
2261
+ verifyPortableObject: verifier2,
2262
+ }));
2263
+ deepStrictEqual(objects.length, 1, name);
2264
+ assertInstanceOf(objects[0], Note);
2265
+ const authors = await Array.fromAsync(
2266
+ (objects[0] as Note).getAttributions(secondHop),
2267
+ );
2268
+ deepStrictEqual(authors.length, 1, name);
2269
+ assertInstanceOf(authors[0], Person);
2270
+ deepStrictEqual(verifier2.calls.length, 2, name);
2271
+ }
2272
+ });
2273
+
2274
+ await t.step("precedence", async () => {
2275
+ const stored = createRecordingVerifier();
2276
+ const given = createRecordingVerifier();
2277
+ const override = createRecordingVerifier();
2278
+ const counts = () => [
2279
+ stored.calls.length,
2280
+ given.calls.length,
2281
+ override.calls.length,
2282
+ ];
2283
+
2284
+ // Without the option, the stored default applies to the call and is
2285
+ // passed on:
2286
+ const byDefault = await (await createCompatibleActivity(
2287
+ compatibleNoteId,
2288
+ undefined,
2289
+ { verifyPortableObject: stored },
2290
+ )).getObject({ ...options, inheritPortableObjectVerifier: false });
2291
+ assertInstanceOf(byDefault, Note);
2292
+ ok(getDefaultVerifier(byDefault) === stored);
2293
+ deepStrictEqual(counts(), [1, 0, 0]);
2294
+
2295
+ // With the opt-out, the given verifier applies to the call only, and the
2296
+ // fetched object gets the stored default:
2297
+ const optedOut = await (await createCompatibleActivity(
2298
+ compatibleNoteId,
2299
+ undefined,
2300
+ { verifyPortableObject: stored },
2301
+ )).getObject({
2302
+ ...options,
2303
+ verifyPortableObject: given,
2304
+ inheritPortableObjectVerifier: false,
2305
+ });
2306
+ assertInstanceOf(optedOut, Note);
2307
+ deepStrictEqual(counts(), [1, 1, 0]);
2308
+ ok(getDefaultVerifier(optedOut) === stored);
2309
+ assertInstanceOf(await optedOut.getAttribution(secondHop), Person);
2310
+ deepStrictEqual(counts(), [2, 1, 0]);
2311
+
2312
+ // Without a stored default, the fetched object gets none:
2313
+ const noDefault = await (await createCompatibleActivity(compatibleNoteId))
2314
+ .getObject({
2315
+ ...options,
2316
+ verifyPortableObject: given,
2317
+ inheritPortableObjectVerifier: false,
2318
+ });
2319
+ assertInstanceOf(noDefault, Note);
2320
+ deepStrictEqual(counts(), [2, 2, 0]);
2321
+ ok(getDefaultVerifier(noDefault) == null);
2322
+ await rejects(
2323
+ async () => await noDefault.getAttribution(secondHop),
2324
+ TypeError,
2325
+ );
2326
+
2327
+ // The opt-out itself is not passed on, and an explicit option given to
2328
+ // the fetched object overrides its inherited default:
2329
+ const inherited = await (await createCompatibleActivity(
2330
+ compatibleNoteId,
2331
+ undefined,
2332
+ { verifyPortableObject: stored },
2333
+ )).getObject({ ...options, verifyPortableObject: given });
2334
+ assertInstanceOf(inherited, Note);
2335
+ deepStrictEqual(counts(), [2, 3, 0]);
2336
+ ok(getDefaultVerifier(inherited) === given);
2337
+ assertInstanceOf(
2338
+ await inherited.getAttribution({
2339
+ ...secondHop,
2340
+ verifyPortableObject: override,
2341
+ }),
2342
+ Person,
2343
+ );
2344
+ deepStrictEqual(counts(), [2, 3, 1]);
2345
+
2346
+ // The opt-out works for plural accessors too:
2347
+ const optedOuts = await Array.fromAsync((await createCompatibleActivity(
2348
+ [compatibleNoteId],
2349
+ undefined,
2350
+ { verifyPortableObject: stored },
2351
+ )).getObjects({
2352
+ ...options,
2353
+ verifyPortableObject: given,
2354
+ inheritPortableObjectVerifier: false,
2355
+ }));
2356
+ deepStrictEqual(optedOuts.length, 1);
2357
+ ok(getDefaultVerifier(optedOuts[0]) === stored);
2358
+ });
2359
+
2360
+ await t.step("ordinary HTTP(S) responses", async () => {
2361
+ const given = createRecordingVerifier();
2362
+ const stored = createRecordingVerifier();
2363
+ const object = await (await createHttpActivity(plainNoteUrl)).getObject({
2364
+ ...options,
2365
+ verifyPortableObject: given,
2366
+ });
2367
+ assertInstanceOf(object, Note);
2368
+ // Fetched as an ordinary object, so nothing is verified, but it uses the
2369
+ // call's verifier for its own references:
2370
+ deepStrictEqual(given.calls.length, 0);
2371
+ ok(getDefaultVerifier(object) === given);
2372
+ assertInstanceOf(await object.getAttribution(secondHop), Person);
2373
+ deepStrictEqual(given.calls.length, 1);
2374
+ // Having inherited a verifier does not mean it was verified:
2375
+ const { referrer } = given.calls[0].options;
2376
+ ok(referrer?.object === object);
2377
+ deepStrictEqual(referrer?.acceptance, undefined);
2378
+
2379
+ const optedOut = await (await Create.fromJsonLd({
2380
+ "@context": "https://www.w3.org/ns/activitystreams",
2381
+ type: "Create",
2382
+ id: plainActivityUrl,
2383
+ object: plainNoteUrl,
2384
+ }, { contextLoader: mockDocumentLoader, verifyPortableObject: stored }))
2385
+ .getObject({
2386
+ ...options,
2387
+ verifyPortableObject: given,
2388
+ inheritPortableObjectVerifier: false,
2389
+ });
2390
+ assertInstanceOf(optedOut, Note);
2391
+ ok(getDefaultVerifier(optedOut) === stored);
2392
+ });
2393
+
2394
+ await t.step(
2395
+ "HTTP(S) responses that stand for portable objects",
2396
+ async () => {
2397
+ const redirecting = createRedirectingLoader({
2398
+ [plainNoteUrl]: {
2399
+ documentUrl: compatibleNoteId,
2400
+ document: attributedNote(compatibleNoteId),
2401
+ },
2402
+ });
2403
+ const given = createRecordingVerifier();
2404
+ const object = await (await createHttpActivity(plainNoteUrl)).getObject({
2405
+ documentLoader: redirecting,
2406
+ contextLoader: mockDocumentLoader,
2407
+ verifyPortableObject: given,
2408
+ });
2409
+ assertInstanceOf(object, Note);
2410
+ deepStrictEqual(given.calls.length, 1);
2411
+ ok(getDefaultVerifier(object) === given);
2412
+
2413
+ const optedOut = await (await createHttpActivity(plainNoteUrl)).getObject(
2414
+ {
2415
+ documentLoader: redirecting,
2416
+ contextLoader: mockDocumentLoader,
2417
+ verifyPortableObject: given,
2418
+ inheritPortableObjectVerifier: false,
2419
+ },
2420
+ );
2421
+ assertInstanceOf(optedOut, Note);
2422
+ deepStrictEqual(given.calls.length, 2);
2423
+ ok(getDefaultVerifier(optedOut) == null);
2424
+ },
2425
+ );
2426
+
2427
+ await t.step("objects that were fetched without a verifier", async () => {
2428
+ const activity = await createHttpActivity(compatibleNoteId);
2429
+ const unverified = await activity.getObject(options);
2430
+ assertInstanceOf(unverified, Note);
2431
+ ok(getDefaultVerifier(unverified) == null);
2432
+ // It is not cached, so a later call verifies it and passes its verifier
2433
+ // on:
2434
+ const verifier = createRecordingVerifier();
2435
+ const verified = await activity.getObject({
2436
+ ...options,
2437
+ verifyPortableObject: verifier,
2438
+ });
2439
+ assertInstanceOf(verified, Note);
2440
+ ok(verified !== unverified);
2441
+ deepStrictEqual(verifier.calls.length, 1);
2442
+ ok(getDefaultVerifier(verified) === verifier);
2443
+ });
2444
+
2445
+ await t.step("property preprocessors", async () => {
2446
+ const iconUrl = "https://example.com/icon";
2447
+ const loader = createLoader({
2448
+ [iconUrl]: {
2449
+ "@context": "https://www.w3.org/ns/activitystreams",
2450
+ type: "Link",
2451
+ href: "https://example.com/icon.png",
2452
+ },
2453
+ });
2454
+ const createPerson = () =>
2455
+ Person.fromJsonLd({
2456
+ "@context": "https://www.w3.org/ns/activitystreams",
2457
+ id: "https://example.com/person",
2458
+ type: "Person",
2459
+ icon: iconUrl,
2460
+ }, { contextLoader: mockDocumentLoader });
2461
+ const verifier = createVerifier();
2462
+ const icon = await (await createPerson()).getIcon({
2463
+ documentLoader: loader,
2464
+ contextLoader: mockDocumentLoader,
2465
+ verifyPortableObject: verifier,
2466
+ });
2467
+ assertInstanceOf(icon, Image);
2468
+ ok(getDefaultVerifier(icon) === verifier);
2469
+ const optedOut = await (await createPerson()).getIcon({
2470
+ documentLoader: loader,
2471
+ contextLoader: mockDocumentLoader,
2472
+ verifyPortableObject: verifier,
2473
+ inheritPortableObjectVerifier: false,
2474
+ });
2475
+ assertInstanceOf(optedOut, Image);
2476
+ ok(getDefaultVerifier(optedOut) == null);
2477
+ });
2478
+
2479
+ await t.step("embedded objects keep the parent's default", async () => {
2480
+ const embedded = attributedNote(`ap://${did}/objects/2`);
2481
+ const withoutContext: Record<string, unknown> = { ...embedded };
2482
+ delete withoutContext["@context"];
2483
+ for (
2484
+ const [name, value] of [["with @context", embedded], [
2485
+ "without @context",
2486
+ withoutContext,
2487
+ ]] as const
2488
+ ) {
2489
+ for (const stored of [undefined, createRecordingVerifier()]) {
2490
+ const given = createRecordingVerifier();
2491
+ const activity = await createCompatibleActivity(
2492
+ value,
2493
+ `ap://${did}/activities/1`,
2494
+ stored == null ? {} : { verifyPortableObject: stored },
2495
+ );
2496
+ const object = await activity.getObject({
2497
+ ...options,
2498
+ verifyPortableObject: given,
2499
+ });
2500
+ assertInstanceOf(object, Note);
2501
+ // However the object is represented, it keeps the default that it
2502
+ // was parsed with along with its parent:
2503
+ ok(getDefaultVerifier(object) === stored, name);
2504
+ deepStrictEqual(given.calls.length, 0, name);
2505
+ }
2506
+ }
2507
+ });
2508
+
2509
+ await t.step("cached objects", async () => {
2510
+ const weak = createRecordingVerifier();
2511
+ const strict = createRecordingVerifier();
2512
+ for (const [first, second] of [[weak, strict], [strict, weak]]) {
2513
+ const activity = await createCompatibleActivity(compatibleNoteId);
2514
+ const object = await activity.getObject({
2515
+ ...options,
2516
+ verifyPortableObject: first,
2517
+ });
2518
+ assertInstanceOf(object, Note);
2519
+ const before = second.calls.length;
2520
+ // A cached object is returned as is, with the default it got when it
2521
+ // was fetched:
2522
+ const cached = await activity.getObject({
2523
+ ...options,
2524
+ verifyPortableObject: second,
2525
+ });
2526
+ ok(cached === object);
2527
+ ok(getDefaultVerifier(cached) === first);
2528
+ deepStrictEqual(second.calls.length, before);
2529
+ }
2530
+ });
2531
+
2532
+ await t.step("traverseCollection()", async () => {
2533
+ const collectionUrl = "https://example.com/collection";
2534
+ const pageUrl = "https://example.com/collection?page=1";
2535
+ const loader = createLoader({
2536
+ ...Object.fromEntries(
2537
+ [compatibleNoteId, actorUrl, plainNoteUrl].map((url) => [
2538
+ url,
2539
+ url === plainNoteUrl
2540
+ ? { ...attributedNote(plainNoteUrl), id: plainNoteUrl }
2541
+ : url === actorUrl
2542
+ ? actor
2543
+ : attributedNote(compatibleNoteId),
2544
+ ]),
2545
+ ),
2546
+ [pageUrl]: {
2547
+ "@context": "https://www.w3.org/ns/activitystreams",
2548
+ id: pageUrl,
2549
+ type: "OrderedCollectionPage",
2550
+ orderedItems: [
2551
+ {
2552
+ id: "https://example.com/notes/2",
2553
+ type: "Note",
2554
+ attributedTo: actorId,
2555
+ },
2556
+ plainNoteUrl,
2557
+ compatibleNoteId,
2558
+ ],
2559
+ },
2560
+ });
2561
+ const createCollection = () =>
2562
+ OrderedCollection.fromJsonLd({
2563
+ "@context": "https://www.w3.org/ns/activitystreams",
2564
+ id: collectionUrl,
2565
+ type: "OrderedCollection",
2566
+ first: pageUrl,
2567
+ }, { contextLoader: mockDocumentLoader });
2568
+ const verifier = createRecordingVerifier();
2569
+ const items = await Array.fromAsync(
2570
+ traverseCollection(await createCollection(), {
2571
+ documentLoader: loader,
2572
+ contextLoader: mockDocumentLoader,
2573
+ verifyPortableObject: verifier,
2574
+ }),
2575
+ );
2576
+ deepStrictEqual(items.length, 3);
2577
+ // Only the item at the compatible identifier is verified:
2578
+ deepStrictEqual(verifier.calls.length, 1);
2579
+ for (const item of items) {
2580
+ assertInstanceOf(item, Note);
2581
+ // Embedded in a fetched page, or fetched themselves:
2582
+ ok(getDefaultVerifier(item) === verifier);
2583
+ assertInstanceOf(
2584
+ await item.getAttribution({
2585
+ documentLoader: loader,
2586
+ contextLoader: mockDocumentLoader,
2587
+ gateways: ["https://gw.example"],
2588
+ }),
2589
+ Person,
2590
+ );
2591
+ }
2592
+ deepStrictEqual(verifier.calls.length, 4);
2593
+
2594
+ const optedOut = await Array.fromAsync(
2595
+ traverseCollection(await createCollection(), {
2596
+ documentLoader: loader,
2597
+ contextLoader: mockDocumentLoader,
2598
+ verifyPortableObject: verifier,
2599
+ inheritPortableObjectVerifier: false,
2600
+ }),
2601
+ );
2602
+ deepStrictEqual(optedOut.length, 3);
2603
+ for (const item of optedOut) ok(getDefaultVerifier(item) == null);
2604
+ });
2605
+ });
2606
+
2607
+ test("getObject() follows location hints made by withGatewayHints()", async () => {
2608
+ const activity = new Create({
2609
+ id: parseIri(`ap://${did}/activities/1`),
2610
+ object: withGatewayHints(objectId, [
2611
+ "https://gw1.example",
2612
+ "https://gw2.example",
2613
+ ]),
2614
+ });
2615
+ const json = await activity.toJsonLd({ contextLoader: mockDocumentLoader });
2616
+ deepStrictEqual(
2617
+ (json as Record<string, unknown>).object,
2618
+ `${objectId}?@gateway=https%3A%2F%2Fgw1.example` +
2619
+ "&@gateway=https%3A%2F%2Fgw2.example",
2620
+ );
2621
+ const parsed = await Create.fromJsonLd(json, {
2622
+ contextLoader: mockDocumentLoader,
2623
+ });
2624
+ const documentLoader = createLoader({
2625
+ [`https://gw2.example${gatewayPath}`]: note(),
2626
+ });
2627
+ const object = await parsed.getObject({
2628
+ documentLoader,
2629
+ contextLoader: mockDocumentLoader,
2630
+ verifyPortableObject: createVerifier(),
2631
+ });
2632
+ assertInstanceOf(object, Note);
2633
+ deepStrictEqual(documentLoader.fetched, [
2634
+ `https://gw1.example${gatewayPath}`,
2635
+ `https://gw2.example${gatewayPath}`,
2636
+ ]);
2637
+ });