@fedify/vocab 2.4.0-pr.936.41 → 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 (36) hide show
  1. package/deno.json +1 -1
  2. package/dist/mod.cjs +31328 -8723
  3. package/dist/mod.d.cts +4599 -6
  4. package/dist/mod.d.ts +4599 -6
  5. package/dist/mod.js +31221 -8623
  6. package/dist-tests/actor-D7_iX4I4.mjs +276 -0
  7. package/dist-tests/actor.test.mjs +55 -49
  8. package/dist-tests/{actor-MBpzF3-D.mjs → esm-DP2Spt0u.mjs} +15 -211
  9. package/dist-tests/gateway.test.mjs +1906 -0
  10. package/dist-tests/lookup-IiNFWuXL.mjs +480 -0
  11. package/dist-tests/lookup.test.mjs +10 -268
  12. package/dist-tests/object.yaml +15 -0
  13. package/dist-tests/portable-lookup.test.mjs +653 -0
  14. package/dist-tests/signed-representation.test.mjs +189 -0
  15. package/dist-tests/translation.test.mjs +404 -0
  16. package/dist-tests/translation.yaml +91 -0
  17. package/dist-tests/type.test.mjs +2 -2
  18. package/dist-tests/{vocab-Bjwctrxn.mjs → vocab-COZcxf00.mjs} +30831 -8515
  19. package/dist-tests/vocab.test.mjs +677 -10
  20. package/package.json +5 -5
  21. package/src/__snapshots__/vocab.test.ts.snap +291 -15
  22. package/src/actor.test.ts +41 -40
  23. package/src/actor.ts +120 -1
  24. package/src/gateway.test.ts +2637 -0
  25. package/src/lookup.test.ts +6 -5
  26. package/src/lookup.ts +460 -33
  27. package/src/mod.ts +9 -0
  28. package/src/object.yaml +15 -0
  29. package/src/portable-lookup.test.ts +889 -0
  30. package/src/preprocessors.ts +1 -1
  31. package/src/signed-representation.test.ts +277 -0
  32. package/src/translation.test.ts +518 -0
  33. package/src/translation.yaml +91 -0
  34. package/src/vocab.test.ts +914 -5
  35. /package/dist-tests/{type-_BlsfT3S.mjs → type-BMdjm4Eg.mjs} +0 -0
  36. /package/dist-tests/{utils-CQjrpUkt.mjs → utils-Df2odC27.mjs} +0 -0
@@ -80,7 +80,8 @@ test("lookupObject()", {
80
80
  );
81
81
  });
82
82
 
