Skip to content
Open
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
34 changes: 23 additions & 11 deletions rest/nodejs/src/api/checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions rest/nodejs/src/api/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -206,6 +212,7 @@ export class DiscoveryService {
},
};

c.header("Cache-Control", PROFILE_CACHE_CONTROL);
return c.json(discoveryProfile);
};
}
38 changes: 38 additions & 0 deletions rest/nodejs/test/discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
104 changes: 104 additions & 0 deletions rest/nodejs/test/lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Loading