@fedify/fedify 2.4.0-dev.2141 → 2.4.0-dev.2155

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 (85) hide show
  1. package/dist/{builder-BI7H61zO.mjs → builder-CxHALSwA.mjs} +56 -3
  2. package/dist/compat/mod.d.cts +1 -1
  3. package/dist/compat/mod.d.ts +1 -1
  4. package/dist/compat/transformers.test.mjs +1 -1
  5. package/dist/{compound-proof-D0H4LLvQ.mjs → compound-proof-nhrXTRQO.mjs} +216 -4
  6. package/dist/{context-CrDDVrbF.d.cts → context-BMggDR-l.d.cts} +394 -5
  7. package/dist/{context-DfSVPcJ8.mjs → context-OrnjnuMq.mjs} +8 -1
  8. package/dist/{context-B2C8nuN3.d.ts → context-exYu7gDU.d.ts} +394 -5
  9. package/dist/{deno-BXI_BLNy.mjs → deno-YLLbRq06.mjs} +1 -1
  10. package/dist/{docloader-B5BIo7e9.mjs → docloader-vc-pSu9-.mjs} +2 -2
  11. package/dist/federation/builder.test.mjs +1 -1
  12. package/dist/federation/compound-fanout.test.mjs +3 -3
  13. package/dist/federation/handler-compound-proof.test.mjs +4 -4
  14. package/dist/federation/handler.test.mjs +6 -6
  15. package/dist/federation/hashlink-media.test.mjs +2 -2
  16. package/dist/federation/idempotency.test.mjs +2 -2
  17. package/dist/federation/metrics.test.mjs +1 -1
  18. package/dist/federation/middleware.test.mjs +8 -8
  19. package/dist/federation/mod.cjs +1 -1
  20. package/dist/federation/mod.d.cts +3 -3
  21. package/dist/federation/mod.d.ts +3 -3
  22. package/dist/federation/mod.js +1 -1
  23. package/dist/federation/outgoing-proof.test.mjs +5 -5
  24. package/dist/federation/portable-actor.test.mjs +3 -3
  25. package/dist/federation/portable-chain.test.mjs +3 -3
  26. package/dist/federation/portable-collection.test.d.mts +2 -0
  27. package/dist/federation/portable-collection.test.mjs +768 -0
  28. package/dist/federation/portable-gateway-keys.test.mjs +4 -4
  29. package/dist/federation/portable-inbox.test.mjs +4 -4
  30. package/dist/federation/portable.test.mjs +4 -4
  31. package/dist/federation/send.test.mjs +3 -3
  32. package/dist/federation/tasks/enqueue.test.mjs +2 -2
  33. package/dist/federation/tasks/tasks.test.mjs +3 -3
  34. package/dist/federation/temporal.test.mjs +1 -1
  35. package/dist/federation/webfinger.test.mjs +2 -2
  36. package/dist/{federation-DqDOPjWo.cjs → federation-D9TufmIO.cjs} +684 -68
  37. package/dist/{http-CEx2SZjf.mjs → http-CXnNbRe_.mjs} +4 -4
  38. package/dist/{http-D4KW2GTi.js → http-Dk4O3WM0.js} +7 -2
  39. package/dist/{http-DSYQQn23.cjs → http-eqYXHRkz.cjs} +12 -1
  40. package/dist/{key-C0giKuki.mjs → key-BZ-Z4uez.mjs} +2 -2
  41. package/dist/{kv-cache-D_E-2K3i.js → kv-cache-3uKc0G76.js} +1 -1
  42. package/dist/{kv-cache-AWTrxTq3.mjs → kv-cache-DZr8HTel.mjs} +1 -1
  43. package/dist/{kv-cache-BcsvaCCL.cjs → kv-cache-DzClEalR.cjs} +1 -1
  44. package/dist/{ld-CWDReGMZ.mjs → ld-Djxsq6JR.mjs} +3 -3
  45. package/dist/{metrics-DxlVXwn5.mjs → metrics-CBHhbHgG.mjs} +1 -1
  46. package/dist/{middleware-1mv9QR16.js → middleware-BDUR99k3.js} +686 -70
  47. package/dist/{middleware-B7stx5gy.mjs → middleware-BahNnLfD.mjs} +1 -1
  48. package/dist/{middleware-CAWRGSwH.mjs → middleware-DDa8pCUa.mjs} +431 -80
  49. package/dist/{mod-C06GoL5Z.d.ts → mod-CnGtADvO.d.ts} +1 -1
  50. package/dist/{mod-B7SfEqIP.d.cts → mod-Decmur1U.d.cts} +1 -1
  51. package/dist/mod.cjs +4 -4
  52. package/dist/mod.d.cts +4 -4
  53. package/dist/mod.d.ts +4 -4
  54. package/dist/mod.js +4 -4
  55. package/dist/nodeinfo/handler.test.mjs +2 -2
  56. package/dist/{owner-CgoCOTUQ.mjs → owner-OdWSE8am.mjs} +3 -3
  57. package/dist/{portable-collection-CG9P_i0E.js → portable-collection-BCy06GiS.js} +1 -1
  58. package/dist/{portable-collection-0_5DyXMt.mjs → portable-collection-DAV7Ho3m.mjs} +2 -2
  59. package/dist/{portable-collection-BD8fE3j3.cjs → portable-collection-wBWbuHOH.cjs} +1 -1
  60. package/dist/{portable-key-BXkgMpw1.mjs → portable-key-BL6K88p2.mjs} +1 -1
  61. package/dist/{proof-B5s-W6Sm.mjs → proof-vZ04IAjq.mjs} +10 -5
  62. package/dist/{send-BAJvkdNL.mjs → send-ylV8KCLx.mjs} +3 -3
  63. package/dist/sig/compound-proof-discovery.test.mjs +1 -1
  64. package/dist/sig/compound-proof-producer.test.mjs +1 -1
  65. package/dist/sig/compound-proof-verification.test.mjs +2 -2
  66. package/dist/sig/compound-proof.test.mjs +2 -2
  67. package/dist/sig/http.test.mjs +2 -2
  68. package/dist/sig/key.test.mjs +1 -1
  69. package/dist/sig/ld.test.mjs +2 -2
  70. package/dist/sig/mod.cjs +2 -2
  71. package/dist/sig/mod.js +2 -2
  72. package/dist/sig/owner.test.mjs +1 -1
  73. package/dist/sig/portable-collection.test.mjs +1 -1
  74. package/dist/sig/portable-key.test.mjs +5 -5
  75. package/dist/sig/proof.test.mjs +3 -3
  76. package/dist/{temporal-BkCPJ_uC.mjs → temporal-BTD4f_mZ.mjs} +1 -1
  77. package/dist/testing/kv.test.mjs +1 -1
  78. package/dist/testing/mod.d.mts +393 -4
  79. package/dist/testing/mod.mjs +2 -2
  80. package/dist/{testing-otQhu4aD.mjs → testing-CKaJhcA0.mjs} +1 -1
  81. package/dist/utils/docloader.test.mjs +2 -2
  82. package/dist/utils/kv-cache.test.mjs +1 -1
  83. package/dist/utils/mod.cjs +1 -1
  84. package/dist/utils/mod.js +1 -1
  85. package/package.json +6 -6
