@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,11 +1,11 @@
1
1
  import { Temporal } from "temporal-polyfill";
2
2
  globalThis.addEventListener = () => {};
3
- import { A as Offer, B as QuoteRequest, C as InteractionPolicy, D as Multikey, E as Measure, F as Place, H as Service, I as PropertyValue, L as Proposal, M as OrderedCollectionPage, N as Organization, O as Note, P as Person, R as Question, S as Intent, T as Link, U as Source, V as Reject, W as Tombstone, Y as vocab_exports, _ as FeaturedItem, a as Application, b as Hashtag, c as Commitment, d as Delete, f as Document, g as FeaturedCollection, h as FeatureRequest, i as Announce, k as Object$1, l as Create, m as FeatureAuthorization, n as Activity, p as Endpoints, r as Agreement, s as Collection, t as Accept, u as CryptographicKey, v as Follow, w as InteractionRule, x as Image, y as Group, z as QuoteAuthorization } from "./vocab-Dw_Vm0NE.mjs";
3
+ import { A as Offer, B as QuoteRequest, C as InteractionPolicy, D as Multikey, E as Measure, F as Place, H as Service, I as PropertyValue, L as Proposal, M as OrderedCollectionPage, N as Organization, O as Note, P as Person, R as Question, S as Intent, T as Link, U as Source, V as Reject, W as Tombstone, Y as vocab_exports, _ as FeaturedItem, a as Application, b as Hashtag, c as Commitment, d as Delete, f as Document, g as FeaturedCollection, h as FeatureRequest, i as Announce, k as Object$1, l as Create, m as FeatureAuthorization, n as Activity, p as Endpoints, r as Agreement, s as Collection, t as Accept, u as CryptographicKey, v as Follow, w as InteractionRule, x as Image, y as Group, z as QuoteAuthorization } from "./vocab-BJGPw--L.mjs";
4
4
  import { t as assertInstanceOf } from "./utils-Df2odC27.mjs";
5
5
  import { mockDocumentLoader, test } from "@fedify/fixture";
6
6
  import { deepStrictEqual, notDeepStrictEqual, ok, rejects, throws } from "node:assert/strict";
7
- import { configure, reset } from "@logtape/logtape";
8
7
  import { LanguageString, decodeMultibase, parseDecimal } from "@fedify/vocab-runtime";
8
+ import { configure, reset } from "@logtape/logtape";
9
9
  import { pascalCase } from "es-toolkit";
10
10
  import { areAllScalarTypes, loadSchemaFiles } from "@fedify/vocab-tools";
11
11
  //#region src/vocab.test.ts
@@ -1227,7 +1227,8 @@ test("Activity.getObject() fetches canonical portable IRIs", async () => {
1227
1227
  contextLoader: mockDocumentLoader
1228
1228
  })).getObject({
1229
1229
  documentLoader,
1230
- contextLoader: mockDocumentLoader
1230
+ contextLoader: mockDocumentLoader,
1231
+ verifyPortableObject: async () => ({ verified: true })
1231
1232
  }), Note);
1232
1233
  deepStrictEqual(fetchedUrls, ["ap+ef61://did:key:z6Mkabc/objects/1"]);
1233
1234
  });
@@ -2870,7 +2871,11 @@ test("FEP-fe34: Portable DID authorities are cross-origin boundaries", async ()
2870
2871
  };
2871
2872
  throw new Error("Document not found");
2872
2873
  };
2873
- const result = await create.getObject({ documentLoader });
2874
+ const result = await create.getObject({
2875
+ documentLoader,
2876
+ contextLoader: mockDocumentLoader,
2877
+ verifyPortableObject: async () => ({ verified: true })
2878
+ });
2874
2879
  assertInstanceOf(result, Note);
2875
2880
  deepStrictEqual(result.content, "Fetched portable note");
