@fedify/vocab-runtime 2.4.0-pr.936.41 → 2.4.1

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 (152) hide show
  1. package/deno.json +4 -2
  2. package/dist/{tests/docloader-Ck8bcBir.mjs → contexts-B09jb9Pi.cjs} +173 -274
  3. package/dist/{tests/docloader-S_gQZ2tt.cjs → contexts-DWrj4AGR.js} +169 -293
  4. package/dist/{docloader-C_dir7Xb.d.ts → docloader-BMHljyYb.d.cts} +68 -2
  5. package/dist/{docloader-D2DTRiyA.d.cts → docloader-BMHljyYb.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 +762 -4529
  20. package/dist/mod.d.cts +427 -9
  21. package/dist/mod.d.ts +427 -10
  22. package/dist/mod.js +742 -4524
  23. package/dist/portable-6Aj-7dZJ.d.cts +160 -0
  24. package/dist/portable-nkTiqH0w.d.ts +160 -0
  25. package/dist/tests/body-DpC9p-iG.mjs +130 -0
  26. package/dist/tests/body-cKLBprYm.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-C9Lj11SC.cjs +4533 -0
  32. package/dist/tests/contexts-duRA_dDC.mjs +4522 -0
  33. package/dist/tests/decimal.test.cjs +9 -7
  34. package/dist/tests/decimal.test.mjs +9 -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-DyZ1JGIl.cjs +398 -0
  42. package/dist/tests/docloader-tUmMeXjU.mjs +375 -0
  43. package/dist/tests/docloader.test.cjs +816 -39
  44. package/dist/tests/docloader.test.mjs +810 -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-CmDhHNTx.cjs +12 -0
  52. package/dist/tests/jsonld-DweIJ07b.mjs +5 -0
  53. package/dist/tests/jsonld-cache-BEXiiM-V.cjs +386 -0
  54. package/dist/tests/jsonld-cache-DFnWsLuZ.mjs +339 -0
  55. package/dist/tests/jsonld-cache.test.cjs +102 -298
  56. package/dist/tests/jsonld-cache.test.mjs +95 -289
  57. package/dist/tests/{key-_wXwomh_.cjs → key-C-AYkdJJ.cjs} +11 -4
  58. package/dist/tests/{key-CDGDH_vC.mjs → key-C2Db_TAJ.mjs} +11 -3
  59. package/dist/tests/key.test.cjs +6 -5
  60. package/dist/tests/key.test.mjs +6 -4
  61. package/dist/tests/{langstr-Di5AvKpB.mjs → langstr-DYRmJqFn.mjs} +5 -2
  62. package/dist/tests/{langstr-CbAxaeEZ.cjs → langstr-_PqsL1ae.cjs} +5 -2
  63. package/dist/tests/langstr.test.cjs +11 -4
  64. package/dist/tests/langstr.test.mjs +9 -2
  65. package/dist/tests/langtag-CUppHs_s.mjs +45 -0
  66. package/dist/tests/langtag-Czjvxfzx.cjs +50 -0
  67. package/dist/tests/langtag.test.cjs +45 -0
  68. package/dist/tests/langtag.test.d.cts +1 -0
  69. package/dist/tests/langtag.test.d.mts +1 -0
  70. package/dist/tests/langtag.test.mjs +46 -0
  71. package/dist/tests/link.test.cjs +2 -3
  72. package/dist/tests/link.test.mjs +2 -2
  73. package/dist/tests/multibase/multibase.test.cjs +8 -9
  74. package/dist/tests/multibase/multibase.test.mjs +6 -6
  75. package/dist/tests/{multibase-Bz_UUDtL.cjs → multibase-B5Mea7Ip.cjs} +19 -2
  76. package/dist/tests/{multibase-B4bvakyA.mjs → multibase-BPnF_L4e.mjs} +12 -1
  77. package/dist/tests/portable-dereference-DmTYDy2p.mjs +250 -0
  78. package/dist/tests/portable-dereference-qTLMqe2_.cjs +279 -0
  79. package/dist/tests/portable-media-5p8azal0.cjs +153 -0
  80. package/dist/tests/portable-media-B_lt9MMA.mjs +148 -0
  81. package/dist/tests/portable-media.test.cjs +222 -0
  82. package/dist/tests/portable-media.test.d.cts +1 -0
  83. package/dist/tests/portable-media.test.d.mts +1 -0
  84. package/dist/tests/portable-media.test.mjs +223 -0
  85. package/dist/tests/portable-workers.test.cjs +36 -0
  86. package/dist/tests/portable-workers.test.d.cts +2 -0
  87. package/dist/tests/portable-workers.test.d.mts +2 -0
  88. package/dist/tests/portable-workers.test.mjs +35 -0
  89. package/dist/tests/{request-uk51rkhO.cjs → request-BgjA9wyl.cjs} +10 -4
  90. package/dist/tests/{request-C8CaGwtt.mjs → request-DodZNukP.mjs} +8 -2
  91. package/dist/tests/request.test.cjs +7 -4
  92. package/dist/tests/request.test.mjs +5 -2
  93. package/dist/tests/signed-representation.test.cjs +588 -0
  94. package/dist/tests/signed-representation.test.d.cts +1 -0
  95. package/dist/tests/signed-representation.test.d.mts +1 -0
  96. package/dist/tests/signed-representation.test.mjs +589 -0
  97. package/dist/tests/temporal.test.cjs +1 -2
  98. package/dist/tests/temporal.test.mjs +1 -1
  99. package/dist/tests/url-BNakuZ8k.cjs +998 -0
  100. package/dist/tests/url-DMxmp7ZG.mjs +859 -0
  101. package/dist/tests/url.test.cjs +489 -6
  102. package/dist/tests/url.test.mjs +489 -5
  103. package/dist/url-DrGTR8yv.cjs +993 -0
  104. package/dist/url-Dzyp-NsC.js +860 -0
  105. package/package.json +27 -4
  106. package/scripts/test-bun.mjs +17 -0
  107. package/src/body.test.ts +125 -0
  108. package/src/body.ts +152 -0
  109. package/src/contexts/cid-v1.json +114 -0
  110. package/src/contexts/fep-22cd.json +21 -0
  111. package/src/contexts/fep-6757.json +12 -0
  112. package/src/contexts/joinmastodon.json +1 -1
  113. package/src/contexts/miscellany.json +17 -0
  114. package/src/contexts.ts +47 -1
  115. package/src/digest.test.ts +220 -0
  116. package/src/digest.ts +229 -0
  117. package/src/docloader.test.ts +1066 -27
  118. package/src/docloader.ts +322 -105
  119. package/src/internal/jsonld-cache.ts +97 -1
  120. package/src/internal/portable-dereference.test.ts +254 -0
  121. package/src/internal/portable-dereference.ts +1000 -0
  122. package/src/internal/signed-representation.ts +565 -0
  123. package/src/jsonld-cache.test.ts +89 -0
  124. package/src/key.test.ts +9 -0
  125. package/src/key.ts +11 -1
  126. package/src/langstr.test.ts +18 -1
  127. package/src/langstr.ts +6 -2
  128. package/src/langtag.test.ts +53 -0
  129. package/src/langtag.ts +76 -0
  130. package/src/mod.ts +29 -0
  131. package/src/multibase/multibase.test.ts +5 -5
  132. package/src/portable-media.test.ts +293 -0
  133. package/src/portable-media.ts +264 -0
  134. package/src/portable-workers.test.ts +81 -0
  135. package/src/portable.ts +180 -0
  136. package/src/preprocessor.ts +7 -0
  137. package/src/request.test.ts +10 -1
  138. package/src/request.ts +9 -1
  139. package/src/signed-representation.test.ts +338 -0
  140. package/src/url.test.ts +823 -3
  141. package/src/url.ts +700 -23
  142. package/tsdown.config.ts +2 -0
  143. package/dist/tests/url-CsOV_B_P.cjs +0 -482
  144. package/dist/tests/url-Du7RQQgP.mjs +0 -392
  145. package/dist/url-DD4F0ULf.cjs +0 -483
  146. package/dist/url-DGVbSVVi.js +0 -393
  147. /package/dist/{chunk-M78iaK0I.cjs → rolldown-runtime-B7lfambq.cjs} +0 -0
  148. /package/dist/tests/{link-NUUWCdnK.mjs → link-Cevmc87v.mjs} +0 -0
  149. /package/dist/tests/{link-FguCydMA.cjs → link-DlKm8bEr.cjs} +0 -0
  150. /package/dist/tests/{multicodec-CxGVGa91.cjs → multicodec-CLRPeW4N.cjs} +0 -0
  151. /package/dist/tests/{multicodec-CyFp54fI.mjs → multicodec-CRIj_05H.mjs} +0 -0
  152. /package/dist/tests/{chunk-C2EiDwsr.cjs → rolldown-runtime-emK7D4bc.cjs} +0 -0
@@ -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
+ }
@@ -0,0 +1,81 @@
1
+ import { test, testDefinitions } from "@fedify/fixture";
2
+ import { deepStrictEqual, equal } from "node:assert/strict";
3
+ import {
4
+ computeDigestMultibase,
5
+ createHashlink,
6
+ verifyHashlink,
7
+ } from "./digest.ts";
8
+ import { exportDidKey, importDidKey } from "./key.ts";
9
+ import { getPortableGatewayCandidates } from "./internal/portable-dereference.ts";
10
+ import {
11
+ canonicalizePortableUri,
12
+ formatIri,
13
+ fromCompatibleEf61Id,
14
+ parseIri,
15
+ toCompatibleEf61Id,
16
+ } from "./url.ts";
17
+
18
+ const portableId = "ap://did:key:z6Mkabc/actor";
19
+
20
+ export { testDefinitions };
21
+
22
+ test("vocab-runtime portable URI formats on Workers", () => {
23
+ equal(formatIri(parseIri(portableId)), "ap+ef61://did:key:z6Mkabc/actor");
24
+ equal(
25
+ canonicalizePortableUri(`${portableId}?@gateway=https%3A%2F%2Fa.example`),
26
+ "ap+ef61://did:key:z6Mkabc/actor",
27
+ );
28
+ });
29
+
30
+ test("vocab-runtime compatible IDs on Workers", () => {
31
+ const compatible = toCompatibleEf61Id(portableId, "https://gw.example");
32
+ equal(
33
+ compatible.href,
34
+ "https://gw.example/.well-known/apgateway/did:key:z6Mkabc/actor",
35
+ );
36
+ equal(
37
+ formatIri(fromCompatibleEf61Id(compatible)!),
38
+ "ap+ef61://did:key:z6Mkabc/actor",
39
+ );
40
+ });
41
+
42
+ test("vocab-runtime did:key on Workers", async () => {
43
+ const keys = await crypto.subtle.generateKey("Ed25519", true, [
44
+ "sign",
45
+ "verify",
46
+ ]) as CryptoKeyPair;
47
+ const did = await exportDidKey(keys.publicKey);
48
+ const imported = await importDidKey(did);
49
+ deepStrictEqual(
50
+ new Uint8Array(await crypto.subtle.exportKey("raw", imported)),
51
+ new Uint8Array(await crypto.subtle.exportKey("raw", keys.publicKey)),
52
+ );
53
+ });
54
+
55
+ test("vocab-runtime hashlink on Workers", async () => {
56
+ const bytes = new TextEncoder().encode("portable media");
57
+ const digest = await computeDigestMultibase(bytes);
58
+ equal(await verifyHashlink(bytes, createHashlink(digest)), true);
59
+ equal(
60
+ await verifyHashlink(
61
+ new TextEncoder().encode("tampered"),
62
+ createHashlink(digest),
63
+ ),
64
+ false,
65
+ );
66
+ });
67
+
68
+ test("vocab-runtime gateway candidates on Workers", () => {
69
+ const hinted = parseIri(
70
+ `${portableId}?@gateway=https%3A%2F%2Fhint.example`,
71
+ );
72
+ deepStrictEqual(
73
+ getPortableGatewayCandidates(hinted, ["https://first.example"])
74
+ .map((url) => url.href),
75
+ ["https://first.example/"],
76
+ );
77
+ deepStrictEqual(
78
+ getPortableGatewayCandidates(hinted).map((url) => url.href),
79
+ ["https://hint.example/"],
80
+ );
81
+ });
@@ -0,0 +1,180 @@
1
+ import type { TracerProvider } from "@opentelemetry/api";
2
+ import type { DocumentLoader } from "./docloader.ts";
3
+
4
+ /**
5
+ * Options passed to a {@link PortableObjectVerifier}.
6
+ * @since 2.4.0
7
+ */
8
+ export interface PortableObjectVerifierOptions {
9
+ /**
10
+ * The document loader for fetching remote documents, such as verification
11
+ * methods that are not `did:key` URLs.
12
+ */
13
+ readonly documentLoader?: DocumentLoader;
14
+
15
+ /**
16
+ * The document loader for fetching remote JSON-LD contexts. During
17
+ * gateway dereferencing, this loader returns the same context documents
18
+ * that the vocabulary parser sees.
19
+ */
20
+ readonly contextLoader?: DocumentLoader;
21
+
22
+ /**
23
+ * The OpenTelemetry tracer provider.
24
+ */
25
+ readonly tracerProvider?: TracerProvider;
26
+
27
+ /**
28
+ * The URL the document was finally retrieved from, i.e., the `documentUrl`
29
+ * of the document loader's result, which may differ from the requested URL
30
+ * after redirects. Trust decisions such as the [FEP-ef61] gateway trust
31
+ * policy for unsecured collections depend on it, so custom document loaders
32
+ * must report it truthfully.
33
+ *
34
+ * [FEP-ef61]: https://w3id.org/fep/ef61
35
+ * @since 2.4.0
36
+ */
37
+ readonly documentUrl?: URL;
38
+
39
+ /**
40
+ * The gateways that the caller explicitly passed to the accessor through
41
+ * its `gateways` option, in order. It is `undefined` if the caller did not
42
+ * pass the option, and can be empty.
43
+ * @since 2.4.0
44
+ */
45
+ readonly gateways?: readonly URL[];
46
+
47
+ /**
48
+ * The gateways inferred from the reference, in order, when the caller did
49
+ * not pass the `gateways` option: the `@gateway` location hints of
50
+ * a portable IRI, the gateway named by an FEP-ef61 compatible identifier,
51
+ * or the WebFinger server of a portable actor. They only tell where the
52
+ * document and related objects might be retrieved, and must not be
53
+ * trusted to act for the portable object's DID.
54
+ * @since 2.4.0
55
+ */
56
+ readonly gatewayHints?: readonly URL[];
57
+
58
+ /**
59
+ * The object whose property referred to the document, if the document was
60
+ * dereferenced through a property accessor.
61
+ * @since 2.4.0
62
+ */
63
+ readonly referrer?: PortableObjectReferrer;
64
+ }
65
+
66
+ /**
67
+ * An object whose property referred to a portable object being verified.
68
+ * Referrers form a chain: {@link PortableObjectReferrer.referrer} is the
69
+ * object through which this referrer itself was obtained, and so on.
70
+ * @since 2.4.0
71
+ */
72
+ export interface PortableObjectReferrer {
73
+ /**
74
+ * The referring vocabulary object.
75
+ */
76
+ readonly object: unknown;
77
+
78
+ /**
79
+ * The `@id` of the referring object, if any.
80
+ */
81
+ readonly id: URL | null;
82
+
83
+ /**
84
+ * The expanded URI of the property through which the referring object
85
+ * referred to the next object in the chain, e.g.,
86
+ * `https://www.w3.org/ns/activitystreams#outbox`.
87
+ */
88
+ readonly property: string;
89
+
90
+ /**
91
+ * How the referring object itself was accepted by a
92
+ * {@link PortableObjectVerifier} when it was dereferenced through gateways:
93
+ *
94
+ * - `"verified"`: accepted as verified, e.g., with a valid integrity proof.
95
+ * - `"unsecured"`: accepted without an integrity proof, e.g., as an
96
+ * unsecured collection served by a trusted gateway.
97
+ *
98
+ * It is `undefined` if the referring object was not dereferenced through
99
+ * gateways, e.g., if it was embedded in another object or constructed by
100
+ * the application.
101
+ */
102
+ readonly acceptance?: "verified" | "unsecured";
103
+
104
+ /**
105
+ * The opaque collection context that a {@link PortableObjectVerifier}
106
+ * attached to the referring object when it accepted it. See
107
+ * {@link PortableObjectVerification}.
108
+ */
109
+ readonly collectionContext?: unknown;
110
+
111
+ /**
112
+ * The referrer of the referring object, if any.
113
+ */
114
+ readonly referrer?: PortableObjectReferrer;
115
+
116
+ /**
117
+ * Whether the chain was cut off here because it was too long. If `true`,
118
+ * the referring object may have a referrer which is not included.
119
+ */
120
+ readonly truncated?: boolean;
121
+ }
122
+
123
+ /**
124
+ * The result of a {@link PortableObjectVerifier}.
125
+ * @since 2.4.0
126
+ */
127
+ export type PortableObjectVerification =
128
+ | {
129
+ /** Whether the document is accepted. */
130
+ readonly verified: false;
131
+ }
132
+ | {
133
+ /** Whether the document is accepted. */
134
+ readonly verified: true;
135
+
136
+ /**
137
+ * Whether the document was accepted without an integrity proof, e.g., as
138
+ * an unsecured [FEP-ef61] collection served by a gateway that its owner
139
+ * lists. Objects embedded in such a document are not trusted because of
140
+ * their origin; property accessors dereference and verify them one by
141
+ * one instead.
142
+ *
143
+ * [FEP-ef61]: https://w3id.org/fep/ef61
144
+ */
145
+ readonly unsecured?: boolean;
146
+
147
+ /**
148
+ * An opaque value that the verifier wants to receive again as
149
+ * {@link PortableObjectReferrer.collectionContext} when objects referred
150
+ * to by this document, such as collection pages, are verified.
151
+ */
152
+ readonly collectionContext?: unknown;
153
+ };
154
+
155
+ /**
156
+ * A function that applies the [FEP-ef61] trust policy to a portable object
157
+ * fetched through a gateway.
158
+ *
159
+ * It receives the fetched JSON-LD document as is, and resolves to an object
160
+ * whose `verified` property tells whether the document is acceptable, e.g.,
161
+ * whether it carries a valid [FEP-8b32] Object Integrity Proof made by the
162
+ * DID in its portable ID. `verifyPortableObject()` and
163
+ * `verifyPortableObjectProof()` from `@fedify/fedify` satisfy this type, so
164
+ * they can be passed as the `verifyPortableObject` option of generated
165
+ * vocabulary accessors such as `Activity.getObject()`.
166
+ *
167
+ * [FEP-ef61]: https://w3id.org/fep/ef61
168
+ * [FEP-8b32]: https://w3id.org/fep/8b32
169
+ *
170
+ * @param document The fetched JSON-LD document.
171
+ * @param options Loaders, tracing options, and where the document came from.
172
+ * @returns An object whose `verified` property is `true` if and only if the
173
+ * document satisfies the policy. Other properties, such as
174
+ * a failure reason, are logged when verification fails.
175
+ * @since 2.4.0
176
+ */
177
+ export type PortableObjectVerifier = (
178
+ document: unknown,
179
+ options: PortableObjectVerifierOptions,
180
+ ) => Promise<PortableObjectVerification>;
@@ -1,5 +1,6 @@
1
1
  import type { DocumentLoader } from "./docloader.ts";
