UNPKG

@fedify/fedify

Version:

An ActivityPub server framework

1,270 lines • 193 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-Cl0Q2bUO.cjs"); const require_proof = require("./proof-DfrItHmh.cjs"); const require_types = require("./types-KC4QAoxe.cjs"); const require_kv_cache = require("./kv-cache-DmGi6uC-.cjs"); let _logtape_logtape = require("@logtape/logtape"); let _fedify_vocab = require("@fedify/vocab"); let _opentelemetry_api = require("@opentelemetry/api"); let es_toolkit = require("es-toolkit"); let uri_template_router = require("uri-template-router"); let url_template = require("url-template"); let byte_encodings_hex = require("byte-encodings/hex"); 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); let _fedify_webfinger = require("@fedify/webfinger"); 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/router.ts function cloneInnerRouter(router) { const clone = new uri_template_router.Router(); clone.nid = router.nid; clone.fsm = (0, es_toolkit.cloneDeep)(router.fsm); clone.routeSet = new Set(router.routeSet); clone.templateRouteMap = new Map(router.templateRouteMap); clone.valueRouteMap = new Map(router.valueRouteMap); clone.hierarchy = (0, es_toolkit.cloneDeep)(router.hierarchy); return clone; } /** * URL router and constructor based on URI Template * ([RFC 6570](https://tools.ietf.org/html/rfc6570)). */ var Router = class Router { #router; #templates; #templateStrings; /** * Whether to ignore trailing slashes when matching paths. * @since 1.6.0 */ trailingSlashInsensitive; /** * Create a new {@link Router}. * @param options Options for the router. */ constructor(options = {}) { this.#router = new uri_template_router.Router(); this.#templates = {}; this.#templateStrings = {}; this.trailingSlashInsensitive = options.trailingSlashInsensitive ?? false; } clone() { const clone = new Router({ trailingSlashInsensitive: this.trailingSlashInsensitive }); clone.#router = cloneInnerRouter(this.#router); clone.#templates = { ...this.#templates }; clone.#templateStrings = { ...this.#templateStrings }; return clone; } /** * Checks if a path name exists in the router. * @param name The name of the path. * @returns `true` if the path name exists, otherwise `false`. */ has(name) { return name in this.#templates; } /** * Adds a new path rule to the router. * @param template The path pattern. * @param name The name of the path. * @returns The names of the variables in the path pattern. */ add(template, name) { if (!template.startsWith("/")) throw new RouterError("Path must start with a slash."); const rule = this.#router.addTemplate(template, {}, name); this.#templates[name] = (0, url_template.parseTemplate)(template); this.#templateStrings[name] = template; return new Set(rule.variables.map((v) => v.varname)); } /** * Resolves a path name and values from a URL, if any match. * @param url The URL to resolve. * @returns The name of the path and its values, if any match. Otherwise, * `null`. */ route(url) { let match = this.#router.resolveURI(url); if (match == null) { if (!this.trailingSlashInsensitive) return null; url = url.endsWith("/") ? url.replace(/\/+$/, "") : `${url}/`; match = this.#router.resolveURI(url); if (match == null) return null; } return { name: match.matchValue, template: this.#templateStrings[match.matchValue], values: match.params }; } /** * Constructs a URL/path from a path name and values. * @param name The name of the path. * @param values The values to expand the path with. * @returns The URL/path, if the name exists. Otherwise, `null`. */ build(name, values) { if (name in this.#templates) return this.#templates[name].expand(values); return null; } }; /** * An error thrown by the {@link Router}. */ var RouterError = class extends Error { /** * Create a new {@link RouterError}. * @param message The error message. */ constructor(message) { super(message); this.name = "RouterError"; } }; //#endregion //#region src/federation/builder.ts function validateSingleIdentifierVariablePath(path, errorMessage) { const operatorMatches = globalThis.Array.from(path.matchAll(/{([+#./;?&]?)([A-Za-z_][A-Za-z0-9_]*)}/g)); if (operatorMatches.length !== 1 || operatorMatches[0]?.[2] !== "identifier") throw new RouterError(errorMessage); if (operatorMatches.some((match) => [ "?", "&", "#" ].includes(match[1]) && match[2] === "identifier")) throw new RouterError(errorMessage); const variables = new Router().add(path, "outbox"); if (variables.size !== 1 || !variables.has("identifier")) throw new RouterError(errorMessage); } 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 Router(); this.objectCallbacks = {}; this.objectTypeIds = {}; this.collectionCallbacks = {}; this.collectionTypeIds = {}; } async build(options) { const { FederationImpl } = await Promise.resolve().then(() => require("./middleware-OQPBzyvx.cjs")); 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 RouterError("Actor dispatcher already set."); const variables = this.router.add(path, "actor"); if (variables.size !== 1 || !variables.has("identifier")) throw new RouterError("Path for actor dispatcher must have one variable: {identifier}"); 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; }, authorize(predicate) { callbacks.authorizePredicate = predicate; return setters; } }; return setters; } setNodeInfoDispatcher(path, dispatcher) { if (this.router.has("nodeInfo")) throw new RouterError("NodeInfo dispatcher already set."); if (this.router.add(path, "nodeInfo").size !== 0) throw new RouterError("Path for NodeInfo dispatcher must have no variables."); this.nodeInfoDispatcher = dispatcher; } setWebFingerLinksDispatcher(dispatcher) { this.webFingerLinksDispatcher = dispatcher; } setObjectDispatcher(cls, path, dispatcher) { const routeName = `object:${cls.typeId.href}`; if (this.router.has(routeName)) throw new RouterError(`Object dispatcher for ${cls.name} already set.`); const variables = this.router.add(path, routeName); if (variables.size < 1) throw new RouterError("Path for object dispatcher must have at least one variable."); 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 RouterError("Inbox dispatcher already set."); if (this.router.has("inbox")) { if (this.inboxPath !== path) throw new RouterError("Inbox dispatcher path must match inbox listener path."); } else { const variables = this.router.add(path, "inbox"); if (variables.size !== 1 || !variables.has("identifier")) throw new RouterError("Path for inbox dispatcher must have one variable: {identifier}"); 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 RouterError("Outbox dispatcher already set."); if (this.router.has("outbox")) { if (this.outboxPath !== path) throw new RouterError("Outbox dispatcher path must match outbox listener path."); } else { validateSingleIdentifierVariablePath(path, "Path for outbox dispatcher must have one variable: {identifier}"); this.router.add(path, "outbox"); 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 RouterError("Outbox listeners already set."); if (this.router.has("outbox")) { if (this.outboxPath !== outboxPath) throw new RouterError("Outbox listener path must match outbox dispatcher path."); } else { validateSingleIdentifierVariablePath(outboxPath, "Path for outbox must have one variable: {identifier}"); this.router.add(outboxPath, "outbox"); 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 RouterError("Following collection dispatcher already set."); const variables = this.router.add(path, "following"); if (variables.size !== 1 || !variables.has("identifier")) throw new RouterError("Path for following collection dispatcher must have one variable: {identifier}"); 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 RouterError("Followers collection dispatcher already set."); const variables = this.router.add(path, "followers"); if (variables.size !== 1 || !variables.has("identifier")) throw new RouterError("Path for followers collection dispatcher must have one variable: {identifier}"); 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 RouterError("Liked collection dispatcher already set."); const variables = this.router.add(path, "liked"); if (variables.size !== 1 || !variables.has("identifier")) throw new RouterError("Path for liked collection dispatcher must have one variable: {identifier}"); 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 RouterError("Featured collection dispatcher already set."); const variables = this.router.add(path, "featured"); if (variables.size !== 1 || !variables.has("identifier")) throw new RouterError("Path for featured collection dispatcher must have one variable: {identifier}"); 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 RouterError("Featured tags collection dispatcher already set."); const variables = this.router.add(path, "featuredTags"); if (variables.size !== 1 || !variables.has("identifier")) throw new RouterError("Path for featured tags collection dispatcher must have one variable: {identifier}"); 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 RouterError("Inbox listeners already set."); if (this.router.has("inbox")) { if (this.inboxPath !== inboxPath) throw new RouterError("Inbox listener path must match inbox dispatcher path."); } else { const variables = this.router.add(inboxPath, "inbox"); if (variables.size !== 1 || !variables.has("identifier")) throw new RouterError("Path for inbox must have one variable: {identifier}"); this.inboxPath = inboxPath; } if (sharedInboxPath != null) { if (this.router.add(sharedInboxPath, "sharedInbox").size !== 0) throw new RouterError("Path for shared inbox must have no variables."); } 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; } setCollectionDispatcher(name, itemType, path, dispatcher) { return this.#setCustomCollectionDispatcher(name, "collection", itemType, path, dispatcher); } 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 RouterError(`Collection dispatcher for ${strName} already set.`); if (this.collectionCallbacks[name] != null) throw new RouterError(`Collection dispatcher for ${strName} already set.`); if (this.router.add(path, routeName).size < 1) throw new RouterError("Path for collection dispatcher must have at least one variable."); 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/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, 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.` }); return "alreadyProcessed"; } } if (activity.actorId == null) { logger.error("Missing actor.", { activity: json }); span.setStatus({ code: _opentelemetry_api.SpanStatusCode.ERROR, message: "Missing actor." }); 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; } 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}` }); span.end(); return "unsupportedActivity"; } const { class: cls, listener } = dispatched; span.updateName(`activitypub.dispatch_inbox_listener ${cls.name}`); try { const contextFactory = listenerInboxContextFactory ?? inboxContextFactory; await listener(contextFactory(recipient, contextFactory === inboxContextFactory ? json : originalJson ?? json, activity?.id?.href, (0, _fedify_vocab.getTypeId)(activity).href), activity); } 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) }); span.end(); return "error"; } 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: await error.response.clone().text() }, { ttl: this.unavailableKeyTtl }); return; } await this.kv.set(this.#getFetchErrorKey(keyId), { errorName: error.error.name, errorMessage: error.error.message }, { ttl: this.unavailableKeyTtl }); } }; //#endregion //#region src/federation/negotiation.ts function compareSpecs(a, b) { return b.q - a.q || (b.s ?? 0) - (a.s ?? 0) || (a.o ?? 0) - (b.o ?? 0) || a.i - b.i || 0; } function isQuality(spec) { return spec.q > 0; } const simpleMediaTypeRegExp = /^\s*([^\s\/;]+)\/([^;\s]+)\s*(?:;(.*))?$/; function splitKeyValuePair(str) { const [key, value] = str.split("="); return [key.toLowerCase(), value]; } function parseMediaType(str, i) { const match = simpleMediaTypeRegExp.exec(str); if (!match) return; const [, type, subtype, parameters] = match; if (!type || !subtype) return; const params = Object.create(null); let q = 1; if (parameters) { const kvps = parameters.split(";").map((p) => p.trim()).map(splitKeyValuePair); for (const [key, val] of kvps) { const value = val && val[0] === `"` && val[val.length - 1] === `"` ? val.slice(1, val.length - 1) : val; if (key === "q" && value) { q = parseFloat(value); break; } params[key] = value; } } return { type, subtype, params, i, o: void 0, q, s: void 0 }; } function parseAccept(accept) { const accepts = accept.split(",").map((p) => p.trim()); const mediaTypes = []; for (const [index, accept] of accepts.entries()) { const mediaType = parseMediaType(accept.trim(), index); if (mediaType) mediaTypes.push(mediaType); } return mediaTypes; } function getFullType(spec) { return `${spec.type}/${spec.subtype}`; } function preferredMediaTypes(accept) { return parseAccept(accept === void 0 ? "*/*" : accept ?? "").filter(isQuality).sort(compareSpecs).map(getFullType); } function acceptsJsonLd(request) { const accept = request.headers.get("Accept"); const types = accept ? preferredMediaTypes(accept) : ["*/*"]; if (types == null) return true; if (types[0] === "text/html" || types[0] === "application/xhtml+xml") return false; return types.includes("application/activity+json") || types.includes("application/ld+json") || types.includes("application/json"); } //#endregion //#region src/federation/temporal.ts function isPlainObject(value) { return typeof value === "object" && value != null && !Array.isArray(value); } function normalizeDateTimeLiteral(value) { return value.substring(19).match(/[Z+-]/) ? value : value + "Z"; } function isMalformedDateTimeLiteral(value) { if (typeof value !== "string") return false; try { Temporal.Instant.from(normalizeDateTimeLiteral(value)); return false; } catch { return true; } } function isMalformedDurationLiteral(value) { if (typeof value !== "string") return false; try { Temporal.Duration.from(value); return false; } catch { return true; } } const TEMPORAL_DATE_TIME_IRIS = new Set([ "https://www.w3.org/ns/activitystreams#deleted", "https://www.w3.org/ns/activitystreams#endTime", "https://www.w3.org/ns/activitystreams#published", "https://www.w3.org/ns/activitystreams#startTime", "https://www.w3.org/ns/activitystreams#updated", "http://purl.org/dc/terms/created", "https://w3id.org/security#created" ]); const TEMPORAL_DURATION_IRIS = new Set(["https://www.w3.org/ns/activitystreams#duration"]); const QUESTION_CLOSED_IRI = "https://www.w3.org/ns/activitystreams#closed"; const XSD_DATE_TIME_IRI = "http://www.w3.org/2001/XMLSchema#dateTime"; function hasMalformedExpandedDateTimeLiteral(value) { if (Array.isArray(value)) return value.some(hasMalformedExpandedDateTimeLiteral); return isPlainObject(value) && "@value" in value && isMalformedDateTimeLiteral(value["@value"]); } function hasMalformedExpandedQuestionClosedLiteral(value) { if (Array.isArray(value)) return value.some(hasMalformedExpandedQuestionClosedLiteral); if (!isPlainObject(value) || !("@value" in value)) return false; const literal = value["@value"]; if (typeof literal === "boolean") return false; if (typeof literal !== "string") return false; if (value["@type"] !== XSD_DATE_TIME_IRI) return false; if (new Date(literal).toString() === "Invalid Date") return false; return isMalformedDateTimeLiteral(literal); } function hasMalformedExpandedDurationLiteral(value) { if (Array.isArray(value)) return value.some(hasMalformedExpandedDurationLiteral); return isPlainObject(value) && "@value" in value && isMalformedDurationLiteral(value["@value"]); } function hasMalformedKnownTemporalLiteralInternal(value, visited) { if (Array.isArray(value)) return value.some((item) => hasMalformedKnownTemporalLiteralInternal(item, visited)); if (!isPlainObject(value)) return false; if (visited.has(value)) return false; visited.add(value); if ("@value" in value) return false; for (const [key, child] of Object.entries(value)) { if (TEMPORAL_DATE_TIME_IRIS.has(key)) { if (hasMalformedExpandedDateTimeLiteral(child)) return true; continue; } if (key === QUESTION_CLOSED_IRI) { if (hasMalformedExpandedQuestionClosedLiteral(child)) return true; continue; } if (TEMPORAL_DURATION_IRIS.has(key)) { if (hasMalformedExpandedDurationLiteral(child)) return true; continue; } if (hasMalformedKnownTemporalLiteralInternal(child, visited)) return true; } return false; } async function hasMalformedKnownTemporalLiteral(value, contextLoader) { try { return hasMalformedKnownTemporalLiteralInternal(await _fedify_vocab_runtime_jsonld.default.expand(value, { documentLoader: require_proof.getNormalizationContextLoader(contextLoader), keepFreeFloatingNodes: true }), /* @__PURE__ */ new Set()); } catch { return false; } } //#endregion //#region src/federation/handler.ts const rawInboxContextFactorySymbol = Symbol("fedify.rawInboxContextFactory"); function isRemoteContextLoadingFailure$1(error) { return error instanceof Error && typeof error.details === "object" && error.details != null && error.details.code === "loading remote context failed"; } function isPermanentRemoteContextError$1(error) { if (!(error instanceof Error) || error.name !== "jsonld.InvalidUrl") return false; const details = error.details; if (details?.code === "invalid remote context") return true; return isRemoteContextLoadingFailure$1(error) && typeof details?.url === "string" && !URL.canParse(details.url) && require_proof.isClearlyMalformedContextReference(details.url); } function isInvalidJsonLdError(error) { if (!(error instanceof Error)) return false; const name = error.name; return name === "UnsafeJsonLdError" || error instanceof require_proof.InvalidContextReferenceError || isPermanentRemoteContextError$1(error) || name === "jsonld.SyntaxError" && !isRemoteContextLoadingFailure$1(error); } function isValidationTypeError(error) { return error instanceof TypeError && (/^(Invalid JSON-LD:|Invalid type:|Unexpected type:)/.test(error.message) || require_proof.isInvalidUrlTypeError(error)); } function isPermanentActivityParseError(error) { return isInvalidJsonLdError(error) || isValidationTypeError(error); } function hasHttpSignatureHeaders(request) { return request.headers.has("Signature") || request.headers.has("Signature-Input"); } function hasObjectIntegrityProof(json) { return typeof json === "object" && json != null && "proof" in json; } /** * Handles an actor request. * @template TContextData The context data to pass to the context. * @param request The HTTP request. * @param parameters The parameters for handling the actor. * @returns A promise that resolves to an HTTP response. */ async function handleActor(request, { identifier, context, actorDispatcher, authorizePredicate, onNotFound, onUnauthorized }) { const logger = (0, _logtape_logtape.getLogger)([ "fedify", "federation", "actor" ]); if (actorDispatcher == null) { logger.debug("Actor dispatcher is not set.", { identifier }); return await onNotFound(request); } const actor = await actorDispatcher(context, identifier); if (actor == null) { logger.debug("Actor {identifier} not found.", { identifier }); return await onNotFound(request); } if (authorizePredicate != null) { if (!await authorizePredicate(context, identifier)) return await onUnauthorized(request); } if (actor instanceof _fedify_vocab.Tombstone) { const jsonLd = await actor.toJsonLd(context); return new Response(JSON.stringify(jsonLd), { status: 410, headers: { "Content-Type": "application/activity+json", Vary: "Accept" } }); } const jsonLd = await actor.toJsonLd(context); return new Response(JSON.stringify(jsonLd), { headers: { "Content-Type": "application/activity+json", Vary: "Accept" } }); } /** * Handles an object request. * @template TContextData The context data to pass to the context. * @param request The HTTP request. * @param parameters The parameters for handling the object. * @returns A promise that resolves to an HTTP response. */ async function handleObject(request, { values, context, objectDispatcher, authorizePredicate, onNotFound, onUnauthorized }) { if (objectDispatcher == null) return await onNotFound(request); const object = await objectDispatcher(context, values); if (object == null) return await onNotFound(request); if (authorizePredicate != null) { if (!await authorizePredicate(context, values)) return await onUnauthorized(request); } const jsonLd = await object.toJsonLd(context); return new Response(JSON.stringify(jsonLd), { headers: { "Content-Type": "application/activity+json", Vary: "Accept" } }); } /** * Handles a collection request. * @template TItem The type of items in the collection. * @template TContext The type of the context, extending {@link RequestContext}. * @template TContextData The context data to pass to the `TContext`. * @template TFilter The type of the filter. * @param request The HTTP request. * @param parameters The parameters for handling the collection. * @returns A promise that resolves to an HTTP response. */ async function handleCollection(request, { name: name$1, identifier, uriGetter, filter, filterPredicate, context, collectionCallbacks, tracerProvider, onUnauthorized, onNotFound }) { const spanName = name$1.trim().replace(/\s+/g, "_"); tracerProvider = tracerProvider ?? _opentelemetry_api.trace.getTracerProvider(); const tracer = tracerProvider.getTracer(require_http.name, require_http.version); const cursor = new URL(request.url).searchParams.get("cursor"); if (collectionCallbacks == null) return await onNotFound(request); let collection; const baseUri = uriGetter(identifier); if (cursor == null) { const firstCursor = await collectionCallbacks.firstCursor?.(context, identifier); const totalItems = filter == null ? await collectionCallbacks.counter?.(context, identifier) : void 0; if (firstCursor == null) { const itemsOrResponse = await tracer.startActiv