@fedify/webfinger 2.3.0-dev.994 → 2.3.0-pr.809.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lookup.ts CHANGED
@@ -6,6 +6,10 @@ import {
6
6
  } from "@fedify/vocab-runtime";
7
7
  import { getLogger } from "@logtape/logtape";
8
8
  import {
9
+ type Attributes,
10
+ type Counter,
11
+ type Histogram,
12
+ type MeterProvider,
9
13
  SpanKind,
10
14
  SpanStatusCode,
11
15
  trace,
@@ -18,6 +22,114 @@ const logger = getLogger(["fedify", "webfinger", "lookup"]);
18
22
 
19
23
  const DEFAULT_MAX_REDIRECTION = 5;
20
24
 
25
+ /**
26
+ * The terminal classification of an outgoing {@link lookupWebFinger} call,
27
+ * recorded as the `webfinger.lookup.result` attribute on the
28
+ * `webfinger.lookup` counter and `webfinger.lookup.duration` histogram.
29
+ *
30
+ * - `found`: the lookup returned a {@link ResourceDescriptor}.
31
+ * - `not_found`: the remote responded with HTTP `404 Not Found` or
32
+ * `410 Gone`. Recorded together with `http.response.status_code`.
33
+ * - `invalid`: the remote responded with content Fedify could not parse
34
+ * into a {@link ResourceDescriptor} (JSON parse failure), the
35
+ * redirect chain exceeded `maxRedirection`, the remote redirected to
36
+ * a different protocol, or the queried `acct:` resource itself was
37
+ * malformed.
38
+ * - `network_error`: no HTTP response was received (the URL was
39
+ * rejected as a private address, `fetch()` threw, or an `AbortError`
40
+ * cancelled the request).
41
+ * - `error`: the remote responded with a non-2xx HTTP response that is
42
+ * neither `404` nor `410`, or any other unexpected failure bubbled up
43
+ * from the lookup.
44
+ * @since 2.3.0
45
+ */
46
+ export type WebFingerLookupResult =
47
+ | "found"
48
+ | "not_found"
49
+ | "invalid"
50
+ | "network_error"
51
+ | "error";
52
+
53
+ interface WebFingerInstruments {
54
+ lookup: Counter;
55
+ lookupDuration: Histogram;
56
+ }
57
+
58
+ const WEBFINGER_HISTOGRAM_BUCKETS: ReadonlyArray<number> = [
59
+ 5,
60
+ 10,
61
+ 25,
62
+ 50,
63
+ 75,
64
+ 100,
65
+ 250,
66
+ 500,
67
+ 750,
68
+ 1000,
69
+ 2500,
70
+ 5000,
71
+ 7500,
72
+ 10000,
73
+ ];
74
+
75
+ const webFingerInstruments = new WeakMap<MeterProvider, WebFingerInstruments>();
76
+
77
+ function getWebFingerInstruments(
78
+ meterProvider: MeterProvider,
79
+ ): WebFingerInstruments {
80
+ let instruments = webFingerInstruments.get(meterProvider);
81
+ if (instruments == null) {
82
+ const meter = meterProvider.getMeter(metadata.name, metadata.version);
83
+ instruments = {
84
+ lookup: meter.createCounter("webfinger.lookup", {
85
+ description: "Outgoing WebFinger lookup attempts.",
86
+ unit: "{lookup}",
87
+ }),
88
+ lookupDuration: meter.createHistogram("webfinger.lookup.duration", {
89
+ description: "Duration of outgoing WebFinger lookups.",
90
+ unit: "ms",
91
+ advice: { explicitBucketBoundaries: [...WEBFINGER_HISTOGRAM_BUCKETS] },
92
+ }),
93
+ };
94
+ webFingerInstruments.set(meterProvider, instruments);
95
+ }
96
+ return instruments;
97
+ }
98
+
99
+ function getResourceScheme(resource: URL | string): string {
100
+ if (typeof resource === "string") {
101
+ const colon = resource.indexOf(":");
102
+ return colon > 0 ? resource.substring(0, colon).toLowerCase() : "";
103
+ }
104
+ return resource.protocol.replace(/:$/, "").toLowerCase();
105
+ }
106
+
107
+ // The scheme attribute is recorded on the `webfinger.lookup` metric. Even
108
+ // though most call sites pass scheme-controlled resources (Fedify code and
109
+ // library users), `lookupObject()` accepts user-supplied identifiers that
110
+ // flow into here, so the metric attribute is bucketed to the schemes
111
+ // WebFinger / fediverse clients legitimately use (RFC 7565 +
112
+ // ActivityPub). Anything else is bucketed as `other`, keeping metric
113
+ // cardinality bounded even when a remote returns redirects whose target
114
+ // scheme is unusual.
115
+ const WEBFINGER_LOOKUP_SCHEME_WHITELIST: ReadonlySet<string> = new Set([
116
+ "acct",
117
+ "http",
118
+ "https",
119
+ "mailto",
120
+ ]);
121
+
122
+ function getMetricResourceScheme(scheme: string): string {
123
+ return WEBFINGER_LOOKUP_SCHEME_WHITELIST.has(scheme) ? scheme : "other";
124
+ }
125
+
126
+ interface WebFingerLookupOutcome {
127
+ resource: ResourceDescriptor | null;
128
+ result: WebFingerLookupResult;
129
+ statusCode?: number;
130
+ remoteHost?: string;
131
+ }
132
+
21
133
  /**
22
134
  * Options for {@link lookupWebFinger}.
23
135
  * @since 1.3.0
@@ -54,6 +166,16 @@ export interface LookupWebFingerOptions {
54
166
  */
55
167
  tracerProvider?: TracerProvider;
56
168
 
169
+ /**
170
+ * The OpenTelemetry meter provider used to record the `webfinger.lookup`
171
+ * counter and `webfinger.lookup.duration` histogram. If omitted, no
172
+ * metric measurements are emitted (the helper is opt-in to avoid
173
+ * touching the global meter provider for callers that do not use
174
+ * OpenTelemetry).
175
+ * @since 2.3.0
176
+ */
177
+ meterProvider?: MeterProvider;
178
+
57
179
  /**
58
180
  * AbortSignal for cancelling the request.
59
181
  * @since 1.8.0
@@ -77,24 +199,35 @@ export async function lookupWebFinger(
77
199
  metadata.name,
78
200
  metadata.version,
79
201
  );
202
+ const scheme = getResourceScheme(resource);
80
203
  return await tracer.startActiveSpan(
81
204
  "webfinger.lookup",
82
205
  {
83
206
  kind: SpanKind.CLIENT,
84
207
  attributes: {
85
208
  "webfinger.resource": resource.toString(),
86
- "webfinger.resource.scheme": typeof resource === "string"
87
- ? resource.replace(/:.*$/, "")
88
- : resource.protocol.replace(/:$/, ""),
209
+ "webfinger.resource.scheme": scheme,
89
210
  },
90
211
  },
91
212
  async (span) => {
213
+ const meterProvider = options.meterProvider;
214
+ const start = meterProvider == null ? 0 : performance.now();
215
+ // Initialise the outcome with the `error` shape that the `finally`
216
+ // block records when `lookupWebFingerInternal()` itself rejects;
217
+ // the `try` body reassigns this to the actual outcome before any
218
+ // other statement runs, so the `catch` does not need to reassign.
219
+ let outcome: WebFingerLookupOutcome = {
220
+ resource: null,
221
+ result: "error",
222
+ };
92
223
  try {
93
- const result = await lookupWebFingerInternal(resource, options);
224
+ outcome = await lookupWebFingerInternal(resource, options);
94
225
  span.setStatus({
95
- code: result === null ? SpanStatusCode.ERROR : SpanStatusCode.OK,
226
+ code: outcome.resource === null
227
+ ? SpanStatusCode.ERROR
228
+ : SpanStatusCode.OK,
96
229
  });
97
- return result;
230
+ return outcome.resource;
98
231
  } catch (error) {
99
232
  span.setStatus({
100
233
  code: SpanStatusCode.ERROR,
@@ -102,24 +235,73 @@ export async function lookupWebFinger(
102
235
  });
103
236
  throw error;
104
237
  } finally {
238
+ if (meterProvider != null) {
239
+ const durationMs = Math.max(0, performance.now() - start);
240
+ recordWebFingerLookup(meterProvider, durationMs, scheme, outcome);
241
+ }
105
242
  span.end();
106
243
  }
107
244
  },
108
245
  );
109
246
  }
110
247
 
248
+ function recordWebFingerLookup(
249
+ meterProvider: MeterProvider,
250
+ durationMs: number,
251
+ scheme: string,
252
+ outcome: WebFingerLookupOutcome,
253
+ ): void {
254
+ const attributes: Attributes = {
255
+ "webfinger.lookup.result": outcome.result,
256
+ "webfinger.resource.scheme": getMetricResourceScheme(scheme),
257
+ };
258
+ if (outcome.remoteHost != null) {
259
+ attributes["activitypub.remote.host"] = outcome.remoteHost;
260
+ }
261
+ if (outcome.statusCode != null) {
262
+ attributes["http.response.status_code"] = outcome.statusCode;
263
+ }
264
+ const instruments = getWebFingerInstruments(meterProvider);
265
+ instruments.lookup.add(1, attributes);
266
+ instruments.lookupDuration.record(durationMs, attributes);
267
+ }
268
+
111
269
  async function lookupWebFingerInternal(
112
270
  resource: URL | string,
113
271
  options: LookupWebFingerOptions = {},
114
- ): Promise<ResourceDescriptor | null> {
272
+ ): Promise<WebFingerLookupOutcome> {
115
273
  if (typeof resource === "string") resource = new URL(resource);
116
274
  let protocol = "https:";
117
275
  let server: string;
118
- if (resource.protocol === "acct:") {
276
+ if (resource.protocol === "acct:" || resource.protocol === "mailto:") {
277
+ // `acct:` (RFC 7565) and `mailto:` (RFC 6068, used as a WebFinger
278
+ // resource per RFC 7033 §4.5) are opaque-path schemes: their
279
+ // `user@host` authority lives in `pathname`, not in `host`. The
280
+ // WebFinger host is extracted from the substring after the last
281
+ // `@`, and the lookup always goes to https on that host.
119
282
  const atPos = resource.pathname.lastIndexOf("@");
120
- if (atPos < 0) return null;
283
+ if (atPos < 0) return { resource: null, result: "invalid" };
121
284
  server = resource.pathname.substring(atPos + 1);
122
- if (server === "") return null;
285
+ // The authority part of both schemes must be a bare host: no
286
+ // path, query, or fragment characters embedded in it. The
287
+ // WHATWG URL parser routes anything after the first `?` or `#`
288
+ // into `search` / `hash`, so by the time we read `pathname` the
289
+ // only stray characters that can land in `server` are slashes.
290
+ // Reject those (along with an empty authority) for both schemes.
291
+ if (server === "" || /[/?#]/.test(server)) {
292
+ return { resource: null, result: "invalid" };
293
+ }
294
+ // `acct:` (RFC 7565 §3) is bare authority only: no `search` or
295
+ // `hash` allowed. `mailto:` (RFC 6068 §2) explicitly permits
296
+ // `?hfields=…` header fields and fragment identifiers, so we
297
+ // forward those to the remote WebFinger lookup unchanged and
298
+ // only enforce the stricter shape for `acct:`.
299
+ if (
300
+ resource.protocol === "acct:" &&
301
+ (resource.search !== "" || resource.hash !== "")
302
+ ) {
303
+ return { resource: null, result: "invalid" };
304
+ }
123
305
  } else {
124
306
  protocol = resource.protocol;
125
307
  server = resource.host;
@@ -128,6 +310,7 @@ async function lookupWebFingerInternal(
128
310
  url.searchParams.set("resource", resource.href);
129
311
  let redirected = 0;
130
312
  while (true) {
313
+ const remoteHost = url.host;
131
314
  logger.debug(
132
315
  "Fetching WebFinger resource descriptor from {url}...",
133
316
  { url: url.href },
@@ -142,7 +325,7 @@ async function lookupWebFingerInternal(
142
325
  "Invalid URL for WebFinger resource descriptor: {error}",
143
326
  { error: e },
144
327
  );
145
- return null;
328
+ return { resource: null, result: "network_error", remoteHost };
146
329
  }
147
330
  throw e;
148
331
  }
@@ -163,7 +346,7 @@ async function lookupWebFingerInternal(
163
346
  "Failed to fetch WebFinger resource descriptor: {error}",
164
347
  { url: url.href, error },
165
348
  );
166
- return null;
349
+ return { resource: null, result: "network_error", remoteHost };
167
350
  }
168
351
  if (
169
352
  response.status >= 300 && response.status < 400 &&
@@ -171,18 +354,44 @@ async function lookupWebFingerInternal(
171
354
  ) {
172
355
  redirected++;
173
356
  const maxRedirection = options.maxRedirection ?? DEFAULT_MAX_REDIRECTION;
174
- if (redirected >= maxRedirection) {
357
+ // `maxRedirection: N` is documented as "the maximum number of
358
+ // redirections to follow", so the Nth redirect must still be
359
+ // followed and the (N+1)th rejected. An earlier version used
360
+ // `>=` here, which drifted by one from the documented semantics
361
+ // and from the sibling code in @fedify/vocab-runtime's document
362
+ // loader.
363
+ if (redirected > maxRedirection) {
175
364
  logger.error(
176
365
  "Too many redirections ({redirections}) while fetching WebFinger " +
177
366
  "resource descriptor.",
178
367
  { redirections: redirected },
179
368
  );
180
- return null;
369
+ return {
370
+ resource: null,
371
+ result: "invalid",
372
+ statusCode: response.status,
373
+ remoteHost,
374
+ };
375
+ }
376
+ let redirectedUrl: URL;
377
+ try {
378
+ redirectedUrl = new URL(
379
+ response.headers.get("Location")!,
380
+ response.url == null || response.url === "" ? url : response.url,
381
+ );
382
+ } catch (e) {
383
+ logger.error(
384
+ "Invalid Location header while following WebFinger redirect: " +
385
+ "{error}",
386
+ { url: url.href, error: e },
387
+ );
388
+ return {
389
+ resource: null,
390
+ result: "invalid",
391
+ statusCode: response.status,
392
+ remoteHost,
393
+ };
181
394
  }
182
- const redirectedUrl = new URL(
183
- response.headers.get("Location")!,
184
- response.url == null || response.url === "" ? url : response.url,
185
- );
186
395
  if (redirectedUrl.protocol !== url.protocol) {
187
396
  logger.error(
188
397
  "Redirected to a different protocol ({protocol} to " +
@@ -193,7 +402,12 @@ async function lookupWebFingerInternal(
193
402
  redirectedProtocol: redirectedUrl.protocol,
194
403
  },
195
404
  );
196
- return null;
405
+ return {
406
+ resource: null,
407
+ result: "invalid",
408
+ statusCode: response.status,
409
+ remoteHost,
410
+ };
197
411
  }
198
412
  url = redirectedUrl;
199
413
  continue;
@@ -207,17 +421,34 @@ async function lookupWebFingerInternal(
207
421
  statusText: response.statusText,
208
422
  },
209
423
  );
210
- return null;
424
+ const isNotFound = response.status === 404 || response.status === 410;
425
+ return {
426
+ resource: null,
427
+ result: isNotFound ? "not_found" : "error",
428
+ statusCode: response.status,
429
+ remoteHost,
430
+ };
211
431
  }
212
432
  try {
213
- return await response.json() as ResourceDescriptor;
433
+ const parsed = await response.json() as ResourceDescriptor;
434
+ return {
435
+ resource: parsed,
436
+ result: "found",
437
+ statusCode: response.status,
438
+ remoteHost,
439
+ };
214
440
  } catch (e) {
215
441
  if (e instanceof SyntaxError) {
216
442
  logger.debug(
217
443
  "Failed to parse WebFinger resource descriptor as JSON: {error}",
218
444
  { error: e },
219
445
  );
220
- return null;
446
+ return {
447
+ resource: null,
448
+ result: "invalid",
449
+ statusCode: response.status,
450
+ remoteHost,
451
+ };
221
452
  }
222
453
  throw e;
223
454
  }