From 8c3727b7408748fb739f97d9edd753fabdc1decb Mon Sep 17 00:00:00 2001 From: Matt Venables Date: Fri, 13 Feb 2026 23:48:04 -0500 Subject: [PATCH 1/2] fix(did): resolve path-based did:web documents per spec Use /.well-known/did.json only for host-only did:web identifiers and resolve path-based identifiers at /:path/did.json. Adds regression tests for both https and allowed-http host resolution with path components. --- .../did-resolvers/web-did-resolver.test.ts | 52 +++++++++++++++++++ .../did/src/did-resolvers/web-did-resolver.ts | 13 ++++- 2 files changed, 64 insertions(+), 1 deletion(-) 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 ba9138e..68716b0 100644 --- a/packages/did/src/did-resolvers/web-did-resolver.test.ts +++ b/packages/did/src/did-resolvers/web-did-resolver.test.ts @@ -93,6 +93,58 @@ describe("web-did-resolver", () => { ) }) + it("resolves path-based did:web documents at /:path/did.json", async () => { + const pathDidDocument = { + ...mockDidDocument, + id: "did:web:example.com:issuers:v1", + } + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(pathDidDocument), + }) + + const did = "did:web:example.com:issuers:v1" + const resolver = getResolver() + const parsedDid: ParsedDID = { + did, + didUrl: did, + method: "web", + id: "example.com:issuers:v1", + } + await resolver.web(did, parsedDid, { resolve: vi.fn() }, {}) + + expect(mockFetch).toHaveBeenCalledWith( + "https://example.com/issuers/v1/did.json", + { mode: "cors" }, + ) + }) + + it("allows http for specified hosts with path-based did:web documents", async () => { + const pathDidDocument = { + ...mockDidDocument, + id: "did:web:localhost%3A8787:issuers:v1", + } + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(pathDidDocument), + }) + + const did = "did:web:localhost%3A8787:issuers:v1" + const resolver = getResolver({ allowedHttpHosts: ["localhost"] }) + const parsedDid: ParsedDID = { + did, + didUrl: did, + method: "web", + id: "localhost%3A8787:issuers:v1", + } + await resolver.web(did, parsedDid, { resolve: vi.fn() }, {}) + + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:8787/issuers/v1/did.json", + { mode: "cors" }, + ) + }) + it("handles fetch errors", async () => { mockFetch.mockRejectedValueOnce(new Error("Network error")) diff --git a/packages/did/src/did-resolvers/web-did-resolver.ts b/packages/did/src/did-resolvers/web-did-resolver.ts index 004a4a7..a3461cc 100644 --- a/packages/did/src/did-resolvers/web-did-resolver.ts +++ b/packages/did/src/did-resolvers/web-did-resolver.ts @@ -105,7 +105,18 @@ function buildDidPath(did: string, docPath: string = DEFAULT_DOC_PATH): string { } const parts = did.replace("did:web:", "").split(":") - return parts.map(decodeURIComponent).join("/") + docPath + const decodedParts = parts.map(decodeURIComponent) + const [host, ...path] = decodedParts + + if (!host) { + throw new Error("Invalid did:web DID") + } + + if (path.length === 0) { + return `${host}${docPath}` + } + + return `${host}/${path.join("/")}/did.json` } /** From 6792ecc900a2cd0d859cb4417eb8673766b8dc94 Mon Sep 17 00:00:00 2001 From: Matt Venables Date: Fri, 13 Feb 2026 23:53:05 -0500 Subject: [PATCH 2/2] chore(changeset): add did resolver patch entry --- .changeset/fair-zebras-talk.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/fair-zebras-talk.md diff --git a/.changeset/fair-zebras-talk.md b/.changeset/fair-zebras-talk.md new file mode 100644 index 0000000..e020582 --- /dev/null +++ b/.changeset/fair-zebras-talk.md @@ -0,0 +1,10 @@ +--- +"@agentcommercekit/did": patch +--- + +Fix `did:web` resolution URL construction to follow the spec: + +- Keep root identifiers at `/.well-known/did.json` (for example, `did:web:example.com`) +- Resolve path-based identifiers to `/:path/did.json` (for example, `did:web:example.com:abc`) + +Also adds regression tests for path-based resolution, including `allowedHttpHosts`.