UNPKG

@fedify/fedify

Version:

An ActivityPub server framework

1,381 lines 95.7 kB
const { Temporal } = require("@js-temporal/polyfill"); const { URLPattern } = require("urlpattern-polyfill"); require("./chunk-DDcVe30Y.cjs"); let _logtape_logtape = require("@logtape/logtape"); let _fedify_vocab = require("@fedify/vocab"); let _opentelemetry_api = require("@opentelemetry/api"); let byte_encodings_hex = require("byte-encodings/hex"); let structured_field_values = require("structured-field-values"); let _fedify_vocab_runtime = require("@fedify/vocab-runtime"); let _opentelemetry_semantic_conventions = require("@opentelemetry/semantic-conventions"); let byte_encodings_base64 = require("byte-encodings/base64"); //#region deno.json var name = "@fedify/fedify"; var version = "2.3.1"; //#endregion //#region src/sig/accept.ts /** * `Accept-Signature` header parsing, serialization, and validation utilities * for RFC 9421 §5 challenge-response negotiation. * * @module */ /** * Parses an `Accept-Signature` header value (RFC 9421 §5.1) into an * array of {@link AcceptSignatureMember} objects. * * The `Accept-Signature` field is a Dictionary Structured Field * (RFC 8941 §3.2). Each dictionary member describes a single * requested message signature. * * On parse failure (malformed or empty header), returns an empty array. * * @param header The raw `Accept-Signature` header value string. * @returns An array of parsed members. Empty if the header is * malformed or empty. * @since 2.1.0 */ function parseAcceptSignature(header) { try { return parseEachSignature((0, structured_field_values.decodeDict)(header)); } catch { (0, _logtape_logtape.getLogger)([ "fedify", "sig", "http" ]).warn("Failed to parse Accept-Signature header: {header}", { header }); return []; } } const compactObject = (obj) => Object.fromEntries(Object.entries(obj).filter(([_, v]) => v !== void 0)); const parseEachSignature = (dict) => Object.entries(dict).filter(([_, item]) => Array.isArray(item.value)).map(([label, item]) => ({ label, components: item.value.filter((subitem) => typeof subitem.value === "string").map((subitem) => ({ value: subitem.value, params: subitem.params ?? {} })), parameters: compactParams(item) })); const compactParams = (item) => { const { keyid, alg, created, expires, nonce, tag } = item.params ?? {}; return compactObject({ keyid: stringOrUndefined(keyid), alg: stringOrUndefined(alg), created: trueOrUndefined(created), expires: trueOrUndefined(expires), nonce: stringOrUndefined(nonce), tag: stringOrUndefined(tag) }); }; const stringOrUndefined = (v) => typeof v === "string" ? v : void 0; const trueOrUndefined = (v) => v === true ? true : void 0; /** * Serializes an array of {@link AcceptSignatureMember} objects into an * `Accept-Signature` header value string (RFC 9421 §5.1). * * The output is a Dictionary Structured Field (RFC 8941 §3.2). * * @param members The members to serialize. * @returns The serialized header value string. * @since 2.1.0 */ function formatAcceptSignature(members) { const items = members.map((member) => [member.label, new structured_field_values.Item(compToItems(member), compactParameters(member))]); return (0, structured_field_values.encodeDict)(Object.fromEntries(items)); } const compToItems = (member) => member.components.map((c) => new structured_field_values.Item(c.value, c.params)); const compactParameters = (member) => { const { keyid, alg, created, expires, nonce, tag } = member.parameters; return compactObject({ keyid, alg, created, expires, nonce, tag }); }; /** * Filters out {@link AcceptSignatureMember} entries whose covered * components include response-only identifiers (`@status`) that are * not applicable to request-target messages, as required by * [RFC 9421 §5](https://www.rfc-editor.org/rfc/rfc9421#section-5). * * A warning is logged for each discarded entry. * * @param members The parsed `Accept-Signature` entries to validate. * @returns Only entries that are valid for request-target messages. * @since 2.1.0 */ function validateAcceptSignature(members) { const logger = (0, _logtape_logtape.getLogger)([ "fedify", "sig", "http" ]); return members.filter((member) => { if (member.components.every((c) => c.value !== "@status")) return true; logLabel(logger, member.label); return false; }); } const logLabel = (logger, label) => logger.warn("Discarding Accept-Signature member {label}: covered components include response-only identifier @status.", { label }); /** * Attempts to translate an {@link AcceptSignatureMember} challenge into * RFC 9421 signing options that the local signer can fulfill. * * Returns `null` if the challenge cannot be fulfilled—for example, if * the requested `alg` or `keyid` is incompatible with the local key. * * Safety constraints: * - `alg`: only honored if it matches `localAlg`. * - `keyid`: only honored if it matches `localKeyId`. * - `components`: passed through exactly as requested, per RFC 9421 §5.2. * - `nonce`, `tag`, and `expires` are passed through directly. * * @param entry The challenge entry from the `Accept-Signature` header. * @param localKeyId The local key identifier (e.g., the actor key URL). * @param localAlg The algorithm of the local private key * (e.g., `"rsa-v1_5-sha256"`). * @returns Signing options if the challenge can be fulfilled, or `null`. * @since 2.1.0 */ function fulfillAcceptSignature(entry, localKeyId, localAlg) { if (entry.parameters.alg != null && entry.parameters.alg !== localAlg) return null; if (entry.parameters.keyid != null && entry.parameters.keyid !== localKeyId) return null; return { label: entry.label, components: entry.components, nonce: entry.parameters.nonce, tag: entry.parameters.tag, expires: entry.parameters.expires }; } //#endregion //#region src/federation/metrics.ts var FederationMetrics = class { deliverySent; deliveryPermanentFailure; signatureVerificationFailure; signatureVerificationDuration; signatureKeyFetchDuration; deliveryDuration; inboxProcessingDuration; httpServerRequestCount; httpServerRequestDuration; queueTaskEnqueued; queueTaskStarted; queueTaskCompleted; queueTaskFailed; queueTaskDuration; queueTaskInFlight; queueDepth; fanoutRecipients; inboxActivity; outboxActivity; circuitBreakerStateChange; keyLookup; keyLookupDuration; documentFetch; documentFetchDuration; documentCache; webFingerHandle; webFingerHandleDuration; collectionRequest; collectionDispatchDuration; collectionPageItems; collectionTotalItems; constructor(meterProvider) { const meter = meterProvider.getMeter(name, version); this.deliverySent = meter.createCounter("activitypub.delivery.sent", { description: "ActivityPub delivery attempts.", unit: "{attempt}" }); this.deliveryPermanentFailure = meter.createCounter("activitypub.delivery.permanent_failure", { description: "ActivityPub deliveries abandoned as permanent failures.", unit: "{failure}" }); this.signatureVerificationFailure = meter.createCounter("activitypub.signature.verification_failure", { description: "ActivityPub signature verification failures.", unit: "{failure}" }); this.signatureVerificationDuration = meter.createHistogram("activitypub.signature.verification.duration", { description: "Duration of ActivityPub signature verification, including local key lookup and remote key fetches.", unit: "ms", advice: { explicitBucketBoundaries: [ .1, .25, .5, 1, 2.5, 5, 10, 25, 50, 100, 250, 500, 1e3 ] } }); this.signatureKeyFetchDuration = meter.createHistogram("activitypub.signature.key_fetch.duration", { description: "Duration of public key lookup performed during ActivityPub signature verification.", unit: "ms" }); this.deliveryDuration = meter.createHistogram("activitypub.delivery.duration", { description: "Duration of ActivityPub delivery attempts.", unit: "ms" }); this.inboxProcessingDuration = meter.createHistogram("activitypub.inbox.processing_duration", { description: "Duration of ActivityPub inbox listener processing.", unit: "ms" }); this.httpServerRequestCount = meter.createCounter("fedify.http.server.request.count", { description: "HTTP requests handled by Federation.fetch().", unit: "{request}" }); this.httpServerRequestDuration = meter.createHistogram("fedify.http.server.request.duration", { description: "Duration of HTTP requests handled by Federation.fetch().", unit: "ms", advice: { explicitBucketBoundaries: [ 5, 10, 25, 50, 75, 100, 250, 500, 750, 1e3, 2500, 5e3, 7500, 1e4 ] } }); this.queueTaskEnqueued = meter.createCounter("fedify.queue.task.enqueued", { description: "Tasks Fedify enqueued for inbox, outbox, or fanout work.", unit: "{task}" }); this.queueTaskStarted = meter.createCounter("fedify.queue.task.started", { description: "Tasks Fedify began processing as a queue worker.", unit: "{task}" }); this.queueTaskCompleted = meter.createCounter("fedify.queue.task.completed", { description: "Queue tasks Fedify finished processing without throwing.", unit: "{task}" }); this.queueTaskFailed = meter.createCounter("fedify.queue.task.failed", { description: "Queue tasks Fedify abandoned because processing threw.", unit: "{task}" }); this.queueTaskDuration = meter.createHistogram("fedify.queue.task.duration", { description: "Duration of queue task processing in Fedify workers.", unit: "ms", advice: { explicitBucketBoundaries: [ 5, 10, 25, 50, 75, 100, 250, 500, 750, 1e3, 2500, 5e3, 7500, 1e4 ] } }); this.queueTaskInFlight = meter.createUpDownCounter("fedify.queue.task.in_flight", { description: "Queue tasks currently being processed in this Fedify process.", unit: "{task}" }); this.queueDepth = meter.createObservableGauge("fedify.queue.depth", { description: "Messages waiting in configured Fedify queues, as reported by the queue backend.", unit: "{message}" }); this.fanoutRecipients = meter.createHistogram("activitypub.fanout.recipients", { description: "Number of recipient inboxes produced by an ActivityPub fanout task.", unit: "{recipient}" }); this.inboxActivity = meter.createCounter("activitypub.inbox.activity", { description: "ActivityPub activities observed at the inbox lifecycle level: queued, processed, retried, rejected, or abandoned.", unit: "{activity}" }); this.outboxActivity = meter.createCounter("activitypub.outbox.activity", { description: "ActivityPub activities observed at the outbox lifecycle level: queued, retried, or abandoned. Per-recipient delivery counters live on `activitypub.delivery.*`.", unit: "{activity}" }); this.circuitBreakerStateChange = meter.createCounter("activitypub.circuit_breaker.state_change", { description: "Outbound ActivityPub delivery circuit breaker changes.", unit: "{change}" }); this.keyLookup = meter.createCounter("activitypub.key.lookup", { description: "Public-key lookup attempts performed by Fedify, including both cache hits and remote fetches.", unit: "{lookup}" }); this.keyLookupDuration = meter.createHistogram("activitypub.key.lookup.duration", { description: "Duration of public-key lookups performed by Fedify, including any remote fetch.", unit: "ms", advice: { explicitBucketBoundaries: [ 5, 10, 25, 50, 75, 100, 250, 500, 750, 1e3, 2500, 5e3, 7500, 1e4 ] } }); this.documentFetch = meter.createCounter("activitypub.document.fetch", { description: "Remote JSON-LD document loader invocations made by Fedify-wrapped loaders.", unit: "{fetch}" }); this.documentFetchDuration = meter.createHistogram("activitypub.document.fetch.duration", { description: "Duration of remote JSON-LD document loader invocations made by Fedify-wrapped loaders.", unit: "ms", advice: { explicitBucketBoundaries: [ 5, 10, 25, 50, 75, 100, 250, 500, 750, 1e3, 2500, 5e3, 7500, 1e4 ] } }); this.documentCache = meter.createCounter("activitypub.document.cache", { description: "KV-backed document loader cache lookups, with `hit` or `miss` classification.", unit: "{lookup}" }); this.webFingerHandle = meter.createCounter("webfinger.handle", { description: "Incoming WebFinger requests handled by Fedify, classified by terminal outcome.", unit: "{request}" }); this.webFingerHandleDuration = meter.createHistogram("webfinger.handle.duration", { description: "Duration of incoming WebFinger request handling in Fedify.", unit: "ms", advice: { explicitBucketBoundaries: [ 5, 10, 25, 50, 75, 100, 250, 500, 750, 1e3, 2500, 5e3, 7500, 1e4 ] } }); this.collectionRequest = meter.createCounter("activitypub.collection.request", { description: "ActivityPub collection and collection-page requests handled by Fedify.", unit: "{request}" }); this.collectionDispatchDuration = meter.createHistogram("activitypub.collection.dispatch.duration", { description: "Duration of ActivityPub collection dispatcher callbacks.", unit: "ms", advice: { explicitBucketBoundaries: [ 5, 10, 25, 50, 75, 100, 250, 500, 750, 1e3, 2500, 5e3, 7500, 1e4 ] } }); this.collectionPageItems = meter.createHistogram("activitypub.collection.page.items", { description: "Number of items Fedify materialized for an ActivityPub collection response.", unit: "{item}" }); this.collectionTotalItems = meter.createHistogram("activitypub.collection.total_items", { description: "Total item count reported by ActivityPub collection counters.", unit: "{item}" }); } recordDelivery(inbox, durationMs, success, activityType) { const deliveryAttributes = { "activitypub.remote.host": getRemoteHost(inbox), "activitypub.delivery.success": success }; if (activityType != null) deliveryAttributes["activitypub.activity.type"] = activityType; this.deliverySent.add(1, deliveryAttributes); this.deliveryDuration.record(durationMs, deliveryAttributes); } recordPermanentFailure(inbox, statusCode) { this.deliveryPermanentFailure.add(1, { "activitypub.remote.host": getRemoteHost(inbox), "http.response.status_code": statusCode }); } recordSignatureVerificationFailure(reason, remoteHost) { const attributes = { "activitypub.verification.failure_reason": reason }; if (remoteHost != null) attributes["activitypub.remote.host"] = remoteHost; this.signatureVerificationFailure.add(1, attributes); } recordSignatureVerificationDuration(durationMs, kind, result, extra = {}) { const attributes = { "activitypub.signature.kind": kind, "activitypub.signature.result": result }; if (extra.algorithm != null) attributes["http_signatures.algorithm"] = extra.algorithm; if (extra.failureReason != null) attributes["http_signatures.failure_reason"] = extra.failureReason; if (extra.ldType != null) attributes["ld_signatures.type"] = extra.ldType; if (extra.cryptosuite != null) attributes["object_integrity_proofs.cryptosuite"] = extra.cryptosuite; this.signatureVerificationDuration.record(durationMs, attributes); } recordSignatureKeyFetchDuration(durationMs, kind, result) { this.signatureKeyFetchDuration.record(durationMs, { "activitypub.signature.kind": kind, "activitypub.signature.key_fetch.result": result }); } recordInboxProcessingDuration(activityType, durationMs) { this.inboxProcessingDuration.record(durationMs, { "activitypub.activity.type": activityType }); } recordHttpServerRequest(method, endpoint, durationMs, options = {}) { const attributes = { "http.request.method": normalizeHttpMethod(method), "fedify.endpoint": endpoint }; if (options.statusCode != null) attributes["http.response.status_code"] = options.statusCode; if (options.routeTemplate != null) attributes["fedify.route.template"] = options.routeTemplate; this.httpServerRequestCount.add(1, attributes); this.httpServerRequestDuration.record(durationMs, attributes); } recordQueueTaskEnqueued(common, attempt) { const attributes = buildQueueTaskAttributes(common); attributes["fedify.queue.task.attempt"] = attempt; this.queueTaskEnqueued.add(1, attributes); } recordQueueTaskStarted(common) { this.queueTaskStarted.add(1, buildQueueTaskAttributes(common)); } incrementQueueTaskInFlight(common) { this.queueTaskInFlight.add(1, buildQueueTaskInFlightAttributes(common)); } decrementQueueTaskInFlight(common) { this.queueTaskInFlight.add(-1, buildQueueTaskInFlightAttributes(common)); } recordQueueTaskOutcome(common, result, durationMs) { const attributes = buildQueueTaskAttributes(common); attributes["fedify.queue.task.result"] = result; if (result === "completed") this.queueTaskCompleted.add(1, attributes); else if (result === "failed") this.queueTaskFailed.add(1, attributes); this.queueTaskDuration.record(durationMs, attributes); } recordFanoutRecipients(recipientCount, activityType) { const attributes = {}; if (activityType != null) attributes["activitypub.activity.type"] = activityType; this.fanoutRecipients.record(recipientCount, attributes); } recordInboxActivity(result, activityType) { this.inboxActivity.add(1, buildActivityLifecycleAttributes(result, activityType)); } recordOutboxActivity(result, activityType) { this.outboxActivity.add(1, buildActivityLifecycleAttributes(result, activityType)); } recordCircuitBreakerStateChange(remoteHost, state) { this.circuitBreakerStateChange.add(1, { "activitypub.remote.host": remoteHost, "activitypub.circuit_breaker.state": state }); } recordKeyLookup(attrs) { const attributes = { "activitypub.lookup.kind": "public_key", "activitypub.lookup.result": attrs.result, "activitypub.cache.enabled": attrs.cacheEnabled }; if (attrs.remoteUrl != null) attributes["activitypub.remote.host"] = getRemoteHost(attrs.remoteUrl); if (attrs.statusCode != null) attributes["http.response.status_code"] = attrs.statusCode; this.keyLookup.add(1, attributes); this.keyLookupDuration.record(attrs.durationMs, attributes); } recordDocumentFetch(attrs) { const attributes = { "activitypub.lookup.kind": attrs.kind, "activitypub.lookup.result": attrs.result }; if (attrs.remoteUrl != null) attributes["activitypub.remote.host"] = getRemoteHost(attrs.remoteUrl); if (attrs.cacheEnabled != null) attributes["activitypub.cache.enabled"] = attrs.cacheEnabled; if (attrs.statusCode != null) attributes["http.response.status_code"] = attrs.statusCode; this.documentFetch.add(1, attributes); this.documentFetchDuration.record(attrs.durationMs, attributes); } recordDocumentCache(attrs) { const attributes = { "activitypub.lookup.kind": attrs.kind, "activitypub.lookup.result": attrs.result }; if (attrs.remoteUrl != null) attributes["activitypub.remote.host"] = getRemoteHost(attrs.remoteUrl); this.documentCache.add(1, attributes); } recordWebFingerHandle(attrs) { const attributes = { "webfinger.handle.result": attrs.result }; if (attrs.scheme != null) attributes["webfinger.resource.scheme"] = attrs.scheme; if (attrs.statusCode != null) attributes["http.response.status_code"] = attrs.statusCode; this.webFingerHandle.add(1, attributes); this.webFingerHandleDuration.record(attrs.durationMs, attributes); } recordCollectionRequest(attrs) { this.collectionRequest.add(1, buildCollectionAttributes(attrs)); } recordCollectionDispatchDuration(durationMs, attrs) { this.collectionDispatchDuration.record(durationMs, buildCollectionAttributes(attrs)); } recordCollectionPageItems(itemCount, attrs) { this.collectionPageItems.record(itemCount, buildCollectionAttributes(attrs)); } recordCollectionTotalItems(totalItems, attrs) { this.collectionTotalItems.record(totalItems, buildCollectionAttributes(attrs)); } }; function buildCollectionAttributes(attrs) { const attributes = { "activitypub.collection.kind": attrs.kind, "activitypub.collection.page": attrs.page, "activitypub.collection.result": attrs.result, "fedify.collection.dispatcher": attrs.dispatcher }; if (attrs.statusCode != null) attributes["http.response.status_code"] = attrs.statusCode; return attributes; } function buildActivityLifecycleAttributes(result, activityType) { const attributes = { "activitypub.processing.result": result }; if (activityType != null) attributes["activitypub.activity.type"] = activityType; return attributes; } function buildQueueTaskAttributes(common) { const attributes = { "fedify.queue.role": common.role }; const backend = getQueueBackend(common.queue); if (backend != null) attributes["fedify.queue.backend"] = backend; const nativeRetrial = common.queue?.nativeRetrial; if (typeof nativeRetrial === "boolean") attributes["fedify.queue.native_retrial"] = nativeRetrial; if (common.activityType != null) attributes["activitypub.activity.type"] = common.activityType; return attributes; } function buildQueueTaskInFlightAttributes(common) { return buildQueueTaskAttributes({ role: common.role, queue: common.queue }); } /** * Returns the constructor name of the given message queue, when it is a * meaningful identifier. Used as a best-effort `fedify.queue.backend` * attribute on queue task metrics; returns `undefined` for plain object * literals (whose constructor is `Object`) so the attribute does not appear * with a non-informative value. * @since 2.3.0 */ function getQueueBackend(queue) { const name = queue?.constructor?.name; if (name == null || name === "" || name === "Object") return void 0; return name; } /** * Registers a callback for observing queue backend depth. * @since 2.3.0 */ function registerQueueDepthGauge(meterProvider, entries, options = {}) { const uniqueQueues = /* @__PURE__ */ new Map(); for (const { role, queue } of entries) { if (queue?.getDepth == null) continue; const roles = uniqueQueues.get(queue); if (roles == null) uniqueQueues.set(queue, [role]); else if (!roles.includes(role)) roles.push(role); } if (uniqueQueues.size < 1) return; const queueEntries = Array.from(uniqueQueues.entries()); getFederationMetrics(meterProvider).queueDepth.addCallback(async (observableResult) => { await Promise.all(queueEntries.map(async ([queue, roles]) => { let depth; try { depth = await queue.getDepth(); } catch { return; } if (depth == null) return; const attributes = buildQueueDepthAttributes(queue, roles, options); observableResult.observe(depth.queued, { ...attributes, "fedify.queue.depth.state": "queued" }); if (depth.ready != null) observableResult.observe(depth.ready, { ...attributes, "fedify.queue.depth.state": "ready" }); if (depth.delayed != null) observableResult.observe(depth.delayed, { ...attributes, "fedify.queue.depth.state": "delayed" }); })); }); } function buildQueueDepthAttributes(queue, roles, options) { const sortedRoles = roles.toSorted(); const role = sortedRoles.length === 1 ? sortedRoles[0] : "shared"; const attributes = { "fedify.queue.role": role }; if (options.sourceId != null) attributes["fedify.federation.instance_id"] = options.sourceId; if (role === "shared") attributes["fedify.queue.roles"] = sortedRoles.join(","); const backend = getQueueBackend(queue); if (backend != null) attributes["fedify.queue.backend"] = backend; const nativeRetrial = queue.nativeRetrial; if (typeof nativeRetrial === "boolean") attributes["fedify.queue.native_retrial"] = nativeRetrial; return attributes; } /** * Records `fedify.queue.task.enqueued` for an outgoing outbox enqueue and, * for the initial attempt, also records * `activitypub.outbox.activity{queued}`. * * Both `Context.sendActivity()` and `OutboxContext.forwardActivity()` enqueue * outbox messages with the same metric attributes (role, queue, activity * type, attempt), so they share this helper rather than each defining a local * closure. Retry enqueues (attempt > 0) intentionally do not record a * second `activitypub.outbox.activity{queued}`; retries are reported as * `result=retried` from the retry-scheduling site, which has the failure * context. * @since 2.3.0 */ function recordOutboxEnqueue(meterProvider, outboxQueue, message) { const metrics = getFederationMetrics(meterProvider); metrics.recordQueueTaskEnqueued({ role: "outbox", queue: outboxQueue, activityType: message.activityType }, message.attempt); if (message.attempt === 0) metrics.recordOutboxActivity("queued", message.activityType); } /** * Records `activitypub.fanout.recipients` with the number of recipient * inboxes a single fanout produced. The histogram is unitless count * (one measurement per fanout enqueue). Recipient URLs are deliberately * not recorded; only the activity type, when known. * @since 2.3.0 */ function recordFanoutRecipients(meterProvider, recipientCount, activityType) { getFederationMetrics(meterProvider).recordFanoutRecipients(recipientCount, activityType); } /** * Records one `activitypub.inbox.activity` measurement. The * `activitypub.processing.result` attribute is always present; * `activitypub.activity.type` is recorded only when Fedify already knows * the activity type. * @since 2.3.0 */ function recordInboxActivity(meterProvider, result, activityType) { getFederationMetrics(meterProvider).recordInboxActivity(result, activityType); } /** * Records one `activitypub.outbox.activity` measurement. The * `activitypub.processing.result` attribute is always present; * `activitypub.activity.type` is recorded only when Fedify already knows * the activity type (it is always known for outbox lifecycle events). * @since 2.3.0 */ function recordOutboxActivity(meterProvider, result, activityType) { getFederationMetrics(meterProvider).recordOutboxActivity(result, activityType); } /** * Records one outbound delivery circuit breaker state transition. * @since 2.3.0 */ function recordCircuitBreakerStateChange(meterProvider, remoteHost, state) { getFederationMetrics(meterProvider).recordCircuitBreakerStateChange(remoteHost, state); } /** * Records one measurement on `activitypub.key.lookup` (counter) and * `activitypub.key.lookup.duration` (histogram) for a public-key lookup. * * `activitypub.lookup.kind` is always recorded as `public_key`; the result * classification, remote host, HTTP status code (when an HTTP response was * received), and `activitypub.cache.enabled` are recorded as attributes on * both measurements. Full key URLs and key IDs are deliberately omitted to * keep cardinality bounded. * @since 2.3.0 */ function recordKeyLookup(meterProvider, attrs) { getFederationMetrics(meterProvider).recordKeyLookup(attrs); } /** * Records one measurement each on `activitypub.document.fetch` (counter) * and `activitypub.document.fetch.duration` (histogram) for one remote * JSON-LD document loader invocation, with bounded * `activitypub.lookup.kind` and `activitypub.lookup.result` attributes * plus the optional remote-host, cache-enabled, and HTTP status-code * attributes. Counter and histogram are always recorded together so * aggregate rate and latency views stay in sync. * @since 2.3.0 */ function recordDocumentFetch(meterProvider, attrs) { getFederationMetrics(meterProvider).recordDocumentFetch(attrs); } /** * Records one `activitypub.document.cache` measurement, classifying the * lookup as `hit` (the cache returned an entry) or `miss` (the cache was * consulted and returned nothing, prompting a delegate fetch). * @since 2.3.0 */ function recordDocumentCache(meterProvider, attrs) { getFederationMetrics(meterProvider).recordDocumentCache(attrs); } /** * Records one measurement on `webfinger.handle` (counter) and * `webfinger.handle.duration` (histogram) for an incoming WebFinger * request handled by Fedify. Counter and histogram are always recorded * together, with `webfinger.handle.result` set to one of `resolved`, * `invalid`, `not_found`, `tombstoned`, or `error`. The queried * resource string is deliberately excluded; it remains on the * `webfinger.handle` span for trace-level investigation. * @since 2.3.0 */ function recordWebFingerHandle(meterProvider, attrs) { getFederationMetrics(meterProvider).recordWebFingerHandle(attrs); } /** * Records one `activitypub.collection.request` measurement for a * collection or collection-page request handled by Fedify. * @since 2.3.0 */ function recordCollectionRequest(meterProvider, attrs) { getFederationMetrics(meterProvider).recordCollectionRequest(attrs); } /** * Records one `activitypub.collection.dispatch.duration` measurement for a * collection dispatcher callback invocation. * @since 2.3.0 */ function recordCollectionDispatchDuration(meterProvider, durationMs, attrs) { getFederationMetrics(meterProvider).recordCollectionDispatchDuration(durationMs, attrs); } /** * Records one `activitypub.collection.page.items` measurement when Fedify * has materialized collection items in memory. * @since 2.3.0 */ function recordCollectionPageItems(meterProvider, itemCount, attrs) { getFederationMetrics(meterProvider).recordCollectionPageItems(itemCount, attrs); } /** * Records one `activitypub.collection.total_items` measurement when a * collection counter has already reported a total item count. * @since 2.3.0 */ function recordCollectionTotalItems(meterProvider, totalItems, attrs) { getFederationMetrics(meterProvider).recordCollectionTotalItems(totalItems, attrs); } /** * Classifies a thrown value from a key or document fetch into the bounded * {@link LookupResult} taxonomy and, when an HTTP response was received, * surfaces its status code. * * - `FetchError` with a `Response` whose status is `404` or `410`: * `result=not_found` and the response status code. * - `FetchError` with any other `Response`: `result=error` and the * response status code. * - `FetchError` without a `Response`: `result=network_error`. * - An `AbortError` (typically from a cancelled fetch): `result=network_error`. * - A bare `TypeError` (the shape native `fetch()` raises on DNS, connect, * and TLS failures before any response is observed): * `result=network_error`. * - Any other value: `result=error`. * @since 2.3.0 */ function classifyFetchError(error) { if (error instanceof _fedify_vocab_runtime.FetchError) { if (error.response != null) { const status = error.response.status; return { result: status === 404 || status === 410 ? "not_found" : "error", statusCode: status }; } return { result: "network_error" }; } if (isAbortError$1(error)) return { result: "network_error" }; if (error instanceof TypeError) return { result: "network_error" }; return { result: "error" }; } /** * Wraps a {@link DocumentLoader} so each invocation records one * measurement on `activitypub.document.fetch` (counter) and one on * `activitypub.document.fetch.duration` (histogram), classifying the * outcome via {@link classifyFetchError} when the wrapped loader throws * and as `fetched` on success. The wrapper rethrows whatever the * wrapped loader throws so caller behavior is unchanged. * * The wrapper records the host of the requested URL, including any * non-default port, on `activitypub.remote.host` when the URL parses; full * URLs, paths, and query strings are deliberately excluded to keep * cardinality bounded. * HTTP status codes are recorded only when the failure carries a * `Response` (currently, when the wrapped loader throws a * {@link FetchError} with a non-`null` `response`). * @since 2.3.0 */ function instrumentDocumentLoader(loader, options) { const meterProvider = options.meterProvider; if (meterProvider == null) return loader; return async (url, opts) => { const start = performance.now(); let remoteUrl; try { remoteUrl = new URL(url); } catch { remoteUrl = void 0; } try { const result = await loader(url, opts); recordDocumentFetch(meterProvider, { durationMs: getDurationMs(start), kind: options.kind, result: "fetched", remoteUrl, cacheEnabled: options.cacheEnabled }); return result; } catch (error) { const classified = classifyFetchError(error); recordDocumentFetch(meterProvider, { durationMs: getDurationMs(start), kind: options.kind, result: classified.result, remoteUrl, cacheEnabled: options.cacheEnabled, statusCode: classified.statusCode }); throw error; } }; } /** * Times an awaited public key fetch and records exactly one * `activitypub.signature.key_fetch.duration` measurement, classifying the * outcome as `hit`, `fetched`, or `error` based on the `cached` flag and * whether the returned key is non-null. Errors thrown by the fetch are * reported as `error` and rethrown, so verifier behavior is unchanged. * * Shared by the three signature verifiers (HTTP, Linked Data, Object * Integrity Proofs); the only per-call variation is the * `activitypub.signature.kind` attribute value. * @since 2.3.0 */ async function measureSignatureKeyFetch(meterProvider, kind, fetch) { const start = performance.now(); try { const result = await fetch(); getFederationMetrics(meterProvider).recordSignatureKeyFetchDuration(getDurationMs(start), kind, result.key != null ? result.cached ? "hit" : "fetched" : "error"); return result; } catch (error) { getFederationMetrics(meterProvider).recordSignatureKeyFetchDuration(getDurationMs(start), kind, "error"); throw error; } } /** * Whether the given thrown value is an `AbortError`. * * `processQueuedTask` distinguishes aborted tasks (recorded as * `fedify.queue.task.result=aborted`) from other failures so that backend * shutdown signals do not inflate the `fedify.queue.task.failed` counter. * @since 2.3.0 */ function isAbortError$1(error) { if (error == null || typeof error !== "object") return false; const name = error.name; return typeof name === "string" && name === "AbortError"; } const KNOWN_HTTP_METHODS = new Set([ "CONNECT", "DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT", "QUERY", "TRACE" ]); function normalizeHttpMethod(method) { const upper = method.toUpperCase(); return KNOWN_HTTP_METHODS.has(upper) ? upper : "_OTHER"; } const federationMetrics = /* @__PURE__ */ new WeakMap(); /** * Gets the cached Fedify metric instruments for a meter provider. * @since 2.3.0 */ function getFederationMetrics(meterProvider = _opentelemetry_api.metrics.getMeterProvider()) { let instruments = federationMetrics.get(meterProvider); if (instruments == null) { instruments = new FederationMetrics(meterProvider); federationMetrics.set(meterProvider, instruments); } return instruments; } /** * Gets the bounded remote host attribute value for a URL. * @since 2.3.0 */ function getRemoteHost(url) { return url.host; } /** * Gets an elapsed duration in milliseconds from a `performance.now()` value. * @since 2.3.0 */ function getDurationMs(start) { return Math.max(0, performance.now() - start); } //#endregion //#region src/sig/key.ts /** * Checks if the given key is valid and supported. No-op if the key is valid, * otherwise throws an error. * @param key The key to check. * @param type Which type of key to check. If not specified, the key can be * either public or private. * @throws {TypeError} If the key is invalid or unsupported. */ function validateCryptoKey(key, type) { if (type != null && key.type !== type) throw new TypeError(`The key is not a ${type} key.`); if (!key.extractable) throw new TypeError("The key is not extractable."); if (key.algorithm.name !== "RSASSA-PKCS1-v1_5" && key.algorithm.name !== "Ed25519") throw new TypeError("Currently only RSASSA-PKCS1-v1_5 and Ed25519 keys are supported. More algorithms will be added in the future!"); if (key.algorithm.name === "RSASSA-PKCS1-v1_5") { if (key.algorithm.hash.name !== "SHA-256") throw new TypeError("For compatibility with the existing Fediverse software (e.g., Mastodon), hash algorithm for RSASSA-PKCS1-v1_5 keys must be SHA-256."); } } /** * Generates a key pair which is appropriate for Fedify. * @param algorithm The algorithm to use. Currently only RSASSA-PKCS1-v1_5 and * Ed25519 are supported. * @returns The generated key pair. * @throws {TypeError} If the algorithm is unsupported. */ function generateCryptoKeyPair(algorithm) { if (algorithm == null) (0, _logtape_logtape.getLogger)([ "fedify", "sig", "key" ]).warn("No algorithm specified. Using RSASSA-PKCS1-v1_5 by default, but it is recommended to specify the algorithm explicitly as the parameter will be required in the future."); if (algorithm == null || algorithm === "RSASSA-PKCS1-v1_5") return crypto.subtle.generateKey({ name: "RSASSA-PKCS1-v1_5", modulusLength: 4096, publicExponent: new Uint8Array([ 1, 0, 1 ]), hash: "SHA-256" }, true, ["sign", "verify"]); else if (algorithm === "Ed25519") return crypto.subtle.generateKey("Ed25519", true, ["sign", "verify"]); throw new TypeError("Unsupported algorithm: " + algorithm); } /** * Exports a key in JWK format. * @param key The key to export. Either public or private key. * @returns The exported key in JWK format. The key is suitable for * serialization and storage. * @throws {TypeError} If the key is invalid or unsupported. */ async function exportJwk(key) { validateCryptoKey(key); const jwk = await crypto.subtle.exportKey("jwk", key); if (jwk.crv === "Ed25519") jwk.alg = "Ed25519"; return jwk; } /** * Imports a key from JWK format. * @param jwk The key in JWK format. * @param type Which type of key to import, either `"public"` or `"private"`. * @returns The imported key. * @throws {TypeError} If the key is invalid or unsupported. */ async function importJwk(jwk, type) { let key; if (jwk.kty === "RSA" && jwk.alg === "RS256") key = await crypto.subtle.importKey("jwk", jwk, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, true, type === "public" ? ["verify"] : ["sign"]); else if (jwk.kty === "OKP" && jwk.crv === "Ed25519") { if (navigator?.userAgent === "Cloudflare-Workers") { jwk = { ...jwk }; delete jwk.alg; } key = await crypto.subtle.importKey("jwk", jwk, "Ed25519", true, type === "public" ? ["verify"] : ["sign"]); } else throw new TypeError("Unsupported JWK format."); validateCryptoKey(key, type); return key; } async function withFetchKeySpan(keyId, tracerProvider, fetcher) { tracerProvider ??= _opentelemetry_api.trace.getTracerProvider(); return await tracerProvider.getTracer(name, version).startActiveSpan("activitypub.fetch_key", { kind: _opentelemetry_api.SpanKind.CLIENT, attributes: { "http.method": "GET", "url.full": keyId.href, "url.scheme": keyId.protocol.replace(/:$/, ""), "url.domain": keyId.hostname, "url.path": keyId.pathname, "url.query": keyId.search.replace(/^\?/, ""), "url.fragment": keyId.hash.replace(/^#/, "") } }, async (span) => { try { const result = await fetcher(); span.setAttribute("activitypub.actor.key.cached", result.cached); return result; } catch (e) { span.setStatus({ code: _opentelemetry_api.SpanStatusCode.ERROR, message: String(e) }); throw e; } finally { span.end(); } }); } /** * Fetches a {@link CryptographicKey} or {@link Multikey} from the given URL. * If the given URL contains an {@link Actor} object, it tries to find * the corresponding key in the `publicKey` or `assertionMethod` property. * @template T The type of the key to fetch. Either {@link CryptographicKey} * or {@link Multikey}. * @param keyId The URL of the key. * @param cls The class of the key to fetch. Either {@link CryptographicKey} * or {@link Multikey}. * @param options Options for fetching the key. See {@link FetchKeyOptions}. * @returns The fetched key or `null` if the key is not found. * @since 1.3.0 */ function fetchKey(keyId, cls, options = {}) { keyId = typeof keyId === "string" ? new URL(keyId) : keyId; return withFetchKeySpan(keyId, options.tracerProvider, () => fetchKeyInternal(keyId, cls, options)); } /** * Fetches a {@link CryptographicKey} or {@link Multikey} from the given URL, * preserving transport-level fetch failures for callers that need to inspect * why the key could not be loaded. * * @template T The type of the key to fetch. Either {@link CryptographicKey} * or {@link Multikey}. * @param keyId The URL of the key. * @param cls The class of the key to fetch. Either {@link CryptographicKey} * or {@link Multikey}. * @param options Options for fetching the key. * @returns The fetched key, or detailed fetch failure information. * @since 2.1.0 */ async function fetchKeyDetailed(keyId, cls, options = {}) { const cacheKey = typeof keyId === "string" ? new URL(keyId) : keyId; return await withFetchKeySpan(cacheKey, options.tracerProvider, async () => { return await fetchKeyWithResult(cacheKey, cls, options, async (cacheKey, keyId, keyCache, logger) => { const fetchError = await keyCache?.getFetchError?.(cacheKey); if (fetchError != null) { logger.debug("Entry {keyId} found in cache with preserved fetch failure details.", { keyId }); return { key: null, cached: true, fetchError }; } logger.debug("Entry {keyId} found in cache, but no fetch failure details are available.", { keyId }); return { key: null, cached: true }; }, async (error, cacheKey, keyId, keyCache, logger) => { logger.debug("Failed to fetch key {keyId}.", { keyId, error }); await keyCache?.set(cacheKey, null); if (error instanceof _fedify_vocab_runtime.FetchError && error.response != null) { const fetchError = { status: error.response.status, response: error.response.clone() }; await keyCache?.setFetchError?.(cacheKey, fetchError); return { key: null, cached: false, fetchError }; } const fetchError = { error: error instanceof Error ? error : new Error(String(error)) }; await keyCache?.setFetchError?.(cacheKey, fetchError); return { key: null, cached: false, fetchError }; }); }); } async function getCachedFetchKey(cacheKey, keyId, cls, keyCache, logger) { if (keyCache == null) return null; const cachedKey = await keyCache.get(cacheKey); if (cachedKey instanceof cls && cachedKey.publicKey != null) { logger.debug("Key {keyId} found in cache.", { keyId }); return { key: cachedKey, cached: true }; } else if (cachedKey === null) { logger.debug("Entry {keyId} found in cache, but it is unavailable.", { keyId }); return { key: null, cached: true }; } return null; } async function clearFetchErrorMetadata(keyId, keyCache) { await keyCache?.setFetchError?.(keyId, null); } async function resolveFetchedKey(document, cacheKey, keyId, cls, { documentLoader, contextLoader, keyCache, tracerProvider }, logger) { let object; try { object = await _fedify_vocab.Object.fromJsonLd(document, { documentLoader, contextLoader, tracerProvider }); } catch (e) { if (!(e instanceof TypeError)) throw e; try { object = await cls.fromJsonLd(document, { documentLoader, contextLoader, tracerProvider }); } catch (e) { if (e instanceof TypeError) { logger.debug("Failed to verify; key {keyId} returned an invalid object.", { keyId }); await keyCache?.set(cacheKey, null); await clearFetchErrorMetadata(cacheKey, keyCache); return { key: null, cached: false }; } throw e; } } let key = null; if (object instanceof cls) key = object; else if ((0, _fedify_vocab.isActor)(object)) { const keys = cls === _fedify_vocab.CryptographicKey ? object.getPublicKeys({ documentLoader, contextLoader, suppressError: true, tracerProvider }) : object.getAssertionMethods({ documentLoader, contextLoader, suppressError: true, tracerProvider }); let length = 0; let lastKey = null; try { for await (const k of keys) { length++; lastKey = k; if (k.id?.href === keyId) { key = k; break; } } } catch (e) { if (!(e instanceof TypeError)) throw e; logger.debug("Failed to verify; a malformed key was encountered while iterating the keys of {keyId}; treating it as a missing key: {error}", { keyId, error: e }); } const keyIdUrl = new URL(keyId); if (key == null && keyIdUrl.hash === "" && length === 1) key = lastKey; if (key == null) { logger.debug("Failed to verify; object {keyId} returned an {actorType}, but has no key matching {keyId}.", { keyId, actorType: object.constructor.name }); await keyCache?.set(cacheKey, null); await clearFetchErrorMetadata(cacheKey, keyCache); return { key: null, cached: false }; } } else { logger.debug("Failed to verify; key {keyId} returned an invalid object.", { keyId }); await keyCache?.set(cacheKey, null); await clearFetchErrorMetadata(cacheKey, keyCache); return { key: null, cached: false }; } if (key.publicKey == null) { logger.debug("Failed to verify; key {keyId} has no publicKeyPem field.", { keyId }); await keyCache?.set(cacheKey, null); await clearFetchErrorMetadata(cacheKey, keyCache); return { key: null, cached: false }; } if (keyCache != null) { await keyCache.set(cacheKey, key); logger.debug("Key {keyId} cached.", { keyId }); } await clearFetchErrorMetadata(cacheKey, keyCache); return { key, cached: false }; } async function fetchKeyWithResult(cacheKey, cls, options, onCachedUnavailable, onFetchError) { const start = performance.now(); let outcome = { result: "error" }; try { const logger = (0, _logtape_logtape.getLogger)([ "fedify", "sig", "key" ]); const keyId = cacheKey.href; const keyCache = options.keyCache; const cached = await getCachedFetchKey(cacheKey, keyId, cls, keyCache, logger); if (cached?.key === null && cached.cached) { const cachedUnavailable = await onCachedUnavailable(cacheKey, keyId, keyCache, logger); outcome = { result: "hit" }; return cachedUnavailable; } if (cached != null) { outcome = { result: "hit" }; return cached; } logger.debug("Fetching key {keyId} to verify signature...", { keyId }); let document; try { document = (await (options.documentLoader ?? (0, _fedify_vocab_runtime.getDocumentLoader)())(keyId)).document; } catch (error) { const classified = classifyFetchError(error); const errored = await onFetchError(error, cacheKey, keyId, keyCache, logger); outcome = classified; return errored; } const resolved = await resolveFetchedKey(document, cacheKey, keyId, cls, options, logger); outcome = { result: resolved.key != null ? "fetched" : "invalid" }; return resolved; } finally { recordKeyLookup(options.meterProvider, { durationMs: getDurationMs(start), result: outcome.result, remoteUrl: cacheKey, cacheEnabled: options.keyCache != null, statusCode: outcome.statusCode }); } } async function fetchKeyInternal(keyId, cls, options = {}) { return await fetchKeyWithResult(typeof keyId === "string" ? new URL(keyId) : keyId, cls, options, (_cacheKey, _keyId, _keyCache, _logger) => { return { key: null, cached: true }; }, async (error, cacheKey, keyId, keyCache, logger) => { logger.debug("Failed to fetch key {keyId}.", { keyId, error }); await keyCache?.set(cacheKey, null); if (error instanceof _fedify_vocab_runtime.FetchError && error.response != null) await keyCache?.setFetchError?.(cacheKey, { status: error.response.status, response: error.response.clone() }); else await keyCache?.setFetchError?.(cacheKey, { error: error instanceof Error ? error : new Error(String(error)) }); return { key: null, cached: false }; }); } //#endregion //#region src/sig/http.ts const DEFAULT_MAX_REDIRECTION = 20; const DOUBLE_KNOCK_TRANSPORT_RETRY_DELAY_MS = 100; /** * Signs a request using the given private key. * @param request The request to sign. * @param privateKey The private key to use for signing. * @param keyId The key ID to use for the signature. It will be used by the * verifier. * @returns The signed request. * @throws {TypeError} If the private key is invalid or unsupported. */ async function signRequest(request, privateKey, keyId, options = {}) { validateCryptoKey(privateKey, "private"); return await (options.tracerProvider ?? _opentelemetry_api.trace.getTracerProvider()).getTracer(name, version).startActiveSpan("http_signatures.sign", async (span) => { try { const spec = options.spec ?? "draft-cavage-http-signatures-12"; let signed; if (spec === "rfc9421") signed = await signRequestRfc9421(request, privateKey, keyId, span, options.currentTime, options.body, options.rfc9421); else signed = await signRequestDraft(request, privateKey, keyId, span, options.currentTime, options.body); if (span.isRecording()) { span.setAttribute(_opentelemetry_semantic_conventions.ATTR_HTTP_REQUEST_METHOD, signed.method); span.setAttribute(_opentelemetry_semantic_conventions.ATTR_URL_FULL, signed.url); for (const [name, value] of signed.headers) span.setAttribute((0, _opentelemetry_semantic_conventions.ATTR_HTTP_REQUEST_HEADER)(name), value); span.setAttribute("http_signatures.key_id", keyId.href); } return signed; } catch (error) { span.setStatus({ code: _opentelemetry_api.SpanStatusCode.ERROR, message: String(error) }); throw error; } finally { span.end(); } }); } async function signRequestDraft(request, privateKey, keyId, span, currentTime, bodyBuffer) { if (privateKey.algorithm.name !== "RSASSA-PKCS1-v1_5") throw new TypeError("Unsupported algorithm: " + privateKey.algorithm.name); const url = new URL(request.url); const body = bodyBuffer !== void 0 ? bodyBuff