UNPKG

@fedify/fedify

Version:

An ActivityPub server framework

1,292 lines • 249 kB
const { Temporal } = require("@js-temporal/polyfill"); const { URLPattern } = require("urlpattern-polyfill"); const require_chunk = require("./chunk-DDcVe30Y.cjs"); const require_transformers = require("./transformers-NeAONrAq.cjs"); const require_http = require("./http-BajqV0pH.cjs"); const require_proof = require("./proof-HzavUYxN.cjs"); const require_types = require("./types-KC4QAoxe.cjs"); const require_kv_cache = require("./kv-cache-CVG3m2_-.cjs"); let _logtape_logtape = require("@logtape/logtape"); let _fedify_uri_template = require("@fedify/uri-template"); let _fedify_vocab = require("@fedify/vocab"); let _opentelemetry_api = require("@opentelemetry/api"); let byte_encodings_hex = require("byte-encodings/hex"); let es_toolkit = require("es-toolkit"); let _fedify_vocab_runtime = require("@fedify/vocab-runtime"); let _opentelemetry_semantic_conventions = require("@opentelemetry/semantic-conventions"); let _fedify_vocab_runtime_jsonld = require("@fedify/vocab-runtime/jsonld"); _fedify_vocab_runtime_jsonld = require_chunk.__toESM(_fedify_vocab_runtime_jsonld, 1); let _fedify_webfinger = require("@fedify/webfinger"); let _opentelemetry_sdk_metrics = require("@opentelemetry/sdk-metrics"); let node_url = require("node:url"); //#region src/federation/activity-listener.ts var ActivityListenerSet = class { #listeners; constructor() { this.#listeners = /* @__PURE__ */ new Map(); } clone() { const Clone = this.constructor; const clone = new Clone(); clone.#listeners = new Map(this.#listeners); return clone; } add(type, listener) { if (this.#listeners.has(type)) throw new TypeError("Listener already set for this type."); this.#listeners.set(type, listener); } dispatchWithClass(activity) { let cls = activity.constructor; while (cls != null) { if (this.#listeners.has(cls)) break; if (cls === _fedify_vocab.Activity) return null; cls = globalThis.Object.getPrototypeOf(cls); } if (cls == null) return null; const listener = this.#listeners.get(cls); return { class: cls, listener }; } dispatch(activity) { return this.dispatchWithClass(activity)?.listener ?? null; } }; //#endregion //#region src/federation/builder.ts const ACTOR_ALIAS_PREFIX = "actorAlias:"; /** * Route options shared by every dispatcher whose path must expose exactly * one `{identifier}` variable bound to a single, non-empty value * for `setOutboxDispatcher/Listener()`. */ const identifierSingular = { exact: true, variables: { identifier: { operatables: [""] } } }; /** * Route options shared by every dispatcher whose path must expose * `{identifier}` and `{+identifier}` variables bound to the same single, * non-empty value for following setters: * - `setActorDispatcher()` (actor path) * - `setInboxDispatcher/Listener()` (inbox path) * - `setFollowingDispatcher()` (following path) * - `setFollowersDispatcher()` (followers path) * - `setLikedDispatcher()` (liked path) * - `setFeaturedDispatcher()` (featured path) * - `setFeaturedTagsDispatcher()` (featured tags path) */ const identifierSingularAllowPlus = { exact: true, variables: { identifier: { operatables: ["", "+"] } } }; var FederationBuilderImpl = class { router; actorCallbacks; nodeInfoDispatcher; webFingerLinksDispatcher; objectCallbacks; objectTypeIds; inboxPath; outboxPath; inboxCallbacks; outboxCallbacks; followingCallbacks; followersCallbacks; likedCallbacks; featuredCallbacks; featuredTagsCallbacks; inboxListeners; outboxListeners; inboxErrorHandler; outboxListenerErrorHandler; outboxAuthorizePredicate; sharedInboxKeyDispatcher; unverifiedActivityHandler; outboxPermanentFailureHandler; idempotencyStrategy; collectionTypeIds; collectionCallbacks; /** * Symbol registry for unique identification of unnamed symbols. */ #symbolRegistry = /* @__PURE__ */ new Map(); constructor() { this.router = new _fedify_uri_template.Router(); this.objectCallbacks = {}; this.objectTypeIds = {}; this.collectionCallbacks = {}; this.collectionTypeIds = {}; } /** * Builds the federation object. * @param options Parameters for initializing the federation object. * @returns The federation object. * @throws {TypeError} If benchmark mode and `meterProvider` are both * specified. */ async build(options) { const { FederationImpl } = await Promise.resolve().then(() => middleware_exports); const f = new FederationImpl(options); const trailingSlashInsensitiveValue = f.router.trailingSlashInsensitive; f.router = this.router.clone(); f.router.trailingSlashInsensitive = trailingSlashInsensitiveValue; f._initializeRouter(); f.actorCallbacks = this.actorCallbacks == null ? void 0 : { ...this.actorCallbacks }; f.nodeInfoDispatcher = this.nodeInfoDispatcher; f.webFingerLinksDispatcher = this.webFingerLinksDispatcher; f.objectCallbacks = { ...this.objectCallbacks }; f.objectTypeIds = { ...this.objectTypeIds }; f.inboxPath = this.inboxPath; f.outboxPath = this.outboxPath; f.inboxCallbacks = this.inboxCallbacks == null ? void 0 : { ...this.inboxCallbacks }; f.outboxCallbacks = this.outboxCallbacks == null ? void 0 : { ...this.outboxCallbacks }; f.followingCallbacks = this.followingCallbacks == null ? void 0 : { ...this.followingCallbacks }; f.followersCallbacks = this.followersCallbacks == null ? void 0 : { ...this.followersCallbacks }; f.likedCallbacks = this.likedCallbacks == null ? void 0 : { ...this.likedCallbacks }; f.featuredCallbacks = this.featuredCallbacks == null ? void 0 : { ...this.featuredCallbacks }; f.featuredTagsCallbacks = this.featuredTagsCallbacks == null ? void 0 : { ...this.featuredTagsCallbacks }; f.inboxListeners = this.inboxListeners?.clone(); f.outboxListeners = this.outboxListeners?.clone(); f.inboxErrorHandler = this.inboxErrorHandler; f.outboxListenerErrorHandler = this.outboxListenerErrorHandler; f.outboxAuthorizePredicate = this.outboxAuthorizePredicate; f.sharedInboxKeyDispatcher = this.sharedInboxKeyDispatcher; f.unverifiedActivityHandler = this.unverifiedActivityHandler; f.outboxPermanentFailureHandler = this.outboxPermanentFailureHandler; f.idempotencyStrategy = this.idempotencyStrategy; return f; } _getTracer() { return _opentelemetry_api.trace.getTracer(require_http.name, require_http.version); } setActorDispatcher(path, dispatcher) { if (this.router.has("actor")) throw new _fedify_uri_template.RouterError("Actor dispatcher already set."); (0, _fedify_uri_template.assertPath)(path); this.router.add(path, "actor", identifierSingularAllowPlus); const callbacks = { dispatcher: async (context, identifier) => { const actor = await this._getTracer().startActiveSpan("activitypub.dispatch_actor", { kind: _opentelemetry_api.SpanKind.SERVER, attributes: { "fedify.actor.identifier": identifier } }, async (span) => { try { const actor = await dispatcher(context, identifier); span.setAttribute("activitypub.actor.id", (actor?.id ?? context.getActorUri(identifier)).href); if (actor == null) span.setStatus({ code: _opentelemetry_api.SpanStatusCode.ERROR }); else span.setAttribute("activitypub.actor.type", (0, _fedify_vocab.getTypeId)(actor).href); return actor; } catch (error) { span.setStatus({ code: _opentelemetry_api.SpanStatusCode.ERROR, message: String(error) }); throw error; } finally { span.end(); } }); if (actor == null) return null; const logger = (0, _logtape_logtape.getLogger)([ "fedify", "federation", "actor" ]); if (actor.id == null) logger.warn("Actor dispatcher returned an actor without an id property. Set the property with Context.getActorUri(identifier)."); else if (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)."); if (actor instanceof _fedify_vocab.Tombstone) return actor; if (this.followingCallbacks != null && this.followingCallbacks.dispatcher != null) { 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)."); else if (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)."); } if (this.followersCallbacks != null && this.followersCallbacks.dispatcher != null) { if (actor.followersId == null) logger.warn("You configured a followers collection dispatcher, but the actor does not have a followers property. Set the property with Context.getFollowersUri(identifier)."); else if (actor.followersId.href != context.getFollowersUri(identifier).href) logger.warn("You configured a followers collection dispatcher, but the actor's followers property does not match the followers collection URI. Set the property with Context.getFollowersUri(identifier)."); } if (this.outboxCallbacks != null && this.outboxCallbacks.dispatcher != null) { if (actor?.outboxId == null) logger.warn("You configured an outbox collection dispatcher, but the actor does not have an outbox property. Set the property with Context.getOutboxUri(identifier)."); else if (actor.outboxId.href != context.getOutboxUri(identifier).href) logger.warn("You configured an outbox collection dispatcher, but the actor's outbox property does not match the outbox collection URI. Set the property with Context.getOutboxUri(identifier)."); } if (this.likedCallbacks != null && this.likedCallbacks.dispatcher != null) { if (actor?.likedId == null) logger.warn("You configured a liked collection dispatcher, but the actor does not have a liked property. Set the property with Context.getLikedUri(identifier)."); else if (actor.likedId.href != context.getLikedUri(identifier).href) logger.warn("You configured a liked collection dispatcher, but the actor's liked property does not match the liked collection URI. Set the property with Context.getLikedUri(identifier)."); } if (this.featuredCallbacks != null && this.featuredCallbacks.dispatcher != null) { if (actor?.featuredId == null) logger.warn("You configured a featured collection dispatcher, but the actor does not have a featured property. Set the property with Context.getFeaturedUri(identifier)."); else if (actor.featuredId.href != context.getFeaturedUri(identifier).href) logger.warn("You configured a featured collection dispatcher, but the actor's featured property does not match the featured collection URI. Set the property with Context.getFeaturedUri(identifier)."); } if (this.featuredTagsCallbacks != null && this.featuredTagsCallbacks.dispatcher != null) { if (actor?.featuredTagsId == null) logger.warn("You configured a featured tags collection dispatcher, but the actor does not have a featuredTags property. Set the property with Context.getFeaturedTagsUri(identifier)."); else if (actor.featuredTagsId.href != context.getFeaturedTagsUri(identifier).href) logger.warn("You configured a featured tags collection dispatcher, but the actor's featuredTags property does not match the featured tags collection URI. Set the property with Context.getFeaturedTagsUri(identifier)."); } if (this.router.has("inbox")) { if (actor.inboxId == null) logger.warn("You configured inbox listeners, but the actor does not have an inbox property. Set the property with Context.getInboxUri(identifier)."); else if (actor.inboxId.href != context.getInboxUri(identifier).href) logger.warn("You configured inbox listeners, but the actor's inbox property does not match the inbox URI. Set the property with Context.getInboxUri(identifier)."); if (actor.endpoints == null || actor.endpoints.sharedInbox == null) logger.warn("You configured inbox listeners, but the actor does not have a endpoints.sharedInbox property. Set the property with Context.getInboxUri()."); else if (actor.endpoints.sharedInbox.href != context.getInboxUri().href) logger.warn("You configured inbox listeners, but the actor's endpoints.sharedInbox property does not match the shared inbox URI. Set the property with Context.getInboxUri()."); } if (callbacks.keyPairsDispatcher != null) { if (actor.publicKeyId == null) logger.warn("You configured a key pairs dispatcher, but the actor does not have a publicKey property. Set the property with Context.getActorKeyPairs(identifier)."); if (actor.assertionMethodId == null) logger.warn("You configured a key pairs dispatcher, but the actor does not have an assertionMethod property. Set the property with Context.getActorKeyPairs(identifier)."); } return actor; } }; this.actorCallbacks = callbacks; const setters = { setKeyPairsDispatcher: (dispatcher) => { callbacks.keyPairsDispatcher = (ctx, identifier) => this._getTracer().startActiveSpan("activitypub.dispatch_actor_key_pairs", { kind: _opentelemetry_api.SpanKind.SERVER, attributes: { "activitypub.actor.id": ctx.getActorUri(identifier).href, "fedify.actor.identifier": identifier } }, async (span) => { try { return await dispatcher(ctx, identifier); } catch (e) { span.setStatus({ code: _opentelemetry_api.SpanStatusCode.ERROR, message: String(e) }); throw e; } finally { span.end(); } }); return setters; }, mapHandle(mapper) { callbacks.handleMapper = mapper; return setters; }, mapAlias(mapper) { callbacks.aliasMapper = mapper; return setters; }, mapActorAlias: (path, identifier) => { if (identifier === "") throw new _fedify_uri_template.RouterError("Identifier cannot be empty."); if (this.router.has(`actorAlias:${identifier}`)) throw new _fedify_uri_template.RouterError(`Actor alias for "${identifier}" already set.`); if (_fedify_uri_template.Router.variables(path).size > 0) throw new _fedify_uri_template.RouterError("Path for actor alias must have no variables."); const existingRoute = this.router.route(path); if (existingRoute != null) throw new _fedify_uri_template.RouterError(`Actor alias path "${path}" conflicts with existing route "${existingRoute.name}".`); this.router.add(path, `${ACTOR_ALIAS_PREFIX}${identifier}`); return setters; }, authorize(predicate) { callbacks.authorizePredicate = predicate; return setters; } }; return setters; } setNodeInfoDispatcher(path, dispatcher) { if (this.router.has("nodeInfo")) throw new _fedify_uri_template.RouterError("NodeInfo dispatcher already set."); if (_fedify_uri_template.Router.variables(path).size !== 0) throw new _fedify_uri_template.RouterError("Path for NodeInfo dispatcher must have no variables."); this.router.add(path, "nodeInfo"); this.nodeInfoDispatcher = dispatcher; } setWebFingerLinksDispatcher(dispatcher) { this.webFingerLinksDispatcher = dispatcher; } /** * The RFC 6570 template-literal `path` overloads were removed for * type-checking efficiency, so the URI variable types can no longer be * inferred from `path` (now typed as a plain `string`); to use them, specify * the variable name through the `TParam` generic argument. */ setObjectDispatcher(cls, path, dispatcher) { const routeName = `object:${cls.typeId.href}`; if (this.router.has(routeName)) throw new _fedify_uri_template.RouterError(`Object dispatcher for ${cls.name} already set.`); (0, _fedify_uri_template.assertPath)(path); const variables = _fedify_uri_template.Router.variables(path); this.router.add(path, routeName); const callbacks = { dispatcher: (ctx, values) => { return this._getTracer().startActiveSpan("activitypub.dispatch_object", { kind: _opentelemetry_api.SpanKind.SERVER, attributes: { "fedify.object.type": cls.typeId.href, ...globalThis.Object.fromEntries(globalThis.Object.entries(values).map(([k, v]) => [`fedify.object.values.${k}`, v])) } }, async (span) => { try { const object = await dispatcher(ctx, values); span.setAttribute("activitypub.object.id", (object?.id ?? ctx.getObjectUri(cls, values)).href); if (object == null) span.setStatus({ code: _opentelemetry_api.SpanStatusCode.ERROR }); else span.setAttribute("activitypub.object.type", (0, _fedify_vocab.getTypeId)(object).href); return object; } catch (e) { span.setStatus({ code: _opentelemetry_api.SpanStatusCode.ERROR, message: String(e) }); throw e; } finally { span.end(); } }); }, parameters: variables }; this.objectCallbacks[cls.typeId.href] = callbacks; this.objectTypeIds[cls.typeId.href] = cls; const setters = { authorize(predicate) { callbacks.authorizePredicate = predicate; return setters; } }; return setters; } setInboxDispatcher(path, dispatcher) { if (this.inboxCallbacks != null) throw new _fedify_uri_template.RouterError("Inbox dispatcher already set."); if (this.router.has("inbox")) { if (this.inboxPath !== path) throw new _fedify_uri_template.RouterError("Inbox dispatcher path must match inbox listener path."); } else { (0, _fedify_uri_template.assertPath)(path); this.router.add(path, "inbox", identifierSingularAllowPlus); this.inboxPath = path; } const callbacks = { dispatcher }; this.inboxCallbacks = callbacks; const setters = { setCounter(counter) { callbacks.counter = counter; return setters; }, setFirstCursor(cursor) { callbacks.firstCursor = cursor; return setters; }, setLastCursor(cursor) { callbacks.lastCursor = cursor; return setters; }, authorize(predicate) { callbacks.authorizePredicate = predicate; return setters; } }; return setters; } setOutboxDispatcher(path, dispatcher) { if (this.outboxCallbacks != null) throw new _fedify_uri_template.RouterError("Outbox dispatcher already set."); if (this.router.has("outbox")) { if (this.outboxPath !== path) throw new _fedify_uri_template.RouterError("Outbox dispatcher path must match outbox listener path."); } else { (0, _fedify_uri_template.assertPath)(path); this.router.add(path, "outbox", identifierSingular); this.outboxPath = path; } const callbacks = { dispatcher }; this.outboxCallbacks = callbacks; const setters = { setCounter(counter) { callbacks.counter = counter; return setters; }, setFirstCursor(cursor) { callbacks.firstCursor = cursor; return setters; }, setLastCursor(cursor) { callbacks.lastCursor = cursor; return setters; }, authorize(predicate) { callbacks.authorizePredicate = predicate; return setters; } }; return setters; } setOutboxListeners(outboxPath) { if (this.outboxListeners != null) throw new _fedify_uri_template.RouterError("Outbox listeners already set."); if (this.router.has("outbox")) { if (this.outboxPath !== outboxPath) throw new _fedify_uri_template.RouterError("Outbox listener path must match outbox dispatcher path."); } else { (0, _fedify_uri_template.assertPath)(outboxPath); this.router.add(outboxPath, "outbox", identifierSingular); this.outboxPath = outboxPath; } const listeners = this.outboxListeners = new ActivityListenerSet(); const setters = { on(type, listener) { listeners.add(type, listener); return setters; }, onError: (handler) => { this.outboxListenerErrorHandler = handler; return setters; }, authorize: (predicate) => { this.outboxAuthorizePredicate = predicate; return setters; } }; return setters; } setFollowingDispatcher(path, dispatcher) { if (this.router.has("following")) throw new _fedify_uri_template.RouterError("Following collection dispatcher already set."); (0, _fedify_uri_template.assertPath)(path); this.router.add(path, "following", identifierSingularAllowPlus); const callbacks = { dispatcher }; this.followingCallbacks = callbacks; const setters = { setCounter(counter) { callbacks.counter = counter; return setters; }, setFirstCursor(cursor) { callbacks.firstCursor = cursor; return setters; }, setLastCursor(cursor) { callbacks.lastCursor = cursor; return setters; }, authorize(predicate) { callbacks.authorizePredicate = predicate; return setters; } }; return setters; } setFollowersDispatcher(path, dispatcher) { if (this.router.has("followers")) throw new _fedify_uri_template.RouterError("Followers collection dispatcher already set."); (0, _fedify_uri_template.assertPath)(path); this.router.add(path, "followers", identifierSingularAllowPlus); const callbacks = { dispatcher }; this.followersCallbacks = callbacks; const setters = { setCounter(counter) { callbacks.counter = counter; return setters; }, setFirstCursor(cursor) { callbacks.firstCursor = cursor; return setters; }, setLastCursor(cursor) { callbacks.lastCursor = cursor; return setters; }, authorize(predicate) { callbacks.authorizePredicate = predicate; return setters; } }; return setters; } setLikedDispatcher(path, dispatcher) { if (this.router.has("liked")) throw new _fedify_uri_template.RouterError("Liked collection dispatcher already set."); (0, _fedify_uri_template.assertPath)(path); this.router.add(path, "liked", identifierSingularAllowPlus); const callbacks = { dispatcher }; this.likedCallbacks = callbacks; const setters = { setCounter(counter) { callbacks.counter = counter; return setters; }, setFirstCursor(cursor) { callbacks.firstCursor = cursor; return setters; }, setLastCursor(cursor) { callbacks.lastCursor = cursor; return setters; }, authorize(predicate) { callbacks.authorizePredicate = predicate; return setters; } }; return setters; } setFeaturedDispatcher(path, dispatcher) { if (this.router.has("featured")) throw new _fedify_uri_template.RouterError("Featured collection dispatcher already set."); (0, _fedify_uri_template.assertPath)(path); this.router.add(path, "featured", identifierSingularAllowPlus); const callbacks = { dispatcher }; this.featuredCallbacks = callbacks; const setters = { setCounter(counter) { callbacks.counter = counter; return setters; }, setFirstCursor(cursor) { callbacks.firstCursor = cursor; return setters; }, setLastCursor(cursor) { callbacks.lastCursor = cursor; return setters; }, authorize(predicate) { callbacks.authorizePredicate = predicate; return setters; } }; return setters; } setFeaturedTagsDispatcher(path, dispatcher) { if (this.router.has("featuredTags")) throw new _fedify_uri_template.RouterError("Featured tags collection dispatcher already set."); (0, _fedify_uri_template.assertPath)(path); this.router.add(path, "featuredTags", identifierSingularAllowPlus); const callbacks = { dispatcher }; this.featuredTagsCallbacks = callbacks; const setters = { setCounter(counter) { callbacks.counter = counter; return setters; }, setFirstCursor(cursor) { callbacks.firstCursor = cursor; return setters; }, setLastCursor(cursor) { callbacks.lastCursor = cursor; return setters; }, authorize(predicate) { callbacks.authorizePredicate = predicate; return setters; } }; return setters; } setInboxListeners(inboxPath, sharedInboxPath) { if (this.inboxListeners != null) throw new _fedify_uri_template.RouterError("Inbox listeners already set."); if (this.router.has("inbox")) { if (this.inboxPath !== inboxPath) throw new _fedify_uri_template.RouterError("Inbox listener path must match inbox dispatcher path."); } else { (0, _fedify_uri_template.assertPath)(inboxPath); this.router.add(inboxPath, "inbox", identifierSingularAllowPlus); this.inboxPath = inboxPath; } if (sharedInboxPath != null) { if (_fedify_uri_template.Router.variables(sharedInboxPath).size !== 0) throw new _fedify_uri_template.RouterError("Path for shared inbox must have no variables."); this.router.add(sharedInboxPath, "sharedInbox"); } const listeners = this.inboxListeners = new ActivityListenerSet(); const setters = { on(type, listener) { listeners.add(type, listener); return setters; }, onError: (handler) => { this.inboxErrorHandler = handler; return setters; }, onUnverifiedActivity: (handler) => { this.unverifiedActivityHandler = handler; return setters; }, setSharedKeyDispatcher: (dispatcher) => { this.sharedInboxKeyDispatcher = dispatcher; return setters; }, withIdempotency: (strategy) => { this.idempotencyStrategy = strategy; return setters; } }; return setters; } /** * The RFC 6570 template-literal `path` overloads were removed for * type-checking efficiency, so the URI variable types can no longer be * inferred from `path` (now typed as a plain `string`); to use them, specify * the variable name through the `TParam` generic argument. */ setCollectionDispatcher(name, itemType, path, dispatcher) { return this.#setCustomCollectionDispatcher(name, "collection", itemType, path, dispatcher); } /** * The RFC 6570 template-literal `path` overloads were removed for * type-checking efficiency, so the URI variable types can no longer be * inferred from `path` (now typed as a plain `string`); to use them, specify * the variable name through the `TParam` generic argument. */ setOrderedCollectionDispatcher(name, itemType, path, dispatcher) { return this.#setCustomCollectionDispatcher(name, "orderedCollection", itemType, path, dispatcher); } #setCustomCollectionDispatcher(name, collectionType, itemType, path, dispatcher) { const strName = String(name); const routeName = `${collectionType}:${this.#uniqueCollectionId(name)}`; if (this.router.has(routeName)) throw new _fedify_uri_template.RouterError(`Collection dispatcher for ${strName} already set.`); if (this.collectionCallbacks[name] != null) throw new _fedify_uri_template.RouterError(`Collection dispatcher for ${strName} already set.`); (0, _fedify_uri_template.assertPath)(path); if (_fedify_uri_template.Router.variables(path).size < 1) throw new _fedify_uri_template.RouterError("Path for collection dispatcher must have at least one variable."); this.router.add(path, routeName); const callbacks = { dispatcher }; this.collectionCallbacks[name] = callbacks; this.collectionTypeIds[name] = itemType; const setters = { setCounter(counter) { callbacks.counter = counter; return setters; }, setFirstCursor(cursor) { callbacks.firstCursor = cursor; return setters; }, setLastCursor(cursor) { callbacks.lastCursor = cursor; return setters; }, authorize(predicate) { callbacks.authorizePredicate = predicate; return setters; } }; return setters; } /** * Get the URL path for a custom collection. * If the collection is not registered, returns null. * @template TParam The parameter names of the requested URL. * @param {string | symbol} name The name of the custom collection. * @param {TParam} values The values to fill in the URL parameters. * @returns {string | null} The URL path for the custom collection, or null if not registered. */ getCollectionPath(name, values) { if (!(name in this.collectionCallbacks)) return null; const routeName = this.#uniqueCollectionId(name); return this.router.build(`collection:${routeName}`, values) ?? this.router.build(`orderedCollection:${routeName}`, values); } setOutboxPermanentFailureHandler(handler) { this.outboxPermanentFailureHandler = handler; } /** * Converts a name (string or symbol) to a unique string identifier. * For symbols, generates and caches a UUID if not already present. * For strings, returns the string as-is. * @param name The name to convert to a unique identifier * @returns A unique string identifier */ #uniqueCollectionId(name) { if (typeof name === "string") return name; if (!this.#symbolRegistry.has(name)) this.#symbolRegistry.set(name, crypto.randomUUID()); return this.#symbolRegistry.get(name); } }; /** * Creates a new {@link FederationBuilder} instance. * @returns A new {@link FederationBuilder} instance. * @since 1.6.0 */ function createFederationBuilder() { return new FederationBuilderImpl(); } //#endregion //#region src/federation/circuit-breaker.ts const MAX_CUSTOM_FAILURE_HISTORY = 100; /** * Tracks reachability state for remote outbox delivery hosts. * @since 2.3.0 */ var CircuitBreaker = class { #kv; #prefix; #options; #now; #stateChangeObserver; constructor(options) { this.#kv = options.kv; this.#prefix = options.prefix; this.#options = normalizeCircuitBreakerOptions(options.options ?? {}); this.#now = options.now ?? (() => Temporal.Now.instant()); this.#stateChangeObserver = options.stateChangeObserver; } get options() { return this.#options; } capHeldDelay(heldSince, delay) { const now = this.#now(); return now.until(this.#capHeldRetryAt(now, heldSince, now.add(delay))); } async beforeSend(remoteHost, message) { const heldSince = parseHeldSince(message.circuitHeldSince); const now = this.#now(); if (heldSince != null && Temporal.Instant.compare(heldSince.add(this.#options.heldActivityTtl), now) <= 0) return { type: "drop", heldSince }; let lastConflictingState; for (let attempt = 0; attempt < 10; attempt++) { const oldState = await this.#get(remoteHost); if (oldState == null || oldState.state === "closed") return { type: "send", probe: false }; if (oldState.state === "half-open") { const halfOpened = oldState.halfOpened == null ? void 0 : Temporal.Instant.from(oldState.halfOpened); if (halfOpened != null) { const staleAt = halfOpened.add(this.#options.recoveryDelay); if (Temporal.Instant.compare(now, staleAt) < 0) { const releaseAt = now.add(this.#options.releaseInterval); const retryAt = Temporal.Instant.compare(releaseAt, staleAt) < 0 ? releaseAt : staleAt; const cappedRetryAt = this.#capHeldRetryAt(now, heldSince, retryAt); return { type: "hold", state: "half-open", delay: now.until(cappedRetryAt), heldSince: heldSince ?? now }; } } const newState = { ...oldState, state: "half-open", halfOpened: now.toString() }; if (await this.#replace(remoteHost, oldState, newState)) return { type: "send", probe: true }; lastConflictingState = "half-open"; continue; } const probeAt = (oldState.opened == null ? now : Temporal.Instant.from(oldState.opened)).add(this.#options.recoveryDelay); if (Temporal.Instant.compare(now, probeAt) < 0) { const retryAt = this.#capHeldRetryAt(now, heldSince, probeAt); return { type: "hold", state: "open", delay: now.until(retryAt), heldSince: heldSince ?? now }; } const newState = { ...oldState, state: "half-open", halfOpened: now.toString() }; if (await this.#replace(remoteHost, oldState, newState)) { await this.#notifyStateChange(remoteHost, "open", "half-open"); return { type: "send", probe: true, stateChange: { previousState: "open", newState: "half-open" } }; } lastConflictingState = "open"; } if (lastConflictingState != null) { const retryAt = this.#capHeldRetryAt(now, heldSince, now.add(this.#options.releaseInterval)); return { type: "hold", state: lastConflictingState, delay: now.until(retryAt), heldSince: heldSince ?? now }; } throw new Error(`Failed to update circuit breaker state for ${remoteHost}`); } async recordSuccess(remoteHost) { for (let attempt = 0; attempt < 10; attempt++) { const oldState = await this.#get(remoteHost); if (oldState == null) return void 0; if (await this.#replace(remoteHost, oldState, void 0)) { if (oldState.state !== "closed") { await this.#notifyStateChange(remoteHost, oldState.state, "closed"); return { previousState: oldState.state, newState: "closed" }; } return; } } throw new Error(`Failed to update circuit breaker state for ${remoteHost}`); } async recordReachableFailure(remoteHost) { return await this.recordSuccess(remoteHost); } async recordFailure(remoteHost) { const now = this.#now(); for (let attempt = 0; attempt < 10; attempt++) { const oldState = await this.#get(remoteHost); if (oldState?.state === "open") return void 0; const oldFailures = oldState?.failures.map(Temporal.Instant.from) ?? []; const failures = this.#options.pruneFailures([...oldFailures, now], now); let newState; let transition; if (oldState?.state === "half-open" || this.#options.failure(failures)) { newState = { state: "open", failures: failures.map((t) => t.toString()), opened: now.toString() }; transition = [oldState?.state ?? "closed", "open"]; } else newState = { state: "closed", failures: failures.map((t) => t.toString()) }; if (await this.#replace(remoteHost, oldState, newState)) { if (transition != null) { await this.#notifyStateChange(remoteHost, transition[0], transition[1]); return { previousState: transition[0], newState: transition[1] }; } return; } } throw new Error(`Failed to update circuit breaker state for ${remoteHost}`); } async dropActivity(remoteHost, details) { try { await this.#options.onActivityDrop?.(remoteHost, details); } catch (error) { (0, _logtape_logtape.getLogger)([ "fedify", "federation", "circuit" ]).error("An unexpected error occurred in circuit breaker activity drop handler:\n{error}", { remoteHost, error }); } } async getState(remoteHost) { return await this.#get(remoteHost); } #key(remoteHost) { return [...this.#prefix, remoteHost]; } #capHeldRetryAt(now, heldSince, retryAt) { const expiresAt = (heldSince ?? now).add(this.#options.heldActivityTtl); return Temporal.Instant.compare(expiresAt, retryAt) < 0 ? expiresAt : retryAt; } async #get(remoteHost) { return parseCircuitBreakerKvState(await this.#kv.get(this.#key(remoteHost))); } async #replace(remoteHost, oldState, newState) { const key = this.#key(remoteHost); if (this.#kv.cas == null) { if (newState == null) await this.#kv.delete(key); else await this.#kv.set(key, newState); return true; } return await this.#kv.cas(key, oldState, newState); } async #notifyStateChange(remoteHost, previousState, newState) { try { await this.#options.onStateChange?.(remoteHost, previousState, newState); } catch (error) { (0, _logtape_logtape.getLogger)([ "fedify", "federation", "circuit" ]).error("An unexpected error occurred in circuit breaker state change handler:\n{error}", { remoteHost, previousState, newState, error }); } try { await this.#stateChangeObserver?.(remoteHost, previousState, newState); } catch (error) { (0, _logtape_logtape.getLogger)([ "fedify", "federation", "circuit" ]).error("An unexpected error occurred in circuit breaker state change observer:\n{error}", { remoteHost, previousState, newState, error }); } } }; /** * Normalizes user-provided circuit breaker options into the internal policy * shape used while processing queued outbox deliveries. * * @param options The public circuit breaker options supplied to Fedify. * @returns The normalized failure predicate, failure pruning function, * duration values, and optional callbacks with defaults applied. * @throws {RangeError} If any configured duration is not positive. * @throws {TypeError} If `failureThreshold` is not a positive integer. */ function normalizeCircuitBreakerOptions(options) { const recoveryDelay = toInstantDuration(options.recoveryDelay ?? { minutes: 30 }); const heldActivityTtl = toInstantDuration(options.heldActivityTtl ?? { hours: 168 }); const releaseInterval = toInstantDuration(options.releaseInterval ?? { seconds: 1 }); assertPositiveDuration(recoveryDelay, "recoveryDelay"); assertPositiveDuration(heldActivityTtl, "heldActivityTtl"); assertPositiveDuration(releaseInterval, "releaseInterval"); let failure; let pruneFailures; if (options.failure == null) { const failureThreshold = options.failureThreshold ?? 5; if (!Number.isInteger(failureThreshold) || failureThreshold <= 0) throw new TypeError("failureThreshold must be a positive integer."); const failureWindow = toInstantDuration(options.failureWindow ?? { minutes: 10 }); assertPositiveDuration(failureWindow, "failureWindow"); pruneFailures = (timestamps, now) => { const earliest = now.subtract(failureWindow); return timestamps.filter((timestamp) => Temporal.Instant.compare(timestamp, earliest) >= 0).slice(-failureThreshold); }; failure = (timestamps) => { if (timestamps.length < failureThreshold) return false; const first = timestamps[timestamps.length - failureThreshold]; const last = timestamps[timestamps.length - 1]; return Temporal.Duration.compare(first.until(last), failureWindow) <= 0; }; } else { failure = options.failure; pruneFailures = (timestamps) => timestamps.slice(-MAX_CUSTOM_FAILURE_HISTORY); } return { failure, pruneFailures, recoveryDelay, heldActivityTtl, releaseInterval, onStateChange: options.onStateChange, onActivityDrop: options.onActivityDrop }; } function toInstantDuration(duration) { const parsed = Temporal.Duration.from(duration); return Temporal.Duration.from({ milliseconds: Math.trunc(parsed.total({ unit: "millisecond", relativeTo: Temporal.PlainDateTime.from("2026-01-01T00:00:00") })) }); } function assertPositiveDuration(duration, name) { if (Temporal.Duration.compare(duration, { seconds: 0 }) <= 0) throw new RangeError(`${name} must be a positive duration.`); } function parseHeldSince(value) { if (value == null) return void 0; try { return Temporal.Instant.from(value); } catch (error) { (0, _logtape_logtape.getLogger)([ "fedify", "federation", "circuit" ]).warn("Invalid circuitHeldSince value in queued outbox message: {value}", { value, error }); return; } } /** * Parses a value loaded from the circuit breaker KV store. * * @param value The raw KV value to validate. * @returns A circuit breaker state when `value` has a recognized state and * valid instant strings, or `undefined` when the stored value is malformed. */ function parseCircuitBreakerKvState(value) { const isInstantString = (v) => { if (typeof v !== "string") return false; try { Temporal.Instant.from(v); return true; } catch { return false; } }; if (typeof value !== "object" || value == null) return void 0; const record = value; if (record.state !== "closed" && record.state !== "open" && record.state !== "half-open") return; if (!Array.isArray(record.failures) || !record.failures.every((failure) => isInstantString(failure))) return; if (record.opened != null && !isInstantString(record.opened)) return; if (record.halfOpened != null && !isInstantString(record.halfOpened)) return; return { state: record.state, failures: record.failures, ...record.opened == null ? {} : { opened: record.opened }, ...record.halfOpened == null ? {} : { halfOpened: record.halfOpened } }; } //#endregion //#region src/federation/collection.ts /** * Calculates the [partial follower collection digest][1]. * * [1]: https://w3id.org/fep/8fcf#partial-follower-collection-digest * @param uris The URIs to calculate the digest. Duplicate URIs are ignored. * @returns The digest. */ async function digest(uris) { const processed = /* @__PURE__ */ new Set(); const encoder = new TextEncoder(); const result = new Uint8Array(32); for (const uri of uris) { const u = uri instanceof URL ? uri.href : uri; if (processed.has(u)) continue; processed.add(u); const encoded = encoder.encode(u); const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", encoded)); for (let i = 0; i < 32; i++) result[i] ^= digest[i]; } return result; } /** * Builds [`Collection-Synchronization`][1] header content. * * [1]: https://w3id.org/fep/8fcf#the-collection-synchronization-http-header * * @param collectionId The sender's followers collection URI. * @param actorIds The actor URIs to digest. * @returns The header content. */ async function buildCollectionSynchronizationHeader(collectionId, actorIds) { const [anyActorId] = actorIds; const baseUrl = new URL(anyActorId); const url = new URL(collectionId); url.searchParams.set("base-url", `${baseUrl.origin}/`); return `collectionId="${collectionId}", url="${url}", digest="${(0, byte_encodings_hex.encodeHex)(await digest(actorIds))}"`; } //#endregion //#region src/federation/inbox.ts async function routeActivity({ context: ctx, json, originalJson, normalizedActivity, ldSignatureVerified, activity, recipient, inboxListeners, inboxContextFactory, listenerInboxContextFactory, inboxErrorHandler, kv, kvPrefixes, queue, span, meterProvider, tracerProvider, idempotencyStrategy }) { const logger = (0, _logtape_logtape.getLogger)([ "fedify", "federation", "inbox" ]); let cacheKey = null; if (activity.id != null) { const inboxContext = inboxContextFactory(recipient, json, activity.id?.href, (0, _fedify_vocab.getTypeId)(activity).href); const strategy = idempotencyStrategy ?? "per-inbox"; let keyString; if (typeof strategy === "function") keyString = await strategy(inboxContext, activity); else switch (strategy) { case "global": keyString = activity.id.href; break; case "per-origin": keyString = `${ctx.origin}\n${activity.id.href}`; break; case "per-inbox": keyString = `${ctx.origin}\n${activity.id.href}\n${recipient == null ? "sharedInbox" : `inbox\n${recipient}`}`; break; default: keyString = `${ctx.origin}\n${activity.id.href}`; } if (keyString != null) cacheKey = [...kvPrefixes.activityIdempotence, keyString]; } if (cacheKey != null) { if (await kv.get(cacheKey) === true) { logger.debug("Activity {activityId} has already been processed.", { activityId: activity.id?.href, activity: json, recipient }); span.setStatus({ code: _opentelemetry_api.SpanStatusCode.UNSET, message: `Activity ${activity.id?.href} has already been processed.` }); require_http.recordInboxActivity(meterProvider, "rejected", (0, _fedify_vocab.getTypeId)(activity).href); return "alreadyProcessed"; } } if (activity.actorId == null) { logger.error("Missing actor.", { activity: json }); span.setStatus({ code: _opentelemetry_api.SpanStatusCode.ERROR, message: "Missing actor." }); require_http.recordInboxActivity(meterProvider, "rejected", (0, _fedify_vocab.getTypeId)(activity).href); return "missingActor"; } span.setAttribute("activitypub.actor.id", activity.actorId.href); if (queue != null) { const carrier = {}; _opentelemetry_api.propagation.inject(_opentelemetry_api.context.active(), carrier); try { await queue.enqueue({ type: "inbox", id: crypto.randomUUID(), baseUrl: ctx.origin, activity: originalJson ?? json, ...normalizedActivity == null ? {} : { normalizedActivity }, ...ldSignatureVerified == null ? {} : { ldSignatureVerified }, identifier: recipient, attempt: 0, started: (/* @__PURE__ */ new Date()).toISOString(), traceContext: carrier }); } catch (error) { logger.error("Failed to enqueue the incoming activity {activityId}:\n{error}", { error, activityId: activity.id?.href, activity: json, recipient }); span.setStatus({ code: _opentelemetry_api.SpanStatusCode.ERROR, message: `Failed to enqueue the incoming activity ${activity.id?.href}.` }); throw error; } require_http.getFederationMetrics(meterProvider).recordQueueTaskEnqueued({ role: "inbox", queue, activityType: (0, _fedify_vocab.getTypeId)(activity).href }, 0); require_http.recordInboxActivity(meterProvider, "queued", (0, _fedify_vocab.getTypeId)(activity).href); logger.info("Activity {activityId} is enqueued.", { activityId: activity.id?.href, activity: json, recipient }); return "enqueued"; } tracerProvider = tracerProvider ?? _opentelemetry_api.trace.getTracerProvider(); return await tracerProvider.getTracer(require_http.name, require_http.version).startActiveSpan("activitypub.dispatch_inbox_listener", { kind: _opentelemetry_api.SpanKind.INTERNAL }, async (span) => { const dispatched = inboxListeners?.dispatchWithClass(activity); if (dispatched == null) { logger.error("Unsupported activity type:\n{activity}", { activity: json, recipient }); span.setStatus({ code: _opentelemetry_api.SpanStatusCode.UNSET, message: `Unsupported activity type: ${(0, _fedify_vocab.getTypeId)(activity).href}` }); require_http.recordInboxActivity(meterProvider, "rejected", (0, _fedify_vocab.getTypeId)(activity).href); span.end(); return "unsupportedActivity"; } const { class: cls, listener } = dispatched; span.updateName(`activitypub.dispatch_inbox_listener ${cls.name}`); try { const activityType = (0, _fedify_vocab.getTypeId)(activity).href; const started = performance.now(); try { const contextFactory = listenerInboxContextFactory ?? inboxContextFactory; await listener(contextFactory(recipient, contextFactory === inboxContextFactory ? json : originalJson ?? json, activity.id?.href, activityType), activity); } finally { require_http.getFederationMetrics(meterProvider).recordInboxProcessingDuration(activityType, require_http.getDurationMs(started)); } } catch (error) { try { await inboxErrorHandler?.(ctx, error); } catch (error) { logger.error("An unexpected error occurred in inbox error handler:\n{error}", { error, activityId: activity.id?.href, activity: json, recipient }); } logger.error("Failed to process the incoming activity {activityId}:\n{error}", { error, activityId: activity.id?.href, activity: json, recipient }); span.setStatus({ code: _opentelemetry_api.SpanStatusCode.ERROR, message: String(error) }); require_http.recordInboxActivity(meterProvider, "rejected", (0, _fedify_vocab.getTypeId)(activity).href); span.end(); return "error"; } require_http.recordInboxActivity(meterProvider, "processed", (0, _fedify_vocab.getTypeId)(activity).href); if (cacheKey != null) await kv.set(cacheKey, true, { ttl: Temporal.Duration.from({ days: 1 }) }); logger.info("Activity {activityId} has been processed.", { activityId: activity.id?.href, activity: json, recipient }); span.end(); return "success"; }); } //#endregion //#region src/federation/keycache.ts var KvKeyCache = class { kv; prefix; options; unavailableKeyTtl; nullKeys; constructor(kv, prefix, options = {}) { this.kv = kv; this.prefix = prefix; this.options = options; this.unavailableKeyTtl = options.unavailableKeyTtl ?? Temporal.Duration.from({ minutes: 10 }); this.nullKeys = /* @__PURE__ */ new Map(); } #getFetchErrorKey(keyId) { return [ ...this.prefix, "__fetchError", keyId.href ]; } async get(keyId) { const negativeExpiration = this.nullKeys.get(keyId.href); if (negativeExpiration != null) { if (Temporal.Now.instant().until(negativeExpiration).sign >= 0) return null; this.nullKeys.delete(keyId.href); } const serialized = await this.kv.get([...this.prefix, keyId.href]); if (serialized === void 0) return void 0; if (serialized === null) { this.nullKeys.set(keyId.href, Temporal.Now.instant().add(this.unavailableKeyTtl)); return null; } try { return await _fedify_vocab.CryptographicKey.fromJsonLd(serialized, this.options); } catch { try { return await _fedify_vocab.Multikey.fromJsonLd(serialized, this.options); } catch { await this.kv.delete([...this.prefix, keyId.href]); return; } } } async set(keyId, key) { if (key == null) { this.nullKeys.set(keyId.href, Temporal.Now.instant().add(this.unavailableKeyTtl)); await this.kv.set([...this.prefix, keyId.href], null, { ttl: this.unavailableKeyTtl }); return; } this.nullKeys.delete(keyId.href); const serialized = await key.toJsonLd(this.options); await this.kv.set([...this.prefix, keyId.href], serialized); } async getFetchError(keyId) { const cached = await this.kv.get(this.#getFetchErrorKey(keyId)); if (cached == null || typeof cached !== "object") return void 0; if ("status" in cached && typeof cached.status === "number" && "statusText" in cached && typeof cached.statusText === "string" && "headers" in cached && Array.isArray(cached.headers) && "body" in cached && typeof cached.body === "string") return { status: cached.status, response: new Response(cached.body, { status: cached.status, statusText: cached.statusText, headers: cached.headers }) }; else if ("errorName" in cached && typeof cached.errorName === "string" && "errorMessage" in cached && typeof cached.errorMessage === "string") { const error = new Error(cached.errorMessage); error.name = cached.errorName; return { error }; } } async setFetchError(keyId, error) { if (error == null) { await this.kv.delete(this.#getFetchErrorKey(keyId)); return; } if ("status" in error) { await this.kv.set(this.#getFetchErrorKey(keyId), { status: error.status, statusText: error.response.statusText, headers: Array.from(error.response.headers.entries()), body: awai