From 450eaaa8b96e58fbc3cf44434b9a374580ff9c24 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Sat, 3 Oct 2026 13:08:22 -0700 Subject: [PATCH 1/2] Release 3.0.0: A2A 1.0 agent cards, UCP 2026-08-25 profiles, MPP payment offers The agent card declared A2A 1.0 but carried the 0.3 shape: the endpoint now sits in supportedInterfaces, the extended-card flag in capabilities, and security as securityRequirements. The UCP profile publishes its signing keys as keys and moves to 2026-08-25, which removed signing_keys; supported_versions is no longer published, since it named the current profile as an older version's. x-payment-info now also carries MPP's offers, priced in each rail's smallest unit, alongside the x402scan fields. The A2A canonical-type guard never ran (vitest does not typecheck and tsc covered src alone). It is now a round-trip through @a2a-js/sdk's own codec, which drops any field the spec does not define. --- README.md | 9 +- examples/signed-ucp-merchant.ts | 3 +- package.json | 2 +- src/checkout.ts | 5 +- src/discovery/openapi.ts | 46 ++++++++++ src/discovery/well_known.ts | 21 ++--- src/identity/a2a.ts | 105 ++++++++++++---------- src/identity/ucp-jwks.ts | 24 ++--- src/identity/ucp.ts | 36 ++++---- src/index.ts | 4 + tests/identity/a2a.test.ts | 49 ++++++---- tests/identity/a2a_canonical_type.test.ts | 75 ++++++++-------- tests/identity/ucp-signing.test.ts | 4 +- tests/identity/ucp.test.ts | 14 ++- tests/seamless-helpers.test.ts | 27 +++++- 15 files changed, 267 insertions(+), 157 deletions(-) diff --git a/README.md b/README.md index fae0969..9de93f1 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,6 @@ const checkout = new Checkout({ // directly inside the route handler.) checkout.mountUcpRoutesHono(app, { name: "Merchant", - wellKnownUcpUrl: "https://merchant.example/.well-known/ucp", services: defaultA2aServices({ agentCardUrl: "https://merchant.example/.well-known/agent-card.json" }), signingKid: "merchant-2026-05", }); @@ -297,14 +296,14 @@ const card = buildA2AAgentCard({ // Google Universal Commerce Protocol; publish at /.well-known/ucp // Output shape: { ucp: { version, services, capabilities, payment_handlers, -// name?, supported_versions? }, signing_keys: [...], signature?: "..." } +// name?, supported_versions? }, keys: [...], signature?: "..." } //, services / capabilities / payment_handlers are MAPS keyed by reverse-DNS // service / capability / handler name (UCP spec §3 + §6). const profile = buildUCPProfile({ name, services: { 'dev.ucp.shopping': [ - { version: '2026-04-08', spec: 'https://ucp.dev/2026-04-08/specification/overview', + { version: '2026-08-25', spec: 'https://ucp.dev/2026-08-25/specification/overview', transport: 'mcp', endpoint: 'https://merchant.example/api/ucp/mcp', schema: 'https://ucp.dev/services/shopping/mcp.openrpc.json' }, ], @@ -314,7 +313,7 @@ const profile = buildUCPProfile({ ...x402PaymentHandler({ networks: [{ network: 'base-8453', recipient: BASE_ADDR }] }), ...stripeSptPaymentHandler({ spec: { profileId: 'profile_5xKvNqM9BaH' } }), }, - signing_keys, + keys, // Optional: declare the merchant's gate policy as an `com.agentscore.identity` capability // binding inside the public profile. Static policy declaration only, no per-operator data. // Per-operator identity attestation lives on the AP2 risk-signal endpoint, not here. @@ -328,7 +327,7 @@ UCP §6 doesn't mandate profile-body JWS signing; production UCP merchants commo import { buildJWKSResponse, generateUCPSigningKey, signUCPProfile, verifyUCPProfile, UCPVerificationError } from "@agent-score/commerce"; const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: "merchant-2026-05" }); -const profile = buildUCPProfile({ name, services, payment_handlers, signing_keys: [publicJWK] }); +const profile = buildUCPProfile({ name, services, payment_handlers, keys: [publicJWK] }); const signed = await signUCPProfile(profile, { signingKey: privateKey, kid: publicJWK.kid, alg: "EdDSA" }); const jwks = buildJWKSResponse([publicJWK]); ``` diff --git a/examples/signed-ucp-merchant.ts b/examples/signed-ucp-merchant.ts index 23e7473..742f844 100644 --- a/examples/signed-ucp-merchant.ts +++ b/examples/signed-ucp-merchant.ts @@ -70,7 +70,6 @@ app.use('*', rateLimitHono()); // CORS + X-Request-ID headers per UCP §6. checkout.mountUcpRoutesHono(app, { name: 'My Agent Service', - wellKnownUcpUrl: 'https://agents.example.com/.well-known/ucp', services: defaultA2aServices({ agentCardUrl: 'https://agents.example.com/.well-known/agent-card.json', }), @@ -92,7 +91,7 @@ app.get('/_selftest/ucp', async (c: Context) => { await verifyUCPProfile(profile as never, jwks); return c.json({ ok: true, - kid: ((profile.signing_keys as Array<{ kid?: string }> | undefined)?.[0])?.kid, + kid: ((profile.keys as Array<{ kid?: string }> | undefined)?.[0])?.kid, }); } catch (err) { if (err instanceof UCPVerificationError) { diff --git a/package.json b/package.json index 54cfd0c..c121ea3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agent-score/commerce", - "version": "2.15.0", + "version": "3.0.0", "description": "Agentic commerce SDK: identity middleware (Hono, Express, Fastify, Next.js, Web Fetch) + payment helpers + 402 builders + discovery + Stripe multichain. The full merchant-side toolkit for AgentScore-powered agentic commerce.", "main": "./dist/index.js", "module": "./dist/index.mjs", diff --git a/src/checkout.ts b/src/checkout.ts index a33cfb4..25c833d 100644 --- a/src/checkout.ts +++ b/src/checkout.ts @@ -2647,7 +2647,9 @@ interface FastifyLikeReply { * (GET ucp + GET jwks + OPTIONS preflights) every time. */ export interface MountUcpRoutesOptions { name: string; - wellKnownUcpUrl: string; + /** @deprecated No longer published (it fed `supported_versions`, which UCP reserves for + * older versions' own profiles). Accepted so existing callers keep compiling. */ + wellKnownUcpUrl?: string; services: Record; signingKid?: string; agentscoreGate?: unknown; @@ -2664,7 +2666,6 @@ async function _ucpSignedResp( return await buildSignedUcpResponse({ checkout, name: opts.name, - wellKnownUcpUrl: opts.wellKnownUcpUrl, services: opts.services as Parameters[0]['services'], requestHeaders: reqHeaders, ...(opts.signingKid !== undefined && { signingKid: opts.signingKid }), diff --git a/src/discovery/openapi.ts b/src/discovery/openapi.ts index f270311..1119295 100644 --- a/src/discovery/openapi.ts +++ b/src/discovery/openapi.ts @@ -7,6 +7,8 @@ * full spec. */ +import { usdToAtomic } from '../payment/amounts'; + /** * Standard AgentScore identity security schemes. Plug into `components.securitySchemes`. * @@ -203,32 +205,76 @@ export interface XPaymentInfoMppProtocol { export type XPaymentInfoProtocol = XPaymentInfoX402Protocol | XPaymentInfoMppProtocol; +/** + * One way to pay for an operation, in MPP's payment-discovery form + * (`draft-payment-discovery`): `amount` is an integer string in the currency's + * smallest unit, or `null` when the price depends on the request. + */ +export interface XPaymentInfoOffer { + intent: 'charge' | 'session'; + method: string; + amount: string | null; + currency?: string; + description?: string; +} + +/** + * Carries both readers' shapes in one object, because MPP and x402scan define the + * same `x-payment-info` extension differently and neither reads the other's keys: + * x402scan reads `price` + `protocols`, MPP reads `offers`. + */ export interface XPaymentInfoBlock { authMode: 'payment'; price: XPaymentInfoPrice; protocols: XPaymentInfoProtocol[]; + offers?: XPaymentInfoOffer[]; description?: string; } export function xPaymentInfoExtension({ price, protocols, + offers, description, }: { price: XPaymentInfoPrice; protocols: XPaymentInfoProtocol[]; + /** MPP payment offers. Defaults to one per MPP protocol entry, priced from `price`. */ + offers?: XPaymentInfoOffer[]; description?: string; }): { 'x-payment-info': XPaymentInfoBlock } { + const derived = offers ?? offersFrom(price, protocols); return { 'x-payment-info': { authMode: 'payment', price, protocols, + ...(derived.length > 0 && { offers: derived }), ...(description !== undefined && { description }), }, }; } +/** + * MPP offers for the MPP entries in `protocols`, priced in each method's smallest unit + * the way the 402 challenge prices it: Stripe in cents, the token rails (Tempo USDC.e, + * Solana USDC) in 6-decimal base units. x402 entries have no MPP offer; a dynamic price + * is `null`, which MPP defines as "depends on the request". + */ +export function offersFrom(price: XPaymentInfoPrice, protocols: XPaymentInfoProtocol[]): XPaymentInfoOffer[] { + const offers: XPaymentInfoOffer[] = []; + for (const p of protocols) { + if (!('mpp' in p)) continue; + const [method, slashIntent] = p.mpp.method.split('/'); + const intent = (slashIntent ?? p.mpp.intent) === 'session' ? 'session' : 'charge'; + const currency = typeof p.mpp.currency === 'string' ? p.mpp.currency : undefined; + const decimals = method === 'stripe' ? 2 : 6; + const amount = price.mode === 'fixed' && price.currency.toUpperCase() === 'USD' ? usdToAtomic(price.amount, { decimals }).toString() : null; + offers.push({ intent, method: method!, amount, ...(currency !== undefined && { currency }) }); + } + return offers; +} + /** * `info.x-guidance` extension, per the x402scan discovery spec. Spread into your * OpenAPI document's `info` block to give agents a high-level prose description diff --git a/src/discovery/well_known.ts b/src/discovery/well_known.ts index f3918a7..3ec9736 100644 --- a/src/discovery/well_known.ts +++ b/src/discovery/well_known.ts @@ -45,7 +45,8 @@ import type { const UCP_CACHE_SECONDS = 60; const JWKS_CACHE_SECONDS = 300; -const UCP_SHOPPING_SPEC_2026_04_08 = 'https://ucp.dev/2026-04-08/specification/overview'; +const UCP_VERSION = '2026-08-25'; +const UCP_SHOPPING_SPEC = `https://ucp.dev/${UCP_VERSION}/specification/overview`; /** * Framework-neutral response shape for discovery endpoints. @@ -175,13 +176,15 @@ function misconfiguredResponse( * Cache-Control) when no payment handlers can be derived from rails. * * `services` is the spec-compliant services map (keyed by reverse-DNS service - * name). `wellKnownUcpUrl` is the canonical URL of this profile, surfaced as - * the value in `supported_versions`. + * name). The profile publishes only the current UCP version: `supported_versions` + * maps OLDER versions to complete profiles for them, and this serves none. */ export async function buildSignedUcpResponse(opts: { checkout: Checkout; name: string; - wellKnownUcpUrl: string; + /** @deprecated No longer published: it fed `supported_versions`, which UCP reserves for + * older versions' own profiles. Accepted so existing callers keep compiling. */ + wellKnownUcpUrl?: string; services: Record; requestHeaders?: Headers | Record; signingKid?: string; @@ -190,7 +193,6 @@ export async function buildSignedUcpResponse(opts: { const { checkout, name, - wellKnownUcpUrl, services, requestHeaders, signingKid = 'merchant-default', @@ -207,11 +209,10 @@ export async function buildSignedUcpResponse(opts: { const profile = buildUCPProfile({ name, - supported_versions: { '2026-04-08': wellKnownUcpUrl }, agentscore_gate: agentscoreGate, services, payment_handlers: handlers, - signing_keys: [signingKeyEntry], + keys: [signingKeyEntry], }); const signed = await signUCPProfile(profile, { signingKey: key.privateKey, @@ -301,7 +302,7 @@ export function wellKnownPreflightResponse( /** * Canonical UCP services map for a merchant publishing an A2A agent card. * - * Returns `{"dev.ucp.shopping": [UCPServiceBinding(version: '2026-04-08', + * Returns `{"dev.ucp.shopping": [UCPServiceBinding(version: '2026-08-25', * spec: , transport: 'a2a', endpoint: agentCardUrl)]}`; * the binding every UCP-publishing merchant declares when their primary agent * surface is the A2A v1.0 `/.well-known/agent-card.json` (versus a UCP MCP or @@ -316,8 +317,8 @@ export function defaultA2aServices(opts: { return { 'dev.ucp.shopping': [ { - version: '2026-04-08', - spec: UCP_SHOPPING_SPEC_2026_04_08, + version: UCP_VERSION, + spec: UCP_SHOPPING_SPEC, transport: 'a2a', endpoint: opts.agentCardUrl, }, diff --git a/src/identity/a2a.ts b/src/identity/a2a.ts index 85ad9b6..ba90cef 100644 --- a/src/identity/a2a.ts +++ b/src/identity/a2a.ts @@ -23,8 +23,8 @@ const DEFAULT_OUTPUT_MODE = 'application/json'; /** Canonical UCP A2A extension URI — verifiers look for this exact URI in * `capabilities.extensions[]` to detect UCP support on the agent card. Pinned - * to the 2026-04-08 spec snapshot. */ -export const UCP_A2A_EXTENSION_URI = 'https://ucp.dev/2026-04-08/specification/reference'; + * to the 2026-08-25 spec snapshot. */ +export const UCP_A2A_EXTENSION_URI = 'https://ucp.dev/2026-08-25/specification/reference'; /** Canonical URI for the AIP (Agentic Identity Protocol) A2A agent-card extension — points at the * issuer-discovery well-known so a reader can resolve the protocol. */ @@ -126,12 +126,28 @@ export function aipA2AExtension( }; } -/** Optional capabilities the agent supports. */ +/** Optional capabilities the agent supports (A2A 1.0 `AgentCapabilities`). */ export interface A2AAgentCardCapabilities { extensions?: A2AAgentCardExtension[]; pushNotifications?: boolean; - stateTransitionHistory?: boolean; streaming?: boolean; + /** The agent serves an extended card to authenticated callers (1.0 moved this here from the + * card's top level, where 0.3 called it `supportsAuthenticatedExtendedCard`). */ + extendedAgentCard?: boolean; +} + +/** One endpoint and the protocol binding it speaks (A2A 1.0 `AgentInterface`). */ +export interface A2ASupportedInterface { + url: string; + /** `JSONRPC`, `GRPC` or `HTTP+JSON`. */ + protocolBinding: string; + protocolVersion: string; + tenant?: string; +} + +/** A2A 1.0 `SecurityRequirement`: scheme name to the scopes it needs. */ +export interface A2ASecurityRequirement { + schemes: Record; } /** JWS signature embedded in an Agent Card. Multiple signatures MAY be attached; @@ -146,73 +162,65 @@ export interface A2AAgentCardSignature { header?: Record; } -/** A2A v1.0 Agent Card body, matching `AgentCard` from `@a2a-js/sdk`. */ +/** A2A 1.0 Agent Card body, matching `message AgentCard` in the A2A specification + * (`specification/a2a.proto`) and `AgentCard` in `@a2a-js/sdk`. */ export interface A2AAgentCard { name: string; description: string; - /** Preferred endpoint URL — MUST support `preferredTransport`. */ - url: string; - /** Transport at the primary `url`. Defaults to `JSONRPC` per spec when omitted by a reader. */ - preferredTransport?: string; - /** A2A protocol version, e.g. `"1.0"`. Distinct from the agent's own `version`. */ - protocolVersion: string; - /** Additional transport+URL bindings beyond the primary. */ - additionalInterfaces?: A2AAgentInterface[]; + /** Every endpoint the agent serves, first one preferred. REQUIRED, and where 1.0 moved the + * endpoint URL, transport and protocol version that 0.3 carried at the top level. */ + supportedInterfaces: A2ASupportedInterface[]; /** Agent's own version, e.g. `"1.0.0"`. */ version: string; capabilities: A2AAgentCardCapabilities; defaultInputModes: string[]; defaultOutputModes: string[]; /** REQUIRED non-empty. */ - skills: A2AAgentSkill[]; + skills: A2AAgentCardSkill[]; provider?: A2AAgentProvider; documentationUrl?: string; iconUrl?: string; - /** Agent can provide an extended card with additional details to authenticated users. - * Defaults to `false`. */ - supportsAuthenticatedExtendedCard?: boolean; /** JWS signatures embedded in the card. Compute over the canonical card body MINUS * this field, then attach. */ signatures?: A2AAgentCardSignature[]; - /** OpenAPI 3.0 security requirement objects (OR of ANDs). */ - security?: Record[]; + /** Any one of these requirements satisfies the card (OR); each names the schemes it needs (AND). */ + securityRequirements?: A2ASecurityRequirement[]; /** Map of security scheme definitions (key = scheme name). */ securitySchemes?: Record; /** Vendor-specific extras merged at top level. */ [k: string]: unknown; } +/** A skill as the 1.0 card carries it: `security` became `securityRequirements`. */ +export type A2AAgentCardSkill = Omit & { securityRequirements?: A2ASecurityRequirement[] }; + interface BuildA2AAgentCardInput { /** Agent display name. REQUIRED. */ name: string; /** Agent purpose/description. REQUIRED per spec. */ description: string; - /** Primary endpoint URL — becomes `AgentCard.url`. The transport at this URL is - * declared via `preferredTransport` (default `HTTP+JSON`). For multi-binding agents, - * pass `additionalInterfaces` for the secondary transports. */ + /** Primary endpoint URL, the first entry of `supportedInterfaces`, speaking + * `preferredTransport` (default `HTTP+JSON`). */ url: string; /** Top-level skill declarations — what the agent can do. REQUIRED per spec * (proto field 12 [field_behavior=REQUIRED]); must have ≥1 entry. */ skills: A2AAgentSkill[]; /** Agent's own version, e.g. `"1.0.0"`. Distinct from the A2A `protocolVersion`. */ version?: string; - /** Transport for the primary `url`. Defaults to `"HTTP+JSON"` for our merchants; the - * canonical A2A spec default when omitted by a reader is `"JSONRPC"`. */ + /** Protocol binding at `url`. Defaults to `"HTTP+JSON"` for our merchants. */ preferredTransport?: string; - /** A2A protocol version. Defaults to `"1.0"`. */ + /** A2A protocol version each interface declares. Defaults to `"1.0"`. */ protocolVersion?: string; - /** Additional transport+URL bindings beyond the primary. */ + /** Further endpoints beyond `url`, each with its own binding; they follow it in `supportedInterfaces`. */ additionalInterfaces?: A2AAgentInterface[]; - /** A2A v1.0 capability extensions. Build the UCP entry with `ucpA2AExtension()`. */ + /** A2A capability extensions. Build the UCP entry with `ucpA2AExtension()`. */ extensions?: A2AAgentCardExtension[]; /** Capability flag: agent supports streaming responses (SSE). */ streaming?: boolean; /** Capability flag: agent supports push notifications for async task updates. */ pushNotifications?: boolean; - /** Capability flag: agent provides task state-transition history. */ - stateTransitionHistory?: boolean; - /** AgentCard top-level flag: agent serves an extended card to authenticated users. */ - supportsAuthenticatedExtendedCard?: boolean; + /** Capability flag: agent serves an extended card to authenticated callers. */ + extendedAgentCard?: boolean; /** Provider org for the agent. */ provider?: A2AAgentProvider; /** URL to additional human-readable documentation. */ @@ -225,7 +233,8 @@ interface BuildA2AAgentCardInput { defaultInputModes?: string[]; /** Default output media types (defaults to `["application/json"]`). */ defaultOutputModes?: string[]; - /** OpenAPI 3.0 security requirement objects (OR of ANDs). */ + /** Security requirements in the OpenAPI form (OR of ANDs, scheme name to scopes); emitted as + * 1.0 `securityRequirements`. */ security?: Record[]; /** Per-scheme security details (key = scheme name). */ securitySchemes?: Record; @@ -233,16 +242,20 @@ interface BuildA2AAgentCardInput { extras?: Record; } +/** OpenAPI-form requirements (OR of ANDs) in the A2A 1.0 shape. */ +export function toSecurityRequirements(security: Record[]): A2ASecurityRequirement[] { + return security.map((req) => ({ schemes: Object.fromEntries(Object.entries(req).map(([name, scopes]) => [name, { list: scopes }])) })); +} + /** - * Compose an A2A v1.0 Agent Card body matching `AgentCard` from `@a2a-js/sdk`. + * Compose an A2A 1.0 Agent Card body (`message AgentCard` in the A2A specification). * * Returns the UNSIGNED card. To attach identity claims, sign the serialized body * as an RFC 7515 JWS (`A2AAgentCardSignature`). Vendors can also add an identity-flavored * extension to `capabilities.extensions[]`. * - * The `url` argument becomes the top-level `AgentCard.url`; `preferredTransport` - * declares the transport at that URL (default `HTTP+JSON`). For multi-binding agents, - * pass `additionalInterfaces`. + * `url` becomes the first entry of `supportedInterfaces`, speaking `preferredTransport` + * (default `HTTP+JSON`); `additionalInterfaces` follow it. * * Example: * ```ts @@ -268,35 +281,31 @@ export function buildA2AAgentCard(input: BuildA2AAgentCardInput): A2AAgentCard { ); } + const protocolVersion = input.protocolVersion ?? PROTOCOL_VERSION; const capabilities: A2AAgentCardCapabilities = {}; if (input.streaming !== undefined) capabilities.streaming = input.streaming; if (input.pushNotifications !== undefined) capabilities.pushNotifications = input.pushNotifications; - if (input.stateTransitionHistory !== undefined) capabilities.stateTransitionHistory = input.stateTransitionHistory; + if (input.extendedAgentCard !== undefined) capabilities.extendedAgentCard = input.extendedAgentCard; if (input.extensions && input.extensions.length > 0) capabilities.extensions = input.extensions; const card: A2AAgentCard = { name: input.name, description: input.description, - url: input.url, - preferredTransport: input.preferredTransport ?? 'HTTP+JSON', - protocolVersion: input.protocolVersion ?? PROTOCOL_VERSION, + supportedInterfaces: [ + { url: input.url, protocolBinding: input.preferredTransport ?? 'HTTP+JSON', protocolVersion }, + ...(input.additionalInterfaces ?? []).map((i) => ({ url: i.url, protocolBinding: i.transport, protocolVersion })), + ], version: input.version ?? '1.0.0', capabilities, defaultInputModes: input.defaultInputModes ?? [DEFAULT_INPUT_MODE], defaultOutputModes: input.defaultOutputModes ?? [DEFAULT_OUTPUT_MODE], - skills: input.skills, + skills: input.skills.map(({ security, ...skill }) => (security ? { ...skill, securityRequirements: toSecurityRequirements(security) } : skill)), }; - if (input.additionalInterfaces !== undefined && input.additionalInterfaces.length > 0) { - card.additionalInterfaces = input.additionalInterfaces; - } if (input.provider !== undefined) card.provider = input.provider; if (input.documentationUrl !== undefined) card.documentationUrl = input.documentationUrl; if (input.iconUrl !== undefined) card.iconUrl = input.iconUrl; - if (input.supportsAuthenticatedExtendedCard !== undefined) { - card.supportsAuthenticatedExtendedCard = input.supportsAuthenticatedExtendedCard; - } if (input.signatures !== undefined && input.signatures.length > 0) card.signatures = input.signatures; - if (input.security !== undefined) card.security = input.security; + if (input.security !== undefined) card.securityRequirements = toSecurityRequirements(input.security); if (input.securitySchemes !== undefined) card.securitySchemes = input.securitySchemes; if (input.extras) { for (const [k, v] of Object.entries(input.extras)) { diff --git a/src/identity/ucp-jwks.ts b/src/identity/ucp-jwks.ts index c4bdc84..43e0f19 100644 --- a/src/identity/ucp-jwks.ts +++ b/src/identity/ucp-jwks.ts @@ -25,12 +25,12 @@ import type { UCPProfile, UCPSigningKey } from './ucp'; /** Output of `generateUCPSigningKey()`. The private key is what you sign with; the * public JWK is what you publish at `/.well-known/jwks.json` and reference in the - * UCP profile's `signing_keys[]`. + * UCP profile's `keys[]`. */ export interface GeneratedUCPKey { /** Private key (KeyLike, opaque) — pass to `signUCPProfile()`. Never publish. */ privateKey: unknown; - /** Public key as JWK — publish at `/.well-known/jwks.json` and inline in UCP `signing_keys[]`. */ + /** Public key as JWK: publish at `/.well-known/jwks.json` and inline in UCP `keys[]`. */ publicJWK: UCPSigningKey; } @@ -53,7 +53,7 @@ const JOSE_INSTALL_HINT = 'Install the optional peer dependency: `npm install jo /** UCP §6 + RFC 8725 §3.1 — restrict accepted JWS algorithms. Anything outside this * list (HS, RS, none, etc.) is rejected to prevent alg-confusion attacks where a - * hostile JWK published in the profile's signing_keys[] is used with an unintended + * hostile JWK published in the profile's keys[] is used with an unintended * algorithm. */ const ALLOWED_ALGS = ['EdDSA', 'ES256'] as const; type AllowedAlg = (typeof ALLOWED_ALGS)[number]; @@ -212,7 +212,7 @@ function stableStringify(value: unknown): string { * `signUCPProfile()`. Never log or transmit the private key. * * The `publicJWK` is what you publish at `/.well-known/jwks.json` and inline in the - * UCP profile's `signing_keys[]` array. + * UCP profile's `keys[]` array. * * Example: * ```ts @@ -224,7 +224,7 @@ function stableStringify(value: unknown): string { * ``` */ export async function generateUCPSigningKey(opts: { - /** Key ID (kid). Must be unique per key; you'll reference this in the UCP profile's `signing_keys[]`. */ + /** Key ID (kid). Must be unique per key; you'll reference this in the UCP profile's `keys[]`. */ kid: string; /** Signing algorithm. Default `EdDSA`. */ alg?: 'EdDSA' | 'ES256'; @@ -251,13 +251,13 @@ export async function generateUCPSigningKey(opts: { * itself, with keys sorted at every level). Trust-mode UCP verifiers reconstruct the * canonical body, look up the key referenced by the JWS header's `kid`, and validate. * - * The profile's `signing_keys[]` MUST already include a JWK with the matching `kid` + * The profile's `keys[]` MUST already include a JWK with the matching `kid` * — otherwise verifiers can't find the public key. Add the `publicJWK` from - * `generateUCPSigningKey()` to your `signing_keys[]` before calling this. + * `generateUCPSigningKey()` to your `keys[]` before calling this. * * Example: * ```ts - * const profile = buildUCPProfile({ ..., signing_keys: [publicJWK] }); + * const profile = buildUCPProfile({ ..., keys: [publicJWK] }); * const signed = await signUCPProfile(profile, { signingKey: privateKey, kid: 'merchant-2026-05' }); * c.json(signed); * ``` @@ -271,7 +271,7 @@ export async function signUCPProfile( }: { /** Private signing key — opaque KeyLike from `generateUCPSigningKey()` or `importJWK()`. */ signingKey: unknown; - /** Key ID (must match a `kid` in the profile's `signing_keys[]`). */ + /** Key ID (must match a `kid` in the profile's `keys[]`). */ kid: string; /** Signing algorithm — `EdDSA` (default) or `ES256`. */ alg?: 'EdDSA' | 'ES256'; @@ -285,17 +285,17 @@ export async function signUCPProfile( ); } - // Sign-time kid sanity check: the profile's `signing_keys[]` MUST contain a + // Sign-time kid sanity check: the profile's `keys[]` MUST contain a // JWK with the matching kid; otherwise verifiers can't resolve the public // key and the profile is dead-on-arrival. Catch this at sign-time rather // than at verifier-time in production. if (typeof kid !== 'string' || !kid) { throw new Error('signUCPProfile: kid must be a non-empty string.'); } - const kids = (profile.signing_keys ?? []).map((k) => (k as Record).kid); + const kids = ((profile.keys ?? profile.signing_keys ?? []) as unknown[]).map((k) => (k as Record).kid); if (!kids.includes(kid)) { throw new Error( - `signUCPProfile: kid ${JSON.stringify(kid)} is not present in profile.signing_keys[] (declared kids: ${JSON.stringify(kids)}). Verifiers will not find the key.`, + `signUCPProfile: kid ${JSON.stringify(kid)} is not present in profile.keys[] (declared kids: ${JSON.stringify(kids)}). Verifiers will not find the key.`, ); } diff --git a/src/identity/ucp.ts b/src/identity/ucp.ts index 00c26b3..4899c01 100644 --- a/src/identity/ucp.ts +++ b/src/identity/ucp.ts @@ -2,7 +2,7 @@ * UCP (Universal Commerce Protocol) profile builder. * * Compose the JSON payload published at `/.well-known/ucp` per the UCP spec. - * Output shape matches the spec example: top-level `{ ucp: {...}, signing_keys: [...] }` + * Output shape matches the spec example: top-level `{ ucp: {...}, keys: [...] }` * envelope, with `services` / `capabilities` / `payment_handlers` as MAPs keyed by * reverse-DNS service / capability / handler name. * @@ -182,13 +182,14 @@ export interface UCPProfileBody { } /** Full UCP profile body as published at `/.well-known/ucp`. Top-level shape: - * `{ ucp: {...}, signing_keys: [...], signature?: "..." }`. */ + * `{ ucp: {...}, keys: [...], signature?: "..." }`. */ export interface UCPProfile { /** UCP body. ALL UCP-spec fields nest here per spec. */ ucp: UCPProfileBody; - /** JWKS — public keys at the OUTER level per UCP spec. Verifiers fetch this profile, + /** The profile's signing keys as a JWK Set, at the OUTER level. UCP 2026-08-25 made + * `keys` the one canonical field and removed `signing_keys`. Verifiers fetch this profile, * match the kid from a JWS / RFC 9421 signature header against this list, and validate. */ - signing_keys: UCPSigningKey[]; + keys: UCPSigningKey[]; /** Set when JWS-signed via `signUCPProfile` — JWS Compact Serialization with detached * payload (header..signature; payload is the canonicalized body minus this field). */ signature?: string; @@ -197,7 +198,7 @@ export interface UCPProfile { } interface BuildUCPProfileInput { - /** UCP spec version. Default `'2026-04-08'` (the latest published UCP spec date). MUST match a published UCP spec version, not a free-form date. */ + /** UCP spec version. Default `'2026-08-25'` (the latest published UCP spec date). MUST match a published UCP spec version, not a free-form date. */ version?: string; /** Display name for the merchant / agent surface. */ name?: string; @@ -209,8 +210,11 @@ interface BuildUCPProfileInput { capabilities?: Record; /** Payment handlers map, keyed by handler reverse-DNS name. */ payment_handlers?: Record; - /** JWKS — public keys the merchant signs with. REQUIRED by spec. */ - signing_keys: UCPSigningKey[]; + /** Public keys the merchant signs with, published as the profile's `keys`. One of + * `keys` or the older `signing_keys` is required. */ + keys?: UCPSigningKey[]; + /** @deprecated The name UCP used before 2026-08-25; accepted and published as `keys`. */ + signing_keys?: UCPSigningKey[]; /** Merchant gate policy declaration. When provided, the SDK auto-injects an * `com.agentscore.identity` capability binding into `capabilities`, with the * policy as the binding's `config`. Static merchant declaration only — no @@ -225,13 +229,13 @@ interface BuildUCPProfileInput { /** `supported_versions` map at the profile root for backwards-compat across * spec dates. Pattern: `{ "": "/.well-known/ucp/" }`. */ supported_versions?: Record; - /** Vendor-specific extras at the OUTER level (alongside `ucp` + `signing_keys`). */ + /** Vendor-specific extras at the OUTER level (alongside `ucp` + `keys`). */ extras?: Record; /** Vendor-specific extras INSIDE the `ucp` envelope (alongside `version`, `services`, etc.). */ ucp_extras?: Record; } -const DEFAULT_VERSION = '2026-04-08'; +const DEFAULT_VERSION = '2026-08-25'; // Reverse-DNS namespacing per UCP convention (`^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9_]*)+$`). // The bare `agentscore-identity` form fails the spec regex; vendor-namespacing under // `com.agentscore` is honest about the capability being our extension, not UCP-canonical. @@ -269,6 +273,7 @@ const AGENTSCORE_EXTENDS = ['dev.ucp.shopping.checkout', 'dev.ucp.shopping.cart' const RESERVED_TOP_LEVEL = new Set([ 'ucp', + 'keys', 'signing_keys', 'signature', '__proto__', @@ -290,7 +295,7 @@ const RESERVED_UCP_FIELDS = new Set([ /** * Compose a UCP profile body for `/.well-known/ucp` publication. Returns the spec- * compliant shape: `{ ucp: { version, services, capabilities, payment_handlers, ... }, - * signing_keys: [...] }`. Pass through `signUCPProfile` to attach a JWS signature for + * keys: [...] }`. Pass through `signUCPProfile` to attach a JWS signature for * trust-mode verifiers. * * Auto-injects `com.agentscore.identity` as a vendor capability extending both @@ -309,7 +314,7 @@ const RESERVED_UCP_FIELDS = new Set([ * name: 'Example Merchant', * services: { * 'dev.ucp.shopping': [ - * { version: '2026-04-08', spec: 'https://ucp.dev/2026-04-08/specification/overview', + * { version: '2026-08-25', spec: 'https://ucp.dev/2026-08-25/specification/overview', * transport: 'mcp', endpoint: 'https://merchant.example/api/ucp/mcp', * schema: 'https://ucp.dev/services/shopping/mcp.openrpc.json' }, * ], @@ -317,7 +322,7 @@ const RESERVED_UCP_FIELDS = new Set([ * payment_handlers: { * ...mppPaymentHandler({ networks: [{ network: 'tempo-mainnet', chain_id: 4217, recipient: TEMPO_ADDR }] }), * }, - * signing_keys: [signingKey], + * keys: [signingKey], * agentscore_gate: { require_kyc: true, min_age: 21, allowed_jurisdictions: ['US'] }, * }); * ``` @@ -396,10 +401,9 @@ export function buildUCPProfile(input: BuildUCPProfileInput): UCPProfile { Object.assign(ucp, input.ucp_extras); } - const profile: UCPProfile = { - ucp, - signing_keys: input.signing_keys, - }; + const keys = input.keys ?? input.signing_keys; + if (!keys) throw new Error('buildUCPProfile: `keys` is required (the public keys the profile is signed with).'); + const profile: UCPProfile = { ucp, keys }; if (input.extras) { // `__proto__`, `constructor`, `prototype` reserved so vendor extras can't slip // prototype-pollution payloads into the canonical body. diff --git a/src/index.ts b/src/index.ts index bd40c79..53debc1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,6 +46,10 @@ export { type A2AAgentInterface, type A2AAgentProvider, type A2AAgentSkill, + type A2AAgentCardSkill, + type A2ASecurityRequirement, + type A2ASupportedInterface, + toSecurityRequirements, } from './identity/a2a'; export { AGENTSCORE_UCP_CAPABILITY, diff --git a/tests/identity/a2a.test.ts b/tests/identity/a2a.test.ts index 7a86b3b..d9158b0 100644 --- a/tests/identity/a2a.test.ts +++ b/tests/identity/a2a.test.ts @@ -58,15 +58,21 @@ describe('buildA2AAgentCard (A2A v1.0 wire format)', () => { }); expect(card.name).toBe('Example Merchant'); expect(card.description).toBe('Buy regulated goods via agent payments.'); - expect(card.url).toBe('https://agents.example.com'); - expect(card.preferredTransport).toBe('HTTP+JSON'); - expect(card.protocolVersion).toBe('1.0'); + expect(card.supportedInterfaces).toEqual([{ url: 'https://agents.example.com', protocolBinding: 'HTTP+JSON', protocolVersion: '1.0' }]); expect(card.version).toBe('1.0.0'); expect(card.capabilities).toEqual({}); expect(card.defaultInputModes).toEqual(['application/json']); expect(card.defaultOutputModes).toEqual(['application/json']); expect(card.skills).toHaveLength(1); - expect(card.additionalInterfaces).toBeUndefined(); + }); + + it('carries none of the 0.3 top-level fields A2A 1.0 removed', () => { + const card = buildA2AAgentCard({ + name: 'X', description: 'y', url: 'https://x.example', skills: [DEFAULT_SKILL], extendedAgentCard: true, security: [{ bearer: [] }], + }) as Record; + for (const gone of ['url', 'preferredTransport', 'protocolVersion', 'additionalInterfaces', 'supportsAuthenticatedExtendedCard', 'security']) { + expect(card[gone], gone).toBeUndefined(); + } }); it('does NOT emit any snake_case keys (canonical wire format is camelCase)', () => { @@ -139,11 +145,11 @@ describe('buildA2AAgentCard (A2A v1.0 wire format)', () => { skills: [DEFAULT_SKILL], streaming: true, pushNotifications: false, - stateTransitionHistory: true, + extendedAgentCard: true, }); expect(card.capabilities.streaming).toBe(true); expect(card.capabilities.pushNotifications).toBe(false); - expect(card.capabilities.stateTransitionHistory).toBe(true); + expect(card.capabilities.extendedAgentCard).toBe(true); }); it('capability flags omitted when unset', () => { @@ -152,19 +158,19 @@ describe('buildA2AAgentCard (A2A v1.0 wire format)', () => { }); expect(card.capabilities.streaming).toBeUndefined(); expect(card.capabilities.pushNotifications).toBeUndefined(); - expect(card.capabilities.stateTransitionHistory).toBeUndefined(); + expect(card.capabilities.extendedAgentCard).toBeUndefined(); }); - it('supportsAuthenticatedExtendedCard lives at AgentCard top level, NOT inside capabilities', () => { + it('the extended-card flag lives inside capabilities in 1.0, not at the card top level', () => { const card = buildA2AAgentCard({ name: 'X', description: 'y', url: 'https://x.example', skills: [DEFAULT_SKILL], - supportsAuthenticatedExtendedCard: true, + extendedAgentCard: true, }); - expect(card.supportsAuthenticatedExtendedCard).toBe(true); - expect((card.capabilities as Record).supportsAuthenticatedExtendedCard).toBeUndefined(); + expect(card.capabilities.extendedAgentCard).toBe(true); + expect((card as Record).supportsAuthenticatedExtendedCard).toBeUndefined(); }); it('provider emitted when set', () => { @@ -236,7 +242,7 @@ describe('buildA2AAgentCard (A2A v1.0 wire format)', () => { expect(card.defaultOutputModes).toEqual(['text/plain']); }); - it('preferredTransport overridable on the AgentCard top level', () => { + it('preferredTransport sets the first interface\'s binding', () => { const card = buildA2AAgentCard({ name: 'X', description: 'y', @@ -245,7 +251,7 @@ describe('buildA2AAgentCard (A2A v1.0 wire format)', () => { preferredTransport: 'GRPC', protocolVersion: '1.0', }); - expect(card.preferredTransport).toBe('GRPC'); + expect(card.supportedInterfaces[0]).toEqual({ url: 'https://x.example', protocolBinding: 'GRPC', protocolVersion: '1.0' }); }); it('additionalInterfaces emitted when set (multi-binding agents)', () => { @@ -260,7 +266,11 @@ describe('buildA2AAgentCard (A2A v1.0 wire format)', () => { skills: [DEFAULT_SKILL], additionalInterfaces: ifaces, }); - expect(card.additionalInterfaces).toEqual(ifaces); + expect(card.supportedInterfaces).toEqual([ + { url: 'https://x.example', protocolBinding: 'HTTP+JSON', protocolVersion: '1.0' }, + { url: 'https://x.example/grpc', protocolBinding: 'GRPC', protocolVersion: '1.0' }, + { url: 'https://x.example/jsonrpc', protocolBinding: 'JSONRPC', protocolVersion: '1.0' }, + ]); }); it('additionalInterfaces omitted when empty', () => { @@ -271,7 +281,7 @@ describe('buildA2AAgentCard (A2A v1.0 wire format)', () => { skills: [DEFAULT_SKILL], additionalInterfaces: [], }); - expect(card.additionalInterfaces).toBeUndefined(); + expect(card.supportedInterfaces).toHaveLength(1); }); it('skills carry optional inputModes / outputModes / examples / security', () => { @@ -290,7 +300,8 @@ describe('buildA2AAgentCard (A2A v1.0 wire format)', () => { }); expect(card.skills[0]?.inputModes).toEqual(['application/json']); expect(card.skills[0]?.outputModes).toEqual(['text/plain']); - expect(card.skills[0]?.security).toEqual([{ bearer: [] }]); + expect(card.skills[0]?.securityRequirements).toEqual([{ schemes: { bearer: { list: [] } } }]); + expect((card.skills[0] as Record).security).toBeUndefined(); }); it('extras merge at top level', () => { @@ -313,7 +324,7 @@ describe('buildA2AAgentCard (A2A v1.0 wire format)', () => { security: [{ bearer: [] }], securitySchemes: { bearer: { type: 'http', scheme: 'bearer' } }, }); - expect(card.security).toEqual([{ bearer: [] }]); + expect(card.securityRequirements).toEqual([{ schemes: { bearer: { list: [] } } }]); expect(card.securitySchemes).toEqual({ bearer: { type: 'http', scheme: 'bearer' } }); }); }); @@ -352,8 +363,8 @@ describe('A2AAgentCardSignature shape', () => { }); describe('UCP A2A extension helper', () => { - it('exports the canonical UCP A2A extension URI pinned to 2026-04-08', () => { - expect(UCP_A2A_EXTENSION_URI).toBe('https://ucp.dev/2026-04-08/specification/reference'); + it('exports the canonical UCP A2A extension URI pinned to 2026-08-25', () => { + expect(UCP_A2A_EXTENSION_URI).toBe('https://ucp.dev/2026-08-25/specification/reference'); }); it('exports A2A protocol version + default transport', () => { diff --git a/tests/identity/a2a_canonical_type.test.ts b/tests/identity/a2a_canonical_type.test.ts index e049513..dcb5f2e 100644 --- a/tests/identity/a2a_canonical_type.test.ts +++ b/tests/identity/a2a_canonical_type.test.ts @@ -1,24 +1,39 @@ /** - * Compile-time guard: a card produced by `buildA2AAgentCard` MUST be assignable to - * the canonical `AgentCard` type from `@a2a-js/sdk`. If Google ships a new required - * field or renames a key in a future `@a2a-js/sdk` release, the type assignment - * below fails to compile and CI catches the drift. + * Guard: a card produced by `buildA2AAgentCard` MUST survive the canonical A2A codec + * from `@a2a-js/sdk` unchanged. The SDK's `AgentCard.fromJSON` keeps only the fields + * the current A2A specification defines, so a field we emit that the spec renamed or + * removed is dropped on the round-trip and this test fails. * - * Runtime assertions here are minimal — the value of this file is in the TS error - * surface, not in vitest output. - * - * Note on cross-SDK drift: `a2a-sdk` (PyPI) ships an OLDER proto schema (uses - * `supportedInterfaces[]` + `securityRequirements` and lacks top-level - * `url`/`protocolVersion`/`preferredTransport`). We pin to the TypeScript SDK, - * which mirrors the latest spec at https://a2a-protocol.org/latest/. + * It is a runtime check on purpose. The earlier form was a type assignment, and vitest + * does not typecheck while `tsc` covered `src` alone, so it never ran: the card sat a + * whole protocol version behind the SDK it claimed to match. A codec round-trip runs + * wherever the tests run. */ +import { AgentCard as CanonicalAgentCard } from '@a2a-js/sdk'; import { describe, expect, it } from 'vitest'; import { buildA2AAgentCard, ucpA2AExtension } from '../../src/identity/a2a'; -import type { AgentCard as CanonicalAgentCard } from '@a2a-js/sdk'; -describe('A2A canonical type guard (@a2a-js/sdk)', () => { - it('buildA2AAgentCard output is assignable to canonical AgentCard', () => { +// Proto3 JSON omits default values (false, empty strings, empty lists), so a field +// set to its default disappears on the round-trip without being wrong. Strip those on +// both sides before comparing; everything else must come back as it went in. +function withoutDefaults(v: unknown): unknown { + if (Array.isArray(v)) return v.length === 0 ? undefined : v.map(withoutDefaults); + if (v && typeof v === 'object') { + const out: Record = {}; + for (const [k, val] of Object.entries(v)) { + const n = withoutDefaults(val); + if (n !== undefined && n !== false && n !== '') out[k] = n; + } + return out; + } + return v; +} + +const roundTrip = (card: unknown) => CanonicalAgentCard.toJSON(CanonicalAgentCard.fromJSON(card)); + +describe('A2A canonical codec guard (@a2a-js/sdk)', () => { + it('a fully-populated card survives the canonical codec field for field', () => { const card = buildA2AAgentCard({ name: 'Example Merchant', description: 'Buy products via agent payments.', @@ -30,39 +45,27 @@ describe('A2A canonical type guard (@a2a-js/sdk)', () => { name: 'Purchase', description: 'Buy products via agent payments.', tags: ['commerce', 'payment'], + examples: ['buy a wine'], + security: [{ bearer: ['read'] }], }, ], - extensions: [ucpA2AExtension()], + extensions: [ucpA2AExtension({ 'dev.ucp.shopping.checkout': [{ version: '2026-08-25' }] }, { required: true })], documentationUrl: 'https://agents.example.com/docs', iconUrl: 'https://agents.example.com/icon.png', provider: { organization: 'Example Inc', url: 'https://example.com' }, pushNotifications: true, - stateTransitionHistory: false, streaming: true, - supportsAuthenticatedExtendedCard: false, + extendedAgentCard: true, security: [{ bearer: [] }], - securitySchemes: { bearer: { type: 'http', scheme: 'bearer' } }, + securitySchemes: { bearer: { httpAuthSecurityScheme: { scheme: 'bearer' } } }, additionalInterfaces: [{ transport: 'GRPC', url: 'https://agents.example.com/grpc' }], }); - - // If buildA2AAgentCard's output ever drifts from the canonical AgentCard, - // this line fails to compile. - const canonical: CanonicalAgentCard = card; - expect(canonical.name).toBe('Example Merchant'); - expect(canonical.url).toBe('https://agents.example.com'); - expect(canonical.protocolVersion).toBe('1.0'); + expect(withoutDefaults(roundTrip(card))).toEqual(withoutDefaults(card)); }); - it('JSON round-trip parses back into the canonical AgentCard shape', () => { - const card = buildA2AAgentCard({ - name: 'X', - description: 'y', - url: 'https://x.example', - skills: [{ id: 'p', name: 'P', description: 'd', tags: ['t'] }], - }); - const parsed: CanonicalAgentCard = JSON.parse(JSON.stringify(card)); - expect(parsed.name).toBe('X'); - expect(parsed.url).toBe('https://x.example'); - expect(parsed.skills).toHaveLength(1); + it('fails on a field the spec does not define, which is what makes it a guard', () => { + const card = buildA2AAgentCard({ name: 'X', description: 'y', url: 'https://x.example', skills: [{ id: 'p', name: 'P', description: 'd', tags: ['t'] }] }); + const stale = { ...card, url: 'https://x.example', preferredTransport: 'HTTP+JSON', protocolVersion: '1.0' }; + expect(withoutDefaults(roundTrip(stale))).not.toEqual(withoutDefaults(stale)); }); }); diff --git a/tests/identity/ucp-signing.test.ts b/tests/identity/ucp-signing.test.ts index b2df15f..85aa4f7 100644 --- a/tests/identity/ucp-signing.test.ts +++ b/tests/identity/ucp-signing.test.ts @@ -323,11 +323,11 @@ describe('UCP signing — float canonicalization defense', () => { }); describe('UCP signing — additional hardening', () => { - it('signUCPProfile throws when kid is not in profile.signing_keys[]', async () => { + it('signUCPProfile throws when kid is not in profile.keys[]', async () => { const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: 'real' }); const profile = buildUCPProfile({ ...baseInput, signing_keys: [publicJWK] }); await expect(signUCPProfile(profile, { signingKey: privateKey, kid: 'wrong' })) - .rejects.toThrow(/not present in profile.signing_keys/); + .rejects.toThrow(/not present in profile.keys/); }); it('verifyUCPProfile rejects malformed JWKS shape (missing keys array)', async () => { diff --git a/tests/identity/ucp.test.ts b/tests/identity/ucp.test.ts index d39364f..4e0df3a 100644 --- a/tests/identity/ucp.test.ts +++ b/tests/identity/ucp.test.ts @@ -26,10 +26,12 @@ const agentscoreCap = (profile: ReturnType): UCPCapabili }; describe('buildUCPProfile (spec-compliant shape)', () => { - it('emits the spec envelope with `ucp` body + outer `signing_keys`', () => { + it('emits the spec envelope with `ucp` body + outer `keys` (UCP 2026-08-25)', () => { const profile = buildUCPProfile(baseInput); expect(profile.ucp).toBeDefined(); - expect(profile.signing_keys).toEqual(baseInput.signing_keys); + expect(profile.keys).toEqual(baseInput.signing_keys); + expect((profile as Record).signing_keys).toBeUndefined(); + expect(profile.ucp.version).toBe('2026-08-25'); expect(profile.ucp.version).toMatch(/^\d{4}-\d{2}-\d{2}$/); expect(profile.ucp.services).toEqual(baseInput.services); expect(profile.ucp.capabilities).toEqual({}); @@ -170,6 +172,12 @@ describe('buildUCPProfile (spec-compliant shape)', () => { expect(agentscoreCap(profile)?.spec).toBe('https://custom.example/spec'); }); + it('takes `keys` directly, and refuses a profile with no keys at all', () => { + const { signing_keys: k, ...rest } = baseInput; + expect(buildUCPProfile({ ...rest, keys: k }).keys).toEqual(k); + expect(() => buildUCPProfile(rest)).toThrow(/`keys` is required/); + }); + it('emits supported_versions map under ucp body when supplied', () => { const profile = buildUCPProfile({ ...baseInput, @@ -181,7 +189,7 @@ describe('buildUCPProfile (spec-compliant shape)', () => { expect(profile.ucp.supported_versions?.['2026-04-08']).toContain('/2026-04-08'); }); - it.each([['ucp'], ['signing_keys'], ['signature'], ['__proto__'], ['constructor'], ['prototype']])( + it.each([['ucp'], ['keys'], ['signing_keys'], ['signature'], ['__proto__'], ['constructor'], ['prototype']])( 'rejects extras key "%s" as a reserved top-level collision', (k) => { expect(() => buildUCPProfile({ ...baseInput, extras: { [k]: 'attacker' } })).toThrow( diff --git a/tests/seamless-helpers.test.ts b/tests/seamless-helpers.test.ts index 971ad0a..2466a0e 100644 --- a/tests/seamless-helpers.test.ts +++ b/tests/seamless-helpers.test.ts @@ -757,7 +757,7 @@ describe('defaultA2aServices', () => { const services = defaultA2aServices({ agentCardUrl: 'https://x/.well-known/agent-card.json' }); expect(services['dev.ucp.shopping']).toBeDefined(); expect(services['dev.ucp.shopping'][0]).toMatchObject({ - version: '2026-04-08', + version: '2026-08-25', transport: 'a2a', endpoint: 'https://x/.well-known/agent-card.json', }); @@ -1031,6 +1031,31 @@ describe('xPaymentInfoFromCheckout', () => { expect(protocols.some((p) => 'x402' in p)).toBe(true); expect(protocols.some((p) => 'mpp' in p && p.mpp.method === 'solana')).toBe(true); }); + + it('also carries MPP offers, each priced in its rail\'s smallest unit, so an MPP client can read it', async () => { + const { xPaymentInfoFromCheckout } = await import('../src/discovery/openapi'); + const checkout = { + rails: { + tempo: { recipient: RECIPIENT, network: 'tempo-mainnet', token: '0xtokenT' }, + base: { recipient: RECIPIENT, network: 'eip155:8453', token: 'USDC' }, + stripe: { profileId: 'profile_abc' }, + solana: { recipient: 'SoLaNaReCiPiEnT', network: 'solana:5eykt4', token: 'SoLaNaMiNt' }, + }, + }; + const fixed = xPaymentInfoFromCheckout({ checkout, price: { mode: 'fixed', currency: 'USD', amount: '1.25' } })['x-payment-info']; + // x402 has no MPP offer; the token rails are 6-decimal USDC, Stripe is cents. + expect(fixed.offers).toEqual([ + { intent: 'charge', method: 'tempo', amount: '1250000', currency: '0xtokenT' }, + { intent: 'charge', method: 'stripe', amount: '125', currency: 'usd' }, + { intent: 'charge', method: 'solana', amount: '1250000', currency: 'SoLaNaMiNt' }, + ]); + // x402scan still reads its own keys from the same object. + expect(fixed.price).toEqual({ mode: 'fixed', currency: 'USD', amount: '1.25' }); + expect(fixed.protocols.some((p) => 'x402' in p)).toBe(true); + + const dynamic = xPaymentInfoFromCheckout({ checkout, price: { mode: 'dynamic', currency: 'USD', min: '0.01', max: '1.00' } })['x-payment-info']; + expect(dynamic.offers?.every((o) => o.amount === null)).toBe(true); + }); }); // ───────────────────────────────────────────────────────────────────────────── From 21642a07ad075ba892292f99d33e0ed4bf6a44a2 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Sat, 3 Oct 2026 13:23:57 -0700 Subject: [PATCH 2/2] chore: take mppx 0.13.1 and @types/node 26.6.4 --- bun.lock | 16 ++++++++-------- package.json | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/bun.lock b/bun.lock index 0eabcec..8cc85bd 100644 --- a/bun.lock +++ b/bun.lock @@ -14,7 +14,7 @@ "@solana/kit": "^8.4.0", "@solana/mpp": "^0.7.0", "@types/express": "^5.0.6", - "@types/node": "^26.6.1", + "@types/node": "^26.6.4", "@vitest/coverage-v8": "^5.0.3", "@x402/core": "2.28.0", "@x402/evm": "2.28.0", @@ -30,7 +30,7 @@ "jose": "^6.2.12", "knip": "^6.39.0", "lefthook": "^2.1.16", - "mppx": "0.12.0", + "mppx": "0.13.1", "tsup": "^8.5.1", "typescript": "^6.0.3", "typescript-eslint": "^8.71.0", @@ -471,7 +471,7 @@ "@solana/transactions": ["@solana/transactions@8.4.0", "", { "dependencies": { "@solana/addresses": "8.4.0", "@solana/codecs-core": "8.4.0", "@solana/codecs-data-structures": "8.4.0", "@solana/codecs-numbers": "8.4.0", "@solana/codecs-strings": "8.4.0", "@solana/errors": "8.4.0", "@solana/functional": "8.4.0", "@solana/instructions": "8.4.0", "@solana/keys": "8.4.0", "@solana/nominal-types": "8.4.0", "@solana/rpc-types": "8.4.0", "@solana/transaction-messages": "8.4.0" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-+FFNSlr5vVTcGS9c7seH7mNnjZY21uZ3pHxiN9jeduODD7z+caKnGgjmzokZSDYapFUcupniIr736CrJNmbMFw=="], - "@stripe/stripe-js": ["@stripe/stripe-js@9.13.0", "", {}, "sha512-/0c72BUgzzVkVTlsw5uBn8x3waTdVJ/PZGfQ6jY1eu6K7olUPf4d9lgDPA9/0sIdsR8j7o3QIG8fOCO6ItcL7A=="], + "@stripe/stripe-js": ["@stripe/stripe-js@9.17.0", "", {}, "sha512-uidL4OJum5kivsOAQnVfQmYLJ2zdgirtmT0zWgskASZ4TPXxpXQ0MNrzW4NsXB90uEqmPHpSEtciLFbF1QQvNA=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], @@ -495,7 +495,7 @@ "@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="], - "@types/node": ["@types/node@26.6.3", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg=="], + "@types/node": ["@types/node@26.6.4", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg=="], "@types/qs": ["@types/qs@6.15.1", "", {}, "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw=="], @@ -743,7 +743,7 @@ "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], - "eventsource-parser": ["eventsource-parser@3.1.1", "", {}, "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="], + "eventsource-parser": ["eventsource-parser@4.1.1", "", {}, "sha512-zaWNqeSLeyzEAAt2Q6aDBRDRk/VnPmsKqvToyGvv6aahvLv71A4Jw7jG93Hckf11zXurbqgsffvpSgKPoeU1Qw=="], "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], @@ -1027,7 +1027,7 @@ "mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="], - "mppx": ["mppx@0.12.0", "", { "dependencies": { "@stripe/stripe-js": "9.13.0", "eventsource-parser": "3.1.1", "ox": "0.14.45", "structured-headers": "2.0.3", "zod": "^4.4.3" }, "peerDependencies": { "@modelcontextprotocol/sdk": ">=1.25.0", "@x402/core": ">=2.22.0", "@x402/express": ">=2.22.0", "@x402/hono": ">=2.22.0", "@x402/mcp": ">=2.22.0", "@x402/next": ">=2.22.0", "elysia": ">=1", "express": ">=5", "hono": ">=4.12.25", "next": ">=16.2.6", "viem": ">=2.57.1" }, "optionalPeers": ["@modelcontextprotocol/sdk", "@x402/core", "@x402/express", "@x402/hono", "@x402/mcp", "@x402/next", "elysia", "express", "hono", "next"], "bin": { "mppx": "./dist/bin.js" } }, "sha512-BEbDwmxOQUMImYQrkfAicD4qwgKHZkPOw8+NgiqJS5/DyFtyKX/0YK/Oz0y/LwyYGQn3KdOB6WmvwAWfOsR/rw=="], + "mppx": ["mppx@0.13.1", "", { "dependencies": { "@stripe/stripe-js": "9.17.0", "eventsource-parser": "4.1.1", "ox": "0.14.45", "structured-headers": "2.1.0", "zod": "^4.6.5" }, "peerDependencies": { "@modelcontextprotocol/sdk": ">=1.25.0", "@x402/core": ">=2.22.0", "@x402/express": ">=2.22.0", "@x402/hono": ">=2.22.0", "@x402/mcp": ">=2.22.0", "@x402/next": ">=2.22.0", "elysia": ">=1.2.0", "express": ">=5", "hono": ">=4.12.25", "next": ">=16.2.6", "viem": ">=2.57.1" }, "optionalPeers": ["@modelcontextprotocol/sdk", "@x402/core", "@x402/express", "@x402/hono", "@x402/mcp", "@x402/next", "elysia", "express", "hono", "next"], "bin": { "mppx": "./dist/bin.js" } }, "sha512-PKz2U3l74aqPbvhzLcPzntHWGCncC6czeH0k0aN/f8f9V60E5/nQq9msyoG425njU+UU/jhOh4Ug8gVv62rJQA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -1233,7 +1233,7 @@ "strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], - "structured-headers": ["structured-headers@2.0.3", "", {}, "sha512-4g5yxhlDMClRwCcfKfLeS7Z8yAVdOWGDADwm80Poh1iReU2KVKLGBlqwpHWJ2qovq0+ZIf1atAEO1eua2o9Rgg=="], + "structured-headers": ["structured-headers@2.1.0", "", {}, "sha512-ClzykN+XQVaSW5UfpOdEWfDplZ1Th7mISdaFvaC2+GjLTeBhGMJw83D/Rw9jPkHOml4oYd3EXaZhbz3gnE5nBQ=="], "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], @@ -1411,7 +1411,7 @@ "light-my-request/process-warning": ["process-warning@4.0.1", "", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="], - "mppx/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], + "mppx/zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], "negotiator/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], diff --git a/package.json b/package.json index c121ea3..d886a11 100644 --- a/package.json +++ b/package.json @@ -197,7 +197,7 @@ "@solana/kit": "^8.4.0", "@solana/mpp": "^0.7.0", "@types/express": "^5.0.6", - "@types/node": "^26.6.1", + "@types/node": "^26.6.4", "@vitest/coverage-v8": "^5.0.3", "@x402/core": "2.28.0", "@x402/evm": "2.28.0", @@ -213,7 +213,7 @@ "jose": "^6.2.12", "knip": "^6.39.0", "lefthook": "^2.1.16", - "mppx": "0.12.0", + "mppx": "0.13.1", "tsup": "^8.5.1", "typescript": "^6.0.3", "typescript-eslint": "^8.71.0",