@fedify/vocab 2.5.0-dev.2440 → 2.5.0-dev.2448

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fedify/vocab",
3
- "version": "2.5.0-dev.2440+635913e8",
3
+ "version": "2.5.0-dev.2448+756933d0",
4
4
  "license": "MIT",
5
5
  "exports": {
6
6
  ".": "./src/mod.ts"
package/dist/mod.cjs CHANGED
@@ -36,7 +36,7 @@ let _fedify_vocab_runtime_temporal = require("@fedify/vocab-runtime/temporal");
36
36
  let es_toolkit = require("es-toolkit");
37
37
  //#region deno.json
38
38
  var name = "@fedify/vocab";
39
- var version = "2.5.0-dev.2440+635913e8";
39
+ var version = "2.5.0-dev.2448+756933d0";
40
40
  //#endregion
41
41
  //#region src/type.ts
42
42
  function getTypeId(object) {
@@ -81571,6 +81571,20 @@ const logger = (0, _logtape_logtape.getLogger)([
81571
81571
  "lookup"
81572
81572
  ]);
81573
81573
  /**
81574
+ * Options for the {@link lookupObject} function.
81575
+ *
81576
+ * @since 0.2.0
81577
+ */
81578
+ /**
81579
+ * The key of the {@link LookupObjectOptions} property that observes how
81580
+ * {@link lookupObject} selects the object it returns.
81581
+ *
81582
+ * @internal Technically exported for other Fedify packages, but not part of
81583
+ * the public API contract. This is not considered public API for Semantic
81584
+ * Versioning decisions.
81585
+ */
81586
+ const lookupObserver = Symbol.for("@fedify/vocab/lookupObserver");
81587
+ /**
81574
81588
  * The maximum number of requests to FEP-ef61 gateways that a single
81575
81589
  * {@link lookupObject} call makes for portable objects. Gateways come from
81576
81590
  * possibly untrusted WebFinger responses and location hints, so they are
@@ -81690,8 +81704,13 @@ async function lookupObjectInternal(identifier, options = {}) {
81690
81704
  remoteDoc = await documentLoader(identifier.href, { signal: options.signal });
81691
81705
  } catch (error) {
81692
81706
  logger.debug("Failed to fetch remote document:\n{error}", { error });
81707
+ options[lookupObserver]?.failed?.({
81708
+ operation: "fetch",
81709
+ error
81710
+ });
81693
81711
  }
81694
81712
  }
81713
+ if (remoteDoc == null && options[lookupObserver]?.webFinger === false) return null;
81695
81714
  if (remoteDoc == null) {
81696
81715
  const jrd = await (0, _fedify_webfinger.lookupWebFinger)(identifier, {
81697
81716
  userAgent: options.userAgent,
@@ -81736,7 +81755,7 @@ async function lookupObjectInternal(identifier, options = {}) {
81736
81755
  }
81737
81756
  }
81738
81757
  if (remoteDoc == null) return null;
81739
- const snapshot = options.verifyPortableObject == null ? null : (0, _fedify_vocab_runtime_internal_portable_dereference.createSnapshotContextLoader)(options.contextLoader ?? (0, _fedify_vocab_runtime.getDocumentLoader)({ userAgent: options.userAgent }));
81758
+ const snapshot = options.verifyPortableObject == null && options[lookupObserver] == null ? null : (0, _fedify_vocab_runtime_internal_portable_dereference.createSnapshotContextLoader)(options.contextLoader ?? (0, _fedify_vocab_runtime.getDocumentLoader)({ userAgent: options.userAgent }));
81740
81759
  try {
81741
81760
  let object;
81742
81761
  let documentUrl;
@@ -81750,6 +81769,10 @@ async function lookupObjectInternal(identifier, options = {}) {
81750
81769
  baseUrl: documentUrl
81751
81770
  });
81752
81771
  } catch (error) {
81772
+ options[lookupObserver]?.failed?.({
81773
+ operation: "parse",
81774
+ error
81775
+ });
81753
81776
  if (error instanceof TypeError) {
81754
81777
  logger.debug("Failed to parse JSON-LD document: {error}\n{document}", {
81755
81778
  ...remoteDoc,
@@ -81759,7 +81782,7 @@ async function lookupObjectInternal(identifier, options = {}) {
81759
81782
  }
81760
81783
  throw error;
81761
81784
  }
81762
- if (snapshot != null) {
81785
+ if (options.verifyPortableObject != null && snapshot != null) {
81763
81786
  const claim = (0, _fedify_vocab_runtime_internal_portable_dereference.getPortableResponseClaim)(remoteDoc.documentUrl, object.id);
81764
81787
  if (claim === null) {
81765
81788
  logger.debug("Refusing the document {documentUrl}, as it claims to be a portable object with a malformed compatible identifier.", { documentUrl: remoteDoc.documentUrl });
@@ -81778,8 +81801,16 @@ async function lookupObjectInternal(identifier, options = {}) {
81778
81801
  ...remoteDoc,
81779
81802
  objectId: object.id.href
81780
81803
  });
81804
+ options[lookupObserver]?.failed?.({ operation: "origin" });
81781
81805
  return null;
81782
81806
  }
81807
+ if (!await (0, _fedify_vocab_runtime_internal_portable_dereference.acceptCandidate)(options[lookupObserver], {
81808
+ document: remoteDoc.document,
81809
+ documentUrl: remoteDoc.documentUrl,
81810
+ baseUrl: documentUrl,
81811
+ contextLoader: snapshot?.loader ?? options.contextLoader ?? (0, _fedify_vocab_runtime.getDocumentLoader)({ userAgent: options.userAgent }),
81812
+ object
81813
+ })) return null;
81783
81814
  return object;
81784
81815
  } finally {
81785
81816
  snapshot?.release();
@@ -81893,6 +81924,7 @@ async function dereference({ options, documentLoader }, id, inferredGateways, ga
81893
81924
  verifyPortableObject: options.verifyPortableObject,
81894
81925
  crossOrigin: options.crossOrigin === "throw" ? "throw" : "ignore",
81895
81926
  signal: options.signal,
81927
+ observer: options[lookupObserver],
81896
81928
  parse: (document, { contextLoader, baseUrl }) => Object$1.fromJsonLd(document, {
81897
81929
  documentLoader,
81898
81930
  contextLoader,
@@ -82043,6 +82075,7 @@ exports.isActor = isActor;
82043
82075
  exports.isEntityType = isEntityType;
82044
82076
  exports.isFediverseHandle = isFediverseHandle;
82045
82077
  exports.lookupObject = lookupObject;
82078
+ exports.lookupObserver = lookupObserver;
82046
82079
  exports.normalizeActorHandle = normalizeActorHandle;
82047
82080
  exports.normalizeLinkToImage = normalizeLinkToImage;
82048
82081
  exports.parseFediverseHandle = parseFediverseHandle;
package/dist/mod.d.cts CHANGED
@@ -1,6 +1,7 @@
1
1
  /// <reference lib="esnext.temporal" />
2
2
  import { Decimal, Decimal as Decimal$1, DocumentLoader, DocumentLoader as DocumentLoader$1, DocumentLoaderOptions, GetUserAgentOptions, GetUserAgentOptions as GetUserAgentOptions$1, Json, LanguageString, LanguageString as LanguageString$1, PortableObjectReferrer, PortableObjectVerification, PortableObjectVerifier, PortableObjectVerifier as PortableObjectVerifier$1, PortableObjectVerifierOptions, PropertyPreprocessor, PropertyPreprocessor as PropertyPreprocessor$1, PropertyPreprocessorContext, RemoteDocument } from "@fedify/vocab-runtime";
3
3
  import { MeterProvider, Span, TracerProvider } from "@opentelemetry/api";
4
+ import { DereferenceObserver } from "@fedify/vocab-runtime/internal/portable-dereference";
4
5
 
5
6
  //#region src/vocab.d.ts
6
7
  /** Describes an object of any kind. The Object type serves as the base type for
@@ -25673,6 +25674,34 @@ type ObjectLookupKind = "actor" | "object" | "other";
25673
25674
  *
25674
25675
  * @since 0.2.0
25675
25676
  */
25677
+ /**
25678
+ * The key of the {@link LookupObjectOptions} property that observes how
25679
+ * {@link lookupObject} selects the object it returns.
25680
+ *
25681
+ * @internal Technically exported for other Fedify packages, but not part of
25682
+ * the public API contract. This is not considered public API for Semantic
25683
+ * Versioning decisions.
25684
+ */
25685
+ declare const lookupObserver: unique symbol;
25686
+ /**
25687
+ * Observes how {@link lookupObject} selects the object it returns: each
25688
+ * candidate is offered to {@link DereferenceObserver.accept} at the boundary
25689
+ * that would otherwise return it, both for ordinary documents and for
25690
+ * FEP-ef61 portable objects, and each failure that does not propagate is
25691
+ * reported to {@link DereferenceObserver.failed}. The observer is not passed
25692
+ * on to the returned object.
25693
+ *
25694
+ * @internal Technically exported for other Fedify packages, but not part of
25695
+ * the public API contract. This is not considered public API for Semantic
25696
+ * Versioning decisions.
25697
+ */
25698
+ interface LookupObserver extends DereferenceObserver<Object$1> {
25699
+ /**
25700
+ * Whether to fall back to WebFinger when an HTTP(S) URL cannot be fetched.
25701
+ * Defaults to `true`.
25702
+ */
25703
+ readonly webFinger?: boolean;
25704
+ }
25676
25705
  interface LookupObjectOptions {
25677
25706
  /**
25678
25707
  * The document loader for loading remote JSON-LD documents.
@@ -25768,6 +25797,11 @@ interface LookupObjectOptions {
25768
25797
  * @since 2.4.0
25769
25798
  */
25770
25799
  verifyPortableObject?: PortableObjectVerifier$1;
25800
+ /**
25801
+ * See {@link LookupObserver}.
25802
+ * @internal
25803
+ */
25804
+ [lookupObserver]?: LookupObserver;
25771
25805
  }
25772
25806
  /**
25773
25807
  * Looks up an ActivityStreams object by its URI (including `acct:` URIs)
@@ -25992,4 +26026,4 @@ declare function getTypeId(object: Object$1 | Link | null): URL | null;
25992
26026
  */
25993
26027
  declare function getTypeId(object: Object$1 | Link | null | undefined): URL | null | undefined;
25994
26028
  //#endregion
25995
- export { $EntityType, Accept, Activity, Actor, ActorDiscoveryResult, ActorTypeName, Add, Agreement, Announce, AnnounceAuthorization, AnnounceRequest, Application, Arrive, Article, Audio, Block, ChatMessage, Collection, CollectionPage, Commitment, Create, CryptographicKey, DataIntegrityProof, type Decimal, Delete, DidService, Dislike, Document, type DocumentLoader, type DocumentLoaderOptions, Emoji, EmojiReact, Endpoints, Event, Export, FeatureAuthorization, FeatureRequest, FeaturedCollection, FeaturedItem, FediverseHandle, Flag, Follow, GetActorHandleOptions, type GetUserAgentOptions, Group, Hashtag, Ignore, Image, Intent, InteractionPolicy, InteractionRule, IntransitiveActivity, Invite, Join, type Json, LanguageString, Leave, Like, LikeAuthorization, LikeRequest, Link, Listen, LookupObjectOptions, Measure, Mention, Move, Multikey, NormalizeActorHandleOptions, Note, Object$1 as Object, ObjectLookupKind, Offer, OrderedCollection, OrderedCollectionPage, Organization, PUBLIC_COLLECTION, Page, Person, Place, type PortableObjectReferrer, type PortableObjectVerification, type PortableObjectVerifier, type PortableObjectVerifierOptions, Profile, type PropertyPreprocessor, type PropertyPreprocessorContext, PropertyValue, Proposal, Question, QuoteAuthorization, QuoteRequest, Read, Recipient, Reject, Relationship, type RemoteDocument, Remove, ReplyAuthorization, ReplyRequest, Service, Source, TentativeAccept, TentativeReject, Tombstone, Translation, Travel, TraverseCollectionOptions, Undo, Update, Video, View, getActorClassByTypeName, getActorHandle, getActorTypeName, getEntityTypeById, getTypeId, isActor, isEntityType, isFediverseHandle, lookupObject, normalizeActorHandle, normalizeLinkToImage, parseFediverseHandle, toAcctUrl, traverseCollection };
26029
+ export { $EntityType, Accept, Activity, Actor, ActorDiscoveryResult, ActorTypeName, Add, Agreement, Announce, AnnounceAuthorization, AnnounceRequest, Application, Arrive, Article, Audio, Block, ChatMessage, Collection, CollectionPage, Commitment, Create, CryptographicKey, DataIntegrityProof, type Decimal, Delete, DidService, Dislike, Document, type DocumentLoader, type DocumentLoaderOptions, Emoji, EmojiReact, Endpoints, Event, Export, FeatureAuthorization, FeatureRequest, FeaturedCollection, FeaturedItem, FediverseHandle, Flag, Follow, GetActorHandleOptions, type GetUserAgentOptions, Group, Hashtag, Ignore, Image, Intent, InteractionPolicy, InteractionRule, IntransitiveActivity, Invite, Join, type Json, LanguageString, Leave, Like, LikeAuthorization, LikeRequest, Link, Listen, LookupObjectOptions, LookupObserver, Measure, Mention, Move, Multikey, NormalizeActorHandleOptions, Note, Object$1 as Object, ObjectLookupKind, Offer, OrderedCollection, OrderedCollectionPage, Organization, PUBLIC_COLLECTION, Page, Person, Place, type PortableObjectReferrer, type PortableObjectVerification, type PortableObjectVerifier, type PortableObjectVerifierOptions, Profile, type PropertyPreprocessor, type PropertyPreprocessorContext, PropertyValue, Proposal, Question, QuoteAuthorization, QuoteRequest, Read, Recipient, Reject, Relationship, type RemoteDocument, Remove, ReplyAuthorization, ReplyRequest, Service, Source, TentativeAccept, TentativeReject, Tombstone, Translation, Travel, TraverseCollectionOptions, Undo, Update, Video, View, getActorClassByTypeName, getActorHandle, getActorTypeName, getEntityTypeById, getTypeId, isActor, isEntityType, isFediverseHandle, lookupObject, lookupObserver, normalizeActorHandle, normalizeLinkToImage, parseFediverseHandle, toAcctUrl, traverseCollection };
package/dist/mod.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /// <reference lib="esnext.temporal" />
2
2
  import { Decimal, Decimal as Decimal$1, DocumentLoader, DocumentLoader as DocumentLoader$1, DocumentLoaderOptions, GetUserAgentOptions, GetUserAgentOptions as GetUserAgentOptions$1, Json, LanguageString, LanguageString as LanguageString$1, PortableObjectReferrer, PortableObjectVerification, PortableObjectVerifier, PortableObjectVerifier as PortableObjectVerifier$1, PortableObjectVerifierOptions, PropertyPreprocessor, PropertyPreprocessor as PropertyPreprocessor$1, PropertyPreprocessorContext, RemoteDocument } from "@fedify/vocab-runtime";
3
3
  import { MeterProvider, Span, TracerProvider } from "@opentelemetry/api";
4
+ import { DereferenceObserver } from "@fedify/vocab-runtime/internal/portable-dereference";
4
5
 
5
6
  //#region src/vocab.d.ts
6
7
  /** Describes an object of any kind. The Object type serves as the base type for
@@ -25673,6 +25674,34 @@ type ObjectLookupKind = "actor" | "object" | "other";
25673
25674
  *
25674
25675
  * @since 0.2.0
25675
25676
  */
25677
+ /**
25678
+ * The key of the {@link LookupObjectOptions} property that observes how
25679
+ * {@link lookupObject} selects the object it returns.
25680
+ *
25681
+ * @internal Technically exported for other Fedify packages, but not part of
25682
+ * the public API contract. This is not considered public API for Semantic
25683
+ * Versioning decisions.
25684
+ */
25685
+ declare const lookupObserver: unique symbol;
25686
+ /**
25687
+ * Observes how {@link lookupObject} selects the object it returns: each
25688
+ * candidate is offered to {@link DereferenceObserver.accept} at the boundary
25689
+ * that would otherwise return it, both for ordinary documents and for
25690
+ * FEP-ef61 portable objects, and each failure that does not propagate is
25691
+ * reported to {@link DereferenceObserver.failed}. The observer is not passed
25692
+ * on to the returned object.
25693
+ *
25694
+ * @internal Technically exported for other Fedify packages, but not part of
25695
+ * the public API contract. This is not considered public API for Semantic
25696
+ * Versioning decisions.
25697
+ */
25698
+ interface LookupObserver extends DereferenceObserver<Object$1> {
25699
+ /**
25700
+ * Whether to fall back to WebFinger when an HTTP(S) URL cannot be fetched.
25701
+ * Defaults to `true`.
25702
+ */
25703
+ readonly webFinger?: boolean;
25704
+ }
25676
25705
  interface LookupObjectOptions {
25677
25706
  /**
25678
25707
  * The document loader for loading remote JSON-LD documents.
@@ -25768,6 +25797,11 @@ interface LookupObjectOptions {
25768
25797
  * @since 2.4.0
25769
25798
  */
25770
25799
  verifyPortableObject?: PortableObjectVerifier$1;
25800
+ /**
25801
+ * See {@link LookupObserver}.
25802
+ * @internal
25803
+ */
25804
+ [lookupObserver]?: LookupObserver;
25771
25805
  }
25772
25806
  /**
25773
25807
  * Looks up an ActivityStreams object by its URI (including `acct:` URIs)
@@ -25992,4 +26026,4 @@ declare function getTypeId(object: Object$1 | Link | null): URL | null;
25992
26026
  */
25993
26027
  declare function getTypeId(object: Object$1 | Link | null | undefined): URL | null | undefined;
25994
26028
  //#endregion
25995
- export { $EntityType, Accept, Activity, Actor, ActorDiscoveryResult, ActorTypeName, Add, Agreement, Announce, AnnounceAuthorization, AnnounceRequest, Application, Arrive, Article, Audio, Block, ChatMessage, Collection, CollectionPage, Commitment, Create, CryptographicKey, DataIntegrityProof, type Decimal, Delete, DidService, Dislike, Document, type DocumentLoader, type DocumentLoaderOptions, Emoji, EmojiReact, Endpoints, Event, Export, FeatureAuthorization, FeatureRequest, FeaturedCollection, FeaturedItem, FediverseHandle, Flag, Follow, GetActorHandleOptions, type GetUserAgentOptions, Group, Hashtag, Ignore, Image, Intent, InteractionPolicy, InteractionRule, IntransitiveActivity, Invite, Join, type Json, LanguageString, Leave, Like, LikeAuthorization, LikeRequest, Link, Listen, LookupObjectOptions, Measure, Mention, Move, Multikey, NormalizeActorHandleOptions, Note, Object$1 as Object, ObjectLookupKind, Offer, OrderedCollection, OrderedCollectionPage, Organization, PUBLIC_COLLECTION, Page, Person, Place, type PortableObjectReferrer, type PortableObjectVerification, type PortableObjectVerifier, type PortableObjectVerifierOptions, Profile, type PropertyPreprocessor, type PropertyPreprocessorContext, PropertyValue, Proposal, Question, QuoteAuthorization, QuoteRequest, Read, Recipient, Reject, Relationship, type RemoteDocument, Remove, ReplyAuthorization, ReplyRequest, Service, Source, TentativeAccept, TentativeReject, Tombstone, Translation, Travel, TraverseCollectionOptions, Undo, Update, Video, View, getActorClassByTypeName, getActorHandle, getActorTypeName, getEntityTypeById, getTypeId, isActor, isEntityType, isFediverseHandle, lookupObject, normalizeActorHandle, normalizeLinkToImage, parseFediverseHandle, toAcctUrl, traverseCollection };
26029
+ export { $EntityType, Accept, Activity, Actor, ActorDiscoveryResult, ActorTypeName, Add, Agreement, Announce, AnnounceAuthorization, AnnounceRequest, Application, Arrive, Article, Audio, Block, ChatMessage, Collection, CollectionPage, Commitment, Create, CryptographicKey, DataIntegrityProof, type Decimal, Delete, DidService, Dislike, Document, type DocumentLoader, type DocumentLoaderOptions, Emoji, EmojiReact, Endpoints, Event, Export, FeatureAuthorization, FeatureRequest, FeaturedCollection, FeaturedItem, FediverseHandle, Flag, Follow, GetActorHandleOptions, type GetUserAgentOptions, Group, Hashtag, Ignore, Image, Intent, InteractionPolicy, InteractionRule, IntransitiveActivity, Invite, Join, type Json, LanguageString, Leave, Like, LikeAuthorization, LikeRequest, Link, Listen, LookupObjectOptions, LookupObserver, Measure, Mention, Move, Multikey, NormalizeActorHandleOptions, Note, Object$1 as Object, ObjectLookupKind, Offer, OrderedCollection, OrderedCollectionPage, Organization, PUBLIC_COLLECTION, Page, Person, Place, type PortableObjectReferrer, type PortableObjectVerification, type PortableObjectVerifier, type PortableObjectVerifierOptions, Profile, type PropertyPreprocessor, type PropertyPreprocessorContext, PropertyValue, Proposal, Question, QuoteAuthorization, QuoteRequest, Read, Recipient, Reject, Relationship, type RemoteDocument, Remove, ReplyAuthorization, ReplyRequest, Service, Source, TentativeAccept, TentativeReject, Tombstone, Translation, Travel, TraverseCollectionOptions, Undo, Update, Video, View, getActorClassByTypeName, getActorHandle, getActorTypeName, getEntityTypeById, getTypeId, isActor, isEntityType, isFediverseHandle, lookupObject, lookupObserver, normalizeActorHandle, normalizeLinkToImage, parseFediverseHandle, toAcctUrl, traverseCollection };
package/dist/mod.js CHANGED
@@ -6,13 +6,13 @@ import { domainToASCII, domainToUnicode } from "node:url";
6
6
  import jsonld from "@fedify/vocab-runtime/jsonld";
7
7
  import { getLogger } from "@logtape/logtape";
8
8
  import { compactJsonLdCache, createScopedContextLoader, getPortableActorGateways, isTrustedIriOrigin, normalizeJsonLdIris } from "@fedify/vocab-runtime/internal/jsonld-cache";
9
- import { PortableObjectRejectedError, copyPortableProvenance, createSnapshotContextLoader, dereferencePortableIri, getPortableGatewayCandidates, getPortableResponseClaim, getReferrerGateways, isPortableIri, isPortableMode, isUnsecuredPortableObject, isUnverifiedPortableClaim, markUnverifiedPortableClaim, mustDereferencePortableObject, parseCompatibleEf61Reference, recordPortableReferrer, rejectMalformedCompatibleReference, warnUnverifiableEmbeddedObject } from "@fedify/vocab-runtime/internal/portable-dereference";
9
+ import { PortableObjectRejectedError, acceptCandidate, copyPortableProvenance, createSnapshotContextLoader, dereferencePortableIri, getPortableGatewayCandidates, getPortableResponseClaim, getReferrerGateways, isPortableIri, isPortableMode, isUnsecuredPortableObject, isUnverifiedPortableClaim, markUnverifiedPortableClaim, mustDereferencePortableObject, parseCompatibleEf61Reference, recordPortableReferrer, rejectMalformedCompatibleReference, warnUnverifiableEmbeddedObject } from "@fedify/vocab-runtime/internal/portable-dereference";
10
10
  import { enterSignedValueScope, resolveSignedValues, retainedSignedValueRef } from "@fedify/vocab-runtime/internal/signed-representation";
11
11
  import { isTemporalDuration, isTemporalInstant } from "@fedify/vocab-runtime/temporal";
12
12
  import { delay } from "es-toolkit";
13
13
  //#region deno.json
14
14
  var name = "@fedify/vocab";
15
- var version = "2.5.0-dev.2440+635913e8";
15
+ var version = "2.5.0-dev.2448+756933d0";
16
16
  //#endregion
17
17
  //#region src/type.ts
18
18
  function getTypeId(object) {
@@ -77981,6 +77981,20 @@ const logger = getLogger([
77981
77981
  "lookup"
77982
77982
  ]);
77983
77983
  /**
77984
+ * Options for the {@link lookupObject} function.
77985
+ *
77986
+ * @since 0.2.0
77987
+ */
77988
+ /**
77989
+ * The key of the {@link LookupObjectOptions} property that observes how
77990
+ * {@link lookupObject} selects the object it returns.
77991
+ *
77992
+ * @internal Technically exported for other Fedify packages, but not part of
77993
+ * the public API contract. This is not considered public API for Semantic
77994
+ * Versioning decisions.
77995
+ */
77996
+ const lookupObserver = Symbol.for("@fedify/vocab/lookupObserver");
77997
+ /**
77984
77998
  * The maximum number of requests to FEP-ef61 gateways that a single
77985
77999
  * {@link lookupObject} call makes for portable objects. Gateways come from
77986
78000
  * possibly untrusted WebFinger responses and location hints, so they are
@@ -78100,8 +78114,13 @@ async function lookupObjectInternal(identifier, options = {}) {
78100
78114
  remoteDoc = await documentLoader(identifier.href, { signal: options.signal });
78101
78115
  } catch (error) {
78102
78116
  logger.debug("Failed to fetch remote document:\n{error}", { error });
78117
+ options[lookupObserver]?.failed?.({
78118
+ operation: "fetch",
78119
+ error
78120
+ });
78103
78121
  }
78104
78122
  }
78123
+ if (remoteDoc == null && options[lookupObserver]?.webFinger === false) return null;
78105
78124
  if (remoteDoc == null) {
78106
78125
  const jrd = await lookupWebFinger(identifier, {
78107
78126
  userAgent: options.userAgent,
@@ -78146,7 +78165,7 @@ async function lookupObjectInternal(identifier, options = {}) {
78146
78165
  }
78147
78166
  }
78148
78167
  if (remoteDoc == null) return null;
78149
- const snapshot = options.verifyPortableObject == null ? null : createSnapshotContextLoader(options.contextLoader ?? getDocumentLoader({ userAgent: options.userAgent }));
78168
+ const snapshot = options.verifyPortableObject == null && options[lookupObserver] == null ? null : createSnapshotContextLoader(options.contextLoader ?? getDocumentLoader({ userAgent: options.userAgent }));
78150
78169
  try {
78151
78170
  let object;
78152
78171
  let documentUrl;
@@ -78160,6 +78179,10 @@ async function lookupObjectInternal(identifier, options = {}) {
78160
78179
  baseUrl: documentUrl
78161
78180
  });
78162
78181
  } catch (error) {
78182
+ options[lookupObserver]?.failed?.({
78183
+ operation: "parse",
78184
+ error
78185
+ });
78163
78186
  if (error instanceof TypeError) {
78164
78187
  logger.debug("Failed to parse JSON-LD document: {error}\n{document}", {
78165
78188
  ...remoteDoc,
@@ -78169,7 +78192,7 @@ async function lookupObjectInternal(identifier, options = {}) {
78169
78192
  }
78170
78193
  throw error;
78171
78194
  }
78172
- if (snapshot != null) {
78195
+ if (options.verifyPortableObject != null && snapshot != null) {
78173
78196
  const claim = getPortableResponseClaim(remoteDoc.documentUrl, object.id);
78174
78197
  if (claim === null) {
78175
78198
  logger.debug("Refusing the document {documentUrl}, as it claims to be a portable object with a malformed compatible identifier.", { documentUrl: remoteDoc.documentUrl });
@@ -78188,8 +78211,16 @@ async function lookupObjectInternal(identifier, options = {}) {
78188
78211
  ...remoteDoc,
78189
78212
  objectId: object.id.href
78190
78213
  });
78214
+ options[lookupObserver]?.failed?.({ operation: "origin" });
78191
78215
  return null;
78192
78216
  }
78217
+ if (!await acceptCandidate(options[lookupObserver], {
78218
+ document: remoteDoc.document,
78219
+ documentUrl: remoteDoc.documentUrl,
78220
+ baseUrl: documentUrl,
78221
+ contextLoader: snapshot?.loader ?? options.contextLoader ?? getDocumentLoader({ userAgent: options.userAgent }),
78222
+ object
78223
+ })) return null;
78193
78224
  return object;
78194
78225
  } finally {
78195
78226
  snapshot?.release();
@@ -78303,6 +78334,7 @@ async function dereference({ options, documentLoader }, id, inferredGateways, ga
78303
78334
  verifyPortableObject: options.verifyPortableObject,
78304
78335
  crossOrigin: options.crossOrigin === "throw" ? "throw" : "ignore",
78305
78336
  signal: options.signal,
78337
+ observer: options[lookupObserver],
78306
78338
  parse: (document, { contextLoader, baseUrl }) => Object$1.fromJsonLd(document, {
78307
78339
  documentLoader,
78308
78340
  contextLoader,
@@ -78351,4 +78383,4 @@ async function* traverseCollection(collection, options = {}) {
78351
78383
  }
78352
78384
  }
78353
78385
  //#endregion
78354
- export { Accept, Activity, Add, Agreement, Announce, AnnounceAuthorization, AnnounceRequest, Application, Arrive, Article, Audio, Block, ChatMessage, Collection, CollectionPage, Commitment, Create, CryptographicKey, DataIntegrityProof, Delete, DidService, Dislike, Document, Emoji, EmojiReact, Endpoints, Event, Export, FeatureAuthorization, FeatureRequest, FeaturedCollection, FeaturedItem, Flag, Follow, Group, Hashtag, Ignore, Image, Intent, InteractionPolicy, InteractionRule, IntransitiveActivity, Invite, Join, LanguageString, Leave, Like, LikeAuthorization, LikeRequest, Link, Listen, Measure, Mention, Move, Multikey, Note, Object$1 as Object, Offer, OrderedCollection, OrderedCollectionPage, Organization, PUBLIC_COLLECTION, Page, Person, Place, Profile, PropertyValue, Proposal, Question, QuoteAuthorization, QuoteRequest, Read, Reject, Relationship, Remove, ReplyAuthorization, ReplyRequest, Service, Source, TentativeAccept, TentativeReject, Tombstone, Translation, Travel, Undo, Update, Video, View, getActorClassByTypeName, getActorHandle, getActorTypeName, getEntityTypeById, getTypeId, isActor, isEntityType, isFediverseHandle, lookupObject, normalizeActorHandle, normalizeLinkToImage, parseFediverseHandle, toAcctUrl, traverseCollection };
78386
+ export { Accept, Activity, Add, Agreement, Announce, AnnounceAuthorization, AnnounceRequest, Application, Arrive, Article, Audio, Block, ChatMessage, Collection, CollectionPage, Commitment, Create, CryptographicKey, DataIntegrityProof, Delete, DidService, Dislike, Document, Emoji, EmojiReact, Endpoints, Event, Export, FeatureAuthorization, FeatureRequest, FeaturedCollection, FeaturedItem, Flag, Follow, Group, Hashtag, Ignore, Image, Intent, InteractionPolicy, InteractionRule, IntransitiveActivity, Invite, Join, LanguageString, Leave, Like, LikeAuthorization, LikeRequest, Link, Listen, Measure, Mention, Move, Multikey, Note, Object$1 as Object, Offer, OrderedCollection, OrderedCollectionPage, Organization, PUBLIC_COLLECTION, Page, Person, Place, Profile, PropertyValue, Proposal, Question, QuoteAuthorization, QuoteRequest, Read, Reject, Relationship, Remove, ReplyAuthorization, ReplyRequest, Service, Source, TentativeAccept, TentativeReject, Tombstone, Translation, Travel, Undo, Update, Video, View, getActorClassByTypeName, getActorHandle, getActorTypeName, getEntityTypeById, getTypeId, isActor, isEntityType, isFediverseHandle, lookupObject, lookupObserver, normalizeActorHandle, normalizeLinkToImage, parseFediverseHandle, toAcctUrl, traverseCollection };
@@ -8,7 +8,7 @@ import { SpanStatusCode, trace } from "@opentelemetry/api";
8
8
  import { domainToASCII, domainToUnicode } from "node:url";
9
9
  //#region deno.json
10
10
  var name = "@fedify/vocab";
11
- var version = "2.5.0-dev.2440+635913e8";
11
+ var version = "2.5.0-dev.2448+756933d0";
12
12
  //#endregion
13
13
  //#region src/actor.ts
14
14
  const ACTOR_DISCOVERY_HISTOGRAM_BUCKETS = [
@@ -2,7 +2,7 @@ import "temporal-polyfill";
2
2
  globalThis.addEventListener = () => {};
3
3
  import { $ as __exportAll, F as Person, P as Organization, U as Service, a as Application, b as Group } from "./vocab-jHKm_LJ6.mjs";
4
4
  import { t as esm_default } from "./esm-DtAfYdqR.mjs";
5
- import { a as normalizeActorHandle, i as isActor, n as getActorHandle, r as getActorTypeName, t as getActorClassByTypeName } from "./actor-D3VGPuRW.mjs";
5
+ import { a as normalizeActorHandle, i as isActor, n as getActorHandle, r as getActorTypeName, t as getActorClassByTypeName } from "./actor-C93GLWk5.mjs";
6
6
  import { createTestMeterProvider, test } from "@fedify/fixture";
7
7
  import { deepStrictEqual, ok, rejects, strictEqual, throws } from "node:assert/strict";
8
8
  //#region ../../node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/precondition/PreconditionFailure.js
@@ -2,7 +2,7 @@ import "temporal-polyfill";
2
2
  globalThis.addEventListener = () => {};
3
3
  import { F as Person, M as OrderedCollection, S as Image, c as CollectionPage, k as Note, s as Collection, u as Create } from "./vocab-jHKm_LJ6.mjs";
4
4
  import { t as esm_default } from "./esm-DtAfYdqR.mjs";
5
- import { n as traverseCollection, t as lookupObject } from "./lookup-DpG_W_SR.mjs";
5
+ import { r as traverseCollection, t as lookupObject } from "./lookup-C4i0dNeY.mjs";
6
6
  import { t as assertInstanceOf } from "./utils-Df2odC27.mjs";
7
7
  import { createTestTracerProvider, mockDocumentLoader, test } from "@fedify/fixture";
8
8
  import { deepStrictEqual, ok, rejects } from "node:assert/strict";
@@ -1,7 +1,7 @@
1
1
  import "temporal-polyfill";
2
2
  globalThis.addEventListener = () => {};
3
3
  import { E as Link, F as Person, J as Video, P as Organization, S as Image, U as Service, a as Application, b as Group, k as Note, s as Collection, u as Create, z as Question } from "./vocab-jHKm_LJ6.mjs";
4
- import "./src-CkIGHS1n.mjs";
4
+ import "./src-Dn87CPDV.mjs";
5
5
  import { test } from "@fedify/fixture";
6
6
  import { deepStrictEqual, ok } from "node:assert/strict";
7
7
  import { getDocumentLoader, preloadedContexts } from "@fedify/vocab-runtime";
@@ -1,13 +1,13 @@
1
1
  import { Temporal } from "temporal-polyfill";
2
2
  globalThis.addEventListener = () => {};
3
3
  import { A as Object$1 } from "./vocab-jHKm_LJ6.mjs";
4
- import { i as isActor, o as name, s as version } from "./actor-D3VGPuRW.mjs";
4
+ import { i as isActor, o as name, s as version } from "./actor-C93GLWk5.mjs";
5
5
  import { t as getTypeId } from "./type-BMdjm4Eg.mjs";
6
6
  import { canonicalizePortableUri, formatIri, getDocumentLoader, haveSameFe34Origin, haveSameIriOrigin, parseIri } from "@fedify/vocab-runtime";
7
7
  import { lookupWebFinger } from "@fedify/webfinger";
8
8
  import { SpanStatusCode, trace } from "@opentelemetry/api";
9
9
  import { getLogger } from "@logtape/logtape";
10
- import { PortableObjectRejectedError, createSnapshotContextLoader, dereferencePortableIri, getPortableGatewayCandidates, getPortableResponseClaim, isPortableIri, parseCompatibleEf61Reference } from "@fedify/vocab-runtime/internal/portable-dereference";
10
+ import { PortableObjectRejectedError, acceptCandidate, createSnapshotContextLoader, dereferencePortableIri, getPortableGatewayCandidates, getPortableResponseClaim, isPortableIri, parseCompatibleEf61Reference } from "@fedify/vocab-runtime/internal/portable-dereference";
11
11
  import { delay } from "es-toolkit";
12
12
  //#region src/handle.ts
13
13
  /**
@@ -107,6 +107,20 @@ const logger = getLogger([
107
107
  "lookup"
108
108
  ]);
109
109
  /**
110
+ * Options for the {@link lookupObject} function.
111
+ *
112
+ * @since 0.2.0
113
+ */
114
+ /**
115
+ * The key of the {@link LookupObjectOptions} property that observes how
116
+ * {@link lookupObject} selects the object it returns.
117
+ *
118
+ * @internal Technically exported for other Fedify packages, but not part of
119
+ * the public API contract. This is not considered public API for Semantic
120
+ * Versioning decisions.
121
+ */
122
+ const lookupObserver = Symbol.for("@fedify/vocab/lookupObserver");
123
+ /**
110
124
  * The maximum number of requests to FEP-ef61 gateways that a single
111
125
  * {@link lookupObject} call makes for portable objects. Gateways come from
112
126
  * possibly untrusted WebFinger responses and location hints, so they are
@@ -226,8 +240,13 @@ async function lookupObjectInternal(identifier, options = {}) {
226
240
  remoteDoc = await documentLoader(identifier.href, { signal: options.signal });
227
241
  } catch (error) {
228
242
  logger.debug("Failed to fetch remote document:\n{error}", { error });
243
+ options[lookupObserver]?.failed?.({
244
+ operation: "fetch",
245
+ error
246
+ });
229
247
  }
230
248
  }
249
+ if (remoteDoc == null && options[lookupObserver]?.webFinger === false) return null;
231
250
  if (remoteDoc == null) {
232
251
  const jrd = await lookupWebFinger(identifier, {
233
252
  userAgent: options.userAgent,
@@ -272,7 +291,7 @@ async function lookupObjectInternal(identifier, options = {}) {
272
291
  }
273
292
  }
274
293
  if (remoteDoc == null) return null;
275
- const snapshot = options.verifyPortableObject == null ? null : createSnapshotContextLoader(options.contextLoader ?? getDocumentLoader({ userAgent: options.userAgent }));
294
+ const snapshot = options.verifyPortableObject == null && options[lookupObserver] == null ? null : createSnapshotContextLoader(options.contextLoader ?? getDocumentLoader({ userAgent: options.userAgent }));
276
295
  try {
277
296
  let object;
278
297
  let documentUrl;
@@ -286,6 +305,10 @@ async function lookupObjectInternal(identifier, options = {}) {
286
305
  baseUrl: documentUrl
287
306
  });
288
307
  } catch (error) {
308
+ options[lookupObserver]?.failed?.({
309
+ operation: "parse",
310
+ error
311
+ });
289
312
  if (error instanceof TypeError) {
290
313
  logger.debug("Failed to parse JSON-LD document: {error}\n{document}", {
291
314
  ...remoteDoc,
@@ -295,7 +318,7 @@ async function lookupObjectInternal(identifier, options = {}) {
295
318
  }
296
319
  throw error;
297
320
  }
298
- if (snapshot != null) {
321
+ if (options.verifyPortableObject != null && snapshot != null) {
299
322
  const claim = getPortableResponseClaim(remoteDoc.documentUrl, object.id);
300
323
  if (claim === null) {
301
324
  logger.debug("Refusing the document {documentUrl}, as it claims to be a portable object with a malformed compatible identifier.", { documentUrl: remoteDoc.documentUrl });
@@ -314,8 +337,16 @@ async function lookupObjectInternal(identifier, options = {}) {
314
337
  ...remoteDoc,
315
338
  objectId: object.id.href
316
339
  });
340
+ options[lookupObserver]?.failed?.({ operation: "origin" });
317
341
  return null;
318
342
  }
343
+ if (!await acceptCandidate(options[lookupObserver], {
344
+ document: remoteDoc.document,
345
+ documentUrl: remoteDoc.documentUrl,
346
+ baseUrl: documentUrl,
347
+ contextLoader: snapshot?.loader ?? options.contextLoader ?? getDocumentLoader({ userAgent: options.userAgent }),
348
+ object
349
+ })) return null;
319
350
  return object;
320
351
  } finally {
321
352
  snapshot?.release();
@@ -429,6 +460,7 @@ async function dereference({ options, documentLoader }, id, inferredGateways, ga
429
460
  verifyPortableObject: options.verifyPortableObject,
430
461
  crossOrigin: options.crossOrigin === "throw" ? "throw" : "ignore",
431
462
  signal: options.signal,
463
+ observer: options[lookupObserver],
432
464
  parse: (document, { contextLoader, baseUrl }) => Object$1.fromJsonLd(document, {
433
465
  documentLoader,
434
466
  contextLoader,
@@ -477,4 +509,4 @@ async function* traverseCollection(collection, options = {}) {
477
509
  }
478
510
  }
479
511
  //#endregion
480
- export { traverseCollection as n, lookupObject as t };
512
+ export { lookupObserver as n, traverseCollection as r, lookupObject as t };
@@ -2,7 +2,7 @@ import "temporal-polyfill";
2
2
  globalThis.addEventListener = () => {};
3
3
  import { A as Object$1, F as Person, k as Note, s as Collection } from "./vocab-jHKm_LJ6.mjs";
4
4
  import { t as esm_default } from "./esm-DtAfYdqR.mjs";
5
- import { n as traverseCollection, t as lookupObject } from "./lookup-DpG_W_SR.mjs";
5
+ import { n as lookupObserver, r as traverseCollection, t as lookupObject } from "./lookup-C4i0dNeY.mjs";
6
6
  import { t as assertInstanceOf } from "./utils-Df2odC27.mjs";
7
7
  import { createTestMeterProvider, createTestTracerProvider, mockDocumentLoader, test } from "@fedify/fixture";
8
8
  import { deepStrictEqual, equal, ok, rejects } from "node:assert/strict";
@@ -665,5 +665,104 @@ test("lookupObject() records activitypub.object.lookup counter", {
665
665
  esm_default.hardReset();
666
666
  }
667
667
  });
668
+ const OBSERVER_DID = "did:key:z6MkhVPuyvgG1RkMv67azDqDCDERPXVrUg1i3qchXY5EACE3";
669
+ test("lookupObject() offers each candidate to the observer", async () => {
670
+ const note = {
671
+ "@context": "https://www.w3.org/ns/activitystreams",
672
+ id: "https://example.com/notes/1",
673
+ type: "Note",
674
+ content: "Hello"
675
+ };
676
+ const fetched = [];
677
+ const documentLoader = (url) => {
678
+ fetched.push(url);
679
+ if (url === "https://example.com/notes/1") return Promise.resolve({
680
+ contextUrl: null,
681
+ document: note,
682
+ documentUrl: "https://example.com/notes/1?redirected"
683
+ });
684
+ return Promise.reject(/* @__PURE__ */ new Error(`Not found: ${url}`));
685
+ };
686
+ const candidates = [];
687
+ const failures = [];
688
+ const accepted = await lookupObject("https://example.com/notes/1", {
689
+ documentLoader,
690
+ contextLoader: mockDocumentLoader,
691
+ [lookupObserver]: {
692
+ accept: (candidate) => {
693
+ candidates.push(candidate);
694
+ return Promise.resolve(true);
695
+ },
696
+ failed: (failure) => failures.push(failure)
697
+ }
698
+ });
699
+ ok(accepted instanceof Note);
700
+ equal(candidates.length, 1);
701
+ deepStrictEqual(candidates[0].document, note);
702
+ equal(candidates[0].documentUrl, "https://example.com/notes/1?redirected");
703
+ equal(candidates[0].baseUrl.href, "https://example.com/notes/1?redirected");
704
+ equal(candidates[0].object, accepted);
705
+ deepStrictEqual(failures, []);
706
+ for (const accept of [() => Promise.resolve(false), () => Promise.reject(/* @__PURE__ */ new Error("Oops"))]) {
707
+ const refusals = [];
708
+ equal(await lookupObject("https://example.com/notes/1", {
709
+ documentLoader,
710
+ contextLoader: mockDocumentLoader,
711
+ [lookupObserver]: {
712
+ accept,
713
+ failed: (f) => refusals.push(f)
714
+ }
715
+ }), null);
716
+ deepStrictEqual(refusals.map((f) => f.operation), ["accept"]);
717
+ }
718
+ fetched.length = 0;
719
+ const missing = [];
720
+ equal(await lookupObject("https://example.com/notes/2", {
721
+ documentLoader,
722
+ contextLoader: mockDocumentLoader,
723
+ [lookupObserver]: {
724
+ webFinger: false,
725
+ failed: (f) => missing.push(f)
726
+ }
727
+ }), null);
728
+ deepStrictEqual(fetched, ["https://example.com/notes/2"]);
729
+ deepStrictEqual(missing.map((f) => f.operation), ["fetch"]);
730
+ });
731
+ test("lookupObject() keeps looking after the observer refuses a portable candidate", async () => {
732
+ const id = `ap://${OBSERVER_DID}/notes/1`;
733
+ const gateways = ["https://gw1.example", "https://gw2.example"];
734
+ const documentLoader = (url) => Promise.resolve({
735
+ contextUrl: null,
736
+ document: {
737
+ "@context": "https://www.w3.org/ns/activitystreams",
738
+ id,
739
+ type: "Note",
740
+ content: url.startsWith("https://gw1.example/") ? "first" : "second"
741
+ },
742
+ documentUrl: url
743
+ });
744
+ const verifier = () => Promise.resolve({ verified: true });
745
+ const offered = [];
746
+ const failures = [];
747
+ const object = await lookupObject(id, {
748
+ documentLoader,
749
+ contextLoader: mockDocumentLoader,
750
+ gateways,
751
+ verifyPortableObject: verifier,
752
+ [lookupObserver]: {
753
+ accept: (candidate) => {
754
+ offered.push(candidate.documentUrl);
755
+ return Promise.resolve(offered.length > 1);
756
+ },
757
+ failed: (failure) => failures.push(failure)
758
+ }
759
+ });
760
+ ok(object instanceof Note);
761
+ equal(object.content, "second");
762
+ equal(offered.length, 2);
763
+ ok(offered[0].startsWith("https://gw1.example/"));
764
+ ok(offered[1].startsWith("https://gw2.example/"));
765
+ deepStrictEqual(failures.map((f) => f.operation), ["accept"]);
766
+ });
668
767
  //#endregion
