@fedify/webfinger 2.5.0-dev.2432 → 2.5.0-dev.2446

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/deno.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fedify/webfinger",
3
- "version": "2.5.0-dev.2432+f56118e1",
3
+ "version": "2.5.0-dev.2446+2014cb03",
4
4
  "license": "MIT",
5
5
  "exports": {
6
6
  ".": "./src/mod.ts"
@@ -1237,7 +1237,7 @@ var esm_default = new class FetchMock {
1237
1237
  //#endregion
1238
1238
  //#region deno.json
1239
1239
  var name = "@fedify/webfinger";
1240
- var version = "2.5.0-dev.2432+f56118e1";
1240
+ var version = "2.5.0-dev.2446+2014cb03";
1241
1241
  //#endregion
1242
1242
  //#region src/body.ts
1243
1243
  /** Decoded JSON body limit: 16 MiB. @internal */
@@ -1372,13 +1372,6 @@ const WEBFINGER_LOOKUP_SCHEME_WHITELIST = /* @__PURE__ */ new Set([
1372
1372
  function getMetricResourceScheme(scheme) {
1373
1373
  return WEBFINGER_LOOKUP_SCHEME_WHITELIST.has(scheme) ? scheme : "other";
1374
1374
  }
1375
- /**
1376
- * Looks up a WebFinger resource.
1377
- * @param resource The resource URL to look up.
1378
- * @param options Extra options for looking up the resource.
1379
- * @returns The resource descriptor, or `null` if not found.
1380
- * @since 0.2.0
1381
- */
1382
1375
  async function lookupWebFinger(resource, options = {}) {
1383
1376
  const tracer = (options.tracerProvider ?? _opentelemetry_api.trace.getTracerProvider()).getTracer(name, version);
1384
1377
  const scheme = getResourceScheme(resource);
@@ -1398,7 +1391,7 @@ async function lookupWebFinger(resource, options = {}) {
1398
1391
  try {
1399
1392
  outcome = await lookupWebFingerInternal(resource, options);
1400
1393
  span.setStatus({ code: outcome.resource === null ? _opentelemetry_api.SpanStatusCode.ERROR : _opentelemetry_api.SpanStatusCode.OK });
1401
- return outcome.resource;
1394
+ return options.withDiagnostics ? outcome : outcome.resource;
1402
1395
  } catch (error) {
1403
1396
  span.setStatus({
1404
1397
  code: _opentelemetry_api.SpanStatusCode.ERROR,
@@ -1545,8 +1538,15 @@ async function lookupWebFingerInternal(resource, options = {}) {
1545
1538
  };
1546
1539
  }
1547
1540
  try {
1541
+ const parsed = JSON.parse(await readBoundedText(response, MAX_BODY_SIZE, url));
1542
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {
1543
+ resource: null,
1544
+ result: "invalid",
1545
+ statusCode: response.status,
1546
+ remoteHost
1547
+ };
1548
1548
  return {
1549
- resource: JSON.parse(await readBoundedText(response, MAX_BODY_SIZE, url)),
1549
+ resource: parsed,
1550
1550
  result: "found",
1551
1551
  statusCode: response.status,
1552
1552
  remoteHost
@@ -2040,4 +2040,93 @@ async function lookupWebFingerInternal(resource, options = {}) {
2040
2040
  }
2041
2041
  });
2042
2042
  });
2043
+ (0, _fedify_fixture.test)("lookupWebFinger() with withDiagnostics: true", {
2044
+ sanitizeOps: false,
2045
+ sanitizeResources: false
2046
+ }, async (t) => {
2047
+ await t.step("network_error", async () => {
2048
+ const outcome = await lookupWebFinger("acct:johndoe@fedify-test.internal", { withDiagnostics: true });
2049
+ (0, node_assert_strict.deepStrictEqual)(outcome.resource, null);
2050
+ (0, node_assert_strict.deepStrictEqual)(outcome.result, "network_error");
2051
+ (0, node_assert_strict.deepStrictEqual)("statusCode" in outcome, false, "no HTTP response means no status code");
2052
+ });
2053
+ esm_default.spyGlobal();
2054
+ try {
2055
+ await t.step("found", async () => {
2056
+ esm_default.removeRoutes();
2057
+ const expected = {
2058
+ subject: "acct:johndoe@example.com",
2059
+ links: []
2060
+ };
2061
+ esm_default.get("https://example.com/.well-known/webfinger?resource=acct%3Ajohndoe%40example.com", { body: expected });
2062
+ const outcome = await lookupWebFinger("acct:johndoe@example.com", { withDiagnostics: true });
2063
+ (0, node_assert_strict.deepStrictEqual)(outcome.resource, expected);
2064
+ (0, node_assert_strict.deepStrictEqual)(outcome.result, "found");
2065
+ (0, node_assert_strict.deepStrictEqual)(outcome.statusCode, 200);
2066
+ (0, node_assert_strict.deepStrictEqual)(outcome.remoteHost, "example.com");
2067
+ });
2068
+ await t.step("not_found", async () => {
2069
+ esm_default.removeRoutes();
2070
+ esm_default.get("begin:https://example.com/.well-known/webfinger?", { status: 404 });
2071
+ const outcome = await lookupWebFinger("acct:johndoe@example.com", { withDiagnostics: true });
2072
+ (0, node_assert_strict.deepStrictEqual)(outcome.resource, null);
2073
+ (0, node_assert_strict.deepStrictEqual)(outcome.result, "not_found");
2074
+ (0, node_assert_strict.deepStrictEqual)(outcome.statusCode, 404);
2075
+ (0, node_assert_strict.deepStrictEqual)(outcome.remoteHost, "example.com");
2076
+ });
2077
+ await t.step("invalid", async () => {
2078
+ esm_default.removeRoutes();
2079
+ esm_default.get("begin:https://example.com/.well-known/webfinger?", { body: "not json" });
2080
+ const outcome = await lookupWebFinger("acct:johndoe@example.com", { withDiagnostics: true });
2081
+ (0, node_assert_strict.deepStrictEqual)(outcome.resource, null);
2082
+ (0, node_assert_strict.deepStrictEqual)(outcome.result, "invalid");
2083
+ (0, node_assert_strict.deepStrictEqual)(outcome.statusCode, 200);
2084
+ (0, node_assert_strict.deepStrictEqual)(outcome.remoteHost, "example.com");
2085
+ });
2086
+ await t.step("invalid: a literal JSON null body is not a resource descriptor", async () => {
2087
+ esm_default.removeRoutes();
2088
+ esm_default.get("begin:https://example.com/.well-known/webfinger?", { body: "null" });
2089
+ const outcome = await lookupWebFinger("acct:johndoe@example.com", { withDiagnostics: true });
2090
+ (0, node_assert_strict.deepStrictEqual)(outcome.resource, null);
2091
+ (0, node_assert_strict.deepStrictEqual)(outcome.result, "invalid");
2092
+ (0, node_assert_strict.deepStrictEqual)(outcome.statusCode, 200);
2093
+ (0, node_assert_strict.deepStrictEqual)(outcome.remoteHost, "example.com");
2094
+ });
2095
+ await t.step("invalid: a JSON array body is not a resource descriptor", async () => {
2096
+ esm_default.removeRoutes();
2097
+ esm_default.get("begin:https://example.com/.well-known/webfinger?", { body: "[]" });
2098
+ const outcome = await lookupWebFinger("acct:johndoe@example.com", { withDiagnostics: true });
2099
+ (0, node_assert_strict.deepStrictEqual)(outcome.resource, null);
2100
+ (0, node_assert_strict.deepStrictEqual)(outcome.result, "invalid");
2101
+ (0, node_assert_strict.deepStrictEqual)(outcome.statusCode, 200);
2102
+ (0, node_assert_strict.deepStrictEqual)(outcome.remoteHost, "example.com");
2103
+ });
2104
+ await t.step("error", async () => {
2105
+ esm_default.removeRoutes();
2106
+ esm_default.get("begin:https://example.com/.well-known/webfinger?", { status: 500 });
2107
+ const outcome = await lookupWebFinger("acct:johndoe@example.com", { withDiagnostics: true });
2108
+ (0, node_assert_strict.deepStrictEqual)(outcome.resource, null);
2109
+ (0, node_assert_strict.deepStrictEqual)(outcome.result, "error");
2110
+ (0, node_assert_strict.deepStrictEqual)(outcome.statusCode, 500);
2111
+ (0, node_assert_strict.deepStrictEqual)(outcome.remoteHost, "example.com");
2112
+ });
2113
+ } finally {
2114
+ esm_default.removeRoutes();
2115
+ esm_default.hardReset();
2116
+ }
2117
+ });
2118
+ (0, _fedify_fixture.test)("lookupWebFinger() accepts a plain LookupWebFingerOptions-typed variable", async () => {
2119
+ esm_default.spyGlobal();
2120
+ try {
2121
+ const expected = {
2122
+ subject: "acct:johndoe@example.com",
2123
+ links: []
2124
+ };
2125
+ esm_default.get("https://example.com/.well-known/webfinger?resource=acct%3Ajohndoe%40example.com", { body: expected });
2126
+ (0, node_assert_strict.deepStrictEqual)(await lookupWebFinger("acct:johndoe@example.com", { userAgent: "test-agent" }), expected);
2127
+ } finally {
2128
+ esm_default.removeRoutes();
2129
+ esm_default.hardReset();
2130
+ }
2131
+ });
2043
2132
  //#endregion
@@ -1235,7 +1235,7 @@ var esm_default = new class FetchMock {
1235
1235
  //#endregion
1236
1236
  //#region deno.json
1237
1237
  var name = "@fedify/webfinger";
1238
- var version = "2.5.0-dev.2432+f56118e1";
1238
+ var version = "2.5.0-dev.2446+2014cb03";
1239
1239
  //#endregion
1240
1240
  //#region src/body.ts
1241
1241
  /** Decoded JSON body limit: 16 MiB. @internal */
@@ -1370,13 +1370,6 @@ const WEBFINGER_LOOKUP_SCHEME_WHITELIST = /* @__PURE__ */ new Set([
1370
1370
  function getMetricResourceScheme(scheme) {
1371
1371
  return WEBFINGER_LOOKUP_SCHEME_WHITELIST.has(scheme) ? scheme : "other";
1372
1372
  }
1373
- /**
1374
- * Looks up a WebFinger resource.
1375
- * @param resource The resource URL to look up.
1376
- * @param options Extra options for looking up the resource.
1377
- * @returns The resource descriptor, or `null` if not found.
1378
- * @since 0.2.0
1379
- */
1380
1373
  async function lookupWebFinger(resource, options = {}) {
1381
1374
  const tracer = (options.tracerProvider ?? trace.getTracerProvider()).getTracer(name, version);
1382
1375
  const scheme = getResourceScheme(resource);
@@ -1396,7 +1389,7 @@ async function lookupWebFinger(resource, options = {}) {
1396
1389
  try {
1397
1390
  outcome = await lookupWebFingerInternal(resource, options);
1398
1391
  span.setStatus({ code: outcome.resource === null ? SpanStatusCode.ERROR : SpanStatusCode.OK });
1399
- return outcome.resource;
1392
+ return options.withDiagnostics ? outcome : outcome.resource;
1400
1393
  } catch (error) {
1401
1394
  span.setStatus({
1402
1395
  code: SpanStatusCode.ERROR,
@@ -1543,8 +1536,15 @@ async function lookupWebFingerInternal(resource, options = {}) {
1543
1536
  };
1544
1537
  }
1545
1538
  try {
1539
+ const parsed = JSON.parse(await readBoundedText(response, MAX_BODY_SIZE, url));
1540
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {
1541
+ resource: null,
1542
+ result: "invalid",
1543
+ statusCode: response.status,
1544
+ remoteHost
1545
+ };
1546
1546
  return {
1547
- resource: JSON.parse(await readBoundedText(response, MAX_BODY_SIZE, url)),
1547
+ resource: parsed,
1548
1548
  result: "found",
1549
1549
  statusCode: response.status,
1550
1550
  remoteHost
@@ -2038,5 +2038,94 @@ test("lookupWebFinger() logs DNS failures as such", async (t) => {
2038
2038
  }
2039
2039
  });
2040
2040
  });
2041
+ test("lookupWebFinger() with withDiagnostics: true", {
2042
+ sanitizeOps: false,
2043
+ sanitizeResources: false
2044
+ }, async (t) => {
2045
+ await t.step("network_error", async () => {
2046
+ const outcome = await lookupWebFinger("acct:johndoe@fedify-test.internal", { withDiagnostics: true });
2047
+ deepStrictEqual(outcome.resource, null);
2048
+ deepStrictEqual(outcome.result, "network_error");
2049
+ deepStrictEqual("statusCode" in outcome, false, "no HTTP response means no status code");
2050
+ });
2051
+ esm_default.spyGlobal();
2052
+ try {
2053
+ await t.step("found", async () => {
2054
+ esm_default.removeRoutes();
2055
+ const expected = {
2056
+ subject: "acct:johndoe@example.com",
2057
+ links: []
2058
+ };
2059
+ esm_default.get("https://example.com/.well-known/webfinger?resource=acct%3Ajohndoe%40example.com", { body: expected });
2060
+ const outcome = await lookupWebFinger("acct:johndoe@example.com", { withDiagnostics: true });
2061
+ deepStrictEqual(outcome.resource, expected);
2062
+ deepStrictEqual(outcome.result, "found");
2063
+ deepStrictEqual(outcome.statusCode, 200);
2064
+ deepStrictEqual(outcome.remoteHost, "example.com");
2065
+ });
2066
+ await t.step("not_found", async () => {
2067
+ esm_default.removeRoutes();
2068
+ esm_default.get("begin:https://example.com/.well-known/webfinger?", { status: 404 });
2069
+ const outcome = await lookupWebFinger("acct:johndoe@example.com", { withDiagnostics: true });
2070
+ deepStrictEqual(outcome.resource, null);
2071
+ deepStrictEqual(outcome.result, "not_found");
2072
+ deepStrictEqual(outcome.statusCode, 404);
2073
+ deepStrictEqual(outcome.remoteHost, "example.com");
2074
+ });
2075
+ await t.step("invalid", async () => {
2076
+ esm_default.removeRoutes();
2077
+ esm_default.get("begin:https://example.com/.well-known/webfinger?", { body: "not json" });
2078
+ const outcome = await lookupWebFinger("acct:johndoe@example.com", { withDiagnostics: true });
2079
+ deepStrictEqual(outcome.resource, null);
2080
+ deepStrictEqual(outcome.result, "invalid");
2081
+ deepStrictEqual(outcome.statusCode, 200);
2082
+ deepStrictEqual(outcome.remoteHost, "example.com");
2083
+ });
2084
+ await t.step("invalid: a literal JSON null body is not a resource descriptor", async () => {
2085
+ esm_default.removeRoutes();
2086
+ esm_default.get("begin:https://example.com/.well-known/webfinger?", { body: "null" });
2087
+ const outcome = await lookupWebFinger("acct:johndoe@example.com", { withDiagnostics: true });
2088
+ deepStrictEqual(outcome.resource, null);
2089
+ deepStrictEqual(outcome.result, "invalid");
2090
+ deepStrictEqual(outcome.statusCode, 200);
2091
+ deepStrictEqual(outcome.remoteHost, "example.com");
2092
+ });
2093
+ await t.step("invalid: a JSON array body is not a resource descriptor", async () => {
2094
+ esm_default.removeRoutes();
2095
+ esm_default.get("begin:https://example.com/.well-known/webfinger?", { body: "[]" });
2096
+ const outcome = await lookupWebFinger("acct:johndoe@example.com", { withDiagnostics: true });
2097
+ deepStrictEqual(outcome.resource, null);
2098
+ deepStrictEqual(outcome.result, "invalid");
2099
+ deepStrictEqual(outcome.statusCode, 200);
2100
+ deepStrictEqual(outcome.remoteHost, "example.com");
2101
+ });
2102
+ await t.step("error", async () => {
2103
+ esm_default.removeRoutes();
2104
+ esm_default.get("begin:https://example.com/.well-known/webfinger?", { status: 500 });
2105
+ const outcome = await lookupWebFinger("acct:johndoe@example.com", { withDiagnostics: true });
2106
+ deepStrictEqual(outcome.resource, null);
2107
+ deepStrictEqual(outcome.result, "error");
2108
+ deepStrictEqual(outcome.statusCode, 500);
2109
+ deepStrictEqual(outcome.remoteHost, "example.com");
2110
+ });
2111
+ } finally {
2112
+ esm_default.removeRoutes();
2113
+ esm_default.hardReset();
2114
+ }
2115
+ });
2116
+ test("lookupWebFinger() accepts a plain LookupWebFingerOptions-typed variable", async () => {
2117
+ esm_default.spyGlobal();
2118
+ try {
2119
+ const expected = {
2120
+ subject: "acct:johndoe@example.com",
2121
+ links: []
2122
+ };
2123
+ esm_default.get("https://example.com/.well-known/webfinger?resource=acct%3Ajohndoe%40example.com", { body: expected });
2124
+ deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com", { userAgent: "test-agent" }), expected);
2125
+ } finally {
2126
+ esm_default.removeRoutes();
2127
+ esm_default.hardReset();
2128
+ }
2129
+ });
2041
2130
  //#endregion
2042
2131
  export {};
package/dist/mod.cjs CHANGED
@@ -4,7 +4,7 @@ let _logtape_logtape = require("@logtape/logtape");
4
4
  let _opentelemetry_api = require("@opentelemetry/api");
5
5
  //#region deno.json
6
6
  var name = "@fedify/webfinger";
7
- var version = "2.5.0-dev.2432+f56118e1";
7
+ var version = "2.5.0-dev.2446+2014cb03";
8
8
  //#endregion
9
9
  //#region src/body.ts
10
10
  /** Decoded JSON body limit: 16 MiB. @internal */
@@ -139,13 +139,6 @@ const WEBFINGER_LOOKUP_SCHEME_WHITELIST = /* @__PURE__ */ new Set([
139
139
  function getMetricResourceScheme(scheme) {
140
140
  return WEBFINGER_LOOKUP_SCHEME_WHITELIST.has(scheme) ? scheme : "other";
141
141
  }
142
- /**
143
- * Looks up a WebFinger resource.
144
- * @param resource The resource URL to look up.
145
- * @param options Extra options for looking up the resource.
146
- * @returns The resource descriptor, or `null` if not found.
147
- * @since 0.2.0
148
- */
149
142
  async function lookupWebFinger(resource, options = {}) {
150
143
  const tracer = (options.tracerProvider ?? _opentelemetry_api.trace.getTracerProvider()).getTracer(name, version);
151
144
  const scheme = getResourceScheme(resource);
@@ -165,7 +158,7 @@ async function lookupWebFinger(resource, options = {}) {
165
158
  try {
166
159
  outcome = await lookupWebFingerInternal(resource, options);
167
160
  span.setStatus({ code: outcome.resource === null ? _opentelemetry_api.SpanStatusCode.ERROR : _opentelemetry_api.SpanStatusCode.OK });
168
- return outcome.resource;
161
+ return options.withDiagnostics ? outcome : outcome.resource;
169
162
  } catch (error) {
170
163
  span.setStatus({
171
164
  code: _opentelemetry_api.SpanStatusCode.ERROR,
@@ -312,8 +305,15 @@ async function lookupWebFingerInternal(resource, options = {}) {
312
305
  };
313
306
  }
314
307
  try {
308
+ const parsed = JSON.parse(await readBoundedText(response, MAX_BODY_SIZE, url));
309
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {
310
+ resource: null,
311
+ result: "invalid",
312
+ statusCode: response.status,
313
+ remoteHost
314
+ };
315
315
  return {
316
- resource: JSON.parse(await readBoundedText(response, MAX_BODY_SIZE, url)),
316
+ resource: parsed,
317
317
  result: "found",
318
318
  statusCode: response.status,
319
319
  remoteHost
package/dist/mod.d.cts CHANGED
@@ -85,6 +85,32 @@ interface Link {
85
85
  */
86
86
  type WebFingerLookupResult = "found" | "not_found" | "invalid" | "network_error" | "error";
87
87
  /**
88
+ * The outcome of a {@link lookupWebFinger} call, returned in place of the
89
+ * plain {@link ResourceDescriptor} when `options.withDiagnostics` is `true`.
90
+ * @since 2.5.0
91
+ */
92
+ interface WebFingerLookupOutcome {
93
+ /**
94
+ * The resource descriptor fetched from the remote WebFinger endpoint, or
95
+ * `null` if the lookup did not succeed.
96
+ */
97
+ resource: ResourceDescriptor | null;
98
+ /**
99
+ * The terminal classification of the lookup. See
100
+ * {@link WebFingerLookupResult} for what each value means.
101
+ */
102
+ result: WebFingerLookupResult;
103
+ /**
104
+ * The HTTP status code of the final response, if a response was received
105
+ * from the remote server.
106
+ */
107
+ statusCode?: number;
108
+ /**
109
+ * The host of the final URL the lookup attempted to reach.
110
+ */
111
+ remoteHost?: string;
112
+ }
113
+ /**
88
114
  * Options for {@link lookupWebFinger}.
89
115
  * @since 1.3.0
90
116
  */
@@ -138,6 +164,31 @@ interface LookupWebFingerOptions {
138
164
  * @returns The resource descriptor, or `null` if not found.
139
165
  * @since 0.2.0
140
166
  */
141
- declare function lookupWebFinger(resource: URL | string, options?: LookupWebFingerOptions): Promise<ResourceDescriptor | null>;
167
+ declare function lookupWebFinger(resource: URL | string, options?: LookupWebFingerOptions & {
168
+ /**
169
+ * Whether to return a {@link WebFingerLookupOutcome} describing why
170
+ * the lookup failed, instead of collapsing every failure into `null`.
171
+ * Turned off by default.
172
+ * @since 2.5.0
173
+ */
174
+ withDiagnostics?: false;
175
+ }): Promise<ResourceDescriptor | null>;
176
+ /**
177
+ * Looks up a WebFinger resource, returning diagnostics that explain why the
178
+ * lookup failed instead of collapsing every failure into `null`.
179
+ * @param resource The resource URL to look up.
180
+ * @param options Extra options for looking up the resource. Must set
181
+ * `withDiagnostics: true`.
182
+ * @returns The outcome of the lookup.
183
+ * @since 2.5.0
184
+ */
185
+ declare function lookupWebFinger(resource: URL | string, options: LookupWebFingerOptions & {
186
+ /**
187
+ * Returns a {@link WebFingerLookupOutcome} instead of a plain
188
+ * {@link ResourceDescriptor}.
189
+ * @since 2.5.0
190
+ */
191
+ withDiagnostics: true;
192
+ }): Promise<WebFingerLookupOutcome>;
142
193
  //#endregion
143
- export { Link, LookupWebFingerOptions, ResourceDescriptor, WebFingerLookupResult, lookupWebFinger };
194
+ export { Link, LookupWebFingerOptions, ResourceDescriptor, WebFingerLookupOutcome, WebFingerLookupResult, lookupWebFinger };
package/dist/mod.d.ts CHANGED
@@ -85,6 +85,32 @@ interface Link {
85
85
  */
86
86
  type WebFingerLookupResult = "found" | "not_found" | "invalid" | "network_error" | "error";
87
87
  /**
88
+ * The outcome of a {@link lookupWebFinger} call, returned in place of the
89
+ * plain {@link ResourceDescriptor} when `options.withDiagnostics` is `true`.
90
+ * @since 2.5.0
91
+ */
92
+ interface WebFingerLookupOutcome {
93
+ /**
94
+ * The resource descriptor fetched from the remote WebFinger endpoint, or
95
+ * `null` if the lookup did not succeed.
96
+ */
97
+ resource: ResourceDescriptor | null;
98
+ /**
99
+ * The terminal classification of the lookup. See
100
+ * {@link WebFingerLookupResult} for what each value means.
101
+ */
102
+ result: WebFingerLookupResult;
103
+ /**
104
+ * The HTTP status code of the final response, if a response was received
105
+ * from the remote server.
106
+ */
107
+ statusCode?: number;
108
+ /**
109
+ * The host of the final URL the lookup attempted to reach.
110
+ */
111
+ remoteHost?: string;
112
+ }
113
+ /**
88
114
  * Options for {@link lookupWebFinger}.
89
115
  * @since 1.3.0
90
116
  */
@@ -138,6 +164,31 @@ interface LookupWebFingerOptions {
138
164
  * @returns The resource descriptor, or `null` if not found.
139
165
  * @since 0.2.0
140
166
  */
141
- declare function lookupWebFinger(resource: URL | string, options?: LookupWebFingerOptions): Promise<ResourceDescriptor | null>;
167
+ declare function lookupWebFinger(resource: URL | string, options?: LookupWebFingerOptions & {
168
+ /**
169
+ * Whether to return a {@link WebFingerLookupOutcome} describing why
170
+ * the lookup failed, instead of collapsing every failure into `null`.
171
+ * Turned off by default.
172
+ * @since 2.5.0
173
+ */
174
+ withDiagnostics?: false;
175
+ }): Promise<ResourceDescriptor | null>;
176
+ /**
177
+ * Looks up a WebFinger resource, returning diagnostics that explain why the
178
+ * lookup failed instead of collapsing every failure into `null`.
179
+ * @param resource The resource URL to look up.
180
+ * @param options Extra options for looking up the resource. Must set
181
+ * `withDiagnostics: true`.
182
+ * @returns The outcome of the lookup.
183
+ * @since 2.5.0
184
+ */
185
+ declare function lookupWebFinger(resource: URL | string, options: LookupWebFingerOptions & {
186
+ /**
187
+ * Returns a {@link WebFingerLookupOutcome} instead of a plain
188
+ * {@link ResourceDescriptor}.
189
+ * @since 2.5.0
190
+ */
191
+ withDiagnostics: true;
192
+ }): Promise<WebFingerLookupOutcome>;
142
193
  //#endregion
143
- export { Link, LookupWebFingerOptions, ResourceDescriptor, WebFingerLookupResult, lookupWebFinger };
194
+ export { Link, LookupWebFingerOptions, ResourceDescriptor, WebFingerLookupOutcome, WebFingerLookupResult, lookupWebFinger };
package/dist/mod.js CHANGED
@@ -3,7 +3,7 @@ import { getLogger } from "@logtape/logtape";
3
3
  import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
4
4
  //#region deno.json
5
5
  var name = "@fedify/webfinger";
6
- var version = "2.5.0-dev.2432+f56118e1";
6
+ var version = "2.5.0-dev.2446+2014cb03";
7
7
  //#endregion
8
8
  //#region src/body.ts
9
9
  /** Decoded JSON body limit: 16 MiB. @internal */
@@ -138,13 +138,6 @@ const WEBFINGER_LOOKUP_SCHEME_WHITELIST = /* @__PURE__ */ new Set([
138
138
  function getMetricResourceScheme(scheme) {
139
139
  return WEBFINGER_LOOKUP_SCHEME_WHITELIST.has(scheme) ? scheme : "other";
140
140
  }
141
- /**
142
- * Looks up a WebFinger resource.
143
- * @param resource The resource URL to look up.
144
- * @param options Extra options for looking up the resource.
145
- * @returns The resource descriptor, or `null` if not found.
146
- * @since 0.2.0
147
- */
148
141
  async function lookupWebFinger(resource, options = {}) {
149
142
  const tracer = (options.tracerProvider ?? trace.getTracerProvider()).getTracer(name, version);
150
143
  const scheme = getResourceScheme(resource);
@@ -164,7 +157,7 @@ async function lookupWebFinger(resource, options = {}) {
164
157
  try {
165
158
  outcome = await lookupWebFingerInternal(resource, options);
166
159
  span.setStatus({ code: outcome.resource === null ? SpanStatusCode.ERROR : SpanStatusCode.OK });
167
- return outcome.resource;
160
+ return options.withDiagnostics ? outcome : outcome.resource;
168
161
  } catch (error) {
169
162
  span.setStatus({
170
163
  code: SpanStatusCode.ERROR,
@@ -311,8 +304,15 @@ async function lookupWebFingerInternal(resource, options = {}) {
311
304
  };
312
305
  }
313
306
  try {
307
+ const parsed = JSON.parse(await readBoundedText(response, MAX_BODY_SIZE, url));
308
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {
309
+ resource: null,
310
+ result: "invalid",
311
+ statusCode: response.status,
312
+ remoteHost
313
+ };
314
314
  return {
315
- resource: JSON.parse(await readBoundedText(response, MAX_BODY_SIZE, url)),
315
+ resource: parsed,
316
316
  result: "found",
317
317
  statusCode: response.status,
318
318
  remoteHost
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fedify/webfinger",
3
- "version": "2.5.0-dev.2432+f56118e1",
3
+ "version": "2.5.0-dev.2446+2014cb03",
4
4
  "homepage": "https://fedify.dev/",
5
5
  "repository": {
6
6
  "type": "git",
@@ -57,7 +57,7 @@
57
57
  "@logtape/logtape": "^2.2.0",
58
58
  "@opentelemetry/api": "^1.9.1",
59
59
  "es-toolkit": "1.46.1",
60
- "@fedify/vocab-runtime": "2.5.0-dev.2432+f56118e1"
60
+ "@fedify/vocab-runtime": "2.5.0-dev.2446+2014cb03"
61
61
  },
62
62
  "scripts": {
63
63
  "build:self": "tsdown",
@@ -6,7 +6,7 @@ import fetchMock from "fetch-mock";
6
6
  import { deepStrictEqual, ok } from "node:assert/strict";
7
7
  import dns from "node:dns/promises";
8
8
  import type { ResourceDescriptor } from "./jrd.ts";
9
- import { lookupWebFinger } from "./lookup.ts";
9
+ import { lookupWebFinger, type LookupWebFingerOptions } from "./lookup.ts";
10
10
 
11
11
  test({
12
12
  name: "lookupWebFinger()",
@@ -989,3 +989,167 @@ test("lookupWebFinger() logs DNS failures as such", async (t) => {
989
989
  });
990
990
  }
991
991
  });
992
+
993
+ test("lookupWebFinger() with withDiagnostics: true", {
994
+ sanitizeOps: false,
995
+ sanitizeResources: false,
996
+ }, async (t) => {
997
+ // No fetch mock is active yet, so this reaches the real (non-existent)
998
+ // host and fails at the network layer, the same way the "connection
999
+ // refused" step above does.
1000
+ await t.step("network_error", async () => {
1001
+ const outcome = await lookupWebFinger(
1002
+ "acct:johndoe@fedify-test.internal",
1003
+ { withDiagnostics: true },
1004
+ );
1005
+ deepStrictEqual(outcome.resource, null);
1006
+ deepStrictEqual(outcome.result, "network_error");
1007
+ deepStrictEqual(
1008
+ "statusCode" in outcome,
1009
+ false,
1010
+ "no HTTP response means no status code",
1011
+ );
1012
+ });
1013
+
1014
+ fetchMock.spyGlobal();
1015
+ try {
1016
+ await t.step("found", async () => {
1017
+ fetchMock.removeRoutes();
1018
+ const expected: ResourceDescriptor = {
1019
+ subject: "acct:johndoe@example.com",
1020
+ links: [],
1021
+ };
1022
+ fetchMock.get(
1023
+ "https://example.com/.well-known/webfinger?resource=acct%3Ajohndoe%40example.com",
1024
+ { body: expected },
1025
+ );
1026
+ const outcome = await lookupWebFinger("acct:johndoe@example.com", {
1027
+ withDiagnostics: true,
1028
+ });
1029
+ deepStrictEqual(outcome.resource, expected);
1030
+ deepStrictEqual(outcome.result, "found");
1031
+ deepStrictEqual(outcome.statusCode, 200);
1032
+ deepStrictEqual(outcome.remoteHost, "example.com");
1033
+ });
1034
+
1035
+ await t.step("not_found", async () => {
1036
+ fetchMock.removeRoutes();
1037
+ fetchMock.get(
1038
+ "begin:https://example.com/.well-known/webfinger?",
1039
+ { status: 404 },
1040
+ );
1041
+ const outcome = await lookupWebFinger("acct:johndoe@example.com", {
1042
+ withDiagnostics: true,
1043
+ });
1044
+ deepStrictEqual(outcome.resource, null);
1045
+ deepStrictEqual(outcome.result, "not_found");
1046
+ deepStrictEqual(outcome.statusCode, 404);
1047
+ deepStrictEqual(outcome.remoteHost, "example.com");
1048
+ });
1049
+
1050
+ await t.step("invalid", async () => {
1051
+ fetchMock.removeRoutes();
1052
+ fetchMock.get(
1053
+ "begin:https://example.com/.well-known/webfinger?",
1054
+ { body: "not json" },
1055
+ );
1056
+ const outcome = await lookupWebFinger("acct:johndoe@example.com", {
1057
+ withDiagnostics: true,
1058
+ });
1059
+ deepStrictEqual(outcome.resource, null);
1060
+ deepStrictEqual(outcome.result, "invalid");
1061
+ deepStrictEqual(outcome.statusCode, 200);
1062
+ deepStrictEqual(outcome.remoteHost, "example.com");
1063
+ });
1064
+
1065
+ await t.step(
1066
+ "invalid: a literal JSON null body is not a resource descriptor",
1067
+ async () => {
1068
+ // JSON.parse("null") succeeds and yields the JS value `null`.
1069
+ // Regression test for a server that returns 200 with a literal
1070
+ // `null` body instead of an object: this must not be classified
1071
+ // as "found" with a null resource.
1072
+ fetchMock.removeRoutes();
1073
+ fetchMock.get(
1074
+ "begin:https://example.com/.well-known/webfinger?",
1075
+ { body: "null" },
1076
+ );
1077
+ const outcome = await lookupWebFinger("acct:johndoe@example.com", {
1078
+ withDiagnostics: true,
1079
+ });
1080
+ deepStrictEqual(outcome.resource, null);
1081
+ deepStrictEqual(outcome.result, "invalid");
1082
+ deepStrictEqual(outcome.statusCode, 200);
1083
+ deepStrictEqual(outcome.remoteHost, "example.com");
1084
+ },
1085
+ );
1086
+
1087
+ await t.step(
1088
+ "invalid: a JSON array body is not a resource descriptor",
1089
+ async () => {
1090
+ // typeof [] === "object" and [] !== null, so the null-only guard
1091
+ // above does not catch an array body; it needs its own check.
1092
+ fetchMock.removeRoutes();
1093
+ fetchMock.get(
1094
+ "begin:https://example.com/.well-known/webfinger?",
1095
+ { body: "[]" },
1096
+ );
1097
+ const outcome = await lookupWebFinger("acct:johndoe@example.com", {
1098
+ withDiagnostics: true,
1099
+ });
1100
+ deepStrictEqual(outcome.resource, null);
1101
+ deepStrictEqual(outcome.result, "invalid");
1102
+ deepStrictEqual(outcome.statusCode, 200);
1103
+ deepStrictEqual(outcome.remoteHost, "example.com");
1104
+ },
1105
+ );
1106
+
1107
+ await t.step("error", async () => {
1108
+ fetchMock.removeRoutes();
1109
+ fetchMock.get(
1110
+ "begin:https://example.com/.well-known/webfinger?",
1111
+ { status: 500 },
1112
+ );
1113
+ const outcome = await lookupWebFinger("acct:johndoe@example.com", {
1114
+ withDiagnostics: true,
1115
+ });
1116
+ deepStrictEqual(outcome.resource, null);
1117
+ deepStrictEqual(outcome.result, "error");
1118
+ deepStrictEqual(outcome.statusCode, 500);
1119
+ deepStrictEqual(outcome.remoteHost, "example.com");
1120
+ });
1121
+ } finally {
1122
+ fetchMock.removeRoutes();
1123
+ fetchMock.hardReset();
1124
+ }
1125
+ });
1126
+
1127
+ test(
1128
+ "lookupWebFinger() accepts a plain LookupWebFingerOptions-typed variable",
1129
+ async () => {
1130
+ // Regression test: a variable typed as `LookupWebFingerOptions`, built
1131
+ // separately from the call site and never mentioning `withDiagnostics`,
1132
+ // must still match the default overload. This is a type-level
1133
+ // concern; the point of this test is that the file compiles at all.
1134
+ fetchMock.spyGlobal();
1135
+ try {
1136
+ const expected: ResourceDescriptor = {
1137
+ subject: "acct:johndoe@example.com",
1138
+ links: [],
1139
+ };
1140
+ fetchMock.get(
1141
+ "https://example.com/.well-known/webfinger?resource=acct%3Ajohndoe%40example.com",
1142
+ { body: expected },
1143
+ );
1144
+ const options: LookupWebFingerOptions = { userAgent: "test-agent" };
1145
+ const result = await lookupWebFinger(
1146
+ "acct:johndoe@example.com",
1147
+ options,
1148
+ );
1149
+ deepStrictEqual(result, expected);
1150
+ } finally {
1151
+ fetchMock.removeRoutes();
1152
+ fetchMock.hardReset();
1153
+ }
1154
+ },
1155
+ );
package/src/lookup.ts CHANGED
@@ -124,10 +124,33 @@ function getMetricResourceScheme(scheme: string): string {
124
124
  return WEBFINGER_LOOKUP_SCHEME_WHITELIST.has(scheme) ? scheme : "other";
125
125
  }
126
126
 
127
- interface WebFingerLookupOutcome {
127
+ /**
128
+ * The outcome of a {@link lookupWebFinger} call, returned in place of the
129
+ * plain {@link ResourceDescriptor} when `options.withDiagnostics` is `true`.
130
+ * @since 2.5.0
131
+ */
132
+ export interface WebFingerLookupOutcome {
133
+ /**
134
+ * The resource descriptor fetched from the remote WebFinger endpoint, or
135
+ * `null` if the lookup did not succeed.
136
+ */
128
137
  resource: ResourceDescriptor | null;
138
+
139
+ /**
140
+ * The terminal classification of the lookup. See
141
+ * {@link WebFingerLookupResult} for what each value means.
142
+ */
129
143
  result: WebFingerLookupResult;
144
+
145
+ /**
146
+ * The HTTP status code of the final response, if a response was received
147
+ * from the remote server.
148
+ */
130
149
  statusCode?: number;
150
+
151
+ /**
152
+ * The host of the final URL the lookup attempted to reach.
153
+ */
131
154
  remoteHost?: string;
132
155
  }
133
156
 
@@ -193,8 +216,42 @@ export interface LookupWebFingerOptions {
193
216
  */
194
217
  export async function lookupWebFinger(
195
218
  resource: URL | string,
196
- options: LookupWebFingerOptions = {},
197
- ): Promise<ResourceDescriptor | null> {
219
+ options?: LookupWebFingerOptions & {
220
+ /**
221
+ * Whether to return a {@link WebFingerLookupOutcome} describing why
222
+ * the lookup failed, instead of collapsing every failure into `null`.
223
+ * Turned off by default.
224
+ * @since 2.5.0
225
+ */
226
+ withDiagnostics?: false;
227
+ },
228
+ ): Promise<ResourceDescriptor | null>;
229
+
230
+ /**
231
+ * Looks up a WebFinger resource, returning diagnostics that explain why the
232
+ * lookup failed instead of collapsing every failure into `null`.
233
+ * @param resource The resource URL to look up.
234
+ * @param options Extra options for looking up the resource. Must set
235
+ * `withDiagnostics: true`.
236
+ * @returns The outcome of the lookup.
237
+ * @since 2.5.0
238
+ */
239
+ export async function lookupWebFinger(
240
+ resource: URL | string,
241
+ options: LookupWebFingerOptions & {
242
+ /**
243
+ * Returns a {@link WebFingerLookupOutcome} instead of a plain
244
+ * {@link ResourceDescriptor}.
245
+ * @since 2.5.0
246
+ */
247
+ withDiagnostics: true;
248
+ },
249
+ ): Promise<WebFingerLookupOutcome>;
250
+
251
+ export async function lookupWebFinger(
252
+ resource: URL | string,
253
+ options: LookupWebFingerOptions & { withDiagnostics?: boolean } = {},
254
+ ): Promise<ResourceDescriptor | null | WebFingerLookupOutcome> {
198
255
  const tracerProvider = options.tracerProvider ?? trace.getTracerProvider();
199
256
  const tracer = tracerProvider.getTracer(
200
257
  metadata.name,
@@ -228,7 +285,7 @@ export async function lookupWebFinger(
228
285
  ? SpanStatusCode.ERROR
229
286
  : SpanStatusCode.OK,
230
287
  });
231
- return outcome.resource;
288
+ return options.withDiagnostics ? outcome : outcome.resource;
232
289
  } catch (error) {
233
290
  span.setStatus({
234
291
  code: SpanStatusCode.ERROR,
@@ -438,11 +495,24 @@ async function lookupWebFingerInternal(
438
495
  };
439
496
  }
440
497
  try {
441
- const parsed = JSON.parse(
498
+ const parsed: unknown = JSON.parse(
442
499
  await readBoundedText(response, MAX_BODY_SIZE, url),
443
- ) as ResourceDescriptor;
500
+ );
501
+ // A body that parses to anything other than a JSON object—such as
502
+ // a literal `null`, a number, or an array—is not a resource
503
+ // descriptor, even though JSON.parse() accepts it without error.
504
+ if (
505
+ typeof parsed !== "object" || parsed === null || Array.isArray(parsed)
506
+ ) {
507
+ return {
508
+ resource: null,
509
+ result: "invalid",
510
+ statusCode: response.status,
511
+ remoteHost,
512
+ };
513
+ }
444
514
  return {
445
- resource: parsed,
515
+ resource: parsed as ResourceDescriptor,
446
516
  result: "found",
447
517
  statusCode: response.status,
448
518
  remoteHost,