@fedify/vocab-runtime 2.5.0-dev.2448 → 2.5.0-dev.2492

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 (120) hide show
  1. package/deno.json +5 -2
  2. package/dist/{docloader-BBnBpbsG.d.cts → docloader-DD6binpN.d.cts} +4 -8
  3. package/dist/{docloader-BBnBpbsG.d.ts → docloader-DD6binpN.d.ts} +4 -8
  4. package/dist/internal/jsonld-cache.cjs +1 -1
  5. package/dist/internal/jsonld-cache.d.cts +1 -1
  6. package/dist/internal/jsonld-cache.d.ts +1 -1
  7. package/dist/internal/jsonld-cache.js +1 -1
  8. package/dist/internal/portable-dereference.cjs +1 -1
  9. package/dist/internal/portable-dereference.d.cts +2 -2
  10. package/dist/internal/portable-dereference.d.ts +2 -2
  11. package/dist/internal/portable-dereference.js +1 -1
  12. package/dist/internal.cjs +6 -0
  13. package/dist/internal.d.cts +108 -0
  14. package/dist/internal.d.ts +108 -0
  15. package/dist/internal.js +3 -0
  16. package/dist/mod.cjs +104 -38
  17. package/dist/mod.d.cts +3 -336
  18. package/dist/mod.d.ts +4 -336
  19. package/dist/mod.js +104 -38
  20. package/dist/{portable-BDydjp5H.d.ts → portable-1VSnSJFj.d.ts} +1 -1
  21. package/dist/{portable-Dfvcg_Wy.d.cts → portable-Cr7NwZwb.d.cts} +1 -1
  22. package/dist/public-fetch-Dvfvawei.cjs +527 -0
  23. package/dist/public-fetch-ziZ39L5I.js +510 -0
  24. package/dist/tests/{body-DP2u2wUX.cjs → body-C8d1PmDa.cjs} +1 -1
  25. package/dist/tests/{body-oV99TVdA.mjs → body-HSLxhexN.mjs} +1 -1
  26. package/dist/tests/body.test.cjs +1 -1
  27. package/dist/tests/body.test.mjs +1 -1
  28. package/dist/tests/decimal.test.cjs +4 -4
  29. package/dist/tests/decimal.test.mjs +4 -4
  30. package/dist/tests/{docloader-CwxZguqN.cjs → docloader-D1fNCGam.cjs} +111 -39
  31. package/dist/tests/{docloader-D3mA7aPJ.mjs → docloader-DkEUFJui.mjs} +106 -40
  32. package/dist/tests/docloader.test.cjs +531 -1293
  33. package/dist/tests/docloader.test.mjs +472 -1252
  34. package/dist/tests/esm-Ds-eHTfY.mjs +1229 -0
  35. package/dist/tests/esm-Q2tdh5f3.cjs +1215 -0
  36. package/dist/tests/internal/portable-dereference.test.cjs +3 -3
  37. package/dist/tests/internal/portable-dereference.test.mjs +3 -3
  38. package/dist/tests/{jsonld-cache-BEXiiM-V.cjs → jsonld-cache-DKIWKxEX.cjs} +1 -1
  39. package/dist/tests/{jsonld-cache-DFnWsLuZ.mjs → jsonld-cache-KH40z9_7.mjs} +1 -1
  40. package/dist/tests/jsonld-cache.test.cjs +2 -2
  41. package/dist/tests/jsonld-cache.test.mjs +2 -2
  42. package/dist/tests/node-CbBR-th-.mjs +84 -0
  43. package/dist/tests/node-Ch29PDXR.cjs +89 -0
  44. package/dist/tests/pin-grant-CPrpk1l4.cjs +70 -0
  45. package/dist/tests/pin-grant-H5XhM1Xs.mjs +53 -0
  46. package/dist/tests/pin-grant.test.cjs +87 -0
  47. package/dist/tests/pin-grant.test.d.cts +1 -0
  48. package/dist/tests/pin-grant.test.d.mts +1 -0
  49. package/dist/tests/pin-grant.test.mjs +88 -0
  50. package/dist/tests/pinners/bun.test.cjs +166 -0
  51. package/dist/tests/pinners/bun.test.d.cts +1 -0
  52. package/dist/tests/pinners/bun.test.d.mts +1 -0
  53. package/dist/tests/pinners/bun.test.mjs +167 -0
  54. package/dist/tests/pinners/node.test.cjs +269 -0
  55. package/dist/tests/pinners/node.test.d.cts +1 -0
  56. package/dist/tests/pinners/node.test.d.mts +1 -0
  57. package/dist/tests/pinners/node.test.mjs +268 -0
  58. package/dist/tests/pinners/pinners.test.cjs +196 -0
  59. package/dist/tests/pinners/pinners.test.d.cts +1 -0
  60. package/dist/tests/pinners/pinners.test.d.mts +1 -0
  61. package/dist/tests/pinners/pinners.test.mjs +197 -0
  62. package/dist/tests/pinners/testdata/node-child.cjs +135 -0
  63. package/dist/tests/pinners/testdata/node-child.d.cts +1 -0
  64. package/dist/tests/pinners/testdata/node-child.d.mts +1 -0
  65. package/dist/tests/pinners/testdata/node-child.mjs +134 -0
  66. package/dist/tests/pinners-DLH_npLf.cjs +220 -0
  67. package/dist/tests/pinners-tGcl49uF.mjs +203 -0
  68. package/dist/tests/{portable-dereference-DaTBBvCj.cjs → portable-dereference-BUoDzMxk.cjs} +2 -2
  69. package/dist/tests/{portable-dereference-sGRs_qdT.mjs → portable-dereference-Du2k3ngE.mjs} +2 -2
  70. package/dist/tests/{portable-media-D7ClSjFO.cjs → portable-media-BM9zgj9g.cjs} +3 -3
  71. package/dist/tests/{portable-media-B-oCeotd.mjs → portable-media-CTxGibcj.mjs} +3 -3
  72. package/dist/tests/portable-media.test.cjs +1 -1
  73. package/dist/tests/portable-media.test.mjs +1 -1
  74. package/dist/tests/portable-workers.test.cjs +2 -2
  75. package/dist/tests/portable-workers.test.mjs +2 -2
  76. package/dist/tests/public-fetch-BGj5pzUD.cjs +186 -0
  77. package/dist/tests/public-fetch-Cxo3Yzdf.mjs +170 -0
  78. package/dist/tests/public-fetch.test.cjs +1073 -0
  79. package/dist/tests/public-fetch.test.d.cts +1 -0
  80. package/dist/tests/public-fetch.test.d.mts +1 -0
  81. package/dist/tests/public-fetch.test.mjs +1074 -0
  82. package/dist/tests/{request-B77_oZnk.cjs → request-7q-Zfe1p.cjs} +2 -2
  83. package/dist/tests/{request-BlLYI4x3.mjs → request-D5uGtmmq.mjs} +2 -2
  84. package/dist/tests/request.test.cjs +2 -2
  85. package/dist/tests/request.test.mjs +2 -2
  86. package/dist/tests/support-Bj44xlQu.mjs +272 -0
  87. package/dist/tests/support-BrdlbLJk.cjs +363 -0
  88. package/dist/tests/{url-BNakuZ8k.cjs → url-Bdj-rTcC.cjs} +88 -12
  89. package/dist/tests/{url-DMxmp7ZG.mjs → url-COj5Q8mb.mjs} +65 -13
  90. package/dist/tests/url.test.cjs +4 -1
  91. package/dist/tests/url.test.mjs +4 -1
  92. package/dist/url-4EE56sNP.d.cts +352 -0
  93. package/dist/url-4EE56sNP.d.ts +352 -0
  94. package/dist/{url-DrGTR8yv.cjs → url-DIKZnaJv.cjs} +88 -12
  95. package/dist/{url-Dzyp-NsC.js → url-mWveR5GA.js} +65 -13
  96. package/package.json +14 -3
  97. package/src/docloader.test.ts +696 -48
  98. package/src/docloader.ts +186 -75
  99. package/src/internal.ts +13 -0
  100. package/src/pin-grant.test.ts +118 -0
  101. package/src/pin-grant.ts +99 -0
  102. package/src/pinners/bun.test.ts +278 -0
  103. package/src/pinners/bun.ts +223 -0
  104. package/src/pinners/mod.ts +59 -0
  105. package/src/pinners/node.test.ts +415 -0
  106. package/src/pinners/node.ts +144 -0
  107. package/src/pinners/pinners.test.ts +316 -0
  108. package/src/pinners/testdata/node-child.ts +147 -0
  109. package/src/pinners/testdata/proxy-child.ts +82 -0
  110. package/src/pinners/testdata/support.ts +343 -0
  111. package/src/pinners/testdata/tls-cert.pem +12 -0
  112. package/src/pinners/testdata/tls-child.ts +88 -0
  113. package/src/pinners/testdata/tls-key.pem +5 -0
  114. package/src/pinners/types.ts +48 -0
  115. package/src/public-fetch.test.ts +1286 -0
  116. package/src/public-fetch.ts +338 -0
  117. package/src/testdata/public-fetch-deno-worker.ts +74 -0
  118. package/src/url.test.ts +11 -0
  119. package/src/url.ts +113 -22
  120. package/tsdown.config.ts +7 -2
