@fedify/vocab-runtime 2.4.0-dev.2228 → 2.4.0-dev.2233

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 (45) hide show
  1. package/deno.json +2 -2
  2. package/dist/internal/jsonld-cache.cjs +1 -1
  3. package/dist/internal/jsonld-cache.js +1 -1
  4. package/dist/internal/portable-dereference.cjs +4 -13
  5. package/dist/internal/portable-dereference.js +2 -11
  6. package/dist/mod.cjs +5 -2
  7. package/dist/mod.d.cts +93 -1
  8. package/dist/mod.d.ts +93 -1
  9. package/dist/mod.js +3 -3
  10. package/dist/tests/{body-BmNV5g5S.mjs → body-BEYUpKvX.mjs} +1 -1
  11. package/dist/tests/{body-BQZL1aJ1.cjs → body-DQKTFMsH.cjs} +1 -1
  12. package/dist/tests/body.test.cjs +1 -1
  13. package/dist/tests/body.test.mjs +1 -1
  14. package/dist/tests/decimal.test.cjs +3 -3
  15. package/dist/tests/decimal.test.mjs +3 -3
  16. package/dist/tests/{docloader-BnuGCXLg.cjs → docloader-DHCYdBXf.cjs} +3 -3
  17. package/dist/tests/{docloader-ButlYQxr.mjs → docloader-ra1lbBfP.mjs} +3 -3
  18. package/dist/tests/docloader.test.cjs +3 -3
  19. package/dist/tests/docloader.test.mjs +3 -3
  20. package/dist/tests/internal/portable-dereference.test.cjs +15 -14
  21. package/dist/tests/internal/portable-dereference.test.mjs +13 -12
  22. package/dist/tests/{jsonld-cache-C7mkqjiE.mjs → jsonld-cache-BKrsWPCQ.mjs} +1 -1
  23. package/dist/tests/{jsonld-cache-CXA76Xi6.cjs → jsonld-cache-BTgizq9A.cjs} +1 -1
  24. package/dist/tests/jsonld-cache.test.cjs +2 -2
  25. package/dist/tests/jsonld-cache.test.mjs +2 -2
  26. package/dist/tests/{portable-media-xFDDMte7.mjs → portable-media-1NjUQari.mjs} +3 -3
  27. package/dist/tests/{portable-media-CFrGpz-3.cjs → portable-media-DHaAEpaQ.cjs} +3 -3
  28. package/dist/tests/portable-media.test.cjs +1 -1
  29. package/dist/tests/portable-media.test.mjs +1 -1
  30. package/dist/tests/{request-Cme6ShRu.cjs → request-BQlkB8du.cjs} +1 -1
  31. package/dist/tests/{request-2GDEEJng.mjs → request-D3elVRyB.mjs} +1 -1
  32. package/dist/tests/request.test.cjs +1 -1
  33. package/dist/tests/request.test.mjs +1 -1
  34. package/dist/tests/{url-R9TTZ67B.mjs → url-Cdu4ASgJ.mjs} +161 -1
  35. package/dist/tests/{url-B6WlNhra.cjs → url-CtHyzMR4.cjs} +190 -0
  36. package/dist/tests/url.test.cjs +119 -1
  37. package/dist/tests/url.test.mjs +119 -1
  38. package/dist/{url-CHAbe3hE.cjs → url-BHfQuRdy.cjs} +190 -0
  39. package/dist/{url-BfguNa6K.js → url-DqqDZY9i.js} +161 -1
  40. package/package.json +1 -1
  41. package/src/internal/portable-dereference.test.ts +14 -1
  42. package/src/internal/portable-dereference.ts +3 -14
  43. package/src/mod.ts +3 -0
  44. package/src/url.test.ts +295 -0
  45. package/src/url.ts +232 -0
package/src/url.test.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  formatIri,
8
8
  fromCompatibleEf61Id,
9
9
  getFe34Origin,
10
+ getGatewayHints,
10
11
  haveSameFe34Origin,
11
12
  haveSameIriOrigin,
12
13
  isGatewayUrl,
@@ -19,6 +20,8 @@ import {
19
20
  UrlError,
20
21
  validateLookupAddresses,
21
22
  validatePublicUrl,
23
+ withGatewayHints,
24
+ withoutGatewayHints,
22
25
  } from "./url.ts";
