@fedify/vocab 2.5.0-dev.2446 → 2.5.0-dev.2492

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fedify/vocab",
3
- "version": "2.5.0-dev.2446+2014cb03",
3
+ "version": "2.5.0-dev.2492+4648c04e",
4
4
  "homepage": "https://fedify.dev/",
5
5
  "repository": {
6
6
  "type": "git",
@@ -37,15 +37,15 @@
37
37
  "dependencies": {
38
38
  "@multiformats/base-x": "^4.0.1",
39
39
  "@logtape/logtape": "^2.2.0",
40
- "@opentelemetry/api": "^1.9.1",
40
+ "@opentelemetry/api": "~1.9.1",
41
41
  "asn1js": "^3.0.10",
42
42
  "es-toolkit": "1.46.1",
43
43
  "jsonld": "^9.0.0",
44
44
  "pkijs": "^3.3.3",
45
45
  "temporal-polyfill": "^1.0.1",
46
- "@fedify/webfinger": "2.5.0-dev.2446+2014cb03",
47
- "@fedify/vocab-tools": "2.5.0-dev.2446+2014cb03",
48
- "@fedify/vocab-runtime": "2.5.0-dev.2446+2014cb03"
46
+ "@fedify/vocab-tools": "2.5.0-dev.2492+4648c04e",
47
+ "@fedify/vocab-runtime": "2.5.0-dev.2492+4648c04e",
48
+ "@fedify/webfinger": "2.5.0-dev.2492+4648c04e"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@types/node": "^24.12.2",
package/src/actor.ts CHANGED
@@ -394,6 +394,8 @@ async function getPortableActorHandle(
394
394
  userAgent: options.userAgent,
395
395
  tracerProvider: options.tracerProvider,
396
396
  meterProvider: options.meterProvider,
397
+ allowPrivateAddress: "allowPrivateAddress" in options &&
398
+ options.allowPrivateAddress === true,
397
399
  });
398
400
  // Unlike an ordinary actor, whose ID vouches for its host, a portable
399
401
  // actor's gateways are claimed by the actor itself, so there is no
@@ -409,6 +411,8 @@ async function getPortableActorHandle(
409
411
  userAgent: options.userAgent,
410
412
  tracerProvider: options.tracerProvider,
411
413
  meterProvider: options.meterProvider,
414
+ allowPrivateAddress: "allowPrivateAddress" in options &&
415
+ options.allowPrivateAddress === true,
412
416
  });
