@fedify/fedify 2.4.0-dev.2084 → 2.4.0-dev.2093

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/{builder-BGlpUocK.mjs → builder-Ld4Y9ymR.mjs} +34 -3
  2. package/dist/compat/transformers.test.mjs +1 -1
  3. package/dist/{compound-proof-B6Aj2kGY.mjs → compound-proof-CTx_lewQ.mjs} +191 -13
  4. package/dist/{deno-Dn-py9Zy.mjs → deno-8t8k_6Yv.mjs} +1 -1
  5. package/dist/{docloader-BUq5bYJr.mjs → docloader-DJKcsAiF.mjs} +2 -2
  6. package/dist/federation/builder.test.mjs +1 -1
  7. package/dist/federation/compound-fanout.test.mjs +3 -3
  8. package/dist/federation/handler-compound-proof.test.mjs +3 -3
  9. package/dist/federation/handler.test.mjs +5 -5
  10. package/dist/federation/hashlink-media.test.mjs +2 -2
  11. package/dist/federation/idempotency.test.mjs +2 -2
  12. package/dist/federation/metrics.test.mjs +1 -1
  13. package/dist/federation/middleware.test.mjs +7 -7
  14. package/dist/federation/mod.cjs +1 -1
  15. package/dist/federation/mod.js +1 -1
  16. package/dist/federation/outgoing-proof.test.mjs +254 -5
  17. package/dist/federation/portable-gateway-keys.test.mjs +72 -11
  18. package/dist/federation/portable-inbox.test.mjs +116 -4
  19. package/dist/federation/portable.test.mjs +182 -4
  20. package/dist/federation/send.test.mjs +3 -3
  21. package/dist/federation/tasks/enqueue.test.mjs +1 -1
  22. package/dist/federation/tasks/tasks.test.mjs +2 -2
  23. package/dist/federation/temporal.test.mjs +1 -1
  24. package/dist/federation/webfinger.test.mjs +89 -1
  25. package/dist/{federation-BLGx8JUH.cjs → federation-aSHO04ob.cjs} +360 -65
  26. package/dist/{http-B0VRNipE.mjs → http-B2ftY0Ha.mjs} +4 -4
  27. package/dist/{http-B5q1CHtL.cjs → http-DSaSAN7X.cjs} +1609 -1457
  28. package/dist/{http-4PCCvhsU.js → http-DyT7DP1Y.js} +1518 -1390
  29. package/dist/{key-BJrp524f.mjs → key-Dwm4VswT.mjs} +39 -99
  30. package/dist/{kv-cache-Dhhhrryv.cjs → kv-cache-BSJAlP4C.cjs} +1 -1
  31. package/dist/{kv-cache-C4C3LyYW.mjs → kv-cache-D0yYukqk.mjs} +1 -1
  32. package/dist/{kv-cache-1KUmAPH7.js → kv-cache-DmroAz3M.js} +1 -1
  33. package/dist/{ld-BmFz5NLA.mjs → ld-BYZOoDzZ.mjs} +3 -3
  34. package/dist/{metrics-Beod6tzO.mjs → metrics-B7YwKTFr.mjs} +1 -1
  35. package/dist/{middleware-B7CNsyqt.mjs → middleware-BNeqKSsL.mjs} +1 -1
  36. package/dist/{middleware-D_9pNYe9.js → middleware-Blq0882d.js} +368 -73
  37. package/dist/{middleware-BKeQYPm0.mjs → middleware-E_0IH72m.mjs} +166 -71
  38. package/dist/{mod-HDSvS-ww.d.cts → mod-C8LieBzF.d.cts} +16 -3
  39. package/dist/{mod-C9WSpZWm.d.ts → mod-Coacostr.d.ts} +16 -3
  40. package/dist/mod.cjs +5 -5
  41. package/dist/mod.d.cts +1 -1
  42. package/dist/mod.d.ts +1 -1
  43. package/dist/mod.js +5 -5
  44. package/dist/nodeinfo/handler.test.mjs +1 -1
  45. package/dist/{owner-CsKVgMzk.mjs → owner-Cgx2Dc7I.mjs} +5 -4
  46. package/dist/{owner-_pbbgLJK.js → owner-DAGSC5gX.js} +2 -2
  47. package/dist/{owner-BQFZtFV4.cjs → owner-szIZl4qI.cjs} +2 -2
  48. package/dist/{portable-key-B4R-CiIV.mjs → portable-key-Cn1WJSN1.mjs} +60 -41
  49. package/dist/portable-key-id-Bva5iT3W.mjs +136 -0
  50. package/dist/{proof-ozG4HfcQ.mjs → proof-DVXC6nYz.mjs} +59 -18
  51. package/dist/{send-ClPt8nYu.mjs → send-D-hOvYKy.mjs} +3 -3
  52. package/dist/sig/compound-proof-discovery.test.mjs +1 -1
  53. package/dist/sig/compound-proof-producer.test.mjs +1 -1
  54. package/dist/sig/compound-proof-verification.test.mjs +160 -3
  55. package/dist/sig/compound-proof.test.mjs +2 -2
  56. package/dist/sig/http.test.mjs +2 -2
  57. package/dist/sig/key.test.mjs +1 -1
  58. package/dist/sig/ld.test.mjs +2 -2
  59. package/dist/sig/mod.cjs +3 -3
  60. package/dist/sig/mod.d.cts +1 -1
  61. package/dist/sig/mod.d.ts +1 -1
  62. package/dist/sig/mod.js +3 -3
  63. package/dist/sig/owner.test.mjs +1 -1
  64. package/dist/sig/portable-collection.test.mjs +69 -5
  65. package/dist/sig/portable-key.test.mjs +176 -10
  66. package/dist/sig/proof.test.mjs +107 -4
  67. package/dist/{sig-DhdomAN0.js → sig-B5Ell7pO.js} +19 -3
  68. package/dist/{sig-k3C8sF7g.cjs → sig-CXOKN4Br.cjs} +19 -3
  69. package/dist/{temporal-Ba0EAZAI.mjs → temporal-CqpKGEPk.mjs} +1 -1
  70. package/dist/testing/kv.test.mjs +1 -1
  71. package/dist/utils/docloader.test.mjs +2 -2
  72. package/dist/utils/kv-cache.test.mjs +1 -1
  73. package/dist/utils/mod.cjs +1 -1
  74. package/dist/utils/mod.js +1 -1
  75. package/package.json +7 -7
@@ -2,16 +2,17 @@ import { Temporal } from "temporal-polyfill";
2
2
  import { URLPattern } from "urlpattern-polyfill";
3
3
  import { t as __exportAll } from "./rolldown-runtime-BC-Eo5Mj.js";
4
4
  import { r as getDefaultActivityTransformers } from "./transformers-DAtXl_Kc.js";
