@fedify/vocab 2.4.0-dev.2035 → 2.4.0-dev.2054

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.
@@ -1,12 +1,13 @@
1
1
  import { Temporal } from "temporal-polyfill";
2
2
  globalThis.addEventListener = () => {};
3
- import { k as Object$1 } from "./vocab-Dw_Vm0NE.mjs";
4
- import { i as isActor, o as name, s as version } from "./actor-BX07d5tK.mjs";
3
+ import { k as Object$1 } from "./vocab-BJGPw--L.mjs";
4
+ import { i as isActor, o as name, s as version } from "./actor-DT_jVPKe.mjs";
5
5
  import { t as getTypeId } from "./type-BMdjm4Eg.mjs";
6
+ import { canonicalizePortableUri, formatIri, fromCompatibleEf61Id, getDocumentLoader, haveSameFe34Origin, haveSameIriOrigin, parseIri } from "@fedify/vocab-runtime";
6
7
  import { lookupWebFinger } from "@fedify/webfinger";
7
8
  import { SpanStatusCode, trace } from "@opentelemetry/api";
8
9
  import { getLogger } from "@logtape/logtape";
9
- import { getDocumentLoader, haveSameFe34Origin, haveSameIriOrigin, parseIri } from "@fedify/vocab-runtime";
10
+ import { PortableObjectRejectedError, dereferencePortableIri, getPortableGatewayCandidates, isPortableIri } from "@fedify/vocab-runtime/internal/portable-dereference";
10
11
  import { delay } from "es-toolkit";
11
12
  //#region src/handle.ts
12
13
  /**
@@ -79,11 +80,13 @@ function getObjectLookupCounter(meterProvider) {
79
80
  function getLookupRemoteHost(identifier) {
80
81
  let url;
81
82
  if (identifier instanceof URL) url = identifier;
83
+ else if (PORTABLE_IRI_PATTERN.test(identifier)) return;
82
84
  else try {
83
85
  url = new URL(identifier);
84
86
  } catch {
85
87
  return extractHandleHost(identifier.startsWith("@") ? identifier.slice(1) : identifier);
86
88
  }
89
+ if (isPortableIri(url)) return void 0;
87
90
  if (url.host !== "") return url.host;
88
91
  if (url.protocol === "acct:") return extractHandleHost(url.pathname);
89
92
  }
@@ -104,6 +107,13 @@ const logger = getLogger([
104
107
  "lookup"
105
108
  ]);
106
109
  /**
110
+ * The maximum number of requests to FEP-ef61 gateways that a single
111
+ * {@link lookupObject} call makes for portable objects. Gateways come from
112
+ * possibly untrusted WebFinger responses and location hints, so they are
113
+ * bounded to keep a single lookup from fanning out to many servers.
114
+ */
115
+ const MAX_PORTABLE_ATTEMPTS = 5;
116
+ /**
107
117
  * Looks up an ActivityStreams object by its URI (including `acct:` URIs)
108
118
  * or a fediverse handle (e.g., `@user@server` or `user@server`).
109
119
  *
@@ -130,6 +140,12 @@ const logger = getLogger([
130
140
  * // returning a `Note` object.
131
141
  * ```
132
142
  *
143
+ * [FEP-ef61] portable objects, including portable actors found through
144
+ * WebFinger, are looked up only if the `verifyPortableObject` option is
145
+ * given; see {@link LookupObjectOptions.verifyPortableObject}.
146
+ *
147
+ * [FEP-ef61]: https://w3id.org/fep/ef61
148
+ *
133
149
  * @param identifier The URI or fediverse handle to look up.
134
150
  * @param options Lookup options.
135
151
  * @returns The object, or `null` if not found.
@@ -172,12 +188,42 @@ async function lookupObject(identifier, options = {}) {
172
188
  }
173
189
  async function lookupObjectInternal(identifier, options = {}) {
174
190
  const documentLoader = options.documentLoader ?? getDocumentLoader({ userAgent: options.userAgent });
175
- if (typeof identifier === "string") identifier = toAcctUrl(identifier) ?? new URL(identifier);
191
+ const portable = {
192
+ options,
193
+ documentLoader,
194
+ attempted: /* @__PURE__ */ new Set(),
195
+ remaining: MAX_PORTABLE_ATTEMPTS
196
+ };
197
+ if (typeof identifier === "string") {
198
+ if (PORTABLE_IRI_PATTERN.test(identifier)) {
199
+ const candidate = parsePortableCandidate(identifier);
200
+ if (candidate == null) return null;
201
+ return await lookupPortableObject(portable, candidate, void 0);
202
+ }
203
+ identifier = toAcctUrl(identifier) ?? new URL(identifier);
204
+ }
205
+ if (isPortableIri(identifier)) {
206
+ let candidate;
207
+ try {
208
+ candidate = parseIri(identifier);
209
+ } catch (error) {
210
+ if (error instanceof TypeError) return null;
211
+ throw error;
212
+ }
213
+ return await lookupPortableObject(portable, candidate, void 0);
214
+ }
176
215
  let remoteDoc = null;