2
2
  import type { TracerProvider } from "@opentelemetry/api";
3
+ import type { PortableObjectVerifier } from "./portable.ts";
3
4
 
4
5
  /**
5
6
  * JSON value shape passed to property preprocessors.
@@ -24,6 +25,12 @@ export interface PropertyPreprocessorContext {
24
25
  contextLoader?: DocumentLoader;
25
26
  /** OpenTelemetry tracer provider for instrumentation. */
26
27
  tracerProvider?: TracerProvider;
28
+ /**
29
+ * The default FEP-ef61 portable object verifier that objects returned by
30
+ * the preprocessor should use for their property accessors.
31
+ * @since 2.4.0
32
+ */
33
+ verifyPortableObject?: PortableObjectVerifier;
27
34
  /** Base URL for resolving relative references. */
28
35
  baseUrl?: URL;
29
36
  }
@@ -2,7 +2,7 @@ import { deepStrictEqual } from "node:assert";
2
2
  import process from "node:process";
3
3
  import { test } from "node:test";
4
4
  import metadata from "../deno.json" with { type: "json" };
5
- import { getUserAgent } from "./request.ts";
5
+ import { createActivityPubRequest, getUserAgent } from "./request.ts";
6
6
 
7
7
  test("getUserAgent()", () => {
8
8
  if ("Deno" in globalThis) {
@@ -91,3 +91,12 @@ test("getUserAgent()", () => {
91
91
  );
92
92
  }
93
93
  });
94
+
95
+ test("createActivityPubRequest() asks for the ActivityStreams profile", () => {
96
+ const request = createActivityPubRequest("https://example.com/object");
97
+ deepStrictEqual(
98
+ request.headers.get("Accept"),
99
+ "application/activity+json, " +
100
+ 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
101
+ );
102
+ });
package/src/request.ts CHANGED
@@ -31,6 +31,14 @@ export class FetchError extends Error {
31
31
  }
32
32
  }
33
33
 
34
+ /**
35
+ * The `Accept` header value for fetching ActivityPub objects. ActivityPub
36
+ * and FEP-ef61 gateways require the ActivityStreams profile on the JSON-LD
37
+ * media type.
38
+ */
39
+ const ACTIVITYPUB_ACCEPT =
40
+ 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"';
41
+
34
42
  /**
35
43
  * Options for creating a request.
36
44
  * @internal
@@ -52,7 +60,7 @@ export function createActivityPubRequest(
52
60
  ): Request {
53
61
  return new Request(url, {
54
62
  headers: {
55
- Accept: "application/activity+json, application/ld+json",
63
+ Accept: ACTIVITYPUB_ACCEPT,
56
64
  "User-Agent": typeof options.userAgent === "string"
57
65
  ? options.userAgent
58
66
  : getUserAgent(options.userAgent),