@fedify/vocab-runtime 2.4.0-dev.2056 → 2.4.0-dev.2065
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.
- package/deno.json +1 -1
- package/dist/internal/portable-dereference.cjs +355 -8
- package/dist/internal/portable-dereference.d.cts +238 -6
- package/dist/internal/portable-dereference.d.ts +238 -6
- package/dist/internal/portable-dereference.js +343 -10
- package/dist/mod.cjs +1 -1
- package/dist/mod.d.cts +2 -2
- package/dist/mod.d.ts +2 -2
- package/dist/mod.js +1 -1
- package/dist/portable-DH3947GK.d.ts +160 -0
- package/dist/portable-comBI05Y.d.cts +160 -0
- package/dist/tests/{body-wkL-XGhe.mjs → body-CNO0jMV8.mjs} +1 -1
- package/dist/tests/{body-h9zUR79f.cjs → body-DXHOazEl.cjs} +1 -1
- package/dist/tests/body.test.cjs +1 -1
- package/dist/tests/body.test.mjs +1 -1
- package/dist/tests/decimal.test.cjs +1 -1
- package/dist/tests/decimal.test.mjs +1 -1
- package/dist/tests/{docloader-CBLgaVPd.cjs → docloader-4Txefi7y.cjs} +2 -2
- package/dist/tests/{docloader-DMGssPEX.mjs → docloader-C-nWtUWo.mjs} +2 -2
- package/dist/tests/docloader.test.cjs +2 -2
- package/dist/tests/docloader.test.mjs +2 -2
- package/dist/tests/internal/portable-dereference.test.cjs +87 -0
- package/dist/tests/internal/portable-dereference.test.mjs +87 -0
- package/dist/tests/{request-ClFs0ePp.cjs → request-BOLanqAd.cjs} +1 -1
- package/dist/tests/{request-Ytm0xyZH.mjs → request-dpFdQKXr.mjs} +1 -1
- package/dist/tests/request.test.cjs +1 -1
- package/dist/tests/request.test.mjs +1 -1
- package/package.json +1 -1
- package/src/internal/portable-dereference.test.ts +32 -0
- package/src/internal/portable-dereference.ts +521 -12
- package/src/mod.ts +2 -0
- package/src/portable.ts +136 -8
- package/dist/portable-CduElToy.d.ts +0 -52
- package/dist/portable-DxF2Ut-Z.d.cts +0 -52
|
@@ -7,10 +7,16 @@ import {
|
|
|
7
7
|
import preloadedContexts from "../contexts.ts";
|
|
8
8
|
import type { DocumentLoader, RemoteDocument } from "../docloader.ts";
|
|
9
9
|
import jsonld from "../jsonld.ts";
|
|
10
|
-
import type {
|
|
10
|
+
import type {
|
|
11
|
+
PortableObjectReferrer,
|
|
12
|
+
PortableObjectVerification,
|
|
13
|
+
PortableObjectVerifier,
|
|
14
|
+
} from "../portable.ts";
|
|
11
15
|
import {
|
|
12
16
|
canonicalizePortableUri,
|
|
13
17
|
formatIri,
|
|
18
|
+
fromCompatibleEf61Id,
|
|
19
|
+
haveSameFe34Origin,
|
|
14
20
|
parseIri,
|
|
15
21
|
toCompatibleEf61Id,
|
|
16
22
|
} from "../url.ts";
|
|
@@ -38,6 +44,425 @@ const BASELINE_CONTEXT_URLS: ReadonlySet<string> = new Set([
|
|
|
38
44
|
"https://w3id.org/security/data-integrity/v1",
|
|
39
45
|
]);
|
|
40
46
|
|
|
47
|
+
/**
|
|
48
|
+
* The maximum number of links in a referrer chain passed to a
|
|
49
|
+
* {@link PortableObjectVerifier}. Longer chains are cut off and marked as
|
|
50
|
+
* truncated.
|
|
51
|
+
*/
|
|
52
|
+
const MAX_REFERRER_CHAIN_LENGTH = 32;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Where a vocabulary object came from, as far as portable objects are
|
|
56
|
+
* concerned.
|
|
57
|
+
*/
|
|
58
|
+
interface Provenance {
|
|
59
|
+
/**
|
|
60
|
+
* How the object was accepted by a portable object verifier, if it was
|
|
61
|
+
* returned by {@link dereferencePortableIri}.
|
|
62
|
+
*/
|
|
63
|
+
readonly acceptance?: "verified" | "unsecured";
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The opaque collection context that the verifier attached to the object.
|
|
67
|
+
*/
|
|
68
|
+
readonly collectionContext?: unknown;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The object and property through which the object was obtained.
|
|
72
|
+
*/
|
|
73
|
+
readonly referrer?: { readonly object: object; readonly property: string };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Kept outside the objects so that they cannot be forged through vocabulary
|
|
77
|
+
// constructors or JSON-LD, and so that they do not show up in serialization:
|
|
78
|
+
const provenances = new WeakMap<object, Provenance>();
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A reference from a vocabulary object's property, which generated
|
|
82
|
+
* accessors pass to {@link dereferencePortableIri}.
|
|
83
|
+
*
|
|
84
|
+
* @internal
|
|
85
|
+
*/
|
|
86
|
+
export interface PortableReferrerLink {
|
|
87
|
+
readonly object: object;
|
|
88
|
+
readonly property: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function getObjectId(object: unknown): URL | null {
|
|
92
|
+
if (object == null || typeof object !== "object" || !("id" in object)) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
const id = object.id;
|
|
96
|
+
return id instanceof URL ? id : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Checks whether a vocabulary object is part of a chain of portable objects,
|
|
101
|
+
* i.e., whether it has a portable ID or was obtained from a portable object.
|
|
102
|
+
*
|
|
103
|
+
* @internal Technically exported for generated vocabulary classes, but not
|
|
104
|
+
* part of the public API contract. This is not considered public API for
|
|
105
|
+
* Semantic Versioning decisions.
|
|
106
|
+
*/
|
|
107
|
+
export function isInPortableChain(object: object): boolean {
|
|
108
|
+
if (provenances.has(object)) return true;
|
|
109
|
+
const id = getObjectId(object);
|
|
110
|
+
return id != null && isPortableIri(id);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Checks whether a vocabulary object was accepted without an integrity
|
|
115
|
+
* proof, e.g., as an unsecured portable collection served by a trusted
|
|
116
|
+
* gateway. Objects embedded in such an object must not be trusted because
|
|
117
|
+
* of their origin.
|
|
118
|
+
*
|
|
119
|
+
* @internal Technically exported for generated vocabulary classes, but not
|
|
120
|
+
* part of the public API contract. This is not considered public API for
|
|
121
|
+
* Semantic Versioning decisions.
|
|
122
|
+
*/
|
|
123
|
+
export function isUnsecuredPortableObject(object: object): boolean {
|
|
124
|
+
return provenances.get(object)?.acceptance === "unsecured";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Checks whether a URL is an FEP-ef61 compatible identifier, i.e., an HTTP(S)
|
|
129
|
+
* URL under a gateway's `/.well-known/apgateway/` path that stands for
|
|
130
|
+
* a portable object. Malformed compatible identifiers count as compatible
|
|
131
|
+
* identifiers too.
|
|
132
|
+
*
|
|
133
|
+
* @internal Technically exported for generated vocabulary classes, but not
|
|
134
|
+
* part of the public API contract. This is not considered public API for
|
|
135
|
+
* Semantic Versioning decisions.
|
|
136
|
+
*/
|
|
137
|
+
export function isCompatibleEf61Iri(url: URL | string): boolean {
|
|
138
|
+
try {
|
|
139
|
+
return fromCompatibleEf61Id(url) != null;
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (error instanceof TypeError) return true;
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Records that a vocabulary object returned by a property accessor was
|
|
148
|
+
* obtained through the given property of a portable object, so that later
|
|
149
|
+
* dereferences from it can tell where it came from. Nothing is recorded if
|
|
150
|
+
* the parent is not in a portable chain, or if the child already has its
|
|
151
|
+
* provenance.
|
|
152
|
+
*
|
|
153
|
+
* @internal Technically exported for generated vocabulary classes, but not
|
|
154
|
+
* part of the public API contract. This is not considered public API for
|
|
155
|
+
* Semantic Versioning decisions.
|
|
156
|
+
*/
|
|
157
|
+
export function recordPortableReferrer(
|
|
158
|
+
parent: object,
|
|
159
|
+
child: unknown,
|
|
160
|
+
property: string,
|
|
161
|
+
): void {
|
|
162
|
+
if (child == null || typeof child !== "object") return;
|
|
163
|
+
if (provenances.has(child) || !isInPortableChain(parent)) return;
|
|
164
|
+
provenances.set(child, { referrer: { object: parent, property } });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Copies the provenance of a vocabulary object to its clone. The clone
|
|
169
|
+
* keeps where the original came from and the restriction on unsecured
|
|
170
|
+
* objects, but not a positive verification status nor a collection context,
|
|
171
|
+
* since the clone may have different property values.
|
|
172
|
+
*
|
|
173
|
+
* @internal Technically exported for generated vocabulary classes, but not
|
|
174
|
+
* part of the public API contract. This is not considered public API for
|
|
175
|
+
* Semantic Versioning decisions.
|
|
176
|
+
*/
|
|
177
|
+
export function copyPortableProvenance(from: object, to: object): void {
|
|
178
|
+
const provenance = provenances.get(from);
|
|
179
|
+
if (provenance == null) return;
|
|
180
|
+
provenances.set(to, {
|
|
181
|
+
referrer: provenance.referrer,
|
|
182
|
+
...(provenance.acceptance === "unsecured"
|
|
183
|
+
? { acceptance: "unsecured" }
|
|
184
|
+
: {}),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Recognizes an FEP-ef61 compatible identifier to dereference as a portable
|
|
190
|
+
* object.
|
|
191
|
+
*
|
|
192
|
+
* @returns `undefined` if the URL is not a compatible identifier; `null` if
|
|
193
|
+
* it is a malformed compatible identifier; otherwise, the portable
|
|
194
|
+
* ID it stands for and the gateway it names.
|
|
195
|
+
* @internal Technically exported for generated vocabulary classes, but not
|
|
196
|
+
* part of the public API contract. This is not considered public API for
|
|
197
|
+
* Semantic Versioning decisions.
|
|
198
|
+
*/
|
|
199
|
+
export function parseCompatibleEf61Reference(
|
|
200
|
+
url: URL | string,
|
|
201
|
+
): { readonly id: URL; readonly gateway: URL } | null | undefined {
|
|
202
|
+
if (typeof url === "string") {
|
|
203
|
+
if (!URL.canParse(url)) return undefined;
|
|
204
|
+
url = new URL(url);
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
const id = fromCompatibleEf61Id(url);
|
|
208
|
+
if (id == null) return undefined;
|
|
209
|
+
return { id, gateway: new URL(url.origin) };
|
|
210
|
+
} catch (error) {
|
|
211
|
+
if (error instanceof TypeError) {
|
|
212
|
+
logger.debug(
|
|
213
|
+
"Invalid FEP-ef61 compatible identifier {url}: {error}",
|
|
214
|
+
{ url: url.href, error },
|
|
215
|
+
);
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
throw error;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Checks whether a property accessor call on a vocabulary object applies the
|
|
224
|
+
* FEP-ef61 portable object policy, i.e., whether the `verifyPortableObject`
|
|
225
|
+
* option is given or the object is part of a chain of portable objects.
|
|
226
|
+
* In this mode, compatible identifiers are dereferenced as portable objects,
|
|
227
|
+
* and fetched documents that turn out to stand for portable objects are
|
|
228
|
+
* verified as such.
|
|
229
|
+
*
|
|
230
|
+
* @internal Technically exported for generated vocabulary classes, but not
|
|
231
|
+
* part of the public API contract. This is not considered public API for
|
|
232
|
+
* Semantic Versioning decisions.
|
|
233
|
+
*/
|
|
234
|
+
export function isPortableMode(
|
|
235
|
+
object: object,
|
|
236
|
+
options: { readonly verifyPortableObject?: PortableObjectVerifier },
|
|
237
|
+
): boolean {
|
|
238
|
+
return options.verifyPortableObject != null || isInPortableChain(object);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Checks whether an object embedded in a property of a vocabulary object has
|
|
243
|
+
* to be dereferenced by its `@id` and verified as a portable object rather
|
|
244
|
+
* than trusted as embedded, even with `crossOrigin: "trust"`. This is the
|
|
245
|
+
* case in {@link isPortableMode | portable mode} if its `@id` is an FEP-ef61
|
|
246
|
+
* compatible identifier, or a portable IRI whose DID differs from the
|
|
247
|
+
* owner's portable ID.
|
|
248
|
+
*
|
|
249
|
+
* @param parent The object whose property embeds the object.
|
|
250
|
+
* @param ownerId The ID of the object that vouches for embedded objects, or
|
|
251
|
+
* `null` if embedded objects are not trusted by their owner.
|
|
252
|
+
* @param id The `@id` of the embedded object.
|
|
253
|
+
* @param options The options of the accessor.
|
|
254
|
+
* @internal Technically exported for generated vocabulary classes, but not
|
|
255
|
+
* part of the public API contract. This is not considered public API for
|
|
256
|
+
* Semantic Versioning decisions.
|
|
257
|
+
*/
|
|
258
|
+
export function mustDereferencePortableObject(
|
|
259
|
+
parent: object,
|
|
260
|
+
ownerId: URL | null,
|
|
261
|
+
id: URL,
|
|
262
|
+
options: { readonly verifyPortableObject?: PortableObjectVerifier },
|
|
263
|
+
): boolean {
|
|
264
|
+
if (!isPortableMode(parent, options)) return false;
|
|
265
|
+
if (isCompatibleEf61Iri(id)) return true;
|
|
266
|
+
if (!isPortableIri(id)) return false;
|
|
267
|
+
return ownerId == null || !isPortableIri(ownerId) ||
|
|
268
|
+
!haveSameFe34Origin(ownerId, id);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const PORTABLE_IRI_PATTERN = /^ap(?:\+ef61)?:/i;
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The portable object that a document fetched as an ordinary HTTP(S)
|
|
275
|
+
* document claims to be.
|
|
276
|
+
*
|
|
277
|
+
* @internal
|
|
278
|
+
*/
|
|
279
|
+
export interface PortableResponseClaim {
|
|
280
|
+
/** The portable ID that the document has to have. */
|
|
281
|
+
readonly id: URL;
|
|
282
|
+
/**
|
|
283
|
+
* The gateways inferred from the document's URL or `@id`, which are
|
|
284
|
+
* reported to the verifier as gateway hints.
|
|
285
|
+
*/
|
|
286
|
+
readonly inferredGateways: readonly URL[];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Finds out whether a document fetched as an ordinary HTTP(S) document
|
|
291
|
+
* claims to be a portable object: its final URL is a portable IRI or an
|
|
292
|
+
* FEP-ef61 compatible identifier, or else its `@id` is. The final URL takes
|
|
293
|
+
* precedence, so a document whose `@id` claims another object fails the
|
|
294
|
+
* identity check against the final URL.
|
|
295
|
+
*
|
|
296
|
+
* @param documentUrl The final URL of the document.
|
|
297
|
+
* @param objectId The `@id` of the parsed document.
|
|
298
|
+
* @returns `undefined` if the document does not claim to be a portable
|
|
299
|
+
* object; `null` if the claim is malformed; otherwise, the claim.
|
|
300
|
+
* @internal Technically exported for generated vocabulary classes, but not
|
|
301
|
+
* part of the public API contract. This is not considered public API for
|
|
302
|
+
* Semantic Versioning decisions.
|
|
303
|
+
*/
|
|
304
|
+
export function getPortableResponseClaim(
|
|
305
|
+
documentUrl: string,
|
|
306
|
+
objectId: URL | null,
|
|
307
|
+
): PortableResponseClaim | null | undefined {
|
|
308
|
+
if (PORTABLE_IRI_PATTERN.test(documentUrl)) {
|
|
309
|
+
let id: URL;
|
|
310
|
+
try {
|
|
311
|
+
id = parseIri(documentUrl);
|
|
312
|
+
// URL parsing normalizes dot segments in the opaque path, which would
|
|
313
|
+
// make the parsed IRI identify another portable object:
|
|
314
|
+
if (
|
|
315
|
+
canonicalizePortableUri(documentUrl) !==
|
|
316
|
+
canonicalizePortableUri(formatIri(id))
|
|
317
|
+
) {
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
} catch (error) {
|
|
321
|
+
if (error instanceof TypeError) return null;
|
|
322
|
+
throw error;
|
|
323
|
+
}
|
|
324
|
+
return { id, inferredGateways: getPortableGatewayCandidates(id) };
|
|
325
|
+
}
|
|
326
|
+
for (const url of [documentUrl, objectId]) {
|
|
327
|
+
if (url == null) continue;
|
|
328
|
+
const compatible = parseCompatibleEf61Reference(url);
|
|
329
|
+
if (compatible === null) return null;
|
|
330
|
+
if (compatible != null) {
|
|
331
|
+
return { id: compatible.id, inferredGateways: [compatible.gateway] };
|
|
332
|
+
}
|
|
333
|
+
if (url instanceof URL && isPortableIri(url)) {
|
|
334
|
+
return { id: url, inferredGateways: getPortableGatewayCandidates(url) };
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return undefined;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Objects that were fetched as ordinary HTTP(S) objects although they stand
|
|
341
|
+
// for portable objects, i.e., without the portable object policy:
|
|
342
|
+
const unverifiedPortableClaims = new WeakSet<object>();
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Records that an object fetched as an ordinary HTTP(S) object stands for
|
|
346
|
+
* a portable object if its reference, its final URL, or its `@id` is
|
|
347
|
+
* a portable IRI or an FEP-ef61 compatible identifier. Accessors do not
|
|
348
|
+
* cache such objects in their parents, so that a later call with the
|
|
349
|
+
* `verifyPortableObject` option dereferences and verifies them.
|
|
350
|
+
*
|
|
351
|
+
* @internal Technically exported for generated vocabulary classes, but not
|
|
352
|
+
* part of the public API contract. This is not considered public API for
|
|
353
|
+
* Semantic Versioning decisions.
|
|
354
|
+
*/
|
|
355
|
+
export function markUnverifiedPortableClaim(
|
|
356
|
+
object: object,
|
|
357
|
+
reference: URL,
|
|
358
|
+
documentUrl: string,
|
|
359
|
+
): void {
|
|
360
|
+
const objectId = getObjectId(object);
|
|
361
|
+
if (
|
|
362
|
+
isPortableIri(reference) || isCompatibleEf61Iri(reference) ||
|
|
363
|
+
PORTABLE_IRI_PATTERN.test(documentUrl) ||
|
|
364
|
+
isCompatibleEf61Iri(documentUrl) ||
|
|
365
|
+
(objectId != null &&
|
|
366
|
+
(isPortableIri(objectId) || isCompatibleEf61Iri(objectId)))
|
|
367
|
+
) {
|
|
368
|
+
unverifiedPortableClaims.add(object);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Checks whether an object was fetched as an ordinary HTTP(S) object although
|
|
374
|
+
* it stands for a portable object. See {@link markUnverifiedPortableClaim}.
|
|
375
|
+
*
|
|
376
|
+
* @internal Technically exported for generated vocabulary classes, but not
|
|
377
|
+
* part of the public API contract. This is not considered public API for
|
|
378
|
+
* Semantic Versioning decisions.
|
|
379
|
+
*/
|
|
380
|
+
export function isUnverifiedPortableClaim(object: object): boolean {
|
|
381
|
+
return unverifiedPortableClaims.has(object);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Rejects a malformed FEP-ef61 compatible identifier found in a reference,
|
|
386
|
+
* the final URL of a fetched document, or its `@id`, when it would be
|
|
387
|
+
* dereferenced as a portable object. It is rejected without a request (or
|
|
388
|
+
* without returning the fetched document), since it cannot be verified as
|
|
389
|
+
* a portable object, and fetching it as an ordinary HTTP(S) object would
|
|
390
|
+
* skip the portable object policy.
|
|
391
|
+
*
|
|
392
|
+
* @param url The rejected URL, for the log message.
|
|
393
|
+
* @param options The options of the accessor.
|
|
394
|
+
* @returns Always `null`.
|
|
395
|
+
* @throws {Error} If `crossOrigin` is `"throw"` and `suppressError` is not
|
|
396
|
+
* set.
|
|
397
|
+
* @internal Technically exported for generated vocabulary classes, but not
|
|
398
|
+
* part of the public API contract. This is not considered public API for
|
|
399
|
+
* Semantic Versioning decisions.
|
|
400
|
+
*/
|
|
401
|
+
export function rejectMalformedCompatibleReference(
|
|
402
|
+
url: string,
|
|
403
|
+
options: {
|
|
404
|
+
readonly crossOrigin?: "ignore" | "throw" | "trust";
|
|
405
|
+
readonly suppressError?: boolean;
|
|
406
|
+
},
|
|
407
|
+
): null {
|
|
408
|
+
const message = "Refusing to dereference {url}, because it is a malformed " +
|
|
409
|
+
"FEP-ef61 compatible identifier, which cannot be verified as a portable " +
|
|
410
|
+
"object.";
|
|
411
|
+
if (options.crossOrigin === "throw" && !options.suppressError) {
|
|
412
|
+
throw new PortableObjectRejectedError(message.replace("{url}", url));
|
|
413
|
+
}
|
|
414
|
+
logger.warn(message, { url });
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Logs that an embedded object without `@id` in an object accepted without
|
|
420
|
+
* an integrity proof is dropped, because it cannot be dereferenced and
|
|
421
|
+
* verified on its own.
|
|
422
|
+
*
|
|
423
|
+
* @internal Technically exported for generated vocabulary classes, but not
|
|
424
|
+
* part of the public API contract. This is not considered public API for
|
|
425
|
+
* Semantic Versioning decisions.
|
|
426
|
+
*/
|
|
427
|
+
export function warnUnverifiableEmbeddedObject(
|
|
428
|
+
parent: object,
|
|
429
|
+
property: string,
|
|
430
|
+
): void {
|
|
431
|
+
logger.warn(
|
|
432
|
+
"Dropping an embedded object without @id in the {property} property of " +
|
|
433
|
+
"{parentId}, because the parent was accepted without an integrity " +
|
|
434
|
+
"proof and the embedded object cannot be verified on its own.",
|
|
435
|
+
{ property, parentId: getObjectId(parent)?.href ?? null },
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function buildReferrerChain(
|
|
440
|
+
link: PortableReferrerLink | undefined,
|
|
441
|
+
depth = 0,
|
|
442
|
+
): PortableObjectReferrer | undefined {
|
|
443
|
+
if (link == null) return undefined;
|
|
444
|
+
const provenance = provenances.get(link.object);
|
|
445
|
+
const base = {
|
|
446
|
+
object: link.object,
|
|
447
|
+
id: getObjectId(link.object),
|
|
448
|
+
property: link.property,
|
|
449
|
+
...(provenance?.acceptance == null
|
|
450
|
+
? {}
|
|
451
|
+
: { acceptance: provenance.acceptance }),
|
|
452
|
+
...(provenance?.collectionContext === undefined
|
|
453
|
+
? {}
|
|
454
|
+
: { collectionContext: provenance.collectionContext }),
|
|
455
|
+
};
|
|
456
|
+
if (provenance?.referrer == null) return base;
|
|
457
|
+
if (depth + 1 >= MAX_REFERRER_CHAIN_LENGTH) {
|
|
458
|
+
return { ...base, truncated: true };
|
|
459
|
+
}
|
|
460
|
+
return {
|
|
461
|
+
...base,
|
|
462
|
+
referrer: buildReferrerChain(provenance.referrer, depth + 1),
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
41
466
|
/**
|
|
42
467
|
* Checks whether a URL is an FEP-ef61 portable ActivityPub IRI.
|
|
43
468
|
*
|
|
@@ -130,14 +555,29 @@ function parseGatewayOrigin(gateway: string | URL): URL | null {
|
|
|
130
555
|
* The parsed object keeps the loader for its own later dereferences, so
|
|
131
556
|
* `release()` turns it into a plain pass-through to the underlying loader
|
|
132
557
|
* once the operation is over.
|
|
558
|
+
*
|
|
559
|
+
* @internal Technically exported for generated vocabulary classes, but not
|
|
560
|
+
* part of the public API contract. This is not considered public API for
|
|
561
|
+
* Semantic Versioning decisions.
|
|
133
562
|
*/
|
|
134
|
-
function createSnapshotContextLoader(
|
|
563
|
+
export function createSnapshotContextLoader(
|
|
135
564
|
contextLoader: DocumentLoader,
|
|
136
565
|
): { loader: DocumentLoader; release: () => void } {
|
|
566
|
+
// Objects parsed during a dereference keep its loader, so later
|
|
567
|
+
// dereferences from them would otherwise wrap released pass-throughs in
|
|
568
|
+
// ever deeper chains:
|
|
569
|
+
for (
|
|
570
|
+
let wrapped = snapshotLoaders.get(contextLoader);
|
|
571
|
+
wrapped?.released;
|
|
572
|
+
wrapped = snapshotLoaders.get(contextLoader)
|
|
573
|
+
) {
|
|
574
|
+
contextLoader = wrapped.base;
|
|
575
|
+
}
|
|
137
576
|
const cache = new Map<string, Promise<RemoteDocument>>();
|
|
138
577
|
let released = false;
|
|
139
578
|
const release = () => {
|
|
140
579
|
released = true;
|
|
580
|
+
state.released = true;
|
|
141
581
|
cache.clear();
|
|
142
582
|
};
|
|
143
583
|
const loader: DocumentLoader = async (url, options) => {
|
|
@@ -163,9 +603,16 @@ function createSnapshotContextLoader(
|
|
|
163
603
|
}
|
|
164
604
|
return structuredClone(await promise);
|
|
165
605
|
};
|
|
606
|
+
const state = { base: contextLoader, released: false };
|
|
607
|
+
snapshotLoaders.set(loader, state);
|
|
166
608
|
return { loader, release };
|
|
167
609
|
}
|
|
168
610
|
|
|
611
|
+
const snapshotLoaders = new WeakMap<
|
|
612
|
+
DocumentLoader,
|
|
613
|
+
{ readonly base: DocumentLoader; released: boolean }
|
|
614
|
+
>();
|
|
615
|
+
|
|
169
616
|
/**
|
|
170
617
|
* Options for {@link dereferencePortableIri}.
|
|
171
618
|
*
|
|
@@ -178,7 +625,22 @@ export interface DereferencePortableIriOptions<T> {
|
|
|
178
625
|
contextLoader: DocumentLoader;
|
|
179
626
|
tracerProvider: TracerProvider;
|
|
180
627
|
gateways?: readonly (string | URL)[];
|
|
628
|
+
/**
|
|
629
|
+
* The gateways inferred from where the reference came from, e.g., the
|
|
630
|
+
* gateway of a compatible identifier. When given, they replace the
|
|
631
|
+
* `@gateway` location hints of the IRI, are asked before the explicit
|
|
632
|
+
* `gateways`, and are reported to the verifier as gateway hints unless
|
|
633
|
+
* `gateways` is given.
|
|
634
|
+
*/
|
|
635
|
+
inferredGateways?: readonly URL[];
|
|
636
|
+
/**
|
|
637
|
+
* An already fetched document to validate instead of fetching the object
|
|
638
|
+
* from gateways. It is validated the same way as a gateway's response,
|
|
639
|
+
* and nothing else is tried if it is rejected.
|
|
640
|
+
*/
|
|
641
|
+
response?: RemoteDocument;
|
|
181
642
|
verifyPortableObject?: PortableObjectVerifier;
|
|
643
|
+
referrer?: PortableReferrerLink;
|
|
182
644
|
suppressError?: boolean;
|
|
183
645
|
crossOrigin?: "ignore" | "throw" | "trust";
|
|
184
646
|
/**
|
|
@@ -224,9 +686,10 @@ class PortableObjectRejection {
|
|
|
224
686
|
*
|
|
225
687
|
* Gateways are tried one by one until one of them returns a single-node
|
|
226
688
|
* document whose `@id`, if any, canonically matches the requested IRI and
|
|
227
|
-
* which passes `verifyPortableObject`. When there is no gateway to try, the
|
|
228
|
-
* loader is asked for the portable IRI itself, and its result is
|
|
229
|
-
* the same way.
|
|
689
|
+
* which passes `verifyPortableObject`. When there is no gateway to try, the
|
|
690
|
+
* document loader is asked for the portable IRI itself, and its result is
|
|
691
|
+
* validated the same way. If the `response` option is given, only that
|
|
692
|
+
* document is validated, and nothing is fetched.
|
|
230
693
|
*
|
|
231
694
|
* @returns The parsed object, or `null` if no valid object was retrieved and
|
|
232
695
|
* `suppressError` is set or the failure was a rejected object
|
|
@@ -246,7 +709,15 @@ export async function dereferencePortableIri<T extends { id: URL | null }>(
|
|
|
246
709
|
const lookupUrl = formatIri(url);
|
|
247
710
|
const { span } = options;
|
|
248
711
|
// Invalid gateways are a programming error, so they are never suppressed:
|
|
249
|
-
const
|
|
712
|
+
const explicitGateways = options.gateways == null
|
|
713
|
+
? undefined
|
|
714
|
+
: getPortableGatewayCandidates(url, options.gateways);
|
|
715
|
+
const inferredGateways = options.inferredGateways ??
|
|
716
|
+
(explicitGateways == null ? getPortableGatewayCandidates(url) : []);
|
|
717
|
+
const gateways = [...inferredGateways];
|
|
718
|
+
for (const gateway of explicitGateways ?? []) {
|
|
719
|
+
if (!gateways.some((g) => g.href === gateway.href)) gateways.push(gateway);
|
|
720
|
+
}
|
|
250
721
|
const fail = (error: unknown): null => {
|
|
251
722
|
span?.setStatus({ code: SpanStatusCode.ERROR, message: String(error) });
|
|
252
723
|
if (options.suppressError) {
|
|
@@ -268,12 +739,27 @@ export async function dereferencePortableIri<T extends { id: URL | null }>(
|
|
|
268
739
|
),
|
|
269
740
|
);
|
|
270
741
|
}
|
|
271
|
-
const requestUrls: {
|
|
742
|
+
const requestUrls: {
|
|
743
|
+
url: string;
|
|
744
|
+
gateway: URL | null;
|
|
745
|
+
response?: RemoteDocument;
|
|
746
|
+
}[] = [];
|
|
272
747
|
let expectedId: string;
|
|
273
748
|
try {
|
|
274
749
|
expectedId = canonicalizePortableUri(lookupUrl);
|
|
275
|
-
|
|
276
|
-
requestUrls.push({
|
|
750
|
+
if (options.response != null) {
|
|
751
|
+
requestUrls.push({
|
|
752
|
+
url: options.response.documentUrl,
|
|
753
|
+
gateway: null,
|
|
754
|
+
response: options.response,
|
|
755
|
+
});
|
|
756
|
+
} else {
|
|
757
|
+
for (const gateway of gateways) {
|
|
758
|
+
requestUrls.push({
|
|
759
|
+
url: toCompatibleEf61Id(url, gateway).href,
|
|
760
|
+
gateway,
|
|
761
|
+
});
|
|
762
|
+
}
|
|
277
763
|
}
|
|
278
764
|
} catch (error) {
|
|
279
765
|
return fail(error);
|
|
@@ -287,12 +773,16 @@ export async function dereferencePortableIri<T extends { id: URL | null }>(
|
|
|
287
773
|
const contextLoader = snapshot.loader;
|
|
288
774
|
const attempts: Attempt[] = [];
|
|
289
775
|
const { signal } = options;
|
|
776
|
+
const gatewayHints = explicitGateways == null && inferredGateways.length > 0
|
|
777
|
+
? inferredGateways
|
|
778
|
+
: undefined;
|
|
779
|
+
const referrer = buildReferrerChain(options.referrer);
|
|
290
780
|
try {
|
|
291
|
-
for (const { url: requestUrl, gateway } of requestUrls) {
|
|
781
|
+
for (const { url: requestUrl, gateway, response } of requestUrls) {
|
|
292
782
|
signal?.throwIfAborted();
|
|
293
783
|
let remoteDocument: RemoteDocument;
|
|
294
784
|
try {
|
|
295
|
-
remoteDocument = await options.documentLoader(
|
|
785
|
+
remoteDocument = response ?? await options.documentLoader(
|
|
296
786
|
requestUrl,
|
|
297
787
|
signal == null ? undefined : { signal },
|
|
298
788
|
);
|
|
@@ -309,12 +799,22 @@ export async function dereferencePortableIri<T extends { id: URL | null }>(
|
|
|
309
799
|
const { document } = remoteDocument;
|
|
310
800
|
try {
|
|
311
801
|
await checkPortableObjectId(document, expectedId, contextLoader);
|
|
312
|
-
let
|
|
802
|
+
let documentUrl: URL | undefined;
|
|
803
|
+
try {
|
|
804
|
+
documentUrl = parseIri(remoteDocument.documentUrl);
|
|
805
|
+
} catch {
|
|
806
|
+
documentUrl = undefined;
|
|
807
|
+
}
|
|
808
|
+
let result: PortableObjectVerification;
|
|
313
809
|
try {
|
|
314
810
|
result = await verify(document, {
|
|
315
811
|
documentLoader: options.documentLoader,
|
|
316
812
|
contextLoader,
|
|
317
813
|
tracerProvider: options.tracerProvider,
|
|
814
|
+
...(documentUrl == null ? {} : { documentUrl }),
|
|
815
|
+
...(explicitGateways == null ? {} : { gateways: explicitGateways }),
|
|
816
|
+
...(gatewayHints == null ? {} : { gatewayHints }),
|
|
817
|
+
...(referrer == null ? {} : { referrer }),
|
|
318
818
|
});
|
|
319
819
|
} catch (error) {
|
|
320
820
|
throw new PortableObjectRejection(
|
|
@@ -333,6 +833,15 @@ export async function dereferencePortableIri<T extends { id: URL | null }>(
|
|
|
333
833
|
baseUrl: url,
|
|
334
834
|
});
|
|
335
835
|
signal?.throwIfAborted();
|
|
836
|
+
if (object != null && typeof object === "object") {
|
|
837
|
+
provenances.set(object, {
|
|
838
|
+
acceptance: result.unsecured === true ? "unsecured" : "verified",
|
|
839
|
+
...(result.collectionContext === undefined
|
|
840
|
+
? {}
|
|
841
|
+
: { collectionContext: result.collectionContext }),
|
|
842
|
+
...(options.referrer == null ? {} : { referrer: options.referrer }),
|
|
843
|
+
});
|
|
844
|
+
}
|
|
336
845
|
if (gateway != null) {
|
|
337
846
|
span?.setAttribute("activitypub.gateway", gateway.href);
|
|
338
847
|
}
|
package/src/mod.ts
CHANGED