From f288d45cf342c8c69d60ee10e65280618183d396 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Wed, 22 Jul 2026 02:52:17 +0300 Subject: [PATCH] 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")