@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/src/lookup.ts CHANGED
@@ -1,12 +1,24 @@
1
- import type { GetUserAgentOptions } from "@fedify/vocab-runtime";
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
- options.crossOrigin !== "trust" && object.id != null &&
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