@fedify/vocab 2.4.0-dev.2056 → 2.4.0-dev.2065
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.
- package/deno.json +1 -1
- package/dist/mod.cjs +16674 -9026
- package/dist/mod.d.cts +28 -2
- package/dist/mod.d.ts +28 -2
- package/dist/mod.js +16675 -9027
- package/dist-tests/{actor-BgeBgt_8.mjs → actor-B0IFZjgb.mjs} +2 -2
- package/dist-tests/actor.test.mjs +3 -3
- package/dist-tests/{esm-Df0Yo-Tp.mjs → esm-C9nxP5_e.mjs} +1 -1
- package/dist-tests/gateway.test.mjs +695 -7
- package/dist-tests/{lookup-C13R72YU.mjs → lookup-BnzAXNN7.mjs} +48 -49
- package/dist-tests/lookup.test.mjs +3 -3
- package/dist-tests/portable-lookup.test.mjs +100 -4
- package/dist-tests/signed-representation.test.mjs +1 -1
- package/dist-tests/translation.test.mjs +3 -3
- package/dist-tests/type.test.mjs +1 -1
- package/dist-tests/{vocab-BJGPw--L.mjs → vocab-BHtBRusa.mjs} +16631 -8982
- package/dist-tests/vocab.test.mjs +1 -1
- package/package.json +4 -4
- package/src/gateway.test.ts +980 -7
- package/src/lookup.ts +114 -65
- package/src/portable-lookup.test.ts +133 -0
package/src/lookup.ts
CHANGED
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
canonicalizePortableUri,
|
|
7
7
|
type DocumentLoader,
|
|
8
8
|
formatIri,
|
|
9
|
-
fromCompatibleEf61Id,
|
|
10
9
|
getDocumentLoader,
|
|
11
10
|
haveSameFe34Origin,
|
|
12
11
|
haveSameIriOrigin,
|
|
@@ -14,9 +13,12 @@ import {
|
|
|
14
13
|
type RemoteDocument,
|
|
15
14
|
} from "@fedify/vocab-runtime";
|
|
16
15
|
import {
|
|
16
|
+
createSnapshotContextLoader,
|
|
17
17
|
dereferencePortableIri,
|
|
18
18
|
getPortableGatewayCandidates,
|
|
19
|
+
getPortableResponseClaim,
|
|
19
20
|
isPortableIri,
|
|
21
|
+
parseCompatibleEf61Reference,
|
|
20
22
|
PortableObjectRejectedError,
|
|
21
23
|
} from "@fedify/vocab-runtime/internal/portable-dereference";
|
|
22
24
|
import { lookupWebFinger } from "@fedify/webfinger";
|
|
@@ -193,8 +195,11 @@ export interface LookupObjectOptions {
|
|
|
193
195
|
* whether they are looked up directly or found in the `self` links of
|
|
194
196
|
* a WebFinger response, are fetched through FEP-ef61 gateways. A fetched
|
|
195
197
|
* portable object is returned only if its `@id` identifies the requested
|
|
196
|
-
* portable object and this function accepts it.
|
|
197
|
-
*
|
|
198
|
+
* portable object and this function accepts it. A document fetched from
|
|
199
|
+
* an ordinary HTTP(S) URL is checked the same way if its final URL or its
|
|
200
|
+
* `@id` is a portable or compatible identifier, instead of being trusted
|
|
201
|
+
* because of its origin. Note that `crossOrigin: "trust"` does not skip
|
|
202
|
+
* these checks.
|
|
198
203
|
*
|
|
199
204
|
* Without it, portable identifiers are not looked up, and compatible
|
|
200
205
|
* identifiers are fetched as ordinary HTTP(S) URLs, whose objects with
|
|
@@ -434,52 +439,82 @@ async function lookupObjectInternal(
|
|
|
434
439
|
}
|
|
435
440
|
}
|
|
436
441
|
if (remoteDoc == null) return null;
|
|
437
|
-
|
|
438
|
-
|
|
442
|
+
// With verifyPortableObject, the document may turn out to be a portable
|
|
443
|
+
// object, which has to be verified with the same context documents:
|
|
444
|
+
const snapshot = options.verifyPortableObject == null
|
|
445
|
+
? null
|
|
446
|
+
: createSnapshotContextLoader(
|
|
447
|
+
options.contextLoader ??
|
|
448
|
+
getDocumentLoader({ userAgent: options.userAgent }),
|
|
449
|
+
);
|
|
439
450
|
try {
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
451
|
+
let object: Object;
|
|
452
|
+
let documentUrl: URL;
|
|
453
|
+
try {
|
|
454
|
+
documentUrl = parseIri(remoteDoc.documentUrl);
|
|
455
|
+
object = await Object.fromJsonLd(remoteDoc.document, {
|
|
456
|
+
documentLoader,
|
|
457
|
+
contextLoader: snapshot?.loader ?? options.contextLoader,
|
|
458
|
+
tracerProvider: options.tracerProvider,
|
|
459
|
+
baseUrl: documentUrl,
|
|
460
|
+
});
|
|
461
|
+
} catch (error) {
|
|
462
|
+
if (error instanceof TypeError) {
|
|
463
|
+
logger.debug(
|
|
464
|
+
"Failed to parse JSON-LD document: {error}\n{document}",
|
|
465
|
+
{ ...remoteDoc, error },
|
|
466
|
+
);
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
throw error;
|
|
454
470
|
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
+
if (snapshot != null) {
|
|
472
|
+
// A document whose final URL or @id stands for a portable object is
|
|
473
|
+
// verified as one instead of being trusted because of its origin:
|
|
474
|
+
const claim = getPortableResponseClaim(remoteDoc.documentUrl, object.id);
|
|
475
|
+
if (claim === null) {
|
|
476
|
+
logger.debug(
|
|
477
|
+
"Refusing the document {documentUrl}, as it claims to be " +
|
|
478
|
+
"a portable object with a malformed compatible identifier.",
|
|
479
|
+
{ documentUrl: remoteDoc.documentUrl },
|
|
480
|
+
);
|
|
481
|
+
return null;
|
|
482
|
+
} else if (claim != null) {
|
|
483
|
+
return await dereference(portable, claim.id, claim.inferredGateways, {
|
|
484
|
+
response: remoteDoc,
|
|
485
|
+
contextLoader: snapshot.loader,
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
if (
|
|
490
|
+
object.id != null &&
|
|
491
|
+
// A portable object belongs to its DID, not to the server that serves
|
|
492
|
+
// it, so crossOrigin: "trust" does not let a server vouch for it:
|
|
493
|
+
(options.crossOrigin !== "trust" || isPortableIri(object.id)) &&
|
|
494
|
+
!haveSameIriOrigin(object.id, documentUrl) &&
|
|
495
|
+
!haveSameFe34Origin(object.id, documentUrl)
|
|
496
|
+
) {
|
|
497
|
+
if (options.crossOrigin === "throw") {
|
|
498
|
+
throw new Error(
|
|
499
|
+
`The object's @id (${object.id.href}) has a different origin than ` +
|
|
500
|
+
`the document URL (${remoteDoc.documentUrl}); refusing to return ` +
|
|
501
|
+
`the object. If you want to bypass this check and are aware of ` +
|
|
502
|
+
`the security implications, set the crossOrigin option to "trust".`,
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
logger.warn(
|
|
506
|
+
"The object's @id ({objectId}) has a different origin than the " +
|
|
507
|
+
"document URL ({documentUrl}); refusing to return the object. If " +
|
|
508
|
+
"you want to bypass this check and are aware of the security " +
|
|
509
|
+
'implications, set the crossOrigin option to "trust".',
|
|
510
|
+
{ ...remoteDoc, objectId: object.id.href },
|
|
471
511
|
);
|
|
512
|
+
return null;
|
|
472
513
|
}
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
"bypass this check and are aware of the security implications, " +
|
|
477
|
-
'set the crossOrigin option to "trust".',
|
|
478
|
-
{ ...remoteDoc, objectId: object.id.href },
|
|
479
|
-
);
|
|
480
|
-
return null;
|
|
514
|
+
return object;
|
|
515
|
+
} finally {
|
|
516
|
+
snapshot?.release();
|
|
481
517
|
}
|
|
482
|
-
return object;
|
|
483
518
|
}
|
|
484
519
|
|
|
485
520
|
const PORTABLE_IRI_PATTERN = /^ap(?:\+ef61)?:/i;
|
|
@@ -534,25 +569,9 @@ function parsePortableCandidate(iri: string): URL | null {
|
|
|
534
569
|
function getCompatibleCandidate(
|
|
535
570
|
href: string,
|
|
536
571
|
options: LookupObjectOptions,
|
|
537
|
-
): { id: URL; gateway: URL } | null | undefined {
|
|
538
|
-
if (options.verifyPortableObject == null
|
|
539
|
-
|
|
540
|
-
}
|
|
541
|
-
const url = new URL(href);
|
|
542
|
-
try {
|
|
543
|
-
const id = fromCompatibleEf61Id(url);
|
|
544
|
-
if (id == null) return undefined;
|
|
545
|
-
return { id, gateway: new URL(url.origin) };
|
|
546
|
-
} catch (error) {
|
|
547
|
-
if (error instanceof TypeError) {
|
|
548
|
-
logger.debug(
|
|
549
|
-
"Invalid FEP-ef61 compatible identifier {href}: {error}",
|
|
550
|
-
{ href, error },
|
|
551
|
-
);
|
|
552
|
-
return null;
|
|
553
|
-
}
|
|
554
|
-
throw error;
|
|
555
|
-
}
|
|
572
|
+
): { readonly id: URL; readonly gateway: URL } | null | undefined {
|
|
573
|
+
if (options.verifyPortableObject == null) return undefined;
|
|
574
|
+
return parseCompatibleEf61Reference(href);
|
|
556
575
|
}
|
|
557
576
|
|
|
558
577
|
/**
|
|
@@ -629,15 +648,20 @@ async function dereference(
|
|
|
629
648
|
{ options, documentLoader }: PortableLookup,
|
|
630
649
|
id: URL,
|
|
631
650
|
gateways: readonly URL[],
|
|
651
|
+
extra: { response?: RemoteDocument; contextLoader?: DocumentLoader } = {},
|
|
632
652
|
): Promise<Object | null> {
|
|
633
653
|
const tracerProvider = options.tracerProvider ?? trace.getTracerProvider();
|
|
634
654
|
try {
|
|
635
655
|
return await dereferencePortableIri(id, {
|
|
636
656
|
documentLoader,
|
|
637
|
-
contextLoader: options.contextLoader ??
|
|
657
|
+
contextLoader: extra.contextLoader ?? options.contextLoader ??
|
|
638
658
|
getDocumentLoader({ userAgent: options.userAgent }),
|
|
639
659
|
tracerProvider,
|
|
640
|
-
|
|
660
|
+
// Gateways are inferred from the identifier, WebFinger, or location
|
|
661
|
+
// hints, rather than given by the caller, so they are reported to the
|
|
662
|
+
// verifier as hints:
|
|
663
|
+
inferredGateways: gateways,
|
|
664
|
+
response: extra.response,
|
|
641
665
|
verifyPortableObject: options.verifyPortableObject,
|
|
642
666
|
crossOrigin: options.crossOrigin === "throw" ? "throw" : "ignore",
|
|
643
667
|
signal: options.signal,
|
|
@@ -687,6 +711,31 @@ export interface TraverseCollectionOptions {
|
|
|
687
711
|
* @default `{ seconds: 0 }`
|
|
688
712
|
*/
|
|
689
713
|
interval?: Temporal.Duration | Temporal.DurationLike;
|
|
714
|
+
/**
|
|
715
|
+
* Whether to trust objects whose origin differs from the collection or page
|
|
716
|
+
* that refers to them. See the `crossOrigin` option of property accessors
|
|
717
|
+
* such as `Collection.getItems()`.
|
|
718
|
+
* @since 2.4.0
|
|
719
|
+
*/
|
|
720
|
+
crossOrigin?: "ignore" | "throw" | "trust";
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* The [FEP-ef61] gateways to fetch portable (`ap:`/`ap+ef61:`) pages and
|
|
724
|
+
* items through, in order. See the `gateways` option of property
|
|
725
|
+
* accessors.
|
|
726
|
+
*
|
|
727
|
+
* [FEP-ef61]: https://w3id.org/fep/ef61
|
|
728
|
+
* @since 2.4.0
|
|
729
|
+
*/
|
|
730
|
+
gateways?: readonly (string | URL)[];
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* The policy to apply to portable pages and items fetched through
|
|
734
|
+
* gateways, typically `verifyPortableObject()` from `@fedify/fedify`.
|
|
735
|
+
* Portable pages and items are not fetched without it.
|
|
736
|
+
* @since 2.4.0
|
|
737
|
+
*/
|
|
738
|
+
verifyPortableObject?: PortableObjectVerifier;
|
|
690
739
|
}
|
|
691
740
|
|
|
692
741
|
/**
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
FetchError,
|
|
5
5
|
parseIri,
|
|
6
6
|
type PortableObjectVerifier,
|
|
7
|
+
type PortableObjectVerifierOptions,
|
|
7
8
|
type RemoteDocument,
|
|
8
9
|
} from "@fedify/vocab-runtime";
|
|
9
10
|
import fetchMock from "fetch-mock";
|
|
@@ -455,3 +456,135 @@ test("getActorHandle() with FEP-ef61 portable actors", {
|
|
|
455
456
|
fetchMock.hardReset();
|
|
456
457
|
}
|
|
457
458
|
});
|
|
459
|
+
|
|
460
|
+
test("lookupObject() reports inferred gateways as hints", async () => {
|
|
461
|
+
const calls: PortableObjectVerifierOptions[] = [];
|
|
462
|
+
// deno-lint-ignore require-await
|
|
463
|
+
const verifyPortableObject: PortableObjectVerifier = async (_, options) => {
|
|
464
|
+
calls.push(options);
|
|
465
|
+
return { verified: true };
|
|
466
|
+
};
|
|
467
|
+
const actor = await lookupObject(compatibleId, {
|
|
468
|
+
documentLoader: createLoader({ [compatibleId]: person() }),
|
|
469
|
+
contextLoader: mockDocumentLoader,
|
|
470
|
+
verifyPortableObject,
|
|
471
|
+
});
|
|
472
|
+
assertInstanceOf(actor, Person);
|
|
473
|
+
deepStrictEqual(calls.length, 1);
|
|
474
|
+
deepStrictEqual(calls[0].gateways, undefined);
|
|
475
|
+
deepStrictEqual(calls[0].gatewayHints, [new URL("https://example.com")]);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test("lookupObject() verifies fetched documents that stand for portable objects", async (t) => {
|
|
479
|
+
const plainUrl = "https://example.com/users/alice";
|
|
480
|
+
const redirectingLoader = (
|
|
481
|
+
documentUrl: string,
|
|
482
|
+
document: Record<string, unknown>,
|
|
483
|
+
): DocumentLoader & { readonly fetched: string[] } => {
|
|
484
|
+
const fetched: string[] = [];
|
|
485
|
+
// deno-lint-ignore require-await
|
|
486
|
+
const loader = async (url: string): Promise<RemoteDocument> => {
|
|
487
|
+
fetched.push(url);
|
|
488
|
+
if (url !== plainUrl) {
|
|
489
|
+
throw new FetchError(
|
|
490
|
+
url,
|
|
491
|
+
"HTTP 404",
|
|
492
|
+
new globalThis.Response(null, { status: 404 }),
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
return { contextUrl: null, documentUrl, document };
|
|
496
|
+
};
|
|
497
|
+
return Object.assign(loader, { fetched });
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
await t.step("a redirect to a compatible identifier", async () => {
|
|
501
|
+
const calls: PortableObjectVerifierOptions[] = [];
|
|
502
|
+
const documentLoader = redirectingLoader(compatibleId, person());
|
|
503
|
+
const actor = await lookupObject(plainUrl, {
|
|
504
|
+
documentLoader,
|
|
505
|
+
contextLoader: mockDocumentLoader,
|
|
506
|
+
// deno-lint-ignore require-await
|
|
507
|
+
verifyPortableObject: async (_, options) => {
|
|
508
|
+
calls.push(options);
|
|
509
|
+
return { verified: true };
|
|
510
|
+
},
|
|
511
|
+
});
|
|
512
|
+
assertInstanceOf(actor, Person);
|
|
513
|
+
deepStrictEqual(actor.id, parseIri(actorId));
|
|
514
|
+
// Verified in place, without another request:
|
|
515
|
+
deepStrictEqual(documentLoader.fetched, [plainUrl]);
|
|
516
|
+
deepStrictEqual(calls.length, 1);
|
|
517
|
+
deepStrictEqual(calls[0].documentUrl, new URL(compatibleId));
|
|
518
|
+
deepStrictEqual(calls[0].gatewayHints, [new URL("https://example.com")]);
|
|
519
|
+
equal(
|
|
520
|
+
await lookupObject(plainUrl, {
|
|
521
|
+
documentLoader,
|
|
522
|
+
contextLoader: mockDocumentLoader,
|
|
523
|
+
verifyPortableObject: createVerifier(false),
|
|
524
|
+
}),
|
|
525
|
+
null,
|
|
526
|
+
);
|
|
527
|
+
// A document that claims another object:
|
|
528
|
+
equal(
|
|
529
|
+
await lookupObject(plainUrl, {
|
|
530
|
+
documentLoader: redirectingLoader(
|
|
531
|
+
compatibleId,
|
|
532
|
+
person(`ap://${did}/other`),
|
|
533
|
+
),
|
|
534
|
+
contextLoader: mockDocumentLoader,
|
|
535
|
+
verifyPortableObject: createVerifier(),
|
|
536
|
+
}),
|
|
537
|
+
null,
|
|
538
|
+
);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
await t.step("a compatible @id", async () => {
|
|
542
|
+
const documentLoader = redirectingLoader(plainUrl, person(compatibleId));
|
|
543
|
+
const verifyPortableObject = createVerifier();
|
|
544
|
+
equal(
|
|
545
|
+
await lookupObject(plainUrl, {
|
|
546
|
+
documentLoader,
|
|
547
|
+
contextLoader: mockDocumentLoader,
|
|
548
|
+
verifyPortableObject,
|
|
549
|
+
}),
|
|
550
|
+
null,
|
|
551
|
+
);
|
|
552
|
+
deepStrictEqual(verifyPortableObject.documents, []);
|
|
553
|
+
// Without verifyPortableObject, it is fetched as before:
|
|
554
|
+
const actor = await lookupObject(plainUrl, {
|
|
555
|
+
documentLoader,
|
|
556
|
+
contextLoader: mockDocumentLoader,
|
|
557
|
+
});
|
|
558
|
+
assertInstanceOf(actor, Person);
|
|
559
|
+
deepStrictEqual(actor.id, new URL(compatibleId));
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
await t.step("a portable @id", async () => {
|
|
563
|
+
const documentLoader = redirectingLoader(plainUrl, person());
|
|
564
|
+
const actor = await lookupObject(plainUrl, {
|
|
565
|
+
documentLoader,
|
|
566
|
+
contextLoader: mockDocumentLoader,
|
|
567
|
+
verifyPortableObject: createVerifier(),
|
|
568
|
+
});
|
|
569
|
+
assertInstanceOf(actor, Person);
|
|
570
|
+
deepStrictEqual(actor.id, parseIri(actorId));
|
|
571
|
+
equal(
|
|
572
|
+
await lookupObject(plainUrl, {
|
|
573
|
+
documentLoader,
|
|
574
|
+
contextLoader: mockDocumentLoader,
|
|
575
|
+
verifyPortableObject: createVerifier(false),
|
|
576
|
+
}),
|
|
577
|
+
null,
|
|
578
|
+
);
|
|
579
|
+
await rejects(
|
|
580
|
+
() =>
|
|
581
|
+
lookupObject(plainUrl, {
|
|
582
|
+
documentLoader,
|
|
583
|
+
contextLoader: mockDocumentLoader,
|
|
584
|
+
verifyPortableObject: createVerifier(false),
|
|
585
|
+
crossOrigin: "throw",
|
|
586
|
+
}),
|
|
587
|
+
/No gateway returned a valid portable object/,
|
|
588
|
+
);
|
|
589
|
+
});
|
|
590
|
+
});
|