23
26
 
24
27
  test("parseIri() accepts portable ActivityPub URI schemes", () => {
@@ -1227,3 +1230,295 @@ test("expandIPv6Address()", () => {
1227
1230
  "0064:ff9b:0000:0000:0000:0000:0808:0808",
1228
1231
  );
1229
1232
  });
1233
+
1234
+ test("withGatewayHints() adds @gateway location hints", () => {
1235
+ const expected = "ap+ef61://did:key:z6Mkabc/actor" +
1236
+ "?@gateway=https%3A%2F%2Fserver1.example" +
1237
+ "&@gateway=https%3A%2F%2Fserver2.example";
1238
+ for (
1239
+ const id of [
1240
+ "ap://did:key:z6Mkabc/actor",
1241
+ "ap+ef61://did:key:z6Mkabc/actor",
1242
+ "ap://did%3Akey%3Az6Mkabc/actor",
1243
+ parseIri("ap://did:key:z6Mkabc/actor"),
1244
+ new URL("ap://did%3Akey%3Az6Mkabc/actor"),
1245
+ ]
1246
+ ) {
1247
+ const hinted = withGatewayHints(id, [
1248
+ "https://server1.example",
1249
+ new URL("https://server2.example/"),
1250
+ ]);
1251
+ ok(hinted instanceof URL);
1252
+ strictEqual(
1253
+ hinted.href,
1254
+ "ap+ef61://did%3Akey%3Az6Mkabc/actor" +
1255
+ "?@gateway=https%3A%2F%2Fserver1.example" +
1256
+ "&@gateway=https%3A%2F%2Fserver2.example",
1257
+ );
1258
+ strictEqual(formatIri(hinted), expected);
1259
+ deepStrictEqual(parseIri(formatIri(hinted)), hinted);
1260
+ }
1261
+ });
1262
+
1263
+ test("withGatewayHints() encodes and deduplicates gateway origins", () => {
1264
+ function* gateways(): Generator<string | URL> {
1265
+ yield "https://A.example:443";
1266
+ yield "https://a.example/";
1267
+ yield new URL("https://a.example");
1268
+ yield "https://b.example:8443";
1269
+ yield "http://c.example:80/";
1270
+ yield "https://例え.jp";
1271
+ }
1272
+ strictEqual(
1273
+ formatIri(withGatewayHints("ap://did:key:z6Mkabc/actor", gateways())),
1274
+ "ap+ef61://did:key:z6Mkabc/actor" +
1275
+ "?@gateway=https%3A%2F%2Fa.example" +
1276
+ "&@gateway=https%3A%2F%2Fb.example%3A8443" +
1277
+ "&@gateway=http%3A%2F%2Fc.example" +
1278
+ "&@gateway=https%3A%2F%2Fxn--r8jz45g.jp",
1279
+ );
1280
+ strictEqual(
1281
+ formatIri(
1282
+ withGatewayHints(
1283
+ "ap://did:key:z6Mkabc/actor",
1284
+ new Set(["https://b.example", "https://a.example"]),
1285
+ ),
1286
+ ),
1287
+ "ap+ef61://did:key:z6Mkabc/actor" +
1288
+ "?@gateway=https%3A%2F%2Fb.example&@gateway=https%3A%2F%2Fa.example",
1289
+ );
1290
+ // There is no limit on the number of hints:
1291
+ const many = Array.from(
1292
+ { length: 7 },
1293
+ (_, i) => `https://server${i}.example`,
1294
+ );
1295
+ deepStrictEqual(
1296
+ getGatewayHints(withGatewayHints("ap://did:key:z6Mkabc/actor", many))
1297
+ .map((url) => url.origin),
1298
+ many,
1299
+ );
1300
+ });
1301
+
1302
+ test("withGatewayHints() keeps other query parameters and fragments", () => {
1303
+ strictEqual(
1304
+ formatIri(
1305
+ withGatewayHints(
1306
+ "ap://did:key:z6Mkabc/collection?page=3&maxItems=20&q=a+b%20c%2b",
1307
+ ["https://server.example"],
1308
+ ),
1309
+ ),
1310
+ "ap+ef61://did:key:z6Mkabc/collection?page=3&maxItems=20&q=a+b%20c%2B" +
1311
+ "&@gateway=https%3A%2F%2Fserver.example",
1312
+ );
1313
+ strictEqual(
1314
+ formatIri(
1315
+ withGatewayHints(
1316
+ "ap://did:key:z6Mkabc/actor?x=%26%3D#main-key",
1317
+ ["https://server.example"],
1318
+ ),
1319
+ ),
1320
+ "ap+ef61://did:key:z6Mkabc/actor?x=%26%3D" +
1321
+ "&@gateway=https%3A%2F%2Fserver.example#main-key",
1322
+ );
1323
+ strictEqual(
1324
+ withGatewayHints("ap://did:key:z6Mkabc/actor#", ["https://s.example"])
1325
+ .href,
1326
+ "ap+ef61://did%3Akey%3Az6Mkabc/actor" +
1327
+ "?@gateway=https%3A%2F%2Fs.example#",
1328
+ );
1329
+ });
1330
+
1331
+ test("withGatewayHints() replaces existing location hints", () => {
1332
+ strictEqual(
1333
+ formatIri(
1334
+ withGatewayHints(
1335
+ "ap://did:key:z6Mkabc/collection?@gateway=https%3A%2F%2Fold.example" +
1336
+ "&page=2&%40gateway=https%3A%2F%2Fold2.example&@gateway" +
1337
+ "&gateways=https%3A%2F%2Flegacy.example&%2540gateway=kept",
1338
+ ["https://new.example"],
1339
+ ),
1340
+ ),
1341
+ "ap+ef61://did:key:z6Mkabc/collection?page=2&%2540gateway=kept" +
1342
+ "&@gateway=https%3A%2F%2Fnew.example",
1343
+ );
1344
+ // Characters that the URL parser strips cannot turn into a hint name:
1345
+ for (const char of ["\t", "\n", "\r"]) {
1346
+ const hinted = withGatewayHints(
1347
+ `ap://did:key:z6Mkabc/actor?@gate${char}way=https%3A%2F%2Fevil.example`,
1348
+ ["https://new.example"],
1349
+ );
1350
+ deepStrictEqual(getGatewayHints(hinted).map((url) => url.href), [
1351
+ "https://new.example/",
1352
+ ]);
1353
+ }
1354
+ });
1355
+
1356
+ test("withGatewayHints() with no gateways removes location hints", () => {
1357
+ for (
1358
+ const [input, expected] of [
1359
+ [
1360
+ "ap://did:key:z6Mkabc/actor?@gateway=https%3A%2F%2Fa.example",
1361
+ "ap+ef61://did%3Akey%3Az6Mkabc/actor",
1362
+ ],
1363
+ [
1364
+ "ap://did:key:z6Mkabc/actor?&@gateway=https%3A%2F%2Fa.example&&p=1&",
1365
+ "ap+ef61://did%3Akey%3Az6Mkabc/actor?p=1",
1366
+ ],
1367
+ ["ap://did:key:z6Mkabc/actor?", "ap+ef61://did%3Akey%3Az6Mkabc/actor"],
1368
+ [
1369
+ "ap://did:key:z6Mkabc/actor?gateways=https%3A%2F%2Fa.example#k",
1370
+ "ap+ef61://did%3Akey%3Az6Mkabc/actor#k",
1371
+ ],
1372
+ ]
1373
+ ) {
1374
+ strictEqual(withGatewayHints(input, []).href, expected, input);
1375
+ strictEqual(withoutGatewayHints(input).href, expected, input);
1376
+ }
1377
+ const input = parseIri(
1378
+ "ap://did:key:z6Mkabc/actor?@gateway=https%3A%2F%2Fa.example",
1379
+ );
1380
+ const href = input.href;
1381
+ withoutGatewayHints(input);
1382
+ strictEqual(input.href, href);
1383
+ });
1384
+
1385
+ test("withGatewayHints() rejects non-portable IDs", () => {
1386
+ for (
1387
+ const id of [
1388
+ "https://server.example/.well-known/apgateway/did:key:z6Mkabc/actor",
1389
+ "https://example.com/actor",
1390
+ "did:key:z6Mkabc#z6Mkabc",
1391
+ "ap://did:key:z6Mkabc",
1392
+ "ap://example.com/actor",
1393
+ "ap://did:key:z6Mkabc/actor%zz",
1394
+ new URL("https://example.com/actor"),
1395
+ new URL("ap://did%3Akey%3Az6Mkabc:8080/actor"),
1396
+ new URL("ap://user@did%3Akey%3Az6Mkabc/actor"),
1397
+ ]
1398
+ ) {
1399
+ throws(
1400
+ () => withGatewayHints(id, ["https://server.example"]),
1401
+ TypeError,
1402
+ String(id),
1403
+ );
1404
+ throws(() => withoutGatewayHints(id), TypeError, String(id));
1405
+ throws(() => getGatewayHints(id), TypeError, String(id));
1406
+ }
1407
+ });
1408
+
1409
+ test("withGatewayHints() rejects paths that URLs cannot represent", () => {
1410
+ for (
1411
+ const id of [
1412
+ "ap://did:key:z6Mkabc/a/../actor",
1413
+ "ap://did:key:z6Mkabc/a/./actor",
1414
+ "ap://did:key:z6Mkabc/a/%2e%2e/actor",
1415
+ "ap://did:key:z6Mkabc/a/%2E/actor",
1416
+ ]
1417
+ ) {
1418
+ throws(
1419
+ () => withGatewayHints(id, ["https://server.example"]),
1420
+ TypeError,
1421
+ id,
1422
+ );
1423
+ throws(() => withoutGatewayHints(id), TypeError, id);
1424
+ }
1425
+ // Characters that the URL parser would strip are percent-encoded instead:
1426
+ strictEqual(
1427
+ withoutGatewayHints("ap://did:key:z6Mkabc/a\tb").href,
1428
+ "ap+ef61://did%3Akey%3Az6Mkabc/a%09b",
1429
+ );
1430
+ });
1431
+
1432
+ test("withGatewayHints() rejects invalid gateways", () => {
1433
+ for (
1434
+ const gateway of [
1435
+ "https://server.example/path",
1436
+ "https://server.example/?",
1437
+ "https://server.example/#",
1438
+ "https://server.example/?q=1",
1439
+ "https://user:pass@server.example",
1440
+ "ftp://server.example",
1441
+ "ap://did:key:z6Mkabc/actor",
1442
+ "server.example",
1443
+ new URL("https://server.example/path"),
1444
+ ]
1445
+ ) {
1446
+ throws(
1447
+ () => withGatewayHints("ap://did:key:z6Mkabc/actor", [gateway]),
1448
+ TypeError,
1449
+ String(gateway),
1450
+ );
1451
+ }
1452
+ throws(
1453
+ () =>
1454
+ withGatewayHints(
1455
+ "ap://did:key:z6Mkabc/actor",
1456
+ "https://server.example" as unknown as string[],
1457
+ ),
1458
+ TypeError,
1459
+ );
1460
+ });
1461
+
1462
+ test("getGatewayHints() reads @gateway location hints", () => {
1463
+ deepStrictEqual(
1464
+ getGatewayHints(
1465
+ "ap://did:key:z6Mkabc/actor?@gateway=https%3A%2F%2Fa.example" +
1466
+ "&@gateway=invalid&%40gateway=https%3A%2F%2Fb.example%2F" +
1467
+ "&@gateway=https%3A%2F%2Fa.example%2F" +
1468
+ "&@gateway=https%3A%2F%2Fc.example%2Fpath" +
1469
+ "&gateways=https%3A%2F%2Flegacy.example&page=1#k",
1470
+ ).map((url) => url.href),
1471
+ ["https://a.example/", "https://b.example/"],
1472
+ );
1473
+ deepStrictEqual(getGatewayHints("ap://did:key:z6Mkabc/actor"), []);
1474
+ // A literal question mark is a part of the parameter name:
1475
+ const questioned =
1476
+ "ap://did:key:z6Mkabc/actor??@gateway=https%3A%2F%2Fa.example";
1477
+ deepStrictEqual(getGatewayHints(questioned), []);
1478
+ strictEqual(
1479
+ withoutGatewayHints(questioned).href,
1480
+ "ap+ef61://did%3Akey%3Az6Mkabc/actor??@gateway=https%3A%2F%2Fa.example",
1481
+ );
1482
+ deepStrictEqual(
1483
+ getGatewayHints(withGatewayHints(questioned, ["https://b.example"]))
1484
+ .map((url) => url.href),
1485
+ ["https://b.example/"],
1486
+ );
1487
+ deepStrictEqual(
1488
+ getGatewayHints(
1489
+ "ap://did:key:z6Mkabc/actor?@gate%09way=https%3A%2F%2Fa.example",
1490
+ ),
1491
+ [],
1492
+ );
1493
+ deepStrictEqual(
1494
+ getGatewayHints(
1495
+ "ap://did:key:z6Mkabc/actor?@gate\tway=https%3A%2F%2Fa.example",
1496
+ ),
1497
+ [],
1498
+ );
1499
+ });
1500
+
1501
+ test("withGatewayHints() round-trips with other portable ID helpers", () => {
1502
+ const hinted = withGatewayHints("ap://did:key:z6Mkabc/objects/1#frag", [
1503
+ "https://server1.example",
1504
+ "https://server2.example",
1505
+ ]);
1506
+ strictEqual(
1507
+ canonicalizePortableUri(formatIri(hinted)),
1508
+ "ap+ef61://did:key:z6Mkabc/objects/1#frag",
1509
+ );
1510
+ ok(
1511
+ arePortableUrisEqual(
1512
+ formatIri(hinted),
1513
+ "ap://did:key:z6Mkabc/objects/1#frag",
1514
+ ),
1515
+ );
1516
+ strictEqual(
1517
+ toCompatibleEf61Id(hinted, "https://server1.example").href,
1518
+ "https://server1.example/.well-known/apgateway/did:key:z6Mkabc/objects/1#frag",
1519
+ );
1520
+ strictEqual(
1521
+ formatIri(withoutGatewayHints(hinted)),
1522
+ "ap+ef61://did:key:z6Mkabc/objects/1#frag",
1523
+ );
1524
+ });
package/src/url.ts CHANGED
@@ -595,6 +595,238 @@ function isLocationHint(pair: string): boolean {
595
595
  }
596
596
  }
