diff --git a/scripts/project-current-ucp-schemas.mjs b/scripts/project-current-ucp-schemas.mjs index 0caf281..a135d80 100644 --- a/scripts/project-current-ucp-schemas.mjs +++ b/scripts/project-current-ucp-schemas.mjs @@ -490,7 +490,223 @@ function writeProjectedFile( writeJson(path.join(outputSchemasRoot, outputRel), projected); } +// --- Source-derived response envelope ------------------------------------- +// The UCP response envelope (ucp.json#/$defs/base, specialized per response by +// #/$defs/response_*_schema) and its registry item shapes are DERIVED from the +// pinned source schemas rather than hand-written, so a base/entity property can +// never silently vanish from the generated envelope and each registry maps to +// its real per-entity RESPONSE shape. See buildResponseEnvelopeSchema(). + +// Root ($id-level) schemas that define the envelope and its entities. Loaded +// lazily so the projection still runs for the legacy layout (which supplies its +// own type files) without them. +function loadRootSchema(name) { + const file = path.join(sourceSchemasRoot, name); + return fs.existsSync(file) ? readJson(file) : undefined; +} + +// Registry-valued base properties (object keyed by reverse-domain name whose +// values are arrays of an entity) map to the projected compat schema for that +// entity's RESPONSE shape. Keyed by the entity file the base $refs. +const RESPONSE_ITEM_COMPAT_BY_ENTITY = { + "capability.json": "capability_response.json", + "payment_handler.json": "payment_handler_resp.json", + "service.json": "service_resp.json", +}; + +// Resolve a "#/..." or "#/..." JSON-Pointer $ref against the loaded root +// schemas. Returns { node, doc } so nested local $refs resolve in their own doc. +function resolveRootRef(ref, currentDoc, rootDocs) { + const [file, fragment = ""] = ref.split("#"); + const doc = file === "" ? currentDoc : rootDocs[file]; + if (!doc) { + throw new Error(`Cannot resolve $ref "${ref}" while deriving the response envelope`); + } + let node = doc; + for (const segment of fragment.split("/").filter(Boolean)) { + node = node?.[segment]; + } + if (node === undefined) { + throw new Error(`$ref "${ref}" did not resolve to a node`); + } + return { node, doc }; +} + +// Flatten an allOf/$ref chain into a single { required:Set, properties } view. +// Only allOf composition is followed (the shape used by entity/base/*_schema); +// anyOf transport variants are intentionally not merged (their per-transport +// config typing is out of scope -- noted as a residual). +function flattenAllOf(node, currentDoc, rootDocs, acc) { + if (!node || typeof node !== "object") { + return acc; + } + if (typeof node.$ref === "string") { + const { node: target, doc } = resolveRootRef(node.$ref, currentDoc, rootDocs); + return flattenAllOf(target, doc, rootDocs, acc); + } + if (Array.isArray(node.allOf)) { + for (const part of node.allOf) { + flattenAllOf(part, currentDoc, rootDocs, acc); + } + } + if (Array.isArray(node.required)) { + for (const name of node.required) { + acc.required.add(name); + } + } + if (node.properties && typeof node.properties === "object") { + for (const [name, schema] of Object.entries(node.properties)) { + // Later allOf parts (and overlays) win, matching JSON Schema merge order. + acc.properties[name] = schema; + } + } + return acc; +} + +// Normalize a source property schema to the compat leaf quicktype consumes, +// dropping annotations (format/pattern/description/default) that the pipeline's +// constraint injector re-attaches, and keeping only shape-bearing keywords. +function toCompatLeaf(schema) { + if (!schema || typeof schema !== "object") { + return { type: "string" }; + } + // A $ref leaf here is a scalar alias (e.g. version -> #/$defs/version): a + // string in the pinned schemas. + if (typeof schema.$ref === "string") { + return { type: "string" }; + } + // A oneOf/anyOf leaf (e.g. capability `extends`: string | string[]) becomes a + // z.union. Disjoint scalar/array branches are emitted as a JSON Schema + // type-union ({ type: ["array","string"], items }) rather than an anyOf node: + // both compile to the same z.union, but an anyOf node perturbs quicktype's + // naming of UNRELATED anonymous types (it renamed catalog product schemas), + // whereas the type-union does not. + const union = schema.oneOf ?? schema.anyOf; + if (Array.isArray(union)) { + const branches = union.map((branch) => toCompatLeaf(branch)); + const branchTypes = branches + .map((branch) => branch.type) + .filter((type) => typeof type === "string"); + const disjointScalars = + branchTypes.length === branches.length && + new Set(branchTypes).size === branchTypes.length; + if (disjointScalars) { + const leaf = { type: branchTypes.slice().sort() }; + const arrayBranch = branches.find((branch) => branch.type === "array"); + if (arrayBranch && arrayBranch.items) { + leaf.items = arrayBranch.items; + } + return leaf; + } + return { anyOf: branches }; + } + if (schema.type === "object") { + return { type: "object", additionalProperties: true }; + } + if (schema.type === "array") { + const items = + schema.items && typeof schema.items.$ref === "string" + ? rewriteItemRefForDiscovery(schema.items) + : toCompatLeaf(schema.items); + const leaf = { type: "array", items }; + if (typeof schema.minItems === "number") { + leaf.minItems = schema.minItems; + } + return leaf; + } + if (schema.type === "string") { + const leaf = { type: "string" }; + if (Array.isArray(schema.enum)) { + leaf.enum = [...schema.enum]; + } + return leaf; + } + if (schema.type === "boolean" || schema.type === "integer" || schema.type === "number") { + return { type: schema.type }; + } + return { type: "string" }; +} + +// Rewrite an array item $ref from its source (schemas-root-relative) path to the +// path a discovery/ compat file uses to reach the projected type tree. +function rewriteItemRefForDiscovery(items) { + if (items && typeof items.$ref === "string") { + const [file, fragment = ""] = items.$ref.split("#"); + const rel = `../schemas/${file}`; + return fragment ? { $ref: `${rel}#${fragment}` } : { $ref: rel }; + } + return items ?? { type: "object", additionalProperties: true }; +} + +// Derive a flat compat schema for an entity's #/$defs/response_schema. +function buildEntityResponseSchema(title, entitySchema, rootDocs) { + const acc = flattenAllOf( + entitySchema.$defs.response_schema, + entitySchema, + rootDocs, + { required: new Set(), properties: {} } + ); + const properties = {}; + for (const [name, schema] of Object.entries(acc.properties)) { + properties[name] = toCompatLeaf(schema); + } + return { + $schema: "https://json-schema.org/draft/2020-12/schema", + title, + type: "object", + required: [...acc.required].sort(), + properties, + }; +} + +// Derive the shared response envelope from ucp.json#/$defs/base: every base +// property is modeled with base's OWN required-ness, registry properties map to +// their per-entity RESPONSE compat shape, and scalars/enums (version, status) +// are carried through. An unknown registry entity fails loudly rather than +// silently dropping the field. +function buildResponseEnvelopeSchema(ucpSchema) { + const base = ucpSchema.$defs.base; + const properties = {}; + for (const [name, schema] of Object.entries(base.properties)) { + const items = schema?.additionalProperties?.items; + if (items && typeof items.$ref === "string") { + const entityFile = items.$ref.split("#")[0]; + const compat = RESPONSE_ITEM_COMPAT_BY_ENTITY[entityFile]; + if (!compat) { + throw new Error( + `Response envelope registry "${name}" refs unknown entity "${entityFile}"; ` + + `add it to RESPONSE_ITEM_COMPAT_BY_ENTITY so it is not dropped.` + ); + } + properties[name] = { + type: "object", + additionalProperties: { type: "array", items: { $ref: compat } }, + }; + } else { + properties[name] = toCompatLeaf(schema); + } + } + return { + $schema: "https://json-schema.org/draft/2020-12/schema", + title: "UCP Response", + type: "object", + required: [...(base.required ?? [])], + properties, + }; +} + function writeCompatibilityDiscoverySchemas() { + const ucpSchema = loadRootSchema("ucp.json"); + const paymentHandlerSchema = loadRootSchema("payment_handler.json"); + const serviceSchema = loadRootSchema("service.json"); + const capabilitySchema = loadRootSchema("capability.json"); + const rootDocs = { + "ucp.json": ucpSchema, + "payment_handler.json": paymentHandlerSchema, + "service.json": serviceSchema, + "capability.json": capabilitySchema, + }; + const signingKey = { $schema: "https://json-schema.org/draft/2020-12/schema", title: "Signing Key", @@ -509,32 +725,25 @@ function writeCompatibilityDiscoverySchemas() { }, }; - const paymentHandlerResponse = { - $schema: "https://json-schema.org/draft/2020-12/schema", - title: "Payment Handler Response", - type: "object", - required: [ - "config", - "config_schema", - "id", - "instrument_schemas", - "name", - "spec", - "version", - ], - properties: { - config: { type: "object", additionalProperties: true }, - config_schema: { type: "string" }, - id: { type: "string" }, - instrument_schemas: { - type: "array", - items: { type: "string" }, - }, - name: { type: "string" }, - spec: { type: "string" }, - version: { type: "string" }, - }, - }; + // Derived from payment_handler.json#/$defs/response_schema (allOf: entity + + // {required:[id]} + available_instruments). Required {id, version}; carries + // available_instruments -- matching real handler responses. Replaces the + // legacy discovery shape (config_schema/instrument_schemas/name) that no + // longer exists in the 2026-04-08 schema. + const paymentHandlerResponse = buildEntityResponseSchema( + "Payment Handler Response", + paymentHandlerSchema, + rootDocs + ); + + // Derived from service.json#/$defs/response_schema. Required {transport, + // version}; carries endpoint. (Per-transport embedded config typing from the + // anyOf overlay is out of scope; config stays a generic object.) + const serviceResponse = buildEntityResponseSchema( + "Service Response", + serviceSchema, + rootDocs + ); const capabilityDiscovery = { $schema: "https://json-schema.org/draft/2020-12/schema", @@ -551,20 +760,16 @@ function writeCompatibilityDiscoverySchemas() { }, }; - const capabilityResponse = { - $schema: "https://json-schema.org/draft/2020-12/schema", - title: "Capability Response", - type: "object", - required: ["name", "version"], - properties: { - config: { type: "object", additionalProperties: true }, - extends: { type: "string" }, - name: { type: "string" }, - schema: { type: "string" }, - spec: { type: "string" }, - version: { type: "string" }, - }, - }; + // Derived from capability.json#/$defs/response_schema (allOf: entity + + // {extends: string | string[]}). Required only {version}; NO `name` (the + // legacy hand-written shape required a non-existent `name`, false-rejecting + // every conformant capabilities registry -- e.g. the golden checkout ucp + // envelope). `extends` is a string|string[] union. + const capabilityResponse = buildEntityResponseSchema( + "Capability Response", + capabilitySchema, + rootDocs + ); const ucpService = { $schema: "https://json-schema.org/draft/2020-12/schema", @@ -603,22 +808,16 @@ function writeCompatibilityDiscoverySchemas() { }, }; - const ucpResponse = { - $schema: "https://json-schema.org/draft/2020-12/schema", - title: "UCP Response", - type: "object", - required: ["capabilities", "version"], - properties: { - capabilities: { - type: "object", - additionalProperties: { - type: "array", - items: { $ref: "capability_response.json" }, - }, - }, - version: { type: "string" }, - }, - }; + // The shared response envelope, DERIVED from ucp.json#/$defs/base. Models + // every base property (capabilities, payment_handlers, services, status, + // version) with base's own required-ness (only version), so payment_handlers + // and services are no longer silently stripped and capabilities is no longer + // wrongly required. This single type is aliased by all four response + // envelopes (checkout/order/cart/catalog); payment_handlers is therefore + // OPTIONAL here even though response_checkout_schema requires it -- enforcing + // the checkout-only requirement needs a distinct type and is filed as a + // follow-up so order/cart/catalog responses are not falsely rejected. + const ucpResponse = buildResponseEnvelopeSchema(ucpSchema); const ucpDiscoveryProfile = { $schema: "https://json-schema.org/draft/2020-12/schema", @@ -662,6 +861,10 @@ function writeCompatibilityDiscoverySchemas() { path.join(outputDiscoveryRoot, "payment_handler_resp.json"), paymentHandlerResponse ); + writeJson( + path.join(outputDiscoveryRoot, "service_resp.json"), + serviceResponse + ); writeJson( path.join(outputDiscoveryRoot, "capability.json"), capabilityDiscovery diff --git a/src/spec_generated.ts b/src/spec_generated.ts index 986c7c5..d163731 100644 --- a/src/spec_generated.ts +++ b/src/spec_generated.ts @@ -42,6 +42,12 @@ export type CheckoutResponseStatus = z.infer< typeof CheckoutResponseStatusSchema >; +export const TransportSchema = z.enum(["a2a", "embedded", "mcp", "rest"]); +export type Transport = z.infer; + +export const UcpResponseStatusSchema = z.enum(["error", "success"]); +export type UcpResponseStatus = z.infer; + // Adjustment status. export const AdjustmentStatusSchema = z.enum([ @@ -81,17 +87,12 @@ export type Method = z.infer; export const MethodTypeSchema = z.enum(["pickup", "shipping"]); export type MethodType = z.infer; -export const PaymentHandlerResponseSchema = z.object({ - config: z.record(z.string(), z.any()), - config_schema: z.string(), - id: z.string(), - instrument_schemas: z.array(z.string()), - name: z.string(), - spec: z.string(), - version: z.string(), +export const AvailablePaymentInstrumentSchema = z.object({ + constraints: z.record(z.string(), z.any()).optional(), + type: z.string(), }); -export type PaymentHandlerResponse = z.infer< - typeof PaymentHandlerResponseSchema +export type AvailablePaymentInstrument = z.infer< + typeof AvailablePaymentInstrumentSchema >; export const SigningKeySchema = z.object({ @@ -259,14 +260,25 @@ export type Line = z.infer; export const CapabilityResponseSchema = z.object({ config: z.record(z.string(), z.any()).optional(), - extends: z.string().optional(), - name: z.string(), + extends: z.union([z.array(z.string()), z.string()]).optional(), + id: z.string().optional(), schema: z.string().optional(), spec: z.string().optional(), version: z.string(), }); export type CapabilityResponse = z.infer; +export const ServiceResponseSchema = z.object({ + config: z.record(z.string(), z.any()).optional(), + endpoint: z.string().optional(), + id: z.string().optional(), + schema: z.string().optional(), + spec: z.string().optional(), + transport: TransportSchema, + version: z.string(), +}); +export type ServiceResponse = z.infer; + export const LineItemQuantityRefSchema = z.object({ id: z.string(), quantity: z.number(), @@ -526,10 +538,20 @@ export type SearchResponsePagination = z.infer< typeof SearchResponsePaginationSchema >; -export const PaymentSchema = z.object({ - handlers: z.array(PaymentHandlerResponseSchema).optional(), +export const PaymentHandlerResponseSchema = z.object({ + available_instruments: z + .array(AvailablePaymentInstrumentSchema) + .min(1) + .optional(), + config: z.record(z.string(), z.any()).optional(), + id: z.string(), + schema: z.string().optional(), + spec: z.string().optional(), + version: z.string(), }); -export type Payment = z.infer; +export type PaymentHandlerResponse = z.infer< + typeof PaymentHandlerResponseSchema +>; export const UcpServiceSchema = z.object({ a2a: A2ASchema.optional(), @@ -599,8 +621,15 @@ export const TotalsResponseSchema = z.object({ export type TotalsResponse = z.infer; export const UcpResponseSchema = z.object({ - capabilities: z.record(z.string(), z.array(CapabilityResponseSchema)), - version: z.string(), + capabilities: z + .record(z.string(), z.array(CapabilityResponseSchema)) + .optional(), + payment_handlers: z + .record(z.string(), z.array(PaymentHandlerResponseSchema)) + .optional(), + services: z.record(z.string(), z.array(ServiceResponseSchema)).optional(), + status: UcpResponseStatusSchema.optional(), + version: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), }); export type UcpResponse = z.infer; @@ -939,6 +968,11 @@ export const SearchRequestSchema = z.object({ }); export type SearchRequest = z.infer; +export const PaymentSchema = z.object({ + handlers: z.array(PaymentHandlerResponseSchema).optional(), +}); +export type Payment = z.infer; + export const UcpSchema = z.object({ capabilities: z.array(CapabilityDiscoverySchema), services: z.record(z.string(), UcpServiceSchema), diff --git a/tests/response-payment-handlers.test.js b/tests/response-payment-handlers.test.js new file mode 100644 index 0000000..290aa7e --- /dev/null +++ b/tests/response-payment-handlers.test.js @@ -0,0 +1,301 @@ +// Copyright 2026 UCP Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Fidelity tests for the generated UCP response envelope. The envelope is +// derived from ucp.json#/$defs/base + the per-entity #/$defs/response_schema +// shapes, so it must model every base registry (capabilities, payment_handlers, +// services) plus status, each with the REAL response item shape -- not a legacy +// hand-written approximation. +// +// Before the fix UcpResponseSchema was { capabilities (required), version } and +// PaymentHandlerResponseSchema was the stale discovery shape +// { config, config_schema, id, instrument_schemas, name, spec, version } with +// all fields required. A spec-conformant checkout response's payment_handlers +// and services were silently stripped, and a real handler entry (which carries +// available_instruments/schema, not config_schema/instrument_schemas/name) was +// unrepresentable. +// +// The schemas are compiled from src/spec_generated.ts by the "pretest" step so +// the test exercises the generated zod schemas directly. + +const { test } = require("node:test"); +const assert = require("node:assert/strict"); + +const { + UcpResponseSchema, + CheckoutResponseSchema, +} = require("./.dist/spec_generated.js"); + +const accepts = (schema, value) => schema.safeParse(value).success === true; +const rejects = (schema, value) => schema.safeParse(value).success === false; + +// A real handler entry, shaped exactly like the conformance golden snapshot's +// payment_handlers entry (ci/merchant_golden_snapshot.json): required id + +// version, plus available_instruments / schema / spec. This is the case the +// pre-fix (and the naive one-property patch) schema WRONGLY REJECTED. +const goldenHandler = { + available_instruments: [{ constraints: { brands: ["visa"] }, type: "card" }], + id: "giftpay", + schema: "https://spck.dev/fixture/handlers/giftpay/schema.json", + spec: "https://spck.dev/fixture/handlers/giftpay", + version: "2026-04-08", +}; + +const serviceEntry = { + transport: "rest", + endpoint: "https://example.com/ucp", + version: "2026-04-08", +}; + +// The full checkout ucp envelope from the conformance golden snapshot +// (merchant_golden_snapshot.json checkout_create.body.ucp): all three +// registries together. Capability items carry only {schema, version} (+ a +// string `extends`) -- NO `name`. This is the case that regressed twice: the +// envelope was fixed one registry at a time while capabilities still ran +// through a legacy `name`-required item shape and false-rejected this payload. +const goldenCheckoutEnvelope = { + version: "2026-04-08", + capabilities: { + "dev.ucp.shopping.checkout": [ + { + schema: "https://ucp.dev/schemas/shopping/checkout.json", + version: "2026-04-08", + }, + ], + "dev.ucp.shopping.discount": [ + { + extends: "dev.ucp.shopping.checkout", + schema: "https://ucp.dev/schemas/shopping/discount.json", + version: "2026-04-08", + }, + ], + }, + payment_handlers: { "dev.spck.giftpay": [goldenHandler] }, +}; + +const envelopeWithHandlers = { + version: "2026-04-08", + payment_handlers: { "dev.spck.giftpay": [goldenHandler] }, +}; + +// --- payment_handlers: modeled with the REAL response shape ----------------- + +test("envelope accepts a golden-snapshot-shaped payment handler (available_instruments/schema/spec)", () => { + assert.ok(accepts(UcpResponseSchema, envelopeWithHandlers)); +}); + +test("envelope accepts a minimal handler carrying only the required {id, version}", () => { + assert.ok( + accepts(UcpResponseSchema, { + version: "2026-04-08", + payment_handlers: { "com.example": [{ id: "h", version: "2026-04-08" }] }, + }) + ); +}); + +test("envelope PRESERVES payment_handlers on parse (was silently stripped)", () => { + const result = UcpResponseSchema.safeParse(envelopeWithHandlers); + assert.ok(result.success); + assert.deepEqual( + result.data.payment_handlers, + envelopeWithHandlers.payment_handlers + ); +}); + +test("envelope rejects a handler missing the required id / version", () => { + assert.ok( + rejects(UcpResponseSchema, { + version: "2026-04-08", + payment_handlers: { "com.example": [{ version: "2026-04-08" }] }, // no id + }) + ); + assert.ok( + rejects(UcpResponseSchema, { + version: "2026-04-08", + payment_handlers: { "com.example": [{ id: "h" }] }, // no version + }) + ); +}); + +test("envelope rejects the stale legacy handler fields as required (config_schema/instrument_schemas/name)", () => { + // The pre-fix schema REQUIRED these; a real response never sends them. A + // handler with only {id, version} must now pass (proves they are gone). + assert.ok( + accepts(UcpResponseSchema, { + version: "2026-04-08", + payment_handlers: { "com.example": [{ id: "h", version: "2026-04-08" }] }, + }) + ); +}); + +test("envelope enforces available_instruments minItems: 1 when present", () => { + assert.ok( + rejects(UcpResponseSchema, { + version: "2026-04-08", + payment_handlers: { + "com.example": [ + { id: "h", version: "2026-04-08", available_instruments: [] }, + ], + }, + }) + ); +}); + +test("envelope rejects a payment_handlers registry value that is not an array", () => { + assert.ok( + rejects(UcpResponseSchema, { + version: "2026-04-08", + payment_handlers: { "com.example": goldenHandler }, // object, not array + }) + ); +}); + +// --- the FULL golden envelope: all registries together ---------------------- + +test("envelope ACCEPTS the full golden checkout ucp envelope and RETAINS every registry", () => { + const result = UcpResponseSchema.safeParse(goldenCheckoutEnvelope); + assert.ok( + result.success, + "golden checkout envelope must validate: " + + (result.success ? "" : JSON.stringify(result.error.issues.slice(0, 3))) + ); + assert.deepEqual( + result.data.capabilities, + goldenCheckoutEnvelope.capabilities + ); + assert.deepEqual( + result.data.payment_handlers, + goldenCheckoutEnvelope.payment_handlers + ); +}); + +// --- capabilities: derived response shape (required only version, no name) --- + +test("envelope accepts a minimal capability carrying only the required {version}", () => { + assert.ok( + accepts(UcpResponseSchema, { + version: "2026-04-08", + capabilities: { "dev.x": [{ version: "2026-04-08" }] }, + }) + ); +}); + +test("envelope accepts capability `extends` as both string and string[]", () => { + assert.ok( + accepts(UcpResponseSchema, { + version: "2026-04-08", + capabilities: { "dev.x": [{ extends: "a.b", version: "2026-04-08" }] }, + }) + ); + assert.ok( + accepts(UcpResponseSchema, { + version: "2026-04-08", + capabilities: { + "dev.x": [{ extends: ["a.b", "c.d"], version: "2026-04-08" }], + }, + }) + ); +}); + +test("envelope rejects a capability missing the required version", () => { + assert.ok( + rejects(UcpResponseSchema, { + version: "2026-04-08", + capabilities: { "dev.x": [{ schema: "https://example.com/s.json" }] }, + }) + ); +}); + +// --- services: modeled and retained ---------------------------------------- + +test("envelope accepts and PRESERVES a services registry (service response shape)", () => { + const env = { + version: "2026-04-08", + services: { "com.example": [serviceEntry] }, + }; + const result = UcpResponseSchema.safeParse(env); + assert.ok(result.success, "services envelope must validate"); + assert.deepEqual(result.data.services, env.services); +}); + +test("envelope rejects a service missing the required transport / version", () => { + assert.ok( + rejects(UcpResponseSchema, { + version: "2026-04-08", + services: { "com.example": [{ version: "2026-04-08" }] }, // no transport + }) + ); + assert.ok( + rejects(UcpResponseSchema, { + version: "2026-04-08", + services: { "com.example": [{ transport: "rest" }] }, // no version + }) + ); +}); + +// --- status: modeled and retained (base enum success/error) ----------------- + +test("envelope RETAINS status (was stripped); accepts success/error, rejects other", () => { + const err = UcpResponseSchema.safeParse({ + version: "2026-04-08", + status: "error", + }); + assert.ok(err.success); + assert.equal( + err.data.status, + "error", + "status must be retained, not stripped" + ); + assert.ok( + accepts(UcpResponseSchema, { version: "2026-04-08", status: "success" }) + ); + assert.ok( + rejects(UcpResponseSchema, { version: "2026-04-08", status: "pending" }) + ); +}); + +// --- base required-ness: only version is required --------------------------- + +test("envelope no longer wrongly requires capabilities (base requires only version)", () => { + assert.ok(accepts(UcpResponseSchema, { version: "2026-04-08" })); +}); + +test("envelope enforces the base version format ^\\d{4}-\\d{2}-\\d{2}$", () => { + assert.ok(rejects(UcpResponseSchema, { version: "not-a-date" })); +}); + +// --- the checkout response type carries the fixed envelope ------------------ + +test("CheckoutResponseSchema.ucp models payment_handlers, services, and status", () => { + const shape = + typeof CheckoutResponseSchema._def?.shape === "function" + ? CheckoutResponseSchema._def.shape() + : CheckoutResponseSchema.shape; + const ucpSchema = shape.ucp; + const ucpShape = + typeof ucpSchema._def?.shape === "function" + ? ucpSchema._def.shape() + : ucpSchema.shape; + for (const key of [ + "payment_handlers", + "services", + "status", + "capabilities", + ]) { + assert.ok( + key in ucpShape, + `checkout response ucp envelope must model ${key}` + ); + } +});