@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
@@ -1,10 +1,10 @@
1
1
  const { URLPattern } = require("urlpattern-polyfill");
2
2
  const require_rolldown_runtime = require("./rolldown-runtime-Dmm3t92W.cjs");
3
3
  const require_transformers = require("./transformers-BalCrSsr.cjs");
4
- const require_http = require("./http-DSYQQn23.cjs");
4
+ const require_http = require("./http-eqYXHRkz.cjs");
5
5
  const require_types = require("./types-Be2zbyeG.cjs");
6
- const require_portable_collection = require("./portable-collection-BD8fE3j3.cjs");
7
- const require_kv_cache = require("./kv-cache-BcsvaCCL.cjs");
6
+ const require_portable_collection = require("./portable-collection-wBWbuHOH.cjs");
7
+ const require_kv_cache = require("./kv-cache-DzClEalR.cjs");
8
8
  let _logtape_logtape = require("@logtape/logtape");
9
9
  let _fedify_uri_template = require("@fedify/uri-template");
10
10
  let _fedify_vocab = require("@fedify/vocab");
@@ -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 _fedify_vocab.Tombstone) return actor;
213
213
  if (actor.id != null && (0, _fedify_vocab_runtime_internal_portable_dereference.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 : require_http.getPortableDid(actor.id);
733
+ if (did == null) return;
734
+ const logger = (0, _logtape_logtape.getLogger)([
735
+ "fedify",
736
+ "federation",
737
+ "actor"
738
+ ]);
739
+ const check = (registered, property, value, helper, build) => {
740
+ if (!registered || value == null || !require_http.isPortableId(value)) return;
741
+ const actual = require_http.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 _fedify_uri_template.RouterError) return;
754
+ throw error;
755
+ }
756
+ if (actual === require_http.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.
@@ -2068,6 +2121,218 @@ function isSingleStringValue(values, datatype) {
2068
2121
  const keys = Object.keys(value);
2069
2122
  return keys.length === 1 || datatype != null && keys.length === 2 && value["@type"] === datatype;
2070
2123
  }
2124
+ /**
2125
+ * FEP-2277 core types that FEP-ef61 does not require to carry proofs.
2126
+ */
2127
+ const UNSECURED_CORE_TYPES = /* @__PURE__ */ new Set([
2128
+ "collection",
2129
+ "link",
2130
+ "verificationMethod",
2131
+ "publicKey"
2132
+ ]);
2133
+ /**
2134
+ * Applies the FEP-ef61 proof policy, under the map-local compound-proof
2135
+ * profile, to every portable object embedded in a document that a gateway is
2136
+ * about to serve, e.g., the items of a portable collection page. The root
2137
+ * map itself is not checked, as the caller built it.
2138
+ *
2139
+ * Each map below the root whose direct `id` or `@id` is a portable ID or
2140
+ * a compatible identifier is checked independently of the others:
2141
+ *
2142
+ * - A map with a direct `proof` is a separately secured document: the proof
2143
+ * has to be a single map, the map has to carry its own `@context`, and
2144
+ * the proof has to verify over the map exactly as it is served, with only
2145
+ * its own proof removed, and be made with a key of the DID in the map's
2146
+ * ID, as the map-local compound-proof profile requires.
2147
+ * - A map without a proof has to be a collection, a link, or a key, which
2148
+ * FEP-ef61 does not require to be secured. It is classified under its
2149
+ * effective context, i.e., the `@context` values of its ancestors and
2150
+ * itself, which therefore may only use the simple, preloaded contexts
2151
+ * that the compound-proof policy accepts.
2152
+ *
2153
+ * Maps that merely refer to a portable object, i.e., have no member other
2154
+ * than `id` or `@id`, and maps with non-portable IDs, are neither checked nor
2155
+ * verified. Every `@context` in the document, however, has to be simple, and
2156
+ * every portable object that the expanded document describes has to be
2157
+ * a map found this way, so that no context can hide a portable object from
2158
+ * this check, e.g., by aliasing `@id` or redefining `proof`. The input must be a finite, already-materialized JSON tree, such
2159
+ * as the compact JSON-LD document that is about to be served.
2160
+ *
2161
+ * @param json The document to check.
2162
+ * @param options The options for verifying the proofs. Proofs whose
2163
+ * verification methods are not `did:key` DID URLs may make
2164
+ * the document loader fetch DID documents.
2165
+ * @returns The result, which names the first refused map, if any.
2166
+ * @internal
2167
+ */
2168
+ async function verifyServedPortableObjects(json, options = {}) {
2169
+ json = JSON.parse(JSON.stringify(json));
2170
+ const collected = collectServedPortableMaps(json);
2171
+ if (!("maps" in collected)) return collected;
2172
+ const maps = collected.maps;
2173
+ const discovered = /* @__PURE__ */ new Map();
2174
+ const ids = maps.map((candidate) => candidate.id);
2175
+ const rootId = isJsonMap(json) ? [json.id, json["@id"]].find(isPortableIdValue) : void 0;
2176
+ if (rootId != null) ids.push(rootId);
2177
+ for (const id of ids) discovered.set(id, (discovered.get(id) ?? 0) + 1);
2178
+ let hidden;
2179
+ try {
2180
+ hidden = findHiddenPortableObject(await _fedify_vocab_runtime_jsonld.default.expand(json, { documentLoader: require_http.preloadedOnlyDocumentLoader }), discovered);
2181
+ } catch {
2182
+ return {
2183
+ verified: false,
2184
+ path: "",
2185
+ id: "",
2186
+ reason: "unsupportedContext"
2187
+ };
2188
+ }
2189
+ if (hidden != null) return {
2190
+ verified: false,
2191
+ path: "",
2192
+ id: hidden,
2193
+ reason: "hiddenPortableObject"
2194
+ };
2195
+ for (const candidate of maps) {
2196
+ const reason = await checkServedPortableMap(candidate, options);
2197
+ if (reason != null) return {
2198
+ verified: false,
2199
+ path: candidate.path,
2200
+ id: candidate.id,
2201
+ reason
2202
+ };
2203
+ }
2204
+ return { verified: true };
2205
+ }
2206
+ /**
2207
+ * Finds a portable object that an expanded JSON-LD document describes, i.e.,
2208
+ * a node with a portable `@id` and other members, more times than the given
2209
+ * numbers of maps found for its ID.
2210
+ */
2211
+ function findHiddenPortableObject(expanded, discovered) {
2212
+ const remaining = new Map(discovered);
2213
+ const pending = [expanded];
2214
+ while (pending.length > 0) {
2215
+ const value = pending.pop();
2216
+ if (Array.isArray(value)) {
2217
+ pending.push(...value);
2218
+ continue;
2219
+ }
2220
+ if (!isJsonMap(value)) continue;
2221
+ const id = value["@id"];
2222
+ if (isPortableIdValue(id) && Object.keys(value).some((key) => key !== "@id")) {
2223
+ const count = remaining.get(id) ?? 0;
2224
+ if (count < 1) return id;
2225
+ remaining.set(id, count - 1);
2226
+ }
2227
+ pending.push(...Object.values(value));
2228
+ }
2229
+ }
2230
+ /**
2231
+ * Checks whether a context uses only preloaded remote contexts and simple
2232
+ * term definitions, so that the context in effect for a map can be told from
2233
+ * the contexts of its ancestors.
2234
+ */
2235
+ function isCheckableContext(context) {
2236
+ return (Array.isArray(context) ? context : [context]).every((value) => typeof value === "string" ? Object.hasOwn(_fedify_vocab_runtime.preloadedContexts, value) : !Array.isArray(value) && isSimpleContext(value));
2237
+ }
2238
+ /**
2239
+ * Checks whether every remote context that a context refers to is preloaded,
2240
+ * so that all of its definitions are known.
2241
+ */
2242
+ function hasOnlyPreloadedRemoteContexts(context) {
2243
+ return (Array.isArray(context) ? context : [context]).every((value) => typeof value === "string" ? Object.hasOwn(_fedify_vocab_runtime.preloadedContexts, value) : !Array.isArray(value));
2244
+ }
2245
+ /**
2246
+ * Checks whether a map or any map below it, outside proof and context values,
2247
+ * has a portable `id` or `@id` and other members.
2248
+ */
2249
+ function hasLiteralPortableObject(json) {
2250
+ return findInJsonMaps(json, (map) => [map.id, map["@id"]].some(isPortableIdValue) && Object.keys(map).some((key) => key !== "id" && key !== "@id") ? true : void 0) ?? false;
2251
+ }
2252
+ function collectServedPortableMaps(json) {
2253
+ const maps = [];
2254
+ const seen = /* @__PURE__ */ new Set();
2255
+ const pending = [{
2256
+ value: json,
2257
+ path: "",
2258
+ depth: 0,
2259
+ contexts: []
2260
+ }];
2261
+ while (pending.length > 0) {
2262
+ const { value, path, depth, contexts } = pending.pop();
2263
+ if (typeof value !== "object" || value == null || seen.has(value)) continue;
2264
+ seen.add(value);
2265
+ if (Array.isArray(value)) {
2266
+ for (let index = value.length - 1; index >= 0; index--) pending.push({
2267
+ value: value[index],
2268
+ path: childPath(path, String(index)),
2269
+ depth: depth + 1,
2270
+ contexts
2271
+ });
2272
+ continue;
2273
+ }
2274
+ const map = value;
2275
+ const hasContext = Object.hasOwn(map, "@context");
2276
+ if (hasContext && !isCheckableContext(map["@context"])) {
2277
+ if (hasOnlyPreloadedRemoteContexts(map["@context"]) && !hasLiteralPortableObject(map)) continue;
2278
+ const id = [map.id, map["@id"]].find((v) => typeof v === "string");
2279
+ return {
2280
+ verified: false,
2281
+ path,
2282
+ id: typeof id === "string" ? id : "",
2283
+ reason: "unsupportedContext"
2284
+ };
2285
+ }
2286
+ const mapContexts = hasContext ? [...contexts, map["@context"]] : contexts;
2287
+ const id = [map.id, map["@id"]].find(isPortableIdValue);
2288
+ if (depth > 0 && id != null && Object.keys(map).some((key) => key !== "id" && key !== "@id")) maps.push({
2289
+ path,
2290
+ id,
2291
+ map,
2292
+ contexts: mapContexts
2293
+ });
2294
+ const keys = Object.keys(map);
2295
+ for (let index = keys.length - 1; index >= 0; index--) {
2296
+ const key = keys[index];
2297
+ if (key === "proof" || key === "@context") continue;
2298
+ pending.push({
2299
+ value: map[key],
2300
+ path: childPath(path, key),
2301
+ depth: depth + 1,
2302
+ contexts: mapContexts
2303
+ });
2304
+ }
2305
+ }
2306
+ maps.sort((left, right) => comparePaths(left.path, right.path));
2307
+ return { maps };
2308
+ }
2309
+ async function checkServedPortableMap({ map, contexts }, options) {
2310
+ if (Object.hasOwn(map, "proof")) {
2311
+ if (!isJsonMap(map.proof)) return "unsupportedProofShape";
2312
+ if (!Object.hasOwn(map, "@context")) return "missingContext";
2313
+ try {
2314
+ const policy = await require_http.verifyPortableObjectProofPolicy(map, await require_http.verifyMapLocalProof(map, options), options);
2315
+ return policy.verified ? null : policy.reason.type;
2316
+ } catch (error) {
2317
+ if (error instanceof TypeError) return "invalidPortableObject";
2318
+ throw error;
2319
+ }
2320
+ }
2321
+ const flattened = contexts.flatMap((context) => Array.isArray(context) ? context : [context]);
2322
+ if (flattened.length < 1 || !isSimpleContext(flattened)) return "unsupportedContext";
2323
+ let expanded;
2324
+ try {
2325
+ expanded = await _fedify_vocab_runtime_jsonld.default.expand({
2326
+ ...map,
2327
+ "@context": flattened
2328
+ }, { documentLoader: require_http.preloadedOnlyDocumentLoader });
2329
+ } catch {
2330
+ return "unsupportedContext";
2331
+ }
2332
+ if (expanded.length !== 1 || !isJsonMap(expanded[0])) return "invalidPortableObject";
2333
+ const type = require_http.classifyFep2277CoreType(expanded[0]);
2334
+ return UNSECURED_CORE_TYPES.has(type) ? null : "missingProof";
2335
+ }
2071
2336
  //#endregion
2072
2337
  //#region src/federation/inbox.ts
2073
2338
  const Temporal$10 = require_http.Temporal;
@@ -3632,6 +3897,46 @@ function portableObjectInternalServerError(request) {
3632
3897
  headers: { "Content-Type": "text/plain; charset=utf-8" }
3633
3898
  });
3634
3899
  }