2876
2881
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fedify/vocab",
3
- "version": "2.4.0-dev.2035+d1f8f8e2",
3
+ "version": "2.4.0-dev.2054+1756e190",
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.4.0-dev.2035+d1f8f8e2",
47
- "@fedify/webfinger": "2.4.0-dev.2035+d1f8f8e2",
48
- "@fedify/vocab-runtime": "2.4.0-dev.2035+d1f8f8e2"
46
+ "@fedify/vocab-tools": "2.4.0-dev.2054+1756e190",
47
+ "@fedify/vocab-runtime": "2.4.0-dev.2054+1756e190",
48
+ "@fedify/webfinger": "2.4.0-dev.2054+1756e190"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@types/node": "^24.12.2",
package/src/actor.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  import type { GetUserAgentOptions } from "@fedify/vocab-runtime";
2
- import { lookupWebFinger } from "@fedify/webfinger";
2
+ import {
3
+ canonicalizePortableUri,
4
+ formatIri,
5
+ fromCompatibleEf61Id,
6
+ isGatewayUrl,
7
+ } from "@fedify/vocab-runtime";
8
+ import { lookupWebFinger, type ResourceDescriptor } from "@fedify/webfinger";
3
9
  import {
4
10
  type Attributes,
5
11
  type Counter,
@@ -93,6 +99,11 @@ function getActorDiscoveryRemoteHost(
93
99
  ): string | undefined {
94
100
  const id = actor instanceof URL ? actor : actor.id;
95
101
  if (id == null) return undefined;
102
+ // The authority of a portable ID is a DID, not a host; the handle of
103
+ // a portable actor comes from its first gateway:
104
+ if (isPortableActorId(id)) {
105
+ return actor instanceof URL ? undefined : actor.gateway?.host;
106
+ }
96
107
  return id.host === "" ? undefined : id.host;
97
108
  }
98
109
 
@@ -234,6 +245,17 @@ export interface GetActorHandleOptions extends NormalizeActorHandleOptions {
234
245
  * await getActorHandle(new URL("https://fosstodon.org/users/hongminhee"));
235
246
  * ```
236
247
  *
248
+ * For an [FEP-ef61] portable actor, whose ID is an `ap:` or `ap+ef61:` URI,
249
+ * the domain is taken from the first gateway in the actor's `gateways` rather
250
+ * than from its ID. The handle is returned only if its WebFinger response
251
+ * links back to the actor, since the actor's `gateways` are claimed by the
252
+ * actor itself; a failed WebFinger lookup is not enough information to get
253
+ * the handle. A portable actor without `gateways` or
254
+ * `preferredUsername`, or a portable actor URI (which does not tell its
255
+ * gateways), is not supported.
256
+ *
257
+ * [FEP-ef61]: https://w3id.org/fep/ef61
258
+ *
237
259
  * @param actor The actor or actor URI to get the handle from.
238
260
  * @param options The extra options for getting the actor handle.
239
261
  * @returns The actor handle. It starts with `@` and is followed by the
@@ -302,6 +324,10 @@ async function getActorHandleInternal(
302
324
  options: GetActorHandleOptions = {},
303
325
  ): Promise<`@${string}@${string}` | `${string}@${string}`> {
304
326
  const actorId = actor instanceof URL ? actor : actor.id;
327
+ if (actorId != null && isPortableActorId(actorId)) {
328
+ if (actor instanceof URL) throw new ActorHandleNotFoundError();
329
+ return await getPortableActorHandle(actor, actorId, options);
330
+ }
305
331
  if (actorId != null) {
306
332
  const result = await lookupWebFinger(actorId, {
307
333
  userAgent: options.userAgent,
@@ -344,6 +370,89 @@ async function getActorHandleInternal(
344
370
  throw new ActorHandleNotFoundError();
345
371
  }
346
372
 
373
+ function isPortableActorId(id: URL): boolean {
374
+ return id.protocol === "ap:" || id.protocol === "ap+ef61:";
375
+ }
376
+
377
+ /**
378
+ * The reverse discovery of an FEP-ef61 portable actor's WebFinger address,
379
+ * which takes the domain from the first gateway in its `gateways`.
380
+ */
381
+ async function getPortableActorHandle(
382
+ actor: Actor,
383
+ actorId: URL,
384
+ options: GetActorHandleOptions,
385
+ ): Promise<`@${string}@${string}` | `${string}@${string}`> {
386
+ const gateway = actor.gateway;
387
+ const username = actor.preferredUsername?.toString();
388
+ if (gateway == null || !isGatewayUrl(gateway) || username == null) {
389
+ throw new ActorHandleNotFoundError();
390
+ }
391
+ const canonicalId = canonicalizePortableUri(formatIri(actorId));
392
+ const handle = `acct:${username}@${gateway.host}`;
393
+ const result = await lookupWebFinger(handle, {
394
+ userAgent: options.userAgent,
395
+ tracerProvider: options.tracerProvider,
396
+ meterProvider: options.meterProvider,
397
+ });
398
+ // Unlike an ordinary actor, whose ID vouches for its host, a portable
399
+ // actor's gateways are claimed by the actor itself, so there is no
400
+ // fallback when the gateway does not link back to the actor:
401
+ if (result == null || !linksToPortableActor(result, canonicalId)) {
402
+ throw new ActorHandleNotFoundError();
403
+ }
404
+ // The subject is the canonical address when it differs from the queried
405
+ // one; follow it once if it also links back to the actor:
406
+ const subject = result.subject?.match(/^acct:([^@]+)@([^@]+)$/);
407
+ if (subject != null && result.subject !== handle) {
408
+ const subjectResult = await lookupWebFinger(result.subject!, {
409
+ userAgent: options.userAgent,
410
+ tracerProvider: options.tracerProvider,
411
+ meterProvider: options.meterProvider,
412
+ });
413
+ if (
414
+ subjectResult != null && linksToPortableActor(subjectResult, canonicalId)
415
+ ) {
416
+ return normalizeActorHandle(`@${subject[1]}@${subject[2]}`, options);
417
+ }
418
+ }
419
+ return normalizeActorHandle(`@${username}@${gateway.host}`, options);
420
+ }
421
+
422
+ /**
423
+ * Checks if the first ActivityStreams `self` link of a WebFinger response
424
+ * identifies the portable actor, either by its portable ID or by
425
+ * a compatible identifier.
426
+ */
427
+ function linksToPortableActor(
428
+ jrd: ResourceDescriptor,
429
+ canonicalId: string,
430
+ ): boolean {
431
+ const link = jrd.links?.find((l) =>
432
+ l.rel === "self" && l.href != null &&
433
+ (l.type === "application/activity+json" ||
434
+ l.type?.match(
435
+ /application\/ld\+json;\s*profile="https:\/\/www.w3.org\/ns\/activitystreams"/,
436
+ ) != null)
437
+ );
438
+ if (link?.href == null) return false;
439
+ try {
440
+ let id: string;
441
+ if (/^ap(?:\+ef61)?:/i.test(link.href)) id = link.href;
442
+ else {
443
+ const portable = URL.canParse(link.href)
444
+ ? fromCompatibleEf61Id(link.href)
445
+ : null;
446
+ if (portable == null) return false;
447
+ id = formatIri(portable);
448
+ }
449
+ return canonicalizePortableUri(id) === canonicalId;
450
+ } catch (error) {
451
+ if (error instanceof TypeError) return false;
452
+ throw error;
453
+ }
454
+ }
455
+
347
456
  async function verifyCrossOriginActorHandle(
348
457
  actorId: string,
349
458
  alias: string,