diff --git a/scripts/project-current-ucp-schemas.mjs b/scripts/project-current-ucp-schemas.mjs index 3f5ac42..4f5cee1 100644 --- a/scripts/project-current-ucp-schemas.mjs +++ b/scripts/project-current-ucp-schemas.mjs @@ -828,6 +828,21 @@ function buildResponseEnvelopeSchema(ucpSchema, extraRequired = []) { type: "object", additionalProperties: { type: "array", items: { $ref: compat } }, }; + } else if ( + typeof schema?.$ref === "string" && + schema.$ref.startsWith("#/$defs/") && + ucpSchema.$defs[schema.$ref.slice("#/$defs/".length)]?.type === "object" + ) { + // An object valued local def (map_order): keep its map shape instead of + // collapsing the $ref to a string, which rejected the map_order example + // in the overview. + const def = ucpSchema.$defs[schema.$ref.slice("#/$defs/".length)]; + properties[name] = { + type: "object", + additionalProperties: def.additionalProperties + ? toCompatLeaf(def.additionalProperties) + : true, + }; } else { properties[name] = toCompatLeaf(schema); } @@ -841,6 +856,49 @@ function buildResponseEnvelopeSchema(ucpSchema, extraRequired = []) { }; } +// The `ucp` member of the discovery profile, DERIVED from ucp.json#/$defs/business_schema +// (allOf: base + overlay). base contributes every registry (services, +// capabilities, payment_handlers) as an object keyed by reverse domain name +// whose values are entity arrays, plus version/status/map_order, requiring +// only version; the business overlay adds required services and +// payment_handlers, and supported_versions. Registry items map to the +// per-entity RESPONSE compat shape exactly as the response envelope does. +// Replaces the hand written node that carried the withdrawn 2026-01-11 shape +// (capabilities as a flat array and required, services as single objects, no +// payment_handlers), which rejected the business profile example the specification +// publishes. No title: the type keeps the property-derived name (UcpSchema), so +// the existing export survives. +function buildBusinessProfileUcpSchema(ucpSchema) { + const overlay = ucpSchema.$defs.business_schema.allOf[1]; + const envelope = buildResponseEnvelopeSchema( + ucpSchema, + overlay.required ?? [] + ); + const { title: _title, ...derived } = envelope; + const properties = { ...derived.properties }; + for (const [name, schema] of Object.entries(overlay.properties ?? {})) { + // Registry properties are already modeled by the envelope (the overlay only + // re-points their item refs); carry the additions of the overlay through. + if (name in properties) continue; + // An open map with a typed value (supported_versions: version -> profile + // URI) keeps that value type; toCompatLeaf would widen it to any. + properties[name] = + schema?.type === "object" && + schema.additionalProperties && + typeof schema.additionalProperties === "object" + ? { + type: "object", + additionalProperties: toCompatLeaf(schema.additionalProperties), + } + : toCompatLeaf(schema); + } + return { + ...derived, + required: [...new Set(derived.required)], + properties, + }; +} + function writeCompatibilityDiscoverySchemas() { const ucpSchema = loadRootSchema("ucp.json"); const paymentHandlerSchema = loadRootSchema("payment_handler.json"); @@ -1012,21 +1070,7 @@ function writeCompatibilityDiscoverySchemas() { type: "array", items: { $ref: "signing_key.json" }, }, - ucp: { - type: "object", - required: ["capabilities", "services", "version"], - properties: { - capabilities: { - type: "array", - items: { $ref: "capability.json" }, - }, - services: { - type: "object", - additionalProperties: { $ref: "ucp_service.json" }, - }, - version: clone(version), - }, - }, + ucp: buildBusinessProfileUcpSchema(ucpSchema), }, }; diff --git a/src/spec_generated.ts b/src/spec_generated.ts index 3f1224f..2d9e367 100644 --- a/src/spec_generated.ts +++ b/src/spec_generated.ts @@ -3,6 +3,14 @@ import * as z from "zod"; export const UseSchema = z.enum(["enc", "sig"]); export type Use = z.infer; +export const TransportSchema = z.enum(["a2a", "embedded", "mcp", "rest"]); +export type Transport = z.infer; + +export const UcpCheckoutResponseStatusSchema = z.enum(["error", "success"]); +export type UcpCheckoutResponseStatus = z.infer< + typeof UcpCheckoutResponseStatusSchema +>; + // Content format, default = plain. export const ContentTypeSchema = z.enum(["markdown", "plain"]); @@ -43,14 +51,6 @@ export type CheckoutResponseStatus = z.infer< typeof CheckoutResponseStatusSchema >; -export const TransportSchema = z.enum(["a2a", "embedded", "mcp", "rest"]); -export type Transport = z.infer; - -export const UcpCheckoutResponseStatusSchema = z.enum(["error", "success"]); -export type UcpCheckoutResponseStatus = z.infer< - typeof UcpCheckoutResponseStatusSchema ->; - // Adjustment status. export const AdjustmentStatusSchema = z.enum([ @@ -233,6 +233,33 @@ export type NetworkTokenCredentialType = z.infer< export const PanCredentialTypeSchema = z.enum(["pan"]); export type PanCredentialType = z.infer; +export const CapabilityDiscoverySchema = z.object({ + config: z.record(z.string(), z.any()).optional(), + extends: z + .union([ + z + .array( + z + .string() + .regex( + /^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$/ + ) + ) + .min(1), + z + .string() + .regex( + /^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$/ + ), + ]) + .optional(), + name: z.string(), + schema: z.string().url(), + spec: z.string().url(), + version: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), +}); +export type CapabilityDiscovery = z.infer; + export const SigningKeySchema = z.object({ alg: z.string().optional(), crv: z.string().optional(), @@ -263,7 +290,7 @@ export type ConstraintsProperty = z.infer; export const ConstraintExpressionPropertySchema = ConstraintsPropertySchema; export type ConstraintExpressionProperty = ConstraintsProperty; -export const CapabilityDiscoverySchema = z.object({ +export const CapabilityResponseSchema = z.object({ config: z.record(z.string(), z.any()).optional(), extends: z .union([ @@ -283,12 +310,23 @@ export const CapabilityDiscoverySchema = z.object({ ), ]) .optional(), - name: z.string(), - schema: z.string().url(), - spec: z.string().url(), + id: z.string().optional(), + schema: z.string().url().optional(), + spec: z.string().url().optional(), version: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), }); -export type CapabilityDiscovery = z.infer; +export type CapabilityResponse = z.infer; + +export const ServiceResponseSchema = z.object({ + config: z.record(z.string(), z.any()).optional(), + endpoint: z.string().url().optional(), + id: z.string().optional(), + schema: z.string().url().optional(), + spec: z.string().url().optional(), + transport: TransportSchema, + version: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), +}); +export type ServiceResponse = z.infer; export const A2ASchema = z.object({ endpoint: z @@ -712,44 +750,6 @@ export type TotalLine = z.infer; export const TotalLineClassSchema = TotalLineSchema; export type TotalLineClass = TotalLine; -export const CapabilityResponseSchema = z.object({ - config: z.record(z.string(), z.any()).optional(), - extends: z - .union([ - z - .array( - z - .string() - .regex( - /^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$/ - ) - ) - .min(1), - z - .string() - .regex( - /^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$/ - ), - ]) - .optional(), - id: z.string().optional(), - schema: z.string().url().optional(), - spec: z.string().url().optional(), - version: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), -}); -export type CapabilityResponse = z.infer; - -export const ServiceResponseSchema = z.object({ - config: z.record(z.string(), z.any()).optional(), - endpoint: z.string().url().optional(), - id: z.string().optional(), - schema: z.string().url().optional(), - spec: z.string().url().optional(), - transport: TransportSchema, - version: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), -}); -export type ServiceResponse = z.infer; - export const EventLineItemSchema = z.object({ id: z.string(), quantity: z.number().int().gte(1).lte(9007199254740991), @@ -2136,13 +2136,6 @@ export type AvailablePaymentInstrument = z.infer< typeof AvailablePaymentInstrumentSchema >; -export const UcpSchema = z.object({ - capabilities: z.array(CapabilityDiscoverySchema), - services: z.record(z.string(), UcpServiceSchema), - version: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), -}); -export type Ucp = z.infer; - export const LineItemCreateRequestSchema = z.object({ item: ItemCreateRequestSchema, quantity: z.number().int().gte(1).lte(9007199254740991), @@ -2388,6 +2381,55 @@ export type PaymentHandlerResponse = z.infer< typeof PaymentHandlerResponseSchema >; +export const UcpSchema = z.object({ + capabilities: z + .record(z.string(), z.array(CapabilityResponseSchema)) + .refine( + (value) => + Object.keys(value).every((key) => + /^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$/.test( + key + ) + ), + { message: "Record keys must match the required pattern (propertyNames)" } + ) + .optional(), + map_order: z.record(z.string(), z.array(z.string())).optional(), + payment_handlers: z + .record(z.string(), z.array(PaymentHandlerResponseSchema)) + .refine( + (value) => + Object.keys(value).every((key) => + /^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$/.test( + key + ) + ), + { message: "Record keys must match the required pattern (propertyNames)" } + ), + services: z + .record(z.string(), z.array(ServiceResponseSchema)) + .refine( + (value) => + Object.keys(value).every((key) => + /^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$/.test( + key + ) + ), + { message: "Record keys must match the required pattern (propertyNames)" } + ), + status: UcpCheckoutResponseStatusSchema.optional(), + supported_versions: z + .record(z.string(), z.string()) + .refine( + (value) => + Object.keys(value).every((key) => /^\d{4}-\d{2}-\d{2}$/.test(key)), + { message: "Record keys must match the required pattern (propertyNames)" } + ) + .optional(), + version: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), +}); +export type Ucp = z.infer; + export const CheckoutCreateRequestSchema = z.object({ attribution: z.record(z.string(), z.string()).optional(), buyer: BuyerClassSchema.optional(), @@ -2420,7 +2462,7 @@ export const UcpCheckoutResponseSchema = z.object({ { message: "Record keys must match the required pattern (propertyNames)" } ) .optional(), - map_order: z.string().optional(), + map_order: z.record(z.string(), z.array(z.string())).optional(), payment_handlers: z .record(z.string(), z.array(PaymentHandlerResponseSchema)) .refine( @@ -2462,7 +2504,7 @@ export const UcpResponseSchema = z.object({ { message: "Record keys must match the required pattern (propertyNames)" } ) .optional(), - map_order: z.string().optional(), + map_order: z.record(z.string(), z.array(z.string())).optional(), payment_handlers: z .record(z.string(), z.array(PaymentHandlerResponseSchema)) .refine( diff --git a/tests/discovery-profile-shape.test.js b/tests/discovery-profile-shape.test.js new file mode 100644 index 0000000..db5dc6c --- /dev/null +++ b/tests/discovery-profile-shape.test.js @@ -0,0 +1,122 @@ +// Fidelity tests for the discovery profile projection (UcpDiscoveryProfileSchema +// and its `ucp` member, UcpSchema). +// +// ucp.json#/$defs/base declares every registry (services, capabilities, +// payment_handlers) as an object keyed by reverse domain name whose values are +// arrays of entities, and requires only `version`; #/$defs/business_schema (the +// shape a business publishes at /.well-known/ucp) additionally requires +// services and payment_handlers. Before the fix the hand written projection +// carried the withdrawn 2026-01-11 shape: capabilities as a flat array (and +// required), services as a record of single objects, no payment_handlers. It +// REJECTED the business profile example the specification publishes while +// the derived UcpProfileDocumentSchema in the same file accepted it. +// +// tests/fixtures/business_profile_2026-08-25.json is the business profile +// example from docs/specification/overview/index.md at release/2026-08-25 +// (the block annotated `ucp:example schema=profile def=business_schema`), with +// the `{{ ucp_version }}` macro rendered as 2026-08-25. It is JSON equal to +// that block; whitespace follows the repository prettier configuration. + +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); + +const { + UcpDiscoveryProfileSchema, + UcpSchema, +} = require("./.dist/spec_generated.js"); + +const accepts = (schema, value) => schema.safeParse(value).success === true; +const rejects = (schema, value) => schema.safeParse(value).success === false; + +const specProfile = JSON.parse( + fs.readFileSync( + path.join(__dirname, "fixtures", "business_profile_2026-08-25.json"), + "utf8" + ) +); + +test("UcpDiscoveryProfileSchema accepts the business profile example from the specification", () => { + const result = UcpDiscoveryProfileSchema.safeParse(specProfile); + assert.ok( + result.success, + result.success ? "" : JSON.stringify(result.error.issues, null, 2) + ); +}); + +test("UcpSchema models capabilities as a record of entity arrays, not a flat array", () => { + const ucp = specProfile.ucp; + assert.ok(accepts(UcpSchema, ucp)); + const flat = { + ...ucp, + capabilities: Object.entries(ucp.capabilities).flatMap(([name, entries]) => + entries.map((entry) => ({ name, ...entry })) + ), + }; + assert.ok(rejects(UcpSchema, flat)); +}); + +test("UcpSchema models each services entry as an array of service entities", () => { + const ucp = specProfile.ucp; + const singleObject = { + ...ucp, + services: Object.fromEntries( + Object.entries(ucp.services).map(([name, entries]) => [name, entries[0]]) + ), + }; + assert.ok(rejects(UcpSchema, singleObject)); +}); + +test("UcpSchema carries payment_handlers, which the business profile requires", () => { + const { payment_handlers, ...withoutHandlers } = specProfile.ucp; + assert.ok(rejects(UcpSchema, withoutHandlers)); + const parsed = UcpSchema.parse(specProfile.ucp); + assert.deepEqual(parsed.payment_handlers, payment_handlers); +}); + +test("UcpSchema does not require capabilities (base requires only version)", () => { + const { capabilities, ...withoutCapabilities } = specProfile.ucp; + assert.ok(accepts(UcpSchema, withoutCapabilities)); +}); + +test("UcpSchema requires services, per business_schema", () => { + const { services, ...withoutServices } = specProfile.ucp; + assert.ok(rejects(UcpSchema, withoutServices)); +}); + +test("UcpSchema models map_order as a record of key arrays (ucp.json#/$defs/map_order)", () => { + // The second business profile example in the overview carries + // map_order.payment_handlers; the envelope builder used to collapse the + // map_order $ref to a string and rejected it. + const ucp = specProfile.ucp; + const ordered = { + ...ucp, + map_order: { payment_handlers: Object.keys(ucp.payment_handlers) }, + }; + assert.ok(accepts(UcpSchema, ordered)); + assert.ok(rejects(UcpSchema, { ...ucp, map_order: "payment_handlers" })); +}); + +test("UcpSchema types supported_versions values as strings, not any", () => { + // business_schema declares supported_versions as version key to profile URI. + // toCompatLeaf widened the value to any, so a number passed. + const ucp = specProfile.ucp; + assert.ok( + accepts(UcpSchema, { + ...ucp, + supported_versions: { + "2026-04-08": "https://example.com/.well-known/ucp", + }, + }) + ); + assert.ok( + rejects(UcpSchema, { ...ucp, supported_versions: { "2026-04-08": 1 } }) + ); + assert.ok( + rejects(UcpSchema, { + ...ucp, + supported_versions: { "not-a-version": "https://e.example" }, + }) + ); +}); diff --git a/tests/fixtures/business_profile_2026-08-25.json b/tests/fixtures/business_profile_2026-08-25.json new file mode 100644 index 0000000..1c49dee --- /dev/null +++ b/tests/fixtures/business_profile_2026-08-25.json @@ -0,0 +1,131 @@ +{ + "ucp": { + "version": "2026-08-25", + "services": { + "dev.ucp.shopping": [ + { + "version": "2026-08-25", + "spec": "https://ucp.dev/2026-08-25/specification/overview/", + "transport": "rest", + "endpoint": "https://business.example.com/ucp/v1", + "schema": "https://ucp.dev/2026-08-25/services/shopping/rest.openapi.json" + }, + { + "version": "2026-08-25", + "spec": "https://ucp.dev/2026-08-25/specification/overview/", + "transport": "mcp", + "endpoint": "https://business.example.com/ucp/mcp", + "schema": "https://ucp.dev/2026-08-25/services/shopping/mcp.openrpc.json" + }, + { + "version": "2026-08-25", + "spec": "https://ucp.dev/2026-08-25/specification/overview/", + "transport": "a2a", + "endpoint": "https://business.example.com/.well-known/agent-card.json" + }, + { + "version": "2026-08-25", + "spec": "https://ucp.dev/2026-08-25/specification/overview/", + "transport": "embedded", + "schema": "https://ucp.dev/2026-08-25/services/shopping/embedded.openrpc.json" + } + ] + }, + "capabilities": { + "dev.ucp.shopping.checkout": [ + { + "version": "2026-08-25", + "spec": "https://ucp.dev/2026-08-25/specification/shopping/checkout", + "schema": "https://ucp.dev/2026-08-25/schemas/shopping/checkout.json" + } + ], + "dev.ucp.shopping.fulfillment": [ + { + "version": "2026-08-25", + "spec": "https://ucp.dev/2026-08-25/specification/shopping/extensions/fulfillment", + "schema": "https://ucp.dev/2026-08-25/schemas/shopping/fulfillment.json", + "extends": "dev.ucp.shopping.checkout" + } + ], + "dev.ucp.shopping.discount": [ + { + "version": "2026-08-25", + "spec": "https://ucp.dev/2026-08-25/specification/shopping/extensions/discount", + "schema": "https://ucp.dev/2026-08-25/schemas/shopping/discount.json", + "extends": "dev.ucp.shopping.checkout" + } + ], + "dev.ucp.common.identity_linking": [ + { + "version": "2026-08-25", + "spec": "https://ucp.dev/2026-08-25/specification/common/identity-linking/", + "schema": "https://ucp.dev/2026-08-25/schemas/common/identity_linking.json", + "config": { + "providers": { + "com.example.idp": [ + { + "type": "oauth2", + "auth_url": "https://accounts.example.com/" + } + ] + }, + "scopes": { + "dev.ucp.shopping.order:read": {}, + "dev.ucp.shopping.order:manage": {} + } + } + } + ] + }, + "payment_handlers": { + "com.example.processor_tokenizer": [ + { + "id": "processor_tokenizer", + "version": "2026-08-25", + "spec": "https://example.com/specs/payments/processor_tokenizer", + "schema": "https://example.com/specs/payments/merchant_tokenizer.json", + "available_instruments": [ + { + "type": "card", + "constraints": { + "properties": { + "brand": { + "enum": ["visa", "mastercard", "amex"] + } + } + } + } + ], + "config": { + "type": "CARD", + "tokenization_specification": { + "type": "PUSH", + "parameters": { + "token_retrieval_url": "https://api.psp.example.com/v1/tokens" + } + } + } + } + ] + } + }, + "keys": [ + { + "kid": "poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U", + "kty": "OKP", + "crv": "Ed25519", + "x": "JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs", + "use": "sig", + "alg": "EdDSA" + }, + { + "kid": "business_2025", + "kty": "EC", + "crv": "P-256", + "x": "qIVYZVLCrPZHGHjP17CTW0_-D9Lfw0EkjqF7xB4FivA", + "y": "Mc4nN9LTDOBhfoUeg8Ye9WedFRhnZXZJA12Qp0zZ6F0", + "use": "sig", + "alg": "ES256" + } + ] +} diff --git a/tests/spec-constraints.test.js b/tests/spec-constraints.test.js index 74e27d3..cfcb06b 100644 --- a/tests/spec-constraints.test.js +++ b/tests/spec-constraints.test.js @@ -608,7 +608,9 @@ test("CapabilityDiscoverySchema accepts valid extends names", () => { }); test("UcpSchema enforces the discovery version pattern", () => { - const discovery = { capabilities: [], services: {} }; + // Spec shape (ucp.json#/$defs/business_schema): registries are records keyed + // by reverse domain name, services and payment_handlers are required. + const discovery = { services: {}, payment_handlers: {} }; assert.ok(rejects(UcpSchema, { ...discovery, version: "not-a-date" })); assert.ok(accepts(UcpSchema, { ...discovery, version: "2026-04-08" })); }); @@ -938,8 +940,8 @@ test("UcpDiscoveryProfileSchema emits keys array per RFC 7517", () => { assert.ok( accepts(UcpDiscoveryProfileSchema, { ucp: { - capabilities: [], services: {}, + payment_handlers: {}, version: "2026-08-25", }, keys: [{ kid: "key-1", kty: "OKP" }],