From 270e8b433a70892b72a7a56af5f015a8a75d51f1 Mon Sep 17 00:00:00 2001 From: Nas Kavian Date: Wed, 23 Sep 2026 14:53:06 -0400 Subject: [PATCH] feat(directory): support source-aware discovery --- .changeset/source-aware-directory.md | 5 + README.md | 5 +- examples/odp-agent-discovery/README.md | 2 +- examples/odp-agent-discovery/src/index.ts | 4 + .../odp-agent-discovery/src/mock-directory.ts | 1 + packages/directory/README.md | 53 +++- packages/directory/src/index.ts | 130 ++++++++- .../directory/test/unit/mixed-search.test.ts | 1 + packages/directory/test/unit/source.test.ts | 264 ++++++++++++++++++ 9 files changed, 451 insertions(+), 14 deletions(-) create mode 100644 .changeset/source-aware-directory.md create mode 100644 packages/directory/test/unit/source.test.ts diff --git a/.changeset/source-aware-directory.md b/.changeset/source-aware-directory.md new file mode 100644 index 0000000..6384a74 --- /dev/null +++ b/.changeset/source-aware-directory.md @@ -0,0 +1,5 @@ +--- +"@offering-protocol/directory": minor +--- + +Support source-aware Directory discovery and source filters for search and suggestions. Mixed results include the exact discovery document URL and allow imported metadata to be absent. Native ODP Service search retains its existing validation contract. diff --git a/README.md b/README.md index 90bf9e8..a03037f 100644 --- a/README.md +++ b/README.md @@ -65,11 +65,14 @@ Use `npm install` or `yarn add` if those are the package managers in your applic For general Directory discovery, use [`directory.search()`](./packages/directory/README.md#search-the-directory) to receive typed Service and Collection results, and `directory.suggest()` to obtain matching names. +Mixed results include native ODP and imported OpenAPI sources. Check `result.service.source.type` +before using an ODP client; imported results provide their exact document URL in `source.url`. +Search and suggestions accept `filters.sources` to select `odp`, `openapi`, or both. The Directory indexes submitted Collections, not every Offering in a Service's catalog. Its mixed search returns at most 100 results and currently has no continuation; refine queries to narrow results. `createOdpAgent` searches the canonical directory and then searches the live catalogs of matching -Services. This orchestration uses `searchServices()`, the Service-only API, and does not interpret +Services. This orchestration uses `searchServices()`, the native ODP Service-only API, and does not interpret mixed results as Services. Directory results never pretend to contain complete Service catalogs. ```ts diff --git a/examples/odp-agent-discovery/README.md b/examples/odp-agent-discovery/README.md index 9e329a1..c9fe75f 100644 --- a/examples/odp-agent-discovery/README.md +++ b/examples/odp-agent-discovery/README.md @@ -25,5 +25,5 @@ Unreachable URLs are skipped. The example calls `directory.search()` and branche `type`. For a Service result, it prints the mock entry, validated ODP Service document, first terse Offering page, and full details for the first Offering. For a Collection result, it prints the entry, inspects the owning Service, and fetches the live Collection using that Service and Collection ID. -Unknown result types are reported without contacting their contents. The marketplace example +Unknown result types and non-ODP sources are reported without contacting their contents. The marketplace example provides Collections, so run it to exercise both known result types. diff --git a/examples/odp-agent-discovery/src/index.ts b/examples/odp-agent-discovery/src/index.ts index d63fea4..3cebed2 100644 --- a/examples/odp-agent-discovery/src/index.ts +++ b/examples/odp-agent-discovery/src/index.ts @@ -32,6 +32,10 @@ for await (const result of directory.search().items) { continue; } const service = result.service; + if (service.source.type !== "odp") { + print("Non-ODP discovery document", service.source); + continue; + } discovered += 1; const serviceUrl = mock.serviceUrlFor(service.service_origin); const client = createOdpServiceClient({ diff --git a/examples/odp-agent-discovery/src/mock-directory.ts b/examples/odp-agent-discovery/src/mock-directory.ts index eba2a68..c7ea7bf 100644 --- a/examples/odp-agent-discovery/src/mock-directory.ts +++ b/examples/odp-agent-discovery/src/mock-directory.ts @@ -31,6 +31,7 @@ export async function createMockDirectory(serviceUrls: string[]): Promise { name?: string; } -export interface DirectoryIndexedService extends DirectoryService { +export type DirectorySourceType = "odp" | "openapi"; + +export interface DirectorySource extends Record { + type: string; + url: string; + x402_discovery: boolean; +} + +export interface DirectoryIndexedService extends Record { service_id: string; + service_origin: string; + source: DirectorySource; + name: string; + indexed_at: string; + description?: string; + documentation_url?: string; + language?: string; + localizations?: string[]; + keywords?: string[]; + operations?: OperationDescriptor[]; + protocols?: ServiceProtocols; + status_url?: string; + support_url?: string; + website_url?: string; } export interface DirectoryServiceResult extends Record { @@ -528,9 +551,11 @@ function parseResult(value: unknown): DirectoryResult { if (type !== "service" && type !== "collection") return { type: "unknown", resource_type: type, raw: { ...object } }; const serviceObject = requireObject(object["service"], "service"); + const source = parseSource(serviceObject["source"]); const service: DirectoryIndexedService = { - ...parseService(serviceObject), - service_id: requireText(serviceObject["service_id"], "service_id", 1, 128) + ...(source.type === "odp" ? parseService(serviceObject) : parseImportedService(serviceObject)), + service_id: requireText(serviceObject["service_id"], "service_id", 1, 128), + source }; const indexedAt = parseIndexedAt(object["indexed_at"]); if (type === "service") { @@ -563,6 +588,102 @@ function parseResult(value: unknown): DirectoryResult { }; } +function parseSource(value: unknown): DirectorySource { + const object = requireObject(value, "source"); + const type = requireText(object["type"], "source.type", 1, 128); + const address = requireText(object["url"], "source.url", 1, 2048); + const url = new URL(address); + if ( + url.protocol !== "https:" || + url.username !== "" || + url.password !== "" || + address.includes("#") || + isPrivateHost(url.hostname) + ) + throw new TypeError( + "source.url must be a public HTTPS document URL without credentials or a fragment" + ); + const discovery = object["x402_discovery"]; + if (typeof discovery !== "boolean") + throw new TypeError("source.x402_discovery must be a boolean"); + return { ...object, type, url: address, x402_discovery: discovery }; +} + +function parseImportedService( + object: Record +): Pick & + Record { + const reference = parseServiceReference(object); + const normalized = { ...object }; + for (const member of [...UNVERIFIED_MEMBERS, "operations", "protocols"]) + delete normalized[member]; + for (const field of [ + "description", + "documentation_url", + "language", + "status_url", + "support_url", + "website_url" + ]) { + if (object[field] !== undefined && typeof object[field] !== "string") + throw new TypeError(`${field} must be a string`); + } + for (const field of ["keywords", "localizations"]) { + const value = object[field]; + if (value !== undefined) { + if (!Array.isArray(value)) throw new TypeError(`${field} must be an array of strings`); + const entries: unknown[] = value; + normalized[field] = entries.map((item) => { + if (typeof item !== "string") throw new TypeError(`${field} must be an array of strings`); + return item; + }); + } + } + const protocols = + object["protocols"] === undefined ? undefined : parseImportedProtocols(object["protocols"]); + return { + ...normalized, + service_origin: reference.service_origin, + name: requireText(object["name"], "name", 1, 128), + indexed_at: parseIndexedAt(object["indexed_at"]), + ...(protocols === undefined ? {} : { protocols }) + }; +} + +function parseImportedProtocols(value: unknown): ServiceProtocols { + const object = requireObject(value, "protocols"); + const enrollment = recognizedDescriptors(object["enrollment"], ["aep"], parseEnrollment); + const payments = recognizedDescriptors(object["payments"], ["mpp", "x402"], parsePayment); + const trust = recognizedDescriptors(object["trust"], ["tap"], parseTrust); + const result: ServiceProtocols = {}; + if (enrollment[0] !== undefined) result.enrollment = [enrollment[0]]; + if (trust[0] !== undefined) result.trust = [trust[0]]; + if (payments[0] !== undefined) + result.payments = payments[1] === undefined ? [payments[0]] : [payments[0], payments[1]]; + return result; +} + +function recognizedDescriptors( + value: unknown, + names: string[], + parse: (value: unknown) => Value +): Value[] { + if (value === undefined) return []; + if (!Array.isArray(value)) throw new TypeError("protocol descriptors must be an array"); + const result: Value[] = []; + const seen = new Set(); + for (const entry of value) { + const descriptor = requireObject(entry, "protocol descriptor"); + const name = requireText(descriptor["name"], "protocol name", 1, 128); + if (names.includes(name)) { + if (seen.has(name)) throw new TypeError("protocol names must be unique"); + seen.add(name); + result.push(parse(entry)); + } + } + return result; +} + function parseServiceReference(value: unknown): DirectoryServiceReference { const object = requireObject(value, "available_through"); const serviceOrigin = requireText(object["service_origin"], "service_origin", 1, 2048); @@ -587,6 +708,9 @@ function parseIndexedAt(value: unknown): string { function validateFilters(filters: DirectoryServiceFilters): DirectoryServiceFilters { return { + ...(filters.sources === undefined + ? {} + : { sources: uniqueEnums(filters.sources, "sources", ["odp", "openapi"] as const) }), ...(filters.keywords === undefined ? {} : { keywords: uniqueText(filters.keywords, "keywords", 32, 64) }), diff --git a/packages/directory/test/unit/mixed-search.test.ts b/packages/directory/test/unit/mixed-search.test.ts index 229ed36..d6e2a2b 100644 --- a/packages/directory/test/unit/mixed-search.test.ts +++ b/packages/directory/test/unit/mixed-search.test.ts @@ -4,6 +4,7 @@ import { createDirectoryClient, type DirectoryResourceSearchRequest } from "../. const indexedAt = "2026-09-18T12:00:00Z"; const service = { + source: { type: "odp", url: "https://api.example.com/.well-known/odp", x402_discovery: false }, service_id: "ca0304cc-ab28-43e5-af94-7bdf11b40c6e", service_origin: "https://api.example.com", name: "Example Service", diff --git a/packages/directory/test/unit/source.test.ts b/packages/directory/test/unit/source.test.ts new file mode 100644 index 0000000..0b0dde1 --- /dev/null +++ b/packages/directory/test/unit/source.test.ts @@ -0,0 +1,264 @@ +import { describe, expect, it, vi } from "vitest"; +import { createDirectoryClient, type DirectoryServiceFilters } from "../../src/index.js"; + +const source = { + type: "openapi", + url: "https://docs.example.com/v1/OpenAPI.json?revision=2", + x402_discovery: false +}; +const service = { + service_id: "imported", + service_origin: "https://api.example.com", + name: "Search", + indexed_at: "2026-09-23T00:00:00Z", + source +}; +const result = { type: "service", service, indexed_at: service.indexed_at }; + +async function collect(values: AsyncIterable): Promise { + const result: Value[] = []; + for await (const value of values) result.push(value); + return result; +} + +function inputUrl(input: string | Request | URL | undefined): string { + return input instanceof Request ? input.url : String(input); +} + +function clientFor(items: unknown[]) { + const transport = vi + .fn() + .mockImplementation(() => Promise.resolve(Response.json({ items }))); + return { client: createDirectoryClient({ transport }), transport }; +} + +describe("source-aware Directory discovery", () => { + it("preserves exact source URLs and omitted metadata for Services and derived Collections", async () => { + const collection = { + ...result, + type: "collection", + collection: { id: "group-1", name: "Search tools" } + }; + const { client, transport } = clientFor([result, collection]); + expect(await collect(client.search().pages)).toEqual([{ items: [result, collection] }]); + expect(transport).toHaveBeenCalledTimes(1); + expect(inputUrl(transport.mock.calls[0]?.[0])).toBe( + "https://api.inflowpay.ai/v1/directory/search" + ); + }); + + it("retains imported metadata and known protocols without exposing unvalidated ODP fields", async () => { + const metadata = { + description: "", + documentation_url: "https://docs.example.com/", + language: "en", + localizations: ["en"], + keywords: ["search"], + status_url: "https://status.example.com/", + support_url: "https://example.com/support", + website_url: "https://example.com/", + extra: 1, + protocols: { + enrollment: [{ name: "aep" }], + payments: [{ name: "x402", authentication: "not-required", options: ["solana"] }], + trust: [{ name: "tap" }] + } + }; + const poisoned = { + ...service, + ...metadata, + source: { ...source, x402_discovery: true, extra: 1 }, + operations: [{ name: "get-offering" }], + http: { endpoint_base: "https://bad.example" }, + mcp: [], + odp_version: "1.0", + branding: {}, + payment_origins: [], + search_capabilities: {} + }; + const { client } = clientFor([{ ...result, service: poisoned }]); + expect(await collect(client.search().items)).toEqual([ + { ...result, service: { ...service, ...metadata, source: poisoned.source } } + ]); + }); + + it("keeps unfamiliar source formats displayable without treating them as ODP", async () => { + const item = { + ...result, + service: { + ...service, + source: { ...source, type: "future" }, + protocols: { + enrollment: [{ name: "future" }], + payments: [{ name: "future" }], + trust: [{ name: "future" }] + } + } + }; + const { client } = clientFor([item]); + expect(await collect(client.search().items)).toEqual([ + { + ...item, + service: { ...item.service, protocols: {} } + } + ]); + const empty = { ...result, service: { ...service, protocols: {} } }; + expect(await collect(clientFor([empty]).client.search().items)).toEqual([empty]); + }); + + it("isolates invalid source records without inferring a source for missing metadata", async () => { + const invalid = [ + undefined, + null, + [], + {}, + { ...source, type: "" }, + { ...source, type: 1 }, + ...[ + undefined, + null, + "", + "relative.json", + "http://example.com/api.json", + "https://user@example.com/api.json", + "https://example.com/a#", + "https://localhost/a", + "https://127.0.0.1/a" + ].map((url) => ({ ...source, url })), + ...[undefined, null, "false", 1].map((x402_discovery) => ({ ...source, x402_discovery })) + ]; + const { client } = clientFor([ + ...invalid.map((source) => ({ ...result, service: { ...service, source } })), + result + ]); + const [page] = await collect(client.search().pages); + expect(page?.items).toEqual([result]); + expect(page?.issues?.map((issue) => issue.index)).toEqual(invalid.map((_, index) => index)); + }); + + it("rejects malformed imported fields and recognized protocol descriptors", async () => { + const invalid = [ + { service_id: "" }, + { service_origin: "https://localhost" }, + { name: undefined }, + { indexed_at: "bad" }, + ...[ + "description", + "documentation_url", + "language", + "status_url", + "support_url", + "website_url" + ].map((field) => ({ [field]: null })), + ...["keywords", "localizations"].flatMap((field) => + [null, "en", [null]].map((value) => ({ [field]: value })) + ), + ...[ + null, + [], + { payments: null }, + { enrollment: "aep" }, + { trust: [null] }, + { payments: [{}] }, + { payments: [{ name: "x402" }] }, + { enrollment: [{ name: "aep" }, { name: "aep" }] }, + { enrollment: [{ name: "aep", invalid: true }] }, + { trust: [{ name: "tap", invalid: true }] } + ].map((protocols) => ({ protocols })) + ]; + const [page] = await collect( + clientFor([ + ...invalid.map((value) => ({ ...result, service: { ...service, ...value } })), + result + ]).client.search().pages + ); + expect(page?.items).toEqual([result]); + expect(page?.issues).toHaveLength(invalid.length); + }); + + it("retains both supported payment protocols", async () => { + const item = { + ...result, + service: { + ...service, + protocols: { + payments: [ + { name: "mpp", authentication: "required" }, + { name: "x402", authentication: "not-required" } + ] + } + } + }; + expect(await collect(clientFor([item]).client.search().items)).toEqual([item]); + }); + + it("keeps native ODP validation strict in mixed and Service-only search", async () => { + const native = { ...service, source: { ...source, type: "odp" } }; + const { client } = clientFor([{ ...result, service: native }]); + const [mixed] = await collect(client.search().pages); + expect(mixed?.items).toEqual([]); + expect(mixed?.issues).toHaveLength(1); + const [legacy] = await collect(clientFor([service]).client.searchServices().pages); + expect(legacy?.items).toEqual([]); + expect(legacy?.issues).toHaveLength(1); + }); + + it("parses imported records on continuation without fetching source documents", async () => { + const { client, transport } = clientFor([result]); + expect(await collect(client.continueSearch("/v1/directory/search?cursor=2").items)).toEqual([ + result + ]); + expect(transport).toHaveBeenCalledTimes(1); + expect(transport.mock.calls[0]?.[1]?.method).toBe("GET"); + }); + + it("copies source filters and sends them on mixed search, native search and suggestions", async () => { + const transport = vi + .fn() + .mockImplementation((input) => + Promise.resolve( + Response.json({ items: inputUrl(input).includes("suggestions") ? ["Weather"] : [] }) + ) + ); + const client = createDirectoryClient({ transport }); + const filters: DirectoryServiceFilters = { sources: ["openapi"], payments: [{ name: "x402" }] }; + const sequence = client.search({ filters }); + filters.sources?.splice(0); + await collect(sequence.items); + const body = transport.mock.calls[0]?.[1]?.body; + expect(typeof body === "string" ? JSON.parse(body) : null).toEqual({ + filters: { sources: ["openapi"], payments: [{ name: "x402" }] } + }); + for (const sources of [["odp"], ["openapi"], ["odp", "openapi"]] satisfies NonNullable< + DirectoryServiceFilters["sources"] + >[]) { + await collect(client.searchServices({ filters: { sources } }).items); + expect(await client.suggest({ prefix: "we", filters: { sources } })).toEqual(["Weather"]); + const body = transport.mock.lastCall?.[1]?.body; + expect(typeof body === "string" ? JSON.parse(body) : null).toEqual({ + prefix: "we", + filters: { sources } + }); + } + }); + + it("rejects invalid source filters before sending a request", async () => { + const { client, transport } = clientFor([]); + for (const sources of [ + null, + "odp", + [], + ["ODP"], + ["future"], + ["odp", "odp"], + ["odp", "openapi", "future"] + ]) { + // Exercise untyped JavaScript input at the public boundary. + const filters = { sources } as DirectoryServiceFilters; + expect(() => client.search({ filters })).toThrow("sources"); + expect(() => client.searchServices({ filters })).toThrow("sources"); + await expect(client.suggest({ prefix: "we", filters })).rejects.toThrow("sources"); + } + expect(transport).not.toHaveBeenCalled(); + }); +});