@@ -2,15 +2,15 @@ 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 recordCollectionRequest, A as isClearlyMalformedContextReference, Ct as name, D as getNormalizationContextLoader, E as detachSignature, G as getDurationMs, H as importJwk, I as wrapContextLoaderForJsonLd, J as instrumentDocumentLoader, K as getFederationMetrics, L as exportJwk, M as signJsonLd, N as verifyCompactJsonLd, O as hasSignature, Q as recordCollectionPageItems, S as assertSafeJsonLd, U as validateCryptoKey, X as recordCircuitBreakerStateChange, Y as isAbortError, Z as recordCollectionDispatchDuration, _ as verifyPortableObjectProofPolicy, _t as hasPortableActor, at as recordOutboxEnqueue, b as normalizeOutgoingActivityJsonLd, bt as isPortableKeyId, ct as preloadedOnlyDocumentLoader, et as recordCollectionTotalItems, f as hasProofLike, g as verifyPortableObjectProof, gt as getRawCanonicalPortableId, h as verifyObject, ht as getPortableDid, i as selectRequestSignature, it as recordOutboxActivity, j as isInvalidUrlTypeError, k as hasSignatureLike, lt as formatAcceptSignature, m as verifyMapLocalProof, mt as getGatewayKeyBase, n as listRequestSignatures, nt as recordFanoutRecipients, o as verifyRequest, ot as recordWebFingerHandle, p as signObject, pt as getCanonicalPortableId, q as getRemoteHost, r as parseRfc9421SignatureInput, rt as recordInboxActivity, s as verifyRequestDetailed, st as registerQueueDepthGauge, t as doubleKnock, u as InvalidPortableObjectIdError, v as verifyPortableObjectProofWithRoot, vt as isCompatibleKeyId, w as compactJsonLd, wt as version, x as InvalidContextReferenceError, xt as isPortableUri, yt as isPortableId } from "./http-D4KW2GTi.js";
5
+ import { $ as recordCollectionPageItems, A as hasSignatureLike, C as assertSafeJsonLd, D as detachSignature, J as getRemoteHost, K as getDurationMs, L as wrapContextLoaderForJsonLd, M as isInvalidUrlTypeError, N as signJsonLd, O as getNormalizationContextLoader, P as verifyCompactJsonLd, Q as recordCollectionDispatchDuration, R as exportJwk, S as InvalidContextReferenceError, St as isPortableUri, T as compactJsonLd, Tt as version, U as importJwk, W as validateCryptoKey, X as isAbortError, Y as instrumentDocumentLoader, Z as recordCircuitBreakerStateChange, _ as verifyPortableObjectProof, _t as getRawCanonicalPortableId, at as recordOutboxActivity, bt as isPortableId, ct as registerQueueDepthGauge, d as classifyFep2277CoreType, et as recordCollectionRequest, g as verifyObject, gt as getPortableDid, h as verifyMapLocalProof, ht as getGatewayKeyBase, i as selectRequestSignature, it as recordInboxActivity, j as isClearlyMalformedContextReference, k as hasSignature, lt as preloadedOnlyDocumentLoader, m as signObject, mt as getCanonicalPortableId, n as listRequestSignatures, o as verifyRequest, ot as recordOutboxEnqueue, p as hasProofLike, q as getFederationMetrics, r as parseRfc9421SignatureInput, rt as recordFanoutRecipients, s as verifyRequestDetailed, st as recordWebFingerHandle, t as doubleKnock, tt as recordCollectionTotalItems, u as InvalidPortableObjectIdError, ut as formatAcceptSignature, v as verifyPortableObjectProofPolicy, vt as hasPortableActor, wt as name, x as normalizeOutgoingActivityJsonLd, xt as isPortableKeyId, y as verifyPortableObjectProofWithRoot, yt as isCompatibleKeyId } from "./http-Dk4O3WM0.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 doesActorOwnKey, r as getKeyOwner, t as verifyPortableObject } from "./portable-collection-CG9P_i0E.js";
8
- import { n as getAuthenticatedDocumentLoader, t as kvCache } from "./kv-cache-D_E-2K3i.js";
7
+ import { n as doesActorOwnKey, r as getKeyOwner, t as verifyPortableObject } from "./portable-collection-BCy06GiS.js";
8
+ import { n as getAuthenticatedDocumentLoader, t as kvCache } from "./kv-cache-3uKc0G76.js";
9
9
  import { getLogger, withContext } from "@logtape/logtape";
10
10
  import { Router, RouterError, assertPath } from "@fedify/uri-template";
11
- import { Activity, Collection, CollectionPage, CryptographicKey, Link, Multikey, Object as Object$1, OrderedCollection, OrderedCollectionPage, Tombstone, getTypeId, lookupObject, traverseCollection } from "@fedify/vocab";
11
+ import { Activity, Collection, CollectionPage, CryptographicKey, Link, Multikey, Object as Object$1, OrderedCollection, OrderedCollectionPage, Tombstone, getTypeId, isActor, 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";
13
+ import { FetchError, UrlError, canonicalizePortableUri, decodeMultibase, formatIri, fromCompatibleEf61Id, getDocumentLoader, getFe34Origin, isGatewayUrl, parseDigestMultibase, parseHashlink, parseIri, preloadedContexts, toCompatibleEf61Id, validatePublicUrl } from "@fedify/vocab-runtime";
14
14
  import { isCompatibleEf61Iri } from "@fedify/vocab-runtime/internal/portable-dereference";
15
15
  import { encodeHex } from "byte-encodings/hex";
16
16
  import jsonld from "@fedify/vocab-runtime/jsonld";
@@ -211,6 +211,7 @@ var FederationBuilderImpl = class {
211
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).");
212
212
  if (actor instanceof Tombstone) return actor;
213
213
  if (actor.id != null && isCompatibleEf61Iri(actor.id)) warnCompatibleActorId(actor.id, actor.gateway);
214
+ if (portable) this.#warnPortableCollectionIds(context, identifier, actor);
214
215
  if (this.followingCallbacks != null && this.followingCallbacks.dispatcher != null) {
215
216
  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).");
216
217
  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).");
@@ -696,6 +697,10 @@ var FederationBuilderImpl = class {
696
697
  authorize(predicate) {
697
698
  callbacks.authorizePredicate = predicate;
698
699
  return setters;
700
+ },
701
+ mapPortableOwner(mapper) {
702
+ callbacks.portableOwnerMapper = mapper;
703
+ return setters;
699
704
  }
700
705
  };
701
706
  return setters;
@@ -717,6 +722,54 @@ var FederationBuilderImpl = class {
717
722
  this.outboxPermanentFailureHandler = handler;
718
723
  }