package/src/docloader.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { getLogger } from "@logtape/logtape";
2
- import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
2
+ import { type Span, SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
3
3
  import metadata from "../deno.json" with { type: "json" };
4
4
  import {
5
5
  BodyTooLargeError,
@@ -9,13 +9,18 @@ import {
9
9
  } from "./body.ts";
10
10
  import preloadedContexts from "./contexts.ts";
11
11
  import { HttpHeaderLink } from "./link.ts";
12
+ import {
13
+ openDirectFetch,
14
+ openPublicFetch,
15
+ type PublicFetchSession,
16
+ } from "./public-fetch.ts";
12
17
  import {
13
18
  createActivityPubRequest,
14
19
  FetchError,
15
20
  type GetUserAgentOptions,
16
21
  logRequest,
17
22
  } from "./request.ts";
18
- import { UrlError, validatePublicUrl } from "./url.ts";
23
+ import { UrlError } from "./url.ts";
19
24
 
20
25
  const logger = getLogger(["fedify", "runtime", "docloader"]);
21
26
  const DEFAULT_MAX_REDIRECTION = 20;
@@ -187,7 +192,8 @@ async function bufferErrorResponse(
187
192
  try {
188
193
  await readBoundedBytes(response, MAX_ERROR_BODY_SIZE, url);
189
194
  } catch (error) {
190
- await clone.body?.cancel().catch(() => {});
195
+ // Cancelling a tee branch must not block scoped session cleanup.
196
+ clone.body?.cancel().catch(() => {});
191
197
  if (signal?.aborted) throw error;
192
198
  logger.debug(
193
199
  "Failed to read the error response body from {url}: {error}",
@@ -222,7 +228,9 @@ async function bufferErrorResponse(
222
228
  * Gets a {@link RemoteDocument} from the given response.
223
229
  * @param url The URL of the document to load.
224
230
  * @param response The response to get the document from.
225
- * @param fetch The function to fetch the document.
231
+ * @param fetch The function to fetch the document. It is called with the URL
232
+ * of an alternate document, if any, after the response body has
233
+ * been either read or its cancellation has been started.
226
234
  * @param options The options for loading the document.
227
235
  * @returns The loaded remote document.
228
236
  * @throws {FetchError} If the response is not OK.
@@ -237,7 +245,55 @@ export async function getRemoteDocument(
237
245
  ) => Promise<RemoteDocument>,
238
246
  options?: DocumentLoaderOptions,
239
247
  ): Promise<RemoteDocument> {
240
- const documentUrl = response.url === "" ? url : response.url;
248
+ const result = await parseRemoteDocument(url, response, options);
249
+ if ("alternate" in result) return await fetch(result.alternate, options);
250
+ return result.document;
251
+ }
252
+
253
+ /**
254
+ * Starts cancelling the body of a response without waiting for it to settle.
255
+ * The cancellation of a branch of a tee (e.g., a body cloned by a fetch mock)
256
+ * settles only after the other branch is cancelled too, per the Streams
257
+ * standard, so awaiting it could block forever. Failures are only logged.
258
+ * @param response The response whose body is cancelled.
259
+ * @param url The URL of the response, for logging.
260
+ */
261
+ function cancelResponseBody(response: Response, url: string): void {
262
+ response.body?.cancel().catch((error) => {
263
+ logger.debug(
264
+ "Failed to cancel the response body from {url}: {error}",
265
+ { url, error },
266
+ );
267
+ });
268
+ }
269
+
270
+ /**
271
+ * The result of {@link parseRemoteDocument}: either the loaded document, or
272
+ * the URL of an alternate document to load instead.
273
+ */
274
+ type ParsedRemoteDocument =
275
+ | { readonly document: RemoteDocument }
276
+ | { readonly alternate: string };
277
+
278
+ /**
279
+ * Gets a {@link RemoteDocument} from the given response, or the URL of an
280
+ * alternate document to load instead. When an alternate document is found,
281
+ * the response body has been either read or its cancellation has been started
282
+ * (without waiting for it), so that the alternate document can be loaded after
283
+ * the response's session is closed.
284
+ * @param url The URL of the document to load.
285
+ * @param response The response to get the document from.
286
+ * @returns The loaded remote document, or the alternate document URL.
287
+ * @throws {FetchError} If the response is not OK.
288
+ */
289
+ async function parseRemoteDocument(
290
+ url: string,
291
+ response: Response,
292
+ options?: DocumentLoaderOptions,
293
+ ): Promise<ParsedRemoteDocument> {
294
+ // Since the response URL is an IP address URL on runtimes that pin
295
+ // connections, the request URL is used instead:
296
+ const documentUrl = url;
241
297
  const docUrl = new URL(documentUrl);
242
298
  if (!response.ok) {
243
299
  if (options?.suppressError) {
@@ -308,7 +364,8 @@ export async function getRemoteDocument(
308
364
  "Found alternate document: {alternateUrl} from {url}",
309
365
  { alternateUrl: altUri.href, url: documentUrl },
310
366
  );
311
- return await fetch(altUri.href, options);
367
+ cancelResponseBody(response, documentUrl);
368
+ return { alternate: altUri.href };
312
369
  }
313
370
  }
314
371
  }
@@ -370,7 +427,7 @@ export async function getRemoteDocument(
370
427
  "Found alternate document: {alternateUrl} from {url}",
371
428
  { alternateUrl: attribs.href, url: documentUrl },
372
429
  );
373
- return await fetch(new URL(attribs.href, docUrl).href, options);
430
+ return { alternate: new URL(attribs.href, docUrl).href };
374
431
  }
375
432
  }
376
433
  try {
@@ -397,7 +454,7 @@ export async function getRemoteDocument(
397
454
  headers: Object.fromEntries(response.headers.entries()),
398
455
  },
399
456
  );
400
- return { contextUrl, document, documentUrl };
457
+ return { document: { contextUrl, document, documentUrl } };
401
458
  }
402
459
 
403
460
  /**
@@ -537,6 +594,37 @@ export interface GetDocumentLoaderOptions extends DocumentLoaderFactoryOptions {
537
594
  * @since 1.3.0
538
595
  */
539
596
  export function getDocumentLoader(
597
+ options: GetDocumentLoaderOptions = {},
598
+ ): DocumentLoader {
599
+ return createDocumentLoader(options);
600
+ }
601
+
602
+ /**
603
+ * The dependencies of {@link createDocumentLoader}.
604
+ */
605
+ export interface DocumentLoaderDependencies {
606
+ /**
607
+ * Opens the fetch session for a single hop, instead of
608
+ * {@link openPublicFetch} (or {@link openDirectFetch} if private addresses
609
+ * are allowed).
610
+ */
611
+ readonly openHop?: (url: string) => Promise<PublicFetchSession>;
612
+ }
613
+
614
+ /**
615
+ * The outcome of a single hop of {@link createDocumentLoader}, decided while
616
+ * its fetch session is open.
617
+ */
618
+ type HopOutcome = ParsedRemoteDocument | { readonly location: string };
619
+
620
+ /**
621
+ * The implementation of {@link getDocumentLoader}, with injectable
622
+ * dependencies. This is not exported from any entry point.
623
+ * @param options Options for the document loader.
624
+ * @param deps The dependencies.
625
+ * @returns The document loader.
626
+ */
627
+ export function createDocumentLoader(
540
628
  {
541
629
  allowPrivateAddress,
542
630
  maxRedirection,
@@ -544,12 +632,76 @@ export function getDocumentLoader(
544
632
  timeout,
545
633
  userAgent,
546
634
  }: GetDocumentLoaderOptions = {},
635
+ deps: DocumentLoaderDependencies = {},
547
636
  ): DocumentLoader {
548
637
  const resolvedTimeout = resolveDocumentLoaderTimeout(timeout);
549
638
  const tracerProvider = trace.getTracerProvider();
550
639
  const tracer = tracerProvider.getTracer(metadata.name, metadata.version);
551
640
  const maximumRedirection = maxRedirection ?? DEFAULT_MAX_REDIRECTION;
552
641
 
642
+ async function openHop(
643
+ url: string,
644
+ options?: DocumentLoaderOptions,
645
+ ): Promise<PublicFetchSession> {
646
+ try {
647
+ const session = deps.openHop != null
648
+ ? await deps.openHop(url)
649
+ : allowPrivateAddress
650
+ ? await openDirectFetch(url)
651
+ : await openPublicFetch(url, { signal: options?.signal });
652
+ if (options?.signal?.aborted) {
653
+ await session.close();
654
+ options.signal.throwIfAborted();
655
+ }
656
+ return session;
657
+ } catch (error) {
658
+ if (error instanceof UrlError) {
659
+ if (error.reason === "dns") {
660
+ logger.debug("DNS lookup failed for {url}", { url, error });
661
+ } else {
662
+ logger[options?.suppressError ? "warn" : "error"](
663
+ "Disallowed private URL: {url}",
664
+ { url, error },
665
+ );
666
+ }
667
+ }
668
+ throw error;
669
+ }
670
+ }
671
+
672
+ async function fetchHop(
673
+ session: PublicFetchSession,
674
+ url: string,
675
+ span: Span,
676
+ options?: DocumentLoaderOptions,
677
+ ): Promise<HopOutcome> {
678
+ try {
679
+ const request = createActivityPubRequest(url, { userAgent });
680
+ logRequest(logger, request);
681
+ const response = await session.fetch(request, {
682
+ // Since Bun has a bug that ignores the `Request.redirect` option,
683
+ // to work around it we specify `redirect: "manual"` here too:
684
+ // https://github.com/oven-sh/bun/issues/10754
685
+ redirect: "manual",
686
+ signal: options?.signal,
687
+ });
688
+ span.setAttribute("http.response.status_code", response.status);
689
+
690
+ // Follow redirects manually to get the final URL:
691
+ if (
692
+ response.status >= 300 && response.status < 400 &&
693
+ response.headers.has("Location")
694
+ ) {
695
+ const location = response.headers.get("Location")!;
696
+ cancelResponseBody(response, url);
697
+ return { location };
698
+ }
699
+ return await parseRemoteDocument(url, response, options);
700
+ } finally {
701
+ await session.close();
702
+ }
703
+ }
704
+
553
705
  async function load(
554
706
  url: string,
555
707
  options?: DocumentLoaderOptions,
@@ -575,32 +727,9 @@ export function getDocumentLoader(
575
727
  documentUrl: currentUrl,
576
728
  };
577
729
  }
578
- if (!allowPrivateAddress) {
579
- try {
580
- await validatePublicUrl(currentUrl);
581
- } catch (error) {
582
- if (error instanceof UrlError) {
583
- if (error.reason === "dns") {
584
- logger.debug("DNS lookup failed for {url}", {
585
- url: currentUrl,
586
- error,
587
- });
588
- } else {
589
- logger[options?.suppressError ? "warn" : "error"](
590
- "Disallowed private URL: {url}",
591
- {
592
- url: currentUrl,
593
- error,
594
- },
595
- );
596
- }
597
- }
598
- throw error;
599
- }
600
- // The DNS lookup cannot be aborted, so do not go on if the call was
601
- // aborted or timed out in the meantime:
602
- options?.signal?.throwIfAborted();
603
- }
730
+ // Opening the session validates the URL; the session is closed by
731
+ // fetchHop() before the next hop is opened:
732
+ const session = await openHop(currentUrl, options);
604
733
  visited.add(currentUrl);
605
734
 
606
735
  return await tracer.startActiveSpan(
@@ -613,22 +742,8 @@ export function getDocumentLoader(
613
742
  },
614
743
  async (span) => {
615
744
  try {
616
- const request = createActivityPubRequest(currentUrl, { userAgent });
617
- logRequest(logger, request);
618
- const response = await fetch(request, {
619
- // Since Bun has a bug that ignores the `Request.redirect` option,
620
- // to work around it we specify `redirect: "manual"` here too:
621
- // https://github.com/oven-sh/bun/issues/10754
622
- redirect: "manual",
623
- signal: options?.signal,
624
- });
625
- span.setAttribute("http.response.status_code", response.status);
626
-
627
- // Follow redirects manually to get the final URL:
628
- if (
629
- response.status >= 300 && response.status < 400 &&
630
- response.headers.has("Location")
631
- ) {
745
+ const outcome = await fetchHop(session, currentUrl, span, options);
746
+ if ("location" in outcome) {
632
747
  if (redirected >= maximumRedirection) {
633
748
  logger[options?.suppressError ? "warn" : "error"](
634
749
  "Too many redirections ({redirections}) while fetching document.",
@@ -639,10 +754,7 @@ export function getDocumentLoader(
639
754
  `Too many redirections (${redirected + 1})`,
640
755
  );
641
756
  }
642
- const redirectUrl = new URL(
643
- response.headers.get("Location")!,
644
- response.url === "" ? currentUrl : response.url,
645
- ).href;
757
+ const redirectUrl = new URL(outcome.location, currentUrl).href;
646
758
  span.setAttribute("http.redirect.url", redirectUrl);
647
759
  if (visited.has(redirectUrl)) {
648
760
  logger[options?.suppressError ? "warn" : "error"](
@@ -658,27 +770,26 @@ export function getDocumentLoader(
658
770
  return await load(redirectUrl, options, redirected + 1, visited);
659
771
  }
660
772
 
661
- const result = await getRemoteDocument(
662
- currentUrl,
663
- response,
664
- async (alternateUrl) => {
665
- options?.signal?.throwIfAborted();
666
- if (redirected >= DEFAULT_MAX_REDIRECTION) {
667
- throw new FetchError(
668
- currentUrl,
669
- `Too many redirections (${redirected + 1})`,
670
- );
671
- }
672
- if (visited.has(alternateUrl)) {
673
- throw new FetchError(
674
- currentUrl,
675
- `Redirect loop detected: ${alternateUrl}`,
676
- );
677
- }
678
- return await load(alternateUrl, options, redirected + 1, visited);
679
- },
680
- options,
681
- );
773
+ let result: RemoteDocument;
774
+ if ("alternate" in outcome) {
775
+ const alternateUrl = outcome.alternate;
776
+ options?.signal?.throwIfAborted();
777
+ if (redirected >= DEFAULT_MAX_REDIRECTION) {
778
+ throw new FetchError(
779
+ currentUrl,
780
+ `Too many redirections (${redirected + 1})`,
781
+ );
782
+ }
783
+ if (visited.has(alternateUrl)) {
784
+ throw new FetchError(
785
+ currentUrl,
786
+ `Redirect loop detected: ${alternateUrl}`,
787
+ );
788
+ }
789
+ result = await load(alternateUrl, options, redirected + 1, visited);
790
+ } else {
791
+ result = outcome.document;
792
+ }
682
793
  span.setAttribute("docloader.document_url", result.documentUrl);
683
794
  if (result.contextUrl != null) {
684
795
  span.setAttribute("docloader.context_url", result.contextUrl);
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Internal APIs shared among Fedify packages. They are not part of the public
3
+ * API and have no semver guarantees; do not use them outside Fedify.
4
+ *
5
+ * @module
6
+ */
7
+ export {
8
+ openDirectFetch,
9
+ openPublicFetch,
10
+ type PublicFetchOptions,
11
+ type PublicFetchSession,
12
+ withPublicFetch,
13
+ } from "./public-fetch.ts";
@@ -0,0 +1,118 @@
1
+ import { test } from "@fedify/fixture";
2
+ import { deepStrictEqual, ok, strictEqual, throws } from "node:assert";
3
+ import type { LookupAddress } from "node:dns";
4
+ import {
5
+ issuePinGrant,
6
+ type PinGrant,
7
+ redeemPinGrant,
8
+ revokePinGrant,
9
+ } from "./pin-grant.ts";
10
+
11
+ const addresses: readonly LookupAddress[] = [
12
+ { address: "8.8.8.8", family: 4 },
13
+ { address: "2001:4860:4860::8888", family: 6 },
14
+ ];
15
+
16
+ test("redeemPinGrant() returns what the grant was issued for", () => {
17
+ const url = new URL("https://pinned-host.invalid:8443/path?query");
18
+ const input = addresses.map((address) => ({ ...address }));
19
+ const grant = issuePinGrant(url, input);
20
+ const record = redeemPinGrant(grant, url);
21
+ deepStrictEqual(record.origin, "https://pinned-host.invalid:8443");
22
+ deepStrictEqual(record.hostname, "pinned-host.invalid");
23
+ deepStrictEqual(record.addresses, addresses);
24
+
25
+ // The record holds a frozen copy of the addresses:
26
+ input[0].address = "127.0.0.1";
27
+ input.push({ address: "10.0.0.1", family: 4 });
28
+ deepStrictEqual(redeemPinGrant(grant, url).addresses, addresses);
29
+ ok(Object.isFrozen(record));
30
+ ok(Object.isFrozen(record.addresses));
31
+ ok(Object.isFrozen(record.addresses[0]));
32
+
33
+ // Other paths and queries of the same origin are accepted:
34
+ strictEqual(
35
+ redeemPinGrant(grant, new URL("https://pinned-host.invalid:8443/other")),
36
+ record,
37
+ );
38
+ });
39
+
40
+ test("redeemPinGrant() strips brackets from IPv6 hostnames", () => {
41
+ const url = new URL("http://[2001:4860:4860::8888]/");
42
+ const grant = issuePinGrant(url, [addresses[1]]);
43
+ const record = redeemPinGrant(grant, url);
44
+ deepStrictEqual(record.hostname, "2001:4860:4860::8888");
45
+ deepStrictEqual(record.origin, "http://[2001:4860:4860::8888]");
46
+ });
47
+
48
+ test("redeemPinGrant() rejects grants not issued by issuePinGrant()", () => {
49
+ const url = new URL("https://pinned-host.invalid/");
50
+ const issued = issuePinGrant(url, addresses);
51
+ const forgeries: Record<string, unknown> = {
52
+ "object literal": {},
53
+ "null-prototype object": Object.create(null),
54
+ "frozen null-prototype object": Object.freeze(Object.create(null)),
55
+ "copy of a grant": { ...issued },
56
+ "structured clone of a grant": structuredClone(issued),
57
+ "object inheriting from a grant": Object.create(issued),
58
+ "null": null,
59
+ "undefined": undefined,
60
+ "string": "grant",
61
+ };
62
+ for (const [name, forgery] of Object.entries(forgeries)) {
63
+ throws(
64
+ () => redeemPinGrant(forgery as PinGrant, url),
65
+ TypeError,
66
+ name,
67
+ );
68
+ }
69
+ // The issued grant itself still works:
70
+ ok(redeemPinGrant(issued, url));
71
+ });
72
+
73
+ test("redeemPinGrant() rejects URLs of other origins", () => {
74
+ const grant = issuePinGrant(
75
+ new URL("https://pinned-host.invalid/"),
76
+ addresses,
77
+ );
78
+ for (
79
+ const url of [
80
+ "http://pinned-host.invalid/",
81
+ "https://other-host.invalid/",
82
+ "https://sub.pinned-host.invalid/",
83
+ "https://pinned-host.invalid:8443/",
84
+ "https://8.8.8.8/",
85
+ ]
86
+ ) {
87
+ throws(() => redeemPinGrant(grant, new URL(url)), TypeError, url);
88
+ }
89
+ // The default port is the same origin:
90
+ ok(redeemPinGrant(grant, new URL("https://pinned-host.invalid:443/")));
91
+ });
92
+
93
+ test("redeemPinGrant() rejects revoked grants", () => {
94
+ const url = new URL("https://pinned-host.invalid/");
95
+ const grant = issuePinGrant(url, addresses);
96
+ const other = issuePinGrant(url, addresses);
97
+ revokePinGrant(grant);
98
+ throws(() => redeemPinGrant(grant, url), TypeError);
99
+ // Revoking is idempotent, and does not affect other grants:
100
+ revokePinGrant(grant);
101
+ throws(() => redeemPinGrant(grant, url), TypeError);
102
+ ok(redeemPinGrant(other, url));
103
+ });
104
+
105
+ test("revokePinGrant() ignores unknown objects", () => {
106
+ revokePinGrant({} as PinGrant);
107
+ revokePinGrant(Object.create(null) as PinGrant);
108
+ });
109
+
110
+ test("issuePinGrant() returns a distinct frozen object every time", () => {
111
+ const url = new URL("https://pinned-host.invalid/");
112
+ const a = issuePinGrant(url, addresses);
113
+ const b = issuePinGrant(url, addresses);
114
+ ok(a !== b);
115
+ ok(Object.isFrozen(a));
116
+ strictEqual(Object.getPrototypeOf(a), null);
117
+ deepStrictEqual(Reflect.ownKeys(a), []);
118
+ });
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Unforgeable capabilities that hand the addresses validated by
3
+ * `openPublicFetch()` over to a connection pinner.
4
+ *
5
+ * This module is not exported from any entry point of the package.
6
+ * @module
7
+ */
8
+
9
+ import type { ResolvedAddress } from "./url.ts";
10
+
11
+ declare const brand: unique symbol;
12
+
13
+ /**
14
+ * An opaque token that stands for a set of validated addresses of an origin.
15
+ * Only objects returned by {@link issuePinGrant} are accepted by
16
+ * {@link redeemPinGrant}; objects of the same shape are not.
17
+ */
18
+ export type PinGrant = { readonly [brand]: never };
19
+
20
+ /**
21
+ * What a {@link PinGrant} stands for.
22
+ */
23
+ export interface PinGrantRecord {
24
+ /** The origin (scheme, host, and port) the grant was issued for. */
25
+ readonly origin: string;
26
+ /** The hostname the grant was issued for, without IPv6 brackets. */
27
+ readonly hostname: string;
28
+ /** The validated addresses, in lookup order. */
29
+ readonly addresses: readonly ResolvedAddress[];
30
+ }
31
+
32
+ interface GrantState {
33
+ readonly record: PinGrantRecord;
34
+ revoked: boolean;
35
+ }
36
+
37
+ const grants = new WeakMap<PinGrant, GrantState>();
38
+
39
+ /**
40
+ * Issues a grant for the given URL's origin and addresses. This does not
41
+ * validate the addresses; callers validate them before issuing, and pinners
42
+ * validate them again right before connecting.
43
+ * @param url The URL whose origin the grant is bound to.
44
+ * @param addresses The addresses to connect to.
45
+ * @returns A new grant.
46
+ */
47
+ export function issuePinGrant(
48
+ url: URL,
49
+ addresses: readonly ResolvedAddress[],
50
+ ): PinGrant {
51
+ const grant = Object.freeze(Object.create(null)) as PinGrant;
52
+ let hostname = url.hostname;
53
+ if (hostname.startsWith("[") && hostname.endsWith("]")) {
54
+ hostname = hostname.slice(1, -1);
55
+ }
56
+ const record: PinGrantRecord = Object.freeze({
57
+ origin: url.origin,
58
+ hostname,
59
+ addresses: Object.freeze(
60
+ addresses.map(({ address, family }) =>
61
+ Object.freeze({ address, family })
62
+ ),
63
+ ),
64
+ });
65
+ grants.set(grant, { record, revoked: false });
66
+ return grant;
67
+ }
68
+
69
+ /**
70
+ * Checks that the given grant was issued by {@link issuePinGrant}, has not
71
+ * been revoked, and was issued for the origin of the given URL.
72
+ * @param grant The grant to redeem.
73
+ * @param url The URL to connect to.
74
+ * @returns What the grant stands for.
75
+ * @throws {TypeError} If the grant cannot be used for the URL.
76
+ */
77
+ export function redeemPinGrant(grant: PinGrant, url: URL): PinGrantRecord {
78
+ const state = typeof grant === "object" && grant !== null
79
+ ? grants.get(grant)
80
+ : undefined;
81
+ if (state == null) throw new TypeError("Invalid pin grant.");
82
+ if (state.revoked) throw new TypeError("The pin grant has been revoked.");
83
+ if (url.origin !== state.record.origin) {
84
+ throw new TypeError(
85
+ `The pin grant for ${state.record.origin} cannot be used for ` +
86
+ `${url.origin}.`,
87
+ );
88
+ }
89
+ return state.record;
90
+ }
91
+
92
+ /**
93
+ * Revokes the given grant so that it can no longer be redeemed.
94
+ * @param grant The grant to revoke.
95
+ */
96
+ export function revokePinGrant(grant: PinGrant): void {
97
+ const state = grants.get(grant);
98
+ if (state != null) state.revoked = true;
99
+ }