@fedify/vocab 2.4.0-dev.2035 → 2.4.0-dev.2047

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.
@@ -0,0 +1,872 @@
1
+ import {
2
+ createTestTracerProvider,
3
+ mockDocumentLoader,
4
+ test,
5
+ } from "@fedify/fixture";
6
+ import {
7
+ type DocumentLoader,
8
+ FetchError,
9
+ getDocumentLoader,
10
+ type PortableObjectVerifier,
11
+ type RemoteDocument,
12
+ } from "@fedify/vocab-runtime";
13
+ import fetchMock from "fetch-mock";
14
+ import { deepStrictEqual, ok, rejects } from "node:assert/strict";
15
+ import { assertInstanceOf } from "./utils.ts";
16
+ import { Create, Note } from "./vocab.ts";
17
+
18
+ const did = "did:key:z6Mkabc";
19
+ const objectId = `ap+ef61://${did}/objects/1`;
20
+ const gatewayPath = `/.well-known/apgateway/${did}/objects/1`;
21
+
22
+ function note(id: string = `ap://${did}/objects/1`): Record<string, unknown> {
23
+ return {
24
+ "@context": "https://www.w3.org/ns/activitystreams",
25
+ id,
26
+ type: "Note",
27
+ content: "Portable note",
28
+ };
29
+ }
30
+
31
+ type Response = Record<string, unknown> | unknown[] | number | Error;
32
+
33
+ function createLoader(
34
+ responses: Record<string, Response>,
35
+ ): DocumentLoader & { readonly fetched: string[] } {
36
+ const fetched: string[] = [];
37
+ // deno-lint-ignore require-await
38
+ const loader = async (url: string): Promise<RemoteDocument> => {
39
+ fetched.push(url);
40
+ const response = responses[url];
41
+ if (response == null) {
42
+ throw new FetchError(
43
+ url,
44
+ "HTTP 404",
45
+ new globalThis.Response(null, {
46
+ status: 404,
47
+ }),
48
+ );
49
+ }
50
+ if (response instanceof Error) throw response;
51
+ if (typeof response === "number") {
52
+ throw new FetchError(
53
+ url,
54
+ `HTTP ${response}`,
55
+ new globalThis.Response(null, { status: response }),
56
+ );
57
+ }
58
+ return { contextUrl: null, documentUrl: url, document: response };
59
+ };
60
+ return Object.assign(loader, { fetched });
61
+ }
62
+
63
+ function createVerifier(
64
+ verified = true,
65
+ ): PortableObjectVerifier & { readonly documents: unknown[] } {
66
+ const documents: unknown[] = [];
67
+ // deno-lint-ignore require-await
68
+ const verifier = async (document: unknown) => {
69
+ documents.push(document);
70
+ return { verified };
71
+ };
72
+ return Object.assign(verifier, { documents });
73
+ }
74
+
75
+ function createActivity(object: string = objectId): Promise<Create> {
76
+ return Create.fromJsonLd({
77
+ "@context": "https://www.w3.org/ns/activitystreams",
78
+ type: "Create",
79
+ id: `ap://${did}/activities/1`,
80
+ object,
81
+ }, { contextLoader: mockDocumentLoader });
82
+ }
83
+
84
+ test("getObject() dereferences a portable IRI through explicit gateways", async () => {
85
+ const document = note();
86
+ const documentLoader = createLoader({
87
+ [`https://gw1.example${gatewayPath}`]: document,
88
+ });
89
+ const verifyPortableObject = createVerifier();
90
+ const activity = await createActivity();
91
+ const object = await activity.getObject({
92
+ documentLoader,
93
+ contextLoader: mockDocumentLoader,
94
+ gateways: ["https://gw1.example", new URL("https://gw2.example/")],
95
+ verifyPortableObject,
96
+ });
97
+ assertInstanceOf(object, Note);
98
+ deepStrictEqual(
99
+ object.id,
100
+ new URL("ap+ef61://did%3Akey%3Az6Mkabc/objects/1"),
101
+ );
102
+ deepStrictEqual(object.content, "Portable note");
103
+ deepStrictEqual(documentLoader.fetched, [
104
+ `https://gw1.example${gatewayPath}`,
105
+ ]);
106
+ // The verifier receives the fetched JSON as is:
107
+ deepStrictEqual(verifyPortableObject.documents, [document]);
108
+
109
+ // The fetched object is cached like any other dereferenced object:
110
+ deepStrictEqual(
111
+ (await activity.getObject({
112
+ documentLoader,
113
+ contextLoader: mockDocumentLoader,
114
+ }))?.content,
115
+ "Portable note",
116
+ );
117
+ deepStrictEqual(documentLoader.fetched.length, 1);
118
+ });
119
+
120
+ test("getObject() keeps non-hint query parameters for gateways", async () => {
121
+ const documentLoader = createLoader({
122
+ [`https://gw.example${gatewayPath}?page=2`]: note(
123
+ `ap://${did}/objects/1?page=2`,
124
+ ),
125
+ });
126
+ const activity = await createActivity(
127
+ `${objectId}?page=2&@gateway=https%3A%2F%2Fgw.example`,
128
+ );
129
+ const object = await activity.getObject({
130
+ documentLoader,
131
+ contextLoader: mockDocumentLoader,
132
+ verifyPortableObject: createVerifier(),
133
+ });
134
+ assertInstanceOf(object, Note);
135
+ deepStrictEqual(documentLoader.fetched, [
136
+ `https://gw.example${gatewayPath}?page=2`,
137
+ ]);
138
+ });
139
+
140
+ test("getObject() tries the next gateway after failures", async () => {
141
+ const documentLoader = createLoader({
142
+ [`https://gw2.example${gatewayPath}`]: new TypeError("Network error"),
143
+ [`https://gw3.example${gatewayPath}`]: 500,
144
+ [`https://gw4.example${gatewayPath}`]: note(),
145
+ [`https://gw5.example${gatewayPath}`]: note(),
146
+ });
147
+ const [tracerProvider, exporter] = createTestTracerProvider();
148
+ const activity = await createActivity();
149
+ const object = await activity.getObject({
150
+ documentLoader,
151
+ contextLoader: mockDocumentLoader,
152
+ tracerProvider,
153
+ gateways: [
154
+ "https://gw1.example", // 404 Not Found
155
+ "https://gw2.example", // network error
156
+ "https://gw3.example", // 500 Internal Server Error
157
+ "https://gw4.example",
158
+ "https://gw5.example",
159
+ ],
160
+ verifyPortableObject: createVerifier(),
161
+ });
162
+ assertInstanceOf(object, Note);
163
+ // Stops at the first gateway that serves a valid object:
164
+ deepStrictEqual(documentLoader.fetched, [
165
+ `https://gw1.example${gatewayPath}`,
166
+ `https://gw2.example${gatewayPath}`,
167
+ `https://gw3.example${gatewayPath}`,
168
+ `https://gw4.example${gatewayPath}`,
169
+ ]);
170
+ const spans = exporter.getSpans("activitypub.lookup_object");
171
+ deepStrictEqual(spans.length, 1);
172
+ deepStrictEqual(spans[0].status.code, 0); // UNSET
173
+ deepStrictEqual(
174
+ spans[0].attributes["activitypub.gateway"],
175
+ "https://gw4.example/",
176
+ );
177
+ deepStrictEqual(
178
+ spans[0].attributes["activitypub.object.type"],
179
+ "https://www.w3.org/ns/activitystreams#Note",
180
+ );
181
+ });
182
+
183
+ test("getObject() reports errors when every gateway fails", async () => {
184
+ const single = createLoader({ [`https://gw1.example${gatewayPath}`]: 500 });
185
+ await rejects(
186
+ async () =>
187
+ await (await createActivity()).getObject({
188
+ documentLoader: single,
189
+ contextLoader: mockDocumentLoader,
190
+ gateways: ["https://gw1.example"],
191
+ verifyPortableObject: createVerifier(),
192
+ }),
193
+ FetchError,
194
+ );
195
+
196
+ const multiple = createLoader({
197
+ [`https://gw1.example${gatewayPath}`]: 500,
198
+ [`https://gw2.example${gatewayPath}`]: new TypeError("Network error"),
199
+ });
200
+ const options = {
201
+ documentLoader: multiple,
202
+ contextLoader: mockDocumentLoader,
203
+ gateways: ["https://gw1.example", "https://gw2.example"],
204
+ verifyPortableObject: createVerifier(),
205
+ };
206
+ await rejects(
207
+ async () => await (await createActivity()).getObject(options),
208
+ (error) => {
209
+ assertInstanceOf(error, AggregateError);
210
+ deepStrictEqual(error.errors.length, 2);
211
+ assertInstanceOf(error.errors[0], FetchError);
212
+ assertInstanceOf(error.errors[1], TypeError);
213
+ return true;
214
+ },
215
+ );
216
+
217
+ const [tracerProvider, exporter] = createTestTracerProvider();
218
+ deepStrictEqual(
219
+ await (await createActivity()).getObject({
220
+ ...options,
221
+ suppressError: true,
222
+ tracerProvider,
223
+ }),
224
+ null,
225
+ );
226
+ const spans = exporter.getSpans("activitypub.lookup_object");
227
+ deepStrictEqual(spans.length, 1);
228
+ deepStrictEqual(spans[0].status.code, 2); // ERROR
229
+
230
+ // A document that cannot be parsed is an error as well:
231
+ const unparsable = createLoader({
232
+ [`https://gw1.example${gatewayPath}`]: {
233
+ ...note(),
234
+ "@context": "https://example.com/unknown-context",
235
+ },
236
+ });
237
+ await rejects(
238
+ async () =>
239
+ await (await createActivity()).getObject({
240
+ documentLoader: unparsable,
241
+ contextLoader: mockDocumentLoader,
242
+ gateways: ["https://gw1.example"],
243
+ verifyPortableObject: createVerifier(),
244
+ }),
245
+ );
246
+ });
247
+
248
+ test("getObject() rejects objects with a mismatching portable ID", async (t) => {
249
+ const mismatches = {
250
+ "another DID": `ap://did:key:z6Mkdef/objects/1`,
251
+ "another path": `ap://${did}/objects/2`,
252
+ "a dot segment": `ap://${did}/objects/x/../1`,
253
+ "a fragment": `ap://${did}/objects/1#fragment`,
254
+ "a compatible identifier": `https://gw1.example${gatewayPath}`,
255
+ "an HTTP(S) ID": "https://gw1.example/objects/1",
256
+ };
257
+ for (const [name, id] of globalThis.Object.entries(mismatches)) {
258
+ await t.step(name, async () => {
259
+ const documentLoader = createLoader({
260
+ [`https://gw1.example${gatewayPath}`]: note(id),
261
+ });
262
+ const options = {
263
+ documentLoader,
264
+ contextLoader: mockDocumentLoader,
265
+ gateways: ["https://gw1.example"],
266
+ verifyPortableObject: createVerifier(),
267
+ };
268
+ deepStrictEqual(await (await createActivity()).getObject(options), null);
269
+ deepStrictEqual(
270
+ await (await createActivity()).getObject({
271
+ ...options,
272
+ crossOrigin: "trust",
273
+ }),
274
+ null,
275
+ );
276
+ deepStrictEqual(
277
+ await (await createActivity()).getObject({
278
+ ...options,
279
+ crossOrigin: "throw",
280
+ suppressError: true,
281
+ }),
282
+ null,
283
+ );
284
+ await rejects(
285
+ async () =>
286
+ await (await createActivity()).getObject({
287
+ ...options,
288
+ crossOrigin: "throw",
289
+ }),
290
+ /No gateway returned a valid portable object/,
291
+ );
292
+ });
293
+ }
294
+
295
+ await t.step("equivalent spellings", async () => {
296
+ for (
297
+ const id of [
298
+ `ap+ef61://${did}/objects/1`,
299
+ `ap://did%3Akey%3Az6Mkabc/objects/1`,
300
+ `ap://${did}/objects/1?@gateway=https%3A%2F%2Fother.example`,
301
+ ]
302
+ ) {
303
+ const documentLoader = createLoader({
304
+ [`https://gw1.example${gatewayPath}`]: note(id),
305
+ });
306
+ const object = await (await createActivity()).getObject({
307
+ documentLoader,
308
+ contextLoader: mockDocumentLoader,
309
+ gateways: ["https://gw1.example"],
310
+ verifyPortableObject: createVerifier(),
311
+ });
312
+ assertInstanceOf(object, Note);
313
+ }
314
+ });
315
+
316
+ await t.step("a mismatch does not stop other gateways", async () => {
317
+ const documentLoader = createLoader({
318
+ [`https://gw1.example${gatewayPath}`]: note(`ap://${did}/objects/2`),
319
+ [`https://gw2.example${gatewayPath}`]: 500,
320
+ [`https://gw3.example${gatewayPath}`]: note(),
321
+ });
322
+ const object = await (await createActivity()).getObject({
323
+ documentLoader,
324
+ contextLoader: mockDocumentLoader,
325
+ gateways: [
326
+ "https://gw1.example",
327
+ "https://gw2.example",
328
+ "https://gw3.example",
329
+ ],
330
+ verifyPortableObject: createVerifier(),
331
+ crossOrigin: "throw",
332
+ });
333
+ assertInstanceOf(object, Note);
334
+ });
335
+
336
+ await t.step(
337
+ "a mismatch among errors is reported as a mismatch",
338
+ async () => {
339
+ const documentLoader = createLoader({
340
+ [`https://gw1.example${gatewayPath}`]: 500,
341
+ [`https://gw2.example${gatewayPath}`]: note(`ap://${did}/objects/2`),
342
+ });
343
+ deepStrictEqual(
344
+ await (await createActivity()).getObject({
345
+ documentLoader,
346
+ contextLoader: mockDocumentLoader,
347
+ gateways: ["https://gw1.example", "https://gw2.example"],
348
+ verifyPortableObject: createVerifier(),
349
+ }),
350
+ null,
351
+ );
352
+ },
353
+ );
354
+ });
355
+
356
+ test("getObject() rejects documents with other shapes", async () => {
357
+ for (
358
+ const document of [
359
+ [note()],
360
+ {
361
+ "@context": "https://www.w3.org/ns/activitystreams",
362
+ "@graph": [note(), note(`ap://${did}/objects/2`)],
363
+ },
364
+ ]
365
+ ) {
366
+ const documentLoader = createLoader({
367
+ [`https://gw1.example${gatewayPath}`]: document,
368
+ });
369
+ deepStrictEqual(
370
+ await (await createActivity()).getObject({
371
+ documentLoader,
372
+ contextLoader: mockDocumentLoader,
373
+ gateways: ["https://gw1.example"],
374
+ verifyPortableObject: createVerifier(),
375
+ }),
376
+ null,
377
+ );
378
+ }
379
+ });
380
+
381
+ test("getObject() leaves documents without @id to the verifier", async () => {
382
+ const document = {
383
+ "@context": "https://www.w3.org/ns/activitystreams",
384
+ type: "Note",
385
+ content: "No ID",
386
+ };
387
+ const documentLoader = createLoader({
388
+ [`https://gw1.example${gatewayPath}`]: document,
389
+ });
390
+ const options = {
391
+ documentLoader,
392
+ contextLoader: mockDocumentLoader,
393
+ gateways: ["https://gw1.example"],
394
+ };
395
+ const rejecting = createVerifier(false);
396
+ deepStrictEqual(
397
+ await (await createActivity()).getObject({
398
+ ...options,
399
+ verifyPortableObject: rejecting,
400
+ }),
401
+ null,
402
+ );
403
+ deepStrictEqual(rejecting.documents, [document]);
404
+ const object = await (await createActivity()).getObject({
405
+ ...options,
406
+ verifyPortableObject: createVerifier(),
407
+ });
408
+ assertInstanceOf(object, Note);
409
+ deepStrictEqual(object.id, null);
410
+ });
411
+
412
+ test("getObject() applies the portable object verifier", async () => {
413
+ const gateways = ["https://gw1.example", "https://gw2.example"];
414
+ const documentLoader = createLoader({
415
+ [`https://gw1.example${gatewayPath}`]: note(),
416
+ [`https://gw2.example${gatewayPath}`]: note(),
417
+ });
418
+ const failing = createVerifier(false);
419
+ deepStrictEqual(
420
+ await (await createActivity()).getObject({
421
+ documentLoader,
422
+ contextLoader: mockDocumentLoader,
423
+ gateways,
424
+ verifyPortableObject: failing,
425
+ crossOrigin: "trust",
426
+ }),
427
+ null,
428
+ );
429
+ deepStrictEqual(failing.documents.length, 2);
430
+ await rejects(
431
+ async () =>
432
+ await (await createActivity()).getObject({
433
+ documentLoader,
434
+ contextLoader: mockDocumentLoader,
435
+ gateways,
436
+ verifyPortableObject: failing,
437
+ crossOrigin: "throw",
438
+ }),
439
+ /No gateway returned a valid portable object/,
440
+ );
441
+
442
+ deepStrictEqual(
443
+ await (await createActivity()).getObject({
444
+ documentLoader,
445
+ contextLoader: mockDocumentLoader,
446
+ gateways,
447
+ // deno-lint-ignore require-await
448
+ verifyPortableObject: async () => {
449
+ throw new Error("Verifier failure");
450
+ },
451
+ }),
452
+ null,
453
+ );
454
+
455
+ // A failed dereference is not cached; a later call retries:
456
+ const activity = await createActivity();
457
+ deepStrictEqual(
458
+ await activity.getObject({
459
+ documentLoader,
460
+ contextLoader: mockDocumentLoader,
461
+ gateways,
462
+ verifyPortableObject: failing,
463
+ }),
464
+ null,
465
+ );
466
+ assertInstanceOf(
467
+ await activity.getObject({
468
+ documentLoader,
469
+ contextLoader: mockDocumentLoader,
470
+ gateways,
471
+ verifyPortableObject: createVerifier(),
472
+ }),
473
+ Note,
474
+ );
475
+ });
476
+
477
+ test("getObject() requires a verifier before fetching portable IRIs", async () => {
478
+ const documentLoader = createLoader({
479
+ [`https://gw1.example${gatewayPath}`]: note(),
480
+ });
481
+ await rejects(
482
+ async () =>
483
+ await (await createActivity()).getObject({
484
+ documentLoader,
485
+ contextLoader: mockDocumentLoader,
486
+ gateways: ["https://gw1.example"],
487
+ }),
488
+ (error) => {
489
+ assertInstanceOf(error, TypeError);
490
+ ok(error.message.includes("verifyPortableObject"));
491
+ return true;
492
+ },
493
+ );
494
+ deepStrictEqual(
495
+ await (await createActivity()).getObject({
496
+ documentLoader,
497
+ contextLoader: mockDocumentLoader,
498
+ gateways: ["https://gw1.example"],
499
+ suppressError: true,
500
+ }),
501
+ null,
502
+ );
503
+ deepStrictEqual(documentLoader.fetched, []);
504
+ });
505
+
506
+ test("getObject() suppresses errors for malformed portable IRIs", async () => {
507
+ const activity = await createActivity(`ap://${did}/objects/%ZZ`);
508
+ for (const gateways of [[], ["https://gw1.example"]]) {
509
+ const documentLoader = createLoader({});
510
+ deepStrictEqual(
511
+ await activity.getObject({
512
+ documentLoader,
513
+ contextLoader: mockDocumentLoader,
514
+ gateways,
515
+ verifyPortableObject: createVerifier(),
516
+ suppressError: true,
517
+ }),
518
+ null,
519
+ );
520
+ deepStrictEqual(documentLoader.fetched, []);
521
+ await rejects(async () =>
522
+ await activity.getObject({
523
+ documentLoader,
524
+ contextLoader: mockDocumentLoader,
525
+ gateways,
526
+ verifyPortableObject: createVerifier(),
527
+ }), TypeError);
528
+ }
529
+ });
530
+
531
+ test("getObject() rejects invalid gateways before fetching", async () => {
532
+ const documentLoader = createLoader({});
533
+ for (
534
+ const gateway of [
535
+ "https://gw.example/path",
536
+ "https://gw.example/?query",
537
+ "https://user@gw.example",
538
+ "ftp://gw.example",
539
+ "not a URL",
540
+ ]
541
+ ) {
542
+ await rejects(
543
+ async () =>
544
+ await (await createActivity()).getObject({
545
+ documentLoader,
546
+ contextLoader: mockDocumentLoader,
547
+ gateways: [gateway],
548
+ verifyPortableObject: createVerifier(),
549
+ suppressError: true,
550
+ }),
551
+ TypeError,
552
+ );
553
+ }
554
+ deepStrictEqual(documentLoader.fetched, []);
555
+ });
556
+
557
+ test("getObject() uses @gateway location hints", async (t) => {
558
+ await t.step("in order, skipping invalid and duplicate hints", async () => {
559
+ const documentLoader = createLoader({
560
+ [`https://gw2.example${gatewayPath}`]: note(),
561
+ });
562
+ const activity = await createActivity(
563
+ `${objectId}?@gateway=https%3A%2F%2Fgw1.example` +
564
+ "&@gateway=https%3A%2F%2Fgw1.example%2F" +
565
+ "&@gateway=https%3A%2F%2Finvalid.example%2Fpath" +
566
+ "&%40gateway=https%3A%2F%2Fgw2.example" +
567
+ "&gateways=https%3A%2F%2Flegacy.example",
568
+ );
569
+ const object = await activity.getObject({
570
+ documentLoader,
571
+ contextLoader: mockDocumentLoader,
572
+ verifyPortableObject: createVerifier(),
573
+ });
574
+ assertInstanceOf(object, Note);
575
+ deepStrictEqual(documentLoader.fetched, [
576
+ `https://gw1.example${gatewayPath}`,
577
+ `https://gw2.example${gatewayPath}`,
578
+ ]);
579
+ });
580
+
581
+ await t.step("up to five hints", async () => {
582
+ const documentLoader = createLoader({});
583
+ const hints = [1, 2, 3, 4, 5, 6, 7].map((i) =>
584
+ `@gateway=https%3A%2F%2Fgw${i}.example`
585
+ );
586
+ const activity = await createActivity(`${objectId}?${hints.join("&")}`);
587
+ await rejects(async () =>
588
+ await activity.getObject({
589
+ documentLoader,
590
+ contextLoader: mockDocumentLoader,
591
+ verifyPortableObject: createVerifier(),
592
+ })
593
+ );
594
+ deepStrictEqual(
595
+ documentLoader.fetched,
596
+ [1, 2, 3, 4, 5].map((i) => `https://gw${i}.example${gatewayPath}`),
597
+ );
598
+ });
599
+
600
+ await t.step("replaced by explicit gateways", async () => {
601
+ const documentLoader = createLoader({
602
+ [`https://explicit.example${gatewayPath}`]: note(),
603
+ });
604
+ const activity = await createActivity(
605
+ `${objectId}?@gateway=https%3A%2F%2Fhint.example`,
606
+ );
607
+ const object = await activity.getObject({
608
+ documentLoader,
609
+ contextLoader: mockDocumentLoader,
610
+ gateways: ["https://explicit.example"],
611
+ verifyPortableObject: createVerifier(),
612
+ });
613
+ assertInstanceOf(object, Note);
614
+ deepStrictEqual(documentLoader.fetched, [
615
+ `https://explicit.example${gatewayPath}`,
616
+ ]);
617
+ });
618
+
619
+ await t.step("disabled by an empty gateway list", async () => {
620
+ const documentLoader = createLoader({});
621
+ const activity = await createActivity(
622
+ `${objectId}?@gateway=https%3A%2F%2Fhint.example`,
623
+ );
624
+ deepStrictEqual(
625
+ await activity.getObject({
626
+ documentLoader,
627
+ contextLoader: mockDocumentLoader,
628
+ gateways: [],
629
+ verifyPortableObject: createVerifier(),
630
+ suppressError: true,
631
+ }),
632
+ null,
633
+ );
634
+ deepStrictEqual(documentLoader.fetched, [
635
+ `ap+ef61://${did}/objects/1?@gateway=https%3A%2F%2Fhint.example`,
636
+ ]);
637
+ });
638
+ });
639
+
640
+ test("getObject() lets the document loader handle portable IRIs without gateways", async () => {
641
+ const documentLoader = createLoader({ [objectId]: note() });
642
+ const activity = await createActivity();
643
+ const object = await activity.getObject({
644
+ documentLoader,
645
+ contextLoader: mockDocumentLoader,
646
+ verifyPortableObject: createVerifier(),
647
+ });
648
+ assertInstanceOf(object, Note);
649
+ deepStrictEqual(documentLoader.fetched, [objectId]);
650
+
651
+ // The same validation applies:
652
+ const spoofing = createLoader({ [objectId]: note(`ap://${did}/objects/2`) });
653
+ deepStrictEqual(
654
+ await (await createActivity()).getObject({
655
+ documentLoader: spoofing,
656
+ contextLoader: mockDocumentLoader,
657
+ verifyPortableObject: createVerifier(),
658
+ }),
659
+ null,
660
+ );
661
+ deepStrictEqual(
662
+ await (await createActivity()).getObject({
663
+ documentLoader,
664
+ contextLoader: mockDocumentLoader,
665
+ verifyPortableObject: createVerifier(false),
666
+ }),
667
+ null,
668
+ );
669
+ });
670
+
671
+ test("getObject() shares context documents within a dereference", async (t) => {
672
+ const contextUrl = "https://example.com/portable-context";
673
+ const document = {
674
+ "@context": ["https://www.w3.org/ns/activitystreams", contextUrl],
675
+ id: `ap://${did}/objects/1`,
676
+ type: "Note",
677
+ content: "Portable note",
678
+ };
679
+
680
+ await t.step("custom contexts are loaded once", async () => {
681
+ const loaded: string[] = [];
682
+ let version = 0;
683
+ const contextLoader: DocumentLoader = async (url) => {
684
+ loaded.push(url);
685
+ if (url === contextUrl) {
686
+ version++;
687
+ return {
688
+ contextUrl: null,
689
+ documentUrl: url,
690
+ document: { "@context": { version: `urn:version:${version}` } },
691
+ };
692
+ }
693
+ return await mockDocumentLoader(url);
694
+ };
695
+ const contexts: unknown[] = [];
696
+ const verifyPortableObject: PortableObjectVerifier = async (
697
+ _,
698
+ { contextLoader },
699
+ ) => {
700
+ contexts.push((await contextLoader!(contextUrl)).document);
701
+ contexts.push((await contextLoader!(contextUrl)).document);
702
+ return { verified: true };
703
+ };
704
+ const object = await (await createActivity()).getObject({
705
+ documentLoader: createLoader({
706
+ [`https://gw1.example${gatewayPath}`]: document,
707
+ }),
708
+ contextLoader,
709
+ gateways: ["https://gw1.example"],
710
+ verifyPortableObject,
711
+ });
712
+ assertInstanceOf(object, Note);
713
+ deepStrictEqual(loaded.filter((url) => url === contextUrl).length, 1);
714
+ deepStrictEqual(contexts, [
715
+ { "@context": { version: "urn:version:1" } },
716
+ { "@context": { version: "urn:version:1" } },
717
+ ]);
718
+ });
719
+
720
+ await t.step("baseline contexts use built-in copies", async () => {
721
+ const loaded: string[] = [];
722
+ const contextLoader: DocumentLoader = async (url) => {
723
+ loaded.push(url);
724
+ return await mockDocumentLoader(url);
725
+ };
726
+ const object = await (await createActivity()).getObject({
727
+ documentLoader: createLoader({
728
+ [`https://gw1.example${gatewayPath}`]: note(),
729
+ }),
730
+ contextLoader,
731
+ gateways: ["https://gw1.example"],
732
+ verifyPortableObject: createVerifier(),
733
+ });
734
+ assertInstanceOf(object, Note);
735
+ deepStrictEqual(loaded, []);
736
+ });
737
+
738
+ await t.step("the snapshot ends with the dereference", async () => {
739
+ const loaded: string[] = [];
740
+ const contextLoader: DocumentLoader = async (url) => {
741
+ loaded.push(url);
742
+ return await mockDocumentLoader(url);
743
+ };
744
+ const object = await (await createActivity()).getObject({
745
+ documentLoader: createLoader({
746
+ [`https://gw1.example${gatewayPath}`]: {
747
+ ...note(),
748
+ attributedTo: "https://example.com/person",
749
+ },
750
+ }),
751
+ contextLoader,
752
+ gateways: ["https://gw1.example"],
753
+ verifyPortableObject: createVerifier(),
754
+ });
755
+ assertInstanceOf(object, Note);
756
+ deepStrictEqual(loaded, []);
757
+ // Later dereferences on the returned object use the caller's context
758
+ // loader again, including for the baseline contexts:
759
+ const person = await object.getAttribution({
760
+ documentLoader: createLoader({
761
+ "https://example.com/person": {
762
+ "@context": "https://www.w3.org/ns/activitystreams",
763
+ id: "https://example.com/person",
764
+ type: "Person",
765
+ },
766
+ }),
767
+ });
768
+ deepStrictEqual(person?.id, new URL("https://example.com/person"));
769
+ ok((loaded as string[]).includes("https://www.w3.org/ns/activitystreams"));
770
+ });
771
+
772
+ await t.step("failed context loads are retried", async () => {
773
+ let failures = 1;
774
+ const contextLoader: DocumentLoader = async (url) => {
775
+ if (url === contextUrl && failures-- > 0) {
776
+ throw new TypeError("Temporary failure");
777
+ }
778
+ if (url === contextUrl) {
779
+ return {
780
+ contextUrl: null,
781
+ documentUrl: url,
782
+ document: { "@context": {} },
783
+ };
784
+ }
785
+ return await mockDocumentLoader(url);
786
+ };
787
+ const object = await (await createActivity()).getObject({
788
+ documentLoader: createLoader({
789
+ [`https://gw1.example${gatewayPath}`]: document,
790
+ [`https://gw2.example${gatewayPath}`]: document,
791
+ }),
792
+ contextLoader,
793
+ gateways: ["https://gw1.example", "https://gw2.example"],
794
+ verifyPortableObject: createVerifier(),
795
+ });
796
+ assertInstanceOf(object, Note);
797
+ });
798
+ });
799
+
800
+ test("getObjects() dereferences each portable IRI separately", async () => {
801
+ const activity = await Create.fromJsonLd({
802
+ "@context": "https://www.w3.org/ns/activitystreams",
803
+ type: "Create",
804
+ id: `ap://${did}/activities/1`,
805
+ object: [objectId, `ap://${did}/objects/2`],
806
+ }, { contextLoader: mockDocumentLoader });
807
+ const documentLoader = createLoader({
808
+ [`https://gw.example${gatewayPath}`]: note(),
809
+ [`https://gw.example/.well-known/apgateway/${did}/objects/2`]: note(),
810
+ });
811
+ const objects = await Array.fromAsync(activity.getObjects({
812
+ documentLoader,
813
+ contextLoader: mockDocumentLoader,
814
+ gateways: ["https://gw.example"],
815
+ verifyPortableObject: createVerifier(),
816
+ }));
817
+ // The second one is rejected because its @id does not match:
818
+ deepStrictEqual(objects.length, 1);
819
+ deepStrictEqual(
820
+ objects[0].id?.href,
821
+ "ap+ef61://did%3Akey%3Az6Mkabc/objects/1",
822
+ );
823
+ });
824
+
825
+ test("getObject() ignores gateway options for HTTP(S) IRIs", async () => {
826
+ const activity = new Create({
827
+ object: new URL("https://example.com/object"),
828
+ });
829
+ const object = await activity.getObject({
830
+ documentLoader: mockDocumentLoader,
831
+ contextLoader: mockDocumentLoader,
832
+ gateways: ["https://gw.example"],
833
+ });
834
+ deepStrictEqual(object?.id, new URL("https://example.com/object"));
835
+ });
836
+
837
+ test("getObject() sends the ActivityStreams Accept header to gateways", {
838
+ sanitizeResources: false,
839
+ sanitizeOps: false,
840
+ }, async () => {
841
+ fetchMock.spyGlobal();
842
+ let accept: string | null = null;
843
+ fetchMock.get(`https://example.com${gatewayPath}`, (callLog) => {
844
+ accept = callLog.request?.headers.get("Accept") ??
845
+ new Headers(callLog.options.headers).get("Accept");
846
+ return {
847
+ headers: {
848
+ "Content-Type":
849
+ 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
850
+ },
851
+ body: note(),
852
+ };
853
+ });
854
+ try {
855
+ const object = await (await createActivity()).getObject({
856
+ documentLoader: getDocumentLoader(),
857
+ contextLoader: mockDocumentLoader,
858
+ gateways: ["https://example.com"],
859
+ verifyPortableObject: createVerifier(),
860
+ });
861
+ assertInstanceOf(object, Note);
862
+ ok(
863
+ accept != null &&
864
+ (accept as string).includes(
865
+ 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
866
+ ),
867
+ `Unexpected Accept header: ${accept}`,
868
+ );
869
+ } finally {
870
+ fetchMock.hardReset();
871
+ }
872
+ });