@fedify/vocab 2.4.0-dev.2228 → 2.4.0-dev.2240

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.
@@ -2,8 +2,8 @@ import "temporal-polyfill";
2
2
  globalThis.addEventListener = () => {};
3
3
  import { F as Person } from "./vocab-D3C791xF.mjs";
4
4
  import { t as esm_default } from "./esm-2_Uqq_my.mjs";
5
- import { n as getActorHandle } from "./actor-BT5CKpjL.mjs";
6
- import { t as lookupObject } from "./lookup-eV4_-duv.mjs";
5
+ import { n as getActorHandle } from "./actor-C-Bvqy6v.mjs";
6
+ import { t as lookupObject } from "./lookup-DiU8zIYx.mjs";
7
7
  import { t as assertInstanceOf } from "./utils-Df2odC27.mjs";
8
8
  import { mockDocumentLoader, test } from "@fedify/fixture";
9
9
  import { deepStrictEqual, equal, rejects } from "node:assert/strict";
@@ -98,6 +98,36 @@ test("lookupObject() with FEP-ef61 portable actors", {
98
98
  }), Person);
99
99
  deepStrictEqual(documentLoader.fetched, [compatibleId, hinted]);
100
100
  });
101
+ await t.step("explicit gateways replace WebFinger hints", async () => {
102
+ esm_default.removeRoutes();
103
+ esm_default.get(webFingerPrefix, selfLinks(`ap://${did}/actor?@gateway=https%3A%2F%2Fhint.example`));
104
+ const second = `https://second.example${gatewayPath}`;
105
+ const documentLoader = createLoader({ [second]: person() });
106
+ assertInstanceOf(await lookupObject("@alice@example.com", {
107
+ documentLoader,
108
+ contextLoader: mockDocumentLoader,
109
+ verifyPortableObject: createVerifier(),
110
+ gateways: ["https://second.example"]
111
+ }), Person);
112
+ deepStrictEqual(documentLoader.fetched, [compatibleId, second]);
113
+ });
114
+ await t.step("WebFinger host counts toward the five attempts", async () => {
115
+ esm_default.removeRoutes();
116
+ esm_default.get(webFingerPrefix, selfLinks(`ap://${did}/actor`));
117
+ const documentLoader = createLoader({});
118
+ equal(await lookupObject("@alice@example.com", {
119
+ documentLoader,
120
+ contextLoader: mockDocumentLoader,
121
+ verifyPortableObject: createVerifier(),
122
+ gateways: Array.from({ length: 6 }, (_, i) => `https://g${i}.example`)
123
+ }), null);
124
+ deepStrictEqual(documentLoader.fetched, [compatibleId, ...[
125
+ 0,
126
+ 1,
127
+ 2,
128
+ 3
129
+ ].map((i) => `https://g${i}.example${gatewayPath}`)]);
130
+ });
101
131
  await t.step("tries the next self link after a failure", async () => {
102
132
  esm_default.removeRoutes();
103
133
  const other = `https://other.example${gatewayPath}`;
@@ -177,6 +207,13 @@ test("lookupObject() with FEP-ef61 portable actors", {
177
207
  });
178
208
  assertInstanceOf(actor, Person);
179
209
  deepStrictEqual(actor.id, new URL(compatibleId));
210
+ const malformed = `${compatibleId}?gateways=x`;
211
+ const ordinaryLoader = createLoader({ [malformed]: person(malformed) });
212
+ assertInstanceOf(await lookupObject(malformed, {
213
+ documentLoader: ordinaryLoader,
214
+ contextLoader: mockDocumentLoader
215
+ }), Person);
216
+ deepStrictEqual(ordinaryLoader.fetched, [malformed]);
180
217
  });