5
- import { $ as recordCollectionPageItems, B as getCanonicalPortableId, C as hasSignature, D as signJsonLd, E as isInvalidUrlTypeError, H as hasPortableActor, J as getRemoteHost, K as getDurationMs, L as importJwk, M as exportJwk, O as verifyCompactJsonLd, Q as recordCollectionDispatchDuration, R as validateCryptoKey, S as getNormalizationContextLoader, T as isClearlyMalformedContextReference, U as isCompatibleKeyId, V as getGatewayKeyBase, W as isPortableUri, X as isAbortError, Y as instrumentDocumentLoader, Z as recordCircuitBreakerStateChange, _ as assertSafeJsonLd, a as verifyRequestDetailed, at as recordOutboxActivity, c as hasProofLike, ct as registerQueueDepthGauge, d as verifyObject, et as recordCollectionRequest, f as verifyPortableObjectProof, g as InvalidContextReferenceError, ht as version, i as verifyRequest, it as recordInboxActivity, j as wrapContextLoaderForJsonLd, l as signObject, lt as normalizeOutgoingActivityJsonLd, mt as name, n as parseRfc9421SignatureInput, ot as recordOutboxEnqueue, p as verifyPortableObjectProofPolicy, q as getFederationMetrics, rt as recordFanoutRecipients, st as recordWebFingerHandle, t as doubleKnock, tt as recordCollectionTotalItems, u as verifyMapLocalProof, ut as formatAcceptSignature, w as hasSignatureLike, x as detachSignature, y as compactJsonLd } from "./http-4PCCvhsU.js";
5
+ import { $ as recordFanoutRecipients, A as verifyCompactJsonLd, B as validateCryptoKey, C as detachSignature, D as isClearlyMalformedContextReference, E as hasSignatureLike, G as instrumentDocumentLoader, H as getDurationMs, J as recordCollectionDispatchDuration, K as isAbortError, N as wrapContextLoaderForJsonLd, O as isInvalidUrlTypeError, P as exportJwk, T as hasSignature, U as getFederationMetrics, W as getRemoteHost, X as recordCollectionRequest, Y as recordCollectionPageItems, Z as recordCollectionTotalItems, _ as normalizeOutgoingActivityJsonLd, a as verifyRequestDetailed, at as preloadedOnlyDocumentLoader, d as verifyMapLocalProof, dt as getGatewayKeyBase, et as recordInboxActivity, f as verifyObject, ft as getPortableDid, gt as isPortableUri, h as verifyPortableObjectProofWithRoot, ht as isPortableId, i as verifyRequest, it as registerQueueDepthGauge, k as signJsonLd, l as hasProofLike, m as verifyPortableObjectProofPolicy, mt as isCompatibleKeyId, n as parseRfc9421SignatureInput, nt as recordOutboxEnqueue, ot as formatAcceptSignature, p as verifyPortableObjectProof, pt as hasPortableActor, q as recordCircuitBreakerStateChange, rt as recordWebFingerHandle, s as InvalidPortableObjectIdError, t as doubleKnock, tt as recordOutboxActivity, u as signObject, ut as getCanonicalPortableId, v as InvalidContextReferenceError, vt as name, w as getNormalizationContextLoader, x as compactJsonLd, y as assertSafeJsonLd, yt as version, z as importJwk } from "./http-DyT7DP1Y.js";
6
6
  import { a as MAX_BODY_SIZE, i as BodyTooLargeError, n as getNodeInfo, o as readBoundedText, t as nodeInfoToJson } from "./types-C_cNlNVK.js";
7
- import { n as getKeyOwner, t as doesActorOwnKey } from "./owner-_pbbgLJK.js";
8
- import { n as getAuthenticatedDocumentLoader, t as kvCache } from "./kv-cache-1KUmAPH7.js";
7
+ import { n as getKeyOwner, t as doesActorOwnKey } from "./owner-DAGSC5gX.js";
8
+ import { n as getAuthenticatedDocumentLoader, t as kvCache } from "./kv-cache-DmroAz3M.js";
9
9
  import { getLogger, withContext } from "@logtape/logtape";
10
10
  import { Router, RouterError, assertPath } from "@fedify/uri-template";
11
11
  import { Activity, Collection, CollectionPage, CryptographicKey, Link, Multikey, Object as Object$1, OrderedCollection, OrderedCollectionPage, Tombstone, getTypeId, lookupObject, traverseCollection } from "@fedify/vocab";
12
12
  import { SpanKind, SpanStatusCode, context, metrics, propagation, trace } from "@opentelemetry/api";
13
+ import { FetchError, UrlError, canonicalizePortableUri, decodeMultibase, formatIri, fromCompatibleEf61Id, getDocumentLoader, getFe34Origin, isGatewayUrl, parseDigestMultibase, parseHashlink, parseIri, toCompatibleEf61Id, validatePublicUrl } from "@fedify/vocab-runtime";
14
+ import { isCompatibleEf61Iri } from "@fedify/vocab-runtime/internal/portable-dereference";
13
15
  import { encodeHex } from "byte-encodings/hex";
14
- import { FetchError, UrlError, canonicalizePortableUri, decodeMultibase, formatIri, fromCompatibleEf61Id, getDocumentLoader, getFe34Origin, haveSameFe34Origin, isGatewayUrl, parseDigestMultibase, parseHashlink, parseIri, toCompatibleEf61Id, validatePublicUrl } from "@fedify/vocab-runtime";
15
16
  import jsonld from "@fedify/vocab-runtime/jsonld";
16
17
  import { uniq } from "es-toolkit";
17
18
  import { ATTR_HTTP_REQUEST_HEADER, ATTR_HTTP_REQUEST_METHOD, ATTR_HTTP_RESPONSE_HEADER, ATTR_HTTP_RESPONSE_STATUS_CODE, ATTR_URL_FULL } from "@opentelemetry/semantic-conventions";
@@ -205,10 +206,11 @@ var FederationBuilderImpl = class {
205
206
  "federation",
206
207
  "actor"
207
208
  ]);
208
- const portable = actor.id?.protocol === "ap:" || actor.id?.protocol === "ap+ef61:";
209
+ const portable = actor.id != null && isPortableId(actor.id);
209
210
  if (actor.id == null) logger.warn("Actor dispatcher returned an actor without an id property. Set the property with Context.getActorUri(identifier).");
210
211
  else if (!portable && actor.id.href != context.getActorUri(identifier).href) logger.warn("Actor dispatcher returned an actor with an id property that does not match the actor URI. Set the property with Context.getActorUri(identifier).");
211
212
  if (actor instanceof Tombstone) return actor;