3900
+ /**
3901
+ * Checks the portable objects embedded in a portable collection or its page
3902
+ * that is about to be served against the FEP-ef61 proof policy. See
3903
+ * `verifyServedPortableObjects()`.
3904
+ * @returns `true` if the collection can be served.
3905
+ */
3906
+ async function verifyPortableCollection(jsonLd, context, portable) {
3907
+ const logger = (0, _logtape_logtape.getLogger)([
3908
+ "fedify",
3909
+ "federation",
3910
+ "collection"
3911
+ ]);
3912
+ try {
3913
+ const result = await verifyServedPortableObjects(jsonLd, {
3914
+ contextLoader: context.contextLoader,
3915
+ documentLoader: context.documentLoader,
3916
+ tracerProvider: context.tracerProvider,
3917
+ meterProvider: context.meterProvider
3918
+ });
3919
+ if (result.verified) return true;
3920
+ 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.", {
3921
+ collectionId: portable.id.href,
3922
+ objectId: result.id,
3923
+ path: result.path,
3924
+ reason: result.reason
3925
+ });
3926
+ } catch (error) {
3927
+ logger.error("Failed to verify the portable objects embedded in the portable collection {collectionId}:\n{error}", {
3928
+ collectionId: portable.id.href,
3929
+ error
3930
+ });
3931
+ }
3932
+ return false;
3933
+ }
3934
+ function respondWithPortableCollection(request, jsonLd) {
3935
+ return new Response(request.method === "HEAD" ? null : JSON.stringify(jsonLd), { headers: {
3936
+ "Content-Type": PORTABLE_OBJECT_CONTENT_TYPE,
3937
+ Vary: "Accept"
3938
+ } });
3939
+ }
3635
3940
  const BUILT_IN_COLLECTION_METRIC_KINDS = /* @__PURE__ */ new Set([
3636
3941
  "inbox",
3637
3942
  "outbox",
@@ -3669,13 +3974,13 @@ function recordCollectionMetrics(meterProvider, base, result, options = {}) {
3669
3974
  * @param parameters The parameters for handling the collection.
3670
3975
  * @returns A promise that resolves to an HTTP response.
3671
3976
  */
3672
- async function handleCollection(request, { name: name$2, identifier, uriGetter, filter, filterPredicate, context, collectionCallbacks, tracerProvider, meterProvider, onUnauthorized, onNotFound }) {
3673
- const spanName = name$2.trim().replace(/\s+/g, "_");
3977
+ async function handleCollection(request, { name: name$1, identifier, uriGetter, filter, filterPredicate, context, collectionCallbacks, portable, tracerProvider, meterProvider, onUnauthorized, onNotFound }) {
3978
+ const spanName = name$1.trim().replace(/\s+/g, "_");
3674
3979
  tracerProvider = tracerProvider ?? _opentelemetry_api.trace.getTracerProvider();
3675
3980
  const tracer = tracerProvider.getTracer(require_http.name, require_http.version);
3676
3981
  const cursor = new URL(request.url).searchParams.get("cursor");
3677
3982
  const metricBase = {
3678
- kind: getCollectionMetricKind(name$2),
3983
+ kind: getCollectionMetricKind(name$1),
3679
3984
  page: cursor != null,
3680
3985
  dispatcher: "built_in"
3681
3986
  };
@@ -3693,8 +3998,13 @@ async function handleCollection(request, { name: name$2, identifier, uriGetter,
3693
3998
  };
3694
3999
  try {
3695
4000
  if (collectionCallbacks == null) return finish(await onNotFound(request), "not_found");
4001
+ if (portable != null && collectionCallbacks.authorizePredicate != null) {
4002
+ if (!await collectionCallbacks.authorizePredicate(context, identifier)) return finish(await onUnauthorized(request), "unauthorized");
4003
+ }
3696
4004
  let collection;
3697
- const baseUri = uriGetter(identifier);
4005
+ const baseUri = portable?.id ?? uriGetter(identifier);
4006
+ const pageBase = portable?.view ?? context.url;
4007
+ const attribution = portable?.attribution ?? null;
3698
4008
  if (cursor == null) {
3699
4009
  const firstCursor = await collectionCallbacks.firstCursor?.(context, identifier);
3700
4010
  const totalItems = filter == null ? await collectionCallbacks.counter?.(context, identifier) : void 0;
@@ -3716,7 +4026,7 @@ async function handleCollection(request, { name: name$2, identifier, uriGetter,
3716
4026
  span.setStatus({ code: _opentelemetry_api.SpanStatusCode.ERROR });
3717
4027
  return await onNotFound(request);
3718
4028
  }
3719
- const items = filterCollectionItems(page.items, name$2, filterPredicate);
4029
+ const items = filterCollectionItems(page.items, name$1, filterPredicate);
3720
4030
  itemCount = items.length;
3721
4031
  span.setAttribute("fedify.collection.items", itemCount);
3722
4032
  return items;
@@ -3734,29 +4044,31 @@ async function handleCollection(request, { name: name$2, identifier, uriGetter,
3734
4044
  if (itemsOrResponse instanceof Response) return finish(itemsOrResponse, "not_found");
3735
4045
  collection = new _fedify_vocab.OrderedCollection({
3736
4046
  id: baseUri,
4047
+ attribution,
3737
4048
  totalItems: totalItemCount ?? null,
3738
4049
  items: itemsOrResponse
3739
4050
  });
3740
4051
  } else {
3741
4052
  const lastCursor = await collectionCallbacks.lastCursor?.(context, identifier);
3742
- const first = new URL(context.url);
4053
+ const first = new URL(pageBase);
3743
4054
  first.searchParams.set("cursor", firstCursor);
3744
4055
  let last = null;
3745
4056
  if (lastCursor != null) {
3746
- last = new URL(context.url);
4057
+ last = new URL(pageBase);
3747
4058
  last.searchParams.set("cursor", lastCursor);
3748
4059
  }
3749
4060
  collection = new _fedify_vocab.OrderedCollection({
3750
4061
  id: baseUri,
4062
+ attribution,
3751
4063
  totalItems: totalItemCount ?? null,
3752
4064
  first,
3753
4065
  last
3754
4066
  });
3755
4067
  }
3756
4068
  } else {
3757
- const uri = new URL(baseUri);
4069
+ const uri = new URL(portable == null ? baseUri : pageBase);
3758
4070
  uri.searchParams.set("cursor", cursor);
3759
- const pageOrResponse = await tracer.startActiveSpan(`activitypub.dispatch_collection_page ${name$2}`, {
4071
+ const pageOrResponse = await tracer.startActiveSpan(`activitypub.dispatch_collection_page ${name$1}`, {
3760
4072
  kind: _opentelemetry_api.SpanKind.SERVER,
3761
4073
  attributes: {
3762
4074
  "activitypub.collection.id": uri.href,
@@ -3772,7 +4084,7 @@ async function handleCollection(request, { name: name$2, identifier, uriGetter,
3772
4084
  span.setStatus({ code: _opentelemetry_api.SpanStatusCode.ERROR });
3773
4085
  return await onNotFound(request);
3774
4086
  }
3775
- const items = filterCollectionItems(page.items, name$2, filterPredicate);
4087
+ const items = filterCollectionItems(page.items, name$1, filterPredicate);
3776
4088
  itemCount = items.length;
3777
4089
  span.setAttribute("fedify.collection.items", itemCount);
3778
4090
  return {
@@ -3794,28 +4106,30 @@ async function handleCollection(request, { name: name$2, identifier, uriGetter,
3794
4106
  const { items, prevCursor, nextCursor } = pageOrResponse;
3795
4107
  let prev = null;
3796
4108
  if (prevCursor != null) {
3797
- prev = new URL(context.url);
4109
+ prev = new URL(pageBase);
3798
4110
  prev.searchParams.set("cursor", prevCursor);
3799
4111
  }
3800
4112
  let next = null;
3801
4113
  if (nextCursor != null) {
3802
- next = new URL(context.url);
4114
+ next = new URL(pageBase);
3803
4115
  next.searchParams.set("cursor", nextCursor);
3804
4116
  }
3805
- const partOf = new URL(context.url);
4117
+ const partOf = new URL(pageBase);
3806
4118
  partOf.searchParams.delete("cursor");
3807
4119
  collection = new _fedify_vocab.OrderedCollectionPage({
3808
4120
  id: uri,
4121
+ attribution,
3809
4122
  prev,
3810
4123
  next,
3811
4124
  items,
3812
4125
  partOf
3813
4126
  });
3814
4127
  }
3815
- if (collectionCallbacks.authorizePredicate != null) {
4128
+ if (portable == null && collectionCallbacks.authorizePredicate != null) {
3816
4129
  if (!await collectionCallbacks.authorizePredicate(context, identifier)) return finish(await onUnauthorized(request), "unauthorized");
3817
4130
  }
3818
4131
  const jsonLd = await collection.toJsonLd(context);
4132
+ if (portable != null) return await verifyPortableCollection(jsonLd, context, portable) ? finish(respondWithPortableCollection(request, jsonLd), "served") : finish(portableObjectInternalServerError(request), "error");
3819
4133
  return finish(new Response(JSON.stringify(jsonLd), { headers: {
3820
4134
  "Content-Type": "application/activity+json",
3821
4135
  Vary: "Accept"
@@ -4863,19 +5177,11 @@ function recordDeferredPendingCollectionMetrics(error, result, response) {
4863
5177
  pendingCollectionMetricRecorders.delete(error);
4864
5178
  recorder?.(result, response);
4865
5179
  }
4866
- async function _handleCustomCollection(request, { name, values, context, tracerProvider, meterProvider, collectionCallbacks: callbacks, filterPredicate }) {
5180
+ async function _handleCustomCollection(request, { name, values, context, tracerProvider, meterProvider, collectionCallbacks: callbacks, filterPredicate, portable }) {
4867
5181
  verifyDefined(callbacks);
4868
5182
  await authIfNeeded(context, values, callbacks);
4869
5183
  const cursor = new URL(request.url).searchParams.get("cursor");
4870
- const handler = new CustomCollectionHandler(name, values, context, callbacks, tracerProvider, meterProvider, _fedify_vocab.Collection, _fedify_vocab.CollectionPage, filterPredicate).fetchCollection(cursor);
4871
- try {
4872
- const response = await handler.toJsonLd().then(respondAsActivity);
4873
- handler.recordPendingCollectionMetrics("served", response);
4874
- return response;
4875
- } catch (e) {
4876
- if (!deferPendingCollectionMetrics(e, (result, response) => handler.recordPendingCollectionMetrics(result, response))) handler.recordPendingCollectionMetrics("error");
4877
- throw e;
4878
- }
5184
+ return await respondWithCustomCollection(request, new CustomCollectionHandler(name, values, context, callbacks, tracerProvider, meterProvider, _fedify_vocab.Collection, _fedify_vocab.CollectionPage, filterPredicate, portable).fetchCollection(cursor), context, portable);
4879
5185
  }
4880
5186
  /**
4881
5187
  * Handles an ordered collection request.
@@ -4889,13 +5195,19 @@ async function _handleCustomCollection(request, { name, values, context, tracerP
4889
5195
  * @since 1.8.0
4890
5196
  */
4891
5197
  const handleOrderedCollection = exceptWrapper(_handleOrderedCollection);
4892
- async function _handleOrderedCollection(request, { name, values, context, tracerProvider, meterProvider, collectionCallbacks: callbacks, filterPredicate }) {
5198
+ async function _handleOrderedCollection(request, { name, values, context, tracerProvider, meterProvider, collectionCallbacks: callbacks, filterPredicate, portable }) {
4893
5199
  verifyDefined(callbacks);
4894
5200
  await authIfNeeded(context, values, callbacks);
4895
5201
  const cursor = new URL(request.url).searchParams.get("cursor");
4896
- const handler = new CustomCollectionHandler(name, values, context, callbacks, tracerProvider, meterProvider, _fedify_vocab.OrderedCollection, _fedify_vocab.OrderedCollectionPage, filterPredicate).fetchCollection(cursor);
5202
+ return await respondWithCustomCollection(request, new CustomCollectionHandler(name, values, context, callbacks, tracerProvider, meterProvider, _fedify_vocab.OrderedCollection, _fedify_vocab.OrderedCollectionPage, filterPredicate, portable).fetchCollection(cursor), context, portable);
5203
+ }
5204
+ async function respondWithCustomCollection(request, handler, context, portable) {
4897
5205
  try {
4898
- const response = await handler.toJsonLd().then(respondAsActivity);
5206
+ const jsonLd = await handler.toJsonLd();
5207
+ let response;
5208
+ if (portable == null) response = respondAsActivity(jsonLd);
5209
+ else if (await verifyPortableCollection(jsonLd, context, portable)) response = respondWithPortableCollection(request, jsonLd);
5210
+ else throw new PortableCollectionRefusedError();
4899
5211
  handler.recordPendingCollectionMetrics("served", response);
4900
5212
  return response;
4901
5213
  } catch (e) {
@@ -4925,6 +5237,7 @@ var CustomCollectionHandler = class {
4925
5237
  Collection;
4926
5238
  CollectionPage;
4927
5239
  filterPredicate;
5240
+ portable;
4928
5241
  /**
4929
5242
  * The tracer for telemetry.
4930
5243
  * @type {Tracer}
@@ -4961,8 +5274,8 @@ var CustomCollectionHandler = class {
4961
5274
  * @param CollectionPage The CollectionPage constructor.
4962
5275
  * @param filterPredicate Optional filter predicate for items.
4963
5276
  */
4964
- constructor(name$1, values, context, callbacks, tracerProvider = _opentelemetry_api.trace.getTracerProvider(), meterProvider, Collection, CollectionPage, filterPredicate) {
4965
- this.name = name$1;
5277
+ constructor(name$2, values, context, callbacks, tracerProvider = _opentelemetry_api.trace.getTracerProvider(), meterProvider, Collection, CollectionPage, filterPredicate, portable) {
5278
+ this.name = name$2;
4966
5279
  this.values = values;
4967
5280
  this.context = context;
4968
5281
  this.callbacks = callbacks;
@@ -4971,9 +5284,14 @@ var CustomCollectionHandler = class {
4971
5284
  this.Collection = Collection;
4972
5285
  this.CollectionPage = CollectionPage;
4973
5286
  this.filterPredicate = filterPredicate;
5287
+ this.portable = portable;
4974
5288
  this.name = this.name.trim().replace(/\s+/g, "_");
4975
5289
  this.#tracer = this.tracerProvider.getTracer(require_http.name, require_http.version);
4976
- this.#id = new URL(this.context.url);
5290
+ if (portable == null) this.#id = new URL(this.context.url);
5291
+ else {
5292
+ const cursor = this.context.url.searchParams.get("cursor");
5293
+ this.#id = cursor == null ? new URL(portable.view) : appendCursorIfExists(portable.view, cursor);
5294
+ }
4977
5295
  this.#dispatcher = callbacks.dispatcher.bind(callbacks);
4978
5296
  }
4979
5297
  /**
@@ -5024,6 +5342,7 @@ var CustomCollectionHandler = class {
5024
5342
  this.recordPendingCollectionItemCount(true, items.length);
5025
5343
  return {
5026
5344
  id,
5345
+ attribution: this.portable?.attribution ?? null,
5027
5346
  partOf,
5028
5347
  items,
5029
5348
  prev: this.appendToUrl(prevCursor),
@@ -5045,6 +5364,7 @@ var CustomCollectionHandler = class {
5045
5364
  });
5046
5365
  return {
5047
5366
  id: this.#id,
5367
+ attribution: this.portable?.attribution ?? null,
5048
5368
  first: this.appendToUrl(firstCursor),
5049
5369
  last: this.appendToUrl(lastCursor),
5050
5370
  totalItems
@@ -5061,6 +5381,7 @@ var CustomCollectionHandler = class {
5061
5381
  this.recordPendingCollectionItemCount(false, items.length);
5062
5382
  return {
5063
5383
  id: this.#id,
5384
+ attribution: this.portable?.attribution ?? null,
5064
5385
  totalItems,
5065
5386
  items
5066
5387
  };
@@ -5178,7 +5499,7 @@ var CustomCollectionHandler = class {
5178
5499
  * @returns The URL with cursor appended, or null if cursor is null/undefined.
5179
5500
  */
5180
5501
  appendToUrl(cursor) {
5181
- return appendCursorIfExists(this.context.url, cursor);
5502
+ return appendCursorIfExists(this.portable?.view ?? this.context.url, cursor);
5182
5503
  }
5183
5504
  /**
5184
5505
  * Gets the stored collection or collection page.
@@ -5255,6 +5576,12 @@ function exceptWrapper(handler) {
5255
5576
  require_http.recordCollectionRequest(meterProvider, collectionAttributes(metricBase, "not_found", response));
5256
5577
  return response;
5257
5578
  }
5579
+ case PortableCollectionRefusedError: {
5580
+ const response = portableObjectInternalServerError(request);
5581
+ recordDeferredPendingCollectionMetrics(error, "error", response);
5582
+ require_http.recordCollectionRequest(meterProvider, collectionAttributes(metricBase, "error", response));
5583
+ return response;
5584
+ }
5258
5585
  case UnauthorizedError: {
5259
5586
  const response = await onUnauthorized(request);
5260
5587
  recordDeferredPendingCollectionMetrics(error, "unauthorized", response);
@@ -5351,6 +5678,16 @@ var UnauthorizedError = class extends HandlerError {
5351
5678
  }
5352
5679
  };
5353
5680
  /**
5681
+ * Error thrown when a portable collection is not served because it embeds
5682
+ * portable objects that do not satisfy the FEP-ef61 proof policy.
5683
+ * @since 2.4.0
5684
+ */
5685
+ var PortableCollectionRefusedError = class extends HandlerError {
5686
+ constructor() {
5687
+ super("The portable collection embeds unverifiable portable objects.");
5688
+ }
5689
+ };
5690
+ /**
5354
5691
  * Responds with the given object in JSON-LD format.
5355
5692
  *
5356
5693
  * @param object The object to respond with.
@@ -5808,6 +6145,100 @@ function serializeDataPointType(dataPointType) {
5808
6145
  }
5809
6146
  }
5810
6147
  //#endregion
6148
+ //#region src/federation/portable-collection.ts
6149
+ /**
6150
+ * The query parameters of a gateway request for a portable collection that
6151
+ * do not select a view of the collection: the page cursor, and FEP-ef61
6152
+ * location hints (`@gateway`, and the legacy `gateways`).
6153
+ */
6154
+ const NON_VIEW_PARAMETERS = /* @__PURE__ */ new Set([
6155
+ "cursor",
6156
+ "@gateway",
6157
+ "gateways"
6158
+ ]);
6159
+ /**
6160
+ * Checks that a collection requested through the FEP-ef61 gateway endpoint
6161
+ * belongs to a portable actor under the requested DID, and determines
6162
+ * the collection's ID from the actor.
6163
+ *
6164
+ * Collection dispatchers return items, not documents with IDs, so unlike
6165
+ * object dispatchers, there is nothing else to tell whether the requested
6166
+ * DID controls the collection.
6167
+ *
6168
+ * @param actor The actor that owns the collection according to
6169
+ * the application, dispatched without the portable request,
6170
+ * or `null` if there is none.
6171
+ * @param options The requested DID, and either the requested collection's
6172
+ * canonical ID and the actor's property that has to refer to
6173
+ * it (for the built-in collections of an actor), or
6174
+ * the requested collection's portable ID (for a custom
6175
+ * collection, whose ID has the same form as the actor's).
6176
+ * @returns The resolution.
6177
+ */
6178
+ function resolvePortableCollectionOwner(actor, options) {
6179
+ if (!(0, _fedify_vocab.isActor)(actor)) return {
6180
+ status: "rejected",
6181
+ reason: "notFound"
6182
+ };
6183
+ const ownerId = actor.id;
6184
+ const did = ownerId == null || !require_http.isPortableId(ownerId) ? null : require_http.getPortableDid(ownerId);
6185
+ if (ownerId == null || did == null) return {
6186
+ status: "rejected",
6187
+ reason: "notPortable"
6188
+ };
6189
+ if (did !== options.authority) return {
6190
+ status: "rejected",
6191
+ reason: "authorityMismatch"
6192
+ };
6193
+ if ("portableId" in options) return {
6194
+ status: "accepted",
6195
+ ownerId,
6196
+ collectionId: getCustomCollectionId(ownerId, actor, options.portableId)
6197
+ };
6198
+ const property = options.property(actor);
6199
+ if (property == null || require_http.getCanonicalPortableId(property) !== options.canonicalId) return {
6200
+ status: "rejected",
6201
+ reason: "collectionMismatch"
6202
+ };
6203
+ const collectionId = new URL(property);
6204
+ collectionId.search = "";
6205
+ collectionId.hash = "";
6206
+ return {
6207
+ status: "accepted",
6208
+ ownerId,
6209
+ collectionId
6210
+ };
6211
+ }
6212
+ /**
6213
+ * Gets the ID of a custom portable collection in the same form as its owner's
6214
+ * ID: a portable ID if the owner has one, or otherwise a compatible identifier
6215
+ * on the owner's first gateway, as FEP-ef61 requires of publishers.
6216
+ */
6217
+ function getCustomCollectionId(ownerId, owner, portableId) {
6218
+ if (require_http.isPortableUri(ownerId)) return portableId;
6219
+ const gateway = owner.gateways[0];
6220
+ if (gateway == null || !(0, _fedify_vocab_runtime.isGatewayUrl)(gateway)) return portableId;
6221
+ try {
6222
+ return (0, _fedify_vocab_runtime.toCompatibleEf61Id)(portableId, gateway);
6223
+ } catch (error) {
6224
+ if (error instanceof TypeError) return portableId;
6225
+ throw error;
6226
+ }
6227
+ }
6228
+ /**
6229
+ * Builds the URI that the pages of a portable collection are relative to:
6230
+ * the collection's ID with the query parameters of the requested view, e.g.,
6231
+ * a filter, but without the page cursor and location hints.
6232
+ * @param collectionId The ID of the collection.
6233
+ * @param requestUrl The URL of the gateway request.
6234
+ * @returns The view URI.
6235
+ */
6236
+ function buildPortableCollectionView(collectionId, requestUrl) {
6237
+ const view = new URL(collectionId);
6238
+ for (const [name, value] of requestUrl.searchParams) if (!NON_VIEW_PARAMETERS.has(name)) view.searchParams.append(name, value);
6239
+ return view;
6240
+ }
6241
+ //#endregion
5811
6242
  //#region src/federation/outgoing-proof.ts
5812
6243
  function formatKeyIds(candidates) {
5813
6244
  return candidates.map((c) => c.verificationMethod.href).join(", ");
@@ -8272,12 +8703,7 @@ var FederationImpl = class extends FederationBuilderImpl {
8272
8703
  onNotFound
8273
8704
  });
8274
8705
  case "followers": {
8275
- let baseUrl = url.searchParams.get("base-url");
8276
- if (baseUrl != null) try {
8277
- baseUrl = `${new URL(baseUrl).origin}/`;
8278
- } catch {
8279
- baseUrl = null;
8280
- }
8706
+ const baseUrl = getFollowersBaseUrl(url);
8281
8707
  return await handleCollection(request, {
8282
8708
  name: "followers",
8283
8709
  identifier: route.values.identifier,
@@ -8288,7 +8714,7 @@ var FederationImpl = class extends FederationBuilderImpl {
8288
8714
  },
8289
8715
  context,
8290
8716
  filter: baseUrl != null ? new URL(baseUrl) : void 0,
8291
- filterPredicate: baseUrl != null ? ((i) => (i instanceof URL ? i.href : i.id?.href ?? "").startsWith(baseUrl)) : void 0,
8717
+ filterPredicate: baseUrl != null ? getFollowersFilterPredicate(baseUrl) : void 0,
8292
8718
  collectionCallbacks: this.followersCallbacks,
8293
8719
  tracerProvider: this.tracerProvider,
8294
8720
  meterProvider: this._meterProvider,
@@ -8382,17 +8808,30 @@ var FederationImpl = class extends FederationBuilderImpl {
8382
8808
  }
8383
8809
  const route = this.router.route(portable.path);
8384
8810
  const isActor = route != null && (route.name === "actor" || route.name.startsWith("actorAlias:"));
8385
- if (route == null || !isActor && !route.name.startsWith("object:")) {
8811
+ const collectionRoute = route == null ? void 0 : getCollectionMetricRoute(route.name.replace(/:.*$/, ""));
8812
+ if (route == null || !isActor && !route.name.startsWith("object:") && collectionRoute == null) {
8386
8813
  metricState.endpoint = "not_found";
8387
8814
  return await onNotFound(request);
8388
8815
  }
8389
8816
  metricState.routeTemplate = PORTABLE_GATEWAY_ROUTE_PREFIX + route.template;
8390
- metricState.endpoint = isActor ? "actor" : "object";
8817
+ metricState.endpoint = getEndpointCategory(route.name);
8391
8818
  span.updateName(`${request.method} ${metricState.routeTemplate}`);
8392
8819
  if (!acceptsJsonLd(request)) {
8393
8820
  metricState.endpoint = "not_acceptable";
8394
- return await onNotAcceptable(request);
8821
+ const response = await onNotAcceptable(request);
8822
+ if (collectionRoute != null) require_http.recordCollectionRequest(this._meterProvider, {
8823
+ ...collectionRoute,
8824
+ page: new URL(request.url).searchParams.get("cursor") != null,
8825
+ result: "not_acceptable",
8826
+ statusCode: response.status
8827
+ });
8828
+ return response;
8395
8829
  }
8830
+ if (collectionRoute != null) return await this.#fetchPortableCollection(request, portable, route, {
8831
+ onNotFound,
8832
+ onUnauthorized,
8833
+ contextData
8834
+ });
8396
8835
  if (isActor) {
8397
8836
  const identifier = route.name.startsWith("actorAlias:") ? route.name.substring(11) : route.values.identifier;
8398
8837
  const context = this.#createContext(request, contextData, {
@@ -8431,6 +8870,108 @@ var FederationImpl = class extends FederationBuilderImpl {
8431
8870
  onNotFound
8432
8871
  });
8433
8872
  }
8873
+ async #fetchPortableCollection(request, portable, route, { onNotFound, onUnauthorized, contextData }) {
8874
+ const logger = (0, _logtape_logtape.getLogger)([
8875
+ "fedify",
8876
+ "federation",
8877
+ "collection"
8878
+ ]);
8879
+ const url = new URL(request.url);
8880
+ const { authority } = portable.portableRequest;
8881
+ const ownerContext = this.#createContext(request, contextData);
8882
+ const context = this.#createContext(request, contextData, { portableRequest: portable.portableRequest });
8883
+ const reject = async (reason, identifier) => {
8884
+ logger.debug("Not serving the portable collection {collection} of the actor {identifier}: {reason}.", {
8885
+ collection: portable.canonicalId,
8886
+ identifier,
8887
+ reason
8888
+ });
8889
+ return await onNotFound(request);
8890
+ };
8891
+ const routeName = route.name.replace(/:.*$/, "");
8892
+ if (routeName === "collection" || routeName === "orderedCollection") {
8893
+ const name = route.name.replace(/^(?:ordered)?[cC]ollection:/, "");
8894
+ const callbacks = this.collectionCallbacks[name];
8895
+ if (callbacks == null) return await onNotFound(request);
8896
+ const mapper = callbacks.portableOwnerMapper;
8897
+ if (mapper == null) return await reject("noPortableOwnerMapper");
8898
+ const identifier = await mapper(ownerContext, route.values);
8899
+ if (identifier == null) return await reject("notPortable");
8900
+ const resolution = resolvePortableCollectionOwner(await this.actorCallbacks?.dispatcher?.(ownerContext, identifier), {
8901
+ authority,
8902
+ portableId: portable.portableRequest.id
8903
+ });
8904
+ if (resolution.status === "rejected") return await reject(resolution.reason, identifier);
8905
+ const view = buildPortableCollectionView(resolution.collectionId, url);
8906
+ return await (routeName === "collection" ? handleCustomCollection : handleOrderedCollection)(request, {
8907
+ name,
8908
+ context,
8909
+ values: route.values,
8910
+ collectionCallbacks: callbacks,
8911
+ portable: {
8912
+ id: view,
8913
+ view,
8914
+ attribution: resolution.ownerId
8915
+ },
8916
+ tracerProvider: this.tracerProvider,
8917
+ meterProvider: this._meterProvider,
8918
+ onUnauthorized,
8919
+ onNotFound
8920
+ });
8921
+ }
8922
+ const identifier = route.values.identifier;
8923
+ const serve = async (name, callbacks, property, filter) => {
8924
+ if (callbacks == null) return await onNotFound(request);
8925
+ const resolution = resolvePortableCollectionOwner(await this.actorCallbacks?.dispatcher?.(ownerContext, identifier), {
8926
+ authority,
8927
+ canonicalId: portable.canonicalId,
8928
+ property
8929
+ });
8930
+ if (resolution.status === "rejected") return await reject(resolution.reason, identifier);
8931
+ const id = new URL(resolution.collectionId);
8932
+ if (filter != null) id.searchParams.set(...filter.parameter);
8933
+ const view = buildPortableCollectionView(resolution.collectionId, url);
8934
+ if (routeName === "followers") {
8935
+ view.searchParams.delete("base-url");
8936
+ if (filter != null) view.searchParams.set(...filter.parameter);
8937
+ }
8938
+ return await handleCollection(request, {
8939
+ name,
8940
+ identifier,
8941
+ uriGetter: () => id,
8942
+ context,
8943
+ filter: filter?.value,
8944
+ filterPredicate: filter?.predicate,
8945
+ collectionCallbacks: callbacks,
8946
+ portable: {
8947
+ id,
8948
+ view,
8949
+ attribution: resolution.ownerId
8950
+ },
8951
+ tracerProvider: this.tracerProvider,
8952
+ meterProvider: this._meterProvider,
8953
+ onUnauthorized,
8954
+ onNotFound
8955
+ });
8956
+ };
8957
+ switch (routeName) {
8958
+ case "outbox": return await serve("outbox", this.outboxCallbacks, (a) => a.outboxId);
8959
+ case "inbox": return await serve("inbox", this.inboxCallbacks, (a) => a.inboxId);
8960
+ case "following": return await serve("following", this.followingCallbacks, (a) => a.followingId);
8961
+ case "followers": {
8962
+ const baseUrl = getFollowersBaseUrl(url);
8963
+ return await serve("followers", this.followersCallbacks, (a) => a.followersId, baseUrl == null ? void 0 : {
8964
+ value: new URL(baseUrl),
8965
+ predicate: getFollowersFilterPredicate(baseUrl),
8966
+ parameter: ["base-url", baseUrl]
8967
+ });
8968
+ }
8969
+ case "liked": return await serve("liked", this.likedCallbacks, (a) => a.likedId);
8970
+ case "featured": return await serve("featured", this.featuredCallbacks, (a) => a.featuredId);
8971
+ case "featuredTags": return await serve("featured tags", this.featuredTagsCallbacks, (a) => a.featuredTagsId);
8972
+ }
8973
+ return await onNotFound(request);
8974
+ }
8434
8975
  async #handleInbox(request, { recipient, context, contextData, onNotFound, portableInbox }) {
8435
8976
  if (!this.manuallyStartQueue) this._startQueueInternal(contextData);
8436
8977
  const inboxContextFactory = context.toInboxContext.bind(context);
@@ -8617,6 +9158,41 @@ var FederationImpl = class extends FederationBuilderImpl {
8617
9158
  };
8618
9159
  const PORTABLE_GATEWAY_ROUTE_PREFIX = "/.well-known/apgateway/{did}";
8619
9160
  /**
9161
+ * Gets the origin that a followers collection request filters followers by,
9162
+ * i.e., its normalized `base-url` query parameter (FEP-8fcf).
9163
+ * @returns The origin with a trailing slash, or `null` if the parameter is
9164
+ * missing or invalid.
9165
+ */
9166
+ function getFollowersBaseUrl(url) {
9167
+ const baseUrl = url.searchParams.get("base-url");
9168
+ if (baseUrl == null) return null;
9169
+ try {
9170
+ return `${new URL(baseUrl).origin}/`;
9171
+ } catch {
9172
+ return null;
9173
+ }
9174
+ }
9175
+ function getFollowersFilterPredicate(baseUrl) {
9176
+ return (i) => (i instanceof URL ? i.href : i.id?.href ?? "").startsWith(baseUrl);
9177
+ }
9178
+ const ACTOR_COLLECTION_ROUTE_ERRORS = {
9179
+ outbox: "No outbox dispatcher registered.",
9180
+ following: "No following collection path registered.",
9181
+ followers: "No followers collection path registered.",
9182
+ liked: "No liked collection path registered.",
9183
+ featured: "No featured collection path registered.",
9184
+ featuredTags: "No featured tags collection path registered."
9185
+ };
9186
+ /**
9187
+ * Checks that the authority of a portable ID to build is given, either
9188
+ * explicitly or by the FEP-ef61 gateway request being handled.
9189
+ * @throws {TypeError} If the authority is missing.
9190
+ */
9191
+ function requirePortableAuthority(authority) {
9192
+ if (authority == null) throw new TypeError("The authority of a portable ID is required outside an FEP-ef61 gateway request.");
9193
+ return authority;
9194
+ }
9195
+ /**
8620
9196
  * Removes the body of a response to a `HEAD` request.
8621
9197
  * @param request The request.
8622
9198
  * @param response The response to the request.
@@ -8795,8 +9371,7 @@ var ContextImpl = class ContextImpl {
8795
9371
  }
8796
9372
  getPortableActorUri(identifier, authority) {
8797
9373
  const path = this.#getActorPath(identifier);
8798
- if (authority == null) throw new TypeError("The authority of a portable ID is required outside an FEP-ef61 gateway request.");
8799
- return buildPortableUri(authority, path);
9374
+ return buildPortableUri(requirePortableAuthority(authority), path);
8800
9375
  }
8801
9376
  #getActorPath(identifier) {
8802
9377
  const path = this.federation.router.build(`actorAlias:${identifier}`, {}) ?? this.federation.router.build("actor", { identifier });
@@ -8808,8 +9383,7 @@ var ContextImpl = class ContextImpl {
8808
9383
  }
8809
9384
  getPortableObjectUri(cls, values, authority) {
8810
9385
  const path = this.#getObjectPath(cls, values);
8811
- if (authority == null) throw new TypeError("The authority of a portable ID is required outside an FEP-ef61 gateway request.");
8812
- return buildPortableUri(authority, path);
9386
+ return buildPortableUri(requirePortableAuthority(authority), path);
8813
9387
  }
8814
9388
  #getObjectPath(cls, values) {
8815
9389
  const callbacks = this.federation.objectCallbacks[cls.typeId.href];
@@ -8819,10 +9393,17 @@ var ContextImpl = class ContextImpl {
8819
9393
  if (path == null) throw new _fedify_uri_template.RouterError("No object dispatcher registered.");
8820
9394
  return path;
8821
9395
  }
9396
+ #getActorCollectionPath(route, identifier) {
9397
+ const path = this.federation.router.build(route, { identifier });
9398
+ if (path == null) throw new _fedify_uri_template.RouterError(ACTOR_COLLECTION_ROUTE_ERRORS[route]);
9399
+ return path;
9400
+ }
8822
9401
  getOutboxUri(identifier) {
8823
- const path = this.federation.router.build("outbox", { identifier });
8824
- if (path == null) throw new _fedify_uri_template.RouterError("No outbox dispatcher registered.");
8825
- return new URL(path, this.canonicalOrigin);
9402
+ return new URL(this.#getActorCollectionPath("outbox", identifier), this.canonicalOrigin);
9403
+ }
9404
+ getPortableOutboxUri(identifier, authority) {
9405
+ const path = this.#getActorCollectionPath("outbox", identifier);
9406
+ return buildPortableUri(requirePortableAuthority(authority), path);
8826
9407
  }
8827
9408
  getMediaUploaderUri(identifier) {
8828
9409
  const path = this.federation.router.build("mediaUploader", { identifier });
@@ -8842,39 +9423,53 @@ var ContextImpl = class ContextImpl {
8842
9423
  getPortableInboxUri(identifier, authority) {
8843
9424
  const path = this.federation.router.build("inbox", { identifier });
8844
9425
  if (path == null) throw new _fedify_uri_template.RouterError("No inbox path registered.");
8845
- if (authority == null) throw new TypeError("The authority of a portable ID is required outside an FEP-ef61 gateway request.");
8846
- return buildPortableUri(authority, path);
9426
+ return buildPortableUri(requirePortableAuthority(authority), path);
8847
9427
  }
8848
9428
  getFollowingUri(identifier) {
8849
- const path = this.federation.router.build("following", { identifier });
8850
- if (path == null) throw new _fedify_uri_template.RouterError("No following collection path registered.");
8851
- return new URL(path, this.canonicalOrigin);
9429
+ return new URL(this.#getActorCollectionPath("following", identifier), this.canonicalOrigin);
9430
+ }
9431
+ getPortableFollowingUri(identifier, authority) {
9432
+ const path = this.#getActorCollectionPath("following", identifier);
9433
+ return buildPortableUri(requirePortableAuthority(authority), path);
8852
9434
  }
8853
9435
  getFollowersUri(identifier) {
8854
- const path = this.federation.router.build("followers", { identifier });
8855
- if (path == null) throw new _fedify_uri_template.RouterError("No followers collection path registered.");
8856
- return new URL(path, this.canonicalOrigin);
9436
+ return new URL(this.#getActorCollectionPath("followers", identifier), this.canonicalOrigin);
9437
+ }
9438
+ getPortableFollowersUri(identifier, authority) {
9439
+ const path = this.#getActorCollectionPath("followers", identifier);
9440
+ return buildPortableUri(requirePortableAuthority(authority), path);
8857
9441
  }
8858
9442
  getLikedUri(identifier) {
8859
- const path = this.federation.router.build("liked", { identifier });
8860
- if (path == null) throw new _fedify_uri_template.RouterError("No liked collection path registered.");
8861
- return new URL(path, this.canonicalOrigin);
9443
+ return new URL(this.#getActorCollectionPath("liked", identifier), this.canonicalOrigin);
9444
+ }
9445
+ getPortableLikedUri(identifier, authority) {
9446
+ const path = this.#getActorCollectionPath("liked", identifier);
9447
+ return buildPortableUri(requirePortableAuthority(authority), path);
8862
9448
  }
8863
9449
  getFeaturedUri(identifier) {
8864
- const path = this.federation.router.build("featured", { identifier });
8865
- if (path == null) throw new _fedify_uri_template.RouterError("No featured collection path registered.");
8866
- return new URL(path, this.canonicalOrigin);
9450
+ return new URL(this.#getActorCollectionPath("featured", identifier), this.canonicalOrigin);
9451
+ }
9452
+ getPortableFeaturedUri(identifier, authority) {
9453
+ const path = this.#getActorCollectionPath("featured", identifier);
9454
+ return buildPortableUri(requirePortableAuthority(authority), path);
8867
9455
  }
8868
9456
  getFeaturedTagsUri(identifier) {
8869
- const path = this.federation.router.build("featuredTags", { identifier });
8870
- if (path == null) throw new _fedify_uri_template.RouterError("No featured tags collection path registered.");
8871
- return new URL(path, this.canonicalOrigin);
9457
+ return new URL(this.#getActorCollectionPath("featuredTags", identifier), this.canonicalOrigin);
9458
+ }
9459
+ getPortableFeaturedTagsUri(identifier, authority) {
9460
+ const path = this.#getActorCollectionPath("featuredTags", identifier);
9461
+ return buildPortableUri(requirePortableAuthority(authority), path);
8872
9462
  }
8873
9463
  getCollectionUri(name, values) {
8874
9464
  const path = this.federation.getCollectionPath(name, values);
8875
9465
  if (path === null) throw new _fedify_uri_template.RouterError(`No collection dispatcher registered for "${String(name)}".`);
8876
9466
  return new URL(path, this.canonicalOrigin);
8877
9467
  }
9468
+ getPortableCollectionUri(name, values, authority) {
9469
+ const path = this.federation.getCollectionPath(name, values);
9470
+ if (path === null) throw new _fedify_uri_template.RouterError(`No collection dispatcher registered for "${String(name)}".`);
9471
+ return buildPortableUri(requirePortableAuthority(authority), path);
9472
+ }
8878
9473
  parseUri(uri) {
8879
9474
  if (uri == null) return null;
8880
9475
  if (uri.origin !== this.origin && uri.origin !== this.canonicalOrigin) return null;
@@ -9454,6 +10049,27 @@ var RequestContextImpl = class RequestContextImpl extends ContextImpl {
9454
10049
  getPortableInboxUri(identifier, authority) {
9455
10050
  return super.getPortableInboxUri(identifier, authority ?? this.portableRequest?.authority);
9456
10051
  }
10052
+ getPortableOutboxUri(identifier, authority) {
10053
+ return super.getPortableOutboxUri(identifier, authority ?? this.portableRequest?.authority);
10054
+ }
10055
+ getPortableFollowingUri(identifier, authority) {
10056
+ return super.getPortableFollowingUri(identifier, authority ?? this.portableRequest?.authority);
10057
+ }
10058
+ getPortableFollowersUri(identifier, authority) {
10059
+ return super.getPortableFollowersUri(identifier, authority ?? this.portableRequest?.authority);
10060
+ }
10061
+ getPortableLikedUri(identifier, authority) {
10062
+ return super.getPortableLikedUri(identifier, authority ?? this.portableRequest?.authority);
10063
+ }
10064
+ getPortableFeaturedUri(identifier, authority) {
10065
+ return super.getPortableFeaturedUri(identifier, authority ?? this.portableRequest?.authority);
10066
+ }
10067
+ getPortableFeaturedTagsUri(identifier, authority) {
10068
+ return super.getPortableFeaturedTagsUri(identifier, authority ?? this.portableRequest?.authority);
10069
+ }
10070
+ getPortableCollectionUri(name, values, authority) {
10071
+ return super.getPortableCollectionUri(name, values, authority ?? this.portableRequest?.authority);
10072
+ }
9457
10073
  async getActor(identifier, options) {
9458
10074
  if (this.federation.actorCallbacks == null || this.federation.actorCallbacks.dispatcher == null) throw new Error("No actor dispatcher registered.");
9459
10075
  if (this.#invokedFromActorDispatcher != null) (0, _logtape_logtape.getLogger)([