@fedify/vocab-runtime 2.4.0-pr.934.40 → 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 (140) hide show
  1. package/deno.json +4 -2
  2. package/dist/{tests/docloader-Xi61Le8w.mjs → contexts-BK1CqBR5.cjs} +164 -269
  3. package/dist/{tests/docloader-BFedvemb.cjs → contexts-DPuJ4UYL.js} +160 -288
  4. package/dist/{docloader-DnUMWHaJ.d.cts → docloader-CYwqh5Df.d.cts} +68 -2
  5. package/dist/{docloader-xRGn1azD.d.ts → docloader-CYwqh5Df.d.ts} +68 -2
  6. package/dist/internal/jsonld-cache.cjs +70 -1
  7. package/dist/internal/jsonld-cache.d.cts +35 -2
  8. package/dist/internal/jsonld-cache.d.ts +35 -2
  9. package/dist/internal/jsonld-cache.js +67 -2
  10. package/dist/internal/portable-dereference.cjs +721 -0
  11. package/dist/internal/portable-dereference.d.cts +349 -0
  12. package/dist/internal/portable-dereference.d.ts +349 -0
  13. package/dist/internal/portable-dereference.js +702 -0
  14. package/dist/internal/signed-representation.cjs +378 -0
  15. package/dist/internal/signed-representation.d.cts +144 -0
  16. package/dist/internal/signed-representation.d.ts +144 -0
  17. package/dist/internal/signed-representation.js +371 -0
  18. package/dist/jsonld.cjs +2 -2
  19. package/dist/mod.cjs +715 -4523
  20. package/dist/mod.d.cts +406 -6
  21. package/dist/mod.d.ts +406 -7
  22. package/dist/mod.js +694 -4518
  23. package/dist/portable-DqtfLy_1.d.cts +160 -0
  24. package/dist/portable-DtWsu2yU.d.ts +160 -0
  25. package/dist/tests/body-CYiu9CO-.mjs +130 -0
  26. package/dist/tests/body-CkEwROsn.cjs +153 -0
  27. package/dist/tests/body.test.cjs +89 -0
  28. package/dist/tests/body.test.d.cts +1 -0
  29. package/dist/tests/body.test.d.mts +1 -0
  30. package/dist/tests/body.test.mjs +90 -0
  31. package/dist/tests/contexts-CIKsin4e.mjs +4512 -0
  32. package/dist/tests/contexts-DizzBjz4.cjs +4523 -0
  33. package/dist/tests/decimal.test.cjs +8 -7
  34. package/dist/tests/decimal.test.mjs +8 -6
  35. package/dist/tests/digest-3FeH2Y-Q.cjs +176 -0
  36. package/dist/tests/digest-COC7xDiQ.mjs +141 -0
  37. package/dist/tests/digest.test.cjs +102 -0
  38. package/dist/tests/digest.test.d.cts +1 -0
  39. package/dist/tests/digest.test.d.mts +1 -0
  40. package/dist/tests/digest.test.mjs +103 -0
  41. package/dist/tests/docloader-C3YFl6y4.cjs +396 -0
  42. package/dist/tests/docloader-CvmS4sVq.mjs +373 -0
  43. package/dist/tests/docloader.test.cjs +692 -39
  44. package/dist/tests/docloader.test.mjs +686 -34
  45. package/dist/tests/internal/multicodec.test.cjs +2 -3
  46. package/dist/tests/internal/multicodec.test.mjs +2 -2
  47. package/dist/tests/internal/portable-dereference.test.cjs +172 -0
  48. package/dist/tests/internal/portable-dereference.test.d.cts +1 -0
  49. package/dist/tests/internal/portable-dereference.test.d.mts +1 -0
  50. package/dist/tests/internal/portable-dereference.test.mjs +173 -0
  51. package/dist/tests/jsonld-cache-BPQmOZWD.mjs +342 -0
  52. package/dist/tests/jsonld-cache-C07AyNOY.cjs +397 -0
  53. package/dist/tests/jsonld-cache.test.cjs +101 -298
  54. package/dist/tests/jsonld-cache.test.mjs +94 -289
  55. package/dist/tests/{key-_wXwomh_.cjs → key-C-AYkdJJ.cjs} +11 -4
  56. package/dist/tests/{key-CDGDH_vC.mjs → key-C2Db_TAJ.mjs} +11 -3
  57. package/dist/tests/key.test.cjs +6 -5
  58. package/dist/tests/key.test.mjs +6 -4
  59. package/dist/tests/langstr.test.cjs +4 -4
  60. package/dist/tests/langstr.test.mjs +2 -2
  61. package/dist/tests/link.test.cjs +2 -3
  62. package/dist/tests/link.test.mjs +2 -2
  63. package/dist/tests/multibase/multibase.test.cjs +8 -9
  64. package/dist/tests/multibase/multibase.test.mjs +6 -6
  65. package/dist/tests/{multibase-Bz_UUDtL.cjs → multibase-B5Mea7Ip.cjs} +19 -2
  66. package/dist/tests/{multibase-B4bvakyA.mjs → multibase-BPnF_L4e.mjs} +12 -1
  67. package/dist/tests/portable-dereference-BNXtgg5U.cjs +278 -0
  68. package/dist/tests/portable-dereference-DRE5bz-l.mjs +249 -0
  69. package/dist/tests/portable-media-BLKllU56.cjs +153 -0
  70. package/dist/tests/portable-media-DRSGBORB.mjs +148 -0
  71. package/dist/tests/portable-media.test.cjs +222 -0
  72. package/dist/tests/portable-media.test.d.cts +1 -0
  73. package/dist/tests/portable-media.test.d.mts +1 -0
  74. package/dist/tests/portable-media.test.mjs +223 -0
  75. package/dist/tests/portable-workers.test.cjs +36 -0
  76. package/dist/tests/portable-workers.test.d.cts +2 -0
  77. package/dist/tests/portable-workers.test.d.mts +2 -0
  78. package/dist/tests/portable-workers.test.mjs +35 -0
  79. package/dist/tests/{request-cCPgOxYG.cjs → request-8vPWtV1-.cjs} +10 -4
  80. package/dist/tests/{request-BOS-hNaf.mjs → request-DRaOaTqD.mjs} +8 -2
  81. package/dist/tests/request.test.cjs +7 -4
  82. package/dist/tests/request.test.mjs +5 -2
  83. package/dist/tests/signed-representation.test.cjs +588 -0
  84. package/dist/tests/signed-representation.test.d.cts +1 -0
  85. package/dist/tests/signed-representation.test.d.mts +1 -0
  86. package/dist/tests/signed-representation.test.mjs +589 -0
  87. package/dist/tests/temporal.test.cjs +1 -2
  88. package/dist/tests/temporal.test.mjs +1 -1
  89. package/dist/tests/url-BNakuZ8k.cjs +998 -0
  90. package/dist/tests/url-DMxmp7ZG.mjs +859 -0
  91. package/dist/tests/url.test.cjs +503 -3
  92. package/dist/tests/url.test.mjs +503 -2
  93. package/dist/url-DrGTR8yv.cjs +993 -0
  94. package/dist/url-Dzyp-NsC.js +860 -0
  95. package/package.json +27 -4
  96. package/scripts/test-bun.mjs +17 -0
  97. package/src/body.test.ts +125 -0
  98. package/src/body.ts +152 -0
  99. package/src/contexts/cid-v1.json +114 -0
  100. package/src/contexts/fep-22cd.json +21 -0
  101. package/src/contexts/fep-ef61.json +10 -0
  102. package/src/contexts/miscellany.json +17 -0
  103. package/src/contexts/security-data-integrity-v1.json +0 -4
  104. package/src/contexts.ts +40 -0
  105. package/src/digest.test.ts +220 -0
  106. package/src/digest.ts +229 -0
  107. package/src/docloader.test.ts +918 -27
  108. package/src/docloader.ts +322 -105
  109. package/src/internal/jsonld-cache.ts +97 -1
  110. package/src/internal/portable-dereference.test.ts +254 -0
  111. package/src/internal/portable-dereference.ts +1000 -0
  112. package/src/internal/signed-representation.ts +565 -0
  113. package/src/jsonld-cache.test.ts +89 -0
  114. package/src/key.test.ts +9 -0
  115. package/src/key.ts +11 -1
  116. package/src/mod.ts +30 -0
  117. package/src/multibase/multibase.test.ts +5 -5
  118. package/src/portable-media.test.ts +293 -0
  119. package/src/portable-media.ts +264 -0
  120. package/src/portable-workers.test.ts +81 -0
  121. package/src/portable.ts +180 -0
  122. package/src/preprocessor.ts +7 -0
  123. package/src/request.test.ts +10 -1
  124. package/src/request.ts +9 -1
  125. package/src/signed-representation.test.ts +338 -0
  126. package/src/url.test.ts +844 -1
  127. package/src/url.ts +717 -17
  128. package/tsdown.config.ts +2 -0
  129. package/dist/tests/url-BvjYQdxL.cjs +0 -456
  130. package/dist/tests/url-a2D8NAgh.mjs +0 -378
  131. package/dist/url-Ck3dGEwH.cjs +0 -457
  132. package/dist/url-m1YxGNZ0.js +0 -379
  133. /package/dist/{chunk-M78iaK0I.cjs → rolldown-runtime-B7lfambq.cjs} +0 -0
  134. /package/dist/tests/{langstr-CbAxaeEZ.cjs → langstr-C4Fl80ae.cjs} +0 -0
  135. /package/dist/tests/{langstr-Di5AvKpB.mjs → langstr-CQ26J_L7.mjs} +0 -0
  136. /package/dist/tests/{link-NUUWCdnK.mjs → link-Cevmc87v.mjs} +0 -0
  137. /package/dist/tests/{link-FguCydMA.cjs → link-DlKm8bEr.cjs} +0 -0
  138. /package/dist/tests/{multicodec-CxGVGa91.cjs → multicodec-CLRPeW4N.cjs} +0 -0
  139. /package/dist/tests/{multicodec-CyFp54fI.mjs → multicodec-CRIj_05H.mjs} +0 -0
  140. /package/dist/tests/{chunk-C2EiDwsr.cjs → rolldown-runtime-emK7D4bc.cjs} +0 -0