719
724
  /**
725
+ * Warns if a portable actor's collection properties that are portable IDs
726
+ * or compatible identifiers do not refer to the collections that
727
+ * the registered collection dispatchers serve through the FEP-ef61 gateway
728
+ * endpoint for the actor's DID. Properties with ordinary HTTP(S) URLs are
729
+ * not compared, since a portable actor may keep ordinary collections.
730
+ */
731
+ #warnPortableCollectionIds(context, identifier, actor) {
732
+ const did = actor.id == null ? null : getPortableDid(actor.id);
733
+ if (did == null) return;
734
+ const logger = getLogger([
735
+ "fedify",
736
+ "federation",
737
+ "actor"
738
+ ]);
739
+ const check = (registered, property, value, helper, build) => {
740
+ if (!registered || value == null || !isPortableId(value)) return;
741
+ const actual = getCanonicalPortableId(value);
742
+ if (actual == null) {
743
+ logger.warn("The actor's {property} property, {value}, is a malformed FEP-ef61 portable ID or compatible identifier.", {
744
+ property,
745
+ value: value.href
746
+ });
747
+ return;
748
+ }
749
+ let expected;
750
+ try {
751
+ expected = build();
752
+ } catch (error) {
753
+ if (error instanceof TypeError || error instanceof RouterError) return;
754
+ throw error;
755
+ }
756
+ if (actual === getCanonicalPortableId(expected)) return;
757
+ logger.warn("The portable actor's {property} property, {value}, does not match the portable ID of the collection that the gateway endpoint serves, {expected}. Set the property with Context.{helper}(identifier, did).", {
758
+ property,
759
+ value: value.href,
760
+ expected: expected.href,
761
+ helper
762
+ });
763
+ };
764
+ check(this.outboxCallbacks?.dispatcher != null, "outbox", actor.outboxId, "getPortableOutboxUri", () => context.getPortableOutboxUri(identifier, did));
765
+ check(this.inboxCallbacks?.dispatcher != null || this.router.has("inbox"), "inbox", actor.inboxId, "getPortableInboxUri", () => context.getPortableInboxUri(identifier, did));
766
+ check(this.followingCallbacks?.dispatcher != null, "following", actor.followingId, "getPortableFollowingUri", () => context.getPortableFollowingUri(identifier, did));
767
+ check(this.followersCallbacks?.dispatcher != null, "followers", actor.followersId, "getPortableFollowersUri", () => context.getPortableFollowersUri(identifier, did));
768
+ check(this.likedCallbacks?.dispatcher != null, "liked", actor.likedId, "getPortableLikedUri", () => context.getPortableLikedUri(identifier, did));
769
+ check(this.featuredCallbacks?.dispatcher != null, "featured", actor.featuredId, "getPortableFeaturedUri", () => context.getPortableFeaturedUri(identifier, did));
770
+ check(this.featuredTagsCallbacks?.dispatcher != null, "featuredTags", actor.featuredTagsId, "getPortableFeaturedTagsUri", () => context.getPortableFeaturedTagsUri(identifier, did));
771
+ }
772
+ /**
720
773
  * Converts a name (string or symbol) to a unique string identifier.
721
774
  * For symbols, generates and caches a UUID if not already present.
722
775
  * For strings, returns the string as-is.
@@ -2067,6 +2120,218 @@ function isSingleStringValue(values, datatype) {
2067
2120
  const keys = Object.keys(value);
2068
2121
  return keys.length === 1 || datatype != null && keys.length === 2 && value["@type"] === datatype;
2069
2122
  }
2123
+ /**
2124
+ * FEP-2277 core types that FEP-ef61 does not require to carry proofs.
2125
+ */
2126
+ const UNSECURED_CORE_TYPES = /* @__PURE__ */ new Set([
2127
+ "collection",
2128
+ "link",
2129
+ "verificationMethod",
2130
+ "publicKey"
2131
+ ]);
2132
+ /**
2133
+ * Applies the FEP-ef61 proof policy, under the map-local compound-proof
2134
+ * profile, to every portable object embedded in a document that a gateway is
2135
+ * about to serve, e.g., the items of a portable collection page. The root
2136
+ * map itself is not checked, as the caller built it.
2137
+ *
2138
+ * Each map below the root whose direct `id` or `@id` is a portable ID or
2139
+ * a compatible identifier is checked independently of the others:
2140
+ *
2141
+ * - A map with a direct `proof` is a separately secured document: the proof
2142
+ * has to be a single map, the map has to carry its own `@context`, and
2143
+ * the proof has to verify over the map exactly as it is served, with only
2144
+ * its own proof removed, and be made with a key of the DID in the map's
2145
+ * ID, as the map-local compound-proof profile requires.
2146
+ * - A map without a proof has to be a collection, a link, or a key, which
2147
+ * FEP-ef61 does not require to be secured. It is classified under its
2148
+ * effective context, i.e., the `@context` values of its ancestors and
2149
+ * itself, which therefore may only use the simple, preloaded contexts
2150
+ * that the compound-proof policy accepts.
2151
+ *
2152
+ * Maps that merely refer to a portable object, i.e., have no member other
2153
+ * than `id` or `@id`, and maps with non-portable IDs, are neither checked nor
2154
+ * verified. Every `@context` in the document, however, has to be simple, and
2155
+ * every portable object that the expanded document describes has to be
2156
+ * a map found this way, so that no context can hide a portable object from
2157
+ * this check, e.g., by aliasing `@id` or redefining `proof`. The input must be a finite, already-materialized JSON tree, such
2158
+ * as the compact JSON-LD document that is about to be served.
2159
+ *
2160
+ * @param json The document to check.
2161
+ * @param options The options for verifying the proofs. Proofs whose
2162
+ * verification methods are not `did:key` DID URLs may make
2163
+ * the document loader fetch DID documents.
2164
+ * @returns The result, which names the first refused map, if any.
2165
+ * @internal
2166
+ */
2167
+ async function verifyServedPortableObjects(json, options = {}) {
2168
+ json = JSON.parse(JSON.stringify(json));
2169
+ const collected = collectServedPortableMaps(json);
2170
+ if (!("maps" in collected)) return collected;
2171
+ const maps = collected.maps;
2172
+ const discovered = /* @__PURE__ */ new Map();
2173
+ const ids = maps.map((candidate) => candidate.id);
2174
+ const rootId = isJsonMap(json) ? [json.id, json["@id"]].find(isPortableIdValue) : void 0;
2175
+ if (rootId != null) ids.push(rootId);
2176
+ for (const id of ids) discovered.set(id, (discovered.get(id) ?? 0) + 1);
2177
+ let hidden;
2178
+ try {
2179
+ hidden = findHiddenPortableObject(await jsonld.expand(json, { documentLoader: preloadedOnlyDocumentLoader }), discovered);
2180
+ } catch {
2181
+ return {
2182
+ verified: false,
2183
+ path: "",
2184
+ id: "",
2185
+ reason: "unsupportedContext"
2186
+ };
2187
+ }
2188
+ if (hidden != null) return {
2189
+ verified: false,
2190
+ path: "",
2191
+ id: hidden,
2192
+ reason: "hiddenPortableObject"
2193
+ };
2194
+ for (const candidate of maps) {
2195
+ const reason = await checkServedPortableMap(candidate, options);
2196
+ if (reason != null) return {
2197
+ verified: false,
2198
+ path: candidate.path,
2199
+ id: candidate.id,
2200
+ reason
2201
+ };
2202
+ }
2203
+ return { verified: true };
2204
+ }
2205
+ /**
2206
+ * Finds a portable object that an expanded JSON-LD document describes, i.e.,
2207
+ * a node with a portable `@id` and other members, more times than the given
2208
+ * numbers of maps found for its ID.
2209
+ */
2210
+ function findHiddenPortableObject(expanded, discovered) {
2211
+ const remaining = new Map(discovered);
2212
+ const pending = [expanded];
2213
+ while (pending.length > 0) {
2214
+ const value = pending.pop();
2215
+ if (Array.isArray(value)) {
2216
+ pending.push(...value);
2217
+ continue;
2218
+ }
2219
+ if (!isJsonMap(value)) continue;
2220
+ const id = value["@id"];
2221
+ if (isPortableIdValue(id) && Object.keys(value).some((key) => key !== "@id")) {
2222
+ const count = remaining.get(id) ?? 0;
2223
+ if (count < 1) return id;
2224
+ remaining.set(id, count - 1);
2225
+ }
2226
+ pending.push(...Object.values(value));
2227
+ }
2228
+ }
2229
+ /**
2230
+ * Checks whether a context uses only preloaded remote contexts and simple
2231
+ * term definitions, so that the context in effect for a map can be told from
2232
+ * the contexts of its ancestors.
2233
+ */
2234
+ function isCheckableContext(context) {
2235
+ return (Array.isArray(context) ? context : [context]).every((value) => typeof value === "string" ? Object.hasOwn(preloadedContexts, value) : !Array.isArray(value) && isSimpleContext(value));
2236
+ }
2237
+ /**
2238
+ * Checks whether every remote context that a context refers to is preloaded,
2239
+ * so that all of its definitions are known.
2240
+ */
2241
+ function hasOnlyPreloadedRemoteContexts(context) {
2242
+ return (Array.isArray(context) ? context : [context]).every((value) => typeof value === "string" ? Object.hasOwn(preloadedContexts, value) : !Array.isArray(value));
2243
+ }
2244
+ /**
2245
+ * Checks whether a map or any map below it, outside proof and context values,
2246
+ * has a portable `id` or `@id` and other members.
2247
+ */
2248
+ function hasLiteralPortableObject(json) {
2249
+ return findInJsonMaps(json, (map) => [map.id, map["@id"]].some(isPortableIdValue) && Object.keys(map).some((key) => key !== "id" && key !== "@id") ? true : void 0) ?? false;
2250
+ }
2251
+ function collectServedPortableMaps(json) {
2252
+ const maps = [];
2253
+ const seen = /* @__PURE__ */ new Set();
2254
+ const pending = [{
2255
+ value: json,
2256
+ path: "",
2257
+ depth: 0,
2258
+ contexts: []
2259
+ }];
2260
+ while (pending.length > 0) {
2261
+ const { value, path, depth, contexts } = pending.pop();
2262
+ if (typeof value !== "object" || value == null || seen.has(value)) continue;
2263
+ seen.add(value);
2264
+ if (Array.isArray(value)) {
2265
+ for (let index = value.length - 1; index >= 0; index--) pending.push({
2266
+ value: value[index],
2267
+ path: childPath(path, String(index)),
2268
+ depth: depth + 1,
2269
+ contexts
2270
+ });
2271
+ continue;
2272
+ }
2273
+ const map = value;
2274
+ const hasContext = Object.hasOwn(map, "@context");
2275
+ if (hasContext && !isCheckableContext(map["@context"])) {
2276
+ if (hasOnlyPreloadedRemoteContexts(map["@context"]) && !hasLiteralPortableObject(map)) continue;
2277
+ const id = [map.id, map["@id"]].find((v) => typeof v === "string");
2278
+ return {
2279
+ verified: false,
2280
+ path,
2281
+ id: typeof id === "string" ? id : "",
2282
+ reason: "unsupportedContext"
2283
+ };
2284
+ }
2285
+ const mapContexts = hasContext ? [...contexts, map["@context"]] : contexts;
2286
+ const id = [map.id, map["@id"]].find(isPortableIdValue);
2287
+ if (depth > 0 && id != null && Object.keys(map).some((key) => key !== "id" && key !== "@id")) maps.push({
2288
+ path,
2289
+ id,
2290
+ map,
2291
+ contexts: mapContexts
2292
+ });
2293
+ const keys = Object.keys(map);
2294
+ for (let index = keys.length - 1; index >= 0; index--) {
2295
+ const key = keys[index];
2296
+ if (key === "proof" || key === "@context") continue;
2297
+ pending.push({
2298
+ value: map[key],
2299
+ path: childPath(path, key),
2300
+ depth: depth + 1,
2301
+ contexts: mapContexts
2302
+ });
2303
+ }
2304
+ }
2305
+ maps.sort((left, right) => comparePaths(left.path, right.path));
2306
+ return { maps };
2307
+ }
2308
+ async function checkServedPortableMap({ map, contexts }, options) {
2309
+ if (Object.hasOwn(map, "proof")) {
2310
+ if (!isJsonMap(map.proof)) return "unsupportedProofShape";
2311
+ if (!Object.hasOwn(map, "@context")) return "missingContext";
2312
+ try {
2313
+ const policy = await verifyPortableObjectProofPolicy(map, await verifyMapLocalProof(map, options), options);
2314
+ return policy.verified ? null : policy.reason.type;
2315
+ } catch (error) {
2316
+ if (error instanceof TypeError) return "invalidPortableObject";
2317
+ throw error;
2318
+ }
2319
+ }
2320
+ const flattened = contexts.flatMap((context) => Array.isArray(context) ? context : [context]);
2321
+ if (flattened.length < 1 || !isSimpleContext(flattened)) return "unsupportedContext";
2322
+ let expanded;
2323
+ try {
2324
+ expanded = await jsonld.expand({
2325
+ ...map,
2326
+ "@context": flattened
2327
+ }, { documentLoader: preloadedOnlyDocumentLoader });
2328
+ } catch {
2329
+ return "unsupportedContext";
2330
+ }
2331
+ if (expanded.length !== 1 || !isJsonMap(expanded[0])) return "invalidPortableObject";
2332
+ const type = classifyFep2277CoreType(expanded[0]);
2333
+ return UNSECURED_CORE_TYPES.has(type) ? null : "missingProof";
2334
+ }
2070
2335
  //#endregion