213
+ if (actor.id != null && isCompatibleEf61Iri(actor.id)) warnCompatibleActorId(actor.id, actor.gateway);
212
214
  if (this.followingCallbacks != null && this.followingCallbacks.dispatcher != null) {
213
215
  if (actor.followingId == null) logger.warn("You configured a following collection dispatcher, but the actor does not have a following property. Set the property with Context.getFollowingUri(identifier).");
214
216
  else if (!portable && actor.followingId.href != context.getFollowingUri(identifier).href) logger.warn("You configured a following collection dispatcher, but the actor's following property does not match the following collection URI. Set the property with Context.getFollowingUri(identifier).");
@@ -733,6 +735,33 @@ var FederationBuilderImpl = class {
733
735
  function createFederationBuilder() {
734
736
  return new FederationBuilderImpl();
735
737
  }
738
+ /**
739
+ * Warns about an FEP-ef61 portable actor identified by a compatible identifier
740
+ * that is malformed, or is not on the actor's first gateway, where FEP-ef61
741
+ * requires publishers to construct compatible identifiers.
742
+ */
743
+ function warnCompatibleActorId(actorId, gateway) {
744
+ const logger = getLogger([
745
+ "fedify",
746
+ "federation",
747
+ "actor"
748
+ ]);
749
+ try {
750
+ fromCompatibleEf61Id(actorId);
751
+ } catch (error) {
752
+ if (!(error instanceof TypeError)) throw error;
753
+ logger.warn("Actor dispatcher returned an actor whose id property, {actorId}, is a malformed FEP-ef61 compatible identifier: {error}", {
754
+ actorId: actorId.href,
755
+ error
756
+ });
757
+ return;
758
+ }
759
+ if (gateway == null || !isGatewayUrl(gateway)) logger.warn("Actor dispatcher returned an actor whose id property, {actorId}, is an FEP-ef61 compatible identifier, but whose first gateway is not an HTTP(S) origin. Set the gateways property, and construct the compatible identifier with its first gateway.", { actorId: actorId.href });
760
+ else if (actorId.origin !== gateway.origin) logger.warn("Actor dispatcher returned an actor whose id property, {actorId}, is an FEP-ef61 compatible identifier on another gateway than its first gateway, {gateway}. FEP-ef61 requires publishers to construct compatible identifiers with the first gateway in the actor's gateways.", {
761
+ actorId: actorId.href,
762
+ gateway: gateway.origin
763
+ });
764
+ }
736
765
  //#endregion
737
766
  //#region src/federation/circuit-breaker.ts
738
767
  const LEGACY_SWEEP_MARKER = ["__fedify_meta", "circuit_breaker_state_ttl_sweep_v2"];
@@ -1309,7 +1338,14 @@ async function buildCollectionSynchronizationHeader(collectionId, actorIds) {
1309
1338
  //#endregion
1310
1339
  //#region src/sig/compound-proof.ts
1311
1340
  const textEncoder = new TextEncoder();
1312
- const PORTABLE_OBJECT_ID_PATTERN = /^ap(?:\+ef61)?:\/\//i;
1341
+ /**
1342
+ * Checks whether a literal `id` or `@id` value makes a map a portable object:
1343
+ * an `ap:` or `ap+ef61:` URI, or anything that looks like an FEP-ef61
1344
+ * compatible identifier, even a malformed one, which then fails the policy.
1345
+ */
1346
+ function isPortableIdValue(value) {
1347
+ return typeof value === "string" && isPortableId(value);
1348
+ }
1313
1349
  function isJsonMap(value) {
1314
1350
  return typeof value === "object" && value != null && !Array.isArray(value);
1315
1351
  }
@@ -1555,18 +1591,20 @@ function collectPortableObjects(snapshot) {
1555
1591
  for (let index = current.value.length - 1; index >= 0; index--) pending.push({
1556
1592
  value: current.value[index],
1557
1593
  path: childPath(current.path, String(index)),
1558
- depth: current.depth + 1
1594
+ depth: current.depth + 1,
1595
+ keyOf: current.keyOf
1559
1596
  });
1560
1597
  continue;
1561
1598
  }
1562
1599
  if (!isCompoundProofJsonObject(current.value)) continue;
1563
1600
  const document = current.value;
1564
- const id = [document.id, document["@id"]].find((value) => typeof value === "string" && PORTABLE_OBJECT_ID_PATTERN.test(value));
1601
+ const id = [document.id, document["@id"]].find(isPortableIdValue);
1565
1602
  if (typeof id === "string") objects.push(Object.freeze({
1566
1603
  path: current.path,
1567
1604
  id,
1568
1605
  depth: current.depth,
1569
- document
1606
+ document,
1607
+ ...current.keyOf == null ? {} : { keyOf: current.keyOf }
1570
1608
  }));
1571
1609
  const entries = Object.entries(document).sort(([left], [right]) => comparePaths(left, right));
1572
1610
  for (let index = entries.length - 1; index >= 0; index--) {
@@ -1575,7 +1613,11 @@ function collectPortableObjects(snapshot) {
1575
1613
  pending.push({
1576
1614
  value,
1577
1615
  path: childPath(current.path, key),
1578
- depth: current.depth + 1
1616
+ depth: current.depth + 1,
1617
+ ...typeof id === "string" && (key === "publicKey" || key === "assertionMethod") ? { keyOf: {
1618
+ path: current.path,
1619
+ member: key
1620
+ } } : {}
1579
1621
  });
1580
1622
  }
1581
1623
  }
@@ -1583,8 +1625,9 @@ function collectPortableObjects(snapshot) {
1583
1625
  return objects;
1584
1626
  }
1585
1627
  /**
1586
- * Determines whether a received JSON tree contains a portable object ID
1587
- * outside a proof or context definition, without exceeding discovery limits.
1628
+ * Determines whether a received JSON tree contains a portable object ID, i.e.,
1629
+ * an `ap:` or `ap+ef61:` URI or an FEP-ef61 compatible identifier, outside
1630
+ * a proof or context definition, without exceeding discovery limits.
1588
1631
  *
1589
1632
  * This is only an applicability check. It does not authenticate the input;
1590
1633
  * callers must still use {@link verifyCompoundPortableObjectProofs} to obtain
@@ -1631,7 +1674,7 @@ function inspectCompoundPortableObjectApplicability(json, limits) {
1631
1674
  } else if (isJsonMap(value)) {
1632
1675
  mapCount++;
1633
1676
  if (mapCount > limits.maxMaps) return "indeterminate";
1634
- if ([value.id, value["@id"]].some((id) => typeof id === "string" && PORTABLE_OBJECT_ID_PATTERN.test(id))) return "present";
1677
+ if ([value.id, value["@id"]].some(isPortableIdValue)) return "present";
1635
1678
  if (!addByteLength(2)) return "indeterminate";
1636
1679
  const children = function* () {
1637
1680
  let first = true;
@@ -1694,7 +1737,8 @@ function findInJsonMaps(json, visit) {
1694
1737
  /**
1695
1738
  * Determines whether an outgoing JSON tree contains portable content that
1696
1739
  * requires producer-side proof-shape checks: a map, outside proof and context
1697
- * values, whose direct `id` or `@id` is a portable ActivityPub URI.
1740
+ * values, whose direct `id` or `@id` is a portable ActivityPub URI or an
1741
+ * FEP-ef61 compatible identifier.
1698
1742
  *
1699
1743
  * This deliberately differs from
1700
1744
  * {@link inspectCompoundPortableObjectApplicability}: it applies no resource
@@ -1707,7 +1751,7 @@ function findInJsonMaps(json, visit) {
1707
1751
  * @internal
1708
1752
  */
1709
1753
  function containsCompoundPortableObject(json) {
1710
- return findInJsonMaps(json, (map) => [map.id, map["@id"]].some((id) => typeof id === "string" && PORTABLE_OBJECT_ID_PATTERN.test(id)) ? true : void 0) ?? false;
1754
+ return findInJsonMaps(json, (map) => [map.id, map["@id"]].some(isPortableIdValue) ? true : void 0) ?? false;
1711
1755
  }
1712
1756
  /**
1713
1757
  * Finds the first direct `proof` member whose value the map-local
@@ -1804,7 +1848,7 @@ async function verifyCompoundPortableObjectProofs(json, limits, options = {}) {
1804
1848
  if (discovered.status !== "ok") return discovered;
1805
1849
  const proofs = await verifyDiscoveredProofDocuments(discovered, options);
1806
1850
  const proofByPath = new Map(proofs.map((proof) => [proof.path, proof]));
1807
- const portableObjects = await Promise.all(collectPortableObjects(discovered.snapshot).map(async (object) => {
1851
+ const verifyPortableObject = async (object) => {
1808
1852
  const metadata = {
1809
1853
  path: object.path,
1810
1854
  id: object.id,
@@ -1836,16 +1880,174 @@ async function verifyCompoundPortableObjectProofs(json, limits, options = {}) {
1836
1880
  reason: { type: "invalidPortableObject" }
1837
1881
  });
1838
1882
  }
1883
+ };
1884
+ const collected = collectPortableObjects(discovered.snapshot);
1885
+ const objectByPath = new Map(collected.map((object) => [object.path, object]));
1886
+ const isKeyCandidate = (object) => object.keyOf != null && !Object.hasOwn(object.document, "proof");
1887
+ const results = /* @__PURE__ */ new Map();
1888
+ for (const result of await Promise.all(collected.filter((object) => !isKeyCandidate(object)).map(verifyPortableObject))) results.set(result.path, result);
1889
+ let projections = 0;
1890
+ for (const object of collected) {
1891
+ if (!isKeyCandidate(object)) continue;
1892
+ const { path, member } = object.keyOf;
1893
+ const parent = results.get(path);
1894
+ const parentObject = objectByPath.get(path);
1895
+ if (parent?.verified === true && parentObject != null && projections++ < MAX_KEY_PROJECTIONS && await isEmbeddedPortableKey(parentObject.document, member, object.document, parent.id)) continue;
1896
+ results.set(object.path, await verifyPortableObject(object));
1897
+ }
1898
+ const portableObjects = Object.freeze(collected.flatMap((object) => {
1899
+ const result = results.get(object.path);
1900
+ return result == null ? [] : [result];
1839
1901
  }));
1840
1902
  return {
1841
1903
  status: "ok",
1842
1904
  verified: portableObjects.length > 0 && proofs.every((proof) => proof.verified) && portableObjects.every((object) => object.verified),
1843
1905
  snapshot: discovered.snapshot,
1844
1906
  proofs,
1845
- portableObjects: Object.freeze(portableObjects),
1907
+ portableObjects,
1846
1908
  statistics: discovered.statistics
1847
1909
  };
1848
1910
  }
1911
+ /**
1912
+ * The maximum number of embedded keys per compound document that are checked
1913
+ * by {@link isEmbeddedPortableKey}, each of which expands a projection of its
1914
+ * parent. Keys beyond it have to carry their own proofs.
1915
+ */
1916
+ const MAX_KEY_PROJECTIONS = 64;
1917
+ const SECURITY = "https://w3id.org/security#";
1918
+ const KEY_TYPES = /* @__PURE__ */ new Set([`${SECURITY}Key`, `${SECURITY}Multikey`]);
1919
+ const KEY_PROPERTIES = /* @__PURE__ */ new Set([
1920
+ "@id",
1921
+ "@type",
1922
+ `${SECURITY}owner`,
1923
+ `${SECURITY}controller`,
1924
+ `${SECURITY}publicKeyPem`,
1925
+ `${SECURITY}publicKeyMultibase`
1926
+ ]);
1927
+ /**
1928
+ * Tells whether an unsigned map embedded in a verified portable map is just
1929
+ * a key of the portable object the parent is, e.g., a gateway key of
1930
+ * a portable actor, which the parent's proof covers.
1931
+ *
1932
+ * The key's meaning depends on the contexts active where it sits, so it is
1933
+ * decided on a projection of the parent that keeps only what those contexts
1934
+ * depend on: the parent's `@context`, its types and ID, and the member that
1935
+ * holds the key. That holds only if nothing else in the parent can change
1936
+ * them, so both documents may only use contexts that define no keyword
1937
+ * aliases and no scoped contexts; Fedify's preloaded contexts, the only
1938
+ * remote ones the compound policy loads, alias just `id` and `type`.
1939
+ *
1940
+ * The projection must expand to the parent's verified ID with the key as the
1941
+ * single value of `publicKey` or `assertionMethod`, and the key must be
1942
+ * nothing but a `CryptographicKey` or `Multikey` whose ID is a compatible
1943
+ * identifier of the parent plus a fragment, and whose owner or controller,
1944
+ * if any, is the parent.
1945
+ */
1946
+ async function isEmbeddedPortableKey(parent, member, key, parentId) {
1947
+ const parentPortableId = getCanonicalPortableIdOf(parentId);
1948
+ if (parentPortableId == null) return false;
1949
+ if (!isSimpleContext(parent["@context"]) || Object.hasOwn(key, "@context") && !isSimpleContext(key["@context"])) return false;
1950
+ const literalIds = [key.id, key["@id"]].filter((id) => id !== void 0);
1951
+ if (literalIds.length < 1 || literalIds.some((id) => id !== literalIds[0]) || typeof literalIds[0] !== "string" || !isKeyIdOf(literalIds[0], parentPortableId)) return false;
1952
+ const projection = {};
1953
+ for (const property of [
1954
+ "@context",
1955
+ "type",
1956
+ "@type",
1957
+ "id",
1958
+ "@id"
1959
+ ]) if (Object.hasOwn(parent, property)) projection[property] = parent[property];
1960
+ projection[member] = key;
1961
+ let expanded;
1962
+ try {
1963
+ expanded = await jsonld.expand(structuredClone(projection), {
1964
+ documentLoader: getNormalizationContextLoader(preloadedOnlyDocumentLoader),
1965
+ keepFreeFloatingNodes: true
1966
+ });
1967
+ } catch {
1968
+ return false;
1969
+ }
1970
+ if (expanded.length !== 1 || hasGraphKeyword(expanded)) return false;
1971
+ const node = expanded[0];
1972
+ if (!isJsonMap(node) || typeof node["@id"] !== "string" || getCanonicalPortableIdOf(node["@id"]) !== parentPortableId) return false;
1973
+ const properties = Object.keys(node).filter((property) => property !== "@id" && property !== "@type");
1974
+ if (properties.length !== 1 || properties[0] !== `${SECURITY}${member}`) return false;
1975
+ const values = node[properties[0]];
1976
+ if (!Array.isArray(values) || values.length !== 1) return false;
1977
+ const expandedKey = values[0];
1978
+ if (!isJsonMap(expandedKey) || expandedKey["@id"] !== literalIds[0] || Object.keys(expandedKey).some((property) => !KEY_PROPERTIES.has(property))) return false;
1979
+ const types = expandedKey["@type"];
1980
+ if (types !== void 0 && (!Array.isArray(types) || types.some((type) => typeof type !== "string" || !KEY_TYPES.has(type)))) return false;
1981
+ const pem = expandedKey[`${SECURITY}publicKeyPem`];
1982
+ const multibase = expandedKey[`${SECURITY}publicKeyMultibase`];
1983
+ if (pem === void 0 && multibase === void 0) return false;
1984
+ if (pem !== void 0 && !isSingleStringValue(pem)) return false;
1985
+ if (multibase !== void 0 && !isSingleStringValue(multibase, `${SECURITY}multibase`)) return false;
1986
+ for (const property of [`${SECURITY}owner`, `${SECURITY}controller`]) {
1987
+ const owners = expandedKey[property];
1988
+ if (owners === void 0) continue;
1989
+ if (!Array.isArray(owners) || owners.length !== 1 || !isJsonMap(owners[0]) || Object.keys(owners[0]).length !== 1 || typeof owners[0]["@id"] !== "string" || getCanonicalPortableIdOf(owners[0]["@id"]) !== parentPortableId) return false;
1990
+ }
1991
+ return true;
1992
+ }
1993
+ function getCanonicalPortableIdOf(id) {
1994
+ try {
1995
+ return getCanonicalPortableId(parseIri(id));
1996
+ } catch (error) {
1997
+ if (error instanceof TypeError) return null;
1998
+ throw error;
1999
+ }
2000
+ }
2001
+ /**
2002
+ * Checks whether an ID is a compatible identifier of the given portable
2003
+ * object plus a fragment.
2004
+ */
2005
+ function isKeyIdOf(id, portableId) {
2006
+ let url;
2007
+ try {
2008
+ if (fromCompatibleEf61Id(id) == null) return false;
2009
+ url = new URL(id);
2010
+ } catch (error) {
2011
+ if (error instanceof TypeError) return false;
2012
+ throw error;
2013
+ }
2014
+ if (url.hash.length < 2) return false;
2015
+ url.hash = "";
2016
+ return getCanonicalPortableId(url) === portableId;
2017
+ }
2018
+ /**
2019
+ * Checks whether a `@context` value defines no keyword aliases and no scoped
2020
+ * contexts: only references to contexts and term definitions that map terms
2021
+ * to IRIs, optionally with an `@id` or `@vocab` type coercion or a datatype.
2022
+ */
2023
+ function isSimpleContext(context) {
2024
+ if (typeof context === "string") return true;
2025
+ if (Array.isArray(context)) return context.every(isSimpleContext);
2026
+ if (context === void 0 || !isCompoundProofJsonObject(context)) return false;
2027
+ return Object.entries(context).every(([term, definition]) => {
2028
+ if (term.startsWith("@")) return false;
2029
+ if (typeof definition === "string") return !definition.startsWith("@");
2030
+ if (!isCompoundProofJsonObject(definition)) return false;
2031
+ const { "@id": id, "@type": type, ...rest } = definition;
2032
+ return Object.keys(rest).length < 1 && typeof id === "string" && !id.startsWith("@") && (type === void 0 || type === "@id" || type === "@vocab" || typeof type === "string" && !type.startsWith("@"));
2033
+ });
2034
+ }
2035
+ function hasGraphKeyword(value) {
2036
+ if (Array.isArray(value)) return value.some(hasGraphKeyword);
2037
+ if (!isJsonMap(value)) return false;
2038
+ return Object.entries(value).some(([key, child]) => key === "@graph" || key === "@included" || key === "@reverse" || hasGraphKeyword(child));
2039
+ }
2040
+ /**
2041
+ * Checks whether expanded values are a single plain string, optionally with
2042
+ * the given datatype.
2043
+ */
2044
+ function isSingleStringValue(values, datatype) {
2045
+ if (!Array.isArray(values) || values.length !== 1) return false;
2046
+ const value = values[0];
2047
+ if (!isJsonMap(value) || typeof value["@value"] !== "string") return false;
2048
+ const keys = Object.keys(value);
2049
+ return keys.length === 1 || datatype != null && keys.length === 2 && value["@type"] === datatype;
2050
+ }
1849
2051
  //#endregion
1850
2052
  //#region src/federation/inbox.ts
1851
2053
  async function routeActivity({ context: ctx, json, originalJson, normalizedActivity, ldSignatureVerified, activity, recipient, inboxListeners, inboxContextFactory, listenerInboxContextFactory, inboxErrorHandler, kv, kvPrefixes, queue, span, meterProvider, tracerProvider, idempotencyStrategy }) {
@@ -2564,14 +2766,7 @@ async function handlePortableObject(request, { values, context, objectDispatcher
2564
2766
  } });
2565
2767
  }
2566
2768
  function isRequestedPortableObject(object, canonicalId) {
2567
- if (object.id == null) return false;
2568
- if (object.id.protocol !== "ap:" && object.id.protocol !== "ap+ef61:") return false;
2569
- try {
2570
- return canonicalizePortableUri(formatIri(object.id)) === canonicalId;
2571
- } catch (error) {
2572
- if (error instanceof TypeError) return false;
2573
- throw error;
2574
- }
2769
+ return object.id != null && getCanonicalPortableId(object.id) === canonicalId;
2575
2770
  }
2576
2771
  async function getJsonLdRootId(jsonLd, contextLoader) {
2577
2772
  const expanded = await jsonld.expand(jsonLd, {
@@ -2583,6 +2778,7 @@ async function getJsonLdRootId(jsonLd, contextLoader) {
2583
2778
  return typeof id === "string" ? id : null;
2584
2779
  }
2585
2780
  function isRequestedPortableId(id, canonicalId) {
2781
+ if (!/^ap(?:\+ef61)?:\/\//i.test(id)) return URL.canParse(id) && getCanonicalPortableId(new URL(id)) === canonicalId;
2586
2782
  try {
2587
2783
  return canonicalizePortableUri(id) === canonicalId;
2588
2784
  } catch (error) {
@@ -2633,13 +2829,13 @@ function recordCollectionMetrics(meterProvider, base, result, options = {}) {
2633
2829
  * @param parameters The parameters for handling the collection.
2634
2830
  * @returns A promise that resolves to an HTTP response.
2635
2831
  */
2636
- async function handleCollection(request, { name: name$1, identifier, uriGetter, filter, filterPredicate, context, collectionCallbacks, tracerProvider, meterProvider, onUnauthorized, onNotFound }) {
2637
- const spanName = name$1.trim().replace(/\s+/g, "_");
2832
+ async function handleCollection(request, { name: name$2, identifier, uriGetter, filter, filterPredicate, context, collectionCallbacks, tracerProvider, meterProvider, onUnauthorized, onNotFound }) {
2833
+ const spanName = name$2.trim().replace(/\s+/g, "_");
2638
2834
  tracerProvider = tracerProvider ?? trace.getTracerProvider();
2639
2835
  const tracer = tracerProvider.getTracer(name, version);
2640
2836
  const cursor = new URL(request.url).searchParams.get("cursor");
2641
2837
  const metricBase = {
2642
- kind: getCollectionMetricKind(name$1),
2838
+ kind: getCollectionMetricKind(name$2),
2643
2839
  page: cursor != null,
2644
2840
  dispatcher: "built_in"
2645
2841
  };
@@ -2680,7 +2876,7 @@ async function handleCollection(request, { name: name$1, identifier, uriGetter,
2680
2876
  span.setStatus({ code: SpanStatusCode.ERROR });
2681
2877
  return await onNotFound(request);
2682
2878
  }
2683
- const items = filterCollectionItems(page.items, name$1, filterPredicate);
2879
+ const items = filterCollectionItems(page.items, name$2, filterPredicate);
2684
2880
  itemCount = items.length;
2685
2881
  span.setAttribute("fedify.collection.items", itemCount);
2686
2882
  return items;
@@ -2720,7 +2916,7 @@ async function handleCollection(request, { name: name$1, identifier, uriGetter,
2720
2916
  } else {
2721
2917
  const uri = new URL(baseUri);
2722
2918
  uri.searchParams.set("cursor", cursor);
2723
- const pageOrResponse = await tracer.startActiveSpan(`activitypub.dispatch_collection_page ${name$1}`, {
2919
+ const pageOrResponse = await tracer.startActiveSpan(`activitypub.dispatch_collection_page ${name$2}`, {
2724
2920
  kind: SpanKind.SERVER,
2725
2921
  attributes: {
2726
2922
  "activitypub.collection.id": uri.href,
@@ -2736,7 +2932,7 @@ async function handleCollection(request, { name: name$1, identifier, uriGetter,
2736
2932
  span.setStatus({ code: SpanStatusCode.ERROR });
2737
2933
  return await onNotFound(request);
2738
2934
  }
2739
- const items = filterCollectionItems(page.items, name$1, filterPredicate);
2935
+ const items = filterCollectionItems(page.items, name$2, filterPredicate);
2740
2936
  itemCount = items.length;
2741
2937
  span.setAttribute("fedify.collection.items", itemCount);
2742
2938
  return {
@@ -3445,7 +3641,7 @@ async function handleInboxInternal(request, parameters, span) {
3445
3641
  return await respondInvalidActivity(error);
3446
3642
  }
3447
3643
  activityVerified = true;
3448
- if (!skipSignatureVerification && hasPortableActor(activity)) try {
3644
+ if (!skipSignatureVerification && isPortableActivity(activity)) try {
3449
3645
  proofVerified = await verifyObject(Activity, jsonWithoutSig, {
3450
3646
  contextLoader: wrapContextLoaderForJsonLd(ctx.contextLoader),
3451
3647
  documentLoader: ctx.documentLoader,
@@ -3629,7 +3825,7 @@ async function handleInboxInternal(request, parameters, span) {
3629
3825
  "http_signatures.verified": httpSigKey != null,
3630
3826
  "http_signatures.key_id": httpSigKey?.id?.href ?? ""
3631
3827
  });
3632
- if (!skipSignatureVerification && !proofVerified && (hasPortableActor(activity) || isPortableGatewayKey(httpSigKey))) {
3828
+ if (!skipSignatureVerification && !proofVerified && (isPortableActivity(activity) || isPortableGatewayKey(httpSigKey))) {
3633
3829
  if (deferredLdSignatureError != null) throw deferredLdSignatureError;
3634
3830
  logger.error("The activity {activityId} of the portable actor {actorId} is not authenticated by a valid Object Integrity Proof.", {
3635
3831
  activity: json,
@@ -3647,6 +3843,32 @@ async function handleInboxInternal(request, parameters, span) {
3647
3843
  headers: { "Content-Type": "text/plain; charset=utf-8" }
3648
3844
  });
3649
3845
  }
3846
+ if (!skipSignatureVerification && activity.id != null && isPortableId(activity.id)) {
3847
+ const rejection = await verifyPortableActivityId(activity.id, jsonWithoutSig, {
3848
+ contextLoader: wrapContextLoaderForJsonLd(ctx.contextLoader),
3849
+ documentLoader: ctx.documentLoader,
3850
+ keyCache,
3851
+ meterProvider,
3852
+ tracerProvider
3853
+ });
3854
+ if (rejection != null) {
3855
+ if (deferredLdSignatureError != null) throw deferredLdSignatureError;
3856
+ logger.error("The portable activity {activityId} is not authenticated by an Object Integrity Proof of its own DID: {reason}", {
3857
+ activity: json,
3858
+ recipient,
3859
+ activityId: activity.id.href,
3860
+ reason: rejection
3861
+ });
3862
+ span.setStatus({
3863
+ code: SpanStatusCode.ERROR,
3864
+ message: `The portable activity (${activity.id.href}) is not authenticated by an Object Integrity Proof of its own DID.`
3865
+ });
3866
+ return new Response("Portable activities must have valid Object Integrity Proofs made by their own DIDs.", {
3867
+ status: 401,
3868
+ headers: { "Content-Type": "text/plain; charset=utf-8" }
3869
+ });
3870
+ }
3871
+ }
3650
3872
  if (httpSigKey != null && !await doesActorOwnKey(activity, httpSigKey, ctx)) {
3651
3873
  if (deferredLdSignatureError != null) throw deferredLdSignatureError;
3652
3874
  getFederationMetrics(parameters.meterProvider).recordSignatureVerificationFailure("actorKeyMismatch", httpSigKey.id == null ? void 0 : getRemoteHost(httpSigKey.id));
@@ -3884,8 +4106,8 @@ var CustomCollectionHandler = class {
3884
4106
  * @param CollectionPage The CollectionPage constructor.
3885
4107
  * @param filterPredicate Optional filter predicate for items.
3886
4108
  */
3887
- constructor(name$2, values, context, callbacks, tracerProvider = trace.getTracerProvider(), meterProvider, Collection, CollectionPage, filterPredicate) {
3888
- this.name = name$2;
4109
+ constructor(name$1, values, context, callbacks, tracerProvider = trace.getTracerProvider(), meterProvider, Collection, CollectionPage, filterPredicate) {
4110
+ this.name = name$1;
3889
4111
  this.values = values;
3890
4112
  this.context = context;
3891
4113
  this.callbacks = callbacks;
@@ -4376,7 +4598,41 @@ const MIN_COMPONENTS = [
4376
4598
  * establish.
4377
4599
  */
4378
4600
  function isPortableGatewayKey(key) {
4379
- return key?.id != null && isCompatibleKeyId(key.id) && key.ownerId != null && isPortableUri(key.ownerId);
4601
+ return key?.id != null && isCompatibleKeyId(key.id) && key.ownerId != null && isPortableId(key.ownerId);
4602
+ }
4603
+ /**
4604
+ * Checks whether an activity needs an Object Integrity Proof by its DID:
4605
+ * either it is performed by an [FEP-ef61] portable actor, or its own ID is
4606
+ * portable. Both `ap:` URIs and compatible identifiers count as portable.
4607
+ *
4608
+ * [FEP-ef61]: https://w3id.org/fep/ef61
4609
+ */
4610
+ function isPortableActivity(activity) {
4611
+ return hasPortableActor(activity) || activity.id != null && isPortableId(activity.id);
4612
+ }
4613
+ /**
4614
+ * Verifies that a portable activity's own ID belongs to the DID that signed
4615
+ * it, on the JSON-LD document received. The ID the proof policy checks has
4616
+ * to be the parsed activity's ID, so that a remote context that changes
4617
+ * between loads cannot make the two name different objects.
4618
+ * @returns `null` if the ID is verified, or the reason why it is not.
4619
+ */
4620
+ async function verifyPortableActivityId(activityId, jsonLd, options) {
4621
+ const expectedId = getCanonicalPortableId(activityId);
4622
+ if (expectedId == null) return "malformed portable ID";
4623
+ let verification;
4624
+ try {
4625
+ verification = await verifyPortableObjectProofWithRoot(jsonLd, options);
4626
+ } catch (error) {
4627
+ if (error instanceof InvalidPortableObjectIdError) return "malformed portable ID";
4628
+ if (!isPermanentActivityParseError(error)) throw error;
4629
+ return "malformed document";
4630
+ }
4631
+ const { result, root } = verification;
4632
+ if (!result.verified) return result.reason.type;
4633
+ const rootId = root?.["@id"];
4634
+ if (typeof rootId !== "string" || getCanonicalPortableId(parseIri(rootId)) !== expectedId) return "the verified document has another ID";
4635
+ return null;
4380
4636
  }
4381
4637
  //#endregion
4382
4638
  //#region src/nodeinfo/handler.ts
@@ -4944,16 +5200,17 @@ function resolvePortableInboxRecipient(actor, { authority, canonicalInboxId, loc
4944
5200
  reason: "notFound"
4945
5201
  };
4946
5202
  const id = actor.id;
4947
- if (id == null || id.protocol !== "ap:" && id.protocol !== "ap+ef61:") return {
5203
+ const did = id == null || !isPortableId(id) ? null : getPortableDid(id);
5204
+ if (id == null || did == null) return {
4948
5205
  status: "rejected",
4949
5206
  reason: "notPortable"
4950
5207
  };
4951
- if (!isSameAuthority(id, authority)) return {
5208
+ if (did !== authority) return {
4952
5209
  status: "rejected",
4953
5210
  reason: "authorityMismatch"
4954
5211
  };
4955
- const inboxId = actor.inboxId;
4956
- if (inboxId == null || canonicalize(inboxId) !== canonicalInboxId) return {
5212
+ const inboxId = actor.inboxId == null ? null : toPortableUri(actor.inboxId);
5213
+ if (inboxId == null || getCanonicalPortableId(inboxId) !== canonicalInboxId) return {
4957
5214
  status: "rejected",
4958
5215
  reason: "inboxMismatch"
4959
5216
  };
@@ -4972,17 +5229,15 @@ function resolvePortableInboxRecipient(actor, { authority, canonicalInboxId, loc
4972
5229
  }
4973
5230
  };
4974
5231
  }
4975
- function isSameAuthority(id, authority) {
4976
- try {
4977
- return getFe34Origin(id) === authority;
4978
- } catch (error) {
4979
- if (error instanceof TypeError) return false;
4980
- throw error;
4981
- }
4982
- }
4983
- function canonicalize(iri) {
5232
+ /**
5233
+ * Gets the portable form of an `ap:` or `ap+ef61:` URI or a compatible
5234
+ * identifier, keeping its query.
5235
+ * @returns The portable URI, or `null` if the ID is neither, or is malformed.
5236
+ */
5237
+ function toPortableUri(id) {
5238
+ if (isPortableUri(id)) return id;
4984
5239
  try {
4985
- return canonicalizePortableUri(typeof iri === "string" ? iri : formatIri(iri));
5240
+ return fromCompatibleEf61Id(id);
4986
5241
  } catch (error) {
4987
5242
  if (error instanceof TypeError) return null;
4988
5243
  throw error;
@@ -5016,7 +5271,7 @@ async function forwardPortableInboxActivity(parameters) {
5016
5271
  const { recipient, activityId, excludedOrigins, kv, kvPrefix, outboxQueue } = parameters;
5017
5272
  const { maxTargets, ttl } = parameters.options ?? resolvePortableInboxForwardingOptions();
5018
5273
  if (kv.cas == null || maxTargets < 1) return [];
5019
- const canonicalActivityId = canonicalize(activityId) ?? activityId.href;
5274
+ const canonicalActivityId = getCanonicalPortableId(activityId) ?? activityId.href;
5020
5275
  const targets = getForwardingTargets(recipient, excludedOrigins);
5021
5276
  if (targets.length > maxTargets) {
5022
5277
  logger.warn("The portable actor that owns the inbox {inbox} has more than {max} other gateways; the activity {activityId} is forwarded only to the first {max} of them.", {
@@ -5203,34 +5458,50 @@ async function enqueueForwarding(parameters, outboxQueue, inboxes) {
5203
5458
  }
5204
5459
  //#endregion
5205
5460
  //#region src/federation/outgoing-proof.ts
5206
- function isPortableUrl(url) {
5207
- return url.protocol === "ap:" || url.protocol === "ap+ef61:";
5208
- }
5209
5461
  function formatKeyIds(candidates) {
5210
5462
  return candidates.map((c) => c.verificationMethod.href).join(", ");
5211
5463
  }
5212
5464
  /**
5213
5465
  * Rejects an activity of a portable actor whose ID cannot be authenticated by
5214
5466
  * the actor's DID: FEP-ef61 requires the activity to be a portable object
5215
- * signed by that DID, so its ID has to be a portable ID with the same DID.
5467
+ * signed by that DID, so its ID has to be a portable ID, i.e., an `ap:` or
5468
+ * `ap+ef61:` URI or a compatible identifier, with the same DID. A portable
5469
+ * actor is one whose ID is such an ID as well.
5216
5470
  * @throws {TypeError} If the activity is performed by a portable actor, but
5217
- * its ID is not a portable ID of the actor's DID, or its
5218
- * portable actors do not share a DID.
5471
+ * its ID is not a portable ID of the actor's DID, its
5472
+ * portable actors do not share a DID, or the ID of one of
5473
+ * them is a malformed compatible identifier.
5219
5474
  * @internal
5220
5475
  */
5221
5476
  function assertPortableActorActivity(activity) {
5222
- const portableActors = activity.actorIds.filter(isPortableUrl);
5477
+ const portableActors = activity.actorIds.filter((id) => isPortableId(id));
5223
5478
  if (portableActors.length < 1) return;
5224
- const did = getFe34Origin(portableActors[0]);
5225
- if (portableActors.some((actorId) => getFe34Origin(actorId) !== did)) throw new TypeError(`The activity ${activity.id?.href} has portable actors with different DIDs (${portableActors.map((a) => a.href).join(", ")}); a portable activity is signed by a single DID.`);
5226
- if (activity.id == null || !isPortableUrl(activity.id) || getFe34Origin(activity.id) !== did) throw new TypeError(`The activity ${activity.id?.href} is performed by the portable actor ${portableActors[0].href}, so its ID has to be an ap: or ap+ef61: URI with the same DID (${did}), as FEP-ef61 requires it to carry an Object Integrity Proof made by that DID.`);
5479
+ const dids = portableActors.map((actorId) => {
5480
+ const did = getPortableDid(actorId);
5481
+ if (did == null) throw new TypeError(`The activity ${activity.id?.href} is performed by the actor ${actorId.href}, whose ID is a malformed FEP-ef61 portable ID. A compatible identifier must have a valid DID and an object path, and must not have credentials or @gateway location hints.`);
5482
+ return did;
5483
+ });
5484
+ const did = dids[0];
5485
+ if (dids.some((d) => d !== did)) throw new TypeError(`The activity ${activity.id?.href} has portable actors with different DIDs (${portableActors.map((a) => a.href).join(", ")}); a portable activity is signed by a single DID.`);
5486
+ if (activity.id == null || !isPortableId(activity.id) || getPortableDid(activity.id) !== did) throw new TypeError(`The activity ${activity.id?.href} is performed by the portable actor ${portableActors[0].href}, so its ID has to be an ap: or ap+ef61: URI, or an FEP-ef61 compatible identifier, with the same DID (${did}), as FEP-ef61 requires it to carry an Object Integrity Proof made by that DID.`);
5487
+ if (!isCompatibleEf61Iri(activity.id)) return;
5488
+ const mismatched = portableActors.find((actorId) => isCompatibleEf61Iri(actorId) && actorId.origin !== activity.id?.origin);
5489
+ if (mismatched == null) return;
5490
+ getLogger([
5491
+ "fedify",
5492
+ "federation",
5493
+ "outbox"
5494
+ ]).warn("The activity {activityId} and its actor {actorId} are identified by FEP-ef61 compatible identifiers on different gateways. FEP-ef61 requires publishers to use the first gateway in the actor's gateways when constructing compatible identifiers.", {
5495
+ activityId: activity.id.href,
5496
+ actorId: mismatched.href
5497
+ });
5227
5498
  }
5228
5499
  /**
5229
5500
  * Creates the Object Integrity Proofs Fedify attaches to an outgoing activity.
5230
5501
  *
5231
5502
  * Keys whose IDs are FEP-ef61 compatible identifiers are gateway keys, which
5232
5503
  * never sign proofs. A portable activity, i.e., one with an `ap:` or
5233
- * `ap+ef61:` ID, that carries no proof is signed only by the one Ed25519 key
5504
+ * `ap+ef61:` ID or a compatible identifier, that carries no proof is signed only by the one Ed25519 key
5234
5505
  * whose verification method is a DID URL for its DID, even if it is the only
5235
5506
  * key; if no key or more than one key qualifies, this function throws.
5236
5507
  *
@@ -5269,7 +5540,7 @@ async function signOutgoingActivity(activity, candidates, options) {
5269
5540
  break;
5270
5541
  }
5271
5542
  const keys = candidates.filter((c) => c.privateKey.algorithm.name === "Ed25519" && !isCompatibleKeyId(c.verificationMethod));
5272
- if (!hasProof && activity.id != null && isPortableUrl(activity.id)) return await sign(selectPortableActivityKey(activity, activity.id, keys));
5543
+ if (!hasProof && activity.id != null && isPortableId(activity.id)) return await sign(selectPortableActivityKey(activity, activity.id, keys));
5273
5544
  if (keys.length < 1 || hasProof && !options.appendToExistingProofs) return {
5274
5545
  activity,
5275
5546
  hasProof,
@@ -5302,8 +5573,9 @@ async function signOutgoingActivity(activity, candidates, options) {
5302
5573
  * @throws {TypeError} If no key or more than one key qualifies.
5303
5574
  */
5304
5575
  function selectPortableActivityKey(activity, activityId, keys) {
5305
- const activityDid = getFe34Origin(activityId);
5306
- const eligible = keys.filter((c) => c.verificationMethod.protocol === "did:" && haveSameFe34Origin(activityId, c.verificationMethod));
5576
+ const activityDid = getPortableDid(activityId);
5577
+ if (activityDid == null) throw new TypeError(`Cannot sign the portable activity ${activityId.href}, as its ID is a malformed FEP-ef61 portable ID. A compatible identifier must have a valid DID and an object path, and must not have credentials or @gateway location hints.`);
5578
+ const eligible = keys.filter((c) => c.verificationMethod.protocol === "did:" && getPortableDid(c.verificationMethod) === activityDid);
5307
5579
  if (eligible.length !== 1) throw new TypeError(`Cannot choose an Object Integrity Proof key for the portable activity ${activityId.href}: ` + (eligible.length < 1 ? `none of its ${keys.length} Ed25519 keys (${formatKeyIds(keys)}) ` : `${eligible.length} of its Ed25519 keys (${formatKeyIds(eligible)}) `) + `${eligible.length < 1 ? "is" : "are"} a DID URL verification method for ${activityDid}. FEP-ef61 requires a portable activity's proof to be made by its DID, and the map-local compound-proof profile accepts exactly one direct proof per map. Gateway keys, whose IDs are compatible identifiers, never sign proofs. Supply exactly one Ed25519 key whose key ID is a DID URL for ${activityDid}, or sign the activity with signObject() before sending it.`);
5308
5580
  getLogger([
5309
5581
  "fedify",
@@ -5721,6 +5993,27 @@ function getWebFingerSubjectAndAliases({ resourceUrl, actorUri, preferredUsernam
5721
5993
  };
5722
5994
  }
5723
5995
  /**
5996
+ * Gets the compatible identifier that a WebFinger response links to for
5997
+ * an FEP-ef61 portable actor. An actor identified by an `ap:` or `ap+ef61:`
5998
+ * URI is linked through its compatible identifier on the given gateway, while
5999
+ * an actor identified by a compatible identifier is linked through its ID as
6000
+ * is, so that software without FEP-ef61 support finds the same ID in the actor
6001
+ * document.
6002
+ * @param actorId The portable actor's ID.
6003
+ * @param gateway The actor's first gateway.
6004
+ * @returns The compatible identifier, or `null` if the actor ID is malformed
6005
+ * or cannot be represented as a compatible identifier.
6006
+ */
6007
+ function getCompatibleActorId(actorId, gateway) {
6008
+ try {
6009
+ if (isPortableUri(actorId)) return toCompatibleEf61Id(actorId, gateway);
6010
+ return fromCompatibleEf61Id(actorId) == null ? null : actorId;
6011
+ } catch (error) {
6012
+ if (error instanceof TypeError) return null;
6013
+ throw error;
6014
+ }
6015
+ }
6016
+ /**
5724
6017
  * Computes the subject and aliases for an FEP-ef61 portable actor, whose
5725
6018
  * WebFinger address takes its domain from the first gateway of the actor
5726
6019
  * rather than from the actor ID. The canonical `ap:` ID is not emitted,
@@ -5884,21 +6177,23 @@ async function handleWebFingerInternal(request, { context, host, actorDispatcher
5884
6177
  });
5885
6178
  let actorUri = context.getActorUri(identifier);
5886
6179
  let portable;
5887
- if (actor.id != null && (actor.id.protocol === "ap:" || actor.id.protocol === "ap+ef61:")) {
6180
+ if (actor.id != null && isPortableId(actor.id)) {
5888
6181
  const gateway = actor.gateway;
5889
- let compatibleId;
5890
- if (gateway != null && isGatewayUrl(gateway)) try {
5891
- compatibleId = toCompatibleEf61Id(actor.id, gateway);
5892
- } catch (error) {
5893
- if (!(error instanceof TypeError)) throw error;
5894
- }
5895
- if (gateway == null || compatibleId == null) {
6182
+ if (gateway == null || !isGatewayUrl(gateway)) {
5896
6183
  logger.error("The portable actor {actorId} (identifier: {identifier}) needs an HTTP(S) origin as the first item of its gateways property to be discovered through WebFinger.", {
5897
6184
  actorId: formatIri(actor.id),
5898
6185
  identifier
5899
6186
  });
5900
6187
  return await onNotFound(request);
5901
6188
  }
6189
+ const compatibleId = getCompatibleActorId(actor.id, gateway);
6190
+ if (compatibleId == null) {
6191
+ logger.error("The portable actor {actorId} (identifier: {identifier}) cannot be discovered through WebFinger, as its ID is malformed or cannot be represented as an FEP-ef61 compatible identifier.", {
6192
+ actorId: formatIri(actor.id),
6193
+ identifier
6194
+ });
6195
+ return await onNotFound(request);
6196
+ }
5902
6197
  actorUri = compatibleId;
5903
6198
  portable = {
5904
6199
  compatibleId,