diff --git a/CHANGES.md b/CHANGES.md index 343bbda66..65e7c7423 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -47,11 +47,17 @@ To be released. every declared size instead of only the first, so an icon offering a large size is no longer discarded because its smallest size is too small. [[#893], [#1187] by Lee Jeongmin\] + - Changed the `fedify webfinger` command's error message when a lookup + fails. It now explains why—resource not found, a network error, a + malformed response, or an unexpected server error—instead of always + reporting the resource as not found. [[#897], [#1296] by Jae-Hyuk-Jang\] [#893]: https://github.com/fedify-dev/fedify/issues/893 +[#897]: https://github.com/fedify-dev/fedify/issues/897 [#901]: https://github.com/fedify-dev/fedify/issues/901 [#1187]: https://github.com/fedify-dev/fedify/pull/1187 [#1265]: https://github.com/fedify-dev/fedify/pull/1265 +[#1296]: https://github.com/fedify-dev/fedify/pull/1296 ### @fedify/debugger @@ -182,6 +188,15 @@ To be released. [#1210]: https://github.com/fedify-dev/fedify/issues/1210 [#1215]: https://github.com/fedify-dev/fedify/pull/1215 +### @fedify/webfinger + + - Added a `withDiagnostics` option to `lookupWebFinger()`. When turned on, + the function returns an object describing why the lookup failed—a + network error, a malformed response, an unexpected server error, or the + resource genuinely not existing—instead of returning `null` for every + failure. `Context.lookupWebFinger()` does not support this option. + [[#897], [#1296] by Jae-Hyuk-Jang\] + Version 2.4.4 ------------- diff --git a/changes.d/cli/webfinger-lookup-error-detail.md b/changes.d/cli/webfinger-lookup-error-detail.md new file mode 100644 index 000000000..e06a65d0d --- /dev/null +++ b/changes.d/cli/webfinger-lookup-error-detail.md @@ -0,0 +1,9 @@ +--- +links: + '#1296': https://github.com/fedify-dev/fedify/pull/1296 + '#897': https://github.com/fedify-dev/fedify/issues/897 +--- + - Changed the `fedify webfinger` command's error message when a lookup + fails. It now explains why—resource not found, a network error, a + malformed response, or an unexpected server error—instead of always + reporting the resource as not found. [[#897], [#1296] by Jae-Hyuk-Jang] diff --git a/changes.d/webfinger/lookup-diagnostics.md b/changes.d/webfinger/lookup-diagnostics.md new file mode 100644 index 000000000..ffa99bd50 --- /dev/null +++ b/changes.d/webfinger/lookup-diagnostics.md @@ -0,0 +1,11 @@ +--- +links: + '#1296': https://github.com/fedify-dev/fedify/pull/1296 + '#897': https://github.com/fedify-dev/fedify/issues/897 +--- + - Added a `withDiagnostics` option to `lookupWebFinger()`. When turned on, + the function returns an object describing why the lookup failed—a + network error, a malformed response, an unexpected server error, or the + resource genuinely not existing—instead of returning `null` for every + failure. `Context.lookupWebFinger()` does not support this option. + [[#897], [#1296] by Jae-Hyuk-Jang] diff --git a/docs/manual/webfinger.md b/docs/manual/webfinger.md index d063d2b09..8a17bd789 100644 --- a/docs/manual/webfinger.md +++ b/docs/manual/webfinger.md @@ -393,6 +393,65 @@ The available options are: `signal` : An `AbortSignal` for cancelling the request. +`withDiagnostics` +: *Since Fedify 2.5.0.* Whether to return a `WebFingerLookupOutcome` + describing why the lookup failed, instead of collapsing every failure + into `null`. Turned off by default. See + [*Diagnosing a failed lookup*](#diagnosing-a-failed-lookup) below. + +### Diagnosing a failed lookup + +*This option is available since Fedify 2.5.0.* + +By default, `lookupWebFinger()` returns `null` for every kind of failure: +a response saying the resource doesn't exist looks the same as a network +error or a malformed response. Pass `withDiagnostics: true` to get back a +`WebFingerLookupOutcome` instead, which explains why the lookup failed: + +~~~~ typescript twoslash +import { lookupWebFinger } from "@fedify/webfinger"; + +const outcome = await lookupWebFinger("acct:alice@example.com", { + withDiagnostics: true, +}); + +if (outcome.resource != null) { + console.log("Found:", outcome.resource.subject); +} else { + console.log("Failed:", outcome.result); +} +~~~~ + +The `result` field is one of: + +`"found"` +: The lookup returned a resource descriptor. + +`"not_found"` +: The remote responded with `404 Not Found` or `410 Gone`. + +`"invalid"` +: The remote responded with content Fedify could not parse into a + resource descriptor, or the queried resource was malformed. + +`"network_error"` +: No HTTP response was received, e.g., the URL was rejected as a private + address, or the request was aborted. + +`"error"` +: The remote responded with an unexpected non-2xx HTTP response. + +`withDiagnostics: true` classifies the failures listed above into a +`WebFingerLookupOutcome`, but it does not turn every failure into one. +An error `lookupWebFinger()` does not recognize—for example, one while +reading the response body—still rejects the returned promise, so +callers must keep handling a rejected promise even with this option +turned on. + +> [!NOTE] +> `~Context.lookupWebFinger()` does not support `withDiagnostics`; it always +> returns the resource descriptor or `null`. + > [!TIP] > Use `@fedify/webfinger` when you need a lightweight WebFinger client without > the full Fedify framework. If you're already using Fedify, prefer diff --git a/packages/cli/src/portable.test.ts b/packages/cli/src/portable.test.ts index 7ccdfd1fe..d1788bccc 100644 --- a/packages/cli/src/portable.test.ts +++ b/packages/cli/src/portable.test.ts @@ -36,6 +36,7 @@ import { import { matchesActor } from "./utils.ts"; import { linksToActor, lookupPortableWebFinger } from "./webfinger/action.ts"; import { webFingerCommand } from "./webfinger/command.ts"; +import { WebFingerLookupError } from "./webfinger/error.ts"; const keyPair = await crypto.subtle.generateKey("Ed25519", true, [ "sign", @@ -901,6 +902,35 @@ test("lookupPortableWebFinger() uses the first gateway of the actor", async () = } }); +test("lookupPortableWebFinger() preserves WebFinger diagnostics", async () => { + const gateway = await startGateway(); + fetchMock.spyGlobal(); + const resource = `ap://${did}/actor?@gateway=${ + encodeURIComponent(gateway.url.origin) + }`; + fetchMock.get(`begin:https://${gateway.url.host}/.well-known/webfinger`, { + status: 500, + }); + try { + await serveFixtures(gateway); + await assert.rejects( + lookupPortableWebFinger({ + resource, + allowPrivateAddresses: true, + }), + (error: unknown) => { + assert.ok(error instanceof WebFingerLookupError); + assert.equal(error.result, "error"); + return true; + }, + ); + } finally { + fetchMock.unmockGlobal(); + fetchMock.removeRoutes(); + await gateway.close(); + } +}); + test("linksToActor() checks the first ActivityStreams self link", () => { const actorId = portableId("/actor"); const compatible = diff --git a/packages/cli/src/webfinger/action.ts b/packages/cli/src/webfinger/action.ts index 2d15143fd..ce8c108c4 100644 --- a/packages/cli/src/webfinger/action.ts +++ b/packages/cli/src/webfinger/action.ts @@ -22,6 +22,7 @@ import { NotFoundError, PortableActorError, UnlinkedPortableActorError, + WebFingerLookupError, } from "./error.ts"; import { convertUrlIfHandle } from "./lib.ts"; @@ -96,11 +97,15 @@ export async function lookupSingleWebFinger( options: LookupSingleWebFingerOptions, ): Promise { const url = convertUrlIfHandle(options.resource); - const webFinger = await lookupWebFinger( - url, - getLookupWebFingerOptions(options), - ) ?? new NotFoundError(options.resource).throw(); - return webFinger; + const outcome = await lookupWebFinger(url, { + ...getLookupWebFingerOptions(options), + withDiagnostics: true, + }); + return outcome.resource ?? ( + outcome.result === "not_found" + ? new NotFoundError(options.resource).throw() + : new WebFingerLookupError(options.resource, outcome.result).throw() + ); } /** @@ -119,6 +124,9 @@ export async function lookupSingleWebFinger( * @throws {PortableActorError} If the actor cannot be looked up, or does * not have a WebFinger address. * @throws {NotFoundError} If the WebFinger address is not found. + * @throws {WebFingerLookupError} If the WebFinger lookup failed for another + * reason, such as a network failure or a + * malformed response. */ export async function lookupPortableWebFinger( options: LookupSingleWebFingerOptions, @@ -168,10 +176,15 @@ export async function lookupPortableWebFinger( ); } const address = `acct:${username}@${gateway.host}`; - const descriptor = await lookupWebFinger( - address, - getLookupWebFingerOptions(options), - ) ?? new NotFoundError(address).throw(); + const outcome = await lookupWebFinger(address, { + ...getLookupWebFingerOptions(options), + withDiagnostics: true, + }); + const descriptor = outcome.resource ?? ( + outcome.result === "not_found" + ? new NotFoundError(address).throw() + : new WebFingerLookupError(address, outcome.result).throw() + ); return { descriptor, address, actorId: actor.id }; } diff --git a/packages/cli/src/webfinger/error.ts b/packages/cli/src/webfinger/error.ts index 003adee40..518cc6051 100644 --- a/packages/cli/src/webfinger/error.ts +++ b/packages/cli/src/webfinger/error.ts @@ -1,4 +1,5 @@ import { type Message, message } from "@optique/core"; +import type { WebFingerLookupResult } from "@fedify/webfinger"; /** * Generates a user-friendly error message based on the type of error @@ -16,6 +17,12 @@ export const getErrorMessage = (resource: string, error: unknown): Message => ? message`${error.address} does not link back to the portable actor ${error.actorId}, so it is not verified to be the actor's WebFinger address:` : error instanceof NotFoundError ? message`Resource not found: ${error.resource}` + : error instanceof WebFingerLookupError + ? error.result === "network_error" + ? message`Failed to look up WebFinger for ${error.resource}: a network error occurred.` + : error.result === "invalid" + ? message`Failed to look up WebFinger for ${error.resource}: the server returned a response Fedify could not understand.` + : message`Failed to look up WebFinger for ${error.resource}: the server returned an unexpected error.` : error instanceof Error ? message`Failed to look up WebFinger for ${resource}: ${error.message}` : message`Failed to look up WebFinger for ${resource}: ${String(error)}`; @@ -50,6 +57,26 @@ export class NotFoundError extends Error { } } +/** + * Custom error class for a WebFinger lookup that failed for a reason other + * than the resource simply not existing, such as a network failure, a + * server error, or a response Fedify could not parse. + * @param resource The resource that could not be looked up. + * @param result Why the lookup failed. + * @extends {Error} + */ +export class WebFingerLookupError extends Error { + constructor(public resource: string, public result: WebFingerLookupResult) { + super( + `Failed to look up WebFinger for ${resource}: result was "${result}".`, + ); + this.name = "WebFingerLookupError"; + } + throw(): never { + throw this; + } +} + /** * Custom error class for [FEP-ef61] portable actors whose WebFinger address * cannot be determined. diff --git a/packages/cli/src/webfinger/mod.test.ts b/packages/cli/src/webfinger/mod.test.ts index 8a23873c3..701be7586 100644 --- a/packages/cli/src/webfinger/mod.test.ts +++ b/packages/cli/src/webfinger/mod.test.ts @@ -1,3 +1,4 @@ +import { formatMessage } from "@optique/core/message"; import { parse } from "@optique/core/parser"; import { run } from "@optique/run"; import assert from "node:assert/strict"; @@ -6,6 +7,11 @@ import test from "node:test"; import { type Config, configContext } from "../config.ts"; import runWebFinger, { lookupSingleWebFinger } from "./action.ts"; import { webFingerCommand } from "./command.ts"; +import { + getErrorMessage, + NotFoundError, + WebFingerLookupError, +} from "./error.ts"; const COMMAND = "webfinger"; const USER_AGENT = "MyUserAgent/1.0"; @@ -236,3 +242,83 @@ test("Test lookupSingleWebFinger", async (): Promise => { globalThis.fetch = originalFetch; } }); + +// ---------------------------------------------------------------------- +// FIX FOR ISSUE #897 – DIFFERENTIATE WHY A WEBFINGER LOOKUP FAILED +// ---------------------------------------------------------------------- + +for ( + const { name, response, expectedResult, expectedText } of [ + { + name: "404 is still reported as not found", + response: () => new Response(null, { status: 404 }), + expectedResult: null, + expectedText: 'Resource not found: "acct:alice@example.com"', + }, + { + name: "a server error is distinguished from not found", + response: () => new Response(null, { status: 500 }), + expectedResult: "error", + expectedText: "the server returned an unexpected error", + }, + { + name: "a malformed response is distinguished from not found", + response: () => new Response("not json", { status: 200 }), + expectedResult: "invalid", + expectedText: + "the server returned a response Fedify could not understand", + }, + ] satisfies { + name: string; + response: () => Response; + expectedResult: "error" | "invalid" | null; + expectedText: string; + }[] +) { + test(`Test lookupSingleWebFinger - ${name}`, async () => { + const resource = "acct:alice@example.com"; + const originalFetch = globalThis.fetch; + globalThis.fetch = () => Promise.resolve(response()); + try { + await assert.rejects( + () => lookupSingleWebFinger({ resource, allowPrivateAddresses: true }), + (error: unknown) => { + if (expectedResult == null) { + assert.ok(error instanceof NotFoundError); + } else { + assert.ok(error instanceof WebFingerLookupError); + assert.strictEqual(error.result, expectedResult); + } + const text = formatMessage(getErrorMessage(resource, error)); + assert.ok(text.includes(expectedText), text); + return true; + }, + ); + } finally { + globalThis.fetch = originalFetch; + } + }); +} + +test("Test lookupSingleWebFinger - a network failure is distinguished from not found", async () => { + const resource = "acct:alice@example.com"; + const originalFetch = globalThis.fetch; + // Mocked lookups skip DNS by passing allowPrivateAddresses, same as the + // upstream fix for issue #1273 (0bece1f8e): a real DNS lookup for a public + // hostname should not be part of a unit test. + globalThis.fetch = () => Promise.reject(new TypeError("network down")); + try { + await assert.rejects( + () => lookupSingleWebFinger({ resource, allowPrivateAddresses: true }), + (error: unknown) => { + assert.ok(error instanceof WebFingerLookupError); + assert.strictEqual(error.result, "network_error"); + const text = formatMessage(getErrorMessage(resource, error)); + assert.ok(text.includes("a network error occurred"), text); + return true; + }, + ); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/packages/fedify/src/federation/context.ts b/packages/fedify/src/federation/context.ts index 04a1670a8..d52347563 100644 --- a/packages/fedify/src/federation/context.ts +++ b/packages/fedify/src/federation/context.ts @@ -710,7 +710,9 @@ export interface Context { * Looks up a WebFinger resource. * * It's almost the same as the {@link lookupWebFinger} function, but it uses - * the context's configuration by default. + * the context's configuration by default. Unlike {@link lookupWebFinger}, + * it does not support the `withDiagnostics` option; it always returns the + * resource descriptor or `null`. * * @param resource The resource URL to look up. * @param options Extra options for looking up the resource. diff --git a/packages/fedify/src/federation/middleware.test.ts b/packages/fedify/src/federation/middleware.test.ts index b3ce8c5c6..741b26cb3 100644 --- a/packages/fedify/src/federation/middleware.test.ts +++ b/packages/fedify/src/federation/middleware.test.ts @@ -16,6 +16,7 @@ import { Person, } from "@fedify/vocab"; import { FetchError, getDocumentLoader, UrlError } from "@fedify/vocab-runtime"; +import type { LookupWebFingerOptions } from "@fedify/webfinger"; import { configure, type LogRecord, @@ -12809,6 +12810,41 @@ test("ContextImpl.enqueueTask()", async (t) => { ); }); +test("ContextImpl.lookupWebFinger()", async (t) => { + await t.step( + "always returns a plain ResourceDescriptor or null, even if " + + "options structurally contains withDiagnostics", + async () => { + fetchMock.spyGlobal(); + try { + const expected = { subject: "acct:alice@example.com", links: [] }; + fetchMock.get( + "https://example.com/.well-known/webfinger?resource=acct%3Aalice%40example.com", + { body: expected }, + ); + const federation = createFederation(mockOptions()); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + // `Context.lookupWebFinger()`'s declared options type does not + // name `withDiagnostics`, but nothing stops a caller from + // assigning a value that structurally has it to a variable typed + // as `LookupWebFingerOptions` and passing that in. This + // simulates that bypass of the type-level check. + const sneaky = { withDiagnostics: true } as LookupWebFingerOptions; + const result = await ctx.lookupWebFinger( + "acct:alice@example.com", + sneaky, + ); + deepStrictEqual(result, expected); + } finally { + fetchMock.hardReset(); + } + }, + ); +}); + test("ContextImpl.enqueueTaskMany()", async (t) => { await t.step( "round-trips every payload through enqueueMany in order, forwarding options", diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index 53f9648c5..fbaef2b8e 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -5416,6 +5416,13 @@ export class ContextImpl implements Context { // fallback. The metric helper is opt-in by design. meterProvider: options.meterProvider ?? this.federation._meterProvider, allowPrivateAddress: this.federation.allowPrivateAddress, + // `options` may structurally contain `withDiagnostics: true` even + // though its declared type does not name that field (e.g., a value + // typed as `LookupWebFingerOptions & { withDiagnostics: true }` + // assigned to a variable before being passed in). Force it to + // `false` last, after the spread, so this method's return type + // always matches its declared `ResourceDescriptor | null`. + withDiagnostics: false, }); } diff --git a/packages/webfinger/src/lookup.test.ts b/packages/webfinger/src/lookup.test.ts index b5c24483b..c9c929bcb 100644 --- a/packages/webfinger/src/lookup.test.ts +++ b/packages/webfinger/src/lookup.test.ts @@ -6,7 +6,7 @@ import fetchMock from "fetch-mock"; import { deepStrictEqual, ok } from "node:assert/strict"; import dns from "node:dns/promises"; import type { ResourceDescriptor } from "./jrd.ts"; -import { lookupWebFinger } from "./lookup.ts"; +import { lookupWebFinger, type LookupWebFingerOptions } from "./lookup.ts"; test({ name: "lookupWebFinger()", @@ -989,3 +989,167 @@ test("lookupWebFinger() logs DNS failures as such", async (t) => { }); } }); + +test("lookupWebFinger() with withDiagnostics: true", { + sanitizeOps: false, + sanitizeResources: false, +}, async (t) => { + // No fetch mock is active yet, so this reaches the real (non-existent) + // host and fails at the network layer, the same way the "connection + // refused" step above does. + await t.step("network_error", async () => { + const outcome = await lookupWebFinger( + "acct:johndoe@fedify-test.internal", + { withDiagnostics: true }, + ); + deepStrictEqual(outcome.resource, null); + deepStrictEqual(outcome.result, "network_error"); + deepStrictEqual( + "statusCode" in outcome, + false, + "no HTTP response means no status code", + ); + }); + + fetchMock.spyGlobal(); + try { + await t.step("found", async () => { + fetchMock.removeRoutes(); + const expected: ResourceDescriptor = { + subject: "acct:johndoe@example.com", + links: [], + }; + fetchMock.get( + "https://example.com/.well-known/webfinger?resource=acct%3Ajohndoe%40example.com", + { body: expected }, + ); + const outcome = await lookupWebFinger("acct:johndoe@example.com", { + withDiagnostics: true, + }); + deepStrictEqual(outcome.resource, expected); + deepStrictEqual(outcome.result, "found"); + deepStrictEqual(outcome.statusCode, 200); + deepStrictEqual(outcome.remoteHost, "example.com"); + }); + + await t.step("not_found", async () => { + fetchMock.removeRoutes(); + fetchMock.get( + "begin:https://example.com/.well-known/webfinger?", + { status: 404 }, + ); + const outcome = await lookupWebFinger("acct:johndoe@example.com", { + withDiagnostics: true, + }); + deepStrictEqual(outcome.resource, null); + deepStrictEqual(outcome.result, "not_found"); + deepStrictEqual(outcome.statusCode, 404); + deepStrictEqual(outcome.remoteHost, "example.com"); + }); + + await t.step("invalid", async () => { + fetchMock.removeRoutes(); + fetchMock.get( + "begin:https://example.com/.well-known/webfinger?", + { body: "not json" }, + ); + const outcome = await lookupWebFinger("acct:johndoe@example.com", { + withDiagnostics: true, + }); + deepStrictEqual(outcome.resource, null); + deepStrictEqual(outcome.result, "invalid"); + deepStrictEqual(outcome.statusCode, 200); + deepStrictEqual(outcome.remoteHost, "example.com"); + }); + + await t.step( + "invalid: a literal JSON null body is not a resource descriptor", + async () => { + // JSON.parse("null") succeeds and yields the JS value `null`. + // Regression test for a server that returns 200 with a literal + // `null` body instead of an object: this must not be classified + // as "found" with a null resource. + fetchMock.removeRoutes(); + fetchMock.get( + "begin:https://example.com/.well-known/webfinger?", + { body: "null" }, + ); + const outcome = await lookupWebFinger("acct:johndoe@example.com", { + withDiagnostics: true, + }); + deepStrictEqual(outcome.resource, null); + deepStrictEqual(outcome.result, "invalid"); + deepStrictEqual(outcome.statusCode, 200); + deepStrictEqual(outcome.remoteHost, "example.com"); + }, + ); + + await t.step( + "invalid: a JSON array body is not a resource descriptor", + async () => { + // typeof [] === "object" and [] !== null, so the null-only guard + // above does not catch an array body; it needs its own check. + fetchMock.removeRoutes(); + fetchMock.get( + "begin:https://example.com/.well-known/webfinger?", + { body: "[]" }, + ); + const outcome = await lookupWebFinger("acct:johndoe@example.com", { + withDiagnostics: true, + }); + deepStrictEqual(outcome.resource, null); + deepStrictEqual(outcome.result, "invalid"); + deepStrictEqual(outcome.statusCode, 200); + deepStrictEqual(outcome.remoteHost, "example.com"); + }, + ); + + await t.step("error", async () => { + fetchMock.removeRoutes(); + fetchMock.get( + "begin:https://example.com/.well-known/webfinger?", + { status: 500 }, + ); + const outcome = await lookupWebFinger("acct:johndoe@example.com", { + withDiagnostics: true, + }); + deepStrictEqual(outcome.resource, null); + deepStrictEqual(outcome.result, "error"); + deepStrictEqual(outcome.statusCode, 500); + deepStrictEqual(outcome.remoteHost, "example.com"); + }); + } finally { + fetchMock.removeRoutes(); + fetchMock.hardReset(); + } +}); + +test( + "lookupWebFinger() accepts a plain LookupWebFingerOptions-typed variable", + async () => { + // Regression test: a variable typed as `LookupWebFingerOptions`, built + // separately from the call site and never mentioning `withDiagnostics`, + // must still match the default overload. This is a type-level + // concern; the point of this test is that the file compiles at all. + fetchMock.spyGlobal(); + try { + const expected: ResourceDescriptor = { + subject: "acct:johndoe@example.com", + links: [], + }; + fetchMock.get( + "https://example.com/.well-known/webfinger?resource=acct%3Ajohndoe%40example.com", + { body: expected }, + ); + const options: LookupWebFingerOptions = { userAgent: "test-agent" }; + const result = await lookupWebFinger( + "acct:johndoe@example.com", + options, + ); + deepStrictEqual(result, expected); + } finally { + fetchMock.removeRoutes(); + fetchMock.hardReset(); + } + }, +); diff --git a/packages/webfinger/src/lookup.ts b/packages/webfinger/src/lookup.ts index 9a5945dd0..fdc70b284 100644 --- a/packages/webfinger/src/lookup.ts +++ b/packages/webfinger/src/lookup.ts @@ -124,10 +124,33 @@ function getMetricResourceScheme(scheme: string): string { return WEBFINGER_LOOKUP_SCHEME_WHITELIST.has(scheme) ? scheme : "other"; } -interface WebFingerLookupOutcome { +/** + * The outcome of a {@link lookupWebFinger} call, returned in place of the + * plain {@link ResourceDescriptor} when `options.withDiagnostics` is `true`. + * @since 2.5.0 + */ +export interface WebFingerLookupOutcome { + /** + * The resource descriptor fetched from the remote WebFinger endpoint, or + * `null` if the lookup did not succeed. + */ resource: ResourceDescriptor | null; + + /** + * The terminal classification of the lookup. See + * {@link WebFingerLookupResult} for what each value means. + */ result: WebFingerLookupResult; + + /** + * The HTTP status code of the final response, if a response was received + * from the remote server. + */ statusCode?: number; + + /** + * The host of the final URL the lookup attempted to reach. + */ remoteHost?: string; } @@ -193,8 +216,42 @@ export interface LookupWebFingerOptions { */ export async function lookupWebFinger( resource: URL | string, - options: LookupWebFingerOptions = {}, -): Promise { + options?: LookupWebFingerOptions & { + /** + * Whether to return a {@link WebFingerLookupOutcome} describing why + * the lookup failed, instead of collapsing every failure into `null`. + * Turned off by default. + * @since 2.5.0 + */ + withDiagnostics?: false; + }, +): Promise; + +/** + * Looks up a WebFinger resource, returning diagnostics that explain why the + * lookup failed instead of collapsing every failure into `null`. + * @param resource The resource URL to look up. + * @param options Extra options for looking up the resource. Must set + * `withDiagnostics: true`. + * @returns The outcome of the lookup. + * @since 2.5.0 + */ +export async function lookupWebFinger( + resource: URL | string, + options: LookupWebFingerOptions & { + /** + * Returns a {@link WebFingerLookupOutcome} instead of a plain + * {@link ResourceDescriptor}. + * @since 2.5.0 + */ + withDiagnostics: true; + }, +): Promise; + +export async function lookupWebFinger( + resource: URL | string, + options: LookupWebFingerOptions & { withDiagnostics?: boolean } = {}, +): Promise { const tracerProvider = options.tracerProvider ?? trace.getTracerProvider(); const tracer = tracerProvider.getTracer( metadata.name, @@ -228,7 +285,7 @@ export async function lookupWebFinger( ? SpanStatusCode.ERROR : SpanStatusCode.OK, }); - return outcome.resource; + return options.withDiagnostics ? outcome : outcome.resource; } catch (error) { span.setStatus({ code: SpanStatusCode.ERROR, @@ -438,11 +495,24 @@ async function lookupWebFingerInternal( }; } try { - const parsed = JSON.parse( + const parsed: unknown = JSON.parse( await readBoundedText(response, MAX_BODY_SIZE, url), - ) as ResourceDescriptor; + ); + // A body that parses to anything other than a JSON object—such as + // a literal `null`, a number, or an array—is not a resource + // descriptor, even though JSON.parse() accepts it without error. + if ( + typeof parsed !== "object" || parsed === null || Array.isArray(parsed) + ) { + return { + resource: null, + result: "invalid", + statusCode: response.status, + remoteHost, + }; + } return { - resource: parsed, + resource: parsed as ResourceDescriptor, result: "found", statusCode: response.status, remoteHost,