177
- if (identifier.protocol === "http:" || identifier.protocol === "https:") try {
178
- remoteDoc = await documentLoader(identifier.href, { signal: options.signal });
179
- } catch (error) {
180
- logger.debug("Failed to fetch remote document:\n{error}", { error });
216
+ if (identifier.protocol === "http:" || identifier.protocol === "https:") {
217
+ const compatible = getCompatibleCandidate(identifier.href, options);
218
+ if (compatible !== void 0) {
219
+ if (compatible == null) return null;
220
+ return await lookupPortableObject(portable, compatible.id, [compatible.gateway]);
221
+ }
222
+ try {
223
+ remoteDoc = await documentLoader(identifier.href, { signal: options.signal });
224
+ } catch (error) {
225
+ logger.debug("Failed to fetch remote document:\n{error}", { error });
226
+ }
181
227
  }
182
228
  if (remoteDoc == null) {
183
229
  const jrd = await lookupWebFinger(identifier, {
@@ -188,8 +234,31 @@ async function lookupObjectInternal(identifier, options = {}) {
188
234
  signal: options.signal
189
235
  });
190
236
  if (jrd?.links == null) return null;
237
+ const webFingerGateway = getWebFingerGateway(identifier);
191
238
  for (const l of jrd.links) {
192
239
  if (l.type !== "application/activity+json" && !l.type?.match(/application\/ld\+json;\s*profile="https:\/\/www.w3.org\/ns\/activitystreams"/) || l.rel !== "self" || l.href == null) continue;
240
+ if (PORTABLE_IRI_PATTERN.test(l.href)) {
241
+ if (options.verifyPortableObject == null) {
242
+ logger.debug("Skipping the portable self link {href}, as the verifyPortableObject option is not given.", { href: l.href });
243
+ continue;
244
+ }
245
+ const candidate = parsePortableCandidate(l.href);
246
+ if (candidate == null) continue;
247
+ const gateways = webFingerGateway == null ? [] : [webFingerGateway];
248
+ gateways.push(...getPortableGatewayCandidates(candidate));
249
+ const object = await lookupPortableObject(portable, candidate, gateways);
250
+ if (object != null) return object;
251
+ if (options.signal?.aborted) return null;
252
+ continue;
253
+ }
254
+ const compatible = getCompatibleCandidate(l.href, options);
255
+ if (compatible !== void 0) {
256
+ if (compatible == null) continue;
257
+ const object = await lookupPortableObject(portable, compatible.id, [compatible.gateway]);
258
+ if (object != null) return object;
259
+ if (options.signal?.aborted) return null;
260
+ continue;
261
+ }
193
262
  try {
194
263
  remoteDoc = await documentLoader(l.href, { signal: options.signal });
195
264
  break;
@@ -220,7 +289,7 @@ async function lookupObjectInternal(identifier, options = {}) {
220
289
  }
221
290
  throw error;
222
291
  }
223
- if (options.crossOrigin !== "trust" && object.id != null && !haveSameIriOrigin(object.id, documentUrl) && !haveSameFe34Origin(object.id, documentUrl)) {
292
+ if (object.id != null && (options.crossOrigin !== "trust" || isPortableIri(object.id)) && !haveSameIriOrigin(object.id, documentUrl) && !haveSameFe34Origin(object.id, documentUrl)) {
224
293
  if (options.crossOrigin === "throw") throw new Error(`The object's @id (${object.id.href}) has a different origin than the document URL (${remoteDoc.documentUrl}); refusing to return the object. If you want to bypass this check and are aware of the security implications, set the crossOrigin option to "trust".`);
225
294
  logger.warn("The object's @id ({objectId}) has a different origin than the document URL ({documentUrl}); refusing to return the object. If you want to bypass this check and are aware of the security implications, set the crossOrigin option to \"trust\".", {
226
295
  ...remoteDoc,
@@ -230,6 +299,139 @@ async function lookupObjectInternal(identifier, options = {}) {
230
299
  }
231
300
  return object;
232
301
  }
302
+ const PORTABLE_IRI_PATTERN = /^ap(?:\+ef61)?:/i;
303
+ /**
304
+ * Parses a raw portable IRI. URL parsing normalizes dot segments in the
305
+ * opaque path, which would make the parsed IRI identify another portable
306
+ * object, so such IRIs are refused.
307
+ */
308
+ function parsePortableCandidate(iri) {
309
+ try {
310
+ const parsed = parseIri(iri);
311
+ if (canonicalizePortableUri(iri) !== canonicalizePortableUri(formatIri(parsed))) {
312
+ logger.debug("Refusing to look up the portable IRI {iri}, as its path cannot be represented without changing the identified object.", { iri });
313
+ return null;
314
+ }
315
+ return parsed;
316
+ } catch (error) {
317
+ if (error instanceof TypeError) {
318
+ logger.debug("Invalid portable IRI {iri}: {error}", {
319
+ iri,
320
+ error
321
+ });
322
+ return null;
323
+ }
324
+ throw error;
325
+ }
326
+ }
327
+ /**
328
+ * Recognizes an FEP-ef61 compatible identifier to look up as a portable
329
+ * object.
330
+ * @returns `undefined` if the URL should be fetched as an ordinary HTTP(S)
331
+ * URL, i.e., it is not a compatible identifier, or the
332
+ * `verifyPortableObject` option is not given; `null` if it is
333
+ * a malformed compatible identifier; otherwise, the portable ID and
334
+ * the gateway to ask.
335
+ */
336
+ function getCompatibleCandidate(href, options) {
337
+ if (options.verifyPortableObject == null || !URL.canParse(href)) return;
338
+ const url = new URL(href);
339
+ try {
340
+ const id = fromCompatibleEf61Id(url);
341
+ if (id == null) return void 0;
342
+ return {
343
+ id,
344
+ gateway: new URL(url.origin)
345
+ };
346
+ } catch (error) {
347
+ if (error instanceof TypeError) {
348
+ logger.debug("Invalid FEP-ef61 compatible identifier {href}: {error}", {
349
+ href,
350
+ error
351
+ });
352
+ return null;
353
+ }
354
+ throw error;
355
+ }
356
+ }
357
+ /**
358
+ * Gets the origin of the WebFinger server that {@link lookupWebFinger} asks
359
+ * for the identifier, which is also the first gateway of a portable actor.
360
+ */
361
+ function getWebFingerGateway(identifier) {
362
+ let url;
363
+ if (identifier.protocol === "acct:") {
364
+ const host = extractHandleHost(identifier.pathname);
365
+ if (host == null) return void 0;
366
+ url = new URL(`https://${host}/`);
367
+ } else if (identifier.protocol === "http:" || identifier.protocol === "https:") url = new URL(identifier.origin);
368
+ else return;
369
+ return url.username === "" && url.password === "" ? url : void 0;
370
+ }
371
+ /**
372
+ * Looks up a portable object through FEP-ef61 gateways.
373
+ * @param gateways The gateways to ask in order. If omitted, the location
374
+ * hints in the IRI are used, and if there are none, the
375
+ * portable IRI itself is passed to the document loader.
376
+ */
377
+ async function lookupPortableObject(lookup, id, gateways) {
378
+ const { options } = lookup;
379
+ const iri = formatIri(id);
380
+ if (options.verifyPortableObject == null) {
381
+ logger.debug("Cannot look up the portable object {iri}, as the verifyPortableObject option is not given.", { iri });
382
+ return null;
383
+ }
384
+ const canonicalId = canonicalizePortableUri(iri);
385
+ let candidates;
386
+ if (gateways == null) {
387
+ candidates = getPortableGatewayCandidates(id);
388
+ if (candidates.length < 1) {
389
+ const key = `${canonicalId} `;
390
+ if (lookup.remaining < 1 || lookup.attempted.has(key)) return null;
391
+ lookup.attempted.add(key);
392
+ lookup.remaining--;
393
+ return await dereference(lookup, id, []);
394
+ }
395
+ } else candidates = [...gateways];
396
+ const selected = [];
397
+ for (const gateway of candidates) {
398
+ if (selected.length >= lookup.remaining) break;
399
+ const key = `${canonicalId} ${gateway.href}`;
400
+ if (lookup.attempted.has(key)) continue;
401
+ lookup.attempted.add(key);
402
+ selected.push(gateway);
403
+ }
404
+ if (selected.length < 1) return null;
405
+ lookup.remaining -= selected.length;
406
+ return await dereference(lookup, id, selected);
407
+ }
408
+ async function dereference({ options, documentLoader }, id, gateways) {
409
+ const tracerProvider = options.tracerProvider ?? trace.getTracerProvider();
410
+ try {
411
+ return await dereferencePortableIri(id, {
412
+ documentLoader,
413
+ contextLoader: options.contextLoader ?? getDocumentLoader({ userAgent: options.userAgent }),
414
+ tracerProvider,
415
+ gateways,
416
+ verifyPortableObject: options.verifyPortableObject,
417
+ crossOrigin: options.crossOrigin === "throw" ? "throw" : "ignore",
418
+ signal: options.signal,
419
+ parse: (document, { contextLoader, baseUrl }) => Object$1.fromJsonLd(document, {
420
+ documentLoader,
421
+ contextLoader,
422
+ tracerProvider,
423
+ baseUrl
424
+ })
425
+ });
426
+ } catch (error) {
427
+ if (error instanceof PortableObjectRejectedError) throw error;
428
+ logger.debug("Failed to look up the portable object {iri}:\n{error}", {
429
+ iri: formatIri(id),
430
+ error
431
+ });
432
+ return null;
433
+ }
434
+ }
233
435
  /**
234
436
  * Traverses a collection, yielding each item in the collection.
235
437
  * If the collection is paginated, it will fetch the next page
@@ -1,9 +1,9 @@
1
1
  import "temporal-polyfill";
2
2
  globalThis.addEventListener = () => {};
3
- import { O as Note, P as Person, k as Object$1, s as Collection } from "./vocab-Dw_Vm0NE.mjs";
4
- import { t as esm_default } from "./esm-C_FjLvhG.mjs";
5
- import { n as traverseCollection, t as lookupObject } from "./lookup-B9lgdxnM.mjs";
3
+ import { O as Note, P as Person, k as Object$1, s as Collection } from "./vocab-BJGPw--L.mjs";
4
+ import { t as esm_default } from "./esm-Df0Yo-Tp.mjs";
6
5
  import { t as assertInstanceOf } from "./utils-Df2odC27.mjs";
6
+ import { n as traverseCollection, t as lookupObject } from "./lookup-4fEg7Ybl.mjs";
7
7
  import { createTestMeterProvider, createTestTracerProvider, mockDocumentLoader, test } from "@fedify/fixture";
8
8
  import { deepStrictEqual, equal, ok, rejects } from "node:assert/strict";
9
9
  //#region src/lookup.test.ts
@@ -0,0 +1,335 @@
1
+ import "temporal-polyfill";
2
+ globalThis.addEventListener = () => {};
3
+ import { P as Person } from "./vocab-BJGPw--L.mjs";
4
+ import { t as esm_default } from "./esm-Df0Yo-Tp.mjs";
5
+ import { n as getActorHandle } from "./actor-DT_jVPKe.mjs";
6
+ import { t as assertInstanceOf } from "./utils-Df2odC27.mjs";
7
+ import { t as lookupObject } from "./lookup-4fEg7Ybl.mjs";
8
+ import { mockDocumentLoader, test } from "@fedify/fixture";
9
+ import { deepStrictEqual, equal, rejects } from "node:assert/strict";
10
+ import { FetchError, parseIri } from "@fedify/vocab-runtime";
11
+ //#region src/portable-lookup.test.ts
12
+ const did = "did:key:z6Mkabc";
13
+ const actorId = `ap+ef61://${did}/actor`;
14
+ const gatewayPath = `/.well-known/apgateway/${did}/actor`;
15
+ const compatibleId = `https://example.com${gatewayPath}`;
16
+ const webFingerPrefix = "begin:https://example.com/.well-known/webfinger";
17
+ const AS_TYPE = "application/activity+json";
18
+ function person(id = `ap://${did}/actor`, extra = {}) {
19
+ return {
20
+ "@context": "https://www.w3.org/ns/activitystreams",
21
+ id,
22
+ type: "Person",
23
+ name: "Alice",
24
+ ...extra
25
+ };
26
+ }
27
+ function createLoader(responses) {
28
+ const fetched = [];
29
+ const loader = async (url, options) => {
30
+ fetched.push(url);
31
+ options?.signal?.throwIfAborted();
32
+ const document = responses[url];
33
+ if (document == null) throw new FetchError(url, "HTTP 404", new globalThis.Response(null, { status: 404 }));
34
+ return {
35
+ contextUrl: null,
36
+ documentUrl: url,
37
+ document
38
+ };
39
+ };
40
+ return Object.assign(loader, { fetched });
41
+ }
42
+ function createVerifier(verified = true) {
43
+ const documents = [];
44
+ const verifier = async (document) => {
45
+ documents.push(document);
46
+ return { verified };
47
+ };
48
+ return Object.assign(verifier, { documents });
49
+ }
50
+ function selfLinks(...hrefs) {
51
+ return {
52
+ subject: "acct:alice@example.com",
53
+ links: hrefs.map((href) => ({
54
+ rel: "self",
55
+ type: AS_TYPE,
56
+ href
57
+ }))
58
+ };
59
+ }
60
+ test("lookupObject() with FEP-ef61 portable actors", {
61
+ sanitizeResources: false,
62
+ sanitizeOps: false
63
+ }, async (t) => {
64
+ esm_default.spyGlobal();
65
+ try {
66
+ await t.step("compatible self link, alias-free JRD", async () => {
67
+ esm_default.removeRoutes();
68
+ esm_default.get(webFingerPrefix, selfLinks(compatibleId));
69
+ const document = person();
70
+ const documentLoader = createLoader({ [compatibleId]: document });
71
+ const verifyPortableObject = createVerifier();
72
+ const actor = await lookupObject("@alice@example.com", {
73
+ documentLoader,
74
+ contextLoader: mockDocumentLoader,
75
+ verifyPortableObject
76
+ });
77
+ assertInstanceOf(actor, Person);
78
+ deepStrictEqual(actor.id, parseIri(actorId));
79
+ deepStrictEqual(documentLoader.fetched, [compatibleId]);
80
+ deepStrictEqual(verifyPortableObject.documents, [document]);
81
+ });
82
+ await t.step("canonical self link asks the WebFinger host first", async () => {
83
+ esm_default.removeRoutes();
84
+ esm_default.get(webFingerPrefix, {
85
+ subject: "acct:alice@example.com",
86
+ links: [{
87
+ rel: "self",
88
+ type: "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"",
89
+ href: `ap://${did}/actor?@gateway=https%3A%2F%2Fhint.example`
90
+ }]
91
+ });
92
+ const hinted = `https://hint.example${gatewayPath}`;
93
+ const documentLoader = createLoader({ [hinted]: person() });
94
+ assertInstanceOf(await lookupObject("@alice@example.com", {
95
+ documentLoader,
96
+ contextLoader: mockDocumentLoader,
97
+ verifyPortableObject: createVerifier()
98
+ }), Person);
99
+ deepStrictEqual(documentLoader.fetched, [compatibleId, hinted]);
100
+ });
101
+ await t.step("tries the next self link after a failure", async () => {
102
+ esm_default.removeRoutes();
103
+ const other = `https://other.example${gatewayPath}`;
104
+ esm_default.get(webFingerPrefix, selfLinks(`ap://${did}/actor`, compatibleId, other));
105
+ const documentLoader = createLoader({ [other]: person() });
106
+ assertInstanceOf(await lookupObject("@alice@example.com", {
107
+ documentLoader,
108
+ contextLoader: mockDocumentLoader,
109
+ verifyPortableObject: createVerifier()
110
+ }), Person);
111
+ deepStrictEqual(documentLoader.fetched, [compatibleId, other]);
112
+ });
113
+ await t.step("rejects objects that fail the checks", async () => {
114
+ esm_default.removeRoutes();
115
+ esm_default.get(webFingerPrefix, selfLinks(compatibleId));
116
+ for (const [document, verified] of [
117
+ [person(), false],
118
+ [person(`ap://${did}/other`), true],
119
+ [person(compatibleId), true]
120
+ ]) {
121
+ const documentLoader = createLoader({ [compatibleId]: document });
122
+ const verifyPortableObject = createVerifier(verified);
123
+ equal(await lookupObject("@alice@example.com", {
124
+ documentLoader,
125
+ contextLoader: mockDocumentLoader,
126
+ verifyPortableObject
127
+ }), null);
128
+ deepStrictEqual(documentLoader.fetched, [compatibleId]);
129
+ await rejects(() => lookupObject("@alice@example.com", {
130
+ documentLoader,
131
+ contextLoader: mockDocumentLoader,
132
+ verifyPortableObject,
133
+ crossOrigin: "throw"
134
+ }), { name: "PortableObjectRejectedError" });
135
+ equal(await lookupObject("@alice@example.com", {
136
+ documentLoader,
137
+ contextLoader: mockDocumentLoader,
138
+ verifyPortableObject,
139
+ crossOrigin: "trust"
140
+ }), null);
141
+ }
142
+ });
143
+ await t.step("without verifyPortableObject", async () => {
144
+ esm_default.removeRoutes();
145
+ esm_default.get(webFingerPrefix, selfLinks(`ap://${did}/actor`, compatibleId));
146
+ const documentLoader = createLoader({ [compatibleId]: person() });
147
+ for (const crossOrigin of ["ignore", "trust"]) equal(await lookupObject("@alice@example.com", {
148
+ documentLoader,
149
+ contextLoader: mockDocumentLoader,
150
+ crossOrigin
151
+ }), null);
152
+ deepStrictEqual(documentLoader.fetched, [compatibleId, compatibleId]);
153
+ await rejects(() => lookupObject("@alice@example.com", {
154
+ documentLoader,
155
+ contextLoader: mockDocumentLoader,
156
+ crossOrigin: "throw"
157
+ }), /has a different origin than the document URL/);
158
+ const actor = await lookupObject("@alice@example.com", {
159
+ documentLoader: createLoader({ [compatibleId]: person(compatibleId) }),
160
+ contextLoader: mockDocumentLoader
161
+ });
162
+ assertInstanceOf(actor, Person);
163
+ deepStrictEqual(actor.id, new URL(compatibleId));
164
+ });
165
+ await t.step("refuses portable IRIs changed by URL parsing", async () => {
166
+ esm_default.removeRoutes();
167
+ esm_default.get(webFingerPrefix, selfLinks(`ap://${did}/x/../actor`, `ap://${did}/x/%2e%2e/actor`));
168
+ const documentLoader = createLoader({ [compatibleId]: person() });
169
+ const options = {
170
+ documentLoader,
171
+ contextLoader: mockDocumentLoader,
172
+ verifyPortableObject: createVerifier()
173
+ };
174
+ equal(await lookupObject("@alice@example.com", options), null);
175
+ equal(await lookupObject(`ap://${did}/x/../actor?@gateway=https%3A%2F%2Fexample.com`, options), null);
176
+ deepStrictEqual(documentLoader.fetched, []);
177
+ });
178
+ await t.step("bounds gateway requests", async () => {
179
+ esm_default.removeRoutes();
180
+ const hints = Array.from({ length: 5 }, (_, i) => `@gateway=https%3A%2F%2Fg${i}.example`).join("&");
181
+ esm_default.get(webFingerPrefix, selfLinks(`ap://${did}/actor?${hints}`, `ap://${did}/actor2?${hints}`, `https://g9.example/.well-known/apgateway/${did}/actor`));
182
+ const documentLoader = createLoader({});
183
+ equal(await lookupObject("@alice@example.com", {
184
+ documentLoader,
185
+ contextLoader: mockDocumentLoader,
186
+ verifyPortableObject: createVerifier()
187
+ }), null);
188
+ deepStrictEqual(documentLoader.fetched, [compatibleId, ...[
189
+ 0,
190
+ 1,
191
+ 2,
192
+ 3
193
+ ].map((i) => `https://g${i}.example${gatewayPath}`)]);
194
+ });
195
+ await t.step("cancellation", async () => {
196
+ esm_default.removeRoutes();
197
+ esm_default.get(webFingerPrefix, selfLinks(`ap://${did}/actor`, `https://other.example${gatewayPath}`));
198
+ const controller = new AbortController();
199
+ const verifyPortableObject = () => {
200
+ controller.abort();
201
+ return Promise.resolve({ verified: true });
202
+ };
203
+ const documentLoader = createLoader({ [compatibleId]: person() });
204
+ equal(await lookupObject("@alice@example.com", {
205
+ documentLoader,
206
+ contextLoader: mockDocumentLoader,
207
+ verifyPortableObject,
208
+ signal: controller.signal
209
+ }), null);
210
+ deepStrictEqual(documentLoader.fetched, [compatibleId]);
211
+ });
212
+ } finally {
213
+ esm_default.removeRoutes();
214
+ esm_default.hardReset();
215
+ }
216
+ });
217
+ test("lookupObject() looks up portable identifiers directly", async () => {
218
+ const verifyPortableObject = createVerifier();
219
+ const hinted = `https://gw.example${gatewayPath}`;
220
+ const documentLoader = createLoader({
221
+ [hinted]: person(),
222
+ [`https://example.com${gatewayPath}`]: person(),
223
+ [actorId]: person()
224
+ });
225
+ const options = {
226
+ documentLoader,
227
+ contextLoader: mockDocumentLoader,
228
+ verifyPortableObject
229
+ };
230
+ for (const identifier of [
231
+ `ap://${did}/actor?@gateway=https%3A%2F%2Fgw.example`,
232
+ compatibleId,
233
+ new URL(compatibleId),
234
+ `ap://${did}/actor`,
235
+ parseIri(`ap://${did}/actor`)
236
+ ]) {
237
+ const actor = await lookupObject(identifier, options);
238
+ assertInstanceOf(actor, Person);
239
+ deepStrictEqual(actor.id, parseIri(actorId));
240
+ }
241
+ deepStrictEqual(documentLoader.fetched, [
242
+ hinted,
243
+ compatibleId,
244
+ compatibleId,
245
+ actorId,
246
+ actorId
247
+ ]);
248
+ equal(await lookupObject(`ap://${did}/actor`, {
249
+ documentLoader,
250
+ contextLoader: mockDocumentLoader
251
+ }), null);
252
+ deepStrictEqual(documentLoader.fetched.length, 5);
253
+ });
254
+ test("getActorHandle() with FEP-ef61 portable actors", {
255
+ sanitizeResources: false,
256
+ sanitizeOps: false
257
+ }, async (t) => {
258
+ const actor = new Person({
259
+ id: parseIri(actorId),
260
+ preferredUsername: "alice",
261
+ gateways: [new URL("https://example.com"), new URL("https://example.org")]
262
+ });
263
+ esm_default.spyGlobal();
264
+ try {
265
+ await t.step("verified through the first gateway", async () => {
266
+ esm_default.removeRoutes();
267
+ esm_default.get("https://example.com/.well-known/webfinger?resource=acct%3Aalice%40example.com", selfLinks(compatibleId));
268
+ deepStrictEqual(await getActorHandle(actor), "@alice@example.com");
269
+ });
270
+ await t.step("canonical self link", async () => {
271
+ esm_default.removeRoutes();
272
+ esm_default.get(webFingerPrefix, selfLinks(`ap://${did}/actor`));
273
+ deepStrictEqual(await getActorHandle(actor), "@alice@example.com");
274
+ });
275
+ await t.step("follows a verified subject", async () => {
276
+ esm_default.removeRoutes();
277
+ esm_default.get(webFingerPrefix, {
278
+ ...selfLinks(compatibleId),
279
+ subject: "acct:alice@example.org"
280
+ });
281
+ esm_default.get("begin:https://example.org/.well-known/webfinger", {
282
+ ...selfLinks(compatibleId),
283
+ subject: "acct:alice@example.org"
284
+ });
285
+ deepStrictEqual(await getActorHandle(actor), "@alice@example.org");
286
+ esm_default.removeRoutes();
287
+ esm_default.get(webFingerPrefix, {
288
+ ...selfLinks(compatibleId),
289
+ subject: "acct:alice@example.org"
290
+ });
291
+ esm_default.get("begin:https://example.org/.well-known/webfinger", selfLinks(`ap://${did}/other`));
292
+ deepStrictEqual(await getActorHandle(actor), "@alice@example.com");
293
+ });
294
+ await t.step("rejects a WebFinger response for another actor", async () => {
295
+ for (const jrd of [
296
+ selfLinks(`https://example.com/.well-known/apgateway/${did}/other`),
297
+ selfLinks("https://example.com/users/alice"),
298
+ {
299
+ subject: "acct:alice@example.com",
300
+ links: []
301
+ }
302
+ ]) {
303
+ esm_default.removeRoutes();
304
+ esm_default.get(webFingerPrefix, jrd);
305
+ await rejects(() => getActorHandle(actor), TypeError);
306
+ }
307
+ });
308
+ await t.step("does not fall back when WebFinger fails", async () => {
309
+ esm_default.removeRoutes();
310
+ esm_default.get(webFingerPrefix, 404);
311
+ await rejects(() => getActorHandle(actor), TypeError);
312
+ });
313
+ await t.step("not enough information", async () => {
314
+ esm_default.removeRoutes();
315
+ esm_default.clearHistory();
316
+ for (const target of [
317
+ parseIri(actorId),
318
+ new Person({
319
+ id: parseIri(actorId),
320
+ preferredUsername: "alice"
321
+ }),
322
+ new Person({
323
+ id: parseIri(actorId),
324
+ gateways: [new URL("https://example.com")]
325
+ })
326
+ ]) await rejects(() => getActorHandle(target), TypeError);
327
+ deepStrictEqual(esm_default.callHistory.calls().length, 0);
328
+ });
329
+ } finally {
330
+ esm_default.removeRoutes();
331
+ esm_default.hardReset();
332
+ }
333
+ });
334
+ //#endregion
335
+ export {};
@@ -1,6 +1,6 @@
1
1
  import "temporal-polyfill";
2
2
  globalThis.addEventListener = () => {};
3
- import { O as Note, R as Question, Y as vocab_exports, i as Announce, j as OrderedCollection, l as Create } from "./vocab-Dw_Vm0NE.mjs";
3
+ import { O as Note, R as Question, Y as vocab_exports, i as Announce, j as OrderedCollection, l as Create } from "./vocab-BJGPw--L.mjs";
4
4
  import { mockDocumentLoader, test } from "@fedify/fixture";
5
5
  import { deepStrictEqual, ok } from "node:assert/strict";
6
6
  import { isMarkerSafeContext, retainSignedRepresentation } from "@fedify/vocab-runtime/internal/signed-representation";
@@ -1,8 +1,8 @@
1
1
  import "temporal-polyfill";
2
2
  globalThis.addEventListener = () => {};
3
- import { G as Translation, H as Service, I as PropertyValue, K as Update, N as Organization, O as Note, P as Person, T as Link, a as Application, k as Object$1, l as Create, o as Article, s as Collection, y as Group } from "./vocab-Dw_Vm0NE.mjs";
4
- import "./actor-BX07d5tK.mjs";
5
- import "./lookup-B9lgdxnM.mjs";
3
+ import { G as Translation, H as Service, I as PropertyValue, K as Update, N as Organization, O as Note, P as Person, T as Link, a as Application, k as Object$1, l as Create, o as Article, s as Collection, y as Group } from "./vocab-BJGPw--L.mjs";
4
+ import "./actor-DT_jVPKe.mjs";
5
+ import "./lookup-4fEg7Ybl.mjs";
6
6
  import { test } from "@fedify/fixture";
7
7
  import { deepStrictEqual, ok, rejects } from "node:assert/strict";
8
8
  import { LanguageString as LanguageString$1, getDocumentLoader, preloadedContexts } from "@fedify/vocab-runtime";
@@ -1,6 +1,6 @@
1
1
  import "temporal-polyfill";
2
2
  globalThis.addEventListener = () => {};
3
- import { J as isEntityType, P as Person, T as Link, q as getEntityTypeById } from "./vocab-Dw_Vm0NE.mjs";
3
+ import { J as isEntityType, P as Person, T as Link, q as getEntityTypeById } from "./vocab-BJGPw--L.mjs";
4
4
  import { t as getTypeId } from "./type-BMdjm4Eg.mjs";
5
5
  import { test } from "@fedify/fixture";
6
6
  import { deepStrictEqual } from "node:assert/strict";