From fb8b8fdd882918230bf2d7a86cf6224a665c5bd9 Mon Sep 17 00:00:00 2001 From: Vishal Katyal Date: Wed, 5 Aug 2026 15:50:30 -0400 Subject: [PATCH 1/2] fix(rest/nodejs): send Cache-Control on the discovery profile response The `/.well-known/ucp` merchant profile response omitted the `Cache-Control` header. `getMerchantProfile` in `src/api/discovery.ts` returned `c.json(...)` with only the content-type set, so `curl -sI` on the running server shows no caching directive. overview.md (Discovery) makes this a MUST: "Profile responses MUST include a Cache-Control header with `public` and `max-age` of at least 60 seconds. Profiles MUST NOT be served with `private`, `no-store`, or `no-cache` directives." (docs/specification/overview.md). Observed: response headers carry only `content-type: application/json`. Expected: `Cache-Control: public, max-age>=60`. This is the Node twin of the merged Python fix (samples#153), which added the same header to the Python discovery route; mirror its `public, max-age=3600`. Why their CI did not catch it: the Node discovery test asserts only the JSON registries, never the response headers. --- rest/nodejs/src/api/discovery.ts | 7 ++++++ rest/nodejs/test/discovery.test.ts | 38 ++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/rest/nodejs/src/api/discovery.ts b/rest/nodejs/src/api/discovery.ts index 4811b3f..050998e 100644 --- a/rest/nodejs/src/api/discovery.ts +++ b/rest/nodejs/src/api/discovery.ts @@ -15,6 +15,12 @@ import { type Context } from "hono"; import { UCP_VERSION } from "../utils/config"; +// overview.md (Discovery) requires the profile response to carry a +// `Cache-Control` header with `public` and a `max-age` of at least 60 seconds, +// and forbids `private`, `no-store`, and `no-cache`. Mirror the Python +// reference (samples#153), which serves `public, max-age=3600`. +const PROFILE_CACHE_CONTROL = "public, max-age=3600"; + type DiscoveryCapability = { version: string; spec: string; @@ -206,6 +212,7 @@ export class DiscoveryService { }, }; + c.header("Cache-Control", PROFILE_CACHE_CONTROL); return c.json(discoveryProfile); }; } diff --git a/rest/nodejs/test/discovery.test.ts b/rest/nodejs/test/discovery.test.ts index 4ebeec1..4a4fae1 100644 --- a/rest/nodejs/test/discovery.test.ts +++ b/rest/nodejs/test/discovery.test.ts @@ -61,6 +61,44 @@ test("merchant profile uses schema-compliant discovery registries", async () => } }); +test("merchant profile sends a public, cacheable Cache-Control header", async () => { + // overview.md (Discovery) MUST: "Profile responses MUST include a + // Cache-Control header with `public` and `max-age` of at least 60 seconds. + // Profiles MUST NOT be served with `private`, `no-store`, or `no-cache` + // directives." (docs/specification/overview.md, "Profiles MUST" list). + const app = new Hono(); + const discoveryService = new DiscoveryService(); + app.get("/.well-known/ucp", discoveryService.getMerchantProfile); + + const response = await app.request("/.well-known/ucp"); + assert.equal(response.status, 200); + + const cacheControl = response.headers.get("Cache-Control"); + assert.ok(cacheControl, "profile response must carry a Cache-Control header"); + + const directives = cacheControl.split(",").map((d) => d.trim().toLowerCase()); + assert.ok( + directives.includes("public"), + `Cache-Control must be public, got "${cacheControl}"` + ); + for (const forbidden of ["private", "no-store", "no-cache"]) { + assert.equal( + directives.includes(forbidden), + false, + `Cache-Control must not include "${forbidden}", got "${cacheControl}"` + ); + } + + const maxAge = directives + .map((d) => /^max-age=(\d+)$/.exec(d)) + .find((m) => m !== null); + assert.ok(maxAge, `Cache-Control must set max-age, got "${cacheControl}"`); + assert.ok( + Number(maxAge[1]) >= 60, + `Cache-Control max-age must be at least 60, got "${cacheControl}"` + ); +}); + test("merchant profile derives the REST endpoint from the request origin", async () => { const app = new Hono(); const discoveryService = new DiscoveryService(); From a059ab03222dfe3315d32d0bbf9be8b5b8af2a1b Mon Sep 17 00:00:00 2001 From: Vishal Katyal Date: Wed, 5 Aug 2026 15:53:31 -0400 Subject: [PATCH 2/2] fix(rest/nodejs): include payment_handlers in the checkout ucp envelope `CheckoutService.createCheckout` built the response `ucp` envelope with only `{ version, capabilities }`. That envelope is persisted with the checkout, so every checkout response path that reads it back (get, update, complete, cancel, and the idempotent-replay branches) also omitted `payment_handlers`. The 04-08 schema binds checkout responses to `ucp.json#/$defs/response_checkout_schema`, whose `allOf` adds `required: ["payment_handlers"]` to the ucp envelope. Validating a live create response against that schema fails with `must have required property 'payment_handlers'` (pointer `/ucp`). Observed: `ucp = { version, capabilities }`. Expected: `ucp = { version, capabilities, payment_handlers }`, where `payment_handlers` is a (possibly empty) object. This mirrors the Python reference, which constructs `ResponseCheckout(..., payment_handlers={})`. Fix: emit `payment_handlers: {}` in the ucp envelope at construction. Because the envelope is stored on the checkout, the single construction site covers all five response paths. The envelope is declared as a standalone object so the extra property reaches the wire even though the JS SDK response type does not yet model it. Why their CI did not catch it: the Node checkout tests assert status and body fields but never schema-validate the ucp envelope. --- rest/nodejs/src/api/checkout.ts | 34 +++++++--- rest/nodejs/test/lifecycle.test.ts | 104 +++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 11 deletions(-) diff --git a/rest/nodejs/src/api/checkout.ts b/rest/nodejs/src/api/checkout.ts index b0e6826..c79950a 100644 --- a/rest/nodejs/src/api/checkout.ts +++ b/rest/nodejs/src/api/checkout.ts @@ -563,21 +563,33 @@ export class CheckoutService { // Construct authoritative checkout const platformConfig = await this.parseAgentProfile(ucpAgent); + // The 04-08 schema binds checkout responses to + // `ucp.json#/$defs/response_checkout_schema`, which adds + // `required: ["payment_handlers"]` to the ucp envelope. Emit it as an + // (empty) object, matching the Python reference + // (`ResponseCheckout(..., payment_handlers={})`). This envelope is + // persisted with the checkout, so the get/update/complete/cancel paths + // that read it back inherit the field. It is declared as a standalone + // object (rather than inline) so the extra property is carried onto the + // wire even though the SDK's response type does not yet model it. + const ucp = { + version: UCP_VERSION, + capabilities: { + "dev.ucp.shopping.checkout": [ + { + name: "dev.ucp.shopping.checkout", + version: UCP_VERSION, + }, + ], + }, + payment_handlers: {}, + }; + const checkout: ExtendedCheckoutResponse = { ...requestBody, // Copy other fields like ucp, etc. id: checkoutId, fulfillment, - ucp: { - version: UCP_VERSION, - capabilities: { - "dev.ucp.shopping.checkout": [ - { - name: "dev.ucp.shopping.checkout", - version: UCP_VERSION, - }, - ], - }, - }, + ucp, status: CheckoutResponseStatusSchema.enum.incomplete, currency: "USD", line_items: lineItems, diff --git a/rest/nodejs/test/lifecycle.test.ts b/rest/nodejs/test/lifecycle.test.ts index 1e14399..b4c25cc 100644 --- a/rest/nodejs/test/lifecycle.test.ts +++ b/rest/nodejs/test/lifecycle.test.ts @@ -261,3 +261,107 @@ test("a canceled checkout cannot be completed (409)", async () => { const res = await complete(app, id); assert.equal(res.status, 409); }); + +// The 04-08 schema binds checkout responses to +// `ucp.json#/$defs/response_checkout_schema`, whose `allOf` adds +// `required: ["payment_handlers"]` to the `ucp` envelope. `payment_handlers` +// is typed as an object (a map of handler key -> handler[]); an empty object +// satisfies the requirement, which is exactly what the Python reference emits +// (`ResponseCheckout(..., payment_handlers={})`). Every checkout response path +// (create, get, update, complete, cancel) must therefore carry +// `ucp.payment_handlers` as a present, non-null object. +function assertPaymentHandlers(label: string, ucp: unknown): void { + assert.ok( + ucp && typeof ucp === "object", + `${label}: response must carry a ucp envelope` + ); + const envelope = ucp as { payment_handlers?: unknown }; + assert.ok( + "payment_handlers" in envelope, + `${label}: ucp.payment_handlers is required by response_checkout_schema` + ); + const handlers = envelope.payment_handlers; + assert.ok( + handlers !== null && + typeof handlers === "object" && + !Array.isArray(handlers), + `${label}: ucp.payment_handlers must be an object, got ${JSON.stringify( + handlers + )}` + ); +} + +test("every checkout response carries ucp.payment_handlers (schema-required)", async () => { + const app = buildApp(); + + // create (POST /checkout-sessions -> 201) + const createRes = await create(app); + assert.equal(createRes.status, 201); + const created = (await createRes.json()) as { id: string; ucp?: unknown }; + assertPaymentHandlers("create", created.ucp); + + // get (GET /checkout-sessions/:id -> 200) + const getRes = await app.request(`/checkout-sessions/${created.id}`); + assert.equal(getRes.status, 200); + const got = (await getRes.json()) as { ucp?: unknown }; + assertPaymentHandlers("get", got.ucp); + + // update (PUT /checkout-sessions/:id -> 200) + const updateRes = await app.request(`/checkout-sessions/${created.id}`, { + method: "PUT", + headers: JSON_HEADERS, + body: JSON.stringify({ + currency: "USD", + line_items: [ + { id: "line_1", item: { id: "bouquet_roses" }, quantity: 2 }, + ], + }), + }); + assert.equal(updateRes.status, 200); + const updated = (await updateRes.json()) as { ucp?: unknown }; + assertPaymentHandlers("update", updated.ucp); + + // complete (POST /checkout-sessions/:id/complete -> 200) + const completeId = await createReadyToComplete(app); + const completeRes = await complete(app, completeId); + assert.equal(completeRes.status, 200); + const completed = (await completeRes.json()) as { ucp?: unknown }; + assertPaymentHandlers("complete", completed.ucp); + + // cancel (POST /checkout-sessions/:id/cancel -> 200) + const toCancel = (await (await create(app)).json()) as { id: string }; + const cancelRes = await app.request( + `/checkout-sessions/${toCancel.id}/cancel`, + { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({}), + } + ); + assert.equal(cancelRes.status, 200); + const canceled = (await cancelRes.json()) as { ucp?: unknown }; + assertPaymentHandlers("cancel", canceled.ucp); +}); + +test("an idempotent create replay still carries ucp.payment_handlers", async () => { + const app = buildApp(); + const headers = { ...JSON_HEADERS, "Idempotency-Key": "pay-handlers-replay" }; + const body = JSON.stringify(CREATE_BODY); + + const first = await app.request("/checkout-sessions", { + method: "POST", + headers, + body, + }); + assert.equal(first.status, 201); + + // Same key + same body -> served from the idempotency record. + const replay = await app.request("/checkout-sessions", { + method: "POST", + headers, + body, + }); + assert.equal(replay.status, 201); + const replayed = (await replay.json()) as { ucp?: unknown }; + assertPaymentHandlers("idempotent replay", replayed.ucp); +});