From aa8ac015d411f8609192fb9243bbf23f287aaa99 Mon Sep 17 00:00:00 2001 From: Jae-Hyuk-Jang Date: Mon, 12 Oct 2026 17:18:09 +0900 Subject: [PATCH 01/11] Add diagnostics option to WebFinger lookups lookupWebFinger() already classified every failure into "not_found", "invalid", "network_error", or "error" for its OpenTelemetry metrics, but discarded that classification before returning to the caller, so every failure collapsed into a bare `null`. Add an opt-in `withDiagnostics` option that returns the existing internal classification instead, through a function overload so every current caller keeps receiving `ResourceDescriptor | null` unchanged. Document the new option and its `result` values in the manual. https://github.com/fedify-dev/fedify/issues/897 Assisted-by: Claude Code:claude-sonnet-5 --- changes.d/webfinger/lookup-diagnostics.md | 11 +++ docs/manual/webfinger.md | 52 +++++++++++++ packages/webfinger/src/lookup.test.ts | 92 +++++++++++++++++++++++ packages/webfinger/src/lookup.ts | 58 +++++++++++++- 4 files changed, 210 insertions(+), 3 deletions(-) create mode 100644 changes.d/webfinger/lookup-diagnostics.md diff --git a/changes.d/webfinger/lookup-diagnostics.md b/changes.d/webfinger/lookup-diagnostics.md new file mode 100644 index 000000000..dcc1dcb13 --- /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, or the resource genuinely not + existing—instead of returning `null` for every failure. + `Context.lookupWebFinger()` does not support this option. + [[#897], [#1296]] diff --git a/docs/manual/webfinger.md b/docs/manual/webfinger.md index d063d2b09..cdc3d0c91 100644 --- a/docs/manual/webfinger.md +++ b/docs/manual/webfinger.md @@ -393,6 +393,58 @@ 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. + +> [!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/webfinger/src/lookup.test.ts b/packages/webfinger/src/lookup.test.ts index b5c24483b..56008393a 100644 --- a/packages/webfinger/src/lookup.test.ts +++ b/packages/webfinger/src/lookup.test.ts @@ -989,3 +989,95 @@ 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("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(); + } +}); diff --git a/packages/webfinger/src/lookup.ts b/packages/webfinger/src/lookup.ts index 9a5945dd0..3b25e72ca 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; } @@ -182,6 +205,16 @@ export interface LookupWebFingerOptions { * @since 1.8.0 */ signal?: AbortSignal; + + /** + * Whether to return a {@link WebFingerLookupOutcome} describing why the + * lookup failed, instead of collapsing every failure into `null`. + * + * Turned off by default, so existing callers keep receiving + * `ResourceDescriptor | null` unchanged. + * @since 2.5.0 + */ + withDiagnostics?: boolean; } /** @@ -191,10 +224,29 @@ export interface LookupWebFingerOptions { * @returns The resource descriptor, or `null` if not found. * @since 0.2.0 */ +export async function lookupWebFinger( + resource: URL | string, + options?: LookupWebFingerOptions & { 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 & { withDiagnostics: true }, +): Promise; + export async function lookupWebFinger( resource: URL | string, options: LookupWebFingerOptions = {}, -): Promise { +): Promise { const tracerProvider = options.tracerProvider ?? trace.getTracerProvider(); const tracer = tracerProvider.getTracer( metadata.name, @@ -228,7 +280,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, From f86bd10a5a38a1bbfff70e742797f5e028488e27 Mon Sep 17 00:00:00 2001 From: Jae-Hyuk-Jang Date: Mon, 12 Oct 2026 17:18:25 +0900 Subject: [PATCH 02/11] Exclude withDiagnostics from Context's lookup Context.lookupWebFinger() forwards its options to the webfinger package's lookupWebFinger(), which can now return a different shape when `withDiagnostics` is set. Context's own return type never changed, so passing that option through it would silently mismatch the declared type. Type the option parameter as `Omit` so passing the field as a literal is a compile error. This does not catch a value already typed to include the field before it is passed in, but that requires deliberately constructing such a value. https://github.com/fedify-dev/fedify/issues/897 Changelog: none Assisted-by: Claude Code:claude-sonnet-5 --- packages/fedify/src/federation/context.ts | 6 ++++-- packages/fedify/src/federation/middleware.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/fedify/src/federation/context.ts b/packages/fedify/src/federation/context.ts index 04a1670a8..aa714e422 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. @@ -719,7 +721,7 @@ export interface Context { */ lookupWebFinger( resource: URL | string, - options?: LookupWebFingerOptions, + options?: Omit, ): Promise; /** diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index 53f9648c5..1215edd32 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -5403,7 +5403,7 @@ export class ContextImpl implements Context { lookupWebFinger( resource: URL | string, - options: LookupWebFingerOptions = {}, + options: Omit = {}, ): Promise { return lookupWebFinger(resource, { ...options, From cbc8425f1add8908754e19f3e6bcbcb62881dffa Mon Sep 17 00:00:00 2001 From: Jae-Hyuk-Jang Date: Mon, 12 Oct 2026 17:18:40 +0900 Subject: [PATCH 03/11] Distinguish CLI WebFinger failure reasons The `fedify webfinger` command reported every lookup failure as "Resource not found", whether the resource genuinely didn't exist, the request hit a network error, or the remote returned a malformed or unexpected response. Use lookupWebFinger()'s new `withDiagnostics` option and add WebFingerLookupError to report the other failure reasons with their own message, while a 404 or 410 still reports NotFoundError unchanged. https://github.com/fedify-dev/fedify/issues/897 Assisted-by: Claude Code:claude-sonnet-5 --- CHANGES.md | 12 +++ .../cli/webfinger-lookup-error-detail.md | 9 ++ packages/cli/src/webfinger/action.ts | 31 +++++-- packages/cli/src/webfinger/error.ts | 27 ++++++ packages/cli/src/webfinger/mod.test.ts | 86 +++++++++++++++++++ 5 files changed, 156 insertions(+), 9 deletions(-) create mode 100644 changes.d/cli/webfinger-lookup-error-detail.md diff --git a/CHANGES.md b/CHANGES.md index 343bbda66..8f08eb316 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -182,6 +182,18 @@ 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, or the resource genuinely not + existing—instead of returning `null` for every failure. + `Context.lookupWebFinger()` does not support this option. + [[#897], [#1296]] + +[#897]: https://github.com/fedify-dev/fedify/issues/897 +[#1296]: https://github.com/fedify-dev/fedify/pull/1296 + 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..ab2a83d84 --- /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]] 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; + } +}); From 2a60fb464475758a595d7d38e7d8c4e90959bb15 Mon Sep 17 00:00:00 2001 From: Jae-Hyuk-Jang Date: Tue, 13 Oct 2026 21:30:42 +0900 Subject: [PATCH 04/11] Fix the WebFinger diagnostics overload design CodeRabbit and @2chanhaeng found two problems with the withDiagnostics overload design. Putting `withDiagnostics` directly on the shared `LookupWebFingerOptions` interface broke every caller holding a plain `LookupWebFingerOptions`-typed variable, even one that never touches the field, because neither overload's literal-type requirement could match a widened `boolean`. Move the field into each overload's own intersection type instead, so the shared interface never declares it. Verified with a regression test that builds options in a separate variable before calling. `Omit` on Context.lookupWebFinger() only blocks a literal argument; `Omit` erases the field from the type, not from a value already structurally typed to include it before being passed in, so the forwarded options spread could still carry `withDiagnostics: true` through to the standalone function. Force `withDiagnostics: false` after the spread in ContextImpl.lookupWebFinger() so the override always wins, independent of what the caller's options value contains. This also makes the `Omit<>` wrapper redundant now that the field is not on the base interface at all; simplified back to a plain `LookupWebFingerOptions` parameter. Verified with a regression test that smuggles `withDiagnostics: true` past the type check and asserts the return value is still a plain descriptor. https://github.com/fedify-dev/fedify/pull/1296#discussion_r4246009201 https://github.com/fedify-dev/fedify/pull/1296#discussion_r4246009183 Assisted-by: Claude Code:claude-sonnet-5 --- packages/fedify/src/federation/context.ts | 2 +- .../fedify/src/federation/middleware.test.ts | 36 +++++++++++++++++++ packages/fedify/src/federation/middleware.ts | 9 ++++- packages/webfinger/src/lookup.test.ts | 32 ++++++++++++++++- packages/webfinger/src/lookup.ts | 31 +++++++++------- 5 files changed, 94 insertions(+), 16 deletions(-) diff --git a/packages/fedify/src/federation/context.ts b/packages/fedify/src/federation/context.ts index aa714e422..d52347563 100644 --- a/packages/fedify/src/federation/context.ts +++ b/packages/fedify/src/federation/context.ts @@ -721,7 +721,7 @@ export interface Context { */ lookupWebFinger( resource: URL | string, - options?: Omit, + options?: LookupWebFingerOptions, ): Promise; /** 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 1215edd32..fbaef2b8e 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -5403,7 +5403,7 @@ export class ContextImpl implements Context { lookupWebFinger( resource: URL | string, - options: Omit = {}, + options: LookupWebFingerOptions = {}, ): Promise { return lookupWebFinger(resource, { ...options, @@ -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 56008393a..31f7e342a 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()", @@ -1081,3 +1081,33 @@ test("lookupWebFinger() with withDiagnostics: true", { 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 3b25e72ca..5f339a819 100644 --- a/packages/webfinger/src/lookup.ts +++ b/packages/webfinger/src/lookup.ts @@ -205,16 +205,6 @@ export interface LookupWebFingerOptions { * @since 1.8.0 */ signal?: AbortSignal; - - /** - * Whether to return a {@link WebFingerLookupOutcome} describing why the - * lookup failed, instead of collapsing every failure into `null`. - * - * Turned off by default, so existing callers keep receiving - * `ResourceDescriptor | null` unchanged. - * @since 2.5.0 - */ - withDiagnostics?: boolean; } /** @@ -226,7 +216,15 @@ export interface LookupWebFingerOptions { */ export async function lookupWebFinger( resource: URL | string, - options?: LookupWebFingerOptions & { withDiagnostics?: false }, + 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; /** @@ -240,12 +238,19 @@ export async function lookupWebFinger( */ export async function lookupWebFinger( resource: URL | string, - options: LookupWebFingerOptions & { withDiagnostics: true }, + 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 = {}, + options: LookupWebFingerOptions & { withDiagnostics?: boolean } = {}, ): Promise { const tracerProvider = options.tracerProvider ?? trace.getTracerProvider(); const tracer = tracerProvider.getTracer( From 01f17a77404a17200624c544ea2661585b40c1ff Mon Sep 17 00:00:00 2001 From: Jae-Hyuk-Jang Date: Tue, 13 Oct 2026 21:31:40 +0900 Subject: [PATCH 05/11] Reject a literal null WebFinger response body JSON.parse("null") succeeds and yields the JS value `null`. A remote that answers 200 OK with a literal `null` body was classified as "found" with a null resource, an outcome that contradicts its own `result` field now that callers can observe it. Check that the parsed body is a non-null object before classifying the lookup as found; anything else, including `null`, a number, or a bare string, is now "invalid" instead. https://github.com/fedify-dev/fedify/pull/1296#discussion_r4246009208 Assisted-by: Claude Code:claude-sonnet-5 --- packages/webfinger/src/lookup.test.ts | 22 ++++++++++++++++++++++ packages/webfinger/src/lookup.ts | 17 ++++++++++++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/webfinger/src/lookup.test.ts b/packages/webfinger/src/lookup.test.ts index 31f7e342a..d9316b034 100644 --- a/packages/webfinger/src/lookup.test.ts +++ b/packages/webfinger/src/lookup.test.ts @@ -1062,6 +1062,28 @@ test("lookupWebFinger() with withDiagnostics: true", { 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("error", async () => { fetchMock.removeRoutes(); fetchMock.get( diff --git a/packages/webfinger/src/lookup.ts b/packages/webfinger/src/lookup.ts index 5f339a819..85518a317 100644 --- a/packages/webfinger/src/lookup.ts +++ b/packages/webfinger/src/lookup.ts @@ -495,11 +495,22 @@ 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) { + return { + resource: null, + result: "invalid", + statusCode: response.status, + remoteHost, + }; + } return { - resource: parsed, + resource: parsed as ResourceDescriptor, result: "found", statusCode: response.status, remoteHost, From adbcaa2e75153acc57b0f8e9d8709cf2991de70d Mon Sep 17 00:00:00 2001 From: Jae-Hyuk-Jang Date: Tue, 13 Oct 2026 22:07:08 +0900 Subject: [PATCH 06/11] Resync CHANGES.md after rebasing onto main again Rebasing onto upstream main pulled in other PRs' changelog entries that landed in the same unreleased 2.5.0 section. Git's merge of CHANGES.md produced a valid but non-canonical result, dropping this branch's @fedify/cli entry from its section. Resync so the materialized region matches the fragments exactly. https://github.com/fedify-dev/fedify/issues/897 Assisted-by: Claude Code:claude-sonnet-5 --- CHANGES.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 8f08eb316..ac4307136 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]] [#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 @@ -191,9 +197,6 @@ To be released. `Context.lookupWebFinger()` does not support this option. [[#897], [#1296]] -[#897]: https://github.com/fedify-dev/fedify/issues/897 -[#1296]: https://github.com/fedify-dev/fedify/pull/1296 - Version 2.4.4 ------------- From 110e47e406593099013609dc88aeb0d26fe2347a Mon Sep 17 00:00:00 2001 From: Jae-Hyuk-Jang Date: Tue, 13 Oct 2026 22:25:02 +0900 Subject: [PATCH 07/11] Reject a JSON array WebFinger response body typeof [] === "object" and [] !== null, so the guard added for a literal `null` body did not catch an array. A remote answering 200 OK with a JSON array body was still classified as "found" with the array as the resource. Add Array.isArray(parsed) to the rejection condition alongside the null check. https://github.com/fedify-dev/fedify/pull/1296#discussion_r4258718870 Assisted-by: Claude Code:claude-sonnet-5 --- packages/webfinger/src/lookup.test.ts | 20 ++++++++++++++++++++ packages/webfinger/src/lookup.ts | 4 +++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/webfinger/src/lookup.test.ts b/packages/webfinger/src/lookup.test.ts index d9316b034..c9c929bcb 100644 --- a/packages/webfinger/src/lookup.test.ts +++ b/packages/webfinger/src/lookup.test.ts @@ -1084,6 +1084,26 @@ test("lookupWebFinger() with withDiagnostics: true", { }, ); + 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( diff --git a/packages/webfinger/src/lookup.ts b/packages/webfinger/src/lookup.ts index 85518a317..fdc70b284 100644 --- a/packages/webfinger/src/lookup.ts +++ b/packages/webfinger/src/lookup.ts @@ -501,7 +501,9 @@ async function lookupWebFingerInternal( // 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) { + if ( + typeof parsed !== "object" || parsed === null || Array.isArray(parsed) + ) { return { resource: null, result: "invalid", From e629393b200b733de055414783ed8c6cf5637e08 Mon Sep 17 00:00:00 2001 From: Jae-Hyuk-Jang Date: Tue, 13 Oct 2026 22:30:52 +0900 Subject: [PATCH 08/11] Mention the error outcome in the diagnostics changelog The withDiagnostics changelog entry named the network-error, invalid-response, and not-found cases but left out an unexpected server error, even though lookupWebFinger() classifies that as its own "error" outcome. https://github.com/fedify-dev/fedify/pull/1296#discussion_r4259161912 Assisted-by: Claude Code:claude-sonnet-5 --- CHANGES.md | 6 +++--- changes.d/webfinger/lookup-diagnostics.md | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index ac4307136..62f06c8a1 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -192,9 +192,9 @@ To be released. - 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, or the resource genuinely not - existing—instead of returning `null` for every failure. - `Context.lookupWebFinger()` does not support this option. + 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]] diff --git a/changes.d/webfinger/lookup-diagnostics.md b/changes.d/webfinger/lookup-diagnostics.md index dcc1dcb13..1543a60e2 100644 --- a/changes.d/webfinger/lookup-diagnostics.md +++ b/changes.d/webfinger/lookup-diagnostics.md @@ -5,7 +5,7 @@ links: --- - 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, or the resource genuinely not - existing—instead of returning `null` for every failure. - `Context.lookupWebFinger()` does not support this option. + 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]] From 620c09284b4861f411fdd6b7b706cb45fae913c7 Mon Sep 17 00:00:00 2001 From: Jae-Hyuk-Jang Date: Tue, 13 Oct 2026 23:26:35 +0900 Subject: [PATCH 09/11] Cover lookupPortableWebFinger()'s WebFinger diagnostics lookupPortableWebFinger() maps a failed lookup to NotFoundError or WebFingerLookupError using the same branch as lookupSingleWebFinger(), but only the latter had a regression test for the non-404 branch. A gateway returning 500 from its WebFinger endpoint could have silently regressed to NotFoundError without a failing test. Add a case mocking a 500 response from the gateway's WebFinger endpoint and asserting the lookup rejects with WebFingerLookupError and result "error". https://github.com/fedify-dev/fedify/pull/1296#pullrequestreview-5504483903 Assisted-by: Claude Code:claude-sonnet-5 --- packages/cli/src/portable.test.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) 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 = From 930017833275221ad67c4f1c1cbce998247a19b4 Mon Sep 17 00:00:00 2001 From: Jae-Hyuk-Jang Date: Thu, 15 Oct 2026 09:04:56 +0900 Subject: [PATCH 10/11] Add contributor credit to two changelog entries Both changelog fragments for this pull request were missing the contributor credit the pull request guide requires for external contributors. https://github.com/fedify-dev/fedify/pull/1296#discussion_r4272683504 Assisted-by: Claude Code:claude-sonnet-5 --- CHANGES.md | 4 ++-- changes.d/cli/webfinger-lookup-error-detail.md | 2 +- changes.d/webfinger/lookup-diagnostics.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 62f06c8a1..65e7c7423 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -50,7 +50,7 @@ To be released. - 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]] + 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 @@ -195,7 +195,7 @@ To be released. 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]] + [[#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 index ab2a83d84..e06a65d0d 100644 --- a/changes.d/cli/webfinger-lookup-error-detail.md +++ b/changes.d/cli/webfinger-lookup-error-detail.md @@ -6,4 +6,4 @@ links: - 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]] + 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 index 1543a60e2..ffa99bd50 100644 --- a/changes.d/webfinger/lookup-diagnostics.md +++ b/changes.d/webfinger/lookup-diagnostics.md @@ -8,4 +8,4 @@ links: 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]] + [[#897], [#1296] by Jae-Hyuk-Jang] From 564e945f844690b57708628170116383f67de450 Mon Sep 17 00:00:00 2001 From: Jae-Hyuk-Jang Date: Thu, 15 Oct 2026 09:05:09 +0900 Subject: [PATCH 11/11] Note that withDiagnostics can still reject the promise The "Diagnosing a failed lookup" section read as if withDiagnostics classified every failure into a WebFingerLookupOutcome. It does not: lookupWebFinger() rethrows any error it does not recognize, such as one while reading the response body, so the promise still rejects. https://github.com/fedify-dev/fedify/pull/1296#discussion_r4272683534 Assisted-by: Claude Code:claude-sonnet-5 --- docs/manual/webfinger.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/manual/webfinger.md b/docs/manual/webfinger.md index cdc3d0c91..8a17bd789 100644 --- a/docs/manual/webfinger.md +++ b/docs/manual/webfinger.md @@ -441,6 +441,13 @@ The `result` field is one of: `"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`.