@fedify/vocab-runtime 2.4.0-dev.2063 → 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.
Files changed (31) hide show
  1. package/deno.json +1 -1
  2. package/dist/internal/portable-dereference.cjs +193 -21
  3. package/dist/internal/portable-dereference.d.cts +155 -15
  4. package/dist/internal/portable-dereference.d.ts +155 -15
  5. package/dist/internal/portable-dereference.js +187 -22
  6. package/dist/mod.cjs +1 -1
  7. package/dist/mod.d.cts +1 -1
  8. package/dist/mod.d.ts +1 -1
  9. package/dist/mod.js +1 -1
  10. package/dist/{portable--NcyA1R_.d.ts → portable-DH3947GK.d.ts} +6 -2
  11. package/dist/{portable-BykOD45s.d.cts → portable-comBI05Y.d.cts} +6 -2
  12. package/dist/tests/{body-BN5x-zDQ.mjs → body-CNO0jMV8.mjs} +1 -1
  13. package/dist/tests/{body-CTGqQhNI.cjs → body-DXHOazEl.cjs} +1 -1
  14. package/dist/tests/body.test.cjs +1 -1
  15. package/dist/tests/body.test.mjs +1 -1
  16. package/dist/tests/decimal.test.cjs +1 -1
  17. package/dist/tests/decimal.test.mjs +1 -1
  18. package/dist/tests/{docloader-JGSpG-3l.cjs → docloader-4Txefi7y.cjs} +2 -2
  19. package/dist/tests/{docloader-BFxwbqMx.mjs → docloader-C-nWtUWo.mjs} +2 -2
  20. package/dist/tests/docloader.test.cjs +2 -2
  21. package/dist/tests/docloader.test.mjs +2 -2
  22. package/dist/tests/internal/portable-dereference.test.cjs +87 -0
  23. package/dist/tests/internal/portable-dereference.test.mjs +87 -0
  24. package/dist/tests/{request-jWhPNG9V.cjs → request-BOLanqAd.cjs} +1 -1
  25. package/dist/tests/{request-BT9FBhk6.mjs → request-dpFdQKXr.mjs} +1 -1
  26. package/dist/tests/request.test.cjs +1 -1
  27. package/dist/tests/request.test.mjs +1 -1
  28. package/package.json +1 -1
  29. package/src/internal/portable-dereference.test.ts +32 -0
  30. package/src/internal/portable-dereference.ts +288 -28
  31. package/src/portable.ts +6 -2
@@ -16,6 +16,7 @@ import {
16
16
  canonicalizePortableUri,
17
17
  formatIri,
18
18
  fromCompatibleEf61Id,
19
+ haveSameFe34Origin,
19
20
  parseIri,
20
21
  toCompatibleEf61Id,
21
22
  } from "../url.ts";
@@ -185,30 +186,230 @@ export function copyPortableProvenance(from: object, to: object): void {
185
186
  }
186
187
 
187
188
  /**
188
- * Rejects a reference from a portable object that would be dereferenced as
189
- * an ordinary HTTP(S) object although it stands for a portable object, i.e.,
190
- * an FEP-ef61 compatible identifier, or a document whose final URL or `@id`
191
- * is a compatible identifier or a portable IRI. Such a document would skip
192
- * the portable object policy, so it is rejected regardless of the
193
- * `crossOrigin` option, except that `crossOrigin: "throw"` makes it throw.
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.
194
391
  *
195
392
  * @param url The rejected URL, for the log message.
196
- * @param crossOrigin The `crossOrigin` option of the accessor.
393
+ * @param options The options of the accessor.
197
394
  * @returns Always `null`.
198
- * @throws {Error} If `crossOrigin` is `"throw"`.
395
+ * @throws {Error} If `crossOrigin` is `"throw"` and `suppressError` is not
396
+ * set.
199
397
  * @internal Technically exported for generated vocabulary classes, but not
200
398
  * part of the public API contract. This is not considered public API for
201
399
  * Semantic Versioning decisions.
202
400
  */
203
- export function rejectPortableChainReference(
401
+ export function rejectMalformedCompatibleReference(
204
402
  url: string,
205
- crossOrigin: "ignore" | "throw" | "trust" | undefined,
403
+ options: {
404
+ readonly crossOrigin?: "ignore" | "throw" | "trust";
405
+ readonly suppressError?: boolean;
406
+ },
206
407
  ): null {
207
- const message = "Refusing to dereference {url} from a portable object as " +
208
- "an ordinary HTTP(S) object, because it stands for an FEP-ef61 portable " +
209
- "object that is not verified this way.";
210
- if (crossOrigin === "throw") {
211
- throw new Error(message.replace("{url}", url));
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));
212
413
  }
213
414
  logger.warn(message, { url });
214
415
  return null;
@@ -354,14 +555,29 @@ function parseGatewayOrigin(gateway: string | URL): URL | null {
354
555
  * The parsed object keeps the loader for its own later dereferences, so
355
556
  * `release()` turns it into a plain pass-through to the underlying loader
356
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.
357
562
  */
358
- function createSnapshotContextLoader(
563
+ export function createSnapshotContextLoader(
359
564
  contextLoader: DocumentLoader,
360
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
+ }
361
576
  const cache = new Map<string, Promise<RemoteDocument>>();
362
577
  let released = false;
363
578
  const release = () => {
364
579
  released = true;
580
+ state.released = true;
365
581
  cache.clear();
366
582
  };
367
583
  const loader: DocumentLoader = async (url, options) => {
@@ -387,9 +603,16 @@ function createSnapshotContextLoader(
387
603
  }
388
604
  return structuredClone(await promise);
389
605
  };
606
+ const state = { base: contextLoader, released: false };
607
+ snapshotLoaders.set(loader, state);
390
608
  return { loader, release };
391
609
  }
392
610
 
611
+ const snapshotLoaders = new WeakMap<
612
+ DocumentLoader,
613
+ { readonly base: DocumentLoader; released: boolean }
614
+ >();
615
+
393
616
  /**
394
617
  * Options for {@link dereferencePortableIri}.
395
618
  *
@@ -402,6 +625,20 @@ export interface DereferencePortableIriOptions<T> {
402
625
  contextLoader: DocumentLoader;
403
626
  tracerProvider: TracerProvider;
404
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;
405
642
  verifyPortableObject?: PortableObjectVerifier;
406
643
  referrer?: PortableReferrerLink;
407
644
  suppressError?: boolean;
@@ -449,9 +686,10 @@ class PortableObjectRejection {
449
686
  *
450
687
  * Gateways are tried one by one until one of them returns a single-node
451
688
  * document whose `@id`, if any, canonically matches the requested IRI and
452
- * which passes `verifyPortableObject`. When there is no gateway to try, the document
453
- * loader is asked for the portable IRI itself, and its result is validated
454
- * 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.
455
693
  *
456
694
  * @returns The parsed object, or `null` if no valid object was retrieved and
457
695
  * `suppressError` is set or the failure was a rejected object
@@ -471,7 +709,15 @@ export async function dereferencePortableIri<T extends { id: URL | null }>(
471
709
  const lookupUrl = formatIri(url);
472
710
  const { span } = options;
473
711
  // Invalid gateways are a programming error, so they are never suppressed:
474
- const gateways = getPortableGatewayCandidates(url, options.gateways);
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
+ }
475
721
  const fail = (error: unknown): null => {
476
722
  span?.setStatus({ code: SpanStatusCode.ERROR, message: String(error) });
477
723
  if (options.suppressError) {
@@ -493,12 +739,27 @@ export async function dereferencePortableIri<T extends { id: URL | null }>(
493
739
  ),
494
740
  );
495
741
  }
496
- const requestUrls: { url: string; gateway: URL | null }[] = [];
742
+ const requestUrls: {
743
+ url: string;
744
+ gateway: URL | null;
745
+ response?: RemoteDocument;
746
+ }[] = [];
497
747
  let expectedId: string;
498
748
  try {
499
749
  expectedId = canonicalizePortableUri(lookupUrl);
500
- for (const gateway of gateways) {
501
- requestUrls.push({ url: toCompatibleEf61Id(url, gateway).href, gateway });
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
+ }
502
763
  }
503
764
  } catch (error) {
504
765
  return fail(error);
@@ -512,17 +773,16 @@ export async function dereferencePortableIri<T extends { id: URL | null }>(
512
773
  const contextLoader = snapshot.loader;
513
774
  const attempts: Attempt[] = [];
514
775
  const { signal } = options;
515
- const explicitGateways = options.gateways == null ? undefined : gateways;
516
- const gatewayHints = options.gateways == null && gateways.length > 0
517
- ? gateways
776
+ const gatewayHints = explicitGateways == null && inferredGateways.length > 0
777
+ ? inferredGateways
518
778
  : undefined;
519
779
  const referrer = buildReferrerChain(options.referrer);
520
780
  try {
521
- for (const { url: requestUrl, gateway } of requestUrls) {
781
+ for (const { url: requestUrl, gateway, response } of requestUrls) {
522
782
  signal?.throwIfAborted();
523
783
  let remoteDocument: RemoteDocument;
524
784
  try {
525
- remoteDocument = await options.documentLoader(
785
+ remoteDocument = response ?? await options.documentLoader(
526
786
  requestUrl,
527
787
  signal == null ? undefined : { signal },
528
788
  );
package/src/portable.ts CHANGED
@@ -45,8 +45,12 @@ export interface PortableObjectVerifierOptions {
45
45
  readonly gateways?: readonly URL[];
46
46
 
47
47
  /**
48
- * The gateways taken from the `@gateway` location hints of the reference,
49
- * in order, when the caller did not pass the `gateways` option.
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.
50
54
  * @since 2.4.0
51
55
  */
52
56
  readonly gatewayHints?: readonly URL[];