package/src/key.ts CHANGED
@@ -174,12 +174,22 @@ export async function importDidKey(did: string | URL): Promise<CryptoKey> {
174
174
  /**
175
175
  * Exports an Ed25519 public key as a `did:key` DID.
176
176
  *
177
+ * The DID is encoded in base58-btc with the Ed25519 multicodec prefix, e.g.,
178
+ * `did:key:z6Mk...`, as [FEP-ef61] requires for the DIDs of portable
179
+ * objects.
180
+ *
181
+ * [FEP-ef61]: https://w3id.org/fep/ef61
182
+ *
177
183
  * @param key The Ed25519 public key.
178
184
  * @returns The `did:key` DID.
179
- * @throws {TypeError} If the key is invalid or unsupported.
185
+ * @throws {TypeError} If the key is invalid or unsupported, or it is not
186
+ * a public key.
180
187
  * @since 2.4.0
181
188
  */
182
189
  export async function exportDidKey(key: CryptoKey): Promise<string> {
190
+ if (key.type !== "public") {
191
+ throw new TypeError("The key must be a public key.");
192
+ }
183
193
  if (key.algorithm.name !== "Ed25519") {
184
194
  throw new TypeError(
185
195
  "Unsupported key type: " + JSON.stringify(key.algorithm),
package/src/mod.ts CHANGED
@@ -15,6 +15,8 @@ export {
15
15
  type GetDocumentLoaderOptions,
16
16
  getRemoteDocument,
17
17
  type RemoteDocument,
18
+ resolveDocumentLoaderTimeout,
19
+ withDocumentLoaderTimeout,
18
20
  } from "./docloader.ts";
19
21
  export {
20
22
  type DidKeyVerificationMethod,
@@ -34,7 +36,28 @@ export {
34
36
  isDecimal,
35
37
  parseDecimal,
36
38
  } from "./decimal.ts";
39
+ export {
40
+ computeDigestMultibase,
41
+ createHashlink,
42
+ type ParsedDigestMultibase,
43
+ type ParsedHashlink,
44
+ parseDigestMultibase,
45
+ parseHashlink,
46
+ verifyDigestMultibase,
47
+ verifyHashlink,
48
+ } from "./digest.ts";
37
49
  export { LanguageString } from "./langstr.ts";
50
+ export {
51
+ fetchPortableMedia,
52
+ type FetchPortableMediaOptions,
53
+ type PortableMedia,
54
+ } from "./portable-media.ts";
55
+ export type {
56
+ PortableObjectReferrer,
57
+ PortableObjectVerification,
58
+ PortableObjectVerifier,
59
+ PortableObjectVerifierOptions,
60
+ } from "./portable.ts";
38
61
  export {
39
62
  decodeMultibase,
40
63
  encodeMultibase,
@@ -58,13 +81,20 @@ export {
58
81
  canonicalizePortableUri,
59
82
  expandIPv6Address,
60
83
  formatIri,
84
+ fromCompatibleEf61Id,
61
85
  getFe34Origin,
86
+ getGatewayHints,
62
87
  haveSameFe34Origin,
63
88
  haveSameIriOrigin,
89
+ isGatewayUrl,
64
90
  isValidPublicIPv4Address,
65
91
  isValidPublicIPv6Address,
92
+ parseGatewayUrl,
66
93
  parseIri,
67
94
  parseJsonLdId,
95
+ toCompatibleEf61Id,
68
96
  UrlError,
69
97
  validatePublicUrl,
98
+ withGatewayHints,
99
+ withoutGatewayHints,
70
100
  } from "./url.ts";
@@ -1,5 +1,5 @@
1
1
  import { deepStrictEqual } from "node:assert";
2
- import { test } from "node:test";
2
+ import { test } from "@fedify/fixture";
3
3
  import * as constants from "./constants.ts";
4
4
  import { decodeMultibase, encodeMultibase } from "./mod.ts";
5
5
  import type { BaseName } from "./types.d.ts";
@@ -76,7 +76,7 @@ test("multibase.encode and decode", async (t) => {
76
76
  ];
77
77
 
78
78
  for (const [name, input, expectedOutput] of testCases) {
79
- await t.test(`Encoding/Decoding ${name} with ${input}`, () => {
79
+ await t.step(`Encoding/Decoding ${name} with ${input}`, () => {
80
80
  const encoded = encodeMultibase(name, encodeText(input));
81
81
  deepStrictEqual(
82
82
  decodeText(encoded),
@@ -96,7 +96,7 @@ test("multibase.encode and decode", async (t) => {
96
96
  });
97
97
  }
98
98
 
99
- await t.test("should allow base32pad full alphabet", () => {
99
+ await t.step("should allow base32pad full alphabet", () => {
100
100
  const encodedStr = "ctimaq4ygg2iegci7";
101
101
  const decoded = decodeMultibase(encodedStr);
102
102
  const encoded = encodeMultibase("c", decoded);
@@ -105,12 +105,12 @@ test("multibase.encode and decode", async (t) => {
105
105
  });
106
106
 
107
107
  test("constants", async (t) => {
108
- await t.test("constants indexed by name", () => {
108
+ await t.step("constants indexed by name", () => {
109
109
  const names = constants.names;
110
110
  deepStrictEqual(Object.keys(names).length, 23);
111
111
  });
112
112
 
113
- await t.test("constants indexed by code", () => {
113
+ await t.step("constants indexed by code", () => {
114
114
  const codes = constants.codes;
115
115
  deepStrictEqual(Object.keys(codes).length, 23);
116
116
  });
@@ -0,0 +1,293 @@
1
+ import { deepStrictEqual, equal, rejects } from "node:assert/strict";
2
+ import { createServer } from "node:http";
3
+ import { test } from "node:test";
4
+ import type { Document, Image, Link } from "@fedify/vocab";
5
+ import { computeDigestMultibase, createHashlink } from "./digest.ts";
6
+ import { fetchPortableMedia, type PortableMedia } from "./portable-media.ts";
7
+
8
+ const bytes = new TextEncoder().encode("portable image");
9
+ const digest = "zQmU2Jds4B2J83K7CrYC34CEJdB6vCLyqkM2cgBxYeaNWTA";
10
+ const hashlink = createHashlink(digest);
11
+
12
+ const networkTest = (name: string, run: () => Promise<void>) =>
13
+ test(name, async (t) => {
14
+ if (
15
+ "Deno" in globalThis &&
16
+ (await Deno.permissions.query({ name: "net" })).state !== "granted"
17
+ ) {
18
+ t.skip("Requires network permission for a local test server.");
19
+ return;
20
+ }
21
+ await run();
22
+ });
23
+
24
+ type Handler = (path: string) => Response | undefined;
25
+
26
+ async function withServers(
27
+ handlers: Handler[],
28
+ run: (origins: string[]) => Promise<void>,
29
+ ): Promise<void> {
30
+ const servers = handlers.map((handler) =>
31
+ createServer(async (request, reply) => {
32
+ const result = handler(request.url ?? "/");
33
+ if (result == null) return;
34
+ reply.writeHead(result.status, Object.fromEntries(result.headers));
35
+ reply.end(new Uint8Array(await result.arrayBuffer()));
36
+ })
37
+ );
38
+ try {
39
+ const origins: string[] = [];
40
+ for (const server of servers) {
41
+ await new Promise<void>((resolve, reject) => {
42
+ server.once("error", reject);
43
+ server.listen(0, "127.0.0.1", resolve);
44
+ });
45
+ const address = server.address();
46
+ if (address == null || typeof address === "string") {
47
+ throw new Error("Server did not bind a TCP port.");
48
+ }
49
+ origins.push(`http://127.0.0.1:${address.port}`);
50
+ }
51
+ await run(origins);
52
+ } finally {
53
+ await Promise.all(servers.map(async (server) => {
54
+ server.closeAllConnections();
55
+ await new Promise<void>((resolve) => server.close(() => resolve()));
56
+ }));
57
+ }
58
+ }
59
+
60
+ networkTest(
61
+ "fetchPortableMedia() accepts Link, Image, and Document",
62
+ async () => {
63
+ const accepts = <T extends PortableMedia>() => {};
64
+ accepts<Link>();
65
+ accepts<Image>();
66
+ accepts<Document>();
67
+ const link: PortableMedia = {
68
+ href: new URL(hashlink),
69
+ digestMultibase: digest,
70
+ };
71
+ const image: PortableMedia = {
72
+ url: new URL(hashlink),
73
+ digestMultibase: digest,
74
+ };
75
+ const document: PortableMedia = {
76
+ url: { href: new URL(hashlink), digestMultibase: digest },
77
+ };
78
+ const requested: string[] = [];
79
+ await withServers([(path) => {
80
+ requested.push(path);
81
+ return new Response(bytes);
82
+ }], async ([gateway]) => {
83
+ for (const media of [link, image, document]) {
84
+ equal(
85
+ await (await fetchPortableMedia(media, {
86
+ gateways: [gateway],
87
+ allowPrivateAddress: true,
88
+ })).text(),
89
+ "portable image",
90
+ );
91
+ }
92
+ });
93
+ deepStrictEqual(
94
+ requested,
95
+ Array(3).fill(
96
+ `/.well-known/apgateway/${hashlink}`,
97
+ ),
98
+ );
99
+ },
100
+ );
101
+
102
+ networkTest(
103
+ "fetchPortableMedia() falls back on status and digest mismatch",
104
+ async () => {
105
+ const requested: string[] = [];
106
+ await withServers([
107
+ (path) => {
108
+ requested.push(`first:${path}`);
109
+ return new Response(null, { status: 503 });
110
+ },
111
+ (path) => {
112
+ requested.push(`second:${path}`);
113
+ return new Response("wrong bytes");
114
+ },
115
+ (path) => {
116
+ requested.push(`third:${path}`);
117
+ return new Response(bytes, {
118
+ headers: { "Content-Type": "image/png", "Set-Cookie": "secret=1" },
119
+ });
120
+ },
121
+ ], async (gateways) => {
122
+ const result = await fetchPortableMedia(
123
+ { url: new URL(hashlink), digestMultibase: digest },
124
+ { gateways, allowPrivateAddress: true },
125
+ );
126
+ equal(await result.text(), "portable image");
127
+ equal(result.headers.get("Content-Type"), "image/png");
128
+ equal(result.headers.get("Set-Cookie"), null);
129
+ });
130
+ deepStrictEqual(requested.map((r) => r.split(":")[0]), [
131
+ "first",
132
+ "second",
133
+ "third",
134
+ ]);
135
+ },
136
+ );
137
+
138
+ networkTest("fetchPortableMedia() verifies direct HTTP media", async () => {
139
+ const wrongDigest = await computeDigestMultibase(new Uint8Array());
140
+ await withServers([() => new Response(bytes)], async ([origin]) => {
141
+ const result = await fetchPortableMedia(`${origin}/a`, {
142
+ digestMultibase: digest,
143
+ allowPrivateAddress: true,
144
+ });
145
+ equal(await result.text(), "portable image");
146
+ await rejects(() =>
147
+ fetchPortableMedia(`${origin}/a`, {
148
+ digestMultibase: wrongDigest,
149
+ allowPrivateAddress: true,
150
+ }), /digest does not match/);
151
+ });
152
+ });
153
+
154
+ test("fetchPortableMedia() rejects invalid references before fetching", async () => {
155
+ const wrongDigest = await computeDigestMultibase(new Uint8Array());
156
+ await rejects(
157
+ () => fetchPortableMedia({ url: new URL(hashlink) }),
158
+ /requires digestMultibase/,
159
+ );
160
+ await rejects(() =>
161
+ fetchPortableMedia({
162
+ url: new URL(hashlink),
163
+ digestMultibase: digest,
164
+ }), /requires at least one gateway/);
165
+ await rejects(() =>
166
+ fetchPortableMedia({
167
+ url: new URL(hashlink),
168
+ digestMultibase: wrongDigest,
169
+ }, { gateways: ["https://example.com"] }), /disagree/);
170
+ await rejects(() =>
171
+ fetchPortableMedia({
172
+ url: new URL("hl:invalid"),
173
+ digestMultibase: digest,
174
+ }, { gateways: ["https://example.com"] }), TypeError);
175
+ await rejects(() =>
176
+ fetchPortableMedia({
177
+ url: { href: new URL(hashlink), digestMultibase: wrongDigest },
178
+ digestMultibase: digest,
179
+ }, { gateways: ["https://example.com"] }), /digests disagree/);
180
+ await rejects(() =>
181
+ fetchPortableMedia("file:///tmp/media", {
182
+ digestMultibase: digest,
183
+ }), TypeError);
184
+ });
185
+
186
+ networkTest("fetchPortableMedia() bounds response bytes", async () => {
187
+ await withServers([() => new Response(bytes)], async ([origin]) => {
188
+ await rejects(() =>
189
+ fetchPortableMedia(`${origin}/media`, {
190
+ digestMultibase: digest,
191
+ maxBytes: bytes.length - 1,
192
+ allowPrivateAddress: true,
193
+ }), /exceeds the limit/);
194
+ });
195
+ await rejects(() =>
196
+ fetchPortableMedia("https://example.com/media", {
197
+ digestMultibase: digest,
198
+ maxBytes: 0,
199
+ }), RangeError);
200
+ });
201
+
202
+ test("fetchPortableMedia() rejects private media and gateways", async () => {
203
+ await rejects(() =>
204
+ fetchPortableMedia("http://127.0.0.1/media", {
205
+ digestMultibase: digest,
206
+ }), /private address/);
207
+ await rejects(
208
+ () =>
209
+ fetchPortableMedia({
210
+ url: new URL(hashlink),
211
+ digestMultibase: digest,
212
+ }, { gateways: ["http://127.0.0.1"] }),
213
+ /No gateway returned verified portable media/,
214
+ );
215
+ });
216
+
217
+ test("fetchPortableMedia() propagates cancellation", async () => {
218
+ const controller = new AbortController();
219
+ const reason = new Error("cancelled");
220
+ controller.abort(reason);
221
+ await rejects(() =>
222
+ fetchPortableMedia({
223
+ url: new URL(hashlink),
224
+ digestMultibase: digest,
225
+ }, {
226
+ gateways: ["https://example.com"],
227
+ signal: controller.signal,
228
+ }), (error) => error === reason);
229
+ });
230
+
231
+ networkTest(
232
+ "fetchPortableMedia() falls back after a gateway timeout",
233
+ async () => {
234
+ await withServers([
235
+ () => undefined,
236
+ () => new Response(bytes),
237
+ ], async (gateways) => {
238
+ const response = await fetchPortableMedia({
239
+ url: new URL(hashlink),
240
+ digestMultibase: digest,
241
+ }, {
242
+ gateways,
243
+ allowPrivateAddress: true,
244
+ gatewayTimeout: 500,
245
+ timeout: 5_000,
246
+ });
247
+ equal(await response.text(), "portable image");
248
+ });
249
+ },
250
+ );
251
+
252
+ networkTest("fetchPortableMedia() applies one overall timeout", async () => {
253
+ await withServers(
254
+ [() => undefined, () => new Response(bytes)],
255
+ async (gateways) => {
256
+ await rejects(
257
+ () =>
258
+ fetchPortableMedia({
259
+ url: new URL(hashlink),
260
+ digestMultibase: digest,
261
+ }, {
262
+ gateways,
263
+ allowPrivateAddress: true,
264
+ gatewayTimeout: 100,
265
+ timeout: 20,
266
+ }),
267
+ (error) =>
268
+ error instanceof DOMException && error.name === "TimeoutError",
269
+ );
270
+ },
271
+ );
272
+ });
273
+
274
+ networkTest("fetchPortableMedia() follows a safe redirect", async () => {
275
+ const requested: string[] = [];
276
+ await withServers([(path) => {
277
+ requested.push(path);
278
+ return path === "/media" ? new Response(bytes) : new Response(null, {
279
+ status: 302,
280
+ headers: { Location: "/media" },
281
+ });
282
+ }], async ([gateway]) => {
283
+ const response = await fetchPortableMedia({
284
+ url: new URL(hashlink),
285
+ digestMultibase: digest,
286
+ }, { gateways: [gateway], allowPrivateAddress: true });
287
+ equal(await response.text(), "portable image");
288
+ });
289
+ deepStrictEqual(requested, [
290
+ `/.well-known/apgateway/${hashlink}`,
291
+ "/media",
292
+ ]);
293
+ });
@@ -0,0 +1,264 @@
1
+ import { MAX_BODY_SIZE, readBoundedBytes } from "./body.ts";
2
+ import {
3
+ parseDigestMultibase,
4
+ parseHashlink,
5
+ verifyDigestMultibase,
6
+ } from "./digest.ts";
7
+ import { getUserAgent, type GetUserAgentOptions } from "./request.ts";
8
+ import { parseGatewayUrl, validatePublicUrl } from "./url.ts";
9
+
10
+ /** A link or document with an external resource and its integrity digest. */
11
+ export interface PortableMedia {
12
+ readonly href?: URL | null;
13
+ readonly url?: URL | {
14
+ readonly href: URL | null;
15
+ readonly digestMultibase?: string | null;
16
+ } | null;
17
+ readonly digestMultibase?: string | null;
18
+ }
19
+
20
+ /** Options for {@link fetchPortableMedia}. */
21
+ export interface FetchPortableMediaOptions {
22
+ /** Ordered FEP-ef61 gateway origins used for `hl:` resources. */
23
+ readonly gateways?: Iterable<string | URL>;
24
+ /** Expected digest when the first argument is a URL or string. */
25
+ readonly digestMultibase?: string;
26
+ /** Maximum decoded response size in bytes. Defaults to 16 MiB. */
27
+ readonly maxBytes?: number;
28
+ /** Whether private network addresses may be fetched. Defaults to `false`. */
29
+ readonly allowPrivateAddress?: boolean;
30
+ /** The HTTP `User-Agent` value or its components. */
31
+ readonly userAgent?: string | GetUserAgentOptions;
32
+ /** Cancels the entire retrieval and all gateway attempts. */
33
+ readonly signal?: AbortSignal;
34
+ /** Overall timeout in milliseconds. Defaults to 10 seconds; `null` disables it. */
35
+ readonly timeout?: number | null;
36
+ /** Timeout per gateway in milliseconds. Defaults to 3 seconds. */
37
+ readonly gatewayTimeout?: number | null;
38
+ }
39
+
40
+ function resolveTimeout(
41
+ value: number | null | undefined,
42
+ fallback: number,
43
+ ): number | null {
44
+ if (value === null) return null;
45
+ const result = value ?? fallback;
46
+ if (!Number.isFinite(result) || result <= 0 || result > 2_147_483_647) {
47
+ throw new RangeError(
48
+ "A media timeout must be a positive finite number of milliseconds.",
49
+ );
50
+ }
51
+ return Math.ceil(result);
52
+ }
53
+
54
+ async function withTimeout<T>(
55
+ operation: (signal: AbortSignal) => Promise<T>,
56
+ parent: AbortSignal | undefined,
57
+ timeout: number | null,
58
+ ): Promise<T> {
59
+ parent?.throwIfAborted();
60
+ const controller = new AbortController();
61
+ const onParentAbort = () => controller.abort(parent?.reason);
62
+ parent?.addEventListener("abort", onParentAbort, { once: true });
63
+ let timer: ReturnType<typeof setTimeout> | undefined;
64
+ if (timeout != null) {
65
+ timer = setTimeout(() =>
66
+ controller.abort(
67
+ new DOMException(
68
+ `Media retrieval timed out after ${timeout} ms.`,
69
+ "TimeoutError",
70
+ ),
71
+ ), timeout);
72
+ }
73
+ let onAbort: (() => void) | undefined;
74
+ const aborted = new Promise<never>((_, reject) => {
75
+ onAbort = () => reject(controller.signal.reason);
76
+ controller.signal.addEventListener("abort", onAbort, { once: true });
77
+ });
78
+ try {
79
+ return await Promise.race([operation(controller.signal), aborted]);
80
+ } finally {
81
+ if (timer != null) clearTimeout(timer);
82
+ parent?.removeEventListener("abort", onParentAbort);
83
+ if (onAbort != null) {
84
+ controller.signal.removeEventListener("abort", onAbort);
85
+ }
86
+ }
87
+ }
88
+
89
+ function mediaTarget(media: string | URL | PortableMedia): {
90
+ url: string;
91
+ digestMultibase: string | null | undefined;
92
+ } {
93
+ if (typeof media === "string" || media instanceof URL) {
94
+ return { url: media.toString(), digestMultibase: undefined };
95
+ }
96
+ const target = media.href ?? media.url;
97
+ const url = target instanceof URL
98
+ ? target.href
99
+ : target != null && typeof target === "object" && "href" in target
100
+ ? target.href?.href
101
+ : undefined;
102
+ if (url == null) throw new TypeError("Portable media has no URL or href.");
103
+ const linkDigest = target != null && !(target instanceof URL) &&
104
+ typeof target === "object" && "digestMultibase" in target
105
+ ? target.digestMultibase
106
+ : undefined;
107
+ if (media.digestMultibase != null && linkDigest != null) {
108
+ const outer = parseDigestMultibase(media.digestMultibase).digest;
109
+ const inner = parseDigestMultibase(linkDigest).digest;
110
+ if (outer.some((byte, i) => byte !== inner[i])) {
111
+ throw new TypeError("Media and linked resource digests disagree.");
112
+ }
113
+ }
114
+ return { url, digestMultibase: media.digestMultibase ?? linkDigest };
115
+ }
116
+
117
+ async function fetchVerified(
118
+ url: URL,
119
+ digestMultibase: string,
120
+ options: FetchPortableMediaOptions,
121
+ signal: AbortSignal,
122
+ maxBytes: number,
123
+ ): Promise<Response> {
124
+ const visited = new Set<string>();
125
+ for (let redirects = 0; redirects <= 20; redirects++) {
126
+ signal.throwIfAborted();
127
+ if (url.username !== "" || url.password !== "") {
128
+ throw new TypeError("Media URLs must not contain credentials.");
129
+ }
130
+ if (!options.allowPrivateAddress) await validatePublicUrl(url.href);
131
+ else if (url.protocol !== "http:" && url.protocol !== "https:") {
132
+ throw new TypeError("Media URLs must use HTTP(S).");
133
+ }
134
+ if (visited.has(url.href)) throw new TypeError("Media redirect loop.");
135
+ visited.add(url.href);
136
+ const response = await fetch(url, {
137
+ method: "GET",
138
+ headers: {
139
+ Accept: "*/*",
140
+ "User-Agent": typeof options.userAgent === "string"
141
+ ? options.userAgent
142
+ : getUserAgent(options.userAgent),
143
+ },
144
+ redirect: "manual",
145
+ signal,
146
+ });
147
+ if ([301, 302, 303, 307, 308].includes(response.status)) {
148
+ const location = response.headers.get("Location");
149
+ void response.body?.cancel().catch(() => {});
150
+ if (location == null || redirects === 20) {
151
+ throw new TypeError("Invalid or excessive media redirects.");
152
+ }
153
+ url = new URL(location, url);
154
+ continue;
155
+ }
156
+ if (response.status !== 200) {
157
+ void response.body?.cancel().catch(() => {});
158
+ throw new Error(`Media request failed with HTTP ${response.status}.`);
159
+ }
160
+ const bytes = await readBoundedBytes(response, maxBytes, url);
161
+ signal.throwIfAborted();
162
+ if (!await verifyDigestMultibase(bytes, digestMultibase)) {
163
+ throw new Error("Portable media digest does not match.");
164
+ }
165
+ const headers = new Headers();
166
+ const contentType = response.headers.get("Content-Type");
167
+ if (contentType != null) headers.set("Content-Type", contentType);
168
+ return new Response(bytes, { headers });
169
+ }
170
+ throw new TypeError("Too many media redirects.");
171
+ }
172
+
173
+ /**
174
+ * Fetches a portable object's external media and returns it only after its
175
+ * SHA-256 `digestMultibase` has been verified. `hl:` resources are tried at
176
+ * the supplied gateways in order. The result contains verified body bytes;
177
+ * its `Content-Type` is unverified metadata supplied by the server.
178
+ *
179
+ * @param media A Link, Image, Document, or URL with an external resource.
180
+ * @param options Retrieval, integrity, and network policy options.
181
+ * @returns A response containing only verified bytes.
182
+ * @throws {TypeError} If the media reference or digest is invalid.
183
+ * @throws {Error} If no source supplies a matching bounded resource.
184
+ * @since 2.4.0
185
+ */
186
+ export async function fetchPortableMedia(
187
+ media: string | URL | PortableMedia,
188
+ options: FetchPortableMediaOptions = {},
189
+ ): Promise<Response> {
190
+ const { url, digestMultibase: propertyDigest } = mediaTarget(media);
191
+ const digestMultibase = propertyDigest ?? options.digestMultibase;
192
+ if (digestMultibase == null) {
193
+ throw new TypeError("Portable media requires digestMultibase.");
194
+ }
195
+ const expected = parseDigestMultibase(digestMultibase).digest;
196
+ const maxBytes = options.maxBytes ?? MAX_BODY_SIZE;
197
+ if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
198
+ throw new RangeError(
199
+ "The media size limit must be a positive safe integer.",
200
+ );
201
+ }
202
+ const timeout = resolveTimeout(options.timeout, 10_000);
203
+ const gatewayTimeout = resolveTimeout(options.gatewayTimeout, 3_000);
204
+ let targets: URL[];
205
+ let hashlink = false;
206
+ if (/^hl:/i.test(url)) {
207
+ hashlink = true;
208
+ const parsed = parseHashlink(url);
209
+ const hashDigest = parseDigestMultibase(parsed.digestMultibase).digest;
210
+ if (expected.some((byte, i) => byte !== hashDigest[i])) {
211
+ throw new TypeError("Hashlink and digestMultibase disagree.");
212
+ }
213
+ const gateways = [
214
+ ...new Set(
215
+ [...options.gateways ?? []].map((gateway) =>
216
+ parseGatewayUrl(gateway.toString()).origin
217
+ ),
218
+ ),
219
+ ];
220
+ if (gateways.length === 0) {
221
+ throw new TypeError("Hashlink media requires at least one gateway.");
222
+ }
223
+ targets = gateways.map((gateway) =>
224
+ new URL(`/.well-known/apgateway/${url}`, gateway)
225
+ );
226
+ } else {
227
+ const target = new URL(url);
228
+ if (target.protocol !== "http:" && target.protocol !== "https:") {
229
+ throw new TypeError("Portable media URLs must use HTTP(S) or hl:.");
230
+ }
231
+ targets = [target];
232
+ }
233
+ return await withTimeout(
234
+ async (signal) => {
235
+ let lastError: unknown;
236
+ for (const target of targets) {
237
+ signal.throwIfAborted();
238
+ try {
239
+ return await withTimeout(
240
+ (attemptSignal) =>
241
+ fetchVerified(
242
+ target,
243
+ digestMultibase,
244
+ options,
245
+ attemptSignal,
246
+ maxBytes,
247
+ ),
248
+ signal,
249
+ hashlink ? gatewayTimeout : null,
250
+ );
251
+ } catch (error) {
252
+ signal.throwIfAborted();
253
+ if (!hashlink) throw error;
254
+ lastError = error;
255
+ }
256
+ }
257
+ throw new Error("No gateway returned verified portable media.", {
258
+ cause: lastError,
259
+ });
260
+ },
261
+ options.signal,
262
+ timeout,
263
+ );
264
+ }