@fedify/vocab 2.4.0-dev.2030 → 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.
- package/deno.json +1 -1
- package/dist/mod.cjs +5472 -645
- package/dist/mod.d.cts +416 -1
- package/dist/mod.d.ts +416 -1
- package/dist/mod.js +5472 -645
- package/dist-tests/{actor-DMXlfUoB.mjs → actor-chbmxipX.mjs} +71 -2
- package/dist-tests/actor.test.mjs +3 -3
- package/dist-tests/{esm-C_FjLvhG.mjs → esm-Df0Yo-Tp.mjs} +1 -1
- package/dist-tests/gateway.test.mjs +604 -0
- package/dist-tests/{lookup-DF2wJiTc.mjs → lookup-CvrMdVSJ.mjs} +211 -9
- package/dist-tests/lookup.test.mjs +3 -3
- package/dist-tests/portable-lookup.test.mjs +335 -0
- 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-Dw_Vm0NE.mjs → vocab-BJGPw--L.mjs} +5109 -551
- package/dist-tests/vocab.test.mjs +9 -4
- package/package.json +5 -5
- package/src/actor.ts +110 -1
- package/src/gateway.test.ts +872 -0
- package/src/lookup.ts +302 -2
- package/src/portable-lookup.test.ts +457 -0
- package/src/vocab.test.ts +8 -1
package/src/lookup.ts
CHANGED
|
@@ -1,12 +1,24 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
GetUserAgentOptions,
|
|
3
|
+
PortableObjectVerifier,
|
|
4
|
+
} from "@fedify/vocab-runtime";
|
|
2
5
|
import {
|
|
6
|
+
canonicalizePortableUri,
|
|
3
7
|
type DocumentLoader,
|
|
8
|
+
formatIri,
|
|
9
|
+
fromCompatibleEf61Id,
|
|
4
10
|
getDocumentLoader,
|
|
5
11
|
haveSameFe34Origin,
|
|
6
12
|
haveSameIriOrigin,
|
|
7
13
|
parseIri,
|
|
8
14
|
type RemoteDocument,
|
|
9
15
|
} from "@fedify/vocab-runtime";
|
|
16
|
+
import {
|
|
17
|
+
dereferencePortableIri,
|
|
18
|
+
getPortableGatewayCandidates,
|
|
19
|
+
isPortableIri,
|
|
20
|
+
PortableObjectRejectedError,
|
|
21
|
+
} from "@fedify/vocab-runtime/internal/portable-dereference";
|
|
10
22
|
import { lookupWebFinger } from "@fedify/webfinger";
|
|
11
23
|
import { getLogger } from "@logtape/logtape";
|
|
12
24
|
import {
|
|
@@ -59,6 +71,9 @@ function getLookupRemoteHost(identifier: string | URL): string | undefined {
|
|
|
59
71
|
let url: URL | undefined;
|
|
60
72
|
if (identifier instanceof URL) {
|
|
61
73
|
url = identifier;
|
|
74
|
+
} else if (PORTABLE_IRI_PATTERN.test(identifier)) {
|
|
75
|
+
// The authority of a portable IRI is a DID, not a host:
|
|
76
|
+
return undefined;
|
|
62
77
|
} else {
|
|
63
78
|
try {
|
|
64
79
|
url = new URL(identifier);
|
|
@@ -70,6 +85,7 @@ function getLookupRemoteHost(identifier: string | URL): string | undefined {
|
|
|
70
85
|
return extractHandleHost(stripped);
|
|
71
86
|
}
|
|
72
87
|
}
|
|
88
|
+
if (isPortableIri(url)) return undefined;
|
|
73
89
|
if (url.host !== "") return url.host;
|
|
74
90
|
// `acct:` URIs are opaque (no `//host` form), so the URL host is empty.
|
|
75
91
|
// The user and authority live in `url.pathname` as
|
|
@@ -166,8 +182,39 @@ export interface LookupObjectOptions {
|
|
|
166
182
|
* @since 1.8.0
|
|
167
183
|
*/
|
|
168
184
|
signal?: AbortSignal;
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The [FEP-ef61] proof policy to apply to portable objects, typically
|
|
188
|
+
* `verifyPortableObjectProof()` from `@fedify/fedify`, which
|
|
189
|
+
* `Context.lookupObject()` uses by default.
|
|
190
|
+
*
|
|
191
|
+
* When it is given, portable `ap:`/`ap+ef61:` identifiers and compatible
|
|
192
|
+
* identifiers (e.g., `https://server.example/.well-known/apgateway/did:...`),
|
|
193
|
+
* whether they are looked up directly or found in the `self` links of
|
|
194
|
+
* a WebFinger response, are fetched through FEP-ef61 gateways. A fetched
|
|
195
|
+
* portable object is returned only if its `@id` identifies the requested
|
|
196
|
+
* portable object and this function accepts it. Note that
|
|
197
|
+
* `crossOrigin: "trust"` does not skip these checks.
|
|
198
|
+
*
|
|
199
|
+
* Without it, portable identifiers are not looked up, and compatible
|
|
200
|
+
* identifiers are fetched as ordinary HTTP(S) URLs, whose objects with
|
|
201
|
+
* a portable `@id` are refused as cross-origin objects, even with
|
|
202
|
+
* `crossOrigin: "trust"`.
|
|
203
|
+
*
|
|
204
|
+
* [FEP-ef61]: https://w3id.org/fep/ef61
|
|
205
|
+
* @since 2.4.0
|
|
206
|
+
*/
|
|
207
|
+
verifyPortableObject?: PortableObjectVerifier;
|
|
169
208
|
}
|
|
170
209
|
|
|
210
|
+
/**
|
|
211
|
+
* The maximum number of requests to FEP-ef61 gateways that a single
|
|
212
|
+
* {@link lookupObject} call makes for portable objects. Gateways come from
|
|
213
|
+
* possibly untrusted WebFinger responses and location hints, so they are
|
|
214
|
+
* bounded to keep a single lookup from fanning out to many servers.
|
|
215
|
+
*/
|
|
216
|
+
const MAX_PORTABLE_ATTEMPTS = 5;
|
|
217
|
+
|
|
171
218
|
/**
|
|
172
219
|
* Looks up an ActivityStreams object by its URI (including `acct:` URIs)
|
|
173
220
|
* or a fediverse handle (e.g., `@user@server` or `user@server`).
|
|
@@ -195,6 +242,12 @@ export interface LookupObjectOptions {
|
|
|
195
242
|
* // returning a `Note` object.
|
|
196
243
|
* ```
|
|
197
244
|
*
|
|
245
|
+
* [FEP-ef61] portable objects, including portable actors found through
|
|
246
|
+
* WebFinger, are looked up only if the `verifyPortableObject` option is
|
|
247
|
+
* given; see {@link LookupObjectOptions.verifyPortableObject}.
|
|
248
|
+
*
|
|
249
|
+
* [FEP-ef61]: https://w3id.org/fep/ef61
|
|
250
|
+
*
|
|
198
251
|
* @param identifier The URI or fediverse handle to look up.
|
|
199
252
|
* @param options Lookup options.
|
|
200
253
|
* @returns The object, or `null` if not found.
|
|
@@ -272,11 +325,41 @@ async function lookupObjectInternal(
|
|
|
272
325
|
): Promise<Object | null> {
|
|
273
326
|
const documentLoader = options.documentLoader ??
|
|
274
327
|
getDocumentLoader({ userAgent: options.userAgent });
|
|
328
|
+
const portable: PortableLookup = {
|
|
329
|
+
options,
|
|
330
|
+
documentLoader,
|
|
331
|
+
attempted: new Set(),
|
|
332
|
+
remaining: MAX_PORTABLE_ATTEMPTS,
|
|
333
|
+
};
|
|
275
334
|
if (typeof identifier === "string") {
|
|
335
|
+
if (PORTABLE_IRI_PATTERN.test(identifier)) {
|
|
336
|
+
const candidate = parsePortableCandidate(identifier);
|
|
337
|
+
if (candidate == null) return null;
|
|
338
|
+
return await lookupPortableObject(portable, candidate, undefined);
|
|
339
|
+
}
|
|
276
340
|
identifier = toAcctUrl(identifier) ?? new URL(identifier);
|
|
277
341
|
}
|
|
342
|
+
if (isPortableIri(identifier)) {
|
|
343
|
+
let candidate: URL;
|
|
344
|
+
try {
|
|
345
|
+
candidate = parseIri(identifier);
|
|
346
|
+
} catch (error) {
|
|
347
|
+
if (error instanceof TypeError) return null;
|
|
348
|
+
throw error;
|
|
349
|
+
}
|
|
350
|
+
return await lookupPortableObject(portable, candidate, undefined);
|
|
351
|
+
}
|
|
278
352
|
let remoteDoc: RemoteDocument | null = null;
|
|
279
353
|
if (identifier.protocol === "http:" || identifier.protocol === "https:") {
|
|
354
|
+
const compatible = getCompatibleCandidate(identifier.href, options);
|
|
355
|
+
if (compatible !== undefined) {
|
|
356
|
+
if (compatible == null) return null;
|
|
357
|
+
return await lookupPortableObject(
|
|
358
|
+
portable,
|
|
359
|
+
compatible.id,
|
|
360
|
+
[compatible.gateway],
|
|
361
|
+
);
|
|
362
|
+
}
|
|
280
363
|
try {
|
|
281
364
|
remoteDoc = await documentLoader(identifier.href, {
|
|
282
365
|
signal: options.signal,
|
|
@@ -295,6 +378,7 @@ async function lookupObjectInternal(
|
|
|
295
378
|
signal: options.signal,
|
|
296
379
|
});
|
|
297
380
|
if (jrd?.links == null) return null;
|
|
381
|
+
const webFingerGateway = getWebFingerGateway(identifier);
|
|
298
382
|
for (const l of jrd.links) {
|
|
299
383
|
if (
|
|
300
384
|
l.type !== "application/activity+json" &&
|
|
@@ -302,6 +386,42 @@ async function lookupObjectInternal(
|
|
|
302
386
|
/application\/ld\+json;\s*profile="https:\/\/www.w3.org\/ns\/activitystreams"/,
|
|
303
387
|
) || l.rel !== "self" || l.href == null
|
|
304
388
|
) continue;
|
|
389
|
+
if (PORTABLE_IRI_PATTERN.test(l.href)) {
|
|
390
|
+
// FEP-ef61 says the WebFinger host is the actor's first gateway, so
|
|
391
|
+
// ask it first, and then the location hints in the link:
|
|
392
|
+
if (options.verifyPortableObject == null) {
|
|
393
|
+
logger.debug(
|
|
394
|
+
"Skipping the portable self link {href}, as the " +
|
|
395
|
+
"verifyPortableObject option is not given.",
|
|
396
|
+
{ href: l.href },
|
|
397
|
+
);
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
const candidate = parsePortableCandidate(l.href);
|
|
401
|
+
if (candidate == null) continue;
|
|
402
|
+
const gateways = webFingerGateway == null ? [] : [webFingerGateway];
|
|
403
|
+
gateways.push(...getPortableGatewayCandidates(candidate));
|
|
404
|
+
const object = await lookupPortableObject(
|
|
405
|
+
portable,
|
|
406
|
+
candidate,
|
|
407
|
+
gateways,
|
|
408
|
+
);
|
|
409
|
+
if (object != null) return object;
|
|
410
|
+
if (options.signal?.aborted) return null;
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
const compatible = getCompatibleCandidate(l.href, options);
|
|
414
|
+
if (compatible !== undefined) {
|
|
415
|
+
if (compatible == null) continue;
|
|
416
|
+
const object = await lookupPortableObject(
|
|
417
|
+
portable,
|
|
418
|
+
compatible.id,
|
|
419
|
+
[compatible.gateway],
|
|
420
|
+
);
|
|
421
|
+
if (object != null) return object;
|
|
422
|
+
if (options.signal?.aborted) return null;
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
305
425
|
try {
|
|
306
426
|
remoteDoc = await documentLoader(l.href, {
|
|
307
427
|
signal: options.signal,
|
|
@@ -335,7 +455,10 @@ async function lookupObjectInternal(
|
|
|
335
455
|
throw error;
|
|
336
456
|
}
|
|
337
457
|
if (
|
|
338
|
-
|
|
458
|
+
object.id != null &&
|
|
459
|
+
// A portable object belongs to its DID, not to the server that serves
|
|
460
|
+
// it, so crossOrigin: "trust" does not let a server vouch for it:
|
|
461
|
+
(options.crossOrigin !== "trust" || isPortableIri(object.id)) &&
|
|
339
462
|
!haveSameIriOrigin(object.id, documentUrl) &&
|
|
340
463
|
!haveSameFe34Origin(object.id, documentUrl)
|
|
341
464
|
) {
|
|
@@ -359,6 +482,183 @@ async function lookupObjectInternal(
|
|
|
359
482
|
return object;
|
|
360
483
|
}
|
|
361
484
|
|
|
485
|
+
const PORTABLE_IRI_PATTERN = /^ap(?:\+ef61)?:/i;
|
|
486
|
+
|
|
487
|
+
interface PortableLookup {
|
|
488
|
+
readonly options: LookupObjectOptions;
|
|
489
|
+
readonly documentLoader: DocumentLoader;
|
|
490
|
+
/** Pairs of a canonical portable ID and a gateway already asked. */
|
|
491
|
+
readonly attempted: Set<string>;
|
|
492
|
+
/** The number of gateway requests left for this lookup. */
|
|
493
|
+
remaining: number;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Parses a raw portable IRI. URL parsing normalizes dot segments in the
|
|
498
|
+
* opaque path, which would make the parsed IRI identify another portable
|
|
499
|
+
* object, so such IRIs are refused.
|
|
500
|
+
*/
|
|
501
|
+
function parsePortableCandidate(iri: string): URL | null {
|
|
502
|
+
try {
|
|
503
|
+
const parsed = parseIri(iri);
|
|
504
|
+
if (
|
|
505
|
+
canonicalizePortableUri(iri) !==
|
|
506
|
+
canonicalizePortableUri(formatIri(parsed))
|
|
507
|
+
) {
|
|
508
|
+
logger.debug(
|
|
509
|
+
"Refusing to look up the portable IRI {iri}, as its path cannot be " +
|
|
510
|
+
"represented without changing the identified object.",
|
|
511
|
+
{ iri },
|
|
512
|
+
);
|
|
513
|
+
return null;
|
|
514
|
+
}
|
|
515
|
+
return parsed;
|
|
516
|
+
} catch (error) {
|
|
517
|
+
if (error instanceof TypeError) {
|
|
518
|
+
logger.debug("Invalid portable IRI {iri}: {error}", { iri, error });
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
throw error;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Recognizes an FEP-ef61 compatible identifier to look up as a portable
|
|
527
|
+
* object.
|
|
528
|
+
* @returns `undefined` if the URL should be fetched as an ordinary HTTP(S)
|
|
529
|
+
* URL, i.e., it is not a compatible identifier, or the
|
|
530
|
+
* `verifyPortableObject` option is not given; `null` if it is
|
|
531
|
+
* a malformed compatible identifier; otherwise, the portable ID and
|
|
532
|
+
* the gateway to ask.
|
|
533
|
+
*/
|
|
534
|
+
function getCompatibleCandidate(
|
|
535
|
+
href: string,
|
|
536
|
+
options: LookupObjectOptions,
|
|
537
|
+
): { id: URL; gateway: URL } | null | undefined {
|
|
538
|
+
if (options.verifyPortableObject == null || !URL.canParse(href)) {
|
|
539
|
+
return undefined;
|
|
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
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Gets the origin of the WebFinger server that {@link lookupWebFinger} asks
|
|
560
|
+
* for the identifier, which is also the first gateway of a portable actor.
|
|
561
|
+
*/
|
|
562
|
+
function getWebFingerGateway(identifier: URL): URL | undefined {
|
|
563
|
+
let url: URL;
|
|
564
|
+
if (identifier.protocol === "acct:") {
|
|
565
|
+
const host = extractHandleHost(identifier.pathname);
|
|
566
|
+
if (host == null) return undefined;
|
|
567
|
+
url = new URL(`https://${host}/`);
|
|
568
|
+
} else if (
|
|
569
|
+
identifier.protocol === "http:" || identifier.protocol === "https:"
|
|
570
|
+
) {
|
|
571
|
+
url = new URL(identifier.origin);
|
|
572
|
+
} else {
|
|
573
|
+
return undefined;
|
|
574
|
+
}
|
|
575
|
+
return url.username === "" && url.password === "" ? url : undefined;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Looks up a portable object through FEP-ef61 gateways.
|
|
580
|
+
* @param gateways The gateways to ask in order. If omitted, the location
|
|
581
|
+
* hints in the IRI are used, and if there are none, the
|
|
582
|
+
* portable IRI itself is passed to the document loader.
|
|
583
|
+
*/
|
|
584
|
+
async function lookupPortableObject(
|
|
585
|
+
lookup: PortableLookup,
|
|
586
|
+
id: URL,
|
|
587
|
+
gateways: readonly URL[] | undefined,
|
|
588
|
+
): Promise<Object | null> {
|
|
589
|
+
const { options } = lookup;
|
|
590
|
+
const iri = formatIri(id);
|
|
591
|
+
if (options.verifyPortableObject == null) {
|
|
592
|
+
logger.debug(
|
|
593
|
+
"Cannot look up the portable object {iri}, as the " +
|
|
594
|
+
"verifyPortableObject option is not given.",
|
|
595
|
+
{ iri },
|
|
596
|
+
);
|
|
597
|
+
return null;
|
|
598
|
+
}
|
|
599
|
+
const canonicalId = canonicalizePortableUri(iri);
|
|
600
|
+
let candidates: URL[];
|
|
601
|
+
if (gateways == null) {
|
|
602
|
+
candidates = getPortableGatewayCandidates(id);
|
|
603
|
+
if (candidates.length < 1) {
|
|
604
|
+
// No gateway to ask; a custom document loader may know how to
|
|
605
|
+
// retrieve the portable IRI itself:
|
|
606
|
+
const key = `${canonicalId} `;
|
|
607
|
+
if (lookup.remaining < 1 || lookup.attempted.has(key)) return null;
|
|
608
|
+
lookup.attempted.add(key);
|
|
609
|
+
lookup.remaining--;
|
|
610
|
+
return await dereference(lookup, id, []);
|
|
611
|
+
}
|
|
612
|
+
} else {
|
|
613
|
+
candidates = [...gateways];
|
|
614
|
+
}
|
|
615
|
+
const selected: URL[] = [];
|
|
616
|
+
for (const gateway of candidates) {
|
|
617
|
+
if (selected.length >= lookup.remaining) break;
|
|
618
|
+
const key = `${canonicalId} ${gateway.href}`;
|
|
619
|
+
if (lookup.attempted.has(key)) continue;
|
|
620
|
+
lookup.attempted.add(key);
|
|
621
|
+
selected.push(gateway);
|
|
622
|
+
}
|
|
623
|
+
if (selected.length < 1) return null;
|
|
624
|
+
lookup.remaining -= selected.length;
|
|
625
|
+
return await dereference(lookup, id, selected);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
async function dereference(
|
|
629
|
+
{ options, documentLoader }: PortableLookup,
|
|
630
|
+
id: URL,
|
|
631
|
+
gateways: readonly URL[],
|
|
632
|
+
): Promise<Object | null> {
|
|
633
|
+
const tracerProvider = options.tracerProvider ?? trace.getTracerProvider();
|
|
634
|
+
try {
|
|
635
|
+
return await dereferencePortableIri(id, {
|
|
636
|
+
documentLoader,
|
|
637
|
+
contextLoader: options.contextLoader ??
|
|
638
|
+
getDocumentLoader({ userAgent: options.userAgent }),
|
|
639
|
+
tracerProvider,
|
|
640
|
+
gateways,
|
|
641
|
+
verifyPortableObject: options.verifyPortableObject,
|
|
642
|
+
crossOrigin: options.crossOrigin === "throw" ? "throw" : "ignore",
|
|
643
|
+
signal: options.signal,
|
|
644
|
+
parse: (document, { contextLoader, baseUrl }) =>
|
|
645
|
+
Object.fromJsonLd(document, {
|
|
646
|
+
documentLoader,
|
|
647
|
+
contextLoader,
|
|
648
|
+
tracerProvider,
|
|
649
|
+
baseUrl,
|
|
650
|
+
}),
|
|
651
|
+
});
|
|
652
|
+
} catch (error) {
|
|
653
|
+
if (error instanceof PortableObjectRejectedError) throw error;
|
|
654
|
+
logger.debug(
|
|
655
|
+
"Failed to look up the portable object {iri}:\n{error}",
|
|
656
|
+
{ iri: formatIri(id), error },
|
|
657
|
+
);
|
|
658
|
+
return null;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
362
662
|
/**
|
|
363
663
|
* Options for the {@link traverseCollection} function.
|
|
364
664
|
* @since 1.1.0
|