Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/directory-publisher.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion packages/directory/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
36 changes: 27 additions & 9 deletions packages/directory/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export interface DirectoryResourceSearchRequest extends DirectorySearchRequest {
types?: Array<"service" | "collection">;
}

export interface DirectoryServiceReference extends Record<string, unknown> {
interface DirectoryServiceReference extends Record<string, unknown> {
service_id: string;
service_origin: string;
name?: string;
Expand Down Expand Up @@ -83,11 +83,17 @@ export interface DirectoryIndexedService extends Record<string, unknown> {
website_url?: string;
}

export interface DirectoryPublisher extends Record<string, unknown> {
publisher_id: string;
name: string;
website_url: string;
}

export interface DirectoryServiceResult extends Record<string, unknown> {
type: "service";
service: DirectoryIndexedService;
indexed_at: string;
available_through?: DirectoryServiceReference;
publisher?: DirectoryPublisher | null;
}

export interface DirectoryCollectionResult extends Record<string, unknown> {
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -684,12 +688,26 @@ function recognizedDescriptors<Value>(
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,
Expand Down
30 changes: 21 additions & 9 deletions packages/directory/test/unit/mixed-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
};

Expand All @@ -58,7 +58,7 @@ describe("mixed Directory discovery", () => {
const unknown = { type: "offering", arbitrary: { id: "future" } };
const transport = vi.fn<typeof fetch>().mockResolvedValueOnce(
json({
items: [{ ...serviceResult, available_through: attribution }, collection, unknown],
items: [{ ...serviceResult, publisher: attribution }, collection, unknown],
facets: { keywords: [{ value: "weather", count: 12 }] },
extra: true
})
Expand All @@ -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 }
],
Expand Down Expand Up @@ -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] }))
Expand All @@ -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: "" } }
Expand Down
Loading