@fedify/fedify
Version:
An ActivityPub server framework
1,381 lines • 95.7 kB
JavaScript
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