From f288d45cf342c8c69d60ee10e65280618183d396 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Wed, 22 Jul 2026 02:52:17 +0300 Subject: [PATCH 1/3] feat(did): add optional fetch timeout to the did:web resolver --- .changeset/did-web-resolver-fetch-timeout.md | 10 ++ .../did-resolvers/web-did-resolver.test.ts | 110 ++++++++++++++++++ .../did/src/did-resolvers/web-did-resolver.ts | 30 ++++- 3 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 .changeset/did-web-resolver-fetch-timeout.md diff --git a/.changeset/did-web-resolver-fetch-timeout.md b/.changeset/did-web-resolver-fetch-timeout.md new file mode 100644 index 0000000..1306f3a --- /dev/null +++ b/.changeset/did-web-resolver-fetch-timeout.md @@ -0,0 +1,10 @@ +--- +"@agentcommercekit/did": minor +--- + +Add an optional `timeout` to `getResolver`'s `DidWebResolverOptions` for the +`did:web` resolver. Resolving a `did:web` DID fetches the host named in the +DID, so an unresponsive or slow host could otherwise hang the caller +indefinitely. When set, the resolver passes `AbortSignal.timeout(timeout)` to +the underlying fetch. Omitting it keeps the previous behaviour (no timeout), +so this change is backward compatible. diff --git a/packages/did/src/did-resolvers/web-did-resolver.test.ts b/packages/did/src/did-resolvers/web-did-resolver.test.ts index be05eac..3dbcbc6 100644 --- a/packages/did/src/did-resolvers/web-did-resolver.test.ts +++ b/packages/did/src/did-resolvers/web-did-resolver.test.ts @@ -375,5 +375,115 @@ describe("web-did-resolver", () => { expect(customFetch).toHaveBeenCalled() expect(mockFetch).not.toHaveBeenCalled() }) + + it("passes an abort signal built from the configured timeout", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockDidDocument), + }) + const timeoutSpy = vi.spyOn(AbortSignal, "timeout") + + const did = "did:web:example.com" + const resolver = getResolver({ timeout: 5000 }) + const parsedDid: ParsedDID = { + did, + didUrl: did, + method: "web", + id: "example.com", + } + await resolver.web( + did, + parsedDid, + { + resolve: + vi.fn< + (didUrl: string, options?: object) => Promise + >(), + }, + {}, + ) + + expect(timeoutSpy).toHaveBeenCalledWith(5000) + expect(mockFetch).toHaveBeenCalledWith( + "https://example.com/.well-known/did.json", + expect.objectContaining({ + mode: "cors", + signal: expect.any(AbortSignal), + }), + ) + timeoutSpy.mockRestore() + }) + + it("does not pass a signal when no timeout is set", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockDidDocument), + }) + + const did = "did:web:example.com" + const resolver = getResolver() + const parsedDid: ParsedDID = { + did, + didUrl: did, + method: "web", + id: "example.com", + } + await resolver.web( + did, + parsedDid, + { + resolve: + vi.fn< + (didUrl: string, options?: object) => Promise + >(), + }, + {}, + ) + + // Exact init, so this fails if a signal (or anything else) is added. + expect(mockFetch).toHaveBeenCalledWith( + "https://example.com/.well-known/did.json", + { mode: "cors" }, + ) + }) + + it("throws for an invalid timeout", () => { + expect(() => getResolver({ timeout: 0 })).toThrow(TypeError) + expect(() => getResolver({ timeout: -1 })).toThrow(TypeError) + expect(() => getResolver({ timeout: 1.5 })).toThrow(TypeError) + expect(() => getResolver({ timeout: NaN })).toThrow(TypeError) + }) + + it("surfaces a timed-out fetch as a notFound resolution error", async () => { + mockFetch.mockRejectedValueOnce( + new DOMException("The operation timed out.", "TimeoutError"), + ) + + const did = "did:web:example.com" + const resolver = getResolver({ timeout: 1 }) + const parsedDid: ParsedDID = { + did, + didUrl: did, + method: "web", + id: "example.com", + } + const result = await resolver.web( + did, + parsedDid, + { + resolve: + vi.fn< + (didUrl: string, options?: object) => Promise + >(), + }, + {}, + ) + + expect(result.didDocument).toBeNull() + expect(result.didResolutionMetadata.error).toBe("notFound") + expect(result.didResolutionMetadata.message).toContain( + "The operation timed out.", + ) + }) }) }) diff --git a/packages/did/src/did-resolvers/web-did-resolver.ts b/packages/did/src/did-resolvers/web-did-resolver.ts index e12f377..e35c529 100644 --- a/packages/did/src/did-resolvers/web-did-resolver.ts +++ b/packages/did/src/did-resolvers/web-did-resolver.ts @@ -44,6 +44,16 @@ export interface DidWebResolverOptions { * @default [] */ allowedHttpHosts?: string[] + /** + * Milliseconds to wait for the DID document fetch before aborting. A + * `did:web` resolution fetches the host named in the DID, so an + * unresponsive host would otherwise hang the caller indefinitely. Omit to + * keep the previous behaviour (no timeout). + * + * The timeout is applied via an `AbortSignal` on the request. A custom + * `fetch` must honour `init.signal` for it to take effect. + */ + timeout?: number } const DEFAULT_ALLOWED_HTTP_HOSTS: string[] = [] @@ -57,9 +67,15 @@ const DEFAULT_DOC_PATH = "/.well-known/did.json" */ async function fetchDidDocumentAtUrl( url: string | URL, - { fetch = globalThis.fetch }: { fetch?: FetchLike } = {}, + { + fetch = globalThis.fetch, + timeout, + }: { fetch?: FetchLike; timeout?: number } = {}, ): Promise { - const res = await fetch(url, { mode: "cors" }) + const res = await fetch(url, { + mode: "cors", + ...(timeout !== undefined ? { signal: AbortSignal.timeout(timeout) } : {}), + }) if (!res.ok) { throw new Error( @@ -141,7 +157,15 @@ export function getResolver({ docPath = DEFAULT_DOC_PATH, fetch = globalThis.fetch, allowedHttpHosts = DEFAULT_ALLOWED_HTTP_HOSTS, + timeout, }: DidWebResolverOptions = {}): { web: DIDResolver } { + // Fail fast on a bad timeout: `AbortSignal.timeout` throws on non-positive, + // non-integer or non-finite values, and that would otherwise surface as a + // misleading `notFound` resolution error rather than a programmer error. + if (timeout !== undefined && (!Number.isInteger(timeout) || timeout <= 0)) { + throw new TypeError("`timeout` must be a positive integer (milliseconds)") + } + async function resolve( did: string, parsed: ParsedDID, @@ -155,7 +179,7 @@ export function getResolver({ let didDocument: DIDDocument | null = null try { - didDocument = await fetchDidDocumentAtUrl(url, { fetch }) + didDocument = await fetchDidDocumentAtUrl(url, { fetch, timeout }) if (!isDidDocumentForDid(didDocument, did)) { throw new Error("DID document id does not match requested did") From 814f8d8dfac2bc9e82d97425889d214e2cbd7311 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Thu, 30 Jul 2026 12:15:04 +0300 Subject: [PATCH 2/3] feat(did): default the did:web fetch timeout to 5000ms Address review feedback: - timeout now defaults to 5000ms instead of being opt-in - out-of-range values throw RangeError instead of TypeError - correct the validation rationale: AbortSignal.timeout(0) does not throw, 0 is rejected because it would abort every request before it starts - trim the option docs and add @default - update the changeset (the previous backward-compat wording no longer held) - tests: default-signal assertion, RangeError, exact-init updates --- .changeset/did-web-resolver-fetch-timeout.md | 14 +++++---- .../did-resolvers/web-did-resolver.test.ts | 29 +++++++++++-------- .../did/src/did-resolvers/web-did-resolver.ts | 19 ++++++------ 3 files changed, 34 insertions(+), 28 deletions(-) diff --git a/.changeset/did-web-resolver-fetch-timeout.md b/.changeset/did-web-resolver-fetch-timeout.md index 1306f3a..21adf0e 100644 --- a/.changeset/did-web-resolver-fetch-timeout.md +++ b/.changeset/did-web-resolver-fetch-timeout.md @@ -2,9 +2,11 @@ "@agentcommercekit/did": minor --- -Add an optional `timeout` to `getResolver`'s `DidWebResolverOptions` for the -`did:web` resolver. Resolving a `did:web` DID fetches the host named in the -DID, so an unresponsive or slow host could otherwise hang the caller -indefinitely. When set, the resolver passes `AbortSignal.timeout(timeout)` to -the underlying fetch. Omitting it keeps the previous behaviour (no timeout), -so this change is backward compatible. +Add a `timeout` option to `getResolver`'s `DidWebResolverOptions` for the +`did:web` resolver, defaulting to 5000ms. Resolving a `did:web` DID fetches +the host named in the DID, so an unresponsive or slow host could otherwise +hang the caller indefinitely. The resolver passes `AbortSignal.timeout(timeout)` +to the underlying fetch; a custom `fetch` must honour `init.signal` for the +timeout to take effect. Invalid values (zero, negative, non-integer or +non-finite) throw a `RangeError`. There is no opt-out: every resolution now +carries a deadline. diff --git a/packages/did/src/did-resolvers/web-did-resolver.test.ts b/packages/did/src/did-resolvers/web-did-resolver.test.ts index 3dbcbc6..13cb8a1 100644 --- a/packages/did/src/did-resolvers/web-did-resolver.test.ts +++ b/packages/did/src/did-resolvers/web-did-resolver.test.ts @@ -60,7 +60,7 @@ describe("web-did-resolver", () => { }) expect(mockFetch).toHaveBeenCalledWith( "https://example.com/.well-known/did.json", - { mode: "cors" }, + { mode: "cors", signal: expect.any(AbortSignal) }, ) }) @@ -92,7 +92,7 @@ describe("web-did-resolver", () => { expect(mockFetch).toHaveBeenCalledWith( "https://example.com/custom/path/did.json", - { mode: "cors" }, + { mode: "cors", signal: expect.any(AbortSignal) }, ) }) @@ -125,7 +125,7 @@ describe("web-did-resolver", () => { expect(mockFetch).toHaveBeenCalledWith( "http://localhost:8787/.well-known/did.json", - { mode: "cors" }, + { mode: "cors", signal: expect.any(AbortSignal) }, ) }) @@ -161,7 +161,7 @@ describe("web-did-resolver", () => { expect(mockFetch).toHaveBeenCalledWith( "https://example.com/issuers/v1/did.json", - { mode: "cors" }, + { mode: "cors", signal: expect.any(AbortSignal) }, ) }) @@ -197,7 +197,7 @@ describe("web-did-resolver", () => { expect(mockFetch).toHaveBeenCalledWith( "http://localhost:8787/issuers/v1/did.json", - { mode: "cors" }, + { mode: "cors", signal: expect.any(AbortSignal) }, ) }) @@ -414,11 +414,12 @@ describe("web-did-resolver", () => { timeoutSpy.mockRestore() }) - it("does not pass a signal when no timeout is set", async () => { + it("applies the 5000ms default timeout when none is set", async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockDidDocument), }) + const timeoutSpy = vi.spyOn(AbortSignal, "timeout") const did = "did:web:example.com" const resolver = getResolver() @@ -440,18 +441,22 @@ describe("web-did-resolver", () => { {}, ) - // Exact init, so this fails if a signal (or anything else) is added. + expect(timeoutSpy).toHaveBeenCalledWith(5000) expect(mockFetch).toHaveBeenCalledWith( "https://example.com/.well-known/did.json", - { mode: "cors" }, + expect.objectContaining({ + mode: "cors", + signal: expect.any(AbortSignal), + }), ) + timeoutSpy.mockRestore() }) it("throws for an invalid timeout", () => { - expect(() => getResolver({ timeout: 0 })).toThrow(TypeError) - expect(() => getResolver({ timeout: -1 })).toThrow(TypeError) - expect(() => getResolver({ timeout: 1.5 })).toThrow(TypeError) - expect(() => getResolver({ timeout: NaN })).toThrow(TypeError) + expect(() => getResolver({ timeout: 0 })).toThrow(RangeError) + expect(() => getResolver({ timeout: -1 })).toThrow(RangeError) + expect(() => getResolver({ timeout: 1.5 })).toThrow(RangeError) + expect(() => getResolver({ timeout: NaN })).toThrow(RangeError) }) it("surfaces a timed-out fetch as a notFound resolution error", async () => { diff --git a/packages/did/src/did-resolvers/web-did-resolver.ts b/packages/did/src/did-resolvers/web-did-resolver.ts index e35c529..1c25139 100644 --- a/packages/did/src/did-resolvers/web-did-resolver.ts +++ b/packages/did/src/did-resolvers/web-did-resolver.ts @@ -45,13 +45,11 @@ export interface DidWebResolverOptions { */ allowedHttpHosts?: string[] /** - * Milliseconds to wait for the DID document fetch before aborting. A - * `did:web` resolution fetches the host named in the DID, so an - * unresponsive host would otherwise hang the caller indefinitely. Omit to - * keep the previous behaviour (no timeout). + * Milliseconds to wait for the DID document fetch before aborting. * * The timeout is applied via an `AbortSignal` on the request. A custom * `fetch` must honour `init.signal` for it to take effect. + * @default 5000 */ timeout?: number } @@ -157,13 +155,14 @@ export function getResolver({ docPath = DEFAULT_DOC_PATH, fetch = globalThis.fetch, allowedHttpHosts = DEFAULT_ALLOWED_HTTP_HOSTS, - timeout, + timeout = 5000, }: DidWebResolverOptions = {}): { web: DIDResolver } { - // Fail fast on a bad timeout: `AbortSignal.timeout` throws on non-positive, - // non-integer or non-finite values, and that would otherwise surface as a - // misleading `notFound` resolution error rather than a programmer error. - if (timeout !== undefined && (!Number.isInteger(timeout) || timeout <= 0)) { - throw new TypeError("`timeout` must be a positive integer (milliseconds)") + // Fail fast on a bad timeout rather than surfacing it later as a + // misleading `notFound` resolution error: `AbortSignal.timeout` throws on + // negative, non-integer or non-finite values, and 0, while legal for the + // API, would abort every request before it starts. + if (timeout <= 0 || !Number.isInteger(timeout)) { + throw new RangeError("`timeout` must be a positive integer (milliseconds)") } async function resolve( From f1e967068031bcbd58701a42bd9d580d6649aeae Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Thu, 30 Jul 2026 12:26:04 +0300 Subject: [PATCH 3/3] fix(did): cap the did:web fetch timeout at the 32-bit timer limit Values in (2^31-1, 2^32-1] pass AbortSignal.timeout in Node but the timer clamps to 1ms (TimeoutOverflowWarning), aborting every request; values above 2^32-1 throw at fetch time. Both surfaced as a misleading notFound, which is exactly what the fail-fast guard exists to prevent. Reject timeouts above 2147483647ms up front and document that the maximum is an effective opt-out. --- .changeset/did-web-resolver-fetch-timeout.md | 8 +++++--- .../src/did-resolvers/web-did-resolver.test.ts | 1 + .../did/src/did-resolvers/web-did-resolver.ts | 18 ++++++++++++------ 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/.changeset/did-web-resolver-fetch-timeout.md b/.changeset/did-web-resolver-fetch-timeout.md index 21adf0e..36cd320 100644 --- a/.changeset/did-web-resolver-fetch-timeout.md +++ b/.changeset/did-web-resolver-fetch-timeout.md @@ -7,6 +7,8 @@ Add a `timeout` option to `getResolver`'s `DidWebResolverOptions` for the the host named in the DID, so an unresponsive or slow host could otherwise hang the caller indefinitely. The resolver passes `AbortSignal.timeout(timeout)` to the underlying fetch; a custom `fetch` must honour `init.signal` for the -timeout to take effect. Invalid values (zero, negative, non-integer or -non-finite) throw a `RangeError`. There is no opt-out: every resolution now -carries a deadline. +timeout to take effect. Values outside 1..2147483647 (the 32-bit +timer limit) throw a `RangeError`: beyond it, runtimes either clamp the timer +to 1ms or throw at fetch time, both of which would surface as a misleading +`notFound`. There is no first-class opt-out; passing the maximum (about 24.8 +days) effectively disables the timeout. diff --git a/packages/did/src/did-resolvers/web-did-resolver.test.ts b/packages/did/src/did-resolvers/web-did-resolver.test.ts index 13cb8a1..b0c0616 100644 --- a/packages/did/src/did-resolvers/web-did-resolver.test.ts +++ b/packages/did/src/did-resolvers/web-did-resolver.test.ts @@ -457,6 +457,7 @@ describe("web-did-resolver", () => { expect(() => getResolver({ timeout: -1 })).toThrow(RangeError) expect(() => getResolver({ timeout: 1.5 })).toThrow(RangeError) expect(() => getResolver({ timeout: NaN })).toThrow(RangeError) + expect(() => getResolver({ timeout: 2147483648 })).toThrow(RangeError) }) it("surfaces a timed-out fetch as a notFound resolution error", async () => { diff --git a/packages/did/src/did-resolvers/web-did-resolver.ts b/packages/did/src/did-resolvers/web-did-resolver.ts index 1c25139..fdbcc25 100644 --- a/packages/did/src/did-resolvers/web-did-resolver.ts +++ b/packages/did/src/did-resolvers/web-did-resolver.ts @@ -45,7 +45,8 @@ export interface DidWebResolverOptions { */ allowedHttpHosts?: string[] /** - * Milliseconds to wait for the DID document fetch before aborting. + * Milliseconds to wait for the DID document fetch before aborting. Must + * be a positive integer of at most 2147483647 (the 32-bit timer limit). * * The timeout is applied via an `AbortSignal` on the request. A custom * `fetch` must honour `init.signal` for it to take effect. @@ -56,6 +57,7 @@ export interface DidWebResolverOptions { const DEFAULT_ALLOWED_HTTP_HOSTS: string[] = [] const DEFAULT_DOC_PATH = "/.well-known/did.json" +const MAX_TIMEOUT_MS = 2147483647 /** * Get a did document from a url and validate that it is a DidDocument @@ -158,11 +160,15 @@ export function getResolver({ timeout = 5000, }: DidWebResolverOptions = {}): { web: DIDResolver } { // Fail fast on a bad timeout rather than surfacing it later as a - // misleading `notFound` resolution error: `AbortSignal.timeout` throws on - // negative, non-integer or non-finite values, and 0, while legal for the - // API, would abort every request before it starts. - if (timeout <= 0 || !Number.isInteger(timeout)) { - throw new RangeError("`timeout` must be a positive integer (milliseconds)") + // misleading `notFound` resolution error. `AbortSignal.timeout` throws on + // negative, non-integer or non-finite values; 0 is legal for the API but + // would abort every request before it starts; and values beyond the + // 32-bit timer range either clamp the timer to 1ms or throw at fetch + // time, depending on the runtime. + if (timeout <= 0 || timeout > MAX_TIMEOUT_MS || !Number.isInteger(timeout)) { + throw new RangeError( + "`timeout` must be a positive integer of at most 2147483647 milliseconds", + ) } async function resolve(