diff --git a/rest/nodejs/package.json b/rest/nodejs/package.json index 4d1e5cc..e49df6c 100644 --- a/rest/nodejs/package.json +++ b/rest/nodejs/package.json @@ -4,6 +4,7 @@ "scripts": { "dev": "tsx watch src/index.ts", "build": "tsc", + "test": "tsx --test test/*.test.ts", "start": "node dist/index.js", "generate:models": "bash scripts/generate_models.sh" }, diff --git a/rest/nodejs/src/api/discovery.ts b/rest/nodejs/src/api/discovery.ts index 47a5d26..dce811b 100644 --- a/rest/nodejs/src/api/discovery.ts +++ b/rest/nodejs/src/api/discovery.ts @@ -1,5 +1,25 @@ import { type Context } from "hono"; -import { type UcpDiscoveryProfile } from "../models"; + +type DiscoveryCapability = { + version: string; + spec: string; + schema: string; + extends?: string; +}; + +type DiscoveryServiceBinding = { + version: string; + spec: string; + schema: string; + transport: "rest"; + endpoint: string; +}; + +type UcpDiscoveryMetadata = { + version: string; + services: Record; + capabilities: Record; +}; /** * Service for handling UCP discovery requests. @@ -24,55 +44,67 @@ export class DiscoveryService { * @returns A JSON response containing the merchant profile. */ getMerchantProfile = (c: Context) => { - const discoveryProfile: UcpDiscoveryProfile = { - ucp: { - version: this.ucpVersion, - services: { - "dev.ucp.shopping": { + const ucp = { + version: this.ucpVersion, + services: { + "dev.ucp.shopping": [ + { version: this.ucpVersion, spec: "https://ucp.dev/2026-01-23/specification/shopping", - rest: { - schema: - "https://ucp.dev/2026-01-23/services/shopping/openapi.json", - endpoint: "http://localhost:3000", - }, + transport: "rest", + schema: "https://ucp.dev/2026-01-23/services/shopping/openapi.json", + endpoint: "http://localhost:3000", }, - }, - capabilities: [ + ], + }, + capabilities: { + "dev.ucp.shopping.checkout": [ { version: this.ucpVersion, spec: "https://ucp.dev/2026-01-23/specification/shopping/checkout", schema: "https://ucp.dev/2026-01-23/schemas/shopping/checkout.json", }, + ], + "dev.ucp.shopping.order": [ { version: this.ucpVersion, spec: "https://ucp.dev/2026-01-23/specification/shopping/order", schema: "https://ucp.dev/2026-01-23/schemas/shopping/order.json", }, + ], + "dev.ucp.shopping.refund": [ { version: this.ucpVersion, spec: "https://ucp.dev/2026-01-23/specification/shopping/refund", schema: "https://ucp.dev/2026-01-23/schemas/shopping/refund.json", extends: "dev.ucp.shopping.order", }, + ], + "dev.ucp.shopping.return": [ { version: this.ucpVersion, spec: "https://ucp.dev/2026-01-23/specification/shopping/return", schema: "https://ucp.dev/2026-01-23/schemas/shopping/return.json", extends: "dev.ucp.shopping.order", }, + ], + "dev.ucp.shopping.dispute": [ { version: this.ucpVersion, spec: "https://ucp.dev/2026-01-23/specification/shopping/dispute", schema: "https://ucp.dev/2026-01-23/schemas/shopping/dispute.json", extends: "dev.ucp.shopping.order", }, + ], + "dev.ucp.shopping.discount": [ { version: this.ucpVersion, spec: "https://ucp.dev/2026-01-23/specification/shopping/discount", schema: "https://ucp.dev/2026-01-23/schemas/shopping/discount.json", extends: "dev.ucp.shopping.checkout", }, + ], + "dev.ucp.shopping.fulfillment": [ { version: this.ucpVersion, spec: "https://ucp.dev/2026-01-23/specification/shopping/fulfillment", @@ -80,6 +112,8 @@ export class DiscoveryService { "https://ucp.dev/2026-01-23/schemas/shopping/fulfillment.json", extends: "dev.ucp.shopping.checkout", }, + ], + "dev.ucp.shopping.buyer_consent": [ { version: this.ucpVersion, spec: "https://ucp.dev/2026-01-23/specification/shopping/buyer_consent", @@ -89,6 +123,10 @@ export class DiscoveryService { }, ], }, + } satisfies UcpDiscoveryMetadata; + + const discoveryProfile = { + ucp, payment: { handlers: [ { diff --git a/rest/nodejs/test/discovery.test.ts b/rest/nodejs/test/discovery.test.ts new file mode 100644 index 0000000..11dfbe1 --- /dev/null +++ b/rest/nodejs/test/discovery.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { Hono } from "hono"; + +import { DiscoveryService } from "../src/api/discovery"; + +type DiscoveryResponse = { + ucp: { + services: Record>; + capabilities: Record>; + }; +}; + +test("merchant profile uses schema-compliant discovery registries", async () => { + const app = new Hono(); + const discoveryService = new DiscoveryService(); + app.get("/.well-known/ucp", discoveryService.getMerchantProfile); + + const response = await app.request("/.well-known/ucp"); + const body = (await response.json()) as DiscoveryResponse; + + assert.equal(response.status, 200); + + const shoppingServices = body.ucp.services["dev.ucp.shopping"]; + assert.ok(Array.isArray(shoppingServices)); + assert.equal(shoppingServices.length, 1); + assert.equal(shoppingServices[0]?.transport, "rest"); + assert.equal(shoppingServices[0]?.endpoint, "http://localhost:3000"); + + assert.equal(Array.isArray(body.ucp.capabilities), false); + assert.deepEqual(Object.keys(body.ucp.capabilities).sort(), [ + "dev.ucp.shopping.buyer_consent", + "dev.ucp.shopping.checkout", + "dev.ucp.shopping.discount", + "dev.ucp.shopping.dispute", + "dev.ucp.shopping.fulfillment", + "dev.ucp.shopping.order", + "dev.ucp.shopping.refund", + "dev.ucp.shopping.return", + ]); + + for (const [name, declarations] of Object.entries(body.ucp.capabilities)) { + assert.ok(Array.isArray(declarations), `${name} must be an array`); + assert.equal(declarations.length, 1); + assert.equal(declarations[0]?.version, discoveryService.ucpVersion); + } +});