83
- fetchMock.removeRoutes();
83
+ // Workers load fixtures through fetch(), so keep the spy fallback between steps.
84
+ fetchMock.removeRoutes({ includeFallback: false });
84
85
  fetchMock.get("begin:https://example.com/.well-known/webfinger", {
85
86
  subject: "acct:janedoe@example.com",
86
87
  links: [
@@ -100,7 +101,7 @@ test("lookupObject()", {
100
101
  );
101
102
  });
102
103
 
103
- fetchMock.removeRoutes();
104
+ fetchMock.removeRoutes({ includeFallback: false });
104
105
  fetchMock.get(
105
106
  "begin:https://example.com/.well-known/webfinger",
106
107
  () =>
@@ -131,7 +132,7 @@ test("lookupObject()", {
131
132
  deepStrictEqual(await promise, null);
132
133
  });
133
134
 
134
- fetchMock.removeRoutes();
135
+ fetchMock.removeRoutes({ includeFallback: false });
135
136
  fetchMock.get(
136
137
  "begin:https://example.com/.well-known/webfinger",
137
138
  {
@@ -156,7 +157,7 @@ test("lookupObject()", {
156
157
  deepStrictEqual(person.id, new URL("https://example.com/person"));
157
158
  });
158
159
 
159
- fetchMock.removeRoutes();
160
+ fetchMock.removeRoutes({ includeFallback: false });
160
161
  fetchMock.get(
161
162
  "begin:https://example.com/.well-known/webfinger",
162
163
  () =>
@@ -187,7 +188,7 @@ test("lookupObject()", {
187
188
  deepStrictEqual(result, null);
188
189
  });
189
190
 
190
- fetchMock.removeRoutes();
191
+ fetchMock.removeRoutes({ includeFallback: false });
191
192
  fetchMock.get(
192
193
  "https://example.com/slow-object",
193
194
  () =>
package/src/lookup.ts CHANGED
@@ -1,12 +1,26 @@
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,
4
9
  getDocumentLoader,
5
10
  haveSameFe34Origin,
6
11
  haveSameIriOrigin,
7
12
  parseIri,
8
13
  type RemoteDocument,
9
14
  } from "@fedify/vocab-runtime";
15
+ import {
16
+ createSnapshotContextLoader,
17
+ dereferencePortableIri,
18
+ getPortableGatewayCandidates,
19
+ getPortableResponseClaim,
20
+ isPortableIri,
21
+ parseCompatibleEf61Reference,
22
+ PortableObjectRejectedError,
23
+ } from "@fedify/vocab-runtime/internal/portable-dereference";
10
24
  import { lookupWebFinger } from "@fedify/webfinger";
11
25
  import { getLogger } from "@logtape/logtape";
12
26
  import {
@@ -59,6 +73,9 @@ function getLookupRemoteHost(identifier: string | URL): string | undefined {
59
73
  let url: URL | undefined;
60
74
  if (identifier instanceof URL) {
61
75
  url = identifier;
76
+ } else if (PORTABLE_IRI_PATTERN.test(identifier)) {
77
+ // The authority of a portable IRI is a DID, not a host:
78
+ return undefined;
62
79
  } else {
63
80
  try {
64
81
  url = new URL(identifier);
@@ -70,6 +87,7 @@ function getLookupRemoteHost(identifier: string | URL): string | undefined {
70
87
  return extractHandleHost(stripped);
71
88
  }
72
89
  }
90
+ if (isPortableIri(url)) return undefined;
73
91
  if (url.host !== "") return url.host;
74
92
  // `acct:` URIs are opaque (no `//host` form), so the URL host is empty.
75
93
  // The user and authority live in `url.pathname` as
@@ -166,8 +184,58 @@ export interface LookupObjectOptions {
166
184
  * @since 1.8.0
167
185
  */
168
186
  signal?: AbortSignal;
187
+
188
+ /**
189
+ * The [FEP-ef61] gateways to try when looking up a portable
190
+ * (`ap:`/`ap+ef61:`) object, in order. Each must be an HTTP(S) origin.
191
+ * An explicit list replaces `@gateway` location hints, including when
192
+ * it is empty. The gateway named by a compatible identifier or the
193
+ * WebFinger server is still tried first. At most five gateways are
194
+ * requested per lookup, including those given here.
195
+ *
196
+ * [FEP-ef61]: https://w3id.org/fep/ef61
197
+ * @since 2.4.0
198
+ */
199
+ gateways?: readonly (string | URL)[];
200
+
201
+ /**
202
+ * The [FEP-ef61] policy to apply to portable objects, typically
203
+ * `verifyPortableObject()` from `@fedify/fedify`, which
204
+ * `Context.lookupObject()` uses by default.
205
+ *
206
+ * When it is given, portable `ap:`/`ap+ef61:` identifiers and compatible
207
+ * identifiers (e.g., `https://server.example/.well-known/apgateway/did:...`),
208
+ * whether they are looked up directly or found in the `self` links of
209
+ * a WebFinger response, are fetched through FEP-ef61 gateways. A fetched
210
+ * portable object is returned only if its `@id` identifies the requested
211
+ * portable object and this function accepts it. A document fetched from
212
+ * an ordinary HTTP(S) URL is checked the same way if its final URL or its
213
+ * `@id` is a portable or compatible identifier, instead of being trusted
214
+ * because of its origin. Note that `crossOrigin: "trust"` does not skip
215
+ * these checks.
216
+ *
217
+ * Without it, portable identifiers are not looked up, and compatible
218
+ * identifiers are fetched as ordinary HTTP(S) URLs, whose objects with
219
+ * a portable `@id` are refused as cross-origin objects, even with
220
+ * `crossOrigin: "trust"`.
221
+ *
222
+ * The returned object also uses it by default for dereferencing its
223
+ * properties, as if it were passed to its property accessors.
224
+ *
225
+ * [FEP-ef61]: https://w3id.org/fep/ef61
226
+ * @since 2.4.0
227
+ */
228
+ verifyPortableObject?: PortableObjectVerifier;
169
229
  }
170
230
 
231
+ /**
232
+ * The maximum number of requests to FEP-ef61 gateways that a single
233
+ * {@link lookupObject} call makes for portable objects. Gateways come from
234
+ * possibly untrusted WebFinger responses and location hints, so they are
235
+ * bounded to keep a single lookup from fanning out to many servers.
236
+ */
237
+ const MAX_PORTABLE_ATTEMPTS = 5;
238
+
171
239
  /**
172
240
  * Looks up an ActivityStreams object by its URI (including `acct:` URIs)
173
241
  * or a fediverse handle (e.g., `@user@server` or `user@server`).
@@ -195,6 +263,14 @@ export interface LookupObjectOptions {
195
263
  * // returning a `Note` object.
196
264
  * ```
197
265
  *
266
+ * [FEP-ef61] portable objects, including portable actors found through
267
+ * WebFinger, are looked up only if the `verifyPortableObject` option is
268
+ * given; see {@link LookupObjectOptions.verifyPortableObject}. Pass
269
+ * {@link LookupObjectOptions.gateways} to look up a portable ID without
270
+ * `@gateway` location hints.
271
+ *
272
+ * [FEP-ef61]: https://w3id.org/fep/ef61
273
+ *
198
274
  * @param identifier The URI or fediverse handle to look up.
199
275
  * @param options Lookup options.
200
276
  * @returns The object, or `null` if not found.
@@ -272,11 +348,47 @@ async function lookupObjectInternal(
272
348
  ): Promise<Object | null> {
273
349
  const documentLoader = options.documentLoader ??
274
350
  getDocumentLoader({ userAgent: options.userAgent });
351
+ const portable: PortableLookup = {
352
+ options,
353
+ documentLoader,
354
+ attempted: new Set(),
355
+ remaining: MAX_PORTABLE_ATTEMPTS,
356
+ };
275
357
  if (typeof identifier === "string") {
276
- identifier = toAcctUrl(identifier) ?? new URL(identifier);
358
+ if (PORTABLE_IRI_PATTERN.test(identifier)) {
359
+ const candidate = parsePortableCandidate(identifier);
360
+ if (candidate == null) return null;
361
+ return await lookupPortableObject(portable, candidate, undefined);
362
+ }
363
+ // Check the raw spelling before WHATWG URL can erase dot segments from a
364
+ // compatible identifier. It cannot be recovered from the parsed URL.
365
+ if (
366
+ options.verifyPortableObject != null &&
367
+ parseCompatibleEf61Reference(identifier) === null
368
+ ) return null;
369
+ identifier = toAcctUrl(identifier) ?? parseIri(identifier);
370
+ }
371
+ if (isPortableIri(identifier)) {
372
+ let candidate: URL;
373
+ try {
374
+ candidate = parseIri(identifier);
375
+ } catch (error) {
376
+ if (error instanceof TypeError) return null;
377
+ throw error;
378
+ }
379
+ return await lookupPortableObject(portable, candidate, undefined);
277
380
  }
278
381
  let remoteDoc: RemoteDocument | null = null;
279
382
  if (identifier.protocol === "http:" || identifier.protocol === "https:") {
383
+ const compatible = getCompatibleCandidate(identifier.href, options);
384
+ if (compatible !== undefined) {
385
+ if (compatible == null) return null;
386
+ return await lookupPortableObject(
387
+ portable,
388
+ compatible.id,
389
+ [compatible.gateway],
390
+ );
391
+ }
280
392
  try {
281
393
  remoteDoc = await documentLoader(identifier.href, {
282
394
  signal: options.signal,
@@ -295,6 +407,7 @@ async function lookupObjectInternal(
295
407
  signal: options.signal,
296
408
  });
297
409
  if (jrd?.links == null) return null;
410
+ const webFingerGateway = getWebFingerGateway(identifier);
298
411
  for (const l of jrd.links) {
299
412
  if (
300
413
  l.type !== "application/activity+json" &&
@@ -302,6 +415,44 @@ async function lookupObjectInternal(
302
415
  /application\/ld\+json;\s*profile="https:\/\/www.w3.org\/ns\/activitystreams"/,
303
416
  ) || l.rel !== "self" || l.href == null
304
417
  ) continue;
418
+ if (PORTABLE_IRI_PATTERN.test(l.href)) {
419
+ // FEP-ef61 says the WebFinger host is the actor's first gateway, so
420
+ // ask it first, and then the location hints in the link:
421
+ if (options.verifyPortableObject == null) {
422
+ logger.debug(
423
+ "Skipping the portable self link {href}, as the " +
424
+ "verifyPortableObject option is not given.",
425
+ { href: l.href },
426
+ );
427
+ continue;
428
+ }
429
+ const candidate = parsePortableCandidate(l.href);
430
+ if (candidate == null) continue;
431
+ const gateways = webFingerGateway == null ? [] : [webFingerGateway];
432
+ if (options.gateways == null) {
433
+ gateways.push(...getPortableGatewayCandidates(candidate));
434
+ }
435
+ const object = await lookupPortableObject(
436
+ portable,
437
+ candidate,
438
+ gateways,
439
+ );
440
+ if (object != null) return object;
441
+ if (options.signal?.aborted) return null;
442
+ continue;
443
+ }
444
+ const compatible = getCompatibleCandidate(l.href, options);
445
+ if (compatible !== undefined) {
446
+ if (compatible == null) continue;
447
+ const object = await lookupPortableObject(
448
+ portable,
449
+ compatible.id,
450
+ [compatible.gateway],
451
+ );
452
+ if (object != null) return object;
453
+ if (options.signal?.aborted) return null;
454
+ continue;
455
+ }
305
456
  try {
306
457
  remoteDoc = await documentLoader(l.href, {
307
458
  signal: options.signal,
@@ -314,49 +465,286 @@ async function lookupObjectInternal(
314
465
  }
315
466
  }
316
467
  if (remoteDoc == null) return null;
317
- let object: Object;
318
- let documentUrl: URL;
468
+ // With verifyPortableObject, the document may turn out to be a portable
469
+ // object, which has to be verified with the same context documents:
470
+ const snapshot = options.verifyPortableObject == null
471
+ ? null
472
+ : createSnapshotContextLoader(
473
+ options.contextLoader ??
474
+ getDocumentLoader({ userAgent: options.userAgent }),
475
+ );
319
476
  try {
320
- documentUrl = parseIri(remoteDoc.documentUrl);
321
- object = await Object.fromJsonLd(remoteDoc.document, {
322
- documentLoader,
323
- contextLoader: options.contextLoader,
324
- tracerProvider: options.tracerProvider,
325
- baseUrl: documentUrl,
326
- });
327
- } catch (error) {
328
- if (error instanceof TypeError) {
477
+ let object: Object;
478
+ let documentUrl: URL;
479
+ try {
480
+ documentUrl = parseIri(remoteDoc.documentUrl);
481
+ object = await Object.fromJsonLd(remoteDoc.document, {
482
+ documentLoader,
483
+ contextLoader: snapshot?.loader ?? options.contextLoader,
484
+ tracerProvider: options.tracerProvider,
485
+ verifyPortableObject: options.verifyPortableObject,
486
+ baseUrl: documentUrl,
487
+ });
488
+ } catch (error) {
489
+ if (error instanceof TypeError) {
490
+ logger.debug(
491
+ "Failed to parse JSON-LD document: {error}\n{document}",
492
+ { ...remoteDoc, error },
493
+ );
494
+ return null;
495
+ }
496
+ throw error;
497
+ }
498
+ if (snapshot != null) {
499
+ // A document whose final URL or @id stands for a portable object is
500
+ // verified as one instead of being trusted because of its origin:
501
+ const claim = getPortableResponseClaim(remoteDoc.documentUrl, object.id);
502
+ if (claim === null) {
503
+ logger.debug(
504
+ "Refusing the document {documentUrl}, as it claims to be " +
505
+ "a portable object with a malformed compatible identifier.",
506
+ { documentUrl: remoteDoc.documentUrl },
507
+ );
508
+ return null;
509
+ } else if (claim != null) {
510
+ const explicit = getExplicitGateways(portable, claim.id);
511
+ return await dereference(
512
+ portable,
513
+ claim.id,
514
+ claim.inferredGateways,
515
+ explicit,
516
+ { response: remoteDoc, contextLoader: snapshot.loader },
517
+ );
518
+ }
519
+ }
520
+ if (
521
+ object.id != null &&
522
+ // A portable object belongs to its DID, not to the server that serves
523
+ // it, so crossOrigin: "trust" does not let a server vouch for it:
524
+ (options.crossOrigin !== "trust" || isPortableIri(object.id)) &&
525
+ !haveSameIriOrigin(object.id, documentUrl) &&
526
+ !haveSameFe34Origin(object.id, documentUrl)
527
+ ) {
528
+ if (options.crossOrigin === "throw") {
529
+ throw new Error(
530
+ `The object's @id (${object.id.href}) has a different origin than ` +
531
+ `the document URL (${remoteDoc.documentUrl}); refusing to return ` +
532
+ `the object. If you want to bypass this check and are aware of ` +
533
+ `the security implications, set the crossOrigin option to "trust".`,
534
+ );
535
+ }
536
+ logger.warn(
537
+ "The object's @id ({objectId}) has a different origin than the " +
538
+ "document URL ({documentUrl}); refusing to return the object. If " +
539
+ "you want to bypass this check and are aware of the security " +
540
+ 'implications, set the crossOrigin option to "trust".',
541
+ { ...remoteDoc, objectId: object.id.href },
542
+ );
543
+ return null;
544
+ }
545
+ return object;
546
+ } finally {
547
+ snapshot?.release();
548
+ }
549
+ }
550
+
551
+ const PORTABLE_IRI_PATTERN = /^ap(?:\+ef61)?:/i;
552
+
553
+ interface PortableLookup {
554
+ readonly options: LookupObjectOptions;
555
+ readonly documentLoader: DocumentLoader;
556
+ /** Pairs of a canonical portable ID and a gateway already asked. */
557
+ readonly attempted: Set<string>;
558
+ /** The number of gateway requests left for this lookup. */
559
+ remaining: number;
560
+ /** Validated only when the lookup reaches a portable object. */
561
+ explicitGateways?: URL[];
562
+ }
563
+
564
+ function getExplicitGateways(
565
+ lookup: PortableLookup,
566
+ id: URL,
567
+ ): URL[] | undefined {
568
+ if (lookup.options.gateways == null) return undefined;
569
+ return lookup.explicitGateways ??= getPortableGatewayCandidates(
570
+ id,
571
+ lookup.options.gateways,
572
+ );
573
+ }
574
+
575
+ /**
576
+ * Parses a raw portable IRI. URL parsing normalizes dot segments in the
577
+ * opaque path, which would make the parsed IRI identify another portable
578
+ * object, so such IRIs are refused.
579
+ */
580
+ function parsePortableCandidate(iri: string): URL | null {
581
+ try {
582
+ const parsed = parseIri(iri);
583
+ if (
584
+ canonicalizePortableUri(iri) !==
585
+ canonicalizePortableUri(formatIri(parsed))
586
+ ) {
329
587
  logger.debug(
330
- "Failed to parse JSON-LD document: {error}\n{document}",
331
- { ...remoteDoc, error },
588
+ "Refusing to look up the portable IRI {iri}, as its path cannot be " +
589
+ "represented without changing the identified object.",
590
+ { iri },
332
591
  );
333
592
  return null;
334
593
  }
594
+ return parsed;
595
+ } catch (error) {
596
+ if (error instanceof TypeError) {
597
+ logger.debug("Invalid portable IRI {iri}: {error}", { iri, error });
598
+ return null;
599
+ }
335
600
  throw error;
336
601
  }
337
- if (
338
- options.crossOrigin !== "trust" && object.id != null &&
339
- !haveSameIriOrigin(object.id, documentUrl) &&
340
- !haveSameFe34Origin(object.id, documentUrl)
602
+ }
603
+
604
+ /**
605
+ * Recognizes an FEP-ef61 compatible identifier to look up as a portable
606
+ * object.
607
+ * @returns `undefined` if the URL should be fetched as an ordinary HTTP(S)
608
+ * URL, i.e., it is not a compatible identifier, or the
609
+ * `verifyPortableObject` option is not given; `null` if it is
610
+ * a malformed compatible identifier; otherwise, the portable ID and
611
+ * the gateway to ask.
612
+ */
613
+ function getCompatibleCandidate(
614
+ href: string,
615
+ options: LookupObjectOptions,
616
+ ): { readonly id: URL; readonly gateway: URL } | null | undefined {
617
+ if (options.verifyPortableObject == null) return undefined;
618
+ return parseCompatibleEf61Reference(href);
619
+ }
620
+
621
+ /**
622
+ * Gets the origin of the WebFinger server that {@link lookupWebFinger} asks
623
+ * for the identifier, which is also the first gateway of a portable actor.
624
+ */
625
+ function getWebFingerGateway(identifier: URL): URL | undefined {
626
+ let url: URL;
627
+ if (identifier.protocol === "acct:") {
628
+ const host = extractHandleHost(identifier.pathname);
629
+ if (host == null) return undefined;
630
+ url = new URL(`https://${host}/`);
631
+ } else if (
632
+ identifier.protocol === "http:" || identifier.protocol === "https:"
341
633
  ) {
342
- if (options.crossOrigin === "throw") {
343
- throw new Error(
344
- `The object's @id (${object.id.href}) has a different origin than ` +
345
- `the document URL (${remoteDoc.documentUrl}); refusing to return ` +
346
- `the object. If you want to bypass this check and are aware of ` +
347
- `the security implications, set the crossOrigin option to "trust".`,
348
- );
634
+ url = new URL(identifier.origin);
635
+ } else {
636
+ return undefined;
637
+ }
638
+ return url.username === "" && url.password === "" ? url : undefined;
639
+ }
640
+
641
+ /**
642
+ * Looks up a portable object through FEP-ef61 gateways.
643
+ * @param inferredGateways Gateways inferred from a compatible identifier or
644
+ * WebFinger. If omitted, location hints in the IRI
645
+ * are used unless explicit gateways were given.
646
+ */
647
+ async function lookupPortableObject(
648
+ lookup: PortableLookup,
649
+ id: URL,
650
+ inferredGateways: readonly URL[] | undefined,
651
+ ): Promise<Object | null> {
652
+ const { options } = lookup;
653
+ const iri = formatIri(id);
654
+ const explicitGateways = getExplicitGateways(lookup, id);
655
+ if (options.verifyPortableObject == null) {
656
+ logger.debug(
657
+ "Cannot look up the portable object {iri}, as the " +
658
+ "verifyPortableObject option is not given.",
659
+ { iri },
660
+ );
661
+ return null;
662
+ }
663
+ const canonicalId = canonicalizePortableUri(iri);
664
+ const inferred = inferredGateways ??
665
+ (explicitGateways == null ? getPortableGatewayCandidates(id) : []);
666
+ const candidates = [...inferred];
667
+ for (const gateway of explicitGateways ?? []) {
668
+ if (!candidates.some((candidate) => candidate.href === gateway.href)) {
669
+ candidates.push(gateway);
349
670
  }
350
- logger.warn(
351
- "The object's @id ({objectId}) has a different origin than the document " +
352
- "URL ({documentUrl}); refusing to return the object. If you want to " +
353
- "bypass this check and are aware of the security implications, " +
354
- 'set the crossOrigin option to "trust".',
355
- { ...remoteDoc, objectId: object.id.href },
671
+ }
672
+ if (candidates.length < 1 && inferredGateways == null) {
673
+ // No gateway to ask; a custom document loader may know how to
674
+ // retrieve the portable IRI itself:
675
+ const key = `${canonicalId} `;
676
+ if (lookup.remaining < 1 || lookup.attempted.has(key)) return null;
677
+ lookup.attempted.add(key);
678
+ lookup.remaining--;
679
+ return await dereference(
680
+ lookup,
681
+ id,
682
+ [],
683
+ explicitGateways == null ? undefined : [],
684
+ );
685
+ }
686
+ const selected: URL[] = [];
687
+ for (const gateway of candidates) {
688
+ if (selected.length >= lookup.remaining) break;
689
+ const key = `${canonicalId} ${gateway.href}`;
690
+ if (lookup.attempted.has(key)) continue;
691
+ lookup.attempted.add(key);
692
+ selected.push(gateway);
693
+ }
694
+ if (selected.length < 1) return null;
695
+ lookup.remaining -= selected.length;
696
+ const selectedInferred = selected.filter((candidate) =>
697
+ inferred.some((gateway) => gateway.href === candidate.href)
698
+ );
699
+ const selectedExplicit = explicitGateways?.filter((gateway) =>
700
+ selected.some((candidate) => candidate.href === gateway.href)
701
+ );
702
+ return await dereference(
703
+ lookup,
704
+ id,
705
+ selectedInferred,
706
+ selectedExplicit,
707
+ );
708
+ }
709
+
710
+ async function dereference(
711
+ { options, documentLoader }: PortableLookup,
712
+ id: URL,
713
+ inferredGateways: readonly URL[],
714
+ gateways?: readonly URL[],
715
+ extra: { response?: RemoteDocument; contextLoader?: DocumentLoader } = {},
716
+ ): Promise<Object | null> {
717
+ const tracerProvider = options.tracerProvider ?? trace.getTracerProvider();
718
+ try {
719
+ return await dereferencePortableIri(id, {
720
+ documentLoader,
721
+ contextLoader: extra.contextLoader ?? options.contextLoader ??
722
+ getDocumentLoader({ userAgent: options.userAgent }),
723
+ tracerProvider,
724
+ // Preserve where the gateways came from for the verifier:
725
+ inferredGateways,
726
+ ...(gateways == null ? {} : { gateways }),
727
+ response: extra.response,
728
+ verifyPortableObject: options.verifyPortableObject,
729
+ crossOrigin: options.crossOrigin === "throw" ? "throw" : "ignore",
730
+ signal: options.signal,
731
+ parse: (document, { contextLoader, baseUrl }) =>
732
+ Object.fromJsonLd(document, {
733
+ documentLoader,
734
+ contextLoader,
735
+ tracerProvider,
736
+ verifyPortableObject: options.verifyPortableObject,
737
+ baseUrl,
738
+ }),
739
+ });
740
+ } catch (error) {
741
+ if (error instanceof PortableObjectRejectedError) throw error;
742
+ logger.debug(
743
+ "Failed to look up the portable object {iri}:\n{error}",
744
+ { iri: formatIri(id), error },
356
745
  );
357
746
  return null;
358
747
  }
359
- return object;
360
748
  }
361
749
 
362
750
  /**
@@ -387,6 +775,45 @@ export interface TraverseCollectionOptions {
387
775
  * @default `{ seconds: 0 }`
388
776
  */
389
777
  interval?: Temporal.Duration | Temporal.DurationLike;
778
+ /**
779
+ * Whether to trust objects whose origin differs from the collection or page
780
+ * that refers to them. See the `crossOrigin` option of property accessors
781
+ * such as `Collection.getItems()`.
782
+ * @since 2.4.0
783
+ */
784
+ crossOrigin?: "ignore" | "throw" | "trust";
785
+
786
+ /**
787
+ * The [FEP-ef61] gateways to fetch portable (`ap:`/`ap+ef61:`) pages and
788
+ * items through, in order. See the `gateways` option of property
789
+ * accessors.
790
+ *
791
+ * [FEP-ef61]: https://w3id.org/fep/ef61
792
+ * @since 2.4.0
793
+ */
794
+ gateways?: readonly (string | URL)[];
795
+
796
+ /**
797
+ * The policy to apply to portable pages and items fetched through
798
+ * gateways, typically `verifyPortableObject()` from `@fedify/fedify`.
799
+ * Portable pages and items are not fetched without it. Pages and items
800
+ * that are fetched use it as their default verifier, unless
801
+ * {@link TraverseCollectionOptions.inheritPortableObjectVerifier} is
802
+ * `false`.
803
+ * @since 2.4.0
804
+ */
805
+ verifyPortableObject?: PortableObjectVerifier;
806
+
807
+ /**
808
+ * Whether pages and items that are newly fetched use
809
+ * {@link TraverseCollectionOptions.verifyPortableObject} as their default
810
+ * verifier for their property accessors. Defaults to `true`. If `false`,
811
+ * they use the default verifier of the object they are obtained from, if
812
+ * any, instead. See the `inheritPortableObjectVerifier` option of property
813
+ * accessors such as `Collection.getItems()`.
814
+ * @since 2.4.0
815
+ */
816
+ inheritPortableObjectVerifier?: boolean;
390
817
  }
391
818
 
392
819
  /**
package/src/mod.ts CHANGED
@@ -58,7 +58,16 @@ export * from "./type.ts";
58
58
  export * from "./vocab.ts";
59
59
  export { LanguageString } from "@fedify/vocab-runtime";
60
60
  export type {
61
+ Decimal,
61
62
  DocumentLoader,
63
+ DocumentLoaderOptions,
62
64
  GetUserAgentOptions,
65
+ Json,
66
+ PortableObjectReferrer,
67
+ PortableObjectVerification,
68
+ PortableObjectVerifier,
69
+ PortableObjectVerifierOptions,
70
+ PropertyPreprocessor,
71
+ PropertyPreprocessorContext,
63
72
  RemoteDocument,
64
73
  } from "@fedify/vocab-runtime";
package/src/object.yaml CHANGED
@@ -469,3 +469,18 @@ properties:
469
469
  (boost) was approved by the target post's author.
470
470
  range:
471
471
  - "https://gotosocial.org/ns#AnnounceAuthorization"
472
+
473
+ - pluralName: translations
474
+ singularName: translation
475
+ compactName: translations
476
+ uri: "https://w3id.org/fep/22cd#translations"
477
+ extraContext: "https://w3id.org/fep/22cd"
478
+ description: |
479
+ Translation metadata for the language versions in this object's content,
480
+ name, or summary maps, as defined by the draft
481
+ [FEP-22cd](https://w3id.org/fep/22cd). Each entry describes one translated
482
+ language; directly authored languages have no entry. The entries share
483
+ this object's identity, replies, and reactions. Translator credit grants
484
+ no authority to update the object.
485
+ range:
486
+ - "https://w3id.org/fep/22cd#Translation"