597
597
 
598
+ /**
599
+ * The name of the FEP-ef61 location hint query parameter.
600
+ * @internal
601
+ */
602
+ export const GATEWAY_HINT_PARAMETER = "@gateway";
603
+
604
+ /**
605
+ * Parses an FEP-ef61 gateway, which has to be an HTTP(S) origin with no
606
+ * credentials, path, query, or fragment.
607
+ * @returns The gateway, or `null` if it is not a valid gateway.
608
+ * @internal
609
+ */
610
+ export function parseGatewayOrigin(gateway: string | URL): URL | null {
611
+ let url: URL;
612
+ if (gateway instanceof URL) url = new URL(gateway.href);
613
+ else if (typeof gateway === "string" && URL.canParse(gateway)) {
614
+ url = new URL(gateway);
615
+ } else return null;
616
+ return isGatewayUrl(url) ? url : null;
617
+ }
618
+
619
+ /**
620
+ * Returns a copy of an [FEP-ef61] portable ActivityPub URI with `@gateway`
621
+ * location hints for the given gateways, which tell consumers where they can
622
+ * retrieve the object. Put hints on *references* to portable actors, e.g.,
623
+ * in `actor`, `attributedTo`, `to`, or `cc`, when constructing an object, as
624
+ * FEP-ef61 recommends:
625
+ *
626
+ * ~~~~ typescript
627
+ * withGatewayHints("ap://did:key:z6Mk.../actor", [
628
+ * "https://server1.example",
629
+ * "https://server2.example",
630
+ * ]);
631
+ * // ap+ef61://did:key:z6Mk.../actor?@gateway=https%3A%2F%2Fserver1.example&@gateway=https%3A%2F%2Fserver2.example
632
+ * ~~~~
633
+ *
634
+ * Do not put hints on an object's own `id`. Hints do not change the
635
+ * identity of a portable URI, since FEP-ef61 drops the query when comparing
636
+ * portable URIs, but implementations that do not canonicalize portable URIs
637
+ * would take a hinted ID for another object. Add hints before signing the
638
+ * object, since its Object Integrity Proof covers its references too.
639
+ *
640
+ * The hints that the URI already has, including the legacy `gateways`
641
+ * parameter, are replaced. Each gateway becomes a `@gateway` query
642
+ * parameter whose value is its URI-encoded origin, e.g.,
643
+ * `@gateway=https%3A%2F%2Fserver1.example`, in the given order after the
644
+ * other query parameters. Duplicate gateways are dropped, and an empty list
645
+ * removes the hints as {@link withoutGatewayHints} does. The other query
646
+ * parameters, their order, and the fragment are kept, but percent-encoding
647
+ * is normalized the same way as {@link canonicalizePortableUri} does it.
648
+ *
649
+ * Fedify follows at most five hints when dereferencing a portable URI
650
+ * (three for the key ID of an HTTP Signature), so list the preferred
651
+ * gateways first; there is no limit on the number of hints added here.
652
+ *
653
+ * [FEP-ef61]: https://w3id.org/fep/ef61
654
+ *
655
+ * @param portableId The `ap:` or `ap+ef61:` URI. Pass the raw string rather
656
+ * than a `URL` if its path may have `.` or `..` segments,
657
+ * because the `URL` class resolves them.
658
+ * @param gateways The gateways, e.g., the `gateways` of the actor that the
659
+ * URI refers to. Each has to be an HTTP(S) origin with no
660
+ * credentials, path, query, or fragment.
661
+ * @returns The portable URI with the hints, in the same internal `URL` form
662
+ * as {@link parseIri} returns. Use {@link formatIri} to get its
663
+ * canonical string.
664
+ * @throws {TypeError} If the portable ID is not a valid `ap:` or `ap+ef61:`
665
+ * URI, e.g., it is a compatible identifier, which must
666
+ * not have location hints; if its path has `.` or `..`
667
+ * segments, which the `URL` class cannot represent; or
668
+ * if a gateway is invalid.
669
+ * @since 2.4.0
670
+ */
671
+ export function withGatewayHints(
672
+ portableId: string | URL,
673
+ gateways: Iterable<string | URL>,
674
+ ): URL {
675
+ const parts = splitPortableIri(portableId);
676
+ if (typeof gateways === "string") {
677
+ throw new TypeError(
678
+ "The gateways must be an iterable of gateways, not a string.",
679
+ );
680
+ }
681
+ const hints: URL[] = [];
682
+ const seen = new Set<string>();
683
+ for (const gateway of gateways) {
684
+ const url = parseGatewayOrigin(gateway);
685
+ if (url == null) {
686
+ throw new TypeError(
687
+ "FEP-ef61 gateways must be HTTP(S) origins with no credentials, " +
688
+ "path, query, or fragment: " + String(gateway),
689
+ );
690
+ }
691
+ if (seen.has(url.href)) continue;
692
+ seen.add(url.href);
693
+ hints.push(url);
694
+ }
695
+ return replaceGatewayHints(parts, hints);
696
+ }
697
+
698
+ /**
699
+ * Returns a copy of an [FEP-ef61] portable ActivityPub URI without its
700
+ * location hints, i.e., `@gateway` query parameters and the legacy
701
+ * `gateways` parameter. The other query parameters, their order, and the
702
+ * fragment are kept, but percent-encoding is normalized the same way as
703
+ * {@link canonicalizePortableUri} does it.
704
+ *
705
+ * [FEP-ef61]: https://w3id.org/fep/ef61
706
+ *
707
+ * @param portableId The `ap:` or `ap+ef61:` URI. Pass the raw string rather
708
+ * than a `URL` if its path may have `.` or `..` segments,
709
+ * because the `URL` class resolves them.
710
+ * @returns The portable URI without the hints, in the same internal `URL`
711
+ * form as {@link parseIri} returns.
712
+ * @throws {TypeError} If the portable ID is not a valid `ap:` or `ap+ef61:`
713
+ * URI, or if its path has `.` or `..` segments, which
714
+ * the `URL` class cannot represent.
715
+ * @since 2.4.0
716
+ */
717
+ export function withoutGatewayHints(portableId: string | URL): URL {
718
+ return replaceGatewayHints(splitPortableIri(portableId), []);
719
+ }
720
+
721
+ /**
722
+ * Gets the gateways in the `@gateway` location hints of an [FEP-ef61]
723
+ * portable ActivityPub URI, in order. Hints that are not valid gateways,
724
+ * i.e., HTTP(S) origins with no credentials, path, query, or fragment, are
725
+ * skipped, and so are duplicates. The legacy `gateways` parameter is not
726
+ * read.
727
+ *
728
+ * Unlike Fedify's dereferencing, which follows at most five hints, this
729
+ * returns all of them.
730
+ *
731
+ * [FEP-ef61]: https://w3id.org/fep/ef61
732
+ *
733
+ * @param portableId The `ap:` or `ap+ef61:` URI.
734
+ * @returns The gateways, e.g., `https://server1.example/`.
735
+ * @throws {TypeError} If the portable ID is not a valid `ap:` or `ap+ef61:`
736
+ * URI.
737
+ * @since 2.4.0
738
+ */
739
+ export function getGatewayHints(portableId: string | URL): URL[] {
740
+ const { query } = splitPortableIri(portableId);
741
+ return query == null ? [] : parseGatewayHints(query);
742
+ }
743
+
744
+ interface PortableIriParts {
745
+ /** The portable ID as it was given, or the `href` of a `URL`. */
746
+ readonly raw: string;
747
+ /** The parsed portable ID. */
748
+ readonly parsed: URL;
749
+ /** The normalized path. */
750
+ readonly path: string;
751
+ /** The normalized query without `?`, or `null` if there is none. */
752
+ readonly query: string | null;
753
+ /** The normalized fragment with `#`, or an empty string if there is none. */
754
+ readonly fragment: string;
755
+ }
756
+
757
+ function splitPortableIri(portableId: string | URL): PortableIriParts {
758
+ const raw = getRawPortableIri(portableId);
759
+ const match = raw.match(PORTABLE_IRI_PATTERN);
760
+ const parsed = parsePortableIri(raw);
761
+ if (match == null || parsed == null) {
762
+ throw new TypeError("Invalid portable ActivityPub IRI.");
763
+ }
764
+ // Normalize the components before looking at them, as the URL parser would
765
+ // otherwise strip characters such as tabs later, which could turn
766
+ // an unrelated query parameter into a location hint:
767
+ return {
768
+ raw,
769
+ parsed,
770
+ path: normalizePortableComponent(match[3]),
771
+ query: match[4] == null
772
+ ? null
773
+ : normalizePortableComponent(match[4].slice(1)),
774
+ fragment: match[5] == null ? "" : normalizePortableComponent(match[5]),
775
+ };
776
+ }
777
+
778
+ function parseGatewayHints(query: string): URL[] {
779
+ const hints: URL[] = [];
780
+ const seen = new Set<string>();
781
+ // Keep the delimiter, as URLSearchParams would otherwise strip a leading
782
+ // question mark that is part of the first parameter's name:
783
+ for (
784
+ const hint of new URLSearchParams(`?${query}`).getAll(
785
+ GATEWAY_HINT_PARAMETER,
786
+ )
787
+ ) {
788
+ const url = parseGatewayOrigin(hint);
789
+ if (url == null || seen.has(url.href)) continue;
790
+ seen.add(url.href);
791
+ hints.push(url);
792
+ }
793
+ return hints;
794
+ }
795
+
796
+ function replaceGatewayHints(
797
+ parts: PortableIriParts,
798
+ hints: readonly URL[],
799
+ ): URL {
800
+ const pairs = parts.query == null
801
+ ? []
802
+ : parts.query.split("&").filter((pair) =>
803
+ pair !== "" && !isLocationHint(pair)
804
+ );
805
+ for (const hint of hints) {
806
+ pairs.push(`${GATEWAY_HINT_PARAMETER}=${encodeURIComponent(hint.origin)}`);
807
+ }
808
+ const query = pairs.length < 1 ? "" : `?${pairs.join("&")}`;
809
+ const result = parsePortableIri(
810
+ `ap+ef61://${parts.parsed.host}${parts.path}${query}${parts.fragment}`,
811
+ );
812
+ // Guard against URL parser normalization that would silently change
813
+ // the referenced object (e.g., dot segments) or its hints:
814
+ if (
815
+ result == null ||
816
+ canonicalizePortableUri(result.href) !==
817
+ canonicalizePortableUri(parts.raw) ||
818
+ parseGatewayHints(result.search.slice(1)).map((url) => url.href).join(
819
+ " ",
820
+ ) !== hints.map((url) => url.href).join(" ")
821
+ ) {
822
+ throw new TypeError(
823
+ "The portable ActivityPub IRI cannot be represented as a URL without " +
824
+ "changing the object it refers to.",
825
+ );
826
+ }
827
+ return result;
828
+ }
829
+
598
830
  /**
599
831
  * Validates a URL to prevent SSRF attacks.
600
832
  */