From 4131b6aa6191276197016ae89c10e917469e1556 Mon Sep 17 00:00:00 2001 From: Nas Kavian Date: Fri, 25 Sep 2026 22:03:40 -0400 Subject: [PATCH] feat(directory): replace service attribution with publishers --- .changeset/directory-publisher.md | 5 +++ packages/directory/README.md | 4 ++- packages/directory/src/index.ts | 36 ++++++++++++++----- .../directory/test/unit/mixed-search.test.ts | 30 +++++++++++----- 4 files changed, 56 insertions(+), 19 deletions(-) create mode 100644 .changeset/directory-publisher.md diff --git a/.changeset/directory-publisher.md b/.changeset/directory-publisher.md new file mode 100644 index 0000000..aedd84e --- /dev/null +++ b/.changeset/directory-publisher.md @@ -0,0 +1,5 @@ +--- +"@offering-protocol/directory": minor +--- + +Replace typed Service-to-Service attribution with publisher metadata in mixed directory results. Preserve additional response fields and accept omitted or null publisher attribution. diff --git a/packages/directory/README.md b/packages/directory/README.md index 5f8e334..396fc01 100644 --- a/packages/directory/README.md +++ b/packages/directory/README.md @@ -86,7 +86,9 @@ for all sources, or specify both. An empty list, duplicates, and unsupported fil Each known result carries `service` metadata and `indexed_at`. For a Collection, the outer timestamp describes its indexed metadata; `service.indexed_at` describes its parent. A Service result can have -`available_through`, a platform reference with `service_id`, `service_origin`, and optional `name`. +`publisher`, display attribution with `publisher_id`, `name`, and `website_url`. +The website is a human-facing link, not a discovery document or execution target. +Publisher attribution is optional; additional response fields are preserved. This describes availability, not brand ownership. A Collection's owning `service` provides its attribution. diff --git a/packages/directory/src/index.ts b/packages/directory/src/index.ts index 2e178f1..6283f53 100644 --- a/packages/directory/src/index.ts +++ b/packages/directory/src/index.ts @@ -51,7 +51,7 @@ export interface DirectoryResourceSearchRequest extends DirectorySearchRequest { types?: Array<"service" | "collection">; } -export interface DirectoryServiceReference extends Record { +interface DirectoryServiceReference extends Record { service_id: string; service_origin: string; name?: string; @@ -83,11 +83,17 @@ export interface DirectoryIndexedService extends Record { website_url?: string; } +export interface DirectoryPublisher extends Record { + publisher_id: string; + name: string; + website_url: string; +} + export interface DirectoryServiceResult extends Record { type: "service"; service: DirectoryIndexedService; indexed_at: string; - available_through?: DirectoryServiceReference; + publisher?: DirectoryPublisher | null; } export interface DirectoryCollectionResult extends Record { @@ -559,16 +565,14 @@ function parseResult(value: unknown): DirectoryResult { }; const indexedAt = parseIndexedAt(object["indexed_at"]); if (type === "service") { - const availableThrough = - object["available_through"] === undefined - ? undefined - : parseServiceReference(object["available_through"]); + const publisher = + object["publisher"] == null ? object["publisher"] : parsePublisher(object["publisher"]); return { ...object, type, service, indexed_at: indexedAt, - ...(availableThrough === undefined ? {} : { available_through: availableThrough }) + ...(publisher === undefined ? {} : { publisher }) }; } const collection = requireObject(object["collection"], "collection"); @@ -684,12 +688,26 @@ function recognizedDescriptors( return result; } +function parsePublisher(value: unknown): DirectoryPublisher { + const object = requireObject(value, "publisher"); + const website = requireText(object["website_url"], "website_url", 1, 512); + const url = new URL(website); + if (url.protocol !== "https:" || url.username !== "" || url.password !== "") + throw new TypeError("Publisher website must be an HTTPS URL without credentials"); + return { + ...object, + publisher_id: requireText(object["publisher_id"], "publisher_id", 1, 128), + name: requireText(object["name"], "name", 1, 128), + website_url: website + }; +} + function parseServiceReference(value: unknown): DirectoryServiceReference { - const object = requireObject(value, "available_through"); + const object = requireObject(value, "service"); const serviceOrigin = requireText(object["service_origin"], "service_origin", 1, 2048); const url = parseOrigin(serviceOrigin); if (url.protocol !== "https:" || url.origin !== serviceOrigin || isPrivateHost(url.hostname)) - throw new TypeError("Attribution origin must be a public HTTPS origin"); + throw new TypeError("Service origin must be a public HTTPS origin"); const name = optionalText(object["name"], "name", 128); return { ...object, diff --git a/packages/directory/test/unit/mixed-search.test.ts b/packages/directory/test/unit/mixed-search.test.ts index d6e2a2b..207fb81 100644 --- a/packages/directory/test/unit/mixed-search.test.ts +++ b/packages/directory/test/unit/mixed-search.test.ts @@ -30,8 +30,8 @@ const collection = { }; const serviceResult = { type: "service", indexed_at: service.indexed_at, service }; const attribution = { - service_id: "platform", - service_origin: "https://platform.example", + publisher_id: "platform", + website_url: "https://platform.example/catalog", name: "Platform" }; @@ -58,7 +58,7 @@ describe("mixed Directory discovery", () => { const unknown = { type: "offering", arbitrary: { id: "future" } }; const transport = vi.fn().mockResolvedValueOnce( json({ - items: [{ ...serviceResult, available_through: attribution }, collection, unknown], + items: [{ ...serviceResult, publisher: attribution }, collection, unknown], facets: { keywords: [{ value: "weather", count: 12 }] }, extra: true }) @@ -70,7 +70,7 @@ describe("mixed Directory discovery", () => { expect(pages).toEqual([ { items: [ - { ...serviceResult, available_through: attribution }, + { ...serviceResult, publisher: attribution }, collection, { type: "unknown", resource_type: "offering", raw: unknown } ], @@ -175,17 +175,23 @@ describe("mixed Directory discovery", () => { { ...collection, collection: { ...collection.collection, description: "x".repeat(1025) } }, { ...serviceResult, - available_through: { ...attribution, service_origin: "http://platform.example" } + publisher: { ...attribution, website_url: "http://platform.example" } }, { ...serviceResult, - available_through: { ...attribution, service_origin: "https://platform.example/path" } + publisher: { ...attribution, website_url: "https://user:secret@platform.example/path" } }, { ...serviceResult, - available_through: { ...attribution, service_origin: "https://127.0.0.1" } + publisher: { ...attribution, website_url: "not-a-url" } }, - { ...serviceResult, available_through: { ...attribution, service_id: "" } } + { ...serviceResult, publisher: { ...attribution, publisher_id: "" } }, + { ...serviceResult, publisher: { ...attribution, name: "" } }, + { ...serviceResult, publisher: [] }, + { + ...serviceResult, + publisher: { ...attribution, website_url: "https://user@platform.example/" } + } ]; const client = createDirectoryClient({ transport: () => Promise.resolve(json({ items: [...malformed, collection] })) @@ -198,9 +204,15 @@ describe("mixed Directory discovery", () => { it("accepts omitted optional metadata, empty descriptions and additive fields", async () => { const items = [ serviceResult, + { ...serviceResult, publisher: null }, + { + ...serviceResult, + available_through: { service_id: "legacy", service_origin: "https://legacy.example" } + }, + { ...serviceResult, future_metadata: { arbitrary: true } }, { ...serviceResult, - available_through: { service_id: "platform", service_origin: "https://platform.example" } + publisher: { ...attribution, extra: { retained: true } } }, { ...collection, collection: { id: "Weather", name: "Forecasts", extra: 1 } }, { ...collection, collection: { ...collection.collection, description: "" } }