2071
2336
  //#region src/federation/inbox.ts
2072
2337
  async function routeActivity({ context: ctx, json, originalJson, normalizedActivity, ldSignatureVerified, activity, recipient, inboxListeners, inboxContextFactory, listenerInboxContextFactory, inboxErrorHandler, kv, kvPrefixes, queue, span, meterProvider, tracerProvider, idempotencyStrategy }) {
@@ -3626,6 +3891,46 @@ function portableObjectInternalServerError(request) {
3626
3891
  headers: { "Content-Type": "text/plain; charset=utf-8" }
3627
3892
  });
3628
3893
  }
3894
+ /**
3895
+ * Checks the portable objects embedded in a portable collection or its page
3896
+ * that is about to be served against the FEP-ef61 proof policy. See
3897
+ * `verifyServedPortableObjects()`.
3898
+ * @returns `true` if the collection can be served.
3899
+ */
3900
+ async function verifyPortableCollection(jsonLd, context, portable) {
3901
+ const logger = getLogger([
3902
+ "fedify",
3903
+ "federation",
3904
+ "collection"
3905
+ ]);
3906
+ try {
3907
+ const result = await verifyServedPortableObjects(jsonLd, {
3908
+ contextLoader: context.contextLoader,
3909
+ documentLoader: context.documentLoader,
3910
+ tracerProvider: context.tracerProvider,
3911
+ meterProvider: context.meterProvider
3912
+ });
3913
+ if (result.verified) return true;
3914
+ logger.error("Refusing to serve the portable collection {collectionId}, as the portable object {objectId} it embeds at {path} does not satisfy the FEP-ef61 proof policy: {reason}. Embedded portable actors, activities, and objects need an Object Integrity Proof made with a key of the DID in their ID, and their own @context.", {
3915
+ collectionId: portable.id.href,
3916
+ objectId: result.id,
3917
+ path: result.path,
3918
+ reason: result.reason
3919
+ });
3920
+ } catch (error) {
3921
+ logger.error("Failed to verify the portable objects embedded in the portable collection {collectionId}:\n{error}", {
3922
+ collectionId: portable.id.href,
3923
+ error
3924
+ });
3925
+ }
3926
+ return false;
3927
+ }
3928
+ function respondWithPortableCollection(request, jsonLd) {
3929
+ return new Response(request.method === "HEAD" ? null : JSON.stringify(jsonLd), { headers: {
3930
+ "Content-Type": PORTABLE_OBJECT_CONTENT_TYPE,
3931
+ Vary: "Accept"
3932
+ } });
3933
+ }
3629
3934
  const BUILT_IN_COLLECTION_METRIC_KINDS = /* @__PURE__ */ new Set([
3630
3935
  "inbox",
3631
3936
  "outbox",
@@ -3663,13 +3968,13 @@ function recordCollectionMetrics(meterProvider, base, result, options = {}) {
3663
3968
  * @param parameters The parameters for handling the collection.
3664
3969
  * @returns A promise that resolves to an HTTP response.
3665
3970
  */
3666
- async function handleCollection(request, { name: name$2, identifier, uriGetter, filter, filterPredicate, context, collectionCallbacks, tracerProvider, meterProvider, onUnauthorized, onNotFound }) {
3667
- const spanName = name$2.trim().replace(/\s+/g, "_");
3971
+ async function handleCollection(request, { name: name$1, identifier, uriGetter, filter, filterPredicate, context, collectionCallbacks, portable, tracerProvider, meterProvider, onUnauthorized, onNotFound }) {
3972
+ const spanName = name$1.trim().replace(/\s+/g, "_");
3668
3973
  tracerProvider = tracerProvider ?? trace.getTracerProvider();
3669
3974
  const tracer = tracerProvider.getTracer(name, version);
3670
3975
  const cursor = new URL(request.url).searchParams.get("cursor");
3671
3976
  const metricBase = {
3672
- kind: getCollectionMetricKind(name$2),
3977
+ kind: getCollectionMetricKind(name$1),
3673
3978
  page: cursor != null,
3674
3979
  dispatcher: "built_in"
3675
3980
  };
@@ -3687,8 +3992,13 @@ async function handleCollection(request, { name: name$2, identifier, uriGetter,
3687
3992
  };
3688
3993
  try {
3689
3994
  if (collectionCallbacks == null) return finish(await onNotFound(request), "not_found");
3995
+ if (portable != null && collectionCallbacks.authorizePredicate != null) {
3996
+ if (!await collectionCallbacks.authorizePredicate(context, identifier)) return finish(await onUnauthorized(request), "unauthorized");
3997
+ }
3690
3998
  let collection;
3691
- const baseUri = uriGetter(identifier);
3999
+ const baseUri = portable?.id ?? uriGetter(identifier);
4000
+ const pageBase = portable?.view ?? context.url;
4001
+ const attribution = portable?.attribution ?? null;
3692
4002
  if (cursor == null) {
3693
4003
  const firstCursor = await collectionCallbacks.firstCursor?.(context, identifier);
3694
4004
  const totalItems = filter == null ? await collectionCallbacks.counter?.(context, identifier) : void 0;
@@ -3710,7 +4020,7 @@ async function handleCollection(request, { name: name$2, identifier, uriGetter,
3710
4020
  span.setStatus({ code: SpanStatusCode.ERROR });
3711
4021
  return await onNotFound(request);
3712
4022
  }
3713
- const items = filterCollectionItems(page.items, name$2, filterPredicate);
4023
+ const items = filterCollectionItems(page.items, name$1, filterPredicate);
3714
4024
  itemCount = items.length;
3715
4025
  span.setAttribute("fedify.collection.items", itemCount);
3716
4026
  return items;
@@ -3728,29 +4038,31 @@ async function handleCollection(request, { name: name$2, identifier, uriGetter,
3728
4038
  if (itemsOrResponse instanceof Response) return finish(itemsOrResponse, "not_found");
3729
4039
  collection = new OrderedCollection({
3730
4040
  id: baseUri,
4041
+ attribution,
3731
4042
  totalItems: totalItemCount ?? null,
3732
4043
  items: itemsOrResponse
3733
4044
  });
3734
4045
  } else {
3735
4046
  const lastCursor = await collectionCallbacks.lastCursor?.(context, identifier);
3736
- const first = new URL(context.url);
4047
+ const first = new URL(pageBase);
3737
4048
  first.searchParams.set("cursor", firstCursor);
3738
4049
  let last = null;
3739
4050
  if (lastCursor != null) {
3740
- last = new URL(context.url);
4051
+ last = new URL(pageBase);
3741
4052
  last.searchParams.set("cursor", lastCursor);
3742
4053
  }
3743
4054
  collection = new OrderedCollection({
3744
4055
  id: baseUri,
4056
+ attribution,
3745
4057
  totalItems: totalItemCount ?? null,
3746
4058
  first,
3747
4059
  last
3748
4060
  });
3749
4061
  }
3750
4062
  } else {
3751
- const uri = new URL(baseUri);
4063
+ const uri = new URL(portable == null ? baseUri : pageBase);
3752
4064
  uri.searchParams.set("cursor", cursor);
3753
- const pageOrResponse = await tracer.startActiveSpan(`activitypub.dispatch_collection_page ${name$2}`, {
4065
+ const pageOrResponse = await tracer.startActiveSpan(`activitypub.dispatch_collection_page ${name$1}`, {
3754
4066
  kind: SpanKind.SERVER,
3755
4067
  attributes: {
3756
4068
  "activitypub.collection.id": uri.href,
@@ -3766,7 +4078,7 @@ async function handleCollection(request, { name: name$2, identifier, uriGetter,
3766
4078
  span.setStatus({ code: SpanStatusCode.ERROR });
3767
4079
  return await onNotFound(request);
3768
4080
  }
3769
- const items = filterCollectionItems(page.items, name$2, filterPredicate);
4081
+ const items = filterCollectionItems(page.items, name$1, filterPredicate);
3770
4082
  itemCount = items.length;
3771
4083
  span.setAttribute("fedify.collection.items", itemCount);
3772
4084
  return {
@@ -3788,28 +4100,30 @@ async function handleCollection(request, { name: name$2, identifier, uriGetter,
3788
4100
  const { items, prevCursor, nextCursor } = pageOrResponse;
3789
4101
  let prev = null;
3790
4102
  if (prevCursor != null) {
3791
- prev = new URL(context.url);
4103
+ prev = new URL(pageBase);
3792
4104
  prev.searchParams.set("cursor", prevCursor);
3793
4105
  }
3794
4106
  let next = null;
3795
4107
  if (nextCursor != null) {
3796
- next = new URL(context.url);
4108
+ next = new URL(pageBase);
3797
4109
  next.searchParams.set("cursor", nextCursor);
3798
4110
  }
3799
- const partOf = new URL(context.url);
4111
+ const partOf = new URL(pageBase);
3800
4112
  partOf.searchParams.delete("cursor");
3801
4113
  collection = new OrderedCollectionPage({
3802
4114
  id: uri,
4115
+ attribution,
3803
4116
  prev,
3804
4117
  next,
3805
4118
  items,
3806
4119
  partOf
3807
4120
  });
3808
4121
  }
3809
- if (collectionCallbacks.authorizePredicate != null) {
4122
+ if (portable == null && collectionCallbacks.authorizePredicate != null) {
3810
4123
  if (!await collectionCallbacks.authorizePredicate(context, identifier)) return finish(await onUnauthorized(request), "unauthorized");
3811
4124
  }
3812
4125
  const jsonLd = await collection.toJsonLd(context);
4126
+ if (portable != null) return await verifyPortableCollection(jsonLd, context, portable) ? finish(respondWithPortableCollection(request, jsonLd), "served") : finish(portableObjectInternalServerError(request), "error");
3813
4127
  return finish(new Response(JSON.stringify(jsonLd), { headers: {
3814
4128
  "Content-Type": "application/activity+json",
3815
4129
  Vary: "Accept"
@@ -4857,19 +5171,11 @@ function recordDeferredPendingCollectionMetrics(error, result, response) {
4857
5171
  pendingCollectionMetricRecorders.delete(error);
4858
5172
  recorder?.(result, response);
4859
5173
  }
4860
- async function _handleCustomCollection(request, { name, values, context, tracerProvider, meterProvider, collectionCallbacks: callbacks, filterPredicate }) {
5174
+ async function _handleCustomCollection(request, { name, values, context, tracerProvider, meterProvider, collectionCallbacks: callbacks, filterPredicate, portable }) {
4861
5175
  verifyDefined(callbacks);
4862
5176
  await authIfNeeded(context, values, callbacks);
4863
5177
  const cursor = new URL(request.url).searchParams.get("cursor");
4864
- const handler = new CustomCollectionHandler(name, values, context, callbacks, tracerProvider, meterProvider, Collection, CollectionPage, filterPredicate).fetchCollection(cursor);
4865
- try {
4866
- const response = await handler.toJsonLd().then(respondAsActivity);
4867
- handler.recordPendingCollectionMetrics("served", response);
4868
- return response;
4869
- } catch (e) {
4870
- if (!deferPendingCollectionMetrics(e, (result, response) => handler.recordPendingCollectionMetrics(result, response))) handler.recordPendingCollectionMetrics("error");
4871
- throw e;
4872
- }
5178
+ return await respondWithCustomCollection(request, new CustomCollectionHandler(name, values, context, callbacks, tracerProvider, meterProvider, Collection, CollectionPage, filterPredicate, portable).fetchCollection(cursor), context, portable);
4873
5179
  }
4874
5180
  /**
4875
5181
  * Handles an ordered collection request.
@@ -4883,13 +5189,19 @@ async function _handleCustomCollection(request, { name, values, context, tracerP
4883
5189
  * @since 1.8.0
4884
5190
  */
4885
5191
  const handleOrderedCollection = exceptWrapper(_handleOrderedCollection);
4886
- async function _handleOrderedCollection(request, { name, values, context, tracerProvider, meterProvider, collectionCallbacks: callbacks, filterPredicate }) {
5192
+ async function _handleOrderedCollection(request, { name, values, context, tracerProvider, meterProvider, collectionCallbacks: callbacks, filterPredicate, portable }) {
4887
5193
  verifyDefined(callbacks);
4888
5194
  await authIfNeeded(context, values, callbacks);
4889
5195
  const cursor = new URL(request.url).searchParams.get("cursor");
4890
- const handler = new CustomCollectionHandler(name, values, context, callbacks, tracerProvider, meterProvider, OrderedCollection, OrderedCollectionPage, filterPredicate).fetchCollection(cursor);
5196
+ return await respondWithCustomCollection(request, new CustomCollectionHandler(name, values, context, callbacks, tracerProvider, meterProvider, OrderedCollection, OrderedCollectionPage, filterPredicate, portable).fetchCollection(cursor), context, portable);
5197
+ }
5198
+ async function respondWithCustomCollection(request, handler, context, portable) {
4891
5199
  try {
4892
- const response = await handler.toJsonLd().then(respondAsActivity);
5200
+ const jsonLd = await handler.toJsonLd();
5201
+ let response;
5202
+ if (portable == null) response = respondAsActivity(jsonLd);
5203
+ else if (await verifyPortableCollection(jsonLd, context, portable)) response = respondWithPortableCollection(request, jsonLd);
5204
+ else throw new PortableCollectionRefusedError();
4893
5205
  handler.recordPendingCollectionMetrics("served", response);
4894
5206
  return response;
4895
5207
  } catch (e) {
@@ -4919,6 +5231,7 @@ var CustomCollectionHandler = class {
4919
5231
  Collection;
4920
5232
  CollectionPage;
4921
5233
  filterPredicate;
5234
+ portable;
4922
5235
  /**
4923
5236
  * The tracer for telemetry.
4924
5237
  * @type {Tracer}
@@ -4955,8 +5268,8 @@ var CustomCollectionHandler = class {
4955
5268
  * @param CollectionPage The CollectionPage constructor.
4956
5269
  * @param filterPredicate Optional filter predicate for items.
4957
5270
  */
4958
- constructor(name$1, values, context, callbacks, tracerProvider = trace.getTracerProvider(), meterProvider, Collection, CollectionPage, filterPredicate) {
4959
- this.name = name$1;
5271
+ constructor(name$2, values, context, callbacks, tracerProvider = trace.getTracerProvider(), meterProvider, Collection, CollectionPage, filterPredicate, portable) {
5272
+ this.name = name$2;
4960
5273
  this.values = values;
4961
5274
  this.context = context;
4962
5275
  this.callbacks = callbacks;
@@ -4965,9 +5278,14 @@ var CustomCollectionHandler = class {
4965
5278
  this.Collection = Collection;
4966
5279
  this.CollectionPage = CollectionPage;
4967
5280
  this.filterPredicate = filterPredicate;
5281
+ this.portable = portable;
4968
5282
  this.name = this.name.trim().replace(/\s+/g, "_");
4969
5283
  this.#tracer = this.tracerProvider.getTracer(name, version);
4970
- this.#id = new URL(this.context.url);
5284
+ if (portable == null) this.#id = new URL(this.context.url);
5285
+ else {
5286
+ const cursor = this.context.url.searchParams.get("cursor");
5287
+ this.#id = cursor == null ? new URL(portable.view) : appendCursorIfExists(portable.view, cursor);
5288
+ }
4971
5289
  this.#dispatcher = callbacks.dispatcher.bind(callbacks);
4972
5290
  }
4973
5291
  /**
@@ -5018,6 +5336,7 @@ var CustomCollectionHandler = class {
5018
5336
  this.recordPendingCollectionItemCount(true, items.length);
5019
5337
  return {
5020
5338
  id,
5339
+ attribution: this.portable?.attribution ?? null,
5021
5340
  partOf,
5022
5341
  items,
5023
5342
  prev: this.appendToUrl(prevCursor),
@@ -5039,6 +5358,7 @@ var CustomCollectionHandler = class {
5039
5358
  });
5040
5359
  return {
5041
5360
  id: this.#id,
5361
+ attribution: this.portable?.attribution ?? null,
5042
5362
  first: this.appendToUrl(firstCursor),
5043
5363
  last: this.appendToUrl(lastCursor),
5044
5364
  totalItems
@@ -5055,6 +5375,7 @@ var CustomCollectionHandler = class {
5055
5375
  this.recordPendingCollectionItemCount(false, items.length);
5056
5376
  return {
5057
5377
  id: this.#id,
5378
+ attribution: this.portable?.attribution ?? null,
5058
5379
  totalItems,
5059
5380
  items
5060
5381
  };
@@ -5172,7 +5493,7 @@ var CustomCollectionHandler = class {
5172
5493
  * @returns The URL with cursor appended, or null if cursor is null/undefined.
5173
5494
  */
5174
5495
  appendToUrl(cursor) {
5175
- return appendCursorIfExists(this.context.url, cursor);
5496
+ return appendCursorIfExists(this.portable?.view ?? this.context.url, cursor);
5176
5497
  }
5177
5498
  /**
5178
5499
  * Gets the stored collection or collection page.
@@ -5249,6 +5570,12 @@ function exceptWrapper(handler) {
5249
5570
  recordCollectionRequest(meterProvider, collectionAttributes(metricBase, "not_found", response));
5250
5571
  return response;
5251
5572
  }
5573
+ case PortableCollectionRefusedError: {
5574
+ const response = portableObjectInternalServerError(request);
5575
+ recordDeferredPendingCollectionMetrics(error, "error", response);
5576
+ recordCollectionRequest(meterProvider, collectionAttributes(metricBase, "error", response));
5577
+ return response;
5578
+ }
5252
5579
  case UnauthorizedError: {
5253
5580
  const response = await onUnauthorized(request);
5254
5581
  recordDeferredPendingCollectionMetrics(error, "unauthorized", response);
@@ -5345,6 +5672,16 @@ var UnauthorizedError = class extends HandlerError {
5345
5672
  }
5346
5673
  };
5347
5674
  /**
5675
+ * Error thrown when a portable collection is not served because it embeds
5676
+ * portable objects that do not satisfy the FEP-ef61 proof policy.
5677
+ * @since 2.4.0
5678
+ */
5679
+ var PortableCollectionRefusedError = class extends HandlerError {
5680
+ constructor() {
5681
+ super("The portable collection embeds unverifiable portable objects.");
5682
+ }
5683
+ };
5684
+ /**
5348
5685
  * Responds with the given object in JSON-LD format.
5349
5686
  *
5350
5687
  * @param object The object to respond with.
@@ -5714,6 +6051,100 @@ function serializeDataPointType(dataPointType) {
5714
6051
  }
5715
6052
  }
5716
6053
  //#endregion
6054
+ //#region src/federation/portable-collection.ts
6055
+ /**
6056
+ * The query parameters of a gateway request for a portable collection that
6057
+ * do not select a view of the collection: the page cursor, and FEP-ef61
6058
+ * location hints (`@gateway`, and the legacy `gateways`).
6059
+ */
6060
+ const NON_VIEW_PARAMETERS = /* @__PURE__ */ new Set([
6061
+ "cursor",
6062
+ "@gateway",
6063
+ "gateways"
6064
+ ]);
6065
+ /**
6066
+ * Checks that a collection requested through the FEP-ef61 gateway endpoint
6067
+ * belongs to a portable actor under the requested DID, and determines
6068
+ * the collection's ID from the actor.
6069
+ *
6070
+ * Collection dispatchers return items, not documents with IDs, so unlike
6071
+ * object dispatchers, there is nothing else to tell whether the requested
6072
+ * DID controls the collection.
6073
+ *
6074
+ * @param actor The actor that owns the collection according to
6075
+ * the application, dispatched without the portable request,
6076
+ * or `null` if there is none.
6077
+ * @param options The requested DID, and either the requested collection's
6078
+ * canonical ID and the actor's property that has to refer to
6079
+ * it (for the built-in collections of an actor), or
6080
+ * the requested collection's portable ID (for a custom
6081
+ * collection, whose ID has the same form as the actor's).
6082
+ * @returns The resolution.
6083
+ */
6084
+ function resolvePortableCollectionOwner(actor, options) {
6085
+ if (!isActor(actor)) return {
6086
+ status: "rejected",
6087
+ reason: "notFound"
6088
+ };
6089
+ const ownerId = actor.id;
6090
+ const did = ownerId == null || !isPortableId(ownerId) ? null : getPortableDid(ownerId);
6091
+ if (ownerId == null || did == null) return {
6092
+ status: "rejected",
6093
+ reason: "notPortable"
6094
+ };
6095
+ if (did !== options.authority) return {
6096
+ status: "rejected",
6097
+ reason: "authorityMismatch"
6098
+ };
6099
+ if ("portableId" in options) return {
6100
+ status: "accepted",
6101
+ ownerId,
6102
+ collectionId: getCustomCollectionId(ownerId, actor, options.portableId)
6103
+ };
6104
+ const property = options.property(actor);
6105
+ if (property == null || getCanonicalPortableId(property) !== options.canonicalId) return {
6106
+ status: "rejected",
6107
+ reason: "collectionMismatch"
6108
+ };
6109
+ const collectionId = new URL(property);
6110
+ collectionId.search = "";
6111
+ collectionId.hash = "";
6112
+ return {
6113
+ status: "accepted",
6114
+ ownerId,
6115
+ collectionId
6116
+ };
6117
+ }
6118
+ /**
6119
+ * Gets the ID of a custom portable collection in the same form as its owner's
6120
+ * ID: a portable ID if the owner has one, or otherwise a compatible identifier
6121
+ * on the owner's first gateway, as FEP-ef61 requires of publishers.
6122
+ */
6123
+ function getCustomCollectionId(ownerId, owner, portableId) {
6124
+ if (isPortableUri(ownerId)) return portableId;
6125
+ const gateway = owner.gateways[0];
6126
+ if (gateway == null || !isGatewayUrl(gateway)) return portableId;
6127
+ try {
6128
+ return toCompatibleEf61Id(portableId, gateway);
6129
+ } catch (error) {
6130
+ if (error instanceof TypeError) return portableId;
6131
+ throw error;
6132
+ }
6133
+ }
6134
+ /**
6135
+ * Builds the URI that the pages of a portable collection are relative to:
6136
+ * the collection's ID with the query parameters of the requested view, e.g.,
6137
+ * a filter, but without the page cursor and location hints.
6138
+ * @param collectionId The ID of the collection.
6139
+ * @param requestUrl The URL of the gateway request.
6140
+ * @returns The view URI.
6141
+ */
6142
+ function buildPortableCollectionView(collectionId, requestUrl) {
6143
+ const view = new URL(collectionId);
6144
+ for (const [name, value] of requestUrl.searchParams) if (!NON_VIEW_PARAMETERS.has(name)) view.searchParams.append(name, value);
6145
+ return view;
6146
+ }
6147
+ //#endregion
5717
6148
  //#region src/federation/outgoing-proof.ts
5718
6149
  function formatKeyIds(candidates) {
5719
6150
  return candidates.map((c) => c.verificationMethod.href).join(", ");
@@ -8175,12 +8606,7 @@ var FederationImpl = class extends FederationBuilderImpl {
8175
8606
  onNotFound
8176
8607
  });
8177
8608
  case "followers": {
8178
- let baseUrl = url.searchParams.get("base-url");
8179
- if (baseUrl != null) try {
8180
- baseUrl = `${new URL(baseUrl).origin}/`;
8181
- } catch {
8182
- baseUrl = null;
8183
- }
8609
+ const baseUrl = getFollowersBaseUrl(url);
8184
8610
  return await handleCollection(request, {
8185
8611
  name: "followers",
8186
8612
  identifier: route.values.identifier,
@@ -8191,7 +8617,7 @@ var FederationImpl = class extends FederationBuilderImpl {
8191
8617
  },
8192
8618
  context,
8193
8619
  filter: baseUrl != null ? new URL(baseUrl) : void 0,
8194
- filterPredicate: baseUrl != null ? ((i) => (i instanceof URL ? i.href : i.id?.href ?? "").startsWith(baseUrl)) : void 0,
8620
+ filterPredicate: baseUrl != null ? getFollowersFilterPredicate(baseUrl) : void 0,
8195
8621
  collectionCallbacks: this.followersCallbacks,
8196
8622
  tracerProvider: this.tracerProvider,
8197
8623
  meterProvider: this._meterProvider,
@@ -8285,17 +8711,30 @@ var FederationImpl = class extends FederationBuilderImpl {
8285
8711
  }
8286
8712
  const route = this.router.route(portable.path);
8287
8713
  const isActor = route != null && (route.name === "actor" || route.name.startsWith("actorAlias:"));
8288
- if (route == null || !isActor && !route.name.startsWith("object:")) {
8714
+ const collectionRoute = route == null ? void 0 : getCollectionMetricRoute(route.name.replace(/:.*$/, ""));
8715
+ if (route == null || !isActor && !route.name.startsWith("object:") && collectionRoute == null) {
8289
8716
  metricState.endpoint = "not_found";
8290
8717
  return await onNotFound(request);
8291
8718
  }
8292
8719
  metricState.routeTemplate = PORTABLE_GATEWAY_ROUTE_PREFIX + route.template;
8293
- metricState.endpoint = isActor ? "actor" : "object";
8720
+ metricState.endpoint = getEndpointCategory(route.name);
8294
8721
  span.updateName(`${request.method} ${metricState.routeTemplate}`);
8295
8722
  if (!acceptsJsonLd(request)) {
8296
8723
  metricState.endpoint = "not_acceptable";
8297
- return await onNotAcceptable(request);
8724
+ const response = await onNotAcceptable(request);
8725
+ if (collectionRoute != null) recordCollectionRequest(this._meterProvider, {
8726
+ ...collectionRoute,
8727
+ page: new URL(request.url).searchParams.get("cursor") != null,
8728
+ result: "not_acceptable",
8729
+ statusCode: response.status
8730
+ });
8731
+ return response;
8298
8732
  }
8733
+ if (collectionRoute != null) return await this.#fetchPortableCollection(request, portable, route, {
8734
+ onNotFound,
8735
+ onUnauthorized,
8736
+ contextData
8737
+ });
8299
8738
  if (isActor) {
8300
8739
  const identifier = route.name.startsWith("actorAlias:") ? route.name.substring(11) : route.values.identifier;
8301
8740
  const context = this.#createContext(request, contextData, {
@@ -8334,6 +8773,108 @@ var FederationImpl = class extends FederationBuilderImpl {
8334
8773
  onNotFound
8335
8774
  });
8336
8775
  }
8776
+ async #fetchPortableCollection(request, portable, route, { onNotFound, onUnauthorized, contextData }) {
8777
+ const logger = getLogger([
8778
+ "fedify",
8779
+ "federation",
8780
+ "collection"
8781
+ ]);
8782
+ const url = new URL(request.url);
8783
+ const { authority } = portable.portableRequest;
8784
+ const ownerContext = this.#createContext(request, contextData);
8785
+ const context = this.#createContext(request, contextData, { portableRequest: portable.portableRequest });
8786
+ const reject = async (reason, identifier) => {
8787
+ logger.debug("Not serving the portable collection {collection} of the actor {identifier}: {reason}.", {
8788
+ collection: portable.canonicalId,
8789
+ identifier,
8790
+ reason
8791
+ });
8792
+ return await onNotFound(request);
8793
+ };
8794
+ const routeName = route.name.replace(/:.*$/, "");
8795
+ if (routeName === "collection" || routeName === "orderedCollection") {
8796
+ const name = route.name.replace(/^(?:ordered)?[cC]ollection:/, "");
8797
+ const callbacks = this.collectionCallbacks[name];
8798
+ if (callbacks == null) return await onNotFound(request);
8799
+ const mapper = callbacks.portableOwnerMapper;
8800
+ if (mapper == null) return await reject("noPortableOwnerMapper");
8801
+ const identifier = await mapper(ownerContext, route.values);
8802
+ if (identifier == null) return await reject("notPortable");
8803
+ const resolution = resolvePortableCollectionOwner(await this.actorCallbacks?.dispatcher?.(ownerContext, identifier), {
8804
+ authority,
8805
+ portableId: portable.portableRequest.id
8806
+ });
8807
+ if (resolution.status === "rejected") return await reject(resolution.reason, identifier);
8808
+ const view = buildPortableCollectionView(resolution.collectionId, url);
8809
+ return await (routeName === "collection" ? handleCustomCollection : handleOrderedCollection)(request, {
8810
+ name,
8811
+ context,
8812
+ values: route.values,
8813
+ collectionCallbacks: callbacks,
8814
+ portable: {
8815
+ id: view,
8816
+ view,
8817
+ attribution: resolution.ownerId
8818
+ },
8819
+ tracerProvider: this.tracerProvider,
8820
+ meterProvider: this._meterProvider,
8821
+ onUnauthorized,
8822
+ onNotFound
8823
+ });
8824
+ }
8825
+ const identifier = route.values.identifier;
8826
+ const serve = async (name, callbacks, property, filter) => {
8827
+ if (callbacks == null) return await onNotFound(request);
8828
+ const resolution = resolvePortableCollectionOwner(await this.actorCallbacks?.dispatcher?.(ownerContext, identifier), {
8829
+ authority,
8830
+ canonicalId: portable.canonicalId,
8831
+ property
8832
+ });
8833
+ if (resolution.status === "rejected") return await reject(resolution.reason, identifier);
8834
+ const id = new URL(resolution.collectionId);
8835
+ if (filter != null) id.searchParams.set(...filter.parameter);
8836
+ const view = buildPortableCollectionView(resolution.collectionId, url);
8837
+ if (routeName === "followers") {
8838
+ view.searchParams.delete("base-url");
8839
+ if (filter != null) view.searchParams.set(...filter.parameter);
8840
+ }
8841
+ return await handleCollection(request, {
8842
+ name,
8843
+ identifier,
8844
+ uriGetter: () => id,
8845
+ context,
8846
+ filter: filter?.value,
8847
+ filterPredicate: filter?.predicate,
8848
+ collectionCallbacks: callbacks,
8849
+ portable: {
8850
+ id,
8851
+ view,
8852
+ attribution: resolution.ownerId
8853
+ },
8854
+ tracerProvider: this.tracerProvider,
8855
+ meterProvider: this._meterProvider,
8856
+ onUnauthorized,
8857
+ onNotFound
8858
+ });
8859
+ };
8860
+ switch (routeName) {
8861
+ case "outbox": return await serve("outbox", this.outboxCallbacks, (a) => a.outboxId);
8862
+ case "inbox": return await serve("inbox", this.inboxCallbacks, (a) => a.inboxId);
8863
+ case "following": return await serve("following", this.followingCallbacks, (a) => a.followingId);
8864
+ case "followers": {
8865
+ const baseUrl = getFollowersBaseUrl(url);
8866
+ return await serve("followers", this.followersCallbacks, (a) => a.followersId, baseUrl == null ? void 0 : {
8867
+ value: new URL(baseUrl),
8868
+ predicate: getFollowersFilterPredicate(baseUrl),
8869
+ parameter: ["base-url", baseUrl]
8870
+ });
8871
+ }
8872
+ case "liked": return await serve("liked", this.likedCallbacks, (a) => a.likedId);
8873
+ case "featured": return await serve("featured", this.featuredCallbacks, (a) => a.featuredId);
8874
+ case "featuredTags": return await serve("featured tags", this.featuredTagsCallbacks, (a) => a.featuredTagsId);
8875
+ }
8876
+ return await onNotFound(request);
8877
+ }
8337
8878
  async #handleInbox(request, { recipient, context, contextData, onNotFound, portableInbox }) {
8338
8879
  if (!this.manuallyStartQueue) this._startQueueInternal(contextData);
8339
8880
  const inboxContextFactory = context.toInboxContext.bind(context);
@@ -8520,6 +9061,41 @@ var FederationImpl = class extends FederationBuilderImpl {
8520
9061
  };
8521
9062
  const PORTABLE_GATEWAY_ROUTE_PREFIX = "/.well-known/apgateway/{did}";
8522
9063
  /**
9064
+ * Gets the origin that a followers collection request filters followers by,
9065
+ * i.e., its normalized `base-url` query parameter (FEP-8fcf).
9066
+ * @returns The origin with a trailing slash, or `null` if the parameter is
9067
+ * missing or invalid.
9068
+ */
9069
+ function getFollowersBaseUrl(url) {
9070
+ const baseUrl = url.searchParams.get("base-url");
9071
+ if (baseUrl == null) return null;
9072
+ try {
9073
+ return `${new URL(baseUrl).origin}/`;
9074
+ } catch {
9075
+ return null;
9076
+ }
9077
+ }
9078
+ function getFollowersFilterPredicate(baseUrl) {
9079
+ return (i) => (i instanceof URL ? i.href : i.id?.href ?? "").startsWith(baseUrl);
9080
+ }
9081
+ const ACTOR_COLLECTION_ROUTE_ERRORS = {
9082
+ outbox: "No outbox dispatcher registered.",
9083
+ following: "No following collection path registered.",
9084
+ followers: "No followers collection path registered.",
9085
+ liked: "No liked collection path registered.",
9086
+ featured: "No featured collection path registered.",
9087
+ featuredTags: "No featured tags collection path registered."
9088
+ };
9089
+ /**
9090
+ * Checks that the authority of a portable ID to build is given, either
9091
+ * explicitly or by the FEP-ef61 gateway request being handled.
9092
+ * @throws {TypeError} If the authority is missing.
9093
+ */
9094
+ function requirePortableAuthority(authority) {
9095
+ if (authority == null) throw new TypeError("The authority of a portable ID is required outside an FEP-ef61 gateway request.");
9096
+ return authority;
9097
+ }
9098
+ /**
8523
9099
  * Removes the body of a response to a `HEAD` request.
8524
9100
  * @param request The request.
8525
9101
  * @param response The response to the request.
@@ -8698,8 +9274,7 @@ var ContextImpl = class ContextImpl {
8698
9274
  }
8699
9275
  getPortableActorUri(identifier, authority) {
8700
9276
  const path = this.#getActorPath(identifier);
8701
- if (authority == null) throw new TypeError("The authority of a portable ID is required outside an FEP-ef61 gateway request.");
8702
- return buildPortableUri(authority, path);
9277
+ return buildPortableUri(requirePortableAuthority(authority), path);
8703
9278
  }
8704
9279
  #getActorPath(identifier) {
8705
9280
  const path = this.federation.router.build(`actorAlias:${identifier}`, {}) ?? this.federation.router.build("actor", { identifier });
@@ -8711,8 +9286,7 @@ var ContextImpl = class ContextImpl {
8711
9286
  }
8712
9287
  getPortableObjectUri(cls, values, authority) {
8713
9288
  const path = this.#getObjectPath(cls, values);
8714
- if (authority == null) throw new TypeError("The authority of a portable ID is required outside an FEP-ef61 gateway request.");
8715
- return buildPortableUri(authority, path);
9289
+ return buildPortableUri(requirePortableAuthority(authority), path);
8716
9290
  }
8717
9291
  #getObjectPath(cls, values) {
8718
9292
  const callbacks = this.federation.objectCallbacks[cls.typeId.href];
@@ -8722,10 +9296,17 @@ var ContextImpl = class ContextImpl {
8722
9296
  if (path == null) throw new RouterError("No object dispatcher registered.");
8723
9297
  return path;
8724
9298
  }
9299
+ #getActorCollectionPath(route, identifier) {
9300
+ const path = this.federation.router.build(route, { identifier });
9301
+ if (path == null) throw new RouterError(ACTOR_COLLECTION_ROUTE_ERRORS[route]);
9302
+ return path;
9303
+ }
8725
9304
  getOutboxUri(identifier) {
8726
- const path = this.federation.router.build("outbox", { identifier });
8727
- if (path == null) throw new RouterError("No outbox dispatcher registered.");
8728
- return new URL(path, this.canonicalOrigin);
9305
+ return new URL(this.#getActorCollectionPath("outbox", identifier), this.canonicalOrigin);
9306
+ }
9307
+ getPortableOutboxUri(identifier, authority) {
9308
+ const path = this.#getActorCollectionPath("outbox", identifier);
9309
+ return buildPortableUri(requirePortableAuthority(authority), path);
8729
9310
  }
8730
9311
  getMediaUploaderUri(identifier) {
8731
9312
  const path = this.federation.router.build("mediaUploader", { identifier });
@@ -8745,39 +9326,53 @@ var ContextImpl = class ContextImpl {
8745
9326
  getPortableInboxUri(identifier, authority) {
8746
9327
  const path = this.federation.router.build("inbox", { identifier });
8747
9328
  if (path == null) throw new RouterError("No inbox path registered.");
8748
- if (authority == null) throw new TypeError("The authority of a portable ID is required outside an FEP-ef61 gateway request.");
8749
- return buildPortableUri(authority, path);
9329
+ return buildPortableUri(requirePortableAuthority(authority), path);
8750
9330
  }
8751
9331
  getFollowingUri(identifier) {
8752
- const path = this.federation.router.build("following", { identifier });
8753
- if (path == null) throw new RouterError("No following collection path registered.");
8754
- return new URL(path, this.canonicalOrigin);
9332
+ return new URL(this.#getActorCollectionPath("following", identifier), this.canonicalOrigin);
9333
+ }
9334
+ getPortableFollowingUri(identifier, authority) {
9335
+ const path = this.#getActorCollectionPath("following", identifier);
9336
+ return buildPortableUri(requirePortableAuthority(authority), path);
8755
9337
  }
8756
9338
  getFollowersUri(identifier) {
8757
- const path = this.federation.router.build("followers", { identifier });
8758
- if (path == null) throw new RouterError("No followers collection path registered.");
8759
- return new URL(path, this.canonicalOrigin);
9339
+ return new URL(this.#getActorCollectionPath("followers", identifier), this.canonicalOrigin);
9340
+ }
9341
+ getPortableFollowersUri(identifier, authority) {
9342
+ const path = this.#getActorCollectionPath("followers", identifier);
9343
+ return buildPortableUri(requirePortableAuthority(authority), path);
8760
9344
  }
8761
9345
  getLikedUri(identifier) {
8762
- const path = this.federation.router.build("liked", { identifier });
8763
- if (path == null) throw new RouterError("No liked collection path registered.");
8764
- return new URL(path, this.canonicalOrigin);
9346
+ return new URL(this.#getActorCollectionPath("liked", identifier), this.canonicalOrigin);
9347
+ }
9348
+ getPortableLikedUri(identifier, authority) {
9349
+ const path = this.#getActorCollectionPath("liked", identifier);
9350
+ return buildPortableUri(requirePortableAuthority(authority), path);
8765
9351
  }
8766
9352
  getFeaturedUri(identifier) {
8767
- const path = this.federation.router.build("featured", { identifier });
8768
- if (path == null) throw new RouterError("No featured collection path registered.");
8769
- return new URL(path, this.canonicalOrigin);
9353
+ return new URL(this.#getActorCollectionPath("featured", identifier), this.canonicalOrigin);
9354
+ }
9355
+ getPortableFeaturedUri(identifier, authority) {
9356
+ const path = this.#getActorCollectionPath("featured", identifier);
9357
+ return buildPortableUri(requirePortableAuthority(authority), path);
8770
9358
  }
8771
9359
  getFeaturedTagsUri(identifier) {
8772
- const path = this.federation.router.build("featuredTags", { identifier });
8773
- if (path == null) throw new RouterError("No featured tags collection path registered.");
8774
- return new URL(path, this.canonicalOrigin);
9360
+ return new URL(this.#getActorCollectionPath("featuredTags", identifier), this.canonicalOrigin);
9361
+ }
9362
+ getPortableFeaturedTagsUri(identifier, authority) {
9363
+ const path = this.#getActorCollectionPath("featuredTags", identifier);
9364
+ return buildPortableUri(requirePortableAuthority(authority), path);
8775
9365
  }
8776
9366
  getCollectionUri(name, values) {
8777
9367
  const path = this.federation.getCollectionPath(name, values);
8778
9368
  if (path === null) throw new RouterError(`No collection dispatcher registered for "${String(name)}".`);
8779
9369
  return new URL(path, this.canonicalOrigin);
8780
9370
  }
9371
+ getPortableCollectionUri(name, values, authority) {
9372
+ const path = this.federation.getCollectionPath(name, values);
9373
+ if (path === null) throw new RouterError(`No collection dispatcher registered for "${String(name)}".`);
9374
+ return buildPortableUri(requirePortableAuthority(authority), path);
9375
+ }
8781
9376
  parseUri(uri) {
8782
9377
  if (uri == null) return null;
8783
9378
  if (uri.origin !== this.origin && uri.origin !== this.canonicalOrigin) return null;
@@ -9357,6 +9952,27 @@ var RequestContextImpl = class RequestContextImpl extends ContextImpl {
9357
9952
  getPortableInboxUri(identifier, authority) {
9358
9953
  return super.getPortableInboxUri(identifier, authority ?? this.portableRequest?.authority);
9359
9954
  }
9955
+ getPortableOutboxUri(identifier, authority) {
9956
+ return super.getPortableOutboxUri(identifier, authority ?? this.portableRequest?.authority);
9957
+ }
9958
+ getPortableFollowingUri(identifier, authority) {
9959
+ return super.getPortableFollowingUri(identifier, authority ?? this.portableRequest?.authority);
9960
+ }
9961
+ getPortableFollowersUri(identifier, authority) {
9962
+ return super.getPortableFollowersUri(identifier, authority ?? this.portableRequest?.authority);
9963
+ }
9964
+ getPortableLikedUri(identifier, authority) {
9965
+ return super.getPortableLikedUri(identifier, authority ?? this.portableRequest?.authority);
9966
+ }
9967
+ getPortableFeaturedUri(identifier, authority) {
9968
+ return super.getPortableFeaturedUri(identifier, authority ?? this.portableRequest?.authority);
9969
+ }
9970
+ getPortableFeaturedTagsUri(identifier, authority) {
9971
+ return super.getPortableFeaturedTagsUri(identifier, authority ?? this.portableRequest?.authority);
9972
+ }
9973
+ getPortableCollectionUri(name, values, authority) {
9974
+ return super.getPortableCollectionUri(name, values, authority ?? this.portableRequest?.authority);
9975
+ }
9360
9976
  async getActor(identifier, options) {
9361
9977
  if (this.federation.actorCallbacks == null || this.federation.actorCallbacks.dispatcher == null) throw new Error("No actor dispatcher registered.");
9362
9978
  if (this.#invokedFromActorDispatcher != null) getLogger([