413
417
  if (
414
418
  subjectResult != null && linksToPortableActor(subjectResult, canonicalId)
@@ -876,7 +876,7 @@ test("getObject() sends the ActivityStreams Accept header to gateways", {
876
876
  });
877
877
  try {
878
878
  const object = await (await createActivity()).getObject({
879
- documentLoader: getDocumentLoader(),
879
+ documentLoader: getDocumentLoader({ allowPrivateAddress: true }),
880
880
  contextLoader: mockDocumentLoader,
881
881
  gateways: ["https://example.com"],
882
882
  verifyPortableObject: createVerifier(),
@@ -6,14 +6,29 @@ 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
 
21
+ // lookupObject() accepts this option without declaring it, since Fedify's
22
+ // Context passes it. It makes WebFinger lookups use the global fetch()
23
+ // (fetch-mock here) without resolving hostnames, so that the tests do not
24
+ // depend on the real DNS:
25
+ const allowPrivateAddress = { allowPrivateAddress: true };
26
+
13
27
  test("lookupObject()", {
14
28
  sanitizeResources: false,
15
29
  sanitizeOps: false,
16
30
  }, async (t) => {
31
+ const responseTimers: ReturnType<typeof setTimeout>[] = [];
17
32
  fetchMock.spyGlobal();
18
33
 
19
34
  fetchMock.get(
@@ -43,6 +58,7 @@ test("lookupObject()", {
43
58
  const options = {
44
59
  documentLoader: mockDocumentLoader,
45
60
  contextLoader: mockDocumentLoader,
61
+ ...allowPrivateAddress,
46
62
  };
47
63
 
48
64
  await t.step("actor", async () => {
@@ -106,7 +122,7 @@ test("lookupObject()", {
106
122
  "begin:https://example.com/.well-known/webfinger",
107
123
  () =>
108
124
  new Promise((resolve) => {
109
- setTimeout(() => {
125
+ responseTimers.push(setTimeout(() => {
110
126
  resolve({
111
127
  subject: "acct:johndoe@example.com",
112
128
  links: [
@@ -117,7 +133,7 @@ test("lookupObject()", {
117
133
  },
118
134
  ],
119
135
  });
120
- }, 1000);
136
+ }, 1000));
121
137
  }),
122
138
  );
123
139
 
@@ -162,7 +178,7 @@ test("lookupObject()", {
162
178
  "begin:https://example.com/.well-known/webfinger",
163
179
  () =>
164
180
  new Promise((resolve) => {
165
- setTimeout(() => {
181
+ responseTimers.push(setTimeout(() => {
166
182
  resolve({
167
183
  subject: "acct:johndoe@example.com",
168
184
  links: [
@@ -173,7 +189,7 @@ test("lookupObject()", {
173
189
  },
174
190
  ],
175
191
  });
176
- }, 500);
192
+ }, 500));
177
193
  }),
178
194
  );
179
195
 
@@ -193,7 +209,7 @@ test("lookupObject()", {
193
209
  "https://example.com/slow-object",
194
210
  () =>
195
211
  new Promise((resolve) => {
196
- setTimeout(() => {
212
+ responseTimers.push(setTimeout(() => {
197
213
  resolve({
198
214
  status: 200,
199
215
  headers: { "Content-Type": "application/activity+json" },
@@ -203,7 +219,7 @@ test("lookupObject()", {
203
219
  content: "Slow response",
204
220
  },
205
221
  });
206
- }, 1000);
222
+ }, 1000));
207
223
  }),
208
224
  );
209
225
 
@@ -218,6 +234,7 @@ test("lookupObject()", {
218
234
  deepStrictEqual(await promise, null);
219
235
  });
220
236
 
237
+ for (const timer of responseTimers) clearTimeout(timer);
221
238
  fetchMock.hardReset();
222
239
  fetchMock.removeRoutes();
223
240
  });
@@ -601,6 +618,7 @@ test("FEP-fe34: lookupObject() cross-origin security", {
601
618
 
602
619
  // Default behavior should return null
603
620
  const result1 = await lookupObject("@user@example.com", {
621
+ ...allowPrivateAddress,
604
622
  documentLoader: webfingerDocumentLoader,
605
623
  contextLoader: mockDocumentLoader,
606
624
  });
@@ -610,6 +628,7 @@ test("FEP-fe34: lookupObject() cross-origin security", {
610
628
  await rejects(
611
629
  () =>
612
630
  lookupObject("@user@example.com", {
631
+ ...allowPrivateAddress,
613
632
  documentLoader: webfingerDocumentLoader,
614
633
  contextLoader: mockDocumentLoader,
615
634
  crossOrigin: "throw",
@@ -620,6 +639,7 @@ test("FEP-fe34: lookupObject() cross-origin security", {
620
639
 
621
640
  // With crossOrigin: trust, should return the object
622
641
  const result2 = await lookupObject("@user@example.com", {
642
+ ...allowPrivateAddress,
623
643
  documentLoader: webfingerDocumentLoader,
624
644
  contextLoader: mockDocumentLoader,
625
645
  crossOrigin: "trust",
@@ -719,12 +739,22 @@ test("FEP-fe34: lookupObject() cross-origin security", {
719
739
  throw new Error("Network error");
720
740
  };
721
741
 
742
+ // The WebFinger fallback must not reach the network:
743
+ fetchMock.spyGlobal();
744
+ fetchMock.get("begin:https://example.com/.well-known/webfinger", 404);
745
+
722
746
  // Network errors should not be confused with cross-origin errors
723
- const result = await lookupObject("https://example.com/note", {
724
- documentLoader: errorDocumentLoader,
725
- contextLoader: mockDocumentLoader,
726
- crossOrigin: "throw",
727
- });
747
+ let result;
748
+ try {
749
+ result = await lookupObject("https://example.com/note", {
750
+ ...allowPrivateAddress,
751
+ documentLoader: errorDocumentLoader,
752
+ contextLoader: mockDocumentLoader,
753
+ crossOrigin: "throw",
754
+ });
755
+ } finally {
756
+ fetchMock.hardReset();
757
+ }
728
758
 
729
759
  // Should return null because the document loader failed,
730
760
  // not because of cross-origin policy
@@ -842,6 +872,7 @@ test("lookupObject() records activitypub.object.lookup counter", {
842
872
  await t.step("records kind=actor for an actor result", async () => {
843
873
  const [meterProvider, recorder] = createTestMeterProvider();
844
874
  const person = await lookupObject("@johndoe@example.com", {
875
+ ...allowPrivateAddress,
845
876
  documentLoader: mockDocumentLoader,
846
877
  contextLoader: mockDocumentLoader,
847
878
  meterProvider,
@@ -928,6 +959,7 @@ test("lookupObject() records activitypub.object.lookup counter", {
928
959
  });
929
960
  const [meterProvider, recorder] = createTestMeterProvider();
930
961
  const result = await lookupObject("janedoe@example.com", {
962
+ ...allowPrivateAddress,
931
963
  documentLoader: mockDocumentLoader,
932
964
  contextLoader: mockDocumentLoader,
933
965
  meterProvider,
@@ -979,6 +1011,7 @@ test("lookupObject() records activitypub.object.lookup counter", {
979
1011
  );
980
1012
  const [meterProvider, recorder] = createTestMeterProvider();
981
1013
  await lookupObject("@johndoe@example.com", {
1014
+ ...allowPrivateAddress,
982
1015
  documentLoader: mockDocumentLoader,
983
1016
  contextLoader: mockDocumentLoader,
984
1017
  meterProvider,
@@ -1012,6 +1045,7 @@ test("lookupObject() records activitypub.object.lookup counter", {
1012
1045
  });
1013
1046
  const [meterProvider, recorder] = createTestMeterProvider();
1014
1047
  await lookupObject(new URL("acct:johndoe@example.com"), {
1048
+ ...allowPrivateAddress,
1015
1049
  documentLoader: mockDocumentLoader,
1016
1050
  contextLoader: mockDocumentLoader,
1017
1051
  meterProvider,
@@ -1051,3 +1085,118 @@ test("lookupObject() records activitypub.object.lookup counter", {
1051
1085
  });
1052
1086
 
1053
1087
  // cSpell: ignore gildong
1088
+
1089
+ const OBSERVER_DID = "did:key:z6MkhVPuyvgG1RkMv67azDqDCDERPXVrUg1i3qchXY5EACE3";
1090
+
1091
+ test("lookupObject() offers each candidate to the observer", async () => {
1092
+ const note = {
1093
+ "@context": "https://www.w3.org/ns/activitystreams",
1094
+ id: "https://example.com/notes/1",
1095
+ type: "Note",
1096
+ content: "Hello",
1097
+ };
1098
+ const fetched: string[] = [];
1099
+ const documentLoader: DocumentLoader = (url) => {
1100
+ fetched.push(url);
1101
+ if (url === "https://example.com/notes/1") {
1102
+ return Promise.resolve({
1103
+ contextUrl: null,
1104
+ document: note,
1105
+ documentUrl: "https://example.com/notes/1?redirected",
1106
+ });
1107
+ }
1108
+ return Promise.reject(new Error(`Not found: ${url}`));
1109
+ };
1110
+ const candidates: DereferenceCandidate<Object>[] = [];
1111
+ const failures: DereferenceFailure[] = [];
1112
+ const accepted = await lookupObject("https://example.com/notes/1", {
1113
+ documentLoader,
1114
+ contextLoader: mockDocumentLoader,
1115
+ [lookupObserver]: {
1116
+ accept: (candidate) => {
1117
+ candidates.push(candidate);
1118
+ return Promise.resolve(true);
1119
+ },
1120
+ failed: (failure) => failures.push(failure),
1121
+ },
1122
+ });
1123
+ ok(accepted instanceof Note);
1124
+ equal(candidates.length, 1);
1125
+ deepStrictEqual(candidates[0].document, note);
1126
+ equal(candidates[0].documentUrl, "https://example.com/notes/1?redirected");
1127
+ equal(candidates[0].baseUrl.href, "https://example.com/notes/1?redirected");
1128
+ equal(candidates[0].object, accepted);
1129
+ deepStrictEqual(failures, []);
1130
+
1131
+ // A refused candidate is not returned, and neither is an observer error:
1132
+ for (
1133
+ const accept of [
1134
+ () => Promise.resolve(false),
1135
+ () => Promise.reject(new Error("Oops")),
1136
+ ]
1137
+ ) {
1138
+ const refusals: DereferenceFailure[] = [];
1139
+ equal(
1140
+ await lookupObject("https://example.com/notes/1", {
1141
+ documentLoader,
1142
+ contextLoader: mockDocumentLoader,
1143
+ [lookupObserver]: { accept, failed: (f) => refusals.push(f) },
1144
+ }),
1145
+ null,
1146
+ );
1147
+ deepStrictEqual(refusals.map((f) => f.operation), ["accept"]);
1148
+ }
1149
+
1150
+ // Without WebFinger, a URL that cannot be fetched is not looked up further:
1151
+ fetched.length = 0;
1152
+ const missing: DereferenceFailure[] = [];
1153
+ equal(
1154
+ await lookupObject("https://example.com/notes/2", {
1155
+ documentLoader,
1156
+ contextLoader: mockDocumentLoader,
1157
+ [lookupObserver]: { webFinger: false, failed: (f) => missing.push(f) },
1158
+ }),
1159
+ null,
1160
+ );
1161
+ deepStrictEqual(fetched, ["https://example.com/notes/2"]);
1162
+ deepStrictEqual(missing.map((f) => f.operation), ["fetch"]);
1163
+ });
1164
+
1165
+ test("lookupObject() keeps looking after the observer refuses a portable candidate", async () => {
1166
+ const id = `ap://${OBSERVER_DID}/notes/1`;
1167
+ const gateways = ["https://gw1.example", "https://gw2.example"];
1168
+ const documentLoader: DocumentLoader = (url) =>
1169
+ Promise.resolve({
1170
+ contextUrl: null,
1171
+ document: {
1172
+ "@context": "https://www.w3.org/ns/activitystreams",
1173
+ id,
1174
+ type: "Note",
1175
+ content: url.startsWith("https://gw1.example/") ? "first" : "second",
1176
+ },
1177
+ documentUrl: url,
1178
+ });
1179
+ const verifier: PortableObjectVerifier = () =>
1180
+ Promise.resolve({ verified: true });
1181
+ const offered: string[] = [];
1182
+ const failures: DereferenceFailure[] = [];
1183
+ const object = await lookupObject(id, {
1184
+ documentLoader,
1185
+ contextLoader: mockDocumentLoader,
1186
+ gateways,
1187
+ verifyPortableObject: verifier,
1188
+ [lookupObserver]: {
1189
+ accept: (candidate) => {
1190
+ offered.push(candidate.documentUrl);
1191
+ return Promise.resolve(offered.length > 1);
1192
+ },
1193
+ failed: (failure) => failures.push(failure),
1194
+ },
1195
+ });
1196
+ ok(object instanceof Note);
1197
+ equal(object.content, "second");
1198
+ equal(offered.length, 2);
1199
+ ok(offered[0].startsWith("https://gw1.example/"));
1200
+ ok(offered[1].startsWith("https://gw2.example/"));
1201
+ deepStrictEqual(failures.map((f) => f.operation), ["accept"]);
1202
+ });
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,