181
218
  await t.step("refuses portable IRIs changed by URL parsing", async () => {
182
219
  esm_default.removeRoutes();
@@ -191,6 +228,20 @@ test("lookupObject() with FEP-ef61 portable actors", {
191
228
  equal(await lookupObject(`ap://${did}/x/../actor?@gateway=https%3A%2F%2Fexample.com`, options), null);
192
229
  deepStrictEqual(documentLoader.fetched, []);
193
230
  });
231
+ await t.step("refuses compatible IDs changed by URL parsing", async () => {
232
+ esm_default.removeRoutes();
233
+ const dotted = compatibleId.replace("/actor", "/x/../actor");
234
+ const documentLoader = createLoader({ [compatibleId]: person() });
235
+ const options = {
236
+ documentLoader,
237
+ contextLoader: mockDocumentLoader,
238
+ verifyPortableObject: createVerifier()
239
+ };
240
+ equal(await lookupObject(dotted, options), null);
241
+ esm_default.get(webFingerPrefix, selfLinks(dotted));
242
+ equal(await lookupObject("@alice@example.com", options), null);
243
+ deepStrictEqual(documentLoader.fetched, []);
244
+ });
194
245
  await t.step("bounds gateway requests", async () => {
195
246
  esm_default.removeRoutes();
196
247
  const hints = Array.from({ length: 5 }, (_, i) => `@gateway=https%3A%2F%2Fg${i}.example`).join("&");
@@ -267,6 +318,79 @@ test("lookupObject() looks up portable identifiers directly", async () => {
267
318
  }), null);
268
319
  deepStrictEqual(documentLoader.fetched.length, 5);
269
320
  });
321
+ test("lookupObject() uses explicit gateways for portable IDs", async (t) => {
322
+ const first = `https://first.example${gatewayPath}`;
323
+ const second = `https://second.example${gatewayPath}`;
324
+ const hint = `https://hint.example${gatewayPath}`;
325
+ const options = {
326
+ contextLoader: mockDocumentLoader,
327
+ verifyPortableObject: createVerifier()
328
+ };
329
+ await t.step("bare ap: ID and one gateway", async () => {
330
+ const documentLoader = createLoader({ [first]: person() });
331
+ assertInstanceOf(await lookupObject(`ap://${did}/actor`, {
332
+ ...options,
333
+ documentLoader,
334
+ gateways: ["https://first.example"]
335
+ }), Person);
336
+ deepStrictEqual(documentLoader.fetched, [first]);
337
+ });
338
+ await t.step("tries several gateways in order", async () => {
339
+ const documentLoader = createLoader({ [second]: person() });
340
+ assertInstanceOf(await lookupObject(actorId, {
341
+ ...options,
342
+ documentLoader,
343
+ gateways: ["https://first.example", new URL("https://second.example")]
344
+ }), Person);
345
+ deepStrictEqual(documentLoader.fetched, [first, second]);
346
+ });
347
+ await t.step("explicit gateways replace location hints", async () => {
348
+ const documentLoader = createLoader({
349
+ [second]: person(),
350
+ [hint]: person()
351
+ });
352
+ assertInstanceOf(await lookupObject(`ap://${did}/actor?@gateway=https%3A%2F%2Fhint.example`, {
353
+ ...options,
354
+ documentLoader,
355
+ gateways: ["https://first.example", "https://second.example"]
356
+ }), Person);
357
+ deepStrictEqual(documentLoader.fetched, [first, second]);
358
+ });
359
+ await t.step("empty list uses a custom document loader", async () => {
360
+ const hintedId = `ap://${did}/actor?@gateway=https%3A%2F%2Fhint.example`;
361
+ const canonicalHintedId = `${actorId}?@gateway=https%3A%2F%2Fhint.example`;
362
+ const documentLoader = createLoader({
363
+ [canonicalHintedId]: person(),
364
+ [hint]: person()
365
+ });
366
+ const actor = await lookupObject(hintedId, {
367
+ ...options,
368
+ documentLoader,
369
+ gateways: []
370
+ });
371
+ deepStrictEqual(documentLoader.fetched, [canonicalHintedId]);
372
+ assertInstanceOf(actor, Person);
373
+ });
374
+ await t.step("invalid gateway throws before fetching", async () => {
375
+ const documentLoader = createLoader({});
376
+ for (const verifyPortableObject of [void 0, createVerifier()]) await rejects(() => lookupObject(actorId, {
377
+ documentLoader,
378
+ contextLoader: mockDocumentLoader,
379
+ verifyPortableObject,
380
+ gateways: ["https://bad.example/path"]
381
+ }), TypeError);
382
+ deepStrictEqual(documentLoader.fetched, []);
383
+ });
384
+ await t.step("a malformed portable ID is refused first", async () => {
385
+ const documentLoader = createLoader({});
386
+ equal(await lookupObject(`ap://${did}/x/../actor`, {
387
+ ...options,
388
+ documentLoader,
389
+ gateways: ["https://bad.example/path"]
390
+ }), null);
391
+ deepStrictEqual(documentLoader.fetched, []);
392
+ });
393
+ });
270
394
  test("getActorHandle() with FEP-ef61 portable actors", {
271
395
  sanitizeResources: false,
272
396
  sanitizeOps: false
@@ -362,6 +486,62 @@ test("lookupObject() reports inferred gateways as hints", async () => {
362
486
  deepStrictEqual(calls[0].gateways, void 0);
363
487
  deepStrictEqual(calls[0].gatewayHints, [new URL("https://example.com")]);
364
488
  });
489
+ test("lookupObject() reports explicit gateways to the verifier", async (t) => {
490
+ const calls = [];
491
+ const verifyPortableObject = async (_, options) => {
492
+ calls.push(options);
493
+ return { verified: true };
494
+ };
495
+ await t.step("bare ID with a limited explicit list", async () => {
496
+ const documentLoader = createLoader({ [`https://g4.example${gatewayPath}`]: person() });
497
+ assertInstanceOf(await lookupObject(actorId, {
498
+ documentLoader,
499
+ contextLoader: mockDocumentLoader,
500
+ verifyPortableObject,
501
+ gateways: Array.from({ length: 6 }, (_, i) => `https://g${i}.example`)
502
+ }), Person);
503
+ deepStrictEqual(documentLoader.fetched, [
504
+ 0,
505
+ 1,
506
+ 2,
507
+ 3,
508
+ 4
509
+ ].map((i) => `https://g${i}.example${gatewayPath}`));
510
+ deepStrictEqual(calls.at(-1)?.gateways, [
511
+ 0,
512
+ 1,
513
+ 2,
514
+ 3,
515
+ 4
516
+ ].map((i) => new URL(`https://g${i}.example`)));
517
+ equal(calls.at(-1)?.gatewayHints, void 0);
518
+ });
519
+ await t.step("compatible origin stays first and is deduplicated", async () => {
520
+ const second = `https://second.example${gatewayPath}`;
521
+ const documentLoader = createLoader({ [second]: person() });
522
+ assertInstanceOf(await lookupObject(compatibleId, {
523
+ documentLoader,
524
+ contextLoader: mockDocumentLoader,
525
+ verifyPortableObject,
526
+ gateways: ["https://example.com", "https://second.example"]
527
+ }), Person);
528
+ deepStrictEqual(documentLoader.fetched, [compatibleId, second]);
529
+ deepStrictEqual(calls.at(-1)?.gateways, [new URL("https://example.com"), new URL("https://second.example")]);
530
+ equal(calls.at(-1)?.gatewayHints, void 0);
531
+ });
532
+ await t.step("empty list keeps the compatible origin", async () => {
533
+ const documentLoader = createLoader({ [compatibleId]: person() });
534
+ assertInstanceOf(await lookupObject(compatibleId, {
535
+ documentLoader,
536
+ contextLoader: mockDocumentLoader,
537
+ verifyPortableObject,
538
+ gateways: []
539
+ }), Person);
540
+ deepStrictEqual(documentLoader.fetched, [compatibleId]);
541
+ deepStrictEqual(calls.at(-1)?.gateways, []);
542
+ equal(calls.at(-1)?.gatewayHints, void 0);
543
+ });
544
+ });
365
545
  test("lookupObject() verifies fetched documents that stand for portable objects", async (t) => {
366
546
  const plainUrl = "https://example.com/users/alice";
367
547
  const redirectingLoader = (documentUrl, document) => {
@@ -394,6 +574,24 @@ test("lookupObject() verifies fetched documents that stand for portable objects"
394
574
  deepStrictEqual(calls.length, 1);
395
575
  deepStrictEqual(calls[0].documentUrl, new URL(compatibleId));
396
576
  deepStrictEqual(calls[0].gatewayHints, [new URL("https://example.com")]);
577
+ const explicitCalls = [];
578
+ assertInstanceOf(await lookupObject(plainUrl, {
579
+ documentLoader,
580
+ contextLoader: mockDocumentLoader,
581
+ gateways: ["https://other.example"],
582
+ verifyPortableObject: async (_, options) => {
583
+ explicitCalls.push(options);
584
+ return { verified: true };
585
+ }
586
+ }), Person);
587
+ deepStrictEqual(explicitCalls[0].gateways, [new URL("https://other.example")]);
588
+ equal(explicitCalls[0].gatewayHints, void 0);
589
+ await rejects(() => lookupObject(plainUrl, {
590
+ documentLoader,
591
+ contextLoader: mockDocumentLoader,
592
+ verifyPortableObject: createVerifier(),
593
+ gateways: ["https://bad.example/path"]
594
+ }), TypeError);
397
595
  equal(await lookupObject(plainUrl, {
398
596
  documentLoader,
399
597
  contextLoader: mockDocumentLoader,
@@ -1,8 +1,8 @@
1
1
  import "temporal-polyfill";
2
2
  globalThis.addEventListener = () => {};
3
3
  import { A as Object$1, E as Link, F as Person, K as Translation, L as PropertyValue, P as Organization, U as Service, a as Application, b as Group, k as Note, o as Article, q as Update, s as Collection, u as Create } from "./vocab-D3C791xF.mjs";
4
- import "./actor-BT5CKpjL.mjs";
5
- import "./lookup-eV4_-duv.mjs";
4
+ import "./actor-C-Bvqy6v.mjs";
5
+ import "./lookup-DiU8zIYx.mjs";
6
6
  import { test } from "@fedify/fixture";
7
7
  import { deepStrictEqual, ok, rejects } from "node:assert/strict";
8
8
  import { LanguageString as LanguageString$1, getDocumentLoader, preloadedContexts } from "@fedify/vocab-runtime";
@@ -411,6 +411,22 @@ test("fromJsonLd() handles portable ActivityPub IRIs", async () => {
411
411
  deepStrictEqual(pageJson.next, "ap+ef61://did:key:z6Mkabc/actor/outbox?page=2");
412
412
  deepStrictEqual(pageJson.prev, "ap+ef61://did:key:z6Mkabc/actor/outbox?page=1");
413
413
  });
414
+ test("fromJsonLd() refuses portable IDs changed by URL parsing", async () => {
415
+ const base = "ap://did:key:z6Mkabc";
416
+ for (const id of [
417
+ `${base}/a/../b`,
418
+ `${base}/a/%2e%2E/b`,
419
+ "https://gw.example/.well-known/apgateway/did:key:z6Mkabc/a/../b",
420
+ "https:/gw.example/.well-known/apgateway/did:key:z6Mkabc/a/../b"
421
+ ]) await rejects(() => Note.fromJsonLd({
422
+ "@context": "https://www.w3.org/ns/activitystreams",
423
+ type: "Note",
424
+ id
425
+ }, {
426
+ documentLoader: mockDocumentLoader,
427
+ contextLoader: mockDocumentLoader
428
+ }), TypeError);
429
+ });
414
430
  test("FEP-ef61: actor gateways round-trip as an ordered URI list", async () => {
415
431
  const actorClasses = [
416
432
  Application,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fedify/vocab",
3
- "version": "2.4.0-dev.2228+6d017d55",
3
+ "version": "2.4.0-dev.2240+79613fa6",
4
4
  "homepage": "https://fedify.dev/",
5
5
  "repository": {
6
6
  "type": "git",
@@ -43,9 +43,9 @@
43
43
  "jsonld": "^9.0.0",
44
44
  "pkijs": "^3.3.3",
45
45
  "temporal-polyfill": "^1.0.1",
46
- "@fedify/webfinger": "2.4.0-dev.2228+6d017d55",
47
- "@fedify/vocab-tools": "2.4.0-dev.2228+6d017d55",
48
- "@fedify/vocab-runtime": "2.4.0-dev.2228+6d017d55"
46
+ "@fedify/vocab-tools": "2.4.0-dev.2240+79613fa6",
47
+ "@fedify/vocab-runtime": "2.4.0-dev.2240+79613fa6",
48
+ "@fedify/webfinger": "2.4.0-dev.2240+79613fa6"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@types/node": "^24.12.2",
@@ -12,6 +12,7 @@ import {
12
12
  type PortableObjectVerifier,
13
13
  type PortableObjectVerifierOptions,
14
14
  type RemoteDocument,
15
+ withGatewayHints,
15
16
  } from "@fedify/vocab-runtime";
16
17
  import fetchMock from "fetch-mock";
17
18
  import { deepStrictEqual, ok, rejects } from "node:assert/strict";
@@ -1632,18 +1633,21 @@ test("accessors verify fetched documents that stand for portable objects", async
1632
1633
  deepStrictEqual(object instanceof Note, expected);
1633
1634
  }
1634
1635
  // A final URL that URL parsing would turn into another ID is refused:
1635
- deepStrictEqual(
1636
- await (await createHttpActivity(plainUrl)).getObject({
1637
- documentLoader: createRedirectingLoader({
1638
- [plainUrl]: {
1639
- documentUrl: `ap://${did}/objects/x/../1`,
1640
- document: note(),
1641
- },
1642
- }),
1643
- contextLoader: mockDocumentLoader,
1644
- verifyPortableObject: createVerifier(),
1645
- }),
1646
- null,
1636
+ await rejects(
1637
+ () =>
1638
+ createHttpActivity(plainUrl).then((activity) =>
1639
+ activity.getObject({
1640
+ documentLoader: createRedirectingLoader({
1641
+ [plainUrl]: {
1642
+ documentUrl: `ap://${did}/objects/x/../1`,
1643
+ document: note(),
1644
+ },
1645
+ }),
1646
+ contextLoader: mockDocumentLoader,
1647
+ verifyPortableObject: createVerifier(),
1648
+ })
1649
+ ),
1650
+ TypeError,
1647
1651
  );
1648
1652
  });
1649
1653
 
@@ -2599,3 +2603,35 @@ test("accessors pass the verifier of a call on to the objects they fetch", async
2599
2603
  for (const item of optedOut) ok(getDefaultVerifier(item) == null);
2600
2604
  });
2601
2605
  });
2606
+
2607
+ test("getObject() follows location hints made by withGatewayHints()", async () => {
2608
+ const activity = new Create({
2609
+ id: parseIri(`ap://${did}/activities/1`),
2610
+ object: withGatewayHints(objectId, [
2611
+ "https://gw1.example",
2612
+ "https://gw2.example",
2613
+ ]),
2614
+ });
2615
+ const json = await activity.toJsonLd({ contextLoader: mockDocumentLoader });
2616
+ deepStrictEqual(
2617
+ (json as Record<string, unknown>).object,
2618
+ `${objectId}?@gateway=https%3A%2F%2Fgw1.example` +
2619
+ "&@gateway=https%3A%2F%2Fgw2.example",
2620
+ );
2621
+ const parsed = await Create.fromJsonLd(json, {
2622
+ contextLoader: mockDocumentLoader,
2623
+ });
2624
+ const documentLoader = createLoader({
2625
+ [`https://gw2.example${gatewayPath}`]: note(),
2626
+ });
2627
+ const object = await parsed.getObject({
2628
+ documentLoader,
2629
+ contextLoader: mockDocumentLoader,
2630
+ verifyPortableObject: createVerifier(),
2631
+ });
2632
+ assertInstanceOf(object, Note);
2633
+ deepStrictEqual(documentLoader.fetched, [
2634
+ `https://gw1.example${gatewayPath}`,
2635
+ `https://gw2.example${gatewayPath}`,
2636
+ ]);
2637
+ });
package/src/lookup.ts CHANGED
@@ -185,6 +185,19 @@ export interface LookupObjectOptions {
185
185
  */
186
186
  signal?: AbortSignal;
187
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
+
188
201
  /**
189
202
  * The [FEP-ef61] policy to apply to portable objects, typically
190
203
  * `verifyPortableObject()` from `@fedify/fedify`, which
@@ -252,7 +265,9 @@ const MAX_PORTABLE_ATTEMPTS = 5;
252
265
  *
253
266
  * [FEP-ef61] portable objects, including portable actors found through
254
267
  * WebFinger, are looked up only if the `verifyPortableObject` option is
255
- * given; see {@link LookupObjectOptions.verifyPortableObject}.
268
+ * given; see {@link LookupObjectOptions.verifyPortableObject}. Pass
269
+ * {@link LookupObjectOptions.gateways} to look up a portable ID without
270
+ * `@gateway` location hints.
256
271
  *
257
272
  * [FEP-ef61]: https://w3id.org/fep/ef61
258
273
  *
@@ -345,7 +360,13 @@ async function lookupObjectInternal(
345
360
  if (candidate == null) return null;
346
361
  return await lookupPortableObject(portable, candidate, undefined);
347
362
  }
348
- identifier = toAcctUrl(identifier) ?? new URL(identifier);
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);
349
370
  }
350
371
  if (isPortableIri(identifier)) {
351
372
  let candidate: URL;
@@ -408,7 +429,9 @@ async function lookupObjectInternal(
408
429
  const candidate = parsePortableCandidate(l.href);
409
430
  if (candidate == null) continue;
410
431
  const gateways = webFingerGateway == null ? [] : [webFingerGateway];
411
- gateways.push(...getPortableGatewayCandidates(candidate));
432
+ if (options.gateways == null) {
433
+ gateways.push(...getPortableGatewayCandidates(candidate));
434
+ }
412
435
  const object = await lookupPortableObject(
413
436
  portable,
414
437
  candidate,
@@ -484,10 +507,14 @@ async function lookupObjectInternal(
484
507
  );
485
508
  return null;
486
509
  } else if (claim != null) {
487
- return await dereference(portable, claim.id, claim.inferredGateways, {
488
- response: remoteDoc,
489
- contextLoader: snapshot.loader,
490
- });
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
+ );
491
518
  }
492
519
  }
493
520
  if (
@@ -530,6 +557,19 @@ interface PortableLookup {
530
557
  readonly attempted: Set<string>;
531
558
  /** The number of gateway requests left for this lookup. */
532
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
+ );
533
573
  }
534
574
 
535
575
  /**
@@ -600,17 +640,18 @@ function getWebFingerGateway(identifier: URL): URL | undefined {
600
640
 
601
641
  /**
602
642
  * Looks up a portable object through FEP-ef61 gateways.
603
- * @param gateways The gateways to ask in order. If omitted, the location
604
- * hints in the IRI are used, and if there are none, the
605
- * portable IRI itself is passed to the document loader.
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.
606
646
  */
607
647
  async function lookupPortableObject(
608
648
  lookup: PortableLookup,
609
649
  id: URL,
610
- gateways: readonly URL[] | undefined,
650
+ inferredGateways: readonly URL[] | undefined,
611
651
  ): Promise<Object | null> {
612
652
  const { options } = lookup;
613
653
  const iri = formatIri(id);
654
+ const explicitGateways = getExplicitGateways(lookup, id);
614
655
  if (options.verifyPortableObject == null) {
615
656
  logger.debug(
616
657
  "Cannot look up the portable object {iri}, as the " +
@@ -620,20 +661,27 @@ async function lookupPortableObject(
620
661
  return null;
621
662
  }
622
663
  const canonicalId = canonicalizePortableUri(iri);
623
- let candidates: URL[];
624
- if (gateways == null) {
625
- candidates = getPortableGatewayCandidates(id);
626
- if (candidates.length < 1) {
627
- // No gateway to ask; a custom document loader may know how to
628
- // retrieve the portable IRI itself:
629
- const key = `${canonicalId} `;
630
- if (lookup.remaining < 1 || lookup.attempted.has(key)) return null;
631
- lookup.attempted.add(key);
632
- lookup.remaining--;
633
- return await dereference(lookup, id, []);
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);
634
670
  }
635
- } else {
636
- candidates = [...gateways];
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
+ );
637
685
  }
638
686
  const selected: URL[] = [];
639
687
  for (const gateway of candidates) {
@@ -645,13 +693,25 @@ async function lookupPortableObject(
645
693
  }
646
694
  if (selected.length < 1) return null;
647
695
  lookup.remaining -= selected.length;
648
- return await dereference(lookup, id, selected);
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
+ );
649
708
  }
650
709
 
651
710
  async function dereference(
652
711
  { options, documentLoader }: PortableLookup,
653
712
  id: URL,
654
- gateways: readonly URL[],
713
+ inferredGateways: readonly URL[],
714
+ gateways?: readonly URL[],
655
715
  extra: { response?: RemoteDocument; contextLoader?: DocumentLoader } = {},
656
716
  ): Promise<Object | null> {
657
717
  const tracerProvider = options.tracerProvider ?? trace.getTracerProvider();
@@ -661,10 +721,9 @@ async function dereference(
661
721
  contextLoader: extra.contextLoader ?? options.contextLoader ??
662
722
  getDocumentLoader({ userAgent: options.userAgent }),
663
723
  tracerProvider,
664
- // Gateways are inferred from the identifier, WebFinger, or location
665
- // hints, rather than given by the caller, so they are reported to the
666
- // verifier as hints:
667
- inferredGateways: gateways,
724
+ // Preserve where the gateways came from for the verifier:
725
+ inferredGateways,
726
+ ...(gateways == null ? {} : { gateways }),
668
727
  response: extra.response,
669
728
  verifyPortableObject: options.verifyPortableObject,
670
729
  crossOrigin: options.crossOrigin === "throw" ? "throw" : "ignore",