669
768
  export {};
@@ -2,8 +2,8 @@ import "temporal-polyfill";
2
2
  globalThis.addEventListener = () => {};
3
3
  import { F as Person } from "./vocab-jHKm_LJ6.mjs";
4
4
  import { t as esm_default } from "./esm-DtAfYdqR.mjs";
5
- import { n as getActorHandle } from "./actor-D3VGPuRW.mjs";
6
- import { t as lookupObject } from "./lookup-DpG_W_SR.mjs";
5
+ import { n as getActorHandle } from "./actor-C93GLWk5.mjs";
6
+ import { t as lookupObject } from "./lookup-C4i0dNeY.mjs";
7
7
  import { t as assertInstanceOf } from "./utils-Df2odC27.mjs";
8
8
  import { mockDocumentLoader, test } from "@fedify/fixture";
9
9
  import { deepStrictEqual, equal, rejects } from "node:assert/strict";
@@ -1,8 +1,8 @@
1
1
  import "temporal-polyfill";
2
2
  globalThis.addEventListener = () => {};
3
3
  import "./vocab-jHKm_LJ6.mjs";
4
- import "./actor-D3VGPuRW.mjs";
5
- import "./lookup-DpG_W_SR.mjs";
4
+ import "./actor-C93GLWk5.mjs";
5
+ import "./lookup-C4i0dNeY.mjs";
6
6
  import { LanguageString as LanguageString$1 } from "@fedify/vocab-runtime";
7
7
  new URL("https://www.w3.org/ns/activitystreams#Public");
8
8
  //#endregion
@@ -1,7 +1,7 @@
1
1
  import "temporal-polyfill";
2
2
  globalThis.addEventListener = () => {};
3
3
  import { A as Object$1, E as Link, F as Person, K as Translation, L as PropertyValue, P as Organization, U as Service, a as Application, b as Group, k as Note, o as Article, q as Update, s as Collection, u as Create } from "./vocab-jHKm_LJ6.mjs";
4
- import { t as LanguageString$1 } from "./src-CkIGHS1n.mjs";
4
+ import { t as LanguageString$1 } from "./src-Dn87CPDV.mjs";
5
5
  import { test } from "@fedify/fixture";
6
6
  import { deepStrictEqual, ok, rejects } from "node:assert/strict";
7
7
  import { getDocumentLoader, preloadedContexts } from "@fedify/vocab-runtime";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fedify/vocab",
3
- "version": "2.5.0-dev.2440+635913e8",
3
+ "version": "2.5.0-dev.2448+756933d0",
4
4
  "homepage": "https://fedify.dev/",
5
5
  "repository": {
6
6
  "type": "git",
@@ -43,9 +43,9 @@
43
43
  "jsonld": "^9.0.0",
44
44
  "pkijs": "^3.3.3",
45
45
  "temporal-polyfill": "^1.0.1",
46
- "@fedify/vocab-tools": "2.5.0-dev.2440+635913e8",
47
- "@fedify/vocab-runtime": "2.5.0-dev.2440+635913e8",
48
- "@fedify/webfinger": "2.5.0-dev.2440+635913e8"
46
+ "@fedify/vocab-tools": "2.5.0-dev.2448+756933d0",
47
+ "@fedify/webfinger": "2.5.0-dev.2448+756933d0",
48
+ "@fedify/vocab-runtime": "2.5.0-dev.2448+756933d0"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@types/node": "^24.12.2",
@@ -6,7 +6,15 @@ import {
6
6
  } from "@fedify/fixture";
7
7
  import fetchMock from "fetch-mock";
8
8
  import { deepStrictEqual, equal, ok, rejects } from "node:assert/strict";
9
- import { lookupObject, traverseCollection } from "./lookup.ts";
9
+ import type {
10
+ DocumentLoader,
11
+ PortableObjectVerifier,
12
+ } from "@fedify/vocab-runtime";
13
+ import type {
14
+ DereferenceCandidate,
15
+ DereferenceFailure,
16
+ } from "@fedify/vocab-runtime/internal/portable-dereference";
17
+ import { lookupObject, lookupObserver, traverseCollection } from "./lookup.ts";
10
18
  import { assertInstanceOf } from "./utils.ts";
11
19
  import { Collection, Note, Object, Person } from "./vocab.ts";
12
20
 
@@ -1051,3 +1059,118 @@ test("lookupObject() records activitypub.object.lookup counter", {
1051
1059
  });
1052
1060
 
1053
1061
  // cSpell: ignore gildong
1062
+
1063
+ const OBSERVER_DID = "did:key:z6MkhVPuyvgG1RkMv67azDqDCDERPXVrUg1i3qchXY5EACE3";
1064
+
1065
+ test("lookupObject() offers each candidate to the observer", async () => {
1066
+ const note = {
1067
+ "@context": "https://www.w3.org/ns/activitystreams",
1068
+ id: "https://example.com/notes/1",
1069
+ type: "Note",
1070
+ content: "Hello",
1071
+ };
1072
+ const fetched: string[] = [];
1073
+ const documentLoader: DocumentLoader = (url) => {
1074
+ fetched.push(url);
1075
+ if (url === "https://example.com/notes/1") {
1076
+ return Promise.resolve({
1077
+ contextUrl: null,
1078
+ document: note,
1079
+ documentUrl: "https://example.com/notes/1?redirected",
1080
+ });
1081
+ }
1082
+ return Promise.reject(new Error(`Not found: ${url}`));
1083
+ };
1084
+ const candidates: DereferenceCandidate<Object>[] = [];
1085
+ const failures: DereferenceFailure[] = [];
1086
+ const accepted = await lookupObject("https://example.com/notes/1", {
1087
+ documentLoader,
1088
+ contextLoader: mockDocumentLoader,
1089
+ [lookupObserver]: {
1090
+ accept: (candidate) => {
1091
+ candidates.push(candidate);
1092
+ return Promise.resolve(true);
1093
+ },
1094
+ failed: (failure) => failures.push(failure),
1095
+ },
1096
+ });
1097
+ ok(accepted instanceof Note);
1098
+ equal(candidates.length, 1);
1099
+ deepStrictEqual(candidates[0].document, note);
1100
+ equal(candidates[0].documentUrl, "https://example.com/notes/1?redirected");
1101
+ equal(candidates[0].baseUrl.href, "https://example.com/notes/1?redirected");
1102
+ equal(candidates[0].object, accepted);
1103
+ deepStrictEqual(failures, []);
1104
+
1105
+ // A refused candidate is not returned, and neither is an observer error:
1106
+ for (
1107
+ const accept of [
1108
+ () => Promise.resolve(false),
1109
+ () => Promise.reject(new Error("Oops")),
1110
+ ]
1111
+ ) {
1112
+ const refusals: DereferenceFailure[] = [];
1113
+ equal(
1114
+ await lookupObject("https://example.com/notes/1", {
1115
+ documentLoader,
1116
+ contextLoader: mockDocumentLoader,
1117
+ [lookupObserver]: { accept, failed: (f) => refusals.push(f) },
1118
+ }),
1119
+ null,
1120
+ );
1121
+ deepStrictEqual(refusals.map((f) => f.operation), ["accept"]);
1122
+ }
1123
+
1124
+ // Without WebFinger, a URL that cannot be fetched is not looked up further:
1125
+ fetched.length = 0;
1126
+ const missing: DereferenceFailure[] = [];
1127
+ equal(
1128
+ await lookupObject("https://example.com/notes/2", {
1129
+ documentLoader,
1130
+ contextLoader: mockDocumentLoader,
1131
+ [lookupObserver]: { webFinger: false, failed: (f) => missing.push(f) },
1132
+ }),
1133
+ null,
1134
+ );
1135
+ deepStrictEqual(fetched, ["https://example.com/notes/2"]);
1136
+ deepStrictEqual(missing.map((f) => f.operation), ["fetch"]);
1137
+ });
1138
+
1139
+ test("lookupObject() keeps looking after the observer refuses a portable candidate", async () => {
1140
+ const id = `ap://${OBSERVER_DID}/notes/1`;
1141
+ const gateways = ["https://gw1.example", "https://gw2.example"];
1142
+ const documentLoader: DocumentLoader = (url) =>
1143
+ Promise.resolve({
1144
+ contextUrl: null,
1145
+ document: {
1146
+ "@context": "https://www.w3.org/ns/activitystreams",
1147
+ id,
1148
+ type: "Note",
1149
+ content: url.startsWith("https://gw1.example/") ? "first" : "second",
1150
+ },
1151
+ documentUrl: url,
1152
+ });
1153
+ const verifier: PortableObjectVerifier = () =>
1154
+ Promise.resolve({ verified: true });
1155
+ const offered: string[] = [];
1156
+ const failures: DereferenceFailure[] = [];
1157
+ const object = await lookupObject(id, {
1158
+ documentLoader,
1159
+ contextLoader: mockDocumentLoader,
1160
+ gateways,
1161
+ verifyPortableObject: verifier,
1162
+ [lookupObserver]: {
1163
+ accept: (candidate) => {
1164
+ offered.push(candidate.documentUrl);
1165
+ return Promise.resolve(offered.length > 1);
1166
+ },
1167
+ failed: (failure) => failures.push(failure),
1168
+ },
1169
+ });
1170
+ ok(object instanceof Note);
1171
+ equal(object.content, "second");
1172
+ equal(offered.length, 2);
1173
+ ok(offered[0].startsWith("https://gw1.example/"));
1174
+ ok(offered[1].startsWith("https://gw2.example/"));
1175
+ deepStrictEqual(failures.map((f) => f.operation), ["accept"]);
1176
+ });
package/src/lookup.ts CHANGED
@@ -13,7 +13,9 @@ import {
13
13
  type RemoteDocument,
14
14
  } from "@fedify/vocab-runtime";
15
15
  import {
16
+ acceptCandidate,
16
17
  createSnapshotContextLoader,
18
+ type DereferenceObserver,
17
19
  dereferencePortableIri,
18
20
  getPortableGatewayCandidates,
19
21
  getPortableResponseClaim,
@@ -123,6 +125,38 @@ const logger = getLogger(["fedify", "vocab", "lookup"]);
123
125
  *
124
126
  * @since 0.2.0
125
127
  */
128
+ /**
129
+ * The key of the {@link LookupObjectOptions} property that observes how
130
+ * {@link lookupObject} selects the object it returns.
131
+ *
132
+ * @internal Technically exported for other Fedify packages, but not part of
133
+ * the public API contract. This is not considered public API for Semantic
134
+ * Versioning decisions.
135
+ */
136
+ export const lookupObserver: unique symbol = Symbol.for(
137
+ "@fedify/vocab/lookupObserver",
138
+ );
139
+
140
+ /**
141
+ * Observes how {@link lookupObject} selects the object it returns: each
142
+ * candidate is offered to {@link DereferenceObserver.accept} at the boundary
143
+ * that would otherwise return it, both for ordinary documents and for
144
+ * FEP-ef61 portable objects, and each failure that does not propagate is
145
+ * reported to {@link DereferenceObserver.failed}. The observer is not passed
146
+ * on to the returned object.
147
+ *
148
+ * @internal Technically exported for other Fedify packages, but not part of
149
+ * the public API contract. This is not considered public API for Semantic
150
+ * Versioning decisions.
151
+ */
152
+ export interface LookupObserver extends DereferenceObserver<Object> {
153
+ /**
154
+ * Whether to fall back to WebFinger when an HTTP(S) URL cannot be fetched.
155
+ * Defaults to `true`.
156
+ */
157
+ readonly webFinger?: boolean;
158
+ }
159
+
126
160
  export interface LookupObjectOptions {
127
161
  /**
128
162
  * The document loader for loading remote JSON-LD documents.
@@ -226,6 +260,12 @@ export interface LookupObjectOptions {
226
260
  * @since 2.4.0
227
261
  */
228
262
  verifyPortableObject?: PortableObjectVerifier;
263
+
264
+ /**
265
+ * See {@link LookupObserver}.
266
+ * @internal
267
+ */
268
+ [lookupObserver]?: LookupObserver;
229
269
  }
230
270
 
231
271
  /**
@@ -395,8 +435,12 @@ async function lookupObjectInternal(
395
435
  });
396
436
  } catch (error) {
397
437
  logger.debug("Failed to fetch remote document:\n{error}", { error });
438
+ options[lookupObserver]?.failed?.({ operation: "fetch", error });
398
439
  }
399
440
  }
441
+ if (remoteDoc == null && options[lookupObserver]?.webFinger === false) {
442
+ return null;
443
+ }
400
444
  if (remoteDoc == null) {
401
445
  const jrd = await lookupWebFinger(identifier, {
402
446
  userAgent: options.userAgent,
@@ -467,12 +511,13 @@ async function lookupObjectInternal(
467
511
  if (remoteDoc == null) return null;
468
512
  // With verifyPortableObject, the document may turn out to be a portable
469
513
  // object, which has to be verified with the same context documents:
470
- const snapshot = options.verifyPortableObject == null
471
- ? null
472
- : createSnapshotContextLoader(
473
- options.contextLoader ??
474
- getDocumentLoader({ userAgent: options.userAgent }),
475
- );
514
+ const snapshot =
515
+ options.verifyPortableObject == null && options[lookupObserver] == null
516
+ ? null
517
+ : createSnapshotContextLoader(
518
+ options.contextLoader ??
519
+ getDocumentLoader({ userAgent: options.userAgent }),
520
+ );
476
521
  try {
477
522
  let object: Object;
478
523
  let documentUrl: URL;
@@ -486,6 +531,7 @@ async function lookupObjectInternal(
486
531
  baseUrl: documentUrl,
487
532
  });
488
533
  } catch (error) {
534
+ options[lookupObserver]?.failed?.({ operation: "parse", error });
489
535
  if (error instanceof TypeError) {
490
536
  logger.debug(
491
537
  "Failed to parse JSON-LD document: {error}\n{document}",
@@ -495,7 +541,7 @@ async function lookupObjectInternal(
495
541
  }
496
542
  throw error;
497
543
  }
498
- if (snapshot != null) {
544
+ if (options.verifyPortableObject != null && snapshot != null) {
499
545
  // A document whose final URL or @id stands for a portable object is
500
546
  // verified as one instead of being trusted because of its origin:
501
547
  const claim = getPortableResponseClaim(remoteDoc.documentUrl, object.id);
@@ -540,6 +586,19 @@ async function lookupObjectInternal(
540
586
  'implications, set the crossOrigin option to "trust".',
541
587
  { ...remoteDoc, objectId: object.id.href },
542
588
  );
589
+ options[lookupObserver]?.failed?.({ operation: "origin" });
590
+ return null;
591
+ }
592
+ if (
593
+ !await acceptCandidate(options[lookupObserver], {
594
+ document: remoteDoc.document,
595
+ documentUrl: remoteDoc.documentUrl,
596
+ baseUrl: documentUrl,
597
+ contextLoader: snapshot?.loader ?? options.contextLoader ??
598
+ getDocumentLoader({ userAgent: options.userAgent }),
599
+ object,
600
+ })
601
+ ) {
543
602
  return null;
544
603
  }
545
604
  return object;
@@ -728,6 +787,7 @@ async function dereference(
728
787
  verifyPortableObject: options.verifyPortableObject,
729
788
  crossOrigin: options.crossOrigin === "throw" ? "throw" : "ignore",
730
789
  signal: options.signal,
790
+ observer: options[lookupObserver],
731
791
  parse: (document, { contextLoader, baseUrl }) =>
732
792
  Object.fromJsonLd(document, {
733
793
  documentLoader,