diff --git a/CLAUDE.md b/CLAUDE.md index be153ec..5b3fcea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ Every helper is extracted from a real consumer, not speculated. | Subpath | What it is | |---|---| | `@agent-score/commerce/identity/{hono,express,fastify,nextjs,web}` | Trust gate middleware (KYC, age, sanctions, jurisdiction) | -| `@agent-score/commerce/identity/policy` | Framework-agnostic per-product / per-tier compliance policy helpers — `PolicyBlock`, `policyToGateOptions`, `runGateWithEnforcement`, `shippingCountryAllowed`, `shippingStateAllowed` | +| `@agent-score/commerce/identity/policy` | Framework-agnostic per-product / per-tier compliance policy helpers: `PolicyBlock`, `policyToGateOptions`, `runGateWithEnforcement`, `shippingCountryAllowed`, `shippingStateAllowed` | | `@agent-score/commerce/payment` | Networks/USDC/rails registries, paymentauth.org directive builders, `createX402Server` (peer-dep `@x402/core` + `@coinbase/x402` for the Coinbase facilitator), `buildX402AcceptsFor402` (one-call helper for the 402-emit path: builds the requirements via the registered scheme so `extra.name` matches the on-chain USDC contract per network), `createMppxServer` (peer-dep `mppx`), `processX402Settle` (verify+settle in one call), dispatch-by-network, signer extraction, WWW-Authenticate header, Settlement-Overrides header | | `@agent-score/commerce/discovery` | Discovery probe middleware, Bazaar wrapper, `/.well-known/mpp.json` builder, `llms.txt` builder, `skill.md` builder (Claude-Skill-compatible agent-discovery manifest), OpenAPI snippets, `noindexNonDiscoveryPaths` Hono middleware | | `@agent-score/commerce/challenge` | 402-body builders: accepted_methods, identity metadata, how_to_pay, agent_instructions, build402Body, `buildValidationError` (4xx body builder) | @@ -18,7 +18,7 @@ Every helper is extracted from a real consumer, not speculated. ## Architecture -Single TypeScript package, tsup-built CJS + ESM with subpath exports. Per-framework identity adapters expose the same surface — `agentscoreGate`, `captureWallet`, `getAgentScoreData`, `verifyWalletSignerMatch`, `getGateDegradedState`, `getGateQuotaInfo` — with network-aware address normalization (EVM lowercased, Solana base58 preserved verbatim). +Single TypeScript package, tsup-built CJS + ESM with subpath exports. Per-framework identity adapters expose the same surface (`agentscoreGate`, `captureWallet`, `getAgentScoreData`, `verifyWalletSignerMatch`, `getGateDegradedState`, `getGateQuotaInfo`) with network-aware address normalization (EVM lowercased, Solana base58 preserved verbatim). | Directory | Contents | |---|---| @@ -30,13 +30,13 @@ Single TypeScript package, tsup-built CJS + ESM with subpath exports. Per-framew | `src/stripe-multichain/` | Stripe multichain PaymentIntent helpers | | `src/api/` | `AgentScore` re-export from sdk | | `examples/` | Runnable single-file Hono apps for each common scenario | -| `tests/` | Vitest, one file per surface, ~360+ tests | +| `tests/` | Vitest, one file per surface, ~750+ tests | -Peer-dep pattern: payment/x402/mppx/stripe modules `dynamic import` at runtime — vendors install only what they use (`@x402/core`, `@x402/evm`, `@coinbase/x402`, `mppx`, `@solana/mpp`, `@solana/kit`, `stripe`). Missing peer dep throws a guiding error with the install command. x402 in this SDK is EVM-only; Solana SPL payments go through MPP `solana/charge` (`@solana/mpp/server`). +Peer-dep pattern: payment/x402/mppx/stripe modules `dynamic import` at runtime, so vendors install only what they use (`@x402/core`, `@x402/evm`, `@coinbase/x402`, `mppx`, `@solana/mpp`, `@solana/kit`, `stripe`). Missing peer dep throws a guiding error with the install command. x402 in this SDK is EVM-only; Solana SPL payments go through MPP `solana/charge` (`@solana/mpp/server`). ## Examples -`examples/` contains full single-file Hono apps for the most common merchant scenarios — copy-paste templates, not frameworks: +`examples/` contains full single-file Hono apps for the most common merchant scenarios; copy-paste templates, not frameworks: | Example | Scenario | |---|---| @@ -45,28 +45,29 @@ Peer-dep pattern: payment/x402/mppx/stripe modules `dynamic import` at runtime | `multi-rail-merchant.ts` | Full agent-commerce: identity + Tempo MPP + x402 + Stripe SPT | | `stripe-multichain-merchant.ts` | Stripe-anchored multichain (PaymentIntent → tempo/base/solana deposit addresses) | | `variable-cost-merchant.ts` | Pay-per-actual-usage on **two protocols**: x402 upto (Permit2 + Settlement-Overrides) AND MPP tempo session (channel + SSE + mid-stream vouchers) | -| `compliance-merchant.ts` | Regulated-goods merchant — full compliance gate + custom `onDenied` composing the denial helpers (`verificationAgentInstructions`, `isFixableDenial`, `buildSignerMismatchBody`, `buildContactSupportNextSteps`, `denialReasonToBody`/`denialReasonStatus`) | -| `per-product-policy-merchant.ts` | Multi-product merchant where each product carries its own compliance policy — wine has hard gate (KYC + 21 + state allowlist), tee has none (anonymous), limited print uses `enforcement: 'soft'` (request KYC, accept anonymous, stamp `identity_status: 'unverified'`). Demonstrates `PolicyBlock`, `policyToGateOptions`, `runGateWithEnforcement`, `shippingCountryAllowed`, `shippingStateAllowed`. | +| `compliance-merchant.ts` | Regulated-goods merchant: full compliance gate + custom `onDenied` composing the denial helpers (`verificationAgentInstructions`, `isFixableDenial`, `buildSignerMismatchBody`, `buildContactSupportNextSteps`, `denialReasonToBody`/`denialReasonStatus`) | +| `per-product-policy-merchant.ts` | Multi-product merchant where each product carries its own compliance policy: wine has hard gate (KYC + 21 + state allowlist), tee has none (anonymous), limited print uses `enforcement: 'soft'` (request KYC, accept anonymous, stamp `identity_status: 'unverified'`). Demonstrates `PolicyBlock`, `policyToGateOptions`, `runGateWithEnforcement`, `shippingCountryAllowed`, `shippingStateAllowed`. | +| `signed-ucp-merchant.ts` | Signed UCP profile (`/.well-known/ucp`) + JWKS endpoint (`/.well-known/jwks.json`). AgentScore's `agentscore-profile+jws` is a vendor extension on top of UCP for trust-mode verifiers (Visa AP2 pilots, regulated-commerce verifiers) that opt into auditable cryptographic provenance — UCP §6 itself does NOT mandate signing; Pura Vida and other Shopify-backed UCP merchants ship unsigned in production. Wires ephemeral-for-dev / env-JWK-for-prod signing, kid rotation, and `Cache-Control` posture. Uses `generateUCPSigningKey`, `signUCPProfile`, `buildJWKSResponse`, `ucpSigningKeyFromJWK`, `UCPVerificationError`. | ## Identity model -Two identity types: wallet (`X-Wallet-Address`) and operator-token (`X-Operator-Token`). Default checks operator-token first, then wallet. Address normalization is network-aware via `src/identity/address.ts`: EVM lowercased, Solana base58 preserved verbatim — used for cache keys, wallet→operator resolves, and signer-match comparisons. +Two identity types: wallet (`X-Wallet-Address`) and operator-token (`X-Operator-Token`). Default checks operator-token first, then wallet. Address normalization is network-aware via `src/identity/address.ts`: EVM lowercased, Solana base58 preserved verbatim. Used for cache keys, wallet→operator resolves, and signer-match comparisons. Denial reason codes: `missing_identity`, `identity_verification_required`, `token_expired`, `invalid_credential`, `wallet_signer_mismatch`, `wallet_auth_requires_wallet_signing`, `wallet_not_trusted`, `api_error`, `payment_required`. Each carries a structured `agent_instructions` JSON block describing concrete recovery actions. See `src/identity/_response.ts` and `src/core.ts` for the canned action copy. `createSessionOnMissing` auto-mints a verification session when no identity is present and returns 403 with `verify_url` + poll instructions instead of a bare denial. `verifyWalletSignerMatch` (per-adapter) recovers the signer from MPP/x402 credentials and compares against `linked_wallets[]` for cross-chain wallet-stack matching. -Captured wallets: `captureWallet(ctx, { walletAddress, network, idempotencyKey })` is fire-and-forget — reads `operator_token` stashed during gating and POSTs to `/v1/credentials/wallets`. No-ops for wallet-authenticated requests. +Captured wallets: `captureWallet(ctx, { walletAddress, network, idempotencyKey })` is fire-and-forget; reads `operator_token` stashed during gating and POSTs to `/v1/credentials/wallets`. No-ops for wallet-authenticated requests. -Wallet-signer-match: `verifyWalletSignerMatch(ctx, { signer, network })` makes a single `/v1/assess` call with `resolve_signer` set; the API resolves both wallets and emits a `signer_match` verdict in the same response — collapses the legacy 2 follow-up assess calls into one round trip. Repeat lookups for the same `(claimed, signer)` pair hit a per-cache-entry `signerMatchBySigner` sub-map and skip the API entirely. Falls back to a 2-resolve path when the API doesn't emit `signer_match` (canary rollout safety). Signer recovery covers x402 EIP-3009 (EVM `from` address), Tempo MPP (`did:pkh:eip155` source), and Solana MPP `solana/charge` (via `did:pkh:solana` source when set, otherwise by decoding the credential's signed-tx payload to read the SPL `TransferChecked` authority — pull mode only, requires the `@solana/kit` optional peer). +Wallet-signer-match: `verifyWalletSignerMatch(ctx, { signer, network })` makes a single `/v1/assess` call with `resolve_signer` set; the API resolves both wallets and emits a `signer_match` verdict in the same response, collapsing the legacy 2 follow-up assess calls into one round trip. Repeat lookups for the same `(claimed, signer)` pair hit a per-cache-entry `signerMatchBySigner` sub-map and skip the API entirely. Falls back to a 2-resolve path when the API doesn't emit `signer_match` (canary rollout safety). Signer recovery covers x402 EIP-3009 (EVM `from` address), Tempo MPP (`did:pkh:eip155` source), and Solana MPP `solana/charge` (via `did:pkh:solana` source when set, otherwise by decoding the credential's signed-tx payload to read the SPL `TransferChecked` authority; pull mode only, requires the `@solana/kit` optional peer). ### Fail-open (opt-in) -`failOpen: true` on `agentscoreGate({...})` flips infra-failure handling: 429 / 5xx / network-timeout return `{ kind: 'allow', degraded: true, infraReason: 'quota_exceeded' | 'api_error' | 'network_timeout' }` instead of throwing. Per-adapter `getGateDegradedState(c)` exposes the flag for merchant logging/alerting; `withAgentScoreGate` (Next.js / Web Fetch) propagates `degraded` + `infraReason` directly on the handler's `gate` arg. Default stays `failOpen: false` — regulated commerce should keep it. Compliance denials (sanctions, age, jurisdiction, signer-mismatch) still deny regardless of the flag. +`failOpen: true` on `agentscoreGate({...})` flips infra-failure handling: 429 / 5xx / network-timeout return `{ kind: 'allow', degraded: true, infraReason: 'quota_exceeded' | 'api_error' | 'network_timeout' }` instead of throwing. Per-adapter `getGateDegradedState(c)` exposes the flag for merchant logging/alerting; `withAgentScoreGate` (Next.js / Web Fetch) propagates `degraded` + `infraReason` directly on the handler's `gate` arg. Default stays `failOpen: false`; regulated commerce should keep it. Compliance denials (sanctions, age, jurisdiction, signer-mismatch) still deny regardless of the flag. ### Mount posture: gate-first vs gate-conditional -`agentscoreGate(...)` returns a vanilla framework middleware. Mount it directly when the route is AgentScore-only (`app.use('/purchase', gate)` in Hono / Express, `dependencies=[Depends(gate)]` in FastAPI, etc.) — every request runs identity + policy. To support **anonymous discovery by any spec-compliant x402 wallet** (Coinbase awal, Phantom, Solflare, …), wrap the gate so it fires only when a payment credential is attached: +`agentscoreGate(...)` returns a vanilla framework middleware. Mount it directly when the route is AgentScore-only (`app.use('/purchase', gate)` in Hono / Express, `dependencies=[Depends(gate)]` in FastAPI, etc.); every request runs identity + policy. To support **anonymous discovery by any spec-compliant x402 wallet** (Coinbase awal, Phantom, Solflare, ...), wrap the gate so it fires only when a payment credential is attached: ```ts const _gate = agentscoreGate({ /* opts */ }); @@ -85,15 +86,15 @@ Anonymous POST flows through to the handler unauthenticated and gets a 402 with ### `compatible_clients` field on emitted 402s -`buildAgentInstructions` emits a `compatible_clients` field in the 402 body, derived automatically from `howToPay` — per-rail list of CLIs the AgentScore team has smoke-verified end-to-end. Vendors override with `buildAgentInstructions({ howToPay, compatibleClients: {...} })` to add their own tested clients. Set to an empty object `{}` to suppress the default. Same data is published as `core/docs/integrations/x402-clients.mdx` for human-side rationale + per-rail commands. +`buildAgentInstructions` emits a `compatible_clients` field in the 402 body, derived automatically from `howToPay`: per-rail list of CLIs the AgentScore team has smoke-verified end-to-end. Vendors override with `buildAgentInstructions({ howToPay, compatibleClients: {...} })` to add their own tested clients. Set to an empty object `{}` to suppress the default. Same data is published as `core/docs/integrations/x402-clients.mdx` for human-side rationale + per-rail commands. ## Tooling -- **Bun** — package manager. -- **ESLint 9** — linting. -- **tsup** — CJS + ESM build with subpath exports. -- **Vitest** — tests. -- **Lefthook** — pre-commit lint, pre-push typecheck. +- **Bun**: package manager. +- **ESLint 9**: linting. +- **tsup**: CJS + ESM build with subpath exports. +- **Vitest**: tests. +- **Lefthook**: pre-commit lint, pre-push typecheck. ```bash bun install @@ -112,16 +113,16 @@ During local development the sdk dep is `link:@agent-score/sdk`. Run `bun link` 1. Create a branch 2. Make changes 3. Lefthook runs lint on commit, typecheck on push -4. Open a PR — CI runs automatically +4. Open a PR; CI runs automatically 5. Merge (squash) ## Rules - **No silent refactors** - **Never commit .env files or secrets** -- **Use PRs** — never push directly to main -- **Helpers are protocol translations + configurable opinions, not opinionated frameworks** — vendor variation is config, not API redesign -- **Extract from real consumers** — every helper lifts from working production code +- **Use PRs**: never push directly to main +- **Helpers are protocol translations + configurable opinions, not opinionated frameworks**: vendor variation is config, not API redesign +- **Extract from real consumers**: every helper lifts from working production code ## Releasing diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b59db24..d0fa70c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,7 @@ Thanks for your interest in contributing! Here's how to get started. - All PRs require 1 approval before merging - Squash merge to `main` is the standard -- Keep PRs focused — one feature or fix per PR +- Keep PRs focused; one feature or fix per PR - Include tests for new functionality - Make sure CI passes before requesting review diff --git a/README.md b/README.md index 8e7ff9f..23d5377 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![npm version](https://img.shields.io/npm/v/@agent-score/commerce.svg)](https://www.npmjs.com/package/@agent-score/commerce) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -The full merchant-side SDK for [AgentScore](https://agentscore.sh) — agent commerce in one install. Ships identity gating, payment rail helpers, 402 challenge builders, MPP discovery, and Stripe multichain support. Built and maintained by AgentScore; works with any 402/MPP merchant in the ecosystem, AgentScore-gated or not. +The full merchant-side SDK for [AgentScore](https://agentscore.sh): agent commerce in one install. Ships identity gating, payment rail helpers, 402 challenge builders, MPP discovery, and Stripe multichain support. Built and maintained by AgentScore; works with any 402/MPP merchant in the ecosystem, AgentScore-gated or not. ## Install @@ -13,7 +13,7 @@ npm install @agent-score/commerce bun add @agent-score/commerce ``` -Framework + protocol packages are optional peer deps — install only what you use: +Framework + protocol packages are optional peer deps; install only what you use: ```bash npm install hono mppx @x402/core @x402/evm @solana/mpp @solana/kit stripe # whatever your stack needs @@ -24,11 +24,11 @@ npm install hono mppx @x402/core @x402/evm @solana/mpp @solana/kit stripe # wh | Subpath | What it provides | |---|---| | `/identity/{hono,express,fastify,nextjs,web}` | Trust gate middleware: KYC, sanctions, age, jurisdiction. `agentscoreGate(...)`, `getAgentScoreData(c)`, `captureWallet(...)`, `verifyWalletSignerMatch(...)`. Plus shared denial helpers: `denialReasonStatus`, `denialReasonToBody`, `buildSignerMismatchBody`, `buildContactSupportNextSteps`, `verificationAgentInstructions`, `isFixableDenial`, `FIXABLE_DENIAL_REASONS`. | -| `/payment` | `networks`, `USDC`, `rails` registries; `paymentDirective`, `buildPaymentDirective`, `wwwAuthenticateHeader`, `paymentRequiredHeader`, `aliasAmountFields` (v1↔v2 amount field shim — emits both `amount` and `maxAmountRequired` so v1-only x402 parsers like Coinbase awal can read v2 bodies), `settlementOverrideHeader`, `dispatchSettlementByNetwork`, `extractPaymentSigner` (returns `{address, network}`); `createX402Server`, `createMppxServer`; drop-in x402 helpers: `validateX402NetworkConfig` (boot-time guard), `verifyX402Request` (parse + validate inbound X-Payment), `processX402Settle` (verify-then-settle with one call), `classifyX402SettleResult` (maps the tagged settle result to a recommended HTTP status / code / nextSteps so merchants get a controlled envelope without coupling to facilitator-specific error text). | -| `/discovery` | `isDiscoveryProbeRequest`, `buildDiscoveryProbeResponse` (with optional `x402Sample` for x402-aware crawlers — `awal x402 details` etc.), `sampleX402AcceptForNetwork` (USDC sample-accept builder for known CAIP-2 networks), `buildWellKnownMpp`, `buildLlmsTxt` + `llmsTxtIdentitySection` + `llmsTxtPaymentSection` (compact + verbose modes), `buildSkillMd` (Claude-Skill-compatible `/skill.md` agent-discovery manifest — strictly agent-facing data only, no internal posture), `agentscoreOpenApiSnippets`, `createBazaarDiscovery`, `noindexNonDiscoveryPaths` (Hono middleware that emits `X-Robots-Tag: noindex` on every path except the agent-discovery surfaces — defaults cover `/openapi.json`, `/llms.txt`, `/skill.md`, `/.well-known/{mpp.json,agent-card.json,ucp}`, `/favicon.{png,ico}`; pure helpers `isDiscoveryPath` + `defaultDiscoveryPaths` for non-Hono frameworks). | -| `/challenge` | `build402Body`, `buildAcceptedMethods`, `buildIdentityMetadata`, `buildHowToPay`, `buildAgentInstructions` (auto-emits per-rail `compatible_clients` — smoke-verified CLIs the agent should use; vendor override supported), `buildPricingBlock`, `firstEncounterAgentMemory`, `OrderReceipt`; `respond402` — drop-in 402 emit that preserves mppx's `WWW-Authenticate` and layers x402's `PAYMENT-REQUIRED`. `buildValidationError` — structured 4xx body builder (`{error: {code, message}, required_fields?, example_body?, next_steps?, ...extra}`) so vendors compose body shapes by name instead of inlining at every validation site. | +| `/payment` | `networks`, `USDC`, `rails` registries; `paymentDirective`, `buildPaymentDirective`, `wwwAuthenticateHeader`, `paymentRequiredHeader`, `aliasAmountFields` (v1↔v2 amount field shim: emits both `amount` and `maxAmountRequired` so v1-only x402 parsers like Coinbase awal can read v2 bodies), `settlementOverrideHeader`, `dispatchSettlementByNetwork`, `extractPaymentSigner` (returns `{address, network}`); `createX402Server`, `createMppxServer`; drop-in x402 helpers: `validateX402NetworkConfig` (boot-time guard), `verifyX402Request` (parse + validate inbound X-Payment), `processX402Settle` (verify-then-settle with one call), `classifyX402SettleResult` (maps the tagged settle result to a recommended HTTP status / code / nextSteps so merchants get a controlled envelope without coupling to facilitator-specific error text). | +| `/discovery` | `isDiscoveryProbeRequest`, `buildDiscoveryProbeResponse` (with optional `x402Sample` for x402-aware crawlers, e.g. `awal x402 details`), `sampleX402AcceptForNetwork` (USDC sample-accept builder for known CAIP-2 networks), `buildWellKnownMpp`, `buildLlmsTxt` + `llmsTxtIdentitySection` + `llmsTxtPaymentSection` (compact + verbose modes), `buildSkillMd` (Claude-Skill-compatible `/skill.md` agent-discovery manifest; strictly agent-facing data only, no internal posture), `agentscoreOpenApiSnippets`, `createBazaarDiscovery`, `noindexNonDiscoveryPaths` (Hono middleware that emits `X-Robots-Tag: noindex` on every path except the agent-discovery surfaces; defaults cover `/openapi.json`, `/llms.txt`, `/skill.md`, `/.well-known/{mpp.json,agent-card.json,ucp,jwks.json}`, `/favicon.{png,ico}`; pure helpers `isDiscoveryPath` + `defaultDiscoveryPaths` for non-Hono frameworks). | +| `/challenge` | `build402Body`, `buildAcceptedMethods`, `buildIdentityMetadata`, `buildHowToPay`, `buildAgentInstructions` (auto-emits per-rail `compatible_clients`: smoke-verified CLIs the agent should use; vendor override supported), `buildPricingBlock`, `firstEncounterAgentMemory`, `OrderReceipt`; `respond402`, a drop-in 402 emit that preserves mppx's `WWW-Authenticate` and layers x402's `PAYMENT-REQUIRED`. `buildValidationError`: structured 4xx body builder (`{error: {code, message}, required_fields?, example_body?, next_steps?, ...extra}`) so vendors compose body shapes by name instead of inlining at every validation site. | | `/stripe-multichain` | `createMultichainPaymentIntent`, `getDepositAddress`, `simulateCryptoDeposit`, `createMppxStripe`; `createPiCache` (TTL'd PI / deposit-address cache, Redis-backed when `redisUrl` set, in-memory otherwise), `simulateDepositIfTestMode` (gates on `sk_test_` and looks up the PI for you), `STRIPE_TEST_TX_HASH_SUCCESS` / `STRIPE_TEST_TX_HASH_FAILED` constants. Peer dep on `stripe`. | -| `/api` | Everything from `@agent-score/sdk` re-exported in one place: `AgentScore` + `AgentScoreError`, `AGENTSCORE_TEST_ADDRESSES` + `isAgentScoreTestAddress`. **Don't add `@agent-score/sdk` as a separate dep** — the two can drift versions and cause subtle type mismatches. | +| `/api` | Everything from `@agent-score/sdk` re-exported in one place: `AgentScore` + `AgentScoreError`, `AGENTSCORE_TEST_ADDRESSES` + `isAgentScoreTestAddress`. **Don't add `@agent-score/sdk` as a separate dep**; the two can drift versions and cause subtle type mismatches. | ## Quick start @@ -53,7 +53,7 @@ const _gate = agentscoreGate({ createSessionOnMissing: { apiKey: process.env.AGENTSCORE_API_KEY!, context: "wine-purchase" }, }); -// Run the gate CONDITIONALLY — only when a payment credential is already attached. +// Run the gate CONDITIONALLY: only when a payment credential is already attached. // Anonymous discovery (no payment header) flows through to the handler so any spec- // compliant x402 wallet can read the 402 challenge with rails + pricing without first // proving identity. Identity is verified at settle time on the retry leg. @@ -95,10 +95,10 @@ const directives = [ ]; const wwwAuth = wwwAuthenticateHeader(directives); -// Recover the on-chain signer from the inbound credential — returns {address, network}. +// Recover the on-chain signer from the inbound credential; returns {address, network}. // Covers x402 EIP-3009 (EVM `from` address), Tempo MPP (`did:pkh:eip155` source), // and Solana MPP `solana/charge` (via `did:pkh:solana` source when set, else by -// decoding the credential's signed-tx payload — `@solana/kit` optional peer). +// decoding the credential's signed-tx payload; `@solana/kit` optional peer). const signer = await extractPaymentSigner(req, req.headers.get("x-payment") ?? undefined); ``` @@ -117,7 +117,7 @@ const mppx = await createMppxServer({ tempo: { recipient: process.env.TEMPO_RECIPIENT! }, solana: { recipient: process.env.SOLANA_RECIPIENT!, - // Optional fee sponsor — pass any `TransactionPartialSigner` from `@solana/kit`. + // Optional fee sponsor: pass any `TransactionPartialSigner` from `@solana/kit`. // signer: solanaFeePayerSigner, }, stripe: { profileId: process.env.STRIPE_PROFILE_ID!, secretKey: process.env.STRIPE_SECRET_KEY! }, @@ -170,7 +170,7 @@ const responseBody = build402Body({ ```typescript import { buildIdempotencyKey, buildPaymentHeaders } from "@agent-score/commerce/payment"; -// Stable per-payment key — Stripe PI id wins, falls back to pi-{orderId}-{amountCents}. +// Stable per-payment key: Stripe PI id wins, falls back to pi-{orderId}-{amountCents}. const idempotencyKey = buildIdempotencyKey({ paymentIntentId, orderId, amountCents }); // One-call WWW-Authenticate + PAYMENT-REQUIRED bundle from a single rails declaration. @@ -190,16 +190,63 @@ return new Response(JSON.stringify(responseBody), { status: 402, headers }); ### Identity publishing (cross-vendor standards) ```typescript -import { buildA2AAgentCard, buildUCPProfile } from "@agent-score/commerce"; +import { buildA2AAgentCard, buildUCPProfile, ucpA2AExtension } from "@agent-score/commerce"; + +// Google A2A v1.0 Signed Agent Card; publish at /.well-known/agent-card.json. +// Per UCP §A2A binding, the card MUST declare the canonical UCP extension URI; +// pass `ucpA2AExtension()` with empty capabilities until you bind formal UCP +// capabilities (dev.ucp.shopping.checkout, etc.). +const card = buildA2AAgentCard({ name, url, capabilities, extensions: [ucpA2AExtension()], data: assess }); + +// Google Universal Commerce Protocol; publish at /.well-known/ucp +// Output shape: { ucp: { version, services, capabilities, payment_handlers, +// name?, supported_versions? }, signing_keys: [...], signature?: "..." } +// — services / capabilities / payment_handlers are MAPS keyed by reverse-DNS +// service / capability / handler name. Verified against the live Pura Vida +// reference at puravidabracelets.com/.well-known/ucp. +const profile = buildUCPProfile({ + name, + services: { + 'dev.ucp.shopping': [ + { version: '2026-04-08', spec: 'https://ucp.dev/2026-04-08/specification/overview', + transport: 'mcp', endpoint: 'https://merchant.example/api/ucp/mcp', + schema: 'https://ucp.dev/services/shopping/openrpc.json' }, + ], + }, + payment_handlers: { + 'sh.agentscore.payment.tempo': [{ + id: 'tempo', version: '2026-04-08', + spec: 'https://agentscore.sh/specification/payment-handlers/tempo', + schema: 'https://agentscore.sh/schemas/payment-handlers/tempo.json', + config: { recipient: TEMPO_ADDR }, + }], + }, + signing_keys, data: assess, +}); +``` + +UCP §6 doesn't mandate profile-body JWS signing — Pura Vida and other Shopify-backed UCP merchants ship unsigned. AgentScore's `agentscore-profile+jws` is a vendor extension for trust-mode verifiers (Visa AP2 pilots, regulated-commerce verifiers) that opt into auditable profiles. Sign + verify via the optional `jose` peer dep (tested against jose v5.x; pin `jose@^5`): -// Google A2A v1.0 Signed Agent Card — publish at /.well-known/agent-card.json -const card = buildA2AAgentCard({ name, url, capabilities, data: assess }); +```typescript +import { buildJWKSResponse, generateUCPSigningKey, signUCPProfile, verifyUCPProfile, UCPVerificationError } from "@agent-score/commerce"; -// Google Universal Commerce Protocol — publish at /.well-known/ucp -const profile = buildUCPProfile({ name, services, payment_handlers, signing_keys, data: assess }); +const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: "merchant-2026-05" }); +const profile = buildUCPProfile({ name, services, payment_handlers, signing_keys: [publicJWK] }); +const signed = await signUCPProfile(profile, { signingKey: privateKey, kid: publicJWK.kid, alg: "EdDSA" }); +const jwks = buildJWKSResponse([publicJWK]); ``` -ACP (Stripe + OpenAI Agentic Commerce Protocol) is a transactional checkout protocol with no identity-publishing surface — ACP merchants integrate via the existing `build402Body` + `buildPaymentHeaders` + Stripe SPT rail. +`verifyUCPProfile` enforces the JWS protected header `typ: "agentscore-profile+jws"` (vendor-namespaced; UCP §6 does not define a profile-as-JWS typ), restricts `alg` to `EdDSA` / `ES256`, requires a `kid`, rejects duplicate kids in the JWKS, and compares the canonical body bytes against the JWS payload to catch swap-after-sign tampering. Failures throw `UCPVerificationError` with a discriminated `code` (`no_signature` / `missing_kid` / `kid_not_found` / `duplicate_kid` / `unsupported_alg` / `wrong_typ` / `signature_invalid` / `body_mismatch` / `malformed_jws` / `malformed_jwks` / `unusable_key` / `unrecognized_critical_header`). `malformed_jwks` covers a JWKS argument that isn't a `{ keys: [...] }` document. `unusable_key` covers a matched JWK whose `use` is not `sig` (e.g. `enc`). `unrecognized_critical_header` covers a JWS whose `crit` header lists an extension the verifier doesn't understand (RFC 7515 §4.1.11). + +`signUCPProfile` rejects profiles containing non-integer `Number` values and integers outside `Number.MAX_SAFE_INTEGER` (cross-language float canonicalization is not stable; values past 2^53 lose precision when JS verifiers reparse the canonical body). Use decimal strings for monetary or fractional fields and for any integer that may exceed the safe range. + +**Persisting the private JWK.** Mint once via `generateUCPSigningKey()`, export with `jose.exportJWK(privateKey)` to get the JSON-serializable form, store in your secret manager (AWS Secrets Manager, GCP Secret Manager, etc.). On each container start, read the secret, `jose.importJWK(jwk, alg)` to re-hydrate. Remote-signer flows (KMS-backed asymmetric keys) require an adapter layer that exposes a `KeyLike` jose can call; `jose` does not natively wrap KMS endpoints. + +**Key rotation.** Mint a new key with a new `kid`, add the public JWK to your JWKS endpoint alongside the old one, then sign new profiles with the new key. Verifiers fetching the JWKS pick up both; any in-flight envelopes signed by the old key still verify until you remove that JWK from the JWKS. Set `Cache-Control: public, max-age=300` on `/.well-known/jwks.json` and wait at least that long after publishing the new key before removing the old JWK. + +**Inline JWK in the profile vs separate JWKS endpoint.** UCP §6 mandates the separate `/.well-known/jwks.json` endpoint as the canonical trust source. The profile's `signing_keys[]` is informational; verifiers MUST resolve the kid against the JWKS (not the embedded copy), to prevent a swap-after-sign attack where a hostile actor replaces the inline key with their own. + +ACP (Stripe + OpenAI Agentic Commerce Protocol) is a transactional checkout protocol with no identity-publishing surface; ACP merchants integrate via the existing `build402Body` + `buildPaymentHeaders` + Stripe SPT rail. ### Stripe multichain (peer dep on `stripe`) @@ -222,8 +269,8 @@ const result = await createMultichainPaymentIntent({ const baseAddress = getDepositAddress(result, "base"); const solanaAddress = getDepositAddress(result, "solana"); -// PI / deposit-address cache. Redis-backed when REDIS_URL is set, in-memory otherwise — -// multi-task deployments need Redis so a deposit lands on whichever task settles it. +// PI / deposit-address cache. Redis-backed when REDIS_URL is set, in-memory otherwise. +// Multi-task deployments need Redis so a deposit lands on whichever task settles it. const piCache = createPiCache({ redisUrl: process.env.REDIS_URL }); for (const addr of Object.values(result.depositAddresses)) { await piCache.cacheAddress(addr); @@ -231,7 +278,7 @@ for (const addr of Object.values(result.depositAddresses)) { } piCache.cacheNetworkAddresses(result.paymentIntentId, result.depositAddresses); -// Testnet helper — gates on sk_test_ and looks up the PI for you. No-op on live keys. +// Testnet helper: gates on sk_test_ and looks up the PI for you. No-op on live keys. await simulateDepositIfTestMode({ getPaymentIntentId: piCache.getPaymentIntentId, depositAddress: baseAddress!, @@ -266,11 +313,11 @@ import { } from "@agent-score/commerce/payment"; import { respond402 } from "@agent-score/commerce/challenge"; -// Boot-time guard — raises if a configured network isn't supported. +// Boot-time guard: raises if a configured network isn't supported. validateX402NetworkConfig({ baseNetwork: X402_BASE }); app.post("/purchase", async (c) => { - // Path A — agent presented an x402 X-Payment header + // Path A: agent presented an x402 X-Payment header if (c.req.header("payment-signature") || c.req.header("x-payment")) { const verified = await verifyX402Request({ request: c.req.raw, @@ -298,7 +345,7 @@ app.post("/purchase", async (c) => { return c.json({ ok: true }, { headers }); } - // Path B — cold call (or Authorization: Payment for mppx). After mppx.compose() returns 402, + // Path B: cold call (or Authorization: Payment for mppx). After mppx.compose() returns 402, // respond402 PRESERVES mppx's WWW-Authenticate and ADDS x402's PAYMENT-REQUIRED. return respond402({ mppxChallenge: mppxResult.challenge as Response, @@ -322,22 +369,22 @@ app.use('/purchase', gate); app.post('/purchase', async (c) => { const { degraded, infraReason } = getGateDegradedState(c); if (degraded) { - // Compliance was NOT enforced this request — log/alert/refund-async/etc. + // Compliance was NOT enforced this request; log/alert/refund-async/etc. console.warn(`[gate] degraded: ${infraReason}`); } // ...rest of handler }); ``` -When `failOpen: true` AND the failure is infra-shape, the gate carries `degraded: true` + `infraReason: 'quota_exceeded' | 'api_error' | 'network_timeout'` so merchants can log/alert without parsing console output. **Compliance denials (sanctions, age, jurisdiction, signer-mismatch) still deny regardless of `failOpen`** — `failOpen` only covers "AgentScore couldn't tell us," never "AgentScore said no." +When `failOpen: true` AND the failure is infra-shape, the gate carries `degraded: true` + `infraReason: 'quota_exceeded' | 'api_error' | 'network_timeout'` so merchants can log/alert without parsing console output. **Compliance denials (sanctions, age, jurisdiction, signer-mismatch) still deny regardless of `failOpen`**; `failOpen` only covers "AgentScore couldn't tell us," never "AgentScore said no." -For regulated commerce (alcohol, age-gated, sanctioned-jurisdiction-relevant) keep the default `failOpen: false` — outage is the correct posture; bypassing compliance on infra failure is a compliance gap. For low-stakes commerce or high-uptime SLAs, opt in and use the `degraded` flag as the audit trail. +For regulated commerce (alcohol, age-gated, sanctioned-jurisdiction-relevant) keep the default `failOpen: false`: outage is the correct posture, and bypassing compliance on infra failure is a compliance gap. For low-stakes commerce or high-uptime SLAs, opt in and use the `degraded` flag as the audit trail. The `getGateDegradedState` helper is exported by every framework adapter (Hono, Express, Fastify, Next.js, Web Fetch). For `withAgentScoreGate` (Next.js / Web Fetch), the `degraded` + `infraReason` fields land directly on the `gate` object passed to your handler. ## Examples -The [examples/](./examples) directory has 7 runnable single-file Hono apps covering common merchant scenarios — copy-paste templates, not frameworks. See [examples/README.md](./examples/README.md) for the full table. +The [examples/](./examples) directory has 8 runnable single-file Hono apps covering common merchant scenarios; copy-paste templates, not frameworks. See [examples/README.md](./examples/README.md) for the full table. ## Vendor profile examples @@ -348,11 +395,11 @@ The [examples/](./examples) directory has 7 runnable single-file Hono apps cover | Tempo-only merchant | `/payment` | `npm install @agent-score/commerce mppx` | | Crypto-native, no Stripe | `/identity/*`, `/payment`, `/challenge` | `npm install @agent-score/commerce @x402/core` | -The SDK is genuinely a toolkit — vendors compose only what they need. Helpers don't bundle assumptions about which rails or protocols you support, and don't recommend one rail over another. +The SDK is genuinely a toolkit; vendors compose only what they need. Helpers don't bundle assumptions about which rails or protocols you support, and don't recommend one rail over another. ## Stability -`@agent-score/commerce@1.0.0` ships with the full merchant SDK surface stable. Helpers are protocol translations + configurable opinions — most evolution is additive (new optional params, new helpers, new networks/rails). Major bumps are reserved for genuine protocol-mapping bugs. +The full merchant SDK surface is stable. Helpers are protocol translations + configurable opinions; most evolution is additive (new optional params, new helpers, new networks/rails). Major bumps are reserved for genuine protocol-mapping bugs. ## Documentation diff --git a/bun.lock b/bun.lock index d715358..33bff2c 100644 --- a/bun.lock +++ b/bun.lock @@ -13,7 +13,7 @@ "@solana/kit": "^6.9.0", "@solana/mpp": "^0.5.2", "@types/express": "^5.0.6", - "@types/node": "^25.6.0", + "@types/node": "^25.6.2", "@vitest/coverage-v8": "^4.1.5", "@x402/core": "^2.11.0", "@x402/evm": "^2.11.0", @@ -25,8 +25,9 @@ "express": "^5.2.1", "fastify": "^5.8.5", "hono": "^4.12.18", + "jose": "^5.9.0", "lefthook": "^2.1.6", - "mppx": "^0.6.15", + "mppx": "^0.6.17", "tsup": "^8.5.1", "typescript": "^6.0.3", "typescript-eslint": "^8.59.2", @@ -38,6 +39,7 @@ "express": ">=4.0.0", "fastify": ">=4.0.0", "hono": ">=4.0.0", + "jose": ">=5.9.0", "stripe": ">=17.0.0", }, "optionalPeers": [ @@ -46,12 +48,14 @@ "express", "fastify", "hono", + "jose", "stripe", ], }, }, "overrides": { "axios": "^1.15.0", + "fast-uri": "^3.1.1", }, "packages": { "@adraffy/ens-normalize": ["@adraffy/ens-normalize@1.11.1", "", {}, "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ=="], @@ -406,7 +410,7 @@ "@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="], - "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + "@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="], "@types/qs": ["@types/qs@6.15.0", "", {}, "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow=="], @@ -666,7 +670,7 @@ "fast-querystring": ["fast-querystring@1.1.2", "", { "dependencies": { "fast-decode-uri-component": "^1.0.1" } }, "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg=="], - "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], "fastify": ["fastify@5.8.5", "", { "dependencies": { "@fastify/ajv-compiler": "^4.0.5", "@fastify/error": "^4.0.0", "@fastify/fast-json-stringify-compiler": "^5.0.0", "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", "avvio": "^9.0.0", "fast-json-stringify": "^6.0.0", "find-my-way": "^9.0.0", "light-my-request": "^6.0.0", "pino": "^9.14.0 || ^10.1.0", "process-warning": "^5.0.0", "rfdc": "^1.3.1", "secure-json-parse": "^4.0.0", "semver": "^7.6.0", "toad-cache": "^3.7.0" } }, "sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q=="], @@ -928,7 +932,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.6.15", "", { "dependencies": { "incur": "^0.4.5", "ox": "0.14.20", "zod": "^4.3.6" }, "peerDependencies": { "@modelcontextprotocol/sdk": ">=1.25.0", "elysia": ">=1", "express": ">=5", "hono": ">=4.12.14", "viem": ">=2.47.5" }, "optionalPeers": ["@modelcontextprotocol/sdk", "elysia", "express", "hono"], "bin": { "mppx": "dist/bin.js", "mppx.src": "src/bin.ts" } }, "sha512-5p0XtrkKGW158rPAMGIuS8Cmowk4IJVoWEiHdxism7iV+8YjJdxNJG5etMBeEnh52wPRcrs9f18ENDGbwy9/EA=="], + "mppx": ["mppx@0.6.17", "", { "dependencies": { "incur": "^0.4.5", "ox": "0.14.18", "zod": "^4.3.6" }, "peerDependencies": { "@modelcontextprotocol/sdk": ">=1.25.0", "elysia": ">=1", "express": ">=5", "hono": ">=4.12.18", "viem": ">=2.47.5" }, "optionalPeers": ["@modelcontextprotocol/sdk", "elysia", "express", "hono"], "bin": { "mppx": "./dist/bin.js", "mppx.src": "./src/bin.ts" } }, "sha512-qGATWL6BFE0J1rsZj/pXWapKRKlqgZvZNui/2hGHeifVoftxYcBesHLs0NNplFF5mPfVSAOqbemoz358YOUlPQ=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -970,7 +974,7 @@ "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], - "ox": ["ox@0.14.20", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-rby38C3nDn8eQkf29Zgw4hkCZJ64Qqi0zRPWL8ENUQ7JVuoITqrVtwWQgM/He19SCMUEc7hS/Sjw0jIOSLJhOw=="], + "ox": ["ox@0.14.18", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-1Irk/tvMsw7xJDuCTT/u9azSjz0YX9hrYFgJOacIuFwibaW2zZBXAMrpzegndYb5o8GLpxB6/0qro4/c40q6VQ=="], "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], @@ -1248,6 +1252,16 @@ "@solana/rpc-transport-http/undici-types": ["undici-types@8.2.0", "", {}, "sha512-uciYZ5yCmf+QJb18kJw10HjquzM7K0z992vWcI+84KeBpTfXT4hfgfGJ5DQbf/mCBPACofkrjvqgcjZfuujjFA=="], + "@types/body-parser/@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + + "@types/connect/@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + + "@types/express-serve-static-core/@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + + "@types/send/@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + + "@types/serve-static/@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + "@typescript-eslint/eslint-plugin/@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g=="], "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], @@ -1308,6 +1322,8 @@ "viem/abitype": ["abitype@1.2.3", "", { "peerDependencies": { "typescript": ">=5.0.4", "zod": "^3.22.0 || ^4.0.0" }, "optionalPeers": ["typescript", "zod"] }, "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg=="], + "viem/ox": ["ox@0.14.20", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-rby38C3nDn8eQkf29Zgw4hkCZJ64Qqi0zRPWL8ENUQ7JVuoITqrVtwWQgM/He19SCMUEc7hS/Sjw0jIOSLJhOw=="], + "vitest/tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="], "@coinbase/cdp-sdk/@solana/kit/@solana/accounts": ["@solana/accounts@5.5.1", "", { "dependencies": { "@solana/addresses": "5.5.1", "@solana/codecs-core": "5.5.1", "@solana/codecs-strings": "5.5.1", "@solana/errors": "5.5.1", "@solana/rpc-spec": "5.5.1", "@solana/rpc-types": "5.5.1" }, "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-TfOY9xixg5rizABuLVuZ9XI2x2tmWUC/OoN556xwfDlhBHBjKfszicYYOyD6nbFmwTGYarCmyGIdteXxTXIdhQ=="], @@ -1388,6 +1404,8 @@ "typescript-eslint/@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + "viem/ox/abitype": ["abitype@1.2.4", "", { "peerDependencies": { "typescript": ">=5.0.4", "zod": "^3.22.0 || ^4.0.0" }, "optionalPeers": ["typescript", "zod"] }, "sha512-dpKH+N27vRjarMVTFFkeY445VTKftzGWpL0FiT7xmVmzQRKazZexzC5uHG0f6XKsVLAuUlndnbGau6lRejClxg=="], + "@coinbase/cdp-sdk/@solana/kit/@solana/accounts/@solana/codecs-core": ["@solana/codecs-core@5.5.1", "", { "dependencies": { "@solana/errors": "5.5.1" }, "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-TgBt//bbKBct0t6/MpA8ElaOA3sa8eYVvR7LGslCZ84WiAwwjCY0lW/lOYsFHJQzwREMdUyuEyy5YWBKtdh8Rw=="], "@coinbase/cdp-sdk/@solana/kit/@solana/accounts/@solana/codecs-strings": ["@solana/codecs-strings@5.5.1", "", { "dependencies": { "@solana/codecs-core": "5.5.1", "@solana/codecs-numbers": "5.5.1", "@solana/errors": "5.5.1" }, "peerDependencies": { "fastestsmallesttextencoderdecoder": "^1.0.22", "typescript": "^5.0.0" }, "optionalPeers": ["fastestsmallesttextencoderdecoder", "typescript"] }, "sha512-7klX4AhfHYA+uKKC/nxRGP2MntbYQCR3N6+v7bk1W/rSxYuhNmt+FN8aoThSZtWIKwN6BEyR1167ka8Co1+E7A=="], diff --git a/examples/README.md b/examples/README.md index 0c9b71f..6a8964c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -10,7 +10,8 @@ Runnable, copy-pasteable example integrations covering the most common merchant | [`stripe-multichain-merchant.ts`](./stripe-multichain-merchant.ts) | Stripe-anchored multi-chain | Stripe PaymentIntent with deposit_options for tempo/base/solana; crypto deposits flow through Stripe | | [`variable-cost-merchant.ts`](./variable-cost-merchant.ts) | Pay-per-actual-usage (LLM, transcode, etc.) | Same use case on **two protocols**: x402 upto (Permit2 authorize-max → Settlement-Overrides settle-actual) AND MPP tempo session (channel + SSE + mid-stream vouchers). Vendor offers both so agents pick whichever their wallet supports. | | [`compliance-merchant.ts`](./compliance-merchant.ts) | Regulated-goods merchant (wine, cannabis, etc.) | Full compliance gate (KYC + sanctions + age + jurisdiction) + custom `onDenied` composing commerce helpers: `verificationAgentInstructions`, `isFixableDenial`, `buildContactSupportNextSteps`, `denialReasonToBody`/`denialReasonStatus`, `buildSignerMismatchBody`. Shows how vendors write only the business-specific branches and let commerce handle the rest. | -| [`per-product-policy-merchant.ts`](./per-product-policy-merchant.ts) | Multi-product merchant with mixed compliance needs | One product hard-gates KYC + 21 + US-state allowlist (wine), one is anonymous (merch, ships anywhere), a third uses `enforcement: 'soft'` to request KYC as a fraud signal but accept anonymous sales — stamps `identity_status: 'unverified'` on the order. Uses `PolicyBlock`, `policyToGateOptions`, `runGateWithEnforcement`, `shippingCountryAllowed`, `shippingStateAllowed`. | +| [`per-product-policy-merchant.ts`](./per-product-policy-merchant.ts) | Multi-product merchant with mixed compliance needs | One product hard-gates KYC + 21 + US-state allowlist (wine), one is anonymous (merch, ships anywhere), a third uses `enforcement: 'soft'` to request KYC as a fraud signal but accept anonymous sales (stamps `identity_status: 'unverified'` on the order). Uses `PolicyBlock`, `policyToGateOptions`, `runGateWithEnforcement`, `shippingCountryAllowed`, `shippingStateAllowed`. | +| [`signed-ucp-merchant.ts`](./signed-ucp-merchant.ts) | Signed UCP profile + JWKS endpoint | Wires `/.well-known/ucp` (signed envelope) + `/.well-known/jwks.json` against a persistent signing key. AgentScore's `agentscore-profile+jws` is a vendor extension on top of UCP for trust-mode verifiers (Visa AP2 pilots, regulated-commerce verifiers) that opt into auditable profiles; UCP §6 itself does NOT mandate signing — Pura Vida and other Shopify-backed UCP merchants ship unsigned. This example shows ephemeral-for-dev / env-JWK-for-prod, key rotation, and `Cache-Control` posture on the JWKS endpoint. Uses `generateUCPSigningKey`, `signUCPProfile`, `buildJWKSResponse`, `ucpSigningKeyFromJWK`. | ## How to use @@ -19,11 +20,11 @@ Runnable, copy-pasteable example integrations covering the most common merchant 3. Install peer deps mentioned at the top of the file (only what you actually need) 4. Set the env vars listed at the top of the file 5. Run with `bun run ` or `node` (after build) -6. Iterate — these are templates, not frameworks +6. Iterate; these are templates, not frameworks ## Patterns -All seven examples follow the same rough shape: +All examples follow the same rough shape: 1. **Boot:** instantiate framework, identity gate (if any), x402/mppx servers (if any) via commerce factories 2. **Discovery routes:** `/llms.txt` + `/.well-known/mpp.json` + `/openapi.json` (where applicable) using commerce/discovery helpers @@ -39,6 +40,6 @@ These examples are intentionally thin on domain logic. Vendors plug in their own - Order storage (DB, durable queue, etc.) - Customer email / fulfillment notifications - Tax / shipping calculators -- Frontend UI (none of these examples include one — they're agent-only APIs) +- Frontend UI (none of these examples include one; they're agent-only APIs) AgentScore Commerce handles the agent commerce protocol layer; everything else is your business. diff --git a/examples/signed-ucp-merchant.ts b/examples/signed-ucp-merchant.ts new file mode 100644 index 0000000..90768ee --- /dev/null +++ b/examples/signed-ucp-merchant.ts @@ -0,0 +1,155 @@ +/** + * Signed UCP profile example — `/.well-known/ucp` + `/.well-known/jwks.json`. + * + * AgentScore's `agentscore-profile+jws` is a vendor extension layered on top of + * the UCP profile for trust-mode verifiers (Visa AP2 pilots, regulated-commerce + * verifiers) that opt into auditable cryptographic provenance. UCP §6 itself + * does NOT mandate profile-body signing — Pura Vida and other Shopify-backed + * UCP merchants ship unsigned in production today, and live UCP-aware agents + * (Google AI Mode, Gemini commerce, Microsoft Copilot, Perplexity) accept + * unsigned profiles. This example wires both routes against a persistent + * signing key (env-loaded for prod, ephemeral for dev) for verifiers that DO + * opt into the signed envelope. + * + * Run: `bun examples/signed-ucp-merchant.ts` (port 3010). + * + * Production checklist: + * - Set `UCP_SIGNING_KEY_JWK_PRIVATE` to a JSON-encoded private JWK (mint via + * `generateUCPSigningKey()` once, persist in your secret manager). + * - The kid in the env JWK MUST match what verifiers will see in your published + * profile — pick a stable name like `merchant-2026-05`. + * - Configure `Cache-Control: public, max-age=300` (or longer) on /.well-known/jwks.json + * so verifiers don't hammer the endpoint. + * - Rotate by minting a new key + new kid, publishing both in the JWKS, signing + * new profiles with the new key, then dropping the old JWK after your verifier + * cache TTL expires. + */ + +import { + buildJWKSResponse, + buildUCPProfile, + generateUCPSigningKey, + signUCPProfile, + type GeneratedUCPKey, + ucpSigningKeyFromJWK, + UCPVerificationError, + verifyUCPProfile, +} from '@agent-score/commerce'; +import { Hono } from 'hono'; +import { exportJWK, importJWK, type CryptoKey, type JWK } from 'jose'; + +const KID = process.env.UCP_SIGNING_KEY_KID ?? 'merchant-2026-05'; +const ALG = (process.env.UCP_SIGNING_KEY_ALG ?? 'EdDSA') as 'EdDSA' | 'ES256'; + +let cached: Promise | null = null; + +function loadSigningKey(): Promise { + // Cache the in-flight Promise (not the resolved value) so two concurrent + // first-callers can't independently generate different keys. On rejection + // the cache clears so the next caller retries. + if (cached) return cached; + cached = (async () => { + const envJwk = process.env.UCP_SIGNING_KEY_JWK_PRIVATE; + if (envJwk) { + let jwk: JWK; + try { + jwk = JSON.parse(envJwk) as JWK; + } catch (err) { + throw new Error( + `Failed to parse UCP_SIGNING_KEY_JWK_PRIVATE as JSON: ${err instanceof Error ? err.message : String(err)}`, + ); + } + // Detect alg from JWK shape (parity with python sibling); ignore env ALG if it conflicts. + let effectiveAlg: 'EdDSA' | 'ES256'; + if (jwk.kty === 'OKP' && jwk.crv === 'Ed25519') { + effectiveAlg = 'EdDSA'; + } else if (jwk.kty === 'EC' && jwk.crv === 'P-256') { + effectiveAlg = 'ES256'; + } else { + throw new Error(`Unsupported env JWK: kty=${jwk.kty} crv=${jwk.crv}`); + } + const privateKey = (await importJWK(jwk, effectiveAlg)) as CryptoKey; + const publicJWK = (await exportJWK(privateKey)) as JWK; + publicJWK.kid = jwk.kid ?? KID; + publicJWK.alg = effectiveAlg; + publicJWK.use = 'sig'; + delete (publicJWK as Record).d; + return { privateKey, publicJWK } as GeneratedUCPKey; + } + console.warn('[ucp] UCP_SIGNING_KEY_JWK_PRIVATE not set — generating ephemeral key. Verifier caches will break across restarts.'); + return generateUCPSigningKey({ kid: KID, alg: ALG }); + })().catch((err) => { + cached = null; + throw err; + }); + return cached; +} + +const app = new Hono(); + +app.get('/.well-known/ucp', async (c) => { + const key = await loadSigningKey(); + const profile = buildUCPProfile({ + name: 'My Agent Service', + services: { + 'dev.ucp.shopping': [ + { + version: '2026-04-08', + spec: 'https://ucp.dev/2026-04-08/specification/overview', + transport: 'mcp', + endpoint: 'https://agents.example.com/api/ucp/mcp', + schema: 'https://ucp.dev/services/shopping/openrpc.json', + }, + ], + }, + payment_handlers: { + 'sh.agentscore.payment.tempo': [{ + id: 'tempo', + version: '2026-04-08', + spec: 'https://agentscore.sh/specification/payment-handlers/tempo', + schema: 'https://agentscore.sh/schemas/payment-handlers/tempo.json', + config: { recipient: '0xfeedface' }, + }], + }, + signing_keys: [ucpSigningKeyFromJWK(key.publicJWK as Record)], + }); + const signed = await signUCPProfile(profile, { + signingKey: key.privateKey, + kid: key.publicJWK.kid as string, + alg: ALG, + }); + c.header('Cache-Control', 'public, max-age=60'); + return c.json(signed); +}); + +app.get('/.well-known/jwks.json', async (c) => { + const key = await loadSigningKey(); + c.header('Cache-Control', 'public, max-age=300'); + c.header('Content-Type', 'application/jwk-set+json'); + return c.json(buildJWKSResponse([key.publicJWK])); +}); + +// Self-smoke: confirm sign + verify round-trip locally. +app.get('/_selftest/ucp', async (c) => { + const profileRes = await app.request('/.well-known/ucp'); + const jwksRes = await app.request('/.well-known/jwks.json'); + const profile = await profileRes.json() as Awaited>; + const jwks = await jwksRes.json() as Parameters[1]; + try { + await verifyUCPProfile(profile, jwks); + return c.json({ ok: true, kid: (profile.signing_keys?.[0] as { kid?: string } | undefined)?.kid }); + } catch (err) { + if (err instanceof UCPVerificationError) { + return c.json({ ok: false, code: err.code, message: err.message }, 500); + } + throw err; + } +}); + +const port = Number(process.env.PORT ?? 3010); +console.warn(`signed-ucp-merchant listening on :${port}`); +console.warn(' /.well-known/ucp — signed profile'); +console.warn(' /.well-known/jwks.json — public key set'); +console.warn(' /_selftest/ucp — local verify round-trip'); + +Bun.serve({ port, fetch: app.fetch }); diff --git a/package.json b/package.json index d724789..62e27ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agent-score/commerce", - "version": "1.3.3", + "version": "1.4.0", "description": "Agent 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 agent commerce.", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -123,7 +123,8 @@ "@agent-score/sdk": "^2.2.0" }, "overrides": { - "axios": "^1.15.0" + "axios": "^1.15.0", + "fast-uri": "^3.1.1" }, "peerDependencies": { "@solana/kit": ">=6.5.0", @@ -131,6 +132,7 @@ "express": ">=4.0.0", "fastify": ">=4.0.0", "hono": ">=4.0.0", + "jose": ">=5.9.0", "stripe": ">=17.0.0" }, "peerDependenciesMeta": { @@ -149,6 +151,9 @@ "hono": { "optional": true }, + "jose": { + "optional": true + }, "stripe": { "optional": true } @@ -159,7 +164,7 @@ "@solana/kit": "^6.9.0", "@solana/mpp": "^0.5.2", "@types/express": "^5.0.6", - "@types/node": "^25.6.0", + "@types/node": "^25.6.2", "@vitest/coverage-v8": "^4.1.5", "@x402/core": "^2.11.0", "@x402/evm": "^2.11.0", @@ -171,8 +176,9 @@ "express": "^5.2.1", "fastify": "^5.8.5", "hono": "^4.12.18", + "jose": "^5.9.0", "lefthook": "^2.1.6", - "mppx": "^0.6.15", + "mppx": "^0.6.17", "tsup": "^8.5.1", "typescript": "^6.0.3", "typescript-eslint": "^8.59.2", diff --git a/scripts/regenerate-cross-lang-fixtures.ts b/scripts/regenerate-cross-lang-fixtures.ts new file mode 100644 index 0000000..4a9ca28 --- /dev/null +++ b/scripts/regenerate-cross-lang-fixtures.ts @@ -0,0 +1,402 @@ +/** + * Regenerate the full cross-lang fixture corpus (Node side). + * + * Writes all `node-*.json` fixtures under `tests/fixtures/cross-lang/`. Used after + * a canonicalization-relevant change (typ rename, capability-name rename, schema-URL + * rename, key-sort tweak, profile-shape change) where every JWS in the corpus needs + * to be re-signed. + * + * Each scenario hand-crafts the profile body using the spec-compliant input shape + * (`services` / `capabilities` / `payment_handlers` as MAPS keyed by reverse-DNS + * service / capability / handler name), signs with a fresh keypair, and writes the + * `{ profile, jwks, alg, kid, generator }` envelope. Cross-lang verify in + * `tests/identity/cross-lang.test.ts` (and the python sibling) pulls these in + * alongside the `py-*` fixtures generated by the python sibling. + * + * Run: `bun run scripts/regenerate-cross-lang-fixtures.ts` + */ + +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + buildUCPProfile, + type UCPCapabilityBinding, + type UCPPaymentHandlerBinding, + type UCPServiceBinding, + type UCPSigningKey, +} from '../src/identity/ucp'; +import { + buildJWKSResponse, + generateUCPSigningKey, + signUCPProfile, + type SignedUCPProfile, +} from '../src/identity/ucp-jwks'; +import type { AgentScoreData } from '../src/core'; + +const OUT_DIR = join(__dirname, '..', 'tests', 'fixtures', 'cross-lang'); + +interface FixtureEnvelope { + profile: SignedUCPProfile; + jwks: { keys: UCPSigningKey[] }; + alg: 'EdDSA' | 'ES256'; + kid: string; + generator: 'node'; +} + +function writeFixture(name: string, env: FixtureEnvelope): void { + const out = join(OUT_DIR, `${name}.json`); + writeFileSync(out, `${JSON.stringify(env, null, 2)}\n`); + console.warn(`wrote ${out}`); +} + +// Spec-compliant binding helpers — each scenario uses these (or variants) so the +// fixtures cover the full set of canonical UCP fields per binding type. +function shopServiceMcp(host: string): UCPServiceBinding { + return { + version: '2026-04-08', + spec: 'https://ucp.dev/2026-04-08/specification/overview', + transport: 'mcp', + endpoint: `${host}/api/ucp/mcp`, + schema: 'https://ucp.dev/services/shopping/openrpc.json', + }; +} + +function shopServiceA2A(host: string): UCPServiceBinding { + return { + version: '2026-04-08', + spec: 'https://ucp.dev/2026-04-08/specification/overview', + transport: 'a2a', + endpoint: `${host}/.well-known/agent-card.json`, + }; +} + +function tempoHandler(config?: Record): UCPPaymentHandlerBinding { + const out: UCPPaymentHandlerBinding = { + id: 'tempo', + version: '2026-04-08', + spec: 'https://agentscore.sh/specification/payment-handlers/tempo', + schema: 'https://agentscore.sh/schemas/payment-handlers/tempo.json', + }; + if (config) out.config = config; + return out; +} + +function x402Handler(networks: string[]): UCPPaymentHandlerBinding { + return { + id: 'x402', + version: '2026-04-08', + spec: 'https://agentscore.sh/specification/payment-handlers/x402', + schema: 'https://agentscore.sh/schemas/payment-handlers/x402.json', + config: { networks }, + }; +} + +function stripeHandler(config: Record): UCPPaymentHandlerBinding { + return { + id: 'stripe', + version: '2026-04-08', + spec: 'https://agentscore.sh/specification/payment-handlers/stripe-spt', + schema: 'https://agentscore.sh/schemas/payment-handlers/stripe-spt.json', + config, + }; +} + +async function main(): Promise { + // ------------------------------------------------------------------------- + // node-minimal — empty maps; just metadata + signing keys + // ------------------------------------------------------------------------- + { + const KID = 'node-minimal-EdDSA'; + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: KID }); + const profile = buildUCPProfile({ + name: 'Minimal Merchant', + services: { 'dev.ucp.shopping': [shopServiceMcp('https://m.example.com')] }, + signing_keys: [publicJWK as UCPSigningKey], + }); + const signed = await signUCPProfile(profile, { signingKey: privateKey, kid: KID }); + writeFixture('node-minimal', { + profile: signed, + jwks: buildJWKSResponse([publicJWK]), + alg: 'EdDSA', + kid: KID, + generator: 'node', + }); + } + + // ------------------------------------------------------------------------- + // node-es256-rails — multi-transport service + multi-rail + ES256 signing key + // ------------------------------------------------------------------------- + { + const KID = 'node-es256-rails-ES256'; + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: KID, alg: 'ES256' }); + const profile = buildUCPProfile({ + name: 'ES256 Merchant', + services: { + 'dev.ucp.shopping': [ + shopServiceMcp('https://a.example.com'), + shopServiceA2A('https://a.example.com'), + ], + }, + payment_handlers: { + 'sh.agentscore.payment.tempo': [tempoHandler({ rail: 'tempo-mainnet', chain_id: 4217 })], + 'sh.agentscore.payment.x402': [x402Handler(['base-8453'])], + }, + signing_keys: [publicJWK as UCPSigningKey], + }); + const signed = await signUCPProfile(profile, { signingKey: privateKey, kid: KID, alg: 'ES256' }); + writeFixture('node-es256-rails', { + profile: signed, + jwks: buildJWKSResponse([publicJWK]), + alg: 'ES256', + kid: KID, + generator: 'node', + }); + } + + // ------------------------------------------------------------------------- + // node-extras-int — payment_handler config with int + string fields + // ------------------------------------------------------------------------- + { + const KID = 'node-extras-int-EdDSA'; + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: KID }); + const profile = buildUCPProfile({ + name: 'Extras Merchant', + services: { 'dev.ucp.shopping': [shopServiceMcp('https://e.example.com')] }, + payment_handlers: { + 'sh.agentscore.payment.stripe-spt': [stripeHandler({ profile_id: 'abc', count: 7 })], + }, + signing_keys: [publicJWK as UCPSigningKey], + }); + const signed = await signUCPProfile(profile, { signingKey: privateKey, kid: KID }); + writeFixture('node-extras-int', { + profile: signed, + jwks: buildJWKSResponse([publicJWK]), + alg: 'EdDSA', + kid: KID, + generator: 'node', + }); + } + + // ------------------------------------------------------------------------- + // node-capability — hand-crafted vendor capability under sh.agentscore.identity + // ------------------------------------------------------------------------- + { + const KID = 'node-capability-EdDSA'; + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: KID }); + const customCapability: UCPCapabilityBinding = { + version: '1', + spec: 'https://agentscore.sh/specification/identity', + schema: 'https://agentscore.sh/schemas/ucp/sh-agentscore-identity-v1.json', + // `extras` flat on the binding — kyc_required is a vendor field on this binding. + kyc_required: true, + }; + const profile = buildUCPProfile({ + name: 'Capability Merchant', + services: { 'dev.ucp.shopping': [shopServiceMcp('https://c.example.com')] }, + capabilities: { 'sh.agentscore.identity': [customCapability] }, + payment_handlers: { + 'sh.agentscore.payment.tempo': [tempoHandler({ rail: 'tempo-mainnet', chain_id: 4217 })], + }, + signing_keys: [publicJWK as UCPSigningKey], + }); + const signed = await signUCPProfile(profile, { signingKey: privateKey, kid: KID }); + writeFixture('node-capability', { + profile: signed, + jwks: buildJWKSResponse([publicJWK]), + alg: 'EdDSA', + kid: KID, + generator: 'node', + }); + } + + // ------------------------------------------------------------------------- + // node-unicode — multi-byte UTF-8 in name / endpoint / config + // ------------------------------------------------------------------------- + { + const KID = 'node-unicode-EdDSA'; + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: KID }); + const profile = buildUCPProfile({ + name: 'Café 日本 🍷 Merchant', + services: { 'dev.ucp.shopping': [shopServiceMcp('https://日本.example.com')] }, + payment_handlers: { + 'sh.agentscore.payment.tempo': [tempoHandler({ note: 'メモ' })], + }, + signing_keys: [publicJWK as UCPSigningKey], + }); + const signed = await signUCPProfile(profile, { signingKey: privateKey, kid: KID }); + writeFixture('node-unicode', { + profile: signed, + jwks: buildJWKSResponse([publicJWK]), + alg: 'EdDSA', + kid: KID, + generator: 'node', + }); + } + + // ------------------------------------------------------------------------- + // node-multikey — JWKS with two keys, signed by the newer one + // ------------------------------------------------------------------------- + { + const oldKey = await generateUCPSigningKey({ kid: 'node-multikey-old' }); + const newKey = await generateUCPSigningKey({ kid: 'node-multikey-new' }); + const profile = buildUCPProfile({ + name: 'Multi-Key Merchant', + services: { 'dev.ucp.shopping': [shopServiceMcp('https://mk.example.com')] }, + payment_handlers: { + 'sh.agentscore.payment.tempo': [tempoHandler({ rail: 'tempo-mainnet' })], + }, + signing_keys: [oldKey.publicJWK as UCPSigningKey, newKey.publicJWK as UCPSigningKey], + }); + const signed = await signUCPProfile(profile, { signingKey: newKey.privateKey, kid: 'node-multikey-new' }); + writeFixture('node-multikey', { + profile: signed, + jwks: buildJWKSResponse([oldKey.publicJWK, newKey.publicJWK]), + alg: 'EdDSA', + kid: 'node-multikey-new', + generator: 'node', + }); + } + + // ------------------------------------------------------------------------- + // node-emoji-keys — extras with non-ASCII object keys (BMP private use, + // CJK compatibility, supplementary plane). Exercises codepoint-vs-UTF-16 sort. + // Lives at top-level `extras` (outside the `ucp` envelope). + // ------------------------------------------------------------------------- + { + const KID = 'node-emoji-keys-EdDSA'; + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: KID }); + const profile = buildUCPProfile({ + name: 'Emoji Keys Merchant', + services: { 'dev.ucp.shopping': [shopServiceMcp('https://emoji.example.com')] }, + payment_handlers: { + 'sh.agentscore.payment.tempo': [tempoHandler()], + }, + signing_keys: [publicJWK as UCPSigningKey], + extras: { + a: 1, + '豈': 2, + '': 3, + '🍷': 4, + }, + }); + const signed = await signUCPProfile(profile, { signingKey: privateKey, kid: KID }); + writeFixture('node-emoji-keys', { + profile: signed, + jwks: buildJWKSResponse([publicJWK]), + alg: 'EdDSA', + kid: KID, + generator: 'node', + }); + } + + // ------------------------------------------------------------------------- + // node-int-boundary — exercises Number.MAX_SAFE_INTEGER round-trip via extras + // ------------------------------------------------------------------------- + { + const KID = 'node-int-boundary-EdDSA'; + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: KID }); + const profile = buildUCPProfile({ + name: 'Int Boundary Merchant', + services: { 'dev.ucp.shopping': [shopServiceMcp('https://i.example.com')] }, + signing_keys: [publicJWK as UCPSigningKey], + extras: { + max_safe_int: 9007199254740991, + min_safe_int: -9007199254740991, + small_int: 42, + neg_small_int: -42, + zero: 0, + }, + }); + const signed = await signUCPProfile(profile, { signingKey: privateKey, kid: KID }); + writeFixture('node-int-boundary', { + profile: signed, + jwks: buildJWKSResponse([publicJWK]), + alg: 'EdDSA', + kid: KID, + generator: 'node', + }); + } + + // ------------------------------------------------------------------------- + // node-data-driven-claims — exercises buildUCPProfile data path with + // API-shape "missing" sentinels (empty string + null). Both languages MUST + // emit identical canonical bytes for this input. + // ------------------------------------------------------------------------- + { + const KID = 'node-data-driven-claims-EdDSA'; + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: KID }); + const data: AgentScoreData = { + decision: 'allow', + decision_reasons: [], + resolved_operator: 'op_data_driven', + verify_url: 'https://agentscore.sh/verify/op_data_driven', + account_verification: { + kyc_level: '', + sanctions_clear: false, + age_bracket: null as unknown as string, + jurisdiction: null as unknown as string, + verified_at: null, + }, + }; + const profile = buildUCPProfile({ + name: 'Data Driven Claims Merchant', + services: { 'dev.ucp.shopping': [shopServiceMcp('https://d.example.com')] }, + signing_keys: [publicJWK as UCPSigningKey], + data, + }); + const signed = await signUCPProfile(profile, { signingKey: privateKey, kid: KID }); + writeFixture('node-data-driven-claims', { + profile: signed, + jwks: buildJWKSResponse([publicJWK]), + alg: 'EdDSA', + kid: KID, + generator: 'node', + }); + } + + // ------------------------------------------------------------------------- + // node-typed-claims — exercises typed AssessResult fields (no raw fallback). + // Cross-lang parity check for the typed-field-only call site. + // ------------------------------------------------------------------------- + { + const KID = 'node-typed-claims-EdDSA'; + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: KID }); + const data: AgentScoreData = { + decision: 'allow', + decision_reasons: [], + resolved_operator: 'op_typed_claims', + verify_url: 'https://agentscore.sh/verify/op_typed_claims', + operator_verification: { + level: 'enhanced', + operator_type: 'api', + verified_at: '2026-04-01T00:00:00Z', + }, + account_verification: { + kyc_level: 'enhanced', + sanctions_clear: true, + age_bracket: '21+', + jurisdiction: 'US', + verified_at: '2026-04-01T00:00:00Z', + }, + }; + const profile = buildUCPProfile({ + name: 'Typed Claims Merchant', + services: { 'dev.ucp.shopping': [shopServiceMcp('https://t.example.com')] }, + signing_keys: [publicJWK as UCPSigningKey], + data, + }); + const signed = await signUCPProfile(profile, { signingKey: privateKey, kid: KID }); + writeFixture('node-typed-claims', { + profile: signed, + jwks: buildJWKSResponse([publicJWK]), + alg: 'EdDSA', + kid: KID, + generator: 'node', + }); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/discovery/robots_tag.ts b/src/discovery/robots_tag.ts index 02045ed..e93724a 100644 --- a/src/discovery/robots_tag.ts +++ b/src/discovery/robots_tag.ts @@ -18,6 +18,7 @@ export const defaultDiscoveryPaths: ReadonlySet = new Set([ '/.well-known/x402', '/.well-known/agent-card.json', '/.well-known/ucp', + '/.well-known/jwks.json', '/favicon.png', '/favicon.ico', ]); diff --git a/src/identity/a2a.ts b/src/identity/a2a.ts index 9d126e4..4b358f4 100644 --- a/src/identity/a2a.ts +++ b/src/identity/a2a.ts @@ -23,6 +23,38 @@ export interface A2AAgentCardCapabilities { skills?: string[]; } +/** Per A2A v1.0: an entry in the card's top-level `extensions` array. UCP support + * is declared this way (UCP §A2A binding requires `https://ucp.dev/2026-04-08/specification/reference`). */ +export interface A2AAgentCardExtension { + /** Canonical extension URI — for UCP, `https://ucp.dev/2026-04-08/specification/reference`. */ + uri: string; + /** Extension-specific params. UCP places `{ capabilities: { "": [{ version: "..." }, ...] } }` here. */ + params?: Record; +} + +/** Canonical UCP A2A extension URI — verifiers look for this exact URI in `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'; + +/** Build the canonical UCP entry for an A2A agent card's `extensions[]` array. + * + * Per UCP §A2A binding: "Businesses supporting UCP must advertise the extension and + * any optional capabilities in their A2A Agent Card to allow platforms to activate + * the extension." Pass the `capabilities` map keyed by reverse-DNS service/capability + * name (e.g. `dev.ucp.shopping.checkout`), each value a list of `{ version }` records. + * Pass `{}` (or omit) when you serve UCP at the discovery layer but have no formal + * capability bindings yet — vendors that haven't implemented checkout/cart/etc. should + * declare the extension URI without claiming capabilities they don't service. + */ +export function ucpA2AExtension( + capabilities: Record> = {}, +): A2AAgentCardExtension { + return { + uri: UCP_A2A_EXTENSION_URI, + params: { capabilities }, + }; +} + export interface A2AAgentCardIdentity { /** Issuer of the identity claims — always `"https://agentscore.sh"` for the AgentScore-issued card. */ issuer: string; @@ -55,6 +87,8 @@ export interface A2AAgentCard { url?: string; /** Agent capabilities — endpoints + skills. */ capabilities?: A2AAgentCardCapabilities; + /** A2A v1.0 extensions array. Use `ucpA2AExtension()` to add the UCP entry. */ + extensions?: A2AAgentCardExtension[]; /** AgentScore identity claims. Empty `null` when no identity is available (pre-KYC). */ identity: A2AAgentCardIdentity | null; /** Vendor-specific extras merged at the top level. */ @@ -70,6 +104,9 @@ export interface BuildA2AAgentCardInput { url?: string; /** Capabilities — endpoints exposed + skill tags. */ capabilities?: A2AAgentCardCapabilities; + /** A2A v1.0 extensions to declare on the card. Build the UCP entry with + * `ucpA2AExtension()`. Other A2A extensions can be added the same way. */ + extensions?: A2AAgentCardExtension[]; /** AgentScore assess data — what `getAgentScoreData(c)` returns or what `assess()` returned directly. * Pass `null` to emit a card with no identity claims (publishable but unverified). */ data?: AgentScoreData | null; @@ -148,6 +185,7 @@ export function buildA2AAgentCard(input: BuildA2AAgentCardInput): A2AAgentCard { if (input.description !== undefined) card.description = input.description; if (input.url !== undefined) card.url = input.url; if (input.capabilities !== undefined) card.capabilities = input.capabilities; + if (input.extensions && input.extensions.length > 0) card.extensions = input.extensions; if (input.extras !== undefined) card.extras = input.extras; return card; } diff --git a/src/identity/ucp-jwks.ts b/src/identity/ucp-jwks.ts new file mode 100644 index 0000000..86e207a --- /dev/null +++ b/src/identity/ucp-jwks.ts @@ -0,0 +1,529 @@ +/** + * UCP profile signing helpers (JWKS + JWS). + * + * UCP §6 (https://ucp.dev/latest/specification/signatures/) requires that profiles + * published at `/.well-known/ucp` carry a JWKS-backed signature for trust-mode clients + * (Google AI Mode, Gemini commerce, future ChatGPT app shells). Without a signature, + * trust-mode clients reject the profile. + * + * This module provides: + * - `generateUCPSigningKey()` — generate an Ed25519 keypair for signing + * - `signUCPProfile()` — sign a UCP profile body, returning a JWS-attached envelope + * - `verifyUCPProfile()` — verify a signed profile against a JWKS + * - `buildJWKSResponse()` — assemble a JWKS document for `/.well-known/jwks.json` + * + * Implementation rides on `jose` (peer-dep, optional). Merchants who don't sign their + * profile (development) skip this module entirely; the unsigned `buildUCPProfile()` + * path still works. + * + * Why Ed25519: smaller signatures (64 bytes vs 256+ for RSA), faster verification, no + * curve-parameter ceremony. UCP also accepts ES256 (P-256 ECDSA) — pass `alg: 'ES256'` + * to `signUCPProfile()` if your existing payment signing key is P-256. + */ + +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[]`. + */ +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[]`. */ + publicJWK: UCPSigningKey; +} + +/** A JWKS document — `{ keys: [...] }` per RFC 7517. Serve at `/.well-known/jwks.json`. */ +export interface JWKSResponse { + keys: UCPSigningKey[]; +} + +/** Options for `signUCPProfile()`. */ +export interface SignUCPProfileOptions { + /** Private signing key — opaque KeyLike from `generateUCPSigningKey()` or `importJWK()`. */ + signingKey: unknown; + /** Key ID (must match a `kid` in the profile's `signing_keys[]`). */ + kid: string; + /** Signing algorithm — `EdDSA` (default) or `ES256`. */ + alg?: 'EdDSA' | 'ES256'; +} + +/** A signed UCP profile envelope. Same shape as `UCPProfile` plus the `signature` field + * carrying the JWS Compact Serialization over the canonicalized profile body. */ +export interface SignedUCPProfile extends UCPProfile { + /** JWS Compact Serialization (`
..`) over the profile body + * with `signature` removed and keys sorted. Verifiers reconstruct the canonical body + * and validate against the JWK identified by `kid` in the JWS protected header. */ + signature: string; +} + +const JOSE_INSTALL_HINT = 'Install the optional peer dependency: `npm install jose@^5` (or `bun add jose`). Tested against jose v5.x.'; + +/** 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 + * algorithm. */ +const ALLOWED_ALGS = ['EdDSA', 'ES256'] as const; +type AllowedAlg = (typeof ALLOWED_ALGS)[number]; + +/** JWS protected header `typ` value. Vendor-namespaced because UCP §6 does not define + * a profile-as-JWS typ; the value advertises that this signed envelope follows the + * AgentScore extension semantics rather than a UCP-canonical signing convention. + * Verifiers SHOULD enforce this to prevent cross-protocol token reuse (RFC 8725 §3.11). */ +const PROFILE_TYP = 'agentscore-profile+jws'; + +/** Discriminated error class so consumers can branch on failure mode without + * parsing message strings or importing jose internals. */ +export class UCPVerificationError extends Error { + constructor( + public readonly code: + | 'no_signature' + | 'missing_kid' + | 'kid_not_found' + | 'duplicate_kid' + | 'unsupported_alg' + | 'wrong_typ' + | 'signature_invalid' + | 'body_mismatch' + | 'malformed_jws' + | 'malformed_jwks' + | 'unrecognized_critical_header' + | 'unusable_key', + message: string, + ) { + super(message); + this.name = 'UCPVerificationError'; + } +} + +async function loadJose(): Promise { + try { + return await import('jose'); + } catch (err) { + throw new Error( + `UCP signing requires the \`jose\` library, which is an optional peer dependency. ${JOSE_INSTALL_HINT}\nOriginal error: ${err instanceof Error ? err.message : String(err)}`, + ); + } +} + +/** + * Canonicalize a UCP profile for signing. Removes the `signature` field (if present), + * sorts keys deterministically, and returns the JSON string. Both signer and verifier + * compute the same bytes. + * + * Implementation note: UCP §6.2 specifies "the JSON-serialized profile body, with + * `signature` removed and keys ordered lexicographically at every nesting level." This + * is JCS-style canonicalization without the full RFC 8785 numeric handling — UCP + * profiles don't contain floats so the simpler key-sort is sufficient. + */ +function canonicalizeProfile(profile: UCPProfile): string { + const stripped = { ...profile } as Record; + delete stripped.signature; + return stableStringify(stripped); +} + +/** Deterministic JSON.stringify with lexicographic key ordering at every level. + * Rejects ANY non-finite Number (NaN, Infinity, -Infinity) and any Number + * whose value has a fractional part OR whose JSON representation may diverge + * cross-language. Cross-language float canonicalization (RFC 8785 §3.2.2.3) + * is not stable between Node's JSON.stringify and Python's json.dumps + * (e.g. `1.0` → `1` vs `1.0`, `1e-7` → `1e-7` vs `1e-07`). UCP profiles + * must use decimal strings for monetary or fractional fields to preserve + * byte parity with the Python sibling. */ +function stableStringify(value: unknown): string { + if (value === undefined) { + throw new Error( + 'stableStringify: undefined values are not allowed in canonicalized JSON. ' + + 'Object fields with no value must be omitted.', + ); + } + if (typeof value === 'function' || typeof value === 'symbol') { + throw new Error(`stableStringify: ${typeof value} values are not allowed in canonicalized JSON.`); + } + if (typeof value === 'bigint') { + throw new Error('stableStringify: BigInt values are not allowed; use a decimal string.'); + } + if (value instanceof Date) { + throw new Error( + 'stableStringify: Date instances are not allowed; serialize to an ISO string before passing.', + ); + } + if (value instanceof Map || value instanceof Set || value instanceof WeakMap || value instanceof WeakSet) { + throw new Error( + `stableStringify: ${value.constructor.name} values are not allowed; convert to a plain object/array first.`, + ); + } + if (ArrayBuffer.isView(value)) { + throw new Error('stableStringify: typed arrays are not allowed; convert to a plain array first.'); + } + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + throw new Error( + `UCP profile canonicalization rejects non-finite Number ${value}. Use a decimal string for any value that may be NaN/Infinity.`, + ); + } + if (!Number.isInteger(value)) { + throw new Error( + `UCP profile canonicalization rejects non-integer Number ${value}. Use a decimal string (e.g. "9.99") for monetary or fractional fields to preserve cross-language byte-parity.`, + ); + } + if (!Number.isSafeInteger(value)) { + throw new Error( + `stableStringify: integer ${value} exceeds Number.MAX_SAFE_INTEGER. ` + + 'For values >2^53, use a decimal string to preserve cross-language byte parity.', + ); + } + } + if (typeof value === 'string') { + // Cross-language byte parity: pre-ES2019 V8 (and any environment whose + // JSON.stringify still escapes U+2028 / U+2029) emits \u2028 / \u2029 + // for these codepoints, while Python's json.dumps with ensure_ascii=False + // emits them raw. A string carrying either would canonicalize to different + // bytes across the Node and Python siblings and break signature + // verification at the language boundary. Mirror the rejection in + // core/api/src/lib/canonicalize.ts so the contract stays symmetric. + if (value.includes('\u2028') || value.includes('\u2029')) { + throw new Error( + 'stableStringify: strings containing U+2028 (LINE SEPARATOR) or U+2029 (PARAGRAPH SEPARATOR) are not allowed; cross-language byte parity requires neither be present (Node JSON.stringify on older V8 escapes them; Python json.dumps with ensure_ascii=False does not).', + ); + } + return JSON.stringify(value); + } + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`; + const obj = value as Record; + const keys = Object.keys(obj).sort((a, b) => { + const aPoints = [...a].map((c) => c.codePointAt(0)!); + const bPoints = [...b].map((c) => c.codePointAt(0)!); + const len = Math.min(aPoints.length, bPoints.length); + for (let i = 0; i < len; i += 1) { + if (aPoints[i] !== bPoints[i]) return aPoints[i] - bPoints[i]; + } + return aPoints.length - bPoints.length; + }); + // Cross-language byte parity: same rejection rationale as the string-value + // branch above. Object keys flow through JSON.stringify(k) at the pairs line + // below, so without this check a key carrying U+2028 / U+2029 would pass on + // modern V8 but Python's _reject_unsafe_numbers (which recurses into dict + // keys) would throw at verify time. + for (const k of keys) { + if (k.includes('
') || k.includes('
')) { + throw new Error( + 'stableStringify: object keys containing U+2028 (LINE SEPARATOR) or U+2029 (PARAGRAPH SEPARATOR) are not allowed; cross-language byte parity (Node JSON.stringify on older V8 escapes them; Python json.dumps with ensure_ascii=False does not).', + ); + } + } + const pairs = keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`); + return `{${pairs.join(',')}}`; +} + +/** + * Generate a fresh Ed25519 (default) or ES256 keypair for signing UCP profiles. + * + * The `privateKey` is an opaque KeyLike — store it server-side and pass to + * `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. + * + * Example: + * ```ts + * import { generateUCPSigningKey } from '@agent-score/commerce'; + * + * const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: 'merchant-2026-05' }); + * // Persist privateKey securely (env var, KMS, secret manager). + * // Publish publicJWK at /.well-known/jwks.json and reference it in your UCP profile. + * ``` + */ +export async function generateUCPSigningKey(opts: { + /** Key ID (kid). Must be unique per key; you'll reference this in the UCP profile's `signing_keys[]`. */ + kid: string; + /** Signing algorithm. Default `EdDSA`. */ + alg?: 'EdDSA' | 'ES256'; +}): Promise { + const jose = await loadJose(); + const alg = opts.alg ?? 'EdDSA'; + const { privateKey, publicKey } = await jose.generateKeyPair(alg, { extractable: true }); + const exportedJwk = await jose.exportJWK(publicKey); + + const publicJWK: UCPSigningKey = { + kid: opts.kid, + alg, + use: 'sig', + ...exportedJwk, + } as UCPSigningKey; + + return { privateKey, publicJWK }; +} + +/** + * Sign a UCP profile, returning a new envelope with the JWS attached as `signature`. + * + * The signature covers the canonicalized profile body (everything except `signature` + * 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` + * — otherwise verifiers can't find the public key. Add the `publicJWK` from + * `generateUCPSigningKey()` to your `signing_keys[]` before calling this. + * + * Example: + * ```ts + * const profile = buildUCPProfile({ ..., signing_keys: [publicJWK] }); + * const signed = await signUCPProfile(profile, { signingKey: privateKey, kid: 'merchant-2026-05' }); + * c.json(signed); + * ``` + */ +export async function signUCPProfile( + profile: UCPProfile, + opts: SignUCPProfileOptions, +): Promise { + const jose = await loadJose(); + const alg = opts.alg ?? 'EdDSA'; + + if (!ALLOWED_ALGS.includes(alg as AllowedAlg)) { + throw new Error( + `signUCPProfile: alg ${JSON.stringify(opts.alg)} is not in the supported set [${ALLOWED_ALGS.join(', ')}].`, + ); + } + + // Sign-time kid sanity check: the profile's `signing_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 opts.kid !== 'string' || !opts.kid) { + throw new Error('signUCPProfile: opts.kid must be a non-empty string.'); + } + const kids = (profile.signing_keys ?? []).map((k) => (k as Record).kid); + if (!kids.includes(opts.kid)) { + throw new Error( + `signUCPProfile: kid ${JSON.stringify(opts.kid)} is not present in profile.signing_keys[] (declared kids: ${JSON.stringify(kids)}). Verifiers will not find the key.`, + ); + } + + const canonicalBody = canonicalizeProfile(profile); + const payloadBytes = new TextEncoder().encode(canonicalBody); + + const signature = await new jose.CompactSign(payloadBytes) + .setProtectedHeader({ alg, kid: opts.kid, typ: PROFILE_TYP }) + .sign(opts.signingKey as Parameters[0]); + + return { ...profile, signature }; +} + +/** + * Verify a signed UCP profile against a JWKS. Returns `true` when the JWS validates + * against a matching key in `jwks`; throws on signature mismatch, missing key, or + * canonicalization drift. + * + * Round-trip helper for tests and for cross-merchant verification flows. Trust-mode + * UCP clients use the same algorithm. + * + * Example: + * ```ts + * const ok = await verifyUCPProfile(signedProfile, { keys: [publicJWK] }); + * ``` + */ +export async function verifyUCPProfile( + profile: SignedUCPProfile, + jwks: JWKSResponse, +): Promise { + if (profile === null || typeof profile !== 'object' || Array.isArray(profile)) { + throw new UCPVerificationError( + 'no_signature', + `UCP profile must be a JSON object; got ${profile === null ? 'null' : Array.isArray(profile) ? 'array' : typeof profile}.`, + ); + } + + const jose = await loadJose(); + + // JWKS shape guard so a malformed argument emits a typed UCPVerificationError + // rather than a raw TypeError on `.filter is not a function`. + if (!jwks || typeof jwks !== 'object' || !Array.isArray((jwks as { keys?: unknown }).keys)) { + throw new UCPVerificationError( + 'malformed_jwks', + `UCP verifier expected JWKS shape { keys: [...] }; got ${jwks === null ? 'null' : typeof jwks === 'object' ? 'object without keys[] array' : typeof jwks}.`, + ); + } + + const stripped = { ...profile } as Partial; + const sig = stripped.signature; + delete stripped.signature; + if (typeof sig !== 'string' || !sig) { + throw new UCPVerificationError( + 'no_signature', + `UCP profile signature must be a non-empty string; got ${sig === undefined ? 'undefined' : typeof sig}.`, + ); + } + + // Pre-decode the protected header so typ → alg → kid → crit checks run BEFORE + // jose's compactVerify. jose enforces `crit` internally ahead of the key-resolver + // callback, which would surface `unrecognized_critical_header` on a JWS that + // also has a wrong typ; the python-commerce sibling's `_peek_jws_header` decodes + // the header manually and checks typ first. Mirroring that ordering here means + // a JWS with multiple header faults emits the same `code` in both SDKs. + let header: { alg?: unknown; kid?: unknown; typ?: unknown; crit?: unknown }; + try { + const protectedB64 = sig.split('.')[0]; + if (!protectedB64) throw new Error('JWS protected header segment is empty.'); + const headerJson = new TextDecoder().decode(jose.base64url.decode(protectedB64)); + const parsed = JSON.parse(headerJson); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('JWS protected header is not a JSON object.'); + } + header = parsed as { alg?: unknown; kid?: unknown; typ?: unknown; crit?: unknown }; + } catch (err) { + throw new UCPVerificationError( + 'malformed_jws', + `JWS protected header is not valid base64url-encoded JSON: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + // Header check order is typ → alg → kid → crit to match the Python sibling's + // _peek_jws_header. RFC 8725 §3.11: enforce expected typ to prevent + // cross-protocol token reuse. + if (header.typ !== PROFILE_TYP) { + throw new UCPVerificationError('wrong_typ', `UCP signature typ must be "${PROFILE_TYP}"; got ${String(header.typ)}.`); + } + // RFC 8725 §3.1: restrict to allow-listed algorithms before key resolution + // so a hostile JWK can never be used with HS256/none/RS256/etc. + if (!ALLOWED_ALGS.includes(header.alg as AllowedAlg)) { + throw new UCPVerificationError('unsupported_alg', `UCP signing alg must be one of ${ALLOWED_ALGS.join(', ')}; got ${String(header.alg)}.`); + } + // Strict string check: a non-string kid (number/bool/null) could accidentally + // match a JWK with an equal-typed kid and mask attacks. + if (typeof header.kid !== 'string' || !header.kid) { + throw new UCPVerificationError( + 'missing_kid', + `UCP signature header kid must be a non-empty string; got ${header.kid === undefined ? 'undefined' : typeof header.kid}.`, + ); + } + // RFC 7515 §4.1.11: `crit` MUST be a non-empty array of strings if present. + // Shape-check first (matches python-commerce's malformed_jws split) so that + // explicit `crit: null` / `crit: []` / `crit: "foo"` / `crit: [42]` aren't + // silently accepted; only well-formed crit arrays fall through to the + // unrecognized-extension check (RFC 8725 §3.10 — UCP defines no crit headers). + if ('crit' in header) { + const crit = (header as { crit?: unknown }).crit; + if (!Array.isArray(crit) || crit.length === 0 || !crit.every((c) => typeof c === 'string')) { + throw new UCPVerificationError( + 'malformed_jws', + `JWS protected header crit must be a non-empty array of strings; got ${JSON.stringify(crit)}.`, + ); + } + throw new UCPVerificationError( + 'unrecognized_critical_header', + `JWS protected header advertises unrecognized crit headers: ${JSON.stringify(crit)}.`, + ); + } + + let signedPayload: Uint8Array; + try { + const verified = await jose.compactVerify( + sig, + async (h) => { + // typ/alg/kid/crit were validated up-front against the pre-decoded header; + // this resolver only handles JWK lookup. Re-checking kid here keeps the + // jose API satisfied and provides defense-in-depth against any header + // re-parse divergence between this code path and jose's internals. + const kid = h.kid; + if (typeof kid !== 'string' || !kid) { + throw new UCPVerificationError( + 'missing_kid', + `UCP signature header kid must be a non-empty string; got ${kid === undefined ? 'undefined' : typeof kid}.`, + ); + } + const matches = jwks.keys.filter( + (k) => k != null && typeof k === 'object' && (k as Record).kid === kid, + ); + if (matches.length === 0) throw new UCPVerificationError('kid_not_found', `No JWK in JWKS matching kid=${JSON.stringify(kid)}.`); + if (matches.length > 1) throw new UCPVerificationError('duplicate_kid', `JWKS contains ${matches.length} keys with kid=${JSON.stringify(kid)}; expected exactly one.`); + // RFC 7517 §4.2: reject keys not intended for signature verification. + // `use` and `alg` are optional per RFC 7517; an explicit JSON null is + // out-of-spec but treat it as absent (skip-on-null) so a JWK with + // `"use": null` matches Python's `is not None` semantics in + // ucp_jwks.py and the two languages stay symmetric. + const matchedKey = matches[0] as Record; + if (matchedKey.use != null && matchedKey.use !== 'sig') { + throw new UCPVerificationError('unusable_key', `JWK with kid=${kid} has use=${JSON.stringify(matchedKey.use)}; expected "sig".`); + } + // RFC 7517 §4.4: a JWK with a declared `alg` field constrains its use to that algorithm. + if (matchedKey.alg != null && matchedKey.alg !== h.alg) { + throw new UCPVerificationError( + 'unusable_key', + `JWK alg ${JSON.stringify(matchedKey.alg)} does not match JWS header alg ${JSON.stringify(h.alg)}.`, + ); + } + return jose.importJWK(matches[0] as Parameters[0], h.alg); + }, + ); + signedPayload = verified.payload; + } catch (err) { + if (err instanceof UCPVerificationError) throw err; + if (err instanceof Error && err.name === 'JOSEAlgNotAllowed') { + throw new UCPVerificationError('unsupported_alg', `UCP signing alg not allowed: ${err.message}`); + } + if (err instanceof Error && err.name === 'JWSSignatureVerificationFailed') { + throw new UCPVerificationError('signature_invalid', `UCP signature verification failed: ${err.message}`); + } + if (err instanceof Error && err.name === 'JWSInvalid') { + throw new UCPVerificationError('malformed_jws', `Malformed JWS: ${err.message}`); + } + // RFC 7515 §4.1.11 / RFC 8725 §3.10: a verifier MUST reject any JWS whose + // `crit` header carries an extension the implementation doesn't understand. + // jose throws JOSENotSupported; wrap so callers see the typed error. + if (err instanceof Error && err.name === 'JOSENotSupported') { + throw new UCPVerificationError('unrecognized_critical_header', `UCP signing rejected unrecognized critical header: ${err.message}`); + } + throw err; + } + + let canonicalBody: string; + try { + canonicalBody = canonicalizeProfile(stripped as UCPProfile); + } catch (err) { + throw new UCPVerificationError( + 'body_mismatch', + `Failed to canonicalize received profile for verification: ${err instanceof Error ? err.message : String(err)}`, + ); + } + const expectedPayload = new TextEncoder().encode(canonicalBody); + + // Compare the bytes that were actually signed against the canonical body of the + // profile we received. `compactVerify` validates the JWS against the bytes embedded + // in the JWS payload segment, but the profile body could have been swapped after + // signing while the JWS stayed unchanged. Body-vs-payload comparison closes that + // gap. + if (!constantTimeEqual(signedPayload, expectedPayload)) { + throw new UCPVerificationError('body_mismatch', 'UCP profile body does not match the signed payload (tampered or non-canonical).'); + } + + return true; +} + +/** Constant-time byte comparison to avoid leaking length / position info on mismatch. */ +function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i += 1) { + diff |= a[i] ^ b[i]; + } + return diff === 0; +} + +/** + * Build a JWKS document for `/.well-known/jwks.json`. + * + * Example: + * ```ts + * import { buildJWKSResponse } from '@agent-score/commerce'; + * + * app.get('/.well-known/jwks.json', (c) => + * c.json(buildJWKSResponse([publicJWK])) + * ); + * ``` + */ +export function buildJWKSResponse(keys: UCPSigningKey[]): JWKSResponse { + return { keys }; +} diff --git a/src/identity/ucp.ts b/src/identity/ucp.ts index bef7ab1..f034621 100644 --- a/src/identity/ucp.ts +++ b/src/identity/ucp.ts @@ -1,35 +1,42 @@ /** * UCP (Universal Commerce Protocol) profile builder. * - * Compose the JSON payload published at `/.well-known/ucp` per the UCP spec, with - * AgentScore identity claims attached as a capability. Returned object is the unsigned - * profile body — the merchant signs it (or wraps it in their JWKS-backed envelope) - * before publishing. + * Compose the JSON payload published at `/.well-known/ucp` per the UCP spec. + * Output shape matches the spec example: top-level `{ ucp: {...}, signing_keys: [...] }` + * envelope, with `services` / `capabilities` / `payment_handlers` as MAPs keyed by + * reverse-DNS service / capability / handler name. Verified against the live + * production reference at `https://puravidabracelets.com/.well-known/ucp` (Shopify's + * UCP integration, one of the launch reference brands). * - * Why publish: UCP is the Google-led cross-vendor standard (announced Jan 2026 at NRF - * with Shopify, Etsy, Wayfair, Target, Walmart, Adyen, Mastercard, Stripe, Visa, Amex, - * etc.). Every UCP-aware platform discovers a merchant via `/.well-known/ucp`, so - * shipping this profile means AgentScore-gated merchants are discoverable through the - * same surface every other UCP merchant uses. + * AgentScore identity claims layer over UCP via the `sh.agentscore.identity` capability + * (vendor-namespaced; UCP doesn't define KYC/sanctions/age/jurisdiction natively). The + * capability extends `dev.ucp.shopping.checkout` AND `dev.ucp.shopping.cart` (multi-parent, + * matching Shopify's `dev.shopify.catalog.storefront` pattern in the live ecosystem). * - * Spec reference: https://ucp.dev/ + * The unsigned profile body returned here is what merchants publish; pass it through + * `signUCPProfile` to attach the `agentscore-profile+jws` signature for trust-mode + * verifiers (vendor extension; UCP itself doesn't mandate profile-body signing). * - * UCP profiles do NOT carry KYC / sanctions / age / jurisdiction claims natively — - * identity in the UCP spec is "who signed this" (JWKS-backed). AgentScore claims layer - * over UCP via a custom capability so consumers who care about verified-buyer identity - * can read them; consumers who don't care just see a normal UCP profile. + * Spec reference: https://ucp.dev/ */ import type { AgentScoreData } from '../core'; +/** + * UCP per-element shape note: each binding interface (`UCPServiceBinding`, + * `UCPCapabilityBinding`, `UCPPaymentHandlerBinding`) carries the canonical UCP fields + * plus arbitrary vendor extras flat on the same object via `[k: string]: unknown`. The + * python sibling models these as dataclasses with an explicit `extras: dict` field. Both + * designs offer equivalent guarantees through different mechanisms. + */ export interface UCPSigningKey { /** JWK kid (key id). */ kid: string; - /** JWK kty (key type) — typically `EC`, `RSA`, or `OKP`. */ + /** JWK kty (key type) — `EC`, `RSA`, or `OKP`. */ kty: string; - /** JWK alg (signing algorithm) — typically `ES256`, `RS256`, or `EdDSA`. */ + /** JWK alg (signing algorithm) — `ES256`, `RS256`, or `EdDSA`. */ alg?: string; - /** JWK use — typically `sig`. */ + /** JWK use, typically `sig`. */ use?: string; /** JWK crv (curve) for EC / OKP keys. */ crv?: string; @@ -37,147 +44,308 @@ export interface UCPSigningKey { [k: string]: unknown; } -export interface UCPService { - /** Transport binding — `rest` / `mcp` / `a2a` / `embedded`. */ - type: string; - /** Service URL (or path for embedded). */ - url?: string; - /** Optional version pin. */ - version?: string; - /** Vendor-specific extras for the binding. */ - [k: string]: unknown; +/** + * Construct a UCPSigningKey from a public JWK dict (e.g. the `publicJWK` returned by + * `generateUCPSigningKey()`). Validates required fields and rejects symmetric keys that + * can't publicly verify a JWS in trust-mode UCP. Symmetric to Python's + * `UCPSigningKey.from_jwk(public_jwk)` classmethod. + */ +export function ucpSigningKeyFromJWK(jwk: Record): UCPSigningKey { + if (!jwk || typeof jwk !== 'object') { + throw new Error(`ucpSigningKeyFromJWK expected a non-null object; got ${typeof jwk}.`); + } + if (typeof jwk.kid !== 'string' || !jwk.kid) { + throw new Error('ucpSigningKeyFromJWK: JWK missing required field `kid` (or non-string).'); + } + if (typeof jwk.kty !== 'string' || !jwk.kty) { + throw new Error('ucpSigningKeyFromJWK: JWK missing required field `kty` (or non-string).'); + } + if (jwk.kty !== 'OKP' && jwk.kty !== 'EC' && jwk.kty !== 'RSA') { + throw new Error( + `ucpSigningKeyFromJWK: kty=${JSON.stringify(jwk.kty)} is not a supported asymmetric key type (expected OKP, EC, or RSA). Symmetric \`oct\` keys are rejected because they cannot publicly verify a JWS in the trust-mode UCP flow.`, + ); + } + if ((jwk.kty === 'EC' || jwk.kty === 'OKP') && (typeof jwk.crv !== 'string' || !jwk.crv)) { + throw new Error(`ucpSigningKeyFromJWK: kty=${jwk.kty} requires a non-empty \`crv\` field (e.g., "P-256" for EC, "Ed25519" for OKP).`); + } + return jwk as unknown as UCPSigningKey; } -export interface UCPCapability { - /** Capability name — `checkout`, `catalog`, `agentscore-identity`, etc. */ - name: string; - /** URL of the JSON Schema describing this capability's payload. */ +/** Transport binding — keyed under a service name (e.g., `dev.ucp.shopping`). */ +export interface UCPServiceBinding { + /** Spec version, YYYY-MM-DD per UCP convention. REQUIRED. */ + version: string; + /** URL to human-readable specification. REQUIRED. */ + spec: string; + /** Transport — `rest` / `mcp` / `a2a` / `embedded`. REQUIRED. */ + transport: 'rest' | 'mcp' | 'a2a' | 'embedded'; + /** Endpoint URL — required for rest/mcp; A2A points at the agent-card.json URL. */ + endpoint?: string; + /** URL to JSON Schema — required for rest/mcp/embedded per spec. */ schema?: string; - /** Capability version — semver or date-stamp per UCP convention. */ - version?: string; - /** Vendor-specific extras for the capability. */ + /** Optional id for entity-instance disambiguation. */ + id?: string; + /** Entity-specific config. */ + config?: Record; + /** Vendor-specific extras. */ [k: string]: unknown; } -export interface UCPPaymentHandler { - /** Handler name — `stripe`, `tempo`, `x402-base`, `solana`, etc. */ - name: string; - /** Handler config — recipient address, profile id, etc. */ +/** Capability binding — keyed under a capability name (e.g., `dev.ucp.shopping.checkout`). */ +export interface UCPCapabilityBinding { + /** Capability version, YYYY-MM-DD. REQUIRED. */ + version: string; + /** URL to human-readable specification. REQUIRED. */ + spec: string; + /** URL to JSON Schema. REQUIRED. */ + schema: string; + /** Optional id for entity-instance disambiguation. */ + id?: string; + /** Entity-specific config (feature flags, callback URLs, etc). */ config?: Record; + /** Parent capability(ies) extended — single string or array for multi-parent. */ + extends?: string | string[]; + /** Optional version requirements per UCP §6.5. */ + requires?: { + protocol?: { min: string; max?: string }; + capabilities?: Record; + }; + /** Vendor-specific extras (e.g., AgentScore claims block on `sh.agentscore.identity`). */ + [k: string]: unknown; } -export interface UCPProfile { - /** UCP spec version (date-stamped). */ +/** Payment handler binding — keyed under a handler reverse-DNS name (e.g., `com.google.pay`). */ +export interface UCPPaymentHandlerBinding { + /** Handler instance id (short, human-readable, e.g., `gpay`, `tempo`, `x402`). REQUIRED. */ + id: string; + /** Handler spec version, YYYY-MM-DD. REQUIRED. */ version: string; - /** URL of the UCP spec. */ + /** URL to handler spec. REQUIRED. */ spec: string; - /** URL of this profile's JSON schema. */ - schema?: string; - /** Display name of the merchant / agent surface. */ + /** URL to handler config schema. REQUIRED. */ + schema: string; + /** Available instruments — type + per-type constraints (cards, wallets, etc.). */ + available_instruments?: Array<{ type: string; constraints?: Record; [k: string]: unknown }>; + /** Handler config — gateway IDs, merchant IDs, public keys, etc. */ + config?: Record; + /** Vendor-specific extras. */ + [k: string]: unknown; +} + +/** UCP body — nested under the `ucp` key of the published profile. */ +export interface UCPProfileBody { + /** UCP spec version (YYYY-MM-DD). */ + version: string; + /** Display name for the merchant / agent surface. */ name?: string; - /** Service bindings — REST, MCP, A2A, embedded transports. */ - services: UCPService[]; - /** Capabilities offered (with schema URLs). */ - capabilities: UCPCapability[]; - /** Payment handlers offered — typically the rails the merchant accepts. */ - payment_handlers: UCPPaymentHandler[]; - /** JWKS — REQUIRED by spec. The merchant signs requests with a private key whose - * public counterpart is listed here. Verifiers fetch this profile, find the kid, and - * validate signatures. */ + /** Services — keyed by service name (e.g., `dev.ucp.shopping`). Each value is an + * array of transport bindings (one merchant typically advertises multiple transports + * under one service name). */ + services: Record; + /** Capabilities — keyed by capability name (e.g., `dev.ucp.shopping.checkout`). */ + capabilities: Record; + /** Payment handlers — keyed by handler reverse-DNS name (e.g., `com.google.pay`). */ + payment_handlers: Record; + /** Optional `supported_versions` map linking historical version-specific profile URLs. + * Pattern: `{ "2026-01-23": "https://merchant/.well-known/ucp/2026-01-23", ... }`. */ + supported_versions?: Record; + /** Vendor-specific extras inside the `ucp` envelope. */ + [k: string]: unknown; +} + +/** Full UCP profile body as published at `/.well-known/ucp`. Top-level shape: + * `{ ucp: {...}, signing_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, + * match the kid from a JWS / RFC 9421 signature header against this list, and validate. */ signing_keys: UCPSigningKey[]; - /** Vendor-specific extras at the top level. */ + /** Set when JWS-signed via `signUCPProfile` — JWS Compact Serialization with detached + * payload (header..signature; payload is the canonicalized body minus this field). */ + signature?: string; + /** Top-level vendor-specific extras (outside the `ucp` envelope). */ [k: string]: unknown; } export interface BuildUCPProfileInput { - /** UCP spec version. Default `"2026-04-17"` (current at time of writing). */ + /** UCP spec version. Default `'2026-04-17'`. */ version?: string; /** Display name for the merchant / agent surface. */ name?: string; - /** Service transport bindings. At minimum, the agent's primary REST endpoint. */ - services: UCPService[]; - /** Capabilities offered. AgentScore identity is auto-added as a capability when `data` is provided. */ - capabilities?: UCPCapability[]; - /** Payment handlers — rails the merchant accepts. */ - payment_handlers?: UCPPaymentHandler[]; - /** JWKS — public keys the merchant signs requests with. REQUIRED by spec. */ + /** Services map, keyed by service name. UCP-shopping merchants typically advertise + * bindings under `'dev.ucp.shopping'`. */ + services?: Record; + /** Capabilities map, keyed by capability name. The `sh.agentscore.identity` capability + * is auto-added when `data` is provided. */ + 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[]; - /** AgentScore assess data — adds an `agentscore-identity` capability + claims block when present. */ + /** AgentScore assess data — adds an `sh.agentscore.identity` capability + claims + * block when present. */ data?: AgentScoreData | null; - /** Optional override for the AgentScore capability schema URL. */ - agentscoreSchemaUrl?: string; - /** Vendor-specific extras at the top level. */ + /** Optional override for the AgentScore capability schema URL. Field is snake_cased + * for cross-language parity with the Python sibling. */ + agentscore_schema_url?: string; + /** Optional override for the AgentScore capability spec URL. */ + agentscore_spec_url?: string; + /** `supported_versions` map at the profile root. Pattern matches Pura Vida's + * production profile (`{ "": "/.well-known/ucp/" }`). */ + supported_versions?: Record; + /** Vendor-specific extras at the OUTER level (alongside `ucp` + `signing_keys`). */ extras?: Record; + /** Vendor-specific extras INSIDE the `ucp` envelope (alongside `version`, `services`, etc.). */ + ucp_extras?: Record; } const DEFAULT_VERSION = '2026-04-17'; -const SPEC_URL = 'https://ucp.dev/'; -const AGENTSCORE_CAPABILITY_NAME = 'agentscore-identity'; +// 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 +// `sh.agentscore` is honest about the capability being our extension, not UCP-canonical. +const AGENTSCORE_CAPABILITY_NAME = 'sh.agentscore.identity'; const AGENTSCORE_CAPABILITY_VERSION = '1'; +const AGENTSCORE_DEFAULT_SPEC_URL = 'https://agentscore.sh/specification/identity'; +const AGENTSCORE_DEFAULT_SCHEMA_URL = 'https://agentscore.sh/schemas/ucp/sh-agentscore-identity-v1.json'; +// Multi-parent extension — `sh.agentscore.identity` carries claims relevant at both +// checkout-build (compliance gate) and cart-build (price-gate eligibility, jurisdiction- +// restricted items in cart) time. Mirrors the multi-parent convention in the live +// ecosystem (Shopify's `dev.shopify.catalog.storefront` extends both `catalog.search` +// and `catalog.lookup`; UCP-canonical `dev.ucp.shopping.discount` extends both checkout +// and cart). +const AGENTSCORE_EXTENDS = ['dev.ucp.shopping.checkout', 'dev.ucp.shopping.cart']; + +const RESERVED_TOP_LEVEL = new Set([ + 'ucp', + 'signing_keys', + 'signature', + '__proto__', + 'constructor', + 'prototype', +]); +const RESERVED_UCP_FIELDS = new Set([ + 'version', + 'name', + 'services', + 'capabilities', + 'payment_handlers', + 'supported_versions', + '__proto__', + 'constructor', + 'prototype', +]); /** - * Compose a UCP profile body for `/.well-known/ucp` publication. Merges AgentScore - * identity claims into the `capabilities` array as an `agentscore-identity` capability - * so UCP-aware consumers can discover verified-buyer claims alongside the standard - * UCP transport metadata. + * 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 + * trust-mode verifiers. + * + * Auto-injects `sh.agentscore.identity` as a vendor capability extending both + * `dev.ucp.shopping.checkout` and `dev.ucp.shopping.cart` when `data` carries a + * resolved operator. Verifiers that recognize the AgentScore namespace can parse + * the `claims` block; vanilla UCP agents see a normal extension capability. * * Example: * ```ts - * import { buildUCPProfile } from '@agent-score/commerce/identity/hono'; + * import { buildUCPProfile } from '@agent-score/commerce'; * - * app.get('/.well-known/ucp', async (c) => { - * const data = getAgentScoreData(c); - * return c.json(buildUCPProfile({ - * name: 'Example Merchant', - * services: [{ type: 'rest', url: 'https://agents.example.com' }], - * payment_handlers: [ - * { name: 'tempo', config: { recipient: TEMPO_ADDR } }, - * { name: 'stripe', config: { profile_id: STRIPE_PROFILE_ID } }, + * const profile = buildUCPProfile({ + * name: 'Example Merchant', + * services: { + * 'dev.ucp.shopping': [ + * { version: '2026-04-08', spec: 'https://ucp.dev/2026-04-08/specification/overview', + * transport: 'mcp', endpoint: 'https://merchant.example/api/ucp/mcp', + * schema: 'https://ucp.dev/services/shopping/openrpc.json' }, * ], - * signing_keys: [{ kid: 'merchant-2026-04', kty: 'EC', alg: 'ES256', crv: 'P-256', x: '...', y: '...' }], - * data, - * })); + * }, + * payment_handlers: { + * 'sh.agentscore.payment.tempo': [{ + * id: 'tempo', + * version: '2026-04-08', + * spec: 'https://agentscore.sh/specification/payment-handlers/tempo', + * schema: 'https://agentscore.sh/schemas/payment-handlers/tempo.json', + * config: { recipient: TEMPO_ADDR }, + * }], + * }, + * signing_keys: [signingKey], * }); * ``` */ export function buildUCPProfile(input: BuildUCPProfileInput): UCPProfile { - const baseCapabilities: UCPCapability[] = [...(input.capabilities ?? [])]; + // Deep-clone the capabilities map so we can safely mutate (auto-add the AgentScore + // identity capability) without altering the caller's input. + const capabilities: Record = {}; + for (const [name, bindings] of Object.entries(input.capabilities ?? {})) { + capabilities[name] = [...bindings]; + } if (input.data) { const operatorId = input.data.resolved_operator; if (operatorId) { const operatorVerification = input.data.operator_verification; const accountVerification = input.data.account_verification; + // `||` (not `??`) coerces both null/undefined AND empty string to the default, + // matching the python sibling. The API can return `account_verification` with + // either null or `""` for un-set fields; profiles signed in one language must + // verify in the other across both shapes. const claims: Record = { operator_id: operatorId, - kyc_level: accountVerification?.kyc_level ?? operatorVerification?.level ?? 'none', + kyc_level: accountVerification?.kyc_level || operatorVerification?.level || 'none', sanctions_clear: accountVerification?.sanctions_clear === true, - age_bracket: accountVerification?.age_bracket ?? 'unknown', - jurisdiction: accountVerification?.jurisdiction ?? '', - verified_at: accountVerification?.verified_at ?? operatorVerification?.verified_at ?? null, + age_bracket: accountVerification?.age_bracket || 'unknown', + jurisdiction: accountVerification?.jurisdiction || '', + verified_at: accountVerification?.verified_at || operatorVerification?.verified_at || null, verify_url: input.data.verify_url ?? null, issuer: 'https://agentscore.sh', }; - baseCapabilities.push({ - name: AGENTSCORE_CAPABILITY_NAME, + const agentscoreBinding: UCPCapabilityBinding = { version: AGENTSCORE_CAPABILITY_VERSION, - schema: input.agentscoreSchemaUrl ?? 'https://agentscore.sh/schemas/ucp/agentscore-identity.v1.json', + spec: input.agentscore_spec_url ?? AGENTSCORE_DEFAULT_SPEC_URL, + schema: input.agentscore_schema_url ?? AGENTSCORE_DEFAULT_SCHEMA_URL, + extends: AGENTSCORE_EXTENDS, + // `claims` is our vendor extra on the binding; allowed per spec via the + // `[k: string]: unknown` index signature on UCPCapabilityBinding. claims, - }); + }; + const existing = capabilities[AGENTSCORE_CAPABILITY_NAME]; + if (existing) existing.push(agentscoreBinding); + else capabilities[AGENTSCORE_CAPABILITY_NAME] = [agentscoreBinding]; } } - const profile: UCPProfile = { + const ucp: UCPProfileBody = { version: input.version ?? DEFAULT_VERSION, - spec: SPEC_URL, - services: input.services, - capabilities: baseCapabilities, - payment_handlers: input.payment_handlers ?? [], - signing_keys: input.signing_keys, + services: input.services ?? {}, + capabilities, + payment_handlers: input.payment_handlers ?? {}, }; + if (input.name !== undefined) ucp.name = input.name; + if (input.supported_versions !== undefined) ucp.supported_versions = input.supported_versions; + if (input.ucp_extras) { + for (const k of Object.keys(input.ucp_extras)) { + if (RESERVED_UCP_FIELDS.has(k)) { + throw new Error(`buildUCPProfile: ucp_extras key "${k}" collides with a reserved \`ucp\` field; rejected.`); + } + } + Object.assign(ucp, input.ucp_extras); + } - if (input.name !== undefined) profile.name = input.name; - if (input.extras) Object.assign(profile, input.extras); + const profile: UCPProfile = { + ucp, + signing_keys: input.signing_keys, + }; + if (input.extras) { + // `__proto__`, `constructor`, `prototype` reserved so vendor extras can't slip + // prototype-pollution payloads into the canonical body. + for (const k of Object.keys(input.extras)) { + if (RESERVED_TOP_LEVEL.has(k)) { + throw new Error(`buildUCPProfile: extras key "${k}" collides with a reserved profile field; rejected.`); + } + } + Object.assign(profile, input.extras); + } return profile; } diff --git a/src/index.ts b/src/index.ts index 78b7698..7cd12ba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,8 +25,11 @@ export { export { denialReasonToBody } from './_response'; export { buildA2AAgentCard, + ucpA2AExtension, + UCP_A2A_EXTENSION_URI, type A2AAgentCard, type A2AAgentCardCapabilities, + type A2AAgentCardExtension, type A2AAgentCardIdentity, type BuildA2AAgentCardInput, } from './identity/a2a'; @@ -34,12 +37,25 @@ export { AGENTSCORE_UCP_CAPABILITY, buildUCPProfile, type BuildUCPProfileInput, - type UCPCapability, - type UCPPaymentHandler, + type UCPCapabilityBinding, + type UCPPaymentHandlerBinding, type UCPProfile, - type UCPService, + type UCPProfileBody, + type UCPServiceBinding, type UCPSigningKey, + ucpSigningKeyFromJWK, } from './identity/ucp'; +export { + buildJWKSResponse, + generateUCPSigningKey, + type GeneratedUCPKey, + type JWKSResponse, + type SignUCPProfileOptions, + type SignedUCPProfile, + signUCPProfile, + UCPVerificationError, + verifyUCPProfile, +} from './identity/ucp-jwks'; export { type EnforcementMode, type GateResult, diff --git a/tests/discovery/openapi.test.ts b/tests/discovery/openapi.test.ts index f9a1296..9e267f3 100644 --- a/tests/discovery/openapi.test.ts +++ b/tests/discovery/openapi.test.ts @@ -100,4 +100,22 @@ describe('agentscoreOpenApiSnippets', () => { expect(agentscoreOpenApiSnippets({ security: false }).securitySchemes).toBeUndefined(); expect(agentscoreOpenApiSnippets({ denials: false, paymentRequired: false }).schemas).toBeUndefined(); }); + + it('emits only paymentRequired schema when denials=false', () => { + // Drives the falsey side of `opts.denials !== false ? agentscoreDenialSchemas() : {}`. + const snippets = agentscoreOpenApiSnippets({ denials: false }); + expect(snippets.schemas).toBeDefined(); + expect(snippets.schemas).toHaveProperty('AgentScorePaymentRequired'); + expect(snippets.schemas).not.toHaveProperty('AgentScoreDenialReason'); + expect(snippets.schemas).not.toHaveProperty('AgentScoreDenialBody'); + }); + + it('emits only denial schemas when paymentRequired=false', () => { + // Drives the falsey side of `opts.paymentRequired !== false ? agentscorePaymentRequiredSchema() : {}`. + const snippets = agentscoreOpenApiSnippets({ paymentRequired: false }); + expect(snippets.schemas).toBeDefined(); + expect(snippets.schemas).toHaveProperty('AgentScoreDenialReason'); + expect(snippets.schemas).toHaveProperty('AgentScoreDenialBody'); + expect(snippets.schemas).not.toHaveProperty('AgentScorePaymentRequired'); + }); }); diff --git a/tests/discovery/robots_tag.test.ts b/tests/discovery/robots_tag.test.ts index 2bf7cf8..53875fa 100644 --- a/tests/discovery/robots_tag.test.ts +++ b/tests/discovery/robots_tag.test.ts @@ -133,6 +133,18 @@ describe('noindexNonDiscoveryPathsExpress', () => { ); expect(headers['X-Robots-Tag']).toBeUndefined(); }); + + it('honors customPaths as additive discovery surfaces', () => { + // Drives the truthy `options?.customPaths` branch in Express. + const mw = noindexNonDiscoveryPathsExpress({ customPaths: ['/sitemap.xml'] }); + const headers: Record = {}; + mw( + { path: '/sitemap.xml' }, + { setHeader: (k, v) => { headers[k] = v; } }, + () => {}, + ); + expect(headers['X-Robots-Tag']).toBeUndefined(); + }); }); describe('noindexNonDiscoveryPathsFastify', () => { @@ -165,6 +177,40 @@ describe('noindexNonDiscoveryPathsFastify', () => { ); expect(discoveryHeaders['X-Robots-Tag']).toBeUndefined(); }); + + it('honors customPaths and falls back to routerPath when url is absent', () => { + // Drives the truthy `options?.customPaths` branch in Fastify, plus the + // `req.url ?? req.routerPath` fallback when url is undefined. + let registeredHook: FastifyHook | undefined; + const fakeApp = { + addHook: (event: 'onRequest', handler: FastifyHook) => { + if (event === 'onRequest') registeredHook = handler; + }, + }; + noindexNonDiscoveryPathsFastify( + fakeApp, + { customPaths: ['/sitemap.xml'] }, + () => {}, + ); + + // url undefined → routerPath used; routerPath is in customPaths so noindex is SKIPPED. + const customHeaders: Record = {}; + registeredHook!( + { routerPath: '/sitemap.xml' } as { url?: string }, + { header: (k: string, v: string) => { customHeaders[k] = v; } }, + () => {}, + ); + expect(customHeaders['X-Robots-Tag']).toBeUndefined(); + + // Both url and routerPath undefined → empty path, not in defaults → noindex applied. + const emptyHeaders: Record = {}; + registeredHook!( + {} as { url?: string }, + { header: (k: string, v: string) => { emptyHeaders[k] = v; } }, + () => {}, + ); + expect(emptyHeaders['X-Robots-Tag']).toBe('noindex, nofollow, noarchive, nosnippet'); + }); }); describe('wrapNoindexResponse / applyNoindexHeader (Web Fetch + Next.js)', () => { @@ -184,4 +230,12 @@ describe('wrapNoindexResponse / applyNoindexHeader (Web Fetch + Next.js)', () => it('applyNoindexHeader is the same helper (Next.js alias)', () => { expect(applyNoindexHeader).toBe(wrapNoindexResponse); }); + + it('honors customPaths so a wrapped non-default path skips noindex', () => { + // Drives the truthy `options?.customPaths` branch in wrapNoindexResponse. + const original = Response.json({ ok: true }); + const wrapped = wrapNoindexResponse('/sitemap.xml', original, { customPaths: ['/sitemap.xml'] }); + expect(wrapped).toBe(original); + expect(wrapped.headers.get('x-robots-tag')).toBeNull(); + }); }); diff --git a/tests/fixtures/cross-lang/node-capability.json b/tests/fixtures/cross-lang/node-capability.json new file mode 100644 index 0000000..a06e437 --- /dev/null +++ b/tests/fixtures/cross-lang/node-capability.json @@ -0,0 +1,69 @@ +{ + "profile": { + "ucp": { + "version": "2026-04-17", + "services": { + "dev.ucp.shopping": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/2026-04-08/specification/overview", + "transport": "mcp", + "endpoint": "https://c.example.com/api/ucp/mcp", + "schema": "https://ucp.dev/services/shopping/openrpc.json" + } + ] + }, + "capabilities": { + "sh.agentscore.identity": [ + { + "version": "1", + "spec": "https://agentscore.sh/specification/identity", + "schema": "https://agentscore.sh/schemas/ucp/sh-agentscore-identity-v1.json", + "kyc_required": true + } + ] + }, + "payment_handlers": { + "sh.agentscore.payment.tempo": [ + { + "id": "tempo", + "version": "2026-04-08", + "spec": "https://agentscore.sh/specification/payment-handlers/tempo", + "schema": "https://agentscore.sh/schemas/payment-handlers/tempo.json", + "config": { + "rail": "tempo-mainnet", + "chain_id": 4217 + } + } + ] + }, + "name": "Capability Merchant" + }, + "signing_keys": [ + { + "kid": "node-capability-EdDSA", + "alg": "EdDSA", + "use": "sig", + "crv": "Ed25519", + "kty": "OKP", + "x": "IuEDuQu_5--c_GVEaY4x0xjGbKro965U5VGyRY8TxpI" + } + ], + "signature": "eyJhbGciOiJFZERTQSIsImtpZCI6Im5vZGUtY2FwYWJpbGl0eS1FZERTQSIsInR5cCI6ImFnZW50c2NvcmUtcHJvZmlsZStqd3MifQ.eyJzaWduaW5nX2tleXMiOlt7ImFsZyI6IkVkRFNBIiwiY3J2IjoiRWQyNTUxOSIsImtpZCI6Im5vZGUtY2FwYWJpbGl0eS1FZERTQSIsImt0eSI6Ik9LUCIsInVzZSI6InNpZyIsIngiOiJJdUVEdVF1XzUtLWNfR1ZFYVk0eDB4akdiS3JvOTY1VTVWR3lSWThUeHBJIn1dLCJ1Y3AiOnsiY2FwYWJpbGl0aWVzIjp7InNoLmFnZW50c2NvcmUuaWRlbnRpdHkiOlt7Imt5Y19yZXF1aXJlZCI6dHJ1ZSwic2NoZW1hIjoiaHR0cHM6Ly9hZ2VudHNjb3JlLnNoL3NjaGVtYXMvdWNwL3NoLWFnZW50c2NvcmUtaWRlbnRpdHktdjEuanNvbiIsInNwZWMiOiJodHRwczovL2FnZW50c2NvcmUuc2gvc3BlY2lmaWNhdGlvbi9pZGVudGl0eSIsInZlcnNpb24iOiIxIn1dfSwibmFtZSI6IkNhcGFiaWxpdHkgTWVyY2hhbnQiLCJwYXltZW50X2hhbmRsZXJzIjp7InNoLmFnZW50c2NvcmUucGF5bWVudC50ZW1wbyI6W3siY29uZmlnIjp7ImNoYWluX2lkIjo0MjE3LCJyYWlsIjoidGVtcG8tbWFpbm5ldCJ9LCJpZCI6InRlbXBvIiwic2NoZW1hIjoiaHR0cHM6Ly9hZ2VudHNjb3JlLnNoL3NjaGVtYXMvcGF5bWVudC1oYW5kbGVycy90ZW1wby5qc29uIiwic3BlYyI6Imh0dHBzOi8vYWdlbnRzY29yZS5zaC9zcGVjaWZpY2F0aW9uL3BheW1lbnQtaGFuZGxlcnMvdGVtcG8iLCJ2ZXJzaW9uIjoiMjAyNi0wNC0wOCJ9XX0sInNlcnZpY2VzIjp7ImRldi51Y3Auc2hvcHBpbmciOlt7ImVuZHBvaW50IjoiaHR0cHM6Ly9jLmV4YW1wbGUuY29tL2FwaS91Y3AvbWNwIiwic2NoZW1hIjoiaHR0cHM6Ly91Y3AuZGV2L3NlcnZpY2VzL3Nob3BwaW5nL29wZW5ycGMuanNvbiIsInNwZWMiOiJodHRwczovL3VjcC5kZXYvMjAyNi0wNC0wOC9zcGVjaWZpY2F0aW9uL292ZXJ2aWV3IiwidHJhbnNwb3J0IjoibWNwIiwidmVyc2lvbiI6IjIwMjYtMDQtMDgifV19LCJ2ZXJzaW9uIjoiMjAyNi0wNC0xNyJ9fQ.s36lpaOS-eGdTC0agCpLU_JxDLNO6nM5YjOTxJb6JoYVYzWBaflJCkWxwN6bDgdgDh-lPSY7_l7X0636TjpzCA" + }, + "jwks": { + "keys": [ + { + "kid": "node-capability-EdDSA", + "alg": "EdDSA", + "use": "sig", + "crv": "Ed25519", + "kty": "OKP", + "x": "IuEDuQu_5--c_GVEaY4x0xjGbKro965U5VGyRY8TxpI" + } + ] + }, + "alg": "EdDSA", + "kid": "node-capability-EdDSA", + "generator": "node" +} diff --git a/tests/fixtures/cross-lang/node-data-driven-claims.json b/tests/fixtures/cross-lang/node-data-driven-claims.json new file mode 100644 index 0000000..c59119e --- /dev/null +++ b/tests/fixtures/cross-lang/node-data-driven-claims.json @@ -0,0 +1,69 @@ +{ + "profile": { + "ucp": { + "version": "2026-04-17", + "services": { + "dev.ucp.shopping": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/2026-04-08/specification/overview", + "transport": "mcp", + "endpoint": "https://d.example.com/api/ucp/mcp", + "schema": "https://ucp.dev/services/shopping/openrpc.json" + } + ] + }, + "capabilities": { + "sh.agentscore.identity": [ + { + "version": "1", + "spec": "https://agentscore.sh/specification/identity", + "schema": "https://agentscore.sh/schemas/ucp/sh-agentscore-identity-v1.json", + "extends": [ + "dev.ucp.shopping.checkout", + "dev.ucp.shopping.cart" + ], + "claims": { + "operator_id": "op_data_driven", + "kyc_level": "none", + "sanctions_clear": false, + "age_bracket": "unknown", + "jurisdiction": "", + "verified_at": null, + "verify_url": "https://agentscore.sh/verify/op_data_driven", + "issuer": "https://agentscore.sh" + } + } + ] + }, + "payment_handlers": {}, + "name": "Data Driven Claims Merchant" + }, + "signing_keys": [ + { + "kid": "node-data-driven-claims-EdDSA", + "alg": "EdDSA", + "use": "sig", + "crv": "Ed25519", + "kty": "OKP", + "x": "t9ul3BiA3r0fugZcbcEcyARb8SAH_-4dalE3sjaVMKc" + } + ], + "signature": "eyJhbGciOiJFZERTQSIsImtpZCI6Im5vZGUtZGF0YS1kcml2ZW4tY2xhaW1zLUVkRFNBIiwidHlwIjoiYWdlbnRzY29yZS1wcm9maWxlK2p3cyJ9.eyJzaWduaW5nX2tleXMiOlt7ImFsZyI6IkVkRFNBIiwiY3J2IjoiRWQyNTUxOSIsImtpZCI6Im5vZGUtZGF0YS1kcml2ZW4tY2xhaW1zLUVkRFNBIiwia3R5IjoiT0tQIiwidXNlIjoic2lnIiwieCI6InQ5dWwzQmlBM3IwZnVnWmNiY0VjeUFSYjhTQUhfLTRkYWxFM3NqYVZNS2MifV0sInVjcCI6eyJjYXBhYmlsaXRpZXMiOnsic2guYWdlbnRzY29yZS5pZGVudGl0eSI6W3siY2xhaW1zIjp7ImFnZV9icmFja2V0IjoidW5rbm93biIsImlzc3VlciI6Imh0dHBzOi8vYWdlbnRzY29yZS5zaCIsImp1cmlzZGljdGlvbiI6IiIsImt5Y19sZXZlbCI6Im5vbmUiLCJvcGVyYXRvcl9pZCI6Im9wX2RhdGFfZHJpdmVuIiwic2FuY3Rpb25zX2NsZWFyIjpmYWxzZSwidmVyaWZpZWRfYXQiOm51bGwsInZlcmlmeV91cmwiOiJodHRwczovL2FnZW50c2NvcmUuc2gvdmVyaWZ5L29wX2RhdGFfZHJpdmVuIn0sImV4dGVuZHMiOlsiZGV2LnVjcC5zaG9wcGluZy5jaGVja291dCIsImRldi51Y3Auc2hvcHBpbmcuY2FydCJdLCJzY2hlbWEiOiJodHRwczovL2FnZW50c2NvcmUuc2gvc2NoZW1hcy91Y3Avc2gtYWdlbnRzY29yZS1pZGVudGl0eS12MS5qc29uIiwic3BlYyI6Imh0dHBzOi8vYWdlbnRzY29yZS5zaC9zcGVjaWZpY2F0aW9uL2lkZW50aXR5IiwidmVyc2lvbiI6IjEifV19LCJuYW1lIjoiRGF0YSBEcml2ZW4gQ2xhaW1zIE1lcmNoYW50IiwicGF5bWVudF9oYW5kbGVycyI6e30sInNlcnZpY2VzIjp7ImRldi51Y3Auc2hvcHBpbmciOlt7ImVuZHBvaW50IjoiaHR0cHM6Ly9kLmV4YW1wbGUuY29tL2FwaS91Y3AvbWNwIiwic2NoZW1hIjoiaHR0cHM6Ly91Y3AuZGV2L3NlcnZpY2VzL3Nob3BwaW5nL29wZW5ycGMuanNvbiIsInNwZWMiOiJodHRwczovL3VjcC5kZXYvMjAyNi0wNC0wOC9zcGVjaWZpY2F0aW9uL292ZXJ2aWV3IiwidHJhbnNwb3J0IjoibWNwIiwidmVyc2lvbiI6IjIwMjYtMDQtMDgifV19LCJ2ZXJzaW9uIjoiMjAyNi0wNC0xNyJ9fQ.8MSGbttC6ITB1vEYr0Wq8kSRniYAV25gMT7jahMGKIJfcE-rBGTukPFpXzpBWUNkSWOW4ihkOvTA5Wxws4NjDA" + }, + "jwks": { + "keys": [ + { + "kid": "node-data-driven-claims-EdDSA", + "alg": "EdDSA", + "use": "sig", + "crv": "Ed25519", + "kty": "OKP", + "x": "t9ul3BiA3r0fugZcbcEcyARb8SAH_-4dalE3sjaVMKc" + } + ] + }, + "alg": "EdDSA", + "kid": "node-data-driven-claims-EdDSA", + "generator": "node" +} diff --git a/tests/fixtures/cross-lang/node-emoji-keys.json b/tests/fixtures/cross-lang/node-emoji-keys.json new file mode 100644 index 0000000..ca325f0 --- /dev/null +++ b/tests/fixtures/cross-lang/node-emoji-keys.json @@ -0,0 +1,60 @@ +{ + "profile": { + "ucp": { + "version": "2026-04-17", + "services": { + "dev.ucp.shopping": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/2026-04-08/specification/overview", + "transport": "mcp", + "endpoint": "https://emoji.example.com/api/ucp/mcp", + "schema": "https://ucp.dev/services/shopping/openrpc.json" + } + ] + }, + "capabilities": {}, + "payment_handlers": { + "sh.agentscore.payment.tempo": [ + { + "id": "tempo", + "version": "2026-04-08", + "spec": "https://agentscore.sh/specification/payment-handlers/tempo", + "schema": "https://agentscore.sh/schemas/payment-handlers/tempo.json" + } + ] + }, + "name": "Emoji Keys Merchant" + }, + "signing_keys": [ + { + "kid": "node-emoji-keys-EdDSA", + "alg": "EdDSA", + "use": "sig", + "crv": "Ed25519", + "kty": "OKP", + "x": "O5o3d9qQsgo-eDXV9rnt-saHwzpiitL4kTcVxGr6mjE" + } + ], + "a": 1, + "豈": 2, + "": 3, + "🍷": 4, + "signature": "eyJhbGciOiJFZERTQSIsImtpZCI6Im5vZGUtZW1vamkta2V5cy1FZERTQSIsInR5cCI6ImFnZW50c2NvcmUtcHJvZmlsZStqd3MifQ.eyIiOjMsImEiOjEsInNpZ25pbmdfa2V5cyI6W3siYWxnIjoiRWREU0EiLCJjcnYiOiJFZDI1NTE5Iiwia2lkIjoibm9kZS1lbW9qaS1rZXlzLUVkRFNBIiwia3R5IjoiT0tQIiwidXNlIjoic2lnIiwieCI6Ik81bzNkOXFRc2dvLWVEWFY5cm50LXNhSHd6cGlpdEw0a1RjVnhHcjZtakUifV0sInVjcCI6eyJjYXBhYmlsaXRpZXMiOnt9LCJuYW1lIjoiRW1vamkgS2V5cyBNZXJjaGFudCIsInBheW1lbnRfaGFuZGxlcnMiOnsic2guYWdlbnRzY29yZS5wYXltZW50LnRlbXBvIjpbeyJpZCI6InRlbXBvIiwic2NoZW1hIjoiaHR0cHM6Ly9hZ2VudHNjb3JlLnNoL3NjaGVtYXMvcGF5bWVudC1oYW5kbGVycy90ZW1wby5qc29uIiwic3BlYyI6Imh0dHBzOi8vYWdlbnRzY29yZS5zaC9zcGVjaWZpY2F0aW9uL3BheW1lbnQtaGFuZGxlcnMvdGVtcG8iLCJ2ZXJzaW9uIjoiMjAyNi0wNC0wOCJ9XX0sInNlcnZpY2VzIjp7ImRldi51Y3Auc2hvcHBpbmciOlt7ImVuZHBvaW50IjoiaHR0cHM6Ly9lbW9qaS5leGFtcGxlLmNvbS9hcGkvdWNwL21jcCIsInNjaGVtYSI6Imh0dHBzOi8vdWNwLmRldi9zZXJ2aWNlcy9zaG9wcGluZy9vcGVucnBjLmpzb24iLCJzcGVjIjoiaHR0cHM6Ly91Y3AuZGV2LzIwMjYtMDQtMDgvc3BlY2lmaWNhdGlvbi9vdmVydmlldyIsInRyYW5zcG9ydCI6Im1jcCIsInZlcnNpb24iOiIyMDI2LTA0LTA4In1dfSwidmVyc2lvbiI6IjIwMjYtMDQtMTcifSwi6LGIIjoyLCLwn423Ijo0fQ.a-34-eGa5zJtMxXiefamLIcm4UM_Wix1XpHcJRXcM8Fs1Lx3ErLxLl-pdgyveDP1DVel7FmaSXJJuANSRvB4Bw" + }, + "jwks": { + "keys": [ + { + "kid": "node-emoji-keys-EdDSA", + "alg": "EdDSA", + "use": "sig", + "crv": "Ed25519", + "kty": "OKP", + "x": "O5o3d9qQsgo-eDXV9rnt-saHwzpiitL4kTcVxGr6mjE" + } + ] + }, + "alg": "EdDSA", + "kid": "node-emoji-keys-EdDSA", + "generator": "node" +} diff --git a/tests/fixtures/cross-lang/node-es256-rails.json b/tests/fixtures/cross-lang/node-es256-rails.json new file mode 100644 index 0000000..c8bd844 --- /dev/null +++ b/tests/fixtures/cross-lang/node-es256-rails.json @@ -0,0 +1,81 @@ +{ + "profile": { + "ucp": { + "version": "2026-04-17", + "services": { + "dev.ucp.shopping": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/2026-04-08/specification/overview", + "transport": "mcp", + "endpoint": "https://a.example.com/api/ucp/mcp", + "schema": "https://ucp.dev/services/shopping/openrpc.json" + }, + { + "version": "2026-04-08", + "spec": "https://ucp.dev/2026-04-08/specification/overview", + "transport": "a2a", + "endpoint": "https://a.example.com/.well-known/agent-card.json" + } + ] + }, + "capabilities": {}, + "payment_handlers": { + "sh.agentscore.payment.tempo": [ + { + "id": "tempo", + "version": "2026-04-08", + "spec": "https://agentscore.sh/specification/payment-handlers/tempo", + "schema": "https://agentscore.sh/schemas/payment-handlers/tempo.json", + "config": { + "rail": "tempo-mainnet", + "chain_id": 4217 + } + } + ], + "sh.agentscore.payment.x402": [ + { + "id": "x402", + "version": "2026-04-08", + "spec": "https://agentscore.sh/specification/payment-handlers/x402", + "schema": "https://agentscore.sh/schemas/payment-handlers/x402.json", + "config": { + "networks": [ + "base-8453" + ] + } + } + ] + }, + "name": "ES256 Merchant" + }, + "signing_keys": [ + { + "kid": "node-es256-rails-ES256", + "alg": "ES256", + "use": "sig", + "crv": "P-256", + "kty": "EC", + "x": "YJlpUMxCjw_uFVaklMcPBroRAAyWRFBb6hogNbBzwqc", + "y": "RPRH4k6hBTqEX0-Wf9s2y3VAFcwtYDnZz53Y-3G-Vl8" + } + ], + "signature": "eyJhbGciOiJFUzI1NiIsImtpZCI6Im5vZGUtZXMyNTYtcmFpbHMtRVMyNTYiLCJ0eXAiOiJhZ2VudHNjb3JlLXByb2ZpbGUrandzIn0.eyJzaWduaW5nX2tleXMiOlt7ImFsZyI6IkVTMjU2IiwiY3J2IjoiUC0yNTYiLCJraWQiOiJub2RlLWVzMjU2LXJhaWxzLUVTMjU2Iiwia3R5IjoiRUMiLCJ1c2UiOiJzaWciLCJ4IjoiWUpscFVNeENqd191RlZha2xNY1BCcm9SQUF5V1JGQmI2aG9nTmJCendxYyIsInkiOiJSUFJINGs2aEJUcUVYMC1XZjlzMnkzVkFGY3d0WURuWno1M1ktM0ctVmw4In1dLCJ1Y3AiOnsiY2FwYWJpbGl0aWVzIjp7fSwibmFtZSI6IkVTMjU2IE1lcmNoYW50IiwicGF5bWVudF9oYW5kbGVycyI6eyJzaC5hZ2VudHNjb3JlLnBheW1lbnQudGVtcG8iOlt7ImNvbmZpZyI6eyJjaGFpbl9pZCI6NDIxNywicmFpbCI6InRlbXBvLW1haW5uZXQifSwiaWQiOiJ0ZW1wbyIsInNjaGVtYSI6Imh0dHBzOi8vYWdlbnRzY29yZS5zaC9zY2hlbWFzL3BheW1lbnQtaGFuZGxlcnMvdGVtcG8uanNvbiIsInNwZWMiOiJodHRwczovL2FnZW50c2NvcmUuc2gvc3BlY2lmaWNhdGlvbi9wYXltZW50LWhhbmRsZXJzL3RlbXBvIiwidmVyc2lvbiI6IjIwMjYtMDQtMDgifV0sInNoLmFnZW50c2NvcmUucGF5bWVudC54NDAyIjpbeyJjb25maWciOnsibmV0d29ya3MiOlsiYmFzZS04NDUzIl19LCJpZCI6Ing0MDIiLCJzY2hlbWEiOiJodHRwczovL2FnZW50c2NvcmUuc2gvc2NoZW1hcy9wYXltZW50LWhhbmRsZXJzL3g0MDIuanNvbiIsInNwZWMiOiJodHRwczovL2FnZW50c2NvcmUuc2gvc3BlY2lmaWNhdGlvbi9wYXltZW50LWhhbmRsZXJzL3g0MDIiLCJ2ZXJzaW9uIjoiMjAyNi0wNC0wOCJ9XX0sInNlcnZpY2VzIjp7ImRldi51Y3Auc2hvcHBpbmciOlt7ImVuZHBvaW50IjoiaHR0cHM6Ly9hLmV4YW1wbGUuY29tL2FwaS91Y3AvbWNwIiwic2NoZW1hIjoiaHR0cHM6Ly91Y3AuZGV2L3NlcnZpY2VzL3Nob3BwaW5nL29wZW5ycGMuanNvbiIsInNwZWMiOiJodHRwczovL3VjcC5kZXYvMjAyNi0wNC0wOC9zcGVjaWZpY2F0aW9uL292ZXJ2aWV3IiwidHJhbnNwb3J0IjoibWNwIiwidmVyc2lvbiI6IjIwMjYtMDQtMDgifSx7ImVuZHBvaW50IjoiaHR0cHM6Ly9hLmV4YW1wbGUuY29tLy53ZWxsLWtub3duL2FnZW50LWNhcmQuanNvbiIsInNwZWMiOiJodHRwczovL3VjcC5kZXYvMjAyNi0wNC0wOC9zcGVjaWZpY2F0aW9uL292ZXJ2aWV3IiwidHJhbnNwb3J0IjoiYTJhIiwidmVyc2lvbiI6IjIwMjYtMDQtMDgifV19LCJ2ZXJzaW9uIjoiMjAyNi0wNC0xNyJ9fQ.lhet7Dek3XSboG8lxyoGEc4-6kEQqwkxXbR2qqKGdKlB4aoXmHrN0hpQZSzzfqKpjwN_I7VgZiKZOteTqhKrMQ" + }, + "jwks": { + "keys": [ + { + "kid": "node-es256-rails-ES256", + "alg": "ES256", + "use": "sig", + "crv": "P-256", + "kty": "EC", + "x": "YJlpUMxCjw_uFVaklMcPBroRAAyWRFBb6hogNbBzwqc", + "y": "RPRH4k6hBTqEX0-Wf9s2y3VAFcwtYDnZz53Y-3G-Vl8" + } + ] + }, + "alg": "ES256", + "kid": "node-es256-rails-ES256", + "generator": "node" +} diff --git a/tests/fixtures/cross-lang/node-extras-int.json b/tests/fixtures/cross-lang/node-extras-int.json new file mode 100644 index 0000000..60f17ec --- /dev/null +++ b/tests/fixtures/cross-lang/node-extras-int.json @@ -0,0 +1,60 @@ +{ + "profile": { + "ucp": { + "version": "2026-04-17", + "services": { + "dev.ucp.shopping": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/2026-04-08/specification/overview", + "transport": "mcp", + "endpoint": "https://e.example.com/api/ucp/mcp", + "schema": "https://ucp.dev/services/shopping/openrpc.json" + } + ] + }, + "capabilities": {}, + "payment_handlers": { + "sh.agentscore.payment.stripe-spt": [ + { + "id": "stripe", + "version": "2026-04-08", + "spec": "https://agentscore.sh/specification/payment-handlers/stripe-spt", + "schema": "https://agentscore.sh/schemas/payment-handlers/stripe-spt.json", + "config": { + "profile_id": "abc", + "count": 7 + } + } + ] + }, + "name": "Extras Merchant" + }, + "signing_keys": [ + { + "kid": "node-extras-int-EdDSA", + "alg": "EdDSA", + "use": "sig", + "crv": "Ed25519", + "kty": "OKP", + "x": "LwGeYhxjsedo9kllWo8uRdHZnf9teSPjEGLJrhF9o0M" + } + ], + "signature": "eyJhbGciOiJFZERTQSIsImtpZCI6Im5vZGUtZXh0cmFzLWludC1FZERTQSIsInR5cCI6ImFnZW50c2NvcmUtcHJvZmlsZStqd3MifQ.eyJzaWduaW5nX2tleXMiOlt7ImFsZyI6IkVkRFNBIiwiY3J2IjoiRWQyNTUxOSIsImtpZCI6Im5vZGUtZXh0cmFzLWludC1FZERTQSIsImt0eSI6Ik9LUCIsInVzZSI6InNpZyIsIngiOiJMd0dlWWh4anNlZG85a2xsV284dVJkSFpuZjl0ZVNQakVHTEpyaEY5bzBNIn1dLCJ1Y3AiOnsiY2FwYWJpbGl0aWVzIjp7fSwibmFtZSI6IkV4dHJhcyBNZXJjaGFudCIsInBheW1lbnRfaGFuZGxlcnMiOnsic2guYWdlbnRzY29yZS5wYXltZW50LnN0cmlwZS1zcHQiOlt7ImNvbmZpZyI6eyJjb3VudCI6NywicHJvZmlsZV9pZCI6ImFiYyJ9LCJpZCI6InN0cmlwZSIsInNjaGVtYSI6Imh0dHBzOi8vYWdlbnRzY29yZS5zaC9zY2hlbWFzL3BheW1lbnQtaGFuZGxlcnMvc3RyaXBlLXNwdC5qc29uIiwic3BlYyI6Imh0dHBzOi8vYWdlbnRzY29yZS5zaC9zcGVjaWZpY2F0aW9uL3BheW1lbnQtaGFuZGxlcnMvc3RyaXBlLXNwdCIsInZlcnNpb24iOiIyMDI2LTA0LTA4In1dfSwic2VydmljZXMiOnsiZGV2LnVjcC5zaG9wcGluZyI6W3siZW5kcG9pbnQiOiJodHRwczovL2UuZXhhbXBsZS5jb20vYXBpL3VjcC9tY3AiLCJzY2hlbWEiOiJodHRwczovL3VjcC5kZXYvc2VydmljZXMvc2hvcHBpbmcvb3BlbnJwYy5qc29uIiwic3BlYyI6Imh0dHBzOi8vdWNwLmRldi8yMDI2LTA0LTA4L3NwZWNpZmljYXRpb24vb3ZlcnZpZXciLCJ0cmFuc3BvcnQiOiJtY3AiLCJ2ZXJzaW9uIjoiMjAyNi0wNC0wOCJ9XX0sInZlcnNpb24iOiIyMDI2LTA0LTE3In19.s6sBki-bBhRuZNZiv7s7NO3NpLDfhoXhZXKcK2uitVGiBh9nANv-pi8L-nAIBte8jN_DFoeqtWQJiAK188XqBg" + }, + "jwks": { + "keys": [ + { + "kid": "node-extras-int-EdDSA", + "alg": "EdDSA", + "use": "sig", + "crv": "Ed25519", + "kty": "OKP", + "x": "LwGeYhxjsedo9kllWo8uRdHZnf9teSPjEGLJrhF9o0M" + } + ] + }, + "alg": "EdDSA", + "kid": "node-extras-int-EdDSA", + "generator": "node" +} diff --git a/tests/fixtures/cross-lang/node-int-boundary.json b/tests/fixtures/cross-lang/node-int-boundary.json new file mode 100644 index 0000000..74dd819 --- /dev/null +++ b/tests/fixtures/cross-lang/node-int-boundary.json @@ -0,0 +1,52 @@ +{ + "profile": { + "ucp": { + "version": "2026-04-17", + "services": { + "dev.ucp.shopping": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/2026-04-08/specification/overview", + "transport": "mcp", + "endpoint": "https://i.example.com/api/ucp/mcp", + "schema": "https://ucp.dev/services/shopping/openrpc.json" + } + ] + }, + "capabilities": {}, + "payment_handlers": {}, + "name": "Int Boundary Merchant" + }, + "signing_keys": [ + { + "kid": "node-int-boundary-EdDSA", + "alg": "EdDSA", + "use": "sig", + "crv": "Ed25519", + "kty": "OKP", + "x": "vmNTcQKo5jUIpTVnWRSkLu-s7cUoNO_OfPJTctAOhR4" + } + ], + "max_safe_int": 9007199254740991, + "min_safe_int": -9007199254740991, + "small_int": 42, + "neg_small_int": -42, + "zero": 0, + "signature": "eyJhbGciOiJFZERTQSIsImtpZCI6Im5vZGUtaW50LWJvdW5kYXJ5LUVkRFNBIiwidHlwIjoiYWdlbnRzY29yZS1wcm9maWxlK2p3cyJ9.eyJtYXhfc2FmZV9pbnQiOjkwMDcxOTkyNTQ3NDA5OTEsIm1pbl9zYWZlX2ludCI6LTkwMDcxOTkyNTQ3NDA5OTEsIm5lZ19zbWFsbF9pbnQiOi00Miwic2lnbmluZ19rZXlzIjpbeyJhbGciOiJFZERTQSIsImNydiI6IkVkMjU1MTkiLCJraWQiOiJub2RlLWludC1ib3VuZGFyeS1FZERTQSIsImt0eSI6Ik9LUCIsInVzZSI6InNpZyIsIngiOiJ2bU5UY1FLbzVqVUlwVFZuV1JTa0x1LXM3Y1VvTk9fT2ZQSlRjdEFPaFI0In1dLCJzbWFsbF9pbnQiOjQyLCJ1Y3AiOnsiY2FwYWJpbGl0aWVzIjp7fSwibmFtZSI6IkludCBCb3VuZGFyeSBNZXJjaGFudCIsInBheW1lbnRfaGFuZGxlcnMiOnt9LCJzZXJ2aWNlcyI6eyJkZXYudWNwLnNob3BwaW5nIjpbeyJlbmRwb2ludCI6Imh0dHBzOi8vaS5leGFtcGxlLmNvbS9hcGkvdWNwL21jcCIsInNjaGVtYSI6Imh0dHBzOi8vdWNwLmRldi9zZXJ2aWNlcy9zaG9wcGluZy9vcGVucnBjLmpzb24iLCJzcGVjIjoiaHR0cHM6Ly91Y3AuZGV2LzIwMjYtMDQtMDgvc3BlY2lmaWNhdGlvbi9vdmVydmlldyIsInRyYW5zcG9ydCI6Im1jcCIsInZlcnNpb24iOiIyMDI2LTA0LTA4In1dfSwidmVyc2lvbiI6IjIwMjYtMDQtMTcifSwiemVybyI6MH0.iIsGfdlC2ZqMh3ouvz86u4QmGS0d-JR9KyTcUNoMTnqbt0P63PBJ7lXCoZ64DY4XtFJ83sPzSrOIzvdsbrOvBQ" + }, + "jwks": { + "keys": [ + { + "kid": "node-int-boundary-EdDSA", + "alg": "EdDSA", + "use": "sig", + "crv": "Ed25519", + "kty": "OKP", + "x": "vmNTcQKo5jUIpTVnWRSkLu-s7cUoNO_OfPJTctAOhR4" + } + ] + }, + "alg": "EdDSA", + "kid": "node-int-boundary-EdDSA", + "generator": "node" +} diff --git a/tests/fixtures/cross-lang/node-minimal.json b/tests/fixtures/cross-lang/node-minimal.json new file mode 100644 index 0000000..32eef05 --- /dev/null +++ b/tests/fixtures/cross-lang/node-minimal.json @@ -0,0 +1,47 @@ +{ + "profile": { + "ucp": { + "version": "2026-04-17", + "services": { + "dev.ucp.shopping": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/2026-04-08/specification/overview", + "transport": "mcp", + "endpoint": "https://m.example.com/api/ucp/mcp", + "schema": "https://ucp.dev/services/shopping/openrpc.json" + } + ] + }, + "capabilities": {}, + "payment_handlers": {}, + "name": "Minimal Merchant" + }, + "signing_keys": [ + { + "kid": "node-minimal-EdDSA", + "alg": "EdDSA", + "use": "sig", + "crv": "Ed25519", + "kty": "OKP", + "x": "69RWgrarCEN0sSH5FfkJ2-miQQNRpXYh0wt9kviqzqk" + } + ], + "signature": "eyJhbGciOiJFZERTQSIsImtpZCI6Im5vZGUtbWluaW1hbC1FZERTQSIsInR5cCI6ImFnZW50c2NvcmUtcHJvZmlsZStqd3MifQ.eyJzaWduaW5nX2tleXMiOlt7ImFsZyI6IkVkRFNBIiwiY3J2IjoiRWQyNTUxOSIsImtpZCI6Im5vZGUtbWluaW1hbC1FZERTQSIsImt0eSI6Ik9LUCIsInVzZSI6InNpZyIsIngiOiI2OVJXZ3JhckNFTjBzU0g1RmZrSjItbWlRUU5ScFhZaDB3dDlrdmlxenFrIn1dLCJ1Y3AiOnsiY2FwYWJpbGl0aWVzIjp7fSwibmFtZSI6Ik1pbmltYWwgTWVyY2hhbnQiLCJwYXltZW50X2hhbmRsZXJzIjp7fSwic2VydmljZXMiOnsiZGV2LnVjcC5zaG9wcGluZyI6W3siZW5kcG9pbnQiOiJodHRwczovL20uZXhhbXBsZS5jb20vYXBpL3VjcC9tY3AiLCJzY2hlbWEiOiJodHRwczovL3VjcC5kZXYvc2VydmljZXMvc2hvcHBpbmcvb3BlbnJwYy5qc29uIiwic3BlYyI6Imh0dHBzOi8vdWNwLmRldi8yMDI2LTA0LTA4L3NwZWNpZmljYXRpb24vb3ZlcnZpZXciLCJ0cmFuc3BvcnQiOiJtY3AiLCJ2ZXJzaW9uIjoiMjAyNi0wNC0wOCJ9XX0sInZlcnNpb24iOiIyMDI2LTA0LTE3In19.ei7hxM6v-gnxAkgG4NiWLwzhd9wOxg3lO9ZTFVEuSBaAho0n_GaQayO99ibjQgqa2yUa1J9PcGh3woMh7cQcAA" + }, + "jwks": { + "keys": [ + { + "kid": "node-minimal-EdDSA", + "alg": "EdDSA", + "use": "sig", + "crv": "Ed25519", + "kty": "OKP", + "x": "69RWgrarCEN0sSH5FfkJ2-miQQNRpXYh0wt9kviqzqk" + } + ] + }, + "alg": "EdDSA", + "kid": "node-minimal-EdDSA", + "generator": "node" +} diff --git a/tests/fixtures/cross-lang/node-multikey.json b/tests/fixtures/cross-lang/node-multikey.json new file mode 100644 index 0000000..5027299 --- /dev/null +++ b/tests/fixtures/cross-lang/node-multikey.json @@ -0,0 +1,75 @@ +{ + "profile": { + "ucp": { + "version": "2026-04-17", + "services": { + "dev.ucp.shopping": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/2026-04-08/specification/overview", + "transport": "mcp", + "endpoint": "https://mk.example.com/api/ucp/mcp", + "schema": "https://ucp.dev/services/shopping/openrpc.json" + } + ] + }, + "capabilities": {}, + "payment_handlers": { + "sh.agentscore.payment.tempo": [ + { + "id": "tempo", + "version": "2026-04-08", + "spec": "https://agentscore.sh/specification/payment-handlers/tempo", + "schema": "https://agentscore.sh/schemas/payment-handlers/tempo.json", + "config": { + "rail": "tempo-mainnet" + } + } + ] + }, + "name": "Multi-Key Merchant" + }, + "signing_keys": [ + { + "kid": "node-multikey-old", + "alg": "EdDSA", + "use": "sig", + "crv": "Ed25519", + "kty": "OKP", + "x": "dh_cI_8_Z79h3t5i72fKw89EwpeJiA2ELN1SnS_OgdQ" + }, + { + "kid": "node-multikey-new", + "alg": "EdDSA", + "use": "sig", + "crv": "Ed25519", + "kty": "OKP", + "x": "oxGfu9h6LckqvQ0eVkovSzUwCdGo8xLkPcq8siUoh7M" + } + ], + "signature": "eyJhbGciOiJFZERTQSIsImtpZCI6Im5vZGUtbXVsdGlrZXktbmV3IiwidHlwIjoiYWdlbnRzY29yZS1wcm9maWxlK2p3cyJ9.eyJzaWduaW5nX2tleXMiOlt7ImFsZyI6IkVkRFNBIiwiY3J2IjoiRWQyNTUxOSIsImtpZCI6Im5vZGUtbXVsdGlrZXktb2xkIiwia3R5IjoiT0tQIiwidXNlIjoic2lnIiwieCI6ImRoX2NJXzhfWjc5aDN0NWk3MmZLdzg5RXdwZUppQTJFTE4xU25TX09nZFEifSx7ImFsZyI6IkVkRFNBIiwiY3J2IjoiRWQyNTUxOSIsImtpZCI6Im5vZGUtbXVsdGlrZXktbmV3Iiwia3R5IjoiT0tQIiwidXNlIjoic2lnIiwieCI6Im94R2Z1OWg2TGNrcXZRMGVWa292U3pVd0NkR284eExrUGNxOHNpVW9oN00ifV0sInVjcCI6eyJjYXBhYmlsaXRpZXMiOnt9LCJuYW1lIjoiTXVsdGktS2V5IE1lcmNoYW50IiwicGF5bWVudF9oYW5kbGVycyI6eyJzaC5hZ2VudHNjb3JlLnBheW1lbnQudGVtcG8iOlt7ImNvbmZpZyI6eyJyYWlsIjoidGVtcG8tbWFpbm5ldCJ9LCJpZCI6InRlbXBvIiwic2NoZW1hIjoiaHR0cHM6Ly9hZ2VudHNjb3JlLnNoL3NjaGVtYXMvcGF5bWVudC1oYW5kbGVycy90ZW1wby5qc29uIiwic3BlYyI6Imh0dHBzOi8vYWdlbnRzY29yZS5zaC9zcGVjaWZpY2F0aW9uL3BheW1lbnQtaGFuZGxlcnMvdGVtcG8iLCJ2ZXJzaW9uIjoiMjAyNi0wNC0wOCJ9XX0sInNlcnZpY2VzIjp7ImRldi51Y3Auc2hvcHBpbmciOlt7ImVuZHBvaW50IjoiaHR0cHM6Ly9tay5leGFtcGxlLmNvbS9hcGkvdWNwL21jcCIsInNjaGVtYSI6Imh0dHBzOi8vdWNwLmRldi9zZXJ2aWNlcy9zaG9wcGluZy9vcGVucnBjLmpzb24iLCJzcGVjIjoiaHR0cHM6Ly91Y3AuZGV2LzIwMjYtMDQtMDgvc3BlY2lmaWNhdGlvbi9vdmVydmlldyIsInRyYW5zcG9ydCI6Im1jcCIsInZlcnNpb24iOiIyMDI2LTA0LTA4In1dfSwidmVyc2lvbiI6IjIwMjYtMDQtMTcifX0.fEq5VVrBtuwEYJGcpHuaTCVQWmS6LvcOdtS-reZGyLFosCrmok9eU86w9m79aO6k0u_CXOC_n90TvbFfuKINCA" + }, + "jwks": { + "keys": [ + { + "kid": "node-multikey-old", + "alg": "EdDSA", + "use": "sig", + "crv": "Ed25519", + "kty": "OKP", + "x": "dh_cI_8_Z79h3t5i72fKw89EwpeJiA2ELN1SnS_OgdQ" + }, + { + "kid": "node-multikey-new", + "alg": "EdDSA", + "use": "sig", + "crv": "Ed25519", + "kty": "OKP", + "x": "oxGfu9h6LckqvQ0eVkovSzUwCdGo8xLkPcq8siUoh7M" + } + ] + }, + "alg": "EdDSA", + "kid": "node-multikey-new", + "generator": "node" +} diff --git a/tests/fixtures/cross-lang/node-typed-claims.json b/tests/fixtures/cross-lang/node-typed-claims.json new file mode 100644 index 0000000..d754f4c --- /dev/null +++ b/tests/fixtures/cross-lang/node-typed-claims.json @@ -0,0 +1,69 @@ +{ + "profile": { + "ucp": { + "version": "2026-04-17", + "services": { + "dev.ucp.shopping": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/2026-04-08/specification/overview", + "transport": "mcp", + "endpoint": "https://t.example.com/api/ucp/mcp", + "schema": "https://ucp.dev/services/shopping/openrpc.json" + } + ] + }, + "capabilities": { + "sh.agentscore.identity": [ + { + "version": "1", + "spec": "https://agentscore.sh/specification/identity", + "schema": "https://agentscore.sh/schemas/ucp/sh-agentscore-identity-v1.json", + "extends": [ + "dev.ucp.shopping.checkout", + "dev.ucp.shopping.cart" + ], + "claims": { + "operator_id": "op_typed_claims", + "kyc_level": "enhanced", + "sanctions_clear": true, + "age_bracket": "21+", + "jurisdiction": "US", + "verified_at": "2026-04-01T00:00:00Z", + "verify_url": "https://agentscore.sh/verify/op_typed_claims", + "issuer": "https://agentscore.sh" + } + } + ] + }, + "payment_handlers": {}, + "name": "Typed Claims Merchant" + }, + "signing_keys": [ + { + "kid": "node-typed-claims-EdDSA", + "alg": "EdDSA", + "use": "sig", + "crv": "Ed25519", + "kty": "OKP", + "x": "6JcesuEfiy104P6W8zOsruWkL7Ju7RLXMyR2F3fQ4xM" + } + ], + "signature": "eyJhbGciOiJFZERTQSIsImtpZCI6Im5vZGUtdHlwZWQtY2xhaW1zLUVkRFNBIiwidHlwIjoiYWdlbnRzY29yZS1wcm9maWxlK2p3cyJ9.eyJzaWduaW5nX2tleXMiOlt7ImFsZyI6IkVkRFNBIiwiY3J2IjoiRWQyNTUxOSIsImtpZCI6Im5vZGUtdHlwZWQtY2xhaW1zLUVkRFNBIiwia3R5IjoiT0tQIiwidXNlIjoic2lnIiwieCI6IjZKY2VzdUVmaXkxMDRQNlc4ek9zcnVXa0w3SnU3UkxYTXlSMkYzZlE0eE0ifV0sInVjcCI6eyJjYXBhYmlsaXRpZXMiOnsic2guYWdlbnRzY29yZS5pZGVudGl0eSI6W3siY2xhaW1zIjp7ImFnZV9icmFja2V0IjoiMjErIiwiaXNzdWVyIjoiaHR0cHM6Ly9hZ2VudHNjb3JlLnNoIiwianVyaXNkaWN0aW9uIjoiVVMiLCJreWNfbGV2ZWwiOiJlbmhhbmNlZCIsIm9wZXJhdG9yX2lkIjoib3BfdHlwZWRfY2xhaW1zIiwic2FuY3Rpb25zX2NsZWFyIjp0cnVlLCJ2ZXJpZmllZF9hdCI6IjIwMjYtMDQtMDFUMDA6MDA6MDBaIiwidmVyaWZ5X3VybCI6Imh0dHBzOi8vYWdlbnRzY29yZS5zaC92ZXJpZnkvb3BfdHlwZWRfY2xhaW1zIn0sImV4dGVuZHMiOlsiZGV2LnVjcC5zaG9wcGluZy5jaGVja291dCIsImRldi51Y3Auc2hvcHBpbmcuY2FydCJdLCJzY2hlbWEiOiJodHRwczovL2FnZW50c2NvcmUuc2gvc2NoZW1hcy91Y3Avc2gtYWdlbnRzY29yZS1pZGVudGl0eS12MS5qc29uIiwic3BlYyI6Imh0dHBzOi8vYWdlbnRzY29yZS5zaC9zcGVjaWZpY2F0aW9uL2lkZW50aXR5IiwidmVyc2lvbiI6IjEifV19LCJuYW1lIjoiVHlwZWQgQ2xhaW1zIE1lcmNoYW50IiwicGF5bWVudF9oYW5kbGVycyI6e30sInNlcnZpY2VzIjp7ImRldi51Y3Auc2hvcHBpbmciOlt7ImVuZHBvaW50IjoiaHR0cHM6Ly90LmV4YW1wbGUuY29tL2FwaS91Y3AvbWNwIiwic2NoZW1hIjoiaHR0cHM6Ly91Y3AuZGV2L3NlcnZpY2VzL3Nob3BwaW5nL29wZW5ycGMuanNvbiIsInNwZWMiOiJodHRwczovL3VjcC5kZXYvMjAyNi0wNC0wOC9zcGVjaWZpY2F0aW9uL292ZXJ2aWV3IiwidHJhbnNwb3J0IjoibWNwIiwidmVyc2lvbiI6IjIwMjYtMDQtMDgifV19LCJ2ZXJzaW9uIjoiMjAyNi0wNC0xNyJ9fQ.MzebNr-eOGPHe84z8ARhjFHmSLju7AvwgsSu4KY_tmg5R_T6xYrx7tZXbYOCfiSaZoFmpIJcXPakit4c-yxMAA" + }, + "jwks": { + "keys": [ + { + "kid": "node-typed-claims-EdDSA", + "alg": "EdDSA", + "use": "sig", + "crv": "Ed25519", + "kty": "OKP", + "x": "6JcesuEfiy104P6W8zOsruWkL7Ju7RLXMyR2F3fQ4xM" + } + ] + }, + "alg": "EdDSA", + "kid": "node-typed-claims-EdDSA", + "generator": "node" +} diff --git a/tests/fixtures/cross-lang/node-unicode.json b/tests/fixtures/cross-lang/node-unicode.json new file mode 100644 index 0000000..89fb48b --- /dev/null +++ b/tests/fixtures/cross-lang/node-unicode.json @@ -0,0 +1,59 @@ +{ + "profile": { + "ucp": { + "version": "2026-04-17", + "services": { + "dev.ucp.shopping": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/2026-04-08/specification/overview", + "transport": "mcp", + "endpoint": "https://日本.example.com/api/ucp/mcp", + "schema": "https://ucp.dev/services/shopping/openrpc.json" + } + ] + }, + "capabilities": {}, + "payment_handlers": { + "sh.agentscore.payment.tempo": [ + { + "id": "tempo", + "version": "2026-04-08", + "spec": "https://agentscore.sh/specification/payment-handlers/tempo", + "schema": "https://agentscore.sh/schemas/payment-handlers/tempo.json", + "config": { + "note": "メモ" + } + } + ] + }, + "name": "Café 日本 🍷 Merchant" + }, + "signing_keys": [ + { + "kid": "node-unicode-EdDSA", + "alg": "EdDSA", + "use": "sig", + "crv": "Ed25519", + "kty": "OKP", + "x": "At1k1YXploco8YrjdagqC9HYxCnN7ommm4MWIRUp5AY" + } + ], + "signature": "eyJhbGciOiJFZERTQSIsImtpZCI6Im5vZGUtdW5pY29kZS1FZERTQSIsInR5cCI6ImFnZW50c2NvcmUtcHJvZmlsZStqd3MifQ.eyJzaWduaW5nX2tleXMiOlt7ImFsZyI6IkVkRFNBIiwiY3J2IjoiRWQyNTUxOSIsImtpZCI6Im5vZGUtdW5pY29kZS1FZERTQSIsImt0eSI6Ik9LUCIsInVzZSI6InNpZyIsIngiOiJBdDFrMVlYcGxvY284WXJqZGFncUM5SFl4Q25ON29tbW00TVdJUlVwNUFZIn1dLCJ1Y3AiOnsiY2FwYWJpbGl0aWVzIjp7fSwibmFtZSI6IkNhZsOpIOaXpeacrCDwn423IE1lcmNoYW50IiwicGF5bWVudF9oYW5kbGVycyI6eyJzaC5hZ2VudHNjb3JlLnBheW1lbnQudGVtcG8iOlt7ImNvbmZpZyI6eyJub3RlIjoi44Oh44OiIn0sImlkIjoidGVtcG8iLCJzY2hlbWEiOiJodHRwczovL2FnZW50c2NvcmUuc2gvc2NoZW1hcy9wYXltZW50LWhhbmRsZXJzL3RlbXBvLmpzb24iLCJzcGVjIjoiaHR0cHM6Ly9hZ2VudHNjb3JlLnNoL3NwZWNpZmljYXRpb24vcGF5bWVudC1oYW5kbGVycy90ZW1wbyIsInZlcnNpb24iOiIyMDI2LTA0LTA4In1dfSwic2VydmljZXMiOnsiZGV2LnVjcC5zaG9wcGluZyI6W3siZW5kcG9pbnQiOiJodHRwczovL-aXpeacrC5leGFtcGxlLmNvbS9hcGkvdWNwL21jcCIsInNjaGVtYSI6Imh0dHBzOi8vdWNwLmRldi9zZXJ2aWNlcy9zaG9wcGluZy9vcGVucnBjLmpzb24iLCJzcGVjIjoiaHR0cHM6Ly91Y3AuZGV2LzIwMjYtMDQtMDgvc3BlY2lmaWNhdGlvbi9vdmVydmlldyIsInRyYW5zcG9ydCI6Im1jcCIsInZlcnNpb24iOiIyMDI2LTA0LTA4In1dfSwidmVyc2lvbiI6IjIwMjYtMDQtMTcifX0.BKhv1J0LSZ-PQBySoMQXTAx-OalhQZSiiCXaWSHjA6HbeCvz4aw-os3p-FlAgfDoiChxRKeGfN-n-LcYIv4yAQ" + }, + "jwks": { + "keys": [ + { + "kid": "node-unicode-EdDSA", + "alg": "EdDSA", + "use": "sig", + "crv": "Ed25519", + "kty": "OKP", + "x": "At1k1YXploco8YrjdagqC9HYxCnN7ommm4MWIRUp5AY" + } + ] + }, + "alg": "EdDSA", + "kid": "node-unicode-EdDSA", + "generator": "node" +} diff --git a/tests/fixtures/cross-lang/py-capability.json b/tests/fixtures/cross-lang/py-capability.json new file mode 100644 index 0000000..af06dd8 --- /dev/null +++ b/tests/fixtures/cross-lang/py-capability.json @@ -0,0 +1,69 @@ +{ + "profile": { + "ucp": { + "version": "2026-04-17", + "services": { + "dev.ucp.shopping": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/2026-04-08/specification/overview", + "transport": "mcp", + "endpoint": "https://c.example.com/api/ucp/mcp", + "schema": "https://ucp.dev/services/shopping/openrpc.json" + } + ] + }, + "capabilities": { + "sh.agentscore.identity": [ + { + "version": "1", + "spec": "https://agentscore.sh/specification/identity", + "schema": "https://agentscore.sh/schemas/ucp/sh-agentscore-identity-v1.json", + "kyc_required": true + } + ] + }, + "payment_handlers": { + "sh.agentscore.payment.tempo": [ + { + "id": "tempo", + "version": "2026-04-08", + "spec": "https://agentscore.sh/specification/payment-handlers/tempo", + "schema": "https://agentscore.sh/schemas/payment-handlers/tempo.json", + "config": { + "rail": "tempo-mainnet", + "chain_id": 4217 + } + } + ] + }, + "name": "Capability Merchant" + }, + "signing_keys": [ + { + "kid": "py-capability-EdDSA", + "kty": "OKP", + "alg": "EdDSA", + "use": "sig", + "crv": "Ed25519", + "x": "TikhC4jSghoLfPC6j9KBytlHrgyFvZVVm5OUjG7bYCM" + } + ], + "signature": "eyJhbGciOiJFZERTQSIsImtpZCI6InB5LWNhcGFiaWxpdHktRWREU0EiLCJ0eXAiOiJhZ2VudHNjb3JlLXByb2ZpbGUrandzIn0.eyJzaWduaW5nX2tleXMiOlt7ImFsZyI6IkVkRFNBIiwiY3J2IjoiRWQyNTUxOSIsImtpZCI6InB5LWNhcGFiaWxpdHktRWREU0EiLCJrdHkiOiJPS1AiLCJ1c2UiOiJzaWciLCJ4IjoiVGlraEM0alNnaG9MZlBDNmo5S0J5dGxIcmd5RnZaVlZtNU9Vakc3YllDTSJ9XSwidWNwIjp7ImNhcGFiaWxpdGllcyI6eyJzaC5hZ2VudHNjb3JlLmlkZW50aXR5IjpbeyJreWNfcmVxdWlyZWQiOnRydWUsInNjaGVtYSI6Imh0dHBzOi8vYWdlbnRzY29yZS5zaC9zY2hlbWFzL3VjcC9zaC1hZ2VudHNjb3JlLWlkZW50aXR5LXYxLmpzb24iLCJzcGVjIjoiaHR0cHM6Ly9hZ2VudHNjb3JlLnNoL3NwZWNpZmljYXRpb24vaWRlbnRpdHkiLCJ2ZXJzaW9uIjoiMSJ9XX0sIm5hbWUiOiJDYXBhYmlsaXR5IE1lcmNoYW50IiwicGF5bWVudF9oYW5kbGVycyI6eyJzaC5hZ2VudHNjb3JlLnBheW1lbnQudGVtcG8iOlt7ImNvbmZpZyI6eyJjaGFpbl9pZCI6NDIxNywicmFpbCI6InRlbXBvLW1haW5uZXQifSwiaWQiOiJ0ZW1wbyIsInNjaGVtYSI6Imh0dHBzOi8vYWdlbnRzY29yZS5zaC9zY2hlbWFzL3BheW1lbnQtaGFuZGxlcnMvdGVtcG8uanNvbiIsInNwZWMiOiJodHRwczovL2FnZW50c2NvcmUuc2gvc3BlY2lmaWNhdGlvbi9wYXltZW50LWhhbmRsZXJzL3RlbXBvIiwidmVyc2lvbiI6IjIwMjYtMDQtMDgifV19LCJzZXJ2aWNlcyI6eyJkZXYudWNwLnNob3BwaW5nIjpbeyJlbmRwb2ludCI6Imh0dHBzOi8vYy5leGFtcGxlLmNvbS9hcGkvdWNwL21jcCIsInNjaGVtYSI6Imh0dHBzOi8vdWNwLmRldi9zZXJ2aWNlcy9zaG9wcGluZy9vcGVucnBjLmpzb24iLCJzcGVjIjoiaHR0cHM6Ly91Y3AuZGV2LzIwMjYtMDQtMDgvc3BlY2lmaWNhdGlvbi9vdmVydmlldyIsInRyYW5zcG9ydCI6Im1jcCIsInZlcnNpb24iOiIyMDI2LTA0LTA4In1dfSwidmVyc2lvbiI6IjIwMjYtMDQtMTcifX0._31-NgZEBmN2c8qyxQvOaEBrhycJ6MULjhfN3sgVp5UqiUduGp66XHQC0HI4Ni6W7CzNx2-ktZWdLWD0clPdDg" + }, + "jwks": { + "keys": [ + { + "crv": "Ed25519", + "x": "TikhC4jSghoLfPC6j9KBytlHrgyFvZVVm5OUjG7bYCM", + "kid": "py-capability-EdDSA", + "alg": "EdDSA", + "use": "sig", + "kty": "OKP" + } + ] + }, + "alg": "EdDSA", + "kid": "py-capability-EdDSA", + "generator": "python" +} diff --git a/tests/fixtures/cross-lang/py-data-driven-claims.json b/tests/fixtures/cross-lang/py-data-driven-claims.json new file mode 100644 index 0000000..5e3bb79 --- /dev/null +++ b/tests/fixtures/cross-lang/py-data-driven-claims.json @@ -0,0 +1,69 @@ +{ + "profile": { + "ucp": { + "version": "2026-04-17", + "services": { + "dev.ucp.shopping": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/2026-04-08/specification/overview", + "transport": "mcp", + "endpoint": "https://d.example.com/api/ucp/mcp", + "schema": "https://ucp.dev/services/shopping/openrpc.json" + } + ] + }, + "capabilities": { + "sh.agentscore.identity": [ + { + "version": "1", + "spec": "https://agentscore.sh/specification/identity", + "schema": "https://agentscore.sh/schemas/ucp/sh-agentscore-identity-v1.json", + "extends": [ + "dev.ucp.shopping.checkout", + "dev.ucp.shopping.cart" + ], + "claims": { + "operator_id": "op_data_driven", + "kyc_level": "none", + "sanctions_clear": false, + "age_bracket": "unknown", + "jurisdiction": "", + "verified_at": null, + "verify_url": "https://agentscore.sh/verify/op_data_driven", + "issuer": "https://agentscore.sh" + } + } + ] + }, + "payment_handlers": {}, + "name": "Data Driven Claims Merchant" + }, + "signing_keys": [ + { + "kid": "py-data-driven-claims-EdDSA", + "kty": "OKP", + "alg": "EdDSA", + "use": "sig", + "crv": "Ed25519", + "x": "g_RzTBbrZ0krF4_f4Rtm__flo_1RH2sxiTF9dLltpC8" + } + ], + "signature": "eyJhbGciOiJFZERTQSIsImtpZCI6InB5LWRhdGEtZHJpdmVuLWNsYWltcy1FZERTQSIsInR5cCI6ImFnZW50c2NvcmUtcHJvZmlsZStqd3MifQ.eyJzaWduaW5nX2tleXMiOlt7ImFsZyI6IkVkRFNBIiwiY3J2IjoiRWQyNTUxOSIsImtpZCI6InB5LWRhdGEtZHJpdmVuLWNsYWltcy1FZERTQSIsImt0eSI6Ik9LUCIsInVzZSI6InNpZyIsIngiOiJnX1J6VEJiclowa3JGNF9mNFJ0bV9fZmxvXzFSSDJzeGlURjlkTGx0cEM4In1dLCJ1Y3AiOnsiY2FwYWJpbGl0aWVzIjp7InNoLmFnZW50c2NvcmUuaWRlbnRpdHkiOlt7ImNsYWltcyI6eyJhZ2VfYnJhY2tldCI6InVua25vd24iLCJpc3N1ZXIiOiJodHRwczovL2FnZW50c2NvcmUuc2giLCJqdXJpc2RpY3Rpb24iOiIiLCJreWNfbGV2ZWwiOiJub25lIiwib3BlcmF0b3JfaWQiOiJvcF9kYXRhX2RyaXZlbiIsInNhbmN0aW9uc19jbGVhciI6ZmFsc2UsInZlcmlmaWVkX2F0IjpudWxsLCJ2ZXJpZnlfdXJsIjoiaHR0cHM6Ly9hZ2VudHNjb3JlLnNoL3ZlcmlmeS9vcF9kYXRhX2RyaXZlbiJ9LCJleHRlbmRzIjpbImRldi51Y3Auc2hvcHBpbmcuY2hlY2tvdXQiLCJkZXYudWNwLnNob3BwaW5nLmNhcnQiXSwic2NoZW1hIjoiaHR0cHM6Ly9hZ2VudHNjb3JlLnNoL3NjaGVtYXMvdWNwL3NoLWFnZW50c2NvcmUtaWRlbnRpdHktdjEuanNvbiIsInNwZWMiOiJodHRwczovL2FnZW50c2NvcmUuc2gvc3BlY2lmaWNhdGlvbi9pZGVudGl0eSIsInZlcnNpb24iOiIxIn1dfSwibmFtZSI6IkRhdGEgRHJpdmVuIENsYWltcyBNZXJjaGFudCIsInBheW1lbnRfaGFuZGxlcnMiOnt9LCJzZXJ2aWNlcyI6eyJkZXYudWNwLnNob3BwaW5nIjpbeyJlbmRwb2ludCI6Imh0dHBzOi8vZC5leGFtcGxlLmNvbS9hcGkvdWNwL21jcCIsInNjaGVtYSI6Imh0dHBzOi8vdWNwLmRldi9zZXJ2aWNlcy9zaG9wcGluZy9vcGVucnBjLmpzb24iLCJzcGVjIjoiaHR0cHM6Ly91Y3AuZGV2LzIwMjYtMDQtMDgvc3BlY2lmaWNhdGlvbi9vdmVydmlldyIsInRyYW5zcG9ydCI6Im1jcCIsInZlcnNpb24iOiIyMDI2LTA0LTA4In1dfSwidmVyc2lvbiI6IjIwMjYtMDQtMTcifX0.X7Xdu_60_sT2XpwD9SqF7Lpuf5OGlbG_t_sxaY1xf7rQID5fSR-4BEdB0Dppq04nuaedhcqGUeyTMZDfHe8YDQ" + }, + "jwks": { + "keys": [ + { + "crv": "Ed25519", + "x": "g_RzTBbrZ0krF4_f4Rtm__flo_1RH2sxiTF9dLltpC8", + "kid": "py-data-driven-claims-EdDSA", + "alg": "EdDSA", + "use": "sig", + "kty": "OKP" + } + ] + }, + "alg": "EdDSA", + "kid": "py-data-driven-claims-EdDSA", + "generator": "python" +} diff --git a/tests/fixtures/cross-lang/py-emoji-keys.json b/tests/fixtures/cross-lang/py-emoji-keys.json new file mode 100644 index 0000000..209540d --- /dev/null +++ b/tests/fixtures/cross-lang/py-emoji-keys.json @@ -0,0 +1,60 @@ +{ + "profile": { + "ucp": { + "version": "2026-04-17", + "services": { + "dev.ucp.shopping": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/2026-04-08/specification/overview", + "transport": "mcp", + "endpoint": "https://emoji.example.com/api/ucp/mcp", + "schema": "https://ucp.dev/services/shopping/openrpc.json" + } + ] + }, + "capabilities": {}, + "payment_handlers": { + "sh.agentscore.payment.tempo": [ + { + "id": "tempo", + "version": "2026-04-08", + "spec": "https://agentscore.sh/specification/payment-handlers/tempo", + "schema": "https://agentscore.sh/schemas/payment-handlers/tempo.json" + } + ] + }, + "name": "Emoji Keys Merchant" + }, + "signing_keys": [ + { + "kid": "py-emoji-keys-EdDSA", + "kty": "OKP", + "alg": "EdDSA", + "use": "sig", + "crv": "Ed25519", + "x": "t9o2BRiSJvI4c7a3KlzCqzKS1evXIyngTwB2GBxtZec" + } + ], + "a": 1, + "豈": 2, + "": 3, + "🍷": 4, + "signature": "eyJhbGciOiJFZERTQSIsImtpZCI6InB5LWVtb2ppLWtleXMtRWREU0EiLCJ0eXAiOiJhZ2VudHNjb3JlLXByb2ZpbGUrandzIn0.eyIiOjMsImEiOjEsInNpZ25pbmdfa2V5cyI6W3siYWxnIjoiRWREU0EiLCJjcnYiOiJFZDI1NTE5Iiwia2lkIjoicHktZW1vamkta2V5cy1FZERTQSIsImt0eSI6Ik9LUCIsInVzZSI6InNpZyIsIngiOiJ0OW8yQlJpU0p2STRjN2EzS2x6Q3F6S1MxZXZYSXluZ1R3QjJHQnh0WmVjIn1dLCJ1Y3AiOnsiY2FwYWJpbGl0aWVzIjp7fSwibmFtZSI6IkVtb2ppIEtleXMgTWVyY2hhbnQiLCJwYXltZW50X2hhbmRsZXJzIjp7InNoLmFnZW50c2NvcmUucGF5bWVudC50ZW1wbyI6W3siaWQiOiJ0ZW1wbyIsInNjaGVtYSI6Imh0dHBzOi8vYWdlbnRzY29yZS5zaC9zY2hlbWFzL3BheW1lbnQtaGFuZGxlcnMvdGVtcG8uanNvbiIsInNwZWMiOiJodHRwczovL2FnZW50c2NvcmUuc2gvc3BlY2lmaWNhdGlvbi9wYXltZW50LWhhbmRsZXJzL3RlbXBvIiwidmVyc2lvbiI6IjIwMjYtMDQtMDgifV19LCJzZXJ2aWNlcyI6eyJkZXYudWNwLnNob3BwaW5nIjpbeyJlbmRwb2ludCI6Imh0dHBzOi8vZW1vamkuZXhhbXBsZS5jb20vYXBpL3VjcC9tY3AiLCJzY2hlbWEiOiJodHRwczovL3VjcC5kZXYvc2VydmljZXMvc2hvcHBpbmcvb3BlbnJwYy5qc29uIiwic3BlYyI6Imh0dHBzOi8vdWNwLmRldi8yMDI2LTA0LTA4L3NwZWNpZmljYXRpb24vb3ZlcnZpZXciLCJ0cmFuc3BvcnQiOiJtY3AiLCJ2ZXJzaW9uIjoiMjAyNi0wNC0wOCJ9XX0sInZlcnNpb24iOiIyMDI2LTA0LTE3In0sIuixiCI6Miwi8J-NtyI6NH0.JFxAqyuCgvA0HNAl2giJeb4MbHDuW5h7jBjGcrQxcSDCiCgXjzhaUSWJXjiB7GeHcL7CDMg3kj79VQ4Rsr1-Bw" + }, + "jwks": { + "keys": [ + { + "crv": "Ed25519", + "x": "t9o2BRiSJvI4c7a3KlzCqzKS1evXIyngTwB2GBxtZec", + "kid": "py-emoji-keys-EdDSA", + "alg": "EdDSA", + "use": "sig", + "kty": "OKP" + } + ] + }, + "alg": "EdDSA", + "kid": "py-emoji-keys-EdDSA", + "generator": "python" +} diff --git a/tests/fixtures/cross-lang/py-es256-rails.json b/tests/fixtures/cross-lang/py-es256-rails.json new file mode 100644 index 0000000..4c9ffa9 --- /dev/null +++ b/tests/fixtures/cross-lang/py-es256-rails.json @@ -0,0 +1,81 @@ +{ + "profile": { + "ucp": { + "version": "2026-04-17", + "services": { + "dev.ucp.shopping": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/2026-04-08/specification/overview", + "transport": "mcp", + "endpoint": "https://a.example.com/api/ucp/mcp", + "schema": "https://ucp.dev/services/shopping/openrpc.json" + }, + { + "version": "2026-04-08", + "spec": "https://ucp.dev/2026-04-08/specification/overview", + "transport": "a2a", + "endpoint": "https://a.example.com/.well-known/agent-card.json" + } + ] + }, + "capabilities": {}, + "payment_handlers": { + "sh.agentscore.payment.tempo": [ + { + "id": "tempo", + "version": "2026-04-08", + "spec": "https://agentscore.sh/specification/payment-handlers/tempo", + "schema": "https://agentscore.sh/schemas/payment-handlers/tempo.json", + "config": { + "rail": "tempo-mainnet", + "chain_id": 4217 + } + } + ], + "sh.agentscore.payment.x402": [ + { + "id": "x402", + "version": "2026-04-08", + "spec": "https://agentscore.sh/specification/payment-handlers/x402", + "schema": "https://agentscore.sh/schemas/payment-handlers/x402.json", + "config": { + "networks": [ + "base-8453" + ] + } + } + ] + }, + "name": "ES256 Merchant" + }, + "signing_keys": [ + { + "kid": "py-es256-rails-ES256", + "kty": "EC", + "alg": "ES256", + "use": "sig", + "crv": "P-256", + "x": "NFS5qrSPV5sDQ5hHVag2zFqOSpTO6NBL-Hqf9EjBOco", + "y": "wtbEwX6TxEFid1IJvIwxkVfNic3Q_xEOq7j54Kje7aY" + } + ], + "signature": "eyJhbGciOiJFUzI1NiIsImtpZCI6InB5LWVzMjU2LXJhaWxzLUVTMjU2IiwidHlwIjoiYWdlbnRzY29yZS1wcm9maWxlK2p3cyJ9.eyJzaWduaW5nX2tleXMiOlt7ImFsZyI6IkVTMjU2IiwiY3J2IjoiUC0yNTYiLCJraWQiOiJweS1lczI1Ni1yYWlscy1FUzI1NiIsImt0eSI6IkVDIiwidXNlIjoic2lnIiwieCI6Ik5GUzVxclNQVjVzRFE1aEhWYWcyekZxT1NwVE82TkJMLUhxZjlFakJPY28iLCJ5Ijoid3RiRXdYNlR4RUZpZDFJSnZJd3hrVmZOaWMzUV94RU9xN2o1NEtqZTdhWSJ9XSwidWNwIjp7ImNhcGFiaWxpdGllcyI6e30sIm5hbWUiOiJFUzI1NiBNZXJjaGFudCIsInBheW1lbnRfaGFuZGxlcnMiOnsic2guYWdlbnRzY29yZS5wYXltZW50LnRlbXBvIjpbeyJjb25maWciOnsiY2hhaW5faWQiOjQyMTcsInJhaWwiOiJ0ZW1wby1tYWlubmV0In0sImlkIjoidGVtcG8iLCJzY2hlbWEiOiJodHRwczovL2FnZW50c2NvcmUuc2gvc2NoZW1hcy9wYXltZW50LWhhbmRsZXJzL3RlbXBvLmpzb24iLCJzcGVjIjoiaHR0cHM6Ly9hZ2VudHNjb3JlLnNoL3NwZWNpZmljYXRpb24vcGF5bWVudC1oYW5kbGVycy90ZW1wbyIsInZlcnNpb24iOiIyMDI2LTA0LTA4In1dLCJzaC5hZ2VudHNjb3JlLnBheW1lbnQueDQwMiI6W3siY29uZmlnIjp7Im5ldHdvcmtzIjpbImJhc2UtODQ1MyJdfSwiaWQiOiJ4NDAyIiwic2NoZW1hIjoiaHR0cHM6Ly9hZ2VudHNjb3JlLnNoL3NjaGVtYXMvcGF5bWVudC1oYW5kbGVycy94NDAyLmpzb24iLCJzcGVjIjoiaHR0cHM6Ly9hZ2VudHNjb3JlLnNoL3NwZWNpZmljYXRpb24vcGF5bWVudC1oYW5kbGVycy94NDAyIiwidmVyc2lvbiI6IjIwMjYtMDQtMDgifV19LCJzZXJ2aWNlcyI6eyJkZXYudWNwLnNob3BwaW5nIjpbeyJlbmRwb2ludCI6Imh0dHBzOi8vYS5leGFtcGxlLmNvbS9hcGkvdWNwL21jcCIsInNjaGVtYSI6Imh0dHBzOi8vdWNwLmRldi9zZXJ2aWNlcy9zaG9wcGluZy9vcGVucnBjLmpzb24iLCJzcGVjIjoiaHR0cHM6Ly91Y3AuZGV2LzIwMjYtMDQtMDgvc3BlY2lmaWNhdGlvbi9vdmVydmlldyIsInRyYW5zcG9ydCI6Im1jcCIsInZlcnNpb24iOiIyMDI2LTA0LTA4In0seyJlbmRwb2ludCI6Imh0dHBzOi8vYS5leGFtcGxlLmNvbS8ud2VsbC1rbm93bi9hZ2VudC1jYXJkLmpzb24iLCJzcGVjIjoiaHR0cHM6Ly91Y3AuZGV2LzIwMjYtMDQtMDgvc3BlY2lmaWNhdGlvbi9vdmVydmlldyIsInRyYW5zcG9ydCI6ImEyYSIsInZlcnNpb24iOiIyMDI2LTA0LTA4In1dfSwidmVyc2lvbiI6IjIwMjYtMDQtMTcifX0.Vo7XPWeW37oSI5Eub7oUVwb3ODY3g70PeNgYLODGQ9L2nrf-5K7yinG2QwHEh5GtIMq7fXp5fiQVk1KtFnL4Wg" + }, + "jwks": { + "keys": [ + { + "crv": "P-256", + "x": "NFS5qrSPV5sDQ5hHVag2zFqOSpTO6NBL-Hqf9EjBOco", + "y": "wtbEwX6TxEFid1IJvIwxkVfNic3Q_xEOq7j54Kje7aY", + "kid": "py-es256-rails-ES256", + "alg": "ES256", + "use": "sig", + "kty": "EC" + } + ] + }, + "alg": "ES256", + "kid": "py-es256-rails-ES256", + "generator": "python" +} diff --git a/tests/fixtures/cross-lang/py-extras-int.json b/tests/fixtures/cross-lang/py-extras-int.json new file mode 100644 index 0000000..f1380bd --- /dev/null +++ b/tests/fixtures/cross-lang/py-extras-int.json @@ -0,0 +1,60 @@ +{ + "profile": { + "ucp": { + "version": "2026-04-17", + "services": { + "dev.ucp.shopping": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/2026-04-08/specification/overview", + "transport": "mcp", + "endpoint": "https://e.example.com/api/ucp/mcp", + "schema": "https://ucp.dev/services/shopping/openrpc.json" + } + ] + }, + "capabilities": {}, + "payment_handlers": { + "sh.agentscore.payment.stripe-spt": [ + { + "id": "stripe", + "version": "2026-04-08", + "spec": "https://agentscore.sh/specification/payment-handlers/stripe-spt", + "schema": "https://agentscore.sh/schemas/payment-handlers/stripe-spt.json", + "config": { + "profile_id": "abc", + "count": 7 + } + } + ] + }, + "name": "Extras Merchant" + }, + "signing_keys": [ + { + "kid": "py-extras-int-EdDSA", + "kty": "OKP", + "alg": "EdDSA", + "use": "sig", + "crv": "Ed25519", + "x": "El2ke55St-sfq6gYs6wYJyJX7TIw3-spyA1hlMiNhpM" + } + ], + "signature": "eyJhbGciOiJFZERTQSIsImtpZCI6InB5LWV4dHJhcy1pbnQtRWREU0EiLCJ0eXAiOiJhZ2VudHNjb3JlLXByb2ZpbGUrandzIn0.eyJzaWduaW5nX2tleXMiOlt7ImFsZyI6IkVkRFNBIiwiY3J2IjoiRWQyNTUxOSIsImtpZCI6InB5LWV4dHJhcy1pbnQtRWREU0EiLCJrdHkiOiJPS1AiLCJ1c2UiOiJzaWciLCJ4IjoiRWwya2U1NVN0LXNmcTZnWXM2d1lKeUpYN1RJdzMtc3B5QTFobE1pTmhwTSJ9XSwidWNwIjp7ImNhcGFiaWxpdGllcyI6e30sIm5hbWUiOiJFeHRyYXMgTWVyY2hhbnQiLCJwYXltZW50X2hhbmRsZXJzIjp7InNoLmFnZW50c2NvcmUucGF5bWVudC5zdHJpcGUtc3B0IjpbeyJjb25maWciOnsiY291bnQiOjcsInByb2ZpbGVfaWQiOiJhYmMifSwiaWQiOiJzdHJpcGUiLCJzY2hlbWEiOiJodHRwczovL2FnZW50c2NvcmUuc2gvc2NoZW1hcy9wYXltZW50LWhhbmRsZXJzL3N0cmlwZS1zcHQuanNvbiIsInNwZWMiOiJodHRwczovL2FnZW50c2NvcmUuc2gvc3BlY2lmaWNhdGlvbi9wYXltZW50LWhhbmRsZXJzL3N0cmlwZS1zcHQiLCJ2ZXJzaW9uIjoiMjAyNi0wNC0wOCJ9XX0sInNlcnZpY2VzIjp7ImRldi51Y3Auc2hvcHBpbmciOlt7ImVuZHBvaW50IjoiaHR0cHM6Ly9lLmV4YW1wbGUuY29tL2FwaS91Y3AvbWNwIiwic2NoZW1hIjoiaHR0cHM6Ly91Y3AuZGV2L3NlcnZpY2VzL3Nob3BwaW5nL29wZW5ycGMuanNvbiIsInNwZWMiOiJodHRwczovL3VjcC5kZXYvMjAyNi0wNC0wOC9zcGVjaWZpY2F0aW9uL292ZXJ2aWV3IiwidHJhbnNwb3J0IjoibWNwIiwidmVyc2lvbiI6IjIwMjYtMDQtMDgifV19LCJ2ZXJzaW9uIjoiMjAyNi0wNC0xNyJ9fQ.0DAtQpZ-9e8U3cmpzTHwWFZq2LmmchY6mz-rhRxybkNX4YDlqpPLcfAig7ybMzdo_O7afJ9QDNYfDERmCGtVDQ" + }, + "jwks": { + "keys": [ + { + "crv": "Ed25519", + "x": "El2ke55St-sfq6gYs6wYJyJX7TIw3-spyA1hlMiNhpM", + "kid": "py-extras-int-EdDSA", + "alg": "EdDSA", + "use": "sig", + "kty": "OKP" + } + ] + }, + "alg": "EdDSA", + "kid": "py-extras-int-EdDSA", + "generator": "python" +} diff --git a/tests/fixtures/cross-lang/py-int-boundary.json b/tests/fixtures/cross-lang/py-int-boundary.json new file mode 100644 index 0000000..9c91acc --- /dev/null +++ b/tests/fixtures/cross-lang/py-int-boundary.json @@ -0,0 +1,52 @@ +{ + "profile": { + "ucp": { + "version": "2026-04-17", + "services": { + "dev.ucp.shopping": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/2026-04-08/specification/overview", + "transport": "mcp", + "endpoint": "https://i.example.com/api/ucp/mcp", + "schema": "https://ucp.dev/services/shopping/openrpc.json" + } + ] + }, + "capabilities": {}, + "payment_handlers": {}, + "name": "Int Boundary Merchant" + }, + "signing_keys": [ + { + "kid": "py-int-boundary-EdDSA", + "kty": "OKP", + "alg": "EdDSA", + "use": "sig", + "crv": "Ed25519", + "x": "b5OlULxsP0xpS8IkLF4tRaiB1u6yODPxsQJJYv1iB6s" + } + ], + "max_safe_int": 9007199254740991, + "min_safe_int": -9007199254740991, + "small_int": 42, + "neg_small_int": -42, + "zero": 0, + "signature": "eyJhbGciOiJFZERTQSIsImtpZCI6InB5LWludC1ib3VuZGFyeS1FZERTQSIsInR5cCI6ImFnZW50c2NvcmUtcHJvZmlsZStqd3MifQ.eyJtYXhfc2FmZV9pbnQiOjkwMDcxOTkyNTQ3NDA5OTEsIm1pbl9zYWZlX2ludCI6LTkwMDcxOTkyNTQ3NDA5OTEsIm5lZ19zbWFsbF9pbnQiOi00Miwic2lnbmluZ19rZXlzIjpbeyJhbGciOiJFZERTQSIsImNydiI6IkVkMjU1MTkiLCJraWQiOiJweS1pbnQtYm91bmRhcnktRWREU0EiLCJrdHkiOiJPS1AiLCJ1c2UiOiJzaWciLCJ4IjoiYjVPbFVMeHNQMHhwUzhJa0xGNHRSYWlCMXU2eU9EUHhzUUpKWXYxaUI2cyJ9XSwic21hbGxfaW50Ijo0MiwidWNwIjp7ImNhcGFiaWxpdGllcyI6e30sIm5hbWUiOiJJbnQgQm91bmRhcnkgTWVyY2hhbnQiLCJwYXltZW50X2hhbmRsZXJzIjp7fSwic2VydmljZXMiOnsiZGV2LnVjcC5zaG9wcGluZyI6W3siZW5kcG9pbnQiOiJodHRwczovL2kuZXhhbXBsZS5jb20vYXBpL3VjcC9tY3AiLCJzY2hlbWEiOiJodHRwczovL3VjcC5kZXYvc2VydmljZXMvc2hvcHBpbmcvb3BlbnJwYy5qc29uIiwic3BlYyI6Imh0dHBzOi8vdWNwLmRldi8yMDI2LTA0LTA4L3NwZWNpZmljYXRpb24vb3ZlcnZpZXciLCJ0cmFuc3BvcnQiOiJtY3AiLCJ2ZXJzaW9uIjoiMjAyNi0wNC0wOCJ9XX0sInZlcnNpb24iOiIyMDI2LTA0LTE3In0sInplcm8iOjB9.PsM9i8EXGN5eNPJI6_6Efk8P-aE-gQQvmXpNCr1vTFMtsjvUrwPO974mweqhbyogrdfm47UkAhJZ2tkGQ26YDQ" + }, + "jwks": { + "keys": [ + { + "crv": "Ed25519", + "x": "b5OlULxsP0xpS8IkLF4tRaiB1u6yODPxsQJJYv1iB6s", + "kid": "py-int-boundary-EdDSA", + "alg": "EdDSA", + "use": "sig", + "kty": "OKP" + } + ] + }, + "alg": "EdDSA", + "kid": "py-int-boundary-EdDSA", + "generator": "python" +} diff --git a/tests/fixtures/cross-lang/py-minimal.json b/tests/fixtures/cross-lang/py-minimal.json new file mode 100644 index 0000000..0e83bfc --- /dev/null +++ b/tests/fixtures/cross-lang/py-minimal.json @@ -0,0 +1,47 @@ +{ + "profile": { + "ucp": { + "version": "2026-04-17", + "services": { + "dev.ucp.shopping": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/2026-04-08/specification/overview", + "transport": "mcp", + "endpoint": "https://m.example.com/api/ucp/mcp", + "schema": "https://ucp.dev/services/shopping/openrpc.json" + } + ] + }, + "capabilities": {}, + "payment_handlers": {}, + "name": "Minimal Merchant" + }, + "signing_keys": [ + { + "kid": "py-minimal-EdDSA", + "kty": "OKP", + "alg": "EdDSA", + "use": "sig", + "crv": "Ed25519", + "x": "dZ6PLK4BfgrHTuRA0klbkcl6iHAXhyX3ACjRefxb8IA" + } + ], + "signature": "eyJhbGciOiJFZERTQSIsImtpZCI6InB5LW1pbmltYWwtRWREU0EiLCJ0eXAiOiJhZ2VudHNjb3JlLXByb2ZpbGUrandzIn0.eyJzaWduaW5nX2tleXMiOlt7ImFsZyI6IkVkRFNBIiwiY3J2IjoiRWQyNTUxOSIsImtpZCI6InB5LW1pbmltYWwtRWREU0EiLCJrdHkiOiJPS1AiLCJ1c2UiOiJzaWciLCJ4IjoiZFo2UExLNEJmZ3JIVHVSQTBrbGJrY2w2aUhBWGh5WDNBQ2pSZWZ4YjhJQSJ9XSwidWNwIjp7ImNhcGFiaWxpdGllcyI6e30sIm5hbWUiOiJNaW5pbWFsIE1lcmNoYW50IiwicGF5bWVudF9oYW5kbGVycyI6e30sInNlcnZpY2VzIjp7ImRldi51Y3Auc2hvcHBpbmciOlt7ImVuZHBvaW50IjoiaHR0cHM6Ly9tLmV4YW1wbGUuY29tL2FwaS91Y3AvbWNwIiwic2NoZW1hIjoiaHR0cHM6Ly91Y3AuZGV2L3NlcnZpY2VzL3Nob3BwaW5nL29wZW5ycGMuanNvbiIsInNwZWMiOiJodHRwczovL3VjcC5kZXYvMjAyNi0wNC0wOC9zcGVjaWZpY2F0aW9uL292ZXJ2aWV3IiwidHJhbnNwb3J0IjoibWNwIiwidmVyc2lvbiI6IjIwMjYtMDQtMDgifV19LCJ2ZXJzaW9uIjoiMjAyNi0wNC0xNyJ9fQ.axue3k1ojtSWw0pZJbuDmx-HBt6DZTwtbD3DiHKwrrP3YSWjdlp_FBfBMT0jA-oQ6HqfdQ4fO9vuRAAIpBepCw" + }, + "jwks": { + "keys": [ + { + "crv": "Ed25519", + "x": "dZ6PLK4BfgrHTuRA0klbkcl6iHAXhyX3ACjRefxb8IA", + "kid": "py-minimal-EdDSA", + "alg": "EdDSA", + "use": "sig", + "kty": "OKP" + } + ] + }, + "alg": "EdDSA", + "kid": "py-minimal-EdDSA", + "generator": "python" +} diff --git a/tests/fixtures/cross-lang/py-multikey.json b/tests/fixtures/cross-lang/py-multikey.json new file mode 100644 index 0000000..514eaba --- /dev/null +++ b/tests/fixtures/cross-lang/py-multikey.json @@ -0,0 +1,75 @@ +{ + "profile": { + "ucp": { + "version": "2026-04-17", + "services": { + "dev.ucp.shopping": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/2026-04-08/specification/overview", + "transport": "mcp", + "endpoint": "https://mk.example.com/api/ucp/mcp", + "schema": "https://ucp.dev/services/shopping/openrpc.json" + } + ] + }, + "capabilities": {}, + "payment_handlers": { + "sh.agentscore.payment.tempo": [ + { + "id": "tempo", + "version": "2026-04-08", + "spec": "https://agentscore.sh/specification/payment-handlers/tempo", + "schema": "https://agentscore.sh/schemas/payment-handlers/tempo.json", + "config": { + "rail": "tempo-mainnet" + } + } + ] + }, + "name": "Multi-Key Merchant" + }, + "signing_keys": [ + { + "kid": "py-multikey-old", + "kty": "OKP", + "alg": "EdDSA", + "use": "sig", + "crv": "Ed25519", + "x": "lW7nqnsPzl7FVllMcMjTSHmAqaMVeBMJk4mEwgfY5Vo" + }, + { + "kid": "py-multikey-new", + "kty": "OKP", + "alg": "EdDSA", + "use": "sig", + "crv": "Ed25519", + "x": "Kmwcte5hHWi17aQjekr9Zdw6fsBQl237_jllIAJBMnk" + } + ], + "signature": "eyJhbGciOiJFZERTQSIsImtpZCI6InB5LW11bHRpa2V5LW5ldyIsInR5cCI6ImFnZW50c2NvcmUtcHJvZmlsZStqd3MifQ.eyJzaWduaW5nX2tleXMiOlt7ImFsZyI6IkVkRFNBIiwiY3J2IjoiRWQyNTUxOSIsImtpZCI6InB5LW11bHRpa2V5LW9sZCIsImt0eSI6Ik9LUCIsInVzZSI6InNpZyIsIngiOiJsVzducW5zUHpsN0ZWbGxNY01qVFNIbUFxYU1WZUJNSms0bUV3Z2ZZNVZvIn0seyJhbGciOiJFZERTQSIsImNydiI6IkVkMjU1MTkiLCJraWQiOiJweS1tdWx0aWtleS1uZXciLCJrdHkiOiJPS1AiLCJ1c2UiOiJzaWciLCJ4IjoiS213Y3RlNWhIV2kxN2FRamVrcjlaZHc2ZnNCUWwyMzdfamxsSUFKQk1uayJ9XSwidWNwIjp7ImNhcGFiaWxpdGllcyI6e30sIm5hbWUiOiJNdWx0aS1LZXkgTWVyY2hhbnQiLCJwYXltZW50X2hhbmRsZXJzIjp7InNoLmFnZW50c2NvcmUucGF5bWVudC50ZW1wbyI6W3siY29uZmlnIjp7InJhaWwiOiJ0ZW1wby1tYWlubmV0In0sImlkIjoidGVtcG8iLCJzY2hlbWEiOiJodHRwczovL2FnZW50c2NvcmUuc2gvc2NoZW1hcy9wYXltZW50LWhhbmRsZXJzL3RlbXBvLmpzb24iLCJzcGVjIjoiaHR0cHM6Ly9hZ2VudHNjb3JlLnNoL3NwZWNpZmljYXRpb24vcGF5bWVudC1oYW5kbGVycy90ZW1wbyIsInZlcnNpb24iOiIyMDI2LTA0LTA4In1dfSwic2VydmljZXMiOnsiZGV2LnVjcC5zaG9wcGluZyI6W3siZW5kcG9pbnQiOiJodHRwczovL21rLmV4YW1wbGUuY29tL2FwaS91Y3AvbWNwIiwic2NoZW1hIjoiaHR0cHM6Ly91Y3AuZGV2L3NlcnZpY2VzL3Nob3BwaW5nL29wZW5ycGMuanNvbiIsInNwZWMiOiJodHRwczovL3VjcC5kZXYvMjAyNi0wNC0wOC9zcGVjaWZpY2F0aW9uL292ZXJ2aWV3IiwidHJhbnNwb3J0IjoibWNwIiwidmVyc2lvbiI6IjIwMjYtMDQtMDgifV19LCJ2ZXJzaW9uIjoiMjAyNi0wNC0xNyJ9fQ.gBimQYPBcvQFutbEzKeJrLzjrkgqyClkbRuSVOaRAfzAvUsxZ5Zse1WmqhadHzv5DUZohfBiWUHjj96kToOPDQ" + }, + "jwks": { + "keys": [ + { + "crv": "Ed25519", + "x": "lW7nqnsPzl7FVllMcMjTSHmAqaMVeBMJk4mEwgfY5Vo", + "kid": "py-multikey-old", + "alg": "EdDSA", + "use": "sig", + "kty": "OKP" + }, + { + "crv": "Ed25519", + "x": "Kmwcte5hHWi17aQjekr9Zdw6fsBQl237_jllIAJBMnk", + "kid": "py-multikey-new", + "alg": "EdDSA", + "use": "sig", + "kty": "OKP" + } + ] + }, + "alg": "EdDSA", + "kid": "py-multikey-new", + "generator": "python" +} diff --git a/tests/fixtures/cross-lang/py-typed-claims.json b/tests/fixtures/cross-lang/py-typed-claims.json new file mode 100644 index 0000000..a486c17 --- /dev/null +++ b/tests/fixtures/cross-lang/py-typed-claims.json @@ -0,0 +1,69 @@ +{ + "profile": { + "ucp": { + "version": "2026-04-17", + "services": { + "dev.ucp.shopping": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/2026-04-08/specification/overview", + "transport": "mcp", + "endpoint": "https://t.example.com/api/ucp/mcp", + "schema": "https://ucp.dev/services/shopping/openrpc.json" + } + ] + }, + "capabilities": { + "sh.agentscore.identity": [ + { + "version": "1", + "spec": "https://agentscore.sh/specification/identity", + "schema": "https://agentscore.sh/schemas/ucp/sh-agentscore-identity-v1.json", + "extends": [ + "dev.ucp.shopping.checkout", + "dev.ucp.shopping.cart" + ], + "claims": { + "operator_id": "op_typed_claims", + "kyc_level": "enhanced", + "sanctions_clear": true, + "age_bracket": "21+", + "jurisdiction": "US", + "verified_at": "2026-04-01T00:00:00Z", + "verify_url": "https://agentscore.sh/verify/op_typed_claims", + "issuer": "https://agentscore.sh" + } + } + ] + }, + "payment_handlers": {}, + "name": "Typed Claims Merchant" + }, + "signing_keys": [ + { + "kid": "py-typed-claims-EdDSA", + "kty": "OKP", + "alg": "EdDSA", + "use": "sig", + "crv": "Ed25519", + "x": "clSTIoRWvV4whYX40RYSSPGfcj2mL3YW-IkgYYM6SLQ" + } + ], + "signature": "eyJhbGciOiJFZERTQSIsImtpZCI6InB5LXR5cGVkLWNsYWltcy1FZERTQSIsInR5cCI6ImFnZW50c2NvcmUtcHJvZmlsZStqd3MifQ.eyJzaWduaW5nX2tleXMiOlt7ImFsZyI6IkVkRFNBIiwiY3J2IjoiRWQyNTUxOSIsImtpZCI6InB5LXR5cGVkLWNsYWltcy1FZERTQSIsImt0eSI6Ik9LUCIsInVzZSI6InNpZyIsIngiOiJjbFNUSW9SV3ZWNHdoWVg0MFJZU1NQR2ZjajJtTDNZVy1Ja2dZWU02U0xRIn1dLCJ1Y3AiOnsiY2FwYWJpbGl0aWVzIjp7InNoLmFnZW50c2NvcmUuaWRlbnRpdHkiOlt7ImNsYWltcyI6eyJhZ2VfYnJhY2tldCI6IjIxKyIsImlzc3VlciI6Imh0dHBzOi8vYWdlbnRzY29yZS5zaCIsImp1cmlzZGljdGlvbiI6IlVTIiwia3ljX2xldmVsIjoiZW5oYW5jZWQiLCJvcGVyYXRvcl9pZCI6Im9wX3R5cGVkX2NsYWltcyIsInNhbmN0aW9uc19jbGVhciI6dHJ1ZSwidmVyaWZpZWRfYXQiOiIyMDI2LTA0LTAxVDAwOjAwOjAwWiIsInZlcmlmeV91cmwiOiJodHRwczovL2FnZW50c2NvcmUuc2gvdmVyaWZ5L29wX3R5cGVkX2NsYWltcyJ9LCJleHRlbmRzIjpbImRldi51Y3Auc2hvcHBpbmcuY2hlY2tvdXQiLCJkZXYudWNwLnNob3BwaW5nLmNhcnQiXSwic2NoZW1hIjoiaHR0cHM6Ly9hZ2VudHNjb3JlLnNoL3NjaGVtYXMvdWNwL3NoLWFnZW50c2NvcmUtaWRlbnRpdHktdjEuanNvbiIsInNwZWMiOiJodHRwczovL2FnZW50c2NvcmUuc2gvc3BlY2lmaWNhdGlvbi9pZGVudGl0eSIsInZlcnNpb24iOiIxIn1dfSwibmFtZSI6IlR5cGVkIENsYWltcyBNZXJjaGFudCIsInBheW1lbnRfaGFuZGxlcnMiOnt9LCJzZXJ2aWNlcyI6eyJkZXYudWNwLnNob3BwaW5nIjpbeyJlbmRwb2ludCI6Imh0dHBzOi8vdC5leGFtcGxlLmNvbS9hcGkvdWNwL21jcCIsInNjaGVtYSI6Imh0dHBzOi8vdWNwLmRldi9zZXJ2aWNlcy9zaG9wcGluZy9vcGVucnBjLmpzb24iLCJzcGVjIjoiaHR0cHM6Ly91Y3AuZGV2LzIwMjYtMDQtMDgvc3BlY2lmaWNhdGlvbi9vdmVydmlldyIsInRyYW5zcG9ydCI6Im1jcCIsInZlcnNpb24iOiIyMDI2LTA0LTA4In1dfSwidmVyc2lvbiI6IjIwMjYtMDQtMTcifX0.0BQic1wyTNOk4TVcs2dJ6iRARokGtSjzMzbP9myAlMdF8zpnXfWAzZ6MwUsmH10eK7PQtRrj5D-St4_xxw6SBw" + }, + "jwks": { + "keys": [ + { + "crv": "Ed25519", + "x": "clSTIoRWvV4whYX40RYSSPGfcj2mL3YW-IkgYYM6SLQ", + "kid": "py-typed-claims-EdDSA", + "alg": "EdDSA", + "use": "sig", + "kty": "OKP" + } + ] + }, + "alg": "EdDSA", + "kid": "py-typed-claims-EdDSA", + "generator": "python" +} diff --git a/tests/fixtures/cross-lang/py-unicode.json b/tests/fixtures/cross-lang/py-unicode.json new file mode 100644 index 0000000..8d27413 --- /dev/null +++ b/tests/fixtures/cross-lang/py-unicode.json @@ -0,0 +1,59 @@ +{ + "profile": { + "ucp": { + "version": "2026-04-17", + "services": { + "dev.ucp.shopping": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/2026-04-08/specification/overview", + "transport": "mcp", + "endpoint": "https://日本.example.com/api/ucp/mcp", + "schema": "https://ucp.dev/services/shopping/openrpc.json" + } + ] + }, + "capabilities": {}, + "payment_handlers": { + "sh.agentscore.payment.tempo": [ + { + "id": "tempo", + "version": "2026-04-08", + "spec": "https://agentscore.sh/specification/payment-handlers/tempo", + "schema": "https://agentscore.sh/schemas/payment-handlers/tempo.json", + "config": { + "note": "メモ" + } + } + ] + }, + "name": "Café 日本 🍷 Merchant" + }, + "signing_keys": [ + { + "kid": "py-unicode-EdDSA", + "kty": "OKP", + "alg": "EdDSA", + "use": "sig", + "crv": "Ed25519", + "x": "Rk_x9yyAht9Xy_mKxxmdh0kyr12andlLUGHY2xh8-3w" + } + ], + "signature": "eyJhbGciOiJFZERTQSIsImtpZCI6InB5LXVuaWNvZGUtRWREU0EiLCJ0eXAiOiJhZ2VudHNjb3JlLXByb2ZpbGUrandzIn0.eyJzaWduaW5nX2tleXMiOlt7ImFsZyI6IkVkRFNBIiwiY3J2IjoiRWQyNTUxOSIsImtpZCI6InB5LXVuaWNvZGUtRWREU0EiLCJrdHkiOiJPS1AiLCJ1c2UiOiJzaWciLCJ4IjoiUmtfeDl5eUFodDlYeV9tS3h4bWRoMGt5cjEyYW5kbExVR0hZMnhoOC0zdyJ9XSwidWNwIjp7ImNhcGFiaWxpdGllcyI6e30sIm5hbWUiOiJDYWbDqSDml6XmnKwg8J-NtyBNZXJjaGFudCIsInBheW1lbnRfaGFuZGxlcnMiOnsic2guYWdlbnRzY29yZS5wYXltZW50LnRlbXBvIjpbeyJjb25maWciOnsibm90ZSI6IuODoeODoiJ9LCJpZCI6InRlbXBvIiwic2NoZW1hIjoiaHR0cHM6Ly9hZ2VudHNjb3JlLnNoL3NjaGVtYXMvcGF5bWVudC1oYW5kbGVycy90ZW1wby5qc29uIiwic3BlYyI6Imh0dHBzOi8vYWdlbnRzY29yZS5zaC9zcGVjaWZpY2F0aW9uL3BheW1lbnQtaGFuZGxlcnMvdGVtcG8iLCJ2ZXJzaW9uIjoiMjAyNi0wNC0wOCJ9XX0sInNlcnZpY2VzIjp7ImRldi51Y3Auc2hvcHBpbmciOlt7ImVuZHBvaW50IjoiaHR0cHM6Ly_ml6XmnKwuZXhhbXBsZS5jb20vYXBpL3VjcC9tY3AiLCJzY2hlbWEiOiJodHRwczovL3VjcC5kZXYvc2VydmljZXMvc2hvcHBpbmcvb3BlbnJwYy5qc29uIiwic3BlYyI6Imh0dHBzOi8vdWNwLmRldi8yMDI2LTA0LTA4L3NwZWNpZmljYXRpb24vb3ZlcnZpZXciLCJ0cmFuc3BvcnQiOiJtY3AiLCJ2ZXJzaW9uIjoiMjAyNi0wNC0wOCJ9XX0sInZlcnNpb24iOiIyMDI2LTA0LTE3In19.fraS8Y7ecHdldvmqwIdCzvSlBqi2GvYatX4UmSnR0jBnKDY8qxQnfYErAbJQ8ywXnP8Ztsp7PvbaRd90GIZ0CQ" + }, + "jwks": { + "keys": [ + { + "crv": "Ed25519", + "x": "Rk_x9yyAht9Xy_mKxxmdh0kyr12andlLUGHY2xh8-3w", + "kid": "py-unicode-EdDSA", + "alg": "EdDSA", + "use": "sig", + "kty": "OKP" + } + ] + }, + "alg": "EdDSA", + "kid": "py-unicode-EdDSA", + "generator": "python" +} diff --git a/tests/identity/a2a.test.ts b/tests/identity/a2a.test.ts index 1d1a86a..869882b 100644 --- a/tests/identity/a2a.test.ts +++ b/tests/identity/a2a.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { buildA2AAgentCard } from '../../src/identity/a2a'; +import { + UCP_A2A_EXTENSION_URI, + buildA2AAgentCard, + ucpA2AExtension, +} from '../../src/identity/a2a'; import type { AgentScoreData } from '../../src/core'; const fullData: AgentScoreData = { @@ -73,4 +77,107 @@ describe('buildA2AAgentCard', () => { expect(card.identity?.issuer).toBe('https://other.example'); expect(card.identity?.verify_url).toBe('https://other.example/v'); }); + + it('falls back to operator_verification fields when account_verification is absent', () => { + // Drives the `?? operatorVerification?.level` and `?? operatorVerification?.verified_at` + // branches plus the default `'unknown'` / `''` / `verify_url` fallbacks. + const card = buildA2AAgentCard({ + name: 'X', + data: { + decision: 'allow', + decision_reasons: [], + resolved_operator: 'op_only_op_verif', + operator_verification: { + level: 'basic', + operator_type: 'agent', + verified_at: '2026-01-01T00:00:00Z', + }, + }, + }); + expect(card.identity).not.toBeNull(); + expect(card.identity?.kyc_level).toBe('basic'); + expect(card.identity?.sanctions_clear).toBe(false); + expect(card.identity?.age_bracket).toBe('unknown'); + expect(card.identity?.jurisdiction).toBe(''); + expect(card.identity?.verified_at).toBe('2026-01-01T00:00:00Z'); + expect(card.identity?.verify_url).toBe('https://agentscore.sh/verify'); + }); + + it('falls back to default kyc_level "none" when neither verification block is present', () => { + // Drives the trailing `?? 'none'` fallback in the kyc_level chain plus the + // `?? null` fallback for verified_at. + const card = buildA2AAgentCard({ + name: 'X', + data: { + decision: 'allow', + decision_reasons: [], + resolved_operator: 'op_no_verif', + }, + }); + expect(card.identity).not.toBeNull(); + expect(card.identity?.kyc_level).toBe('none'); + expect(card.identity?.verified_at).toBeNull(); + }); + + it('reads verify_url from data when input.verifyUrl is absent', () => { + // Drives the `data.verify_url` branch of the verify_url ?? chain. + const card = buildA2AAgentCard({ + name: 'X', + data: { + decision: 'allow', + decision_reasons: [], + resolved_operator: 'op_with_verify_url', + verify_url: 'https://from-data.example/verify', + }, + }); + expect(card.identity?.verify_url).toBe('https://from-data.example/verify'); + }); +}); + +describe('UCP A2A extension', () => { + 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('ucpA2AExtension() with no args produces empty-capabilities entry', () => { + const ext = ucpA2AExtension(); + expect(ext.uri).toBe(UCP_A2A_EXTENSION_URI); + expect(ext.params).toEqual({ capabilities: {} }); + }); + + it('ucpA2AExtension(map) wraps the capabilities map under params.capabilities', () => { + const ext = ucpA2AExtension({ + 'dev.ucp.shopping.checkout': [{ version: '2026-04-08' }], + 'dev.ucp.shopping.cart': [{ version: '2026-04-08' }], + }); + expect(ext.params).toEqual({ + capabilities: { + 'dev.ucp.shopping.checkout': [{ version: '2026-04-08' }], + 'dev.ucp.shopping.cart': [{ version: '2026-04-08' }], + }, + }); + }); + + it('buildA2AAgentCard emits extensions[] when passed', () => { + const card = buildA2AAgentCard({ + name: 'X', + data: null, + extensions: [ucpA2AExtension()], + }); + expect(card.extensions).toHaveLength(1); + expect(card.extensions?.[0]?.uri).toBe(UCP_A2A_EXTENSION_URI); + expect(card.extensions?.[0]?.params).toEqual({ capabilities: {} }); + }); + + it('buildA2AAgentCard omits extensions[] when not passed', () => { + const card = buildA2AAgentCard({ name: 'X', data: null }); + expect(card.extensions).toBeUndefined(); + }); + + it('buildA2AAgentCard omits extensions[] when passed an empty array (parity with python)', () => { + // Python's to_dict skips `extensions` when empty; node skips the same way so + // cross-language profiles canonicalize to identical bytes when both omit. + const card = buildA2AAgentCard({ name: 'X', data: null, extensions: [] }); + expect(card.extensions).toBeUndefined(); + }); }); diff --git a/tests/identity/cross-lang.test.ts b/tests/identity/cross-lang.test.ts new file mode 100644 index 0000000..101ba7e --- /dev/null +++ b/tests/identity/cross-lang.test.ts @@ -0,0 +1,79 @@ +/** + * Cross-language UCP signing fixture corpus. + * + * Each fixture file is a `{ profile, jwks, alg, kid, generator }` envelope. + * Both Node and Python check in identical fixtures here so a future + * canonicalization change in either language fails CI loudly. Without this, + * cross-language byte parity drift would silently break verifier-side + * compatibility in production. + */ + +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { verifyUCPProfile, type JWKSResponse, type SignedUCPProfile } from '../../src/identity/ucp-jwks'; + +interface Fixture { + profile: SignedUCPProfile; + jwks: JWKSResponse; + alg: 'EdDSA' | 'ES256'; + kid: string; + generator: 'node' | 'python'; +} + +const FIXTURE_DIR = join(__dirname, '..', 'fixtures', 'cross-lang'); + +const fixtures: { name: string; data: Fixture }[] = readdirSync(FIXTURE_DIR) + .filter((f) => f.endsWith('.json')) + .map((name) => ({ + name, + data: JSON.parse(readFileSync(join(FIXTURE_DIR, name), 'utf-8')) as Fixture, + })); + +describe('UCP signing — cross-language fixture corpus', () => { + for (const { name, data } of fixtures) { + it(`verifies ${name} (${data.alg}, generated by ${data.generator})`, async () => { + const ok = await verifyUCPProfile(data.profile, data.jwks); + expect(ok).toBe(true); + }); + } + + it('corpus covers the canonical scenarios from both languages', () => { + const names = fixtures.map((f) => f.name); + const generators = new Set(fixtures.map((f) => f.data.generator)); + expect(generators).toContain('node'); + expect(generators).toContain('python'); + + // Each language ships its base scenarios so cross-lang verify exercises all of them. + // `emoji-keys` exercises non-ASCII object keys with codepoints that genuinely + // distinguish UTF-16 first-unit sort from Unicode codepoint sort: BMP private use + // (U+E000) ranks BEFORE supplementary plane (U+1F377) by codepoint but AFTER it by + // UTF-16 first unit (because the high surrogate 55356 < 57344). Both repos ship the + // node and python emoji-keys fixtures so a regression in either language's key sort + // surfaces here. + for (const lang of ['node', 'py'] as const) { + for (const scenario of [ + 'minimal', + 'es256-rails', + 'extras-int', + 'capability', + 'unicode', + 'multikey', + 'emoji-keys', + 'int-boundary', + // `data-driven-claims` exercises the raw-dict fallback read path + // (`AssessResult(raw={"account_verification": {...}})`) that production + // callers populate. `typed-claims` exercises the typed field path + // (`AssessResult(account_verification={...}, raw=None)`) that + // hand-constructed callers use — Node's `buildUCPProfile` reads typed + // fields directly without consulting raw, so both paths must produce + // byte-identical canonical bytes across languages or cross-lang verify + // silently drifts. + 'data-driven-claims', + 'typed-claims', + ] as const) { + expect(names).toContain(`${lang}-${scenario}.json`); + } + } + }); +}); diff --git a/tests/identity/ucp-signing.test.ts b/tests/identity/ucp-signing.test.ts new file mode 100644 index 0000000..e9ddac0 --- /dev/null +++ b/tests/identity/ucp-signing.test.ts @@ -0,0 +1,880 @@ +import { describe, expect, it } from 'vitest'; +import { buildUCPProfile, ucpSigningKeyFromJWK } from '../../src/identity/ucp'; +import { + buildJWKSResponse, + generateUCPSigningKey, + signUCPProfile, + UCPVerificationError, + verifyUCPProfile, +} from '../../src/identity/ucp-jwks'; + +const baseInput = { + name: 'Test Merchant', + services: [{ type: 'rest', url: 'https://agents.example.com' }], + payment_handlers: [ + { name: 'tempo', config: { recipient: '0x1234' } }, + ], +}; + +describe('UCP signing — generateUCPSigningKey', () => { + it('generates an Ed25519 keypair by default', async () => { + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: 'test-key-1' }); + expect(privateKey).toBeDefined(); + expect(publicJWK.kid).toBe('test-key-1'); + expect(publicJWK.alg).toBe('EdDSA'); + expect(publicJWK.use).toBe('sig'); + expect(publicJWK.kty).toBe('OKP'); + expect(publicJWK.crv).toBe('Ed25519'); + expect(typeof (publicJWK as Record).x).toBe('string'); + }); + + it('generates an ES256 keypair when alg=ES256', async () => { + const { publicJWK } = await generateUCPSigningKey({ kid: 'test-es256', alg: 'ES256' }); + expect(publicJWK.alg).toBe('ES256'); + expect(publicJWK.kty).toBe('EC'); + expect(publicJWK.crv).toBe('P-256'); + expect(typeof (publicJWK as Record).x).toBe('string'); + expect(typeof (publicJWK as Record).y).toBe('string'); + }); + + it('produces a different kid + key material on each call', async () => { + const a = await generateUCPSigningKey({ kid: 'a' }); + const b = await generateUCPSigningKey({ kid: 'b' }); + expect(a.publicJWK.kid).toBe('a'); + expect(b.publicJWK.kid).toBe('b'); + expect((a.publicJWK as Record).x).not.toBe((b.publicJWK as Record).x); + }); +}); + +describe('UCP signing — signUCPProfile / verifyUCPProfile round-trip', () => { + it('signs an Ed25519-keyed profile and verifies against the matching JWKS', async () => { + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: 'merchant-2026-05' }); + const profile = buildUCPProfile({ ...baseInput, signing_keys: [publicJWK] }); + const signed = await signUCPProfile(profile, { signingKey: privateKey, kid: 'merchant-2026-05' }); + + expect(signed.signature).toBeDefined(); + expect(typeof signed.signature).toBe('string'); + expect(signed.signature.split('.')).toHaveLength(3); // JWS Compact has 3 segments + + const ok = await verifyUCPProfile(signed, buildJWKSResponse([publicJWK])); + expect(ok).toBe(true); + }); + + it('signs an ES256-keyed profile and verifies', async () => { + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: 'es256-key', alg: 'ES256' }); + const profile = buildUCPProfile({ ...baseInput, signing_keys: [publicJWK] }); + const signed = await signUCPProfile(profile, { signingKey: privateKey, kid: 'es256-key', alg: 'ES256' }); + + const ok = await verifyUCPProfile(signed, buildJWKSResponse([publicJWK])); + expect(ok).toBe(true); + }); + + it('verifies against a multi-key JWKS (selects by kid)', async () => { + const oldKey = await generateUCPSigningKey({ kid: 'old-key' }); + const newKey = await generateUCPSigningKey({ kid: 'new-key' }); + const profile = buildUCPProfile({ ...baseInput, signing_keys: [oldKey.publicJWK, newKey.publicJWK] }); + const signed = await signUCPProfile(profile, { signingKey: newKey.privateKey, kid: 'new-key' }); + + const ok = await verifyUCPProfile(signed, buildJWKSResponse([oldKey.publicJWK, newKey.publicJWK])); + expect(ok).toBe(true); + }); + + it('rejects a tampered profile body', async () => { + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: 'k' }); + const profile = buildUCPProfile({ ...baseInput, signing_keys: [publicJWK] }); + const signed = await signUCPProfile(profile, { signingKey: privateKey, kid: 'k' }); + + const tampered = { ...signed, name: 'Different Name' }; + await expect(verifyUCPProfile(tampered, buildJWKSResponse([publicJWK]))).rejects.toThrow(); + }); + + it('rejects when JWKS does not contain the signing key', async () => { + const signer = await generateUCPSigningKey({ kid: 'signer' }); + const other = await generateUCPSigningKey({ kid: 'other' }); + const profile = buildUCPProfile({ ...baseInput, signing_keys: [signer.publicJWK] }); + const signed = await signUCPProfile(profile, { signingKey: signer.privateKey, kid: 'signer' }); + + await expect(verifyUCPProfile(signed, buildJWKSResponse([other.publicJWK]))).rejects.toThrow(/No JWK in JWKS/); + }); + + it('rejects when profile has no signature field', async () => { + const { publicJWK } = await generateUCPSigningKey({ kid: 'k' }); + const profile = buildUCPProfile({ ...baseInput, signing_keys: [publicJWK] }); + await expect( + verifyUCPProfile(profile as unknown as Awaited>, buildJWKSResponse([publicJWK])), + ).rejects.toMatchObject({ name: 'UCPVerificationError', code: 'no_signature' }); + }); +}); + +describe('UCP signing — canonicalization', () => { + it('signs the profile such that key-order in the JSON does not affect verification', async () => { + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: 'k' }); + const profileA = buildUCPProfile({ ...baseInput, signing_keys: [publicJWK] }); + const signed = await signUCPProfile(profileA, { signingKey: privateKey, kid: 'k' }); + + // Hand-construct the same profile with keys in REVERSE insertion order so + // canonicalization actually has work to do. JSON.parse(JSON.stringify(x)) + // preserves the source order, which is a vacuous round-trip — this version + // genuinely re-orders. + const reordered: Record = {}; + const sortedKeys = Object.keys(signed).sort().reverse(); + for (const k of sortedKeys) reordered[k] = (signed as Record)[k]; + expect(Object.keys(reordered)[0]).not.toBe(Object.keys(signed).sort()[0]); // sanity: order really differs + const ok = await verifyUCPProfile(reordered as never, buildJWKSResponse([publicJWK])); + expect(ok).toBe(true); + }); +}); + +describe('UCP signing — buildJWKSResponse', () => { + it('wraps keys in a `{ keys: [...] }` document', () => { + const k1 = { kid: 'a', kty: 'OKP', crv: 'Ed25519', x: 'xxx', use: 'sig', alg: 'EdDSA' }; + const k2 = { kid: 'b', kty: 'EC', crv: 'P-256', x: 'xxx', y: 'yyy', use: 'sig', alg: 'ES256' }; + const jwks = buildJWKSResponse([k1, k2]); + expect(jwks).toEqual({ keys: [k1, k2] }); + }); + + it('handles empty key set', () => { + expect(buildJWKSResponse([])).toEqual({ keys: [] }); + }); +}); + +describe('UCP signing — security: alg-confusion + typ + dup-kid', () => { + // RFC 8725 §3.1: a verifier MUST restrict accepted JWS algorithms to the + // set the application expects. A naive implementation that calls importJWK(jwk, header.alg) + // can be coerced into using HS256 (symmetric) with the public key as the secret — + // a hostile signing_keys[] entry then mints valid-looking signatures. + it('rejects HS256 signatures even when the JWKS contains an HS256 oct key', async () => { + const jose = await import('jose'); + const sharedSecret = new Uint8Array(32).fill(0xab); + const ocJwk = { + kid: 'attacker', + kty: 'oct', + alg: 'HS256', + use: 'sig', + k: Buffer.from(sharedSecret).toString('base64url'), + }; + const profile = buildUCPProfile({ ...baseInput, signing_keys: [ocJwk as never] }); + const stripped = { ...profile } as Record; + delete stripped.signature; + const sortedJson = (() => { + const sort = (v: unknown): unknown => { + if (v === null || typeof v !== 'object') return v; + if (Array.isArray(v)) return v.map(sort); + return Object.keys(v as Record).sort().reduce>((acc, k) => { + acc[k] = sort((v as Record)[k]); + return acc; + }, {}); + }; + return JSON.stringify(sort(stripped)); + })(); + const evilSig = await new jose.CompactSign(new TextEncoder().encode(sortedJson)) + .setProtectedHeader({ alg: 'HS256', kid: 'attacker', typ: 'agentscore-profile+jws' }) + .sign(sharedSecret); + const tampered = { ...profile, signature: evilSig }; + await expect(verifyUCPProfile(tampered as never, buildJWKSResponse([ocJwk as never]))) + .rejects.toThrow(UCPVerificationError); + }); + + it('rejects a JWS with typ != "agentscore-profile+jws"', async () => { + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: 'k' }); + const profile = buildUCPProfile({ ...baseInput, signing_keys: [publicJWK] }); + const jose = await import('jose'); + const stripped = { ...profile } as Record; + delete stripped.signature; + const sortedJson = (() => { + const sort = (v: unknown): unknown => { + if (v === null || typeof v !== 'object') return v; + if (Array.isArray(v)) return v.map(sort); + return Object.keys(v as Record).sort().reduce>((acc, k) => { + acc[k] = sort((v as Record)[k]); + return acc; + }, {}); + }; + return JSON.stringify(sort(stripped)); + })(); + const wrongTypSig = await new jose.CompactSign(new TextEncoder().encode(sortedJson)) + .setProtectedHeader({ alg: 'EdDSA', kid: 'k', typ: 'JWT' }) + .sign(privateKey as Parameters[0]); + await expect( + verifyUCPProfile({ ...profile, signature: wrongTypSig } as never, buildJWKSResponse([publicJWK])), + ).rejects.toThrow(/typ/); + }); + + it('rejects duplicate kids in the JWKS', async () => { + const a = await generateUCPSigningKey({ kid: 'dup' }); + const b = await generateUCPSigningKey({ kid: 'dup' }); + const profile = buildUCPProfile({ ...baseInput, signing_keys: [a.publicJWK] }); + const signed = await signUCPProfile(profile, { signingKey: a.privateKey, kid: 'dup' }); + await expect(verifyUCPProfile(signed, buildJWKSResponse([a.publicJWK, b.publicJWK]))) + .rejects.toThrow(/duplicate|2 keys/); + }); + + it('emits typed UCPVerificationError for body mismatch', async () => { + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: 'k' }); + const profile = buildUCPProfile({ ...baseInput, signing_keys: [publicJWK] }); + const signed = await signUCPProfile(profile, { signingKey: privateKey, kid: 'k' }); + const tampered = { ...signed, name: 'Different' }; + await expect(verifyUCPProfile(tampered, buildJWKSResponse([publicJWK]))) + .rejects.toMatchObject({ name: 'UCPVerificationError', code: 'body_mismatch' }); + }); + + it('emits typed UCPVerificationError for missing signature', async () => { + const { publicJWK } = await generateUCPSigningKey({ kid: 'k' }); + const profile = buildUCPProfile({ ...baseInput, signing_keys: [publicJWK] }); + await expect(verifyUCPProfile(profile as never, buildJWKSResponse([publicJWK]))) + .rejects.toMatchObject({ name: 'UCPVerificationError', code: 'no_signature' }); + }); + + it('emits typed UCPVerificationError for kid not in JWKS', async () => { + const signer = await generateUCPSigningKey({ kid: 'signer' }); + const other = await generateUCPSigningKey({ kid: 'other' }); + const profile = buildUCPProfile({ ...baseInput, signing_keys: [signer.publicJWK] }); + const signed = await signUCPProfile(profile, { signingKey: signer.privateKey, kid: 'signer' }); + await expect(verifyUCPProfile(signed, buildJWKSResponse([other.publicJWK]))) + .rejects.toMatchObject({ name: 'UCPVerificationError', code: 'kid_not_found' }); + }); + + it('rejects malformed JWS (not three segments)', async () => { + const { publicJWK } = await generateUCPSigningKey({ kid: 'k' }); + const profile = buildUCPProfile({ ...baseInput, signing_keys: [publicJWK] }); + const garbage = { ...profile, signature: 'not.a.jws' }; + await expect(verifyUCPProfile(garbage as never, buildJWKSResponse([publicJWK]))) + .rejects.toThrow(); + }); + + it('rejects a tampered signature segment with valid header+payload', async () => { + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: 'k' }); + const profile = buildUCPProfile({ ...baseInput, signing_keys: [publicJWK] }); + const signed = await signUCPProfile(profile, { signingKey: privateKey, kid: 'k' }); + const segments = signed.signature.split('.'); + // Flip a char near the start of the signature segment (NOT the last char, + // which is partial padding bits and may not affect the decoded signature). + const sig = segments[2]!; + const flippedChar = sig[0] === 'A' ? 'B' : 'A'; + const flipped = flippedChar + sig.slice(1); + const tampered = { ...signed, signature: `${segments[0]}.${segments[1]}.${flipped}` }; + // JWSSignatureVerificationFailed → wraps to signature_invalid (line 465-466 in ucp-jwks.ts). + await expect(verifyUCPProfile(tampered, buildJWKSResponse([publicJWK]))) + .rejects.toMatchObject({ name: 'UCPVerificationError', code: 'signature_invalid' }); + }); + + + it('signing twice with EdDSA is idempotent (deterministic signature)', async () => { + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: 'k' }); + const profile = buildUCPProfile({ ...baseInput, signing_keys: [publicJWK] }); + const a = await signUCPProfile(profile, { signingKey: privateKey, kid: 'k' }); + const b = await signUCPProfile(profile, { signingKey: privateKey, kid: 'k' }); + expect(a.signature).toBe(b.signature); + }); + + it('signing twice with ES256 produces different signatures but both verify', async () => { + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: 'k', alg: 'ES256' }); + const profile = buildUCPProfile({ ...baseInput, signing_keys: [publicJWK] }); + const a = await signUCPProfile(profile, { signingKey: privateKey, kid: 'k', alg: 'ES256' }); + const b = await signUCPProfile(profile, { signingKey: privateKey, kid: 'k', alg: 'ES256' }); + expect(a.signature).not.toBe(b.signature); + expect(await verifyUCPProfile(a, buildJWKSResponse([publicJWK]))).toBe(true); + expect(await verifyUCPProfile(b, buildJWKSResponse([publicJWK]))).toBe(true); + }); +}); + +describe('UCP signing — float canonicalization defense', () => { + it('throws when signing a profile that contains a non-integer Number anywhere', async () => { + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: 'k' }); + const profile = buildUCPProfile({ ...baseInput, signing_keys: [publicJWK] }); + (profile as unknown as Record).extras = { rate: 0.0125 }; + await expect(signUCPProfile(profile, { signingKey: privateKey, kid: 'k' })) + .rejects.toThrow(/non-integer Number/); + }); + + it('throws on NaN / Infinity', async () => { + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: 'k' }); + const profile = buildUCPProfile({ ...baseInput, signing_keys: [publicJWK] }); + (profile as unknown as Record).extras = { value: Number.POSITIVE_INFINITY }; + await expect(signUCPProfile(profile, { signingKey: privateKey, kid: 'k' })) + .rejects.toThrow(/non-finite Number/); + }); + + it('signing with integers + strings is fine', async () => { + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: 'k' }); + const profile = buildUCPProfile({ ...baseInput, signing_keys: [publicJWK] }); + (profile as unknown as Record).extras = { count: 7, label: 'wine' }; + const signed = await signUCPProfile(profile, { signingKey: privateKey, kid: 'k' }); + expect(await verifyUCPProfile(signed, buildJWKSResponse([publicJWK]))).toBe(true); + }); +}); + +describe('UCP signing — additional hardening', () => { + it('signUCPProfile throws when kid is not in profile.signing_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/); + }); + + it('verifyUCPProfile rejects malformed JWKS shape (missing keys array)', async () => { + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: 'k' }); + const profile = buildUCPProfile({ ...baseInput, signing_keys: [publicJWK] }); + const signed = await signUCPProfile(profile, { signingKey: privateKey, kid: 'k' }); + await expect(verifyUCPProfile(signed, {} as never)) + .rejects.toMatchObject({ name: 'UCPVerificationError', code: 'malformed_jwks' }); + }); + + it('verifyUCPProfile rejects null JWKS', async () => { + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: 'k' }); + const profile = buildUCPProfile({ ...baseInput, signing_keys: [publicJWK] }); + const signed = await signUCPProfile(profile, { signingKey: privateKey, kid: 'k' }); + await expect(verifyUCPProfile(signed, null as never)) + .rejects.toMatchObject({ name: 'UCPVerificationError', code: 'malformed_jwks' }); + }); + + it('verifyUCPProfile rejects JWKS where keys is not an array', async () => { + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: 'k' }); + const profile = buildUCPProfile({ ...baseInput, signing_keys: [publicJWK] }); + const signed = await signUCPProfile(profile, { signingKey: privateKey, kid: 'k' }); + await expect(verifyUCPProfile(signed, { keys: 'not-an-array' } as never)) + .rejects.toMatchObject({ name: 'UCPVerificationError', code: 'malformed_jwks' }); + }); + + it('verifyUCPProfile wraps unrecognized critical header into typed error', async () => { + const { generateUCPSigningKey, buildJWKSResponse, verifyUCPProfile } = await import('../../src/identity/ucp-jwks'); + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: 'k' }); + const profile = buildUCPProfile({ ...baseInput, signing_keys: [publicJWK] }); + + // Hand-craft a JWS with a critical header that the verifier doesn't recognize. + const { base64url } = await import('jose'); + const { sign } = await import('node:crypto'); + function ss(v: unknown): string { + if (v === null || typeof v !== 'object') return JSON.stringify(v); + if (Array.isArray(v)) return `[${v.map(ss).join(',')}]`; + const o = v as Record; + return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${ss(o[k])}`).join(',')}}`; + } + const canonical = ss(profile); + const headerJson = JSON.stringify({ alg: 'EdDSA', kid: 'k', typ: 'agentscore-profile+jws', crit: ['fakething'], fakething: 'x' }); + const headerB64 = base64url.encode(new TextEncoder().encode(headerJson)); + const payloadB64 = base64url.encode(new TextEncoder().encode(canonical)); + const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`); + const sigBytes = sign(null, data, privateKey as Parameters[2]); + const sigB64 = base64url.encode(sigBytes); + const jws = `${headerB64}.${payloadB64}.${sigB64}`; + const signed = { ...profile, signature: jws }; + + await expect(verifyUCPProfile(signed as never, buildJWKSResponse([publicJWK]))) + .rejects.toMatchObject({ name: 'UCPVerificationError', code: 'unrecognized_critical_header' }); + }); + + // RFC 7515 §4.1.11: crit MUST be a non-empty array of strings if present. + // The four cases below mirror python-commerce's malformed_jws parity tests so + // a malformed crit shape never silently falls through to the unrecognized + // branch (or worse, gets accepted) on either SDK. + it.each([ + { label: 'null', crit: null as unknown }, + { label: 'empty array', crit: [] as unknown }, + { label: 'string (not array)', crit: 'fakething' as unknown }, + { label: 'array with non-string element', crit: [42] as unknown }, + ])('verifyUCPProfile rejects malformed crit ($label) with malformed_jws', async ({ crit }) => { + const { generateUCPSigningKey, buildJWKSResponse, verifyUCPProfile } = await import('../../src/identity/ucp-jwks'); + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: 'k' }); + const profile = buildUCPProfile({ ...baseInput, signing_keys: [publicJWK] }); + + const { base64url } = await import('jose'); + const { sign } = await import('node:crypto'); + function ss(v: unknown): string { + if (v === null || typeof v !== 'object') return JSON.stringify(v); + if (Array.isArray(v)) return `[${v.map(ss).join(',')}]`; + const o = v as Record; + return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${ss(o[k])}`).join(',')}}`; + } + const canonical = ss(profile); + const headerJson = JSON.stringify({ alg: 'EdDSA', kid: 'k', typ: 'agentscore-profile+jws', crit }); + const headerB64 = base64url.encode(new TextEncoder().encode(headerJson)); + const payloadB64 = base64url.encode(new TextEncoder().encode(canonical)); + const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`); + const sigBytes = sign(null, data, privateKey as Parameters[2]); + const sigB64 = base64url.encode(sigBytes); + const jws = `${headerB64}.${payloadB64}.${sigB64}`; + const signed = { ...profile, signature: jws }; + + await expect(verifyUCPProfile(signed as never, buildJWKSResponse([publicJWK]))) + .rejects.toMatchObject({ name: 'UCPVerificationError', code: 'malformed_jws' }); + }); +}); + +describe('ucpSigningKeyFromJWK', () => { + it('round-trips an EdDSA public JWK from generateUCPSigningKey', async () => { + const { publicJWK } = await generateUCPSigningKey({ kid: 'rt-eddsa', alg: 'EdDSA' }); + const result = ucpSigningKeyFromJWK(publicJWK as Record); + const r = result as Record; + expect(r.kid).toBe('rt-eddsa'); + expect(r.kty).toBe('OKP'); + expect(r.crv).toBe('Ed25519'); + expect(r.alg).toBe('EdDSA'); + expect(r.use).toBe('sig'); + expect(typeof r.x).toBe('string'); + }); + + it('round-trips an ES256 public JWK from generateUCPSigningKey', async () => { + const { publicJWK } = await generateUCPSigningKey({ kid: 'rt-es256', alg: 'ES256' }); + const result = ucpSigningKeyFromJWK(publicJWK as Record); + const r = result as Record; + expect(r.kid).toBe('rt-es256'); + expect(r.kty).toBe('EC'); + expect(r.crv).toBe('P-256'); + expect(r.alg).toBe('ES256'); + expect(r.use).toBe('sig'); + expect(typeof r.x).toBe('string'); + expect(typeof r.y).toBe('string'); + }); + + it('rejects symmetric oct keys', () => { + expect(() => + ucpSigningKeyFromJWK({ kid: 'k', kty: 'oct', k: 'AAAA' }), + ).toThrow(/asymmetric/i); + }); + + it('rejects JWK missing kid', () => { + expect(() => ucpSigningKeyFromJWK({ kty: 'OKP' })).toThrow(/kid/); + }); + + it('rejects JWK missing kty', () => { + expect(() => ucpSigningKeyFromJWK({ kid: 'k' })).toThrow(/kty/); + }); + + it('rejects non-object inputs', () => { + expect(() => ucpSigningKeyFromJWK(null as never)).toThrow(); + expect(() => ucpSigningKeyFromJWK('string' as never)).toThrow(); + expect(() => ucpSigningKeyFromJWK(42 as never)).toThrow(); + }); + + it('rejects EC JWK missing crv', () => { + expect(() => ucpSigningKeyFromJWK({ kid: 'k', kty: 'EC' })).toThrow(/crv/); + }); + + it('rejects OKP JWK with empty crv', () => { + expect(() => ucpSigningKeyFromJWK({ kid: 'k', kty: 'OKP', crv: '' })).toThrow(/crv/); + }); +}); + +describe('UCP signing — JCS-incompatible value rejection', () => { + // Probe the internal stableStringify by signing a profile that holds the + // offending value. The signer canonicalizes via stableStringify, so any + // rejection there bubbles up through signUCPProfile. + async function signWith(extras: unknown): Promise { + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: 'k' }); + const profile = buildUCPProfile({ ...baseInput, signing_keys: [publicJWK] }); + (profile as unknown as Record).extras = extras; + await signUCPProfile(profile, { signingKey: privateKey, kid: 'k' }); + } + + it('rejects undefined values in objects', async () => { + await expect(signWith({ a: undefined })).rejects.toThrow(/undefined values are not allowed/); + }); + + it('rejects undefined values inside arrays', async () => { + await expect(signWith([1, undefined, 3])).rejects.toThrow(/undefined values are not allowed/); + }); + + it('rejects function values', async () => { + await expect(signWith({ a: () => {} })).rejects.toThrow(/function values are not allowed/); + }); + + it('rejects Symbol values', async () => { + await expect(signWith({ a: Symbol('x') })).rejects.toThrow(/symbol values are not allowed/); + }); + + it('rejects Date instances', async () => { + await expect(signWith({ a: new Date() })).rejects.toThrow(/Date instances are not allowed/); + }); + + it('rejects BigInt values', async () => { + await expect(signWith({ a: 1n })).rejects.toThrow(/BigInt values are not allowed/); + }); + + it('rejects Map values', async () => { + await expect(signWith({ a: new Map([['x', 1]]) })).rejects.toThrow(/Map values are not allowed/); + }); + + it('rejects Set values', async () => { + await expect(signWith({ a: new Set([1, 2]) })).rejects.toThrow(/Set values are not allowed/); + }); + + it('rejects WeakMap values', async () => { + await expect(signWith({ a: new WeakMap() })).rejects.toThrow(/WeakMap values are not allowed/); + }); + + it('rejects WeakSet values', async () => { + await expect(signWith({ a: new WeakSet() })).rejects.toThrow(/WeakSet values are not allowed/); + }); + + it('rejects typed arrays (Uint8Array)', async () => { + await expect(signWith({ a: new Uint8Array([1, 2, 3]) })).rejects.toThrow(/typed arrays are not allowed/); + }); + + it('rejects typed arrays (Int32Array)', async () => { + await expect(signWith({ a: new Int32Array([1, 2, 3]) })).rejects.toThrow(/typed arrays are not allowed/); + }); +}); + +describe('UCP signing — integer overflow defense', () => { + async function signWith(extras: unknown): Promise>> { + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: 'k' }); + const profile = buildUCPProfile({ ...baseInput, signing_keys: [publicJWK] }); + (profile as unknown as Record).extras = extras; + return signUCPProfile(profile, { signingKey: privateKey, kid: 'k' }); + } + + it('accepts Number.MAX_SAFE_INTEGER (2^53 - 1)', async () => { + await expect(signWith({ n: 9007199254740991 })).resolves.toBeDefined(); + }); + + it('rejects 2^53 (boundary, ambiguous in IEEE 754)', async () => { + await expect(signWith({ n: 9007199254740992 })).rejects.toThrow(/MAX_SAFE_INTEGER/); + }); + + it('rejects 2^60 as lossy (well above MAX_SAFE_INTEGER, distinct float from 2^53)', async () => { + await expect(signWith({ n: 2 ** 60 })).rejects.toThrow(/MAX_SAFE_INTEGER/); + }); + + it('rejects -(2^60) as lossy', async () => { + await expect(signWith({ n: -(2 ** 60) })).rejects.toThrow(/MAX_SAFE_INTEGER/); + }); + + it('accepts Number.MAX_SAFE_INTEGER', async () => { + await expect(signWith({ n: Number.MAX_SAFE_INTEGER })).resolves.toBeDefined(); + }); + + it('rejects Number.MAX_SAFE_INTEGER + 1', async () => { + await expect(signWith({ n: Number.MAX_SAFE_INTEGER + 1 })).rejects.toThrow(/MAX_SAFE_INTEGER/); + }); +}); + +describe('UCP signing — JWK alg / header alg consistency', () => { + it('rejects when matched JWK alg does not match JWS header alg', async () => { + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: 'mismatch', alg: 'EdDSA' }); + const profile = buildUCPProfile({ ...baseInput, signing_keys: [publicJWK] }); + const signed = await signUCPProfile(profile, { signingKey: privateKey, kid: 'mismatch', alg: 'EdDSA' }); + const lyingJWK = { ...(publicJWK as Record), alg: 'ES256' }; + const badJWKS = { keys: [lyingJWK] }; + await expect(verifyUCPProfile(signed, badJWKS as never)) + .rejects.toMatchObject({ name: 'UCPVerificationError', code: 'unusable_key' }); + }); +}); + +describe('UCP signing — round-4 hardening', () => { + it('rejects a JWK with use=enc as unusable_key', async () => { + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: 'enc-key', alg: 'EdDSA' }); + const profile = buildUCPProfile({ ...baseInput, signing_keys: [publicJWK] }); + const signed = await signUCPProfile(profile, { signingKey: privateKey, kid: 'enc-key', alg: 'EdDSA' }); + const badJWKS = { keys: [{ ...(publicJWK as Record), use: 'enc' }] }; + await expect(verifyUCPProfile(signed, badJWKS as never)) + .rejects.toMatchObject({ name: 'UCPVerificationError', code: 'unusable_key' }); + }); + + it('rejects non-string signature values with no_signature', async () => { + const { publicJWK } = await generateUCPSigningKey({ kid: 'k' }); + const profile = buildUCPProfile({ ...baseInput, signing_keys: [publicJWK] }); + for (const badSig of [42, null, [], {}]) { + const tampered = { ...profile, signature: badSig as unknown as string }; + await expect(verifyUCPProfile(tampered as never, buildJWKSResponse([publicJWK]))) + .rejects.toMatchObject({ name: 'UCPVerificationError', code: 'no_signature' }); + } + }); + + it('returns kid_not_found when JWKS contains a null entry', async () => { + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: 'real' }); + const profile = buildUCPProfile({ ...baseInput, signing_keys: [publicJWK] }); + const signed = await signUCPProfile(profile, { signingKey: privateKey, kid: 'real' }); + const badJWKS = { keys: [null] }; + await expect(verifyUCPProfile(signed, badJWKS as never)) + .rejects.toMatchObject({ name: 'UCPVerificationError', code: 'kid_not_found' }); + }); + + it('returns kid_not_found when JWKS contains a string entry', async () => { + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: 'real' }); + const profile = buildUCPProfile({ ...baseInput, signing_keys: [publicJWK] }); + const signed = await signUCPProfile(profile, { signingKey: privateKey, kid: 'real' }); + const badJWKS = { keys: ['string-not-jwk'] }; + await expect(verifyUCPProfile(signed, badJWKS as never)) + .rejects.toMatchObject({ name: 'UCPVerificationError', code: 'kid_not_found' }); + }); + + it('rejects a JWS whose protected header decodes to a JSON array', async () => { + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: 'k' }); + const profile = buildUCPProfile({ ...baseInput, signing_keys: [publicJWK] }); + const { base64url } = await import('jose'); + const { sign } = await import('node:crypto'); + + function ss(v: unknown): string { + if (v === null || typeof v !== 'object') return JSON.stringify(v); + if (Array.isArray(v)) return `[${v.map(ss).join(',')}]`; + const o = v as Record; + return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${ss(o[k])}`).join(',')}}`; + } + const canonical = ss(profile); + + const headerJson = JSON.stringify(['EdDSA', 'kid-x']); + const headerB64 = base64url.encode(new TextEncoder().encode(headerJson)); + const payloadB64 = base64url.encode(new TextEncoder().encode(canonical)); + const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`); + const sigBytes = sign(null, data, privateKey as Parameters[2]); + const sigB64 = base64url.encode(sigBytes); + const jws = `${headerB64}.${payloadB64}.${sigB64}`; + const signed = { ...profile, signature: jws }; + + await expect(verifyUCPProfile(signed as never, buildJWKSResponse([publicJWK]))) + .rejects.toBeInstanceOf(UCPVerificationError); + }); +}); + +describe('UCP signing — verifier-side canonicalize must not leak raw Error', () => { + async function makeSigned(): Promise<{ + signed: Awaited>; + jwks: ReturnType; + }> { + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: 'k' }); + const profile = buildUCPProfile({ ...baseInput, signing_keys: [publicJWK] }); + const signed = await signUCPProfile(profile, { signingKey: privateKey, kid: 'k' }); + return { signed, jwks: buildJWKSResponse([publicJWK]) }; + } + + it('emits typed body_mismatch when received profile carries a non-integer Number', async () => { + const { signed, jwks } = await makeSigned(); + const tampered = { ...signed, extras: { n: 1.5 } } as unknown as typeof signed; + await expect(verifyUCPProfile(tampered, jwks)) + .rejects.toMatchObject({ name: 'UCPVerificationError', code: 'body_mismatch' }); + }); + + it('emits typed body_mismatch when received profile carries an unsafe-large Number', async () => { + const { signed, jwks } = await makeSigned(); + const tampered = { ...signed, extras: { n: Number.MAX_SAFE_INTEGER + 1 } } as unknown as typeof signed; + await expect(verifyUCPProfile(tampered, jwks)) + .rejects.toMatchObject({ name: 'UCPVerificationError', code: 'body_mismatch' }); + }); + + it('emits typed body_mismatch when received profile carries NaN', async () => { + const { signed, jwks } = await makeSigned(); + const tampered = { ...signed, extras: { n: NaN } } as unknown as typeof signed; + await expect(verifyUCPProfile(tampered, jwks)) + .rejects.toMatchObject({ name: 'UCPVerificationError', code: 'body_mismatch' }); + }); + + it('emits typed body_mismatch when received profile carries Infinity', async () => { + const { signed, jwks } = await makeSigned(); + const tampered = { ...signed, extras: { n: Infinity } } as unknown as typeof signed; + await expect(verifyUCPProfile(tampered, jwks)) + .rejects.toMatchObject({ name: 'UCPVerificationError', code: 'body_mismatch' }); + }); + + it('emits typed body_mismatch when received profile carries a BigInt', async () => { + const { signed, jwks } = await makeSigned(); + const tampered = { ...signed, extras: { n: 1n } } as unknown as typeof signed; + await expect(verifyUCPProfile(tampered, jwks)) + .rejects.toMatchObject({ name: 'UCPVerificationError', code: 'body_mismatch' }); + }); +}); + +describe('UCP signing — error precedence parity (profile-first)', () => { + it('null profile + malformed JWKS returns no_signature (profile-first)', async () => { + await expect(verifyUCPProfile(null as never, 'not a jwks' as never)) + .rejects.toMatchObject({ name: 'UCPVerificationError', code: 'no_signature' }); + }); + + it('mixed body-malformed + wrong-typ JWS emits wrong_typ (header-first like Python)', async () => { + // Build a profile, sign it with the wrong typ (typ="JWT" instead of + // agentscore-profile+jws) so header validation rejects, then mutate the body + // to also carry a non-integer Number that would fail canonicalize. The + // verifier must surface `wrong_typ` (header-first), matching the Python + // sibling's _peek_jws_header order. + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: 'k' }); + const profile = buildUCPProfile({ ...baseInput, signing_keys: [publicJWK] }); + const jose = await import('jose'); + const stripped = { ...profile } as Record; + delete stripped.signature; + const sortedJson = (() => { + const sort = (v: unknown): unknown => { + if (v === null || typeof v !== 'object') return v; + if (Array.isArray(v)) return v.map(sort); + return Object.keys(v as Record).sort().reduce>((acc, k) => { + acc[k] = sort((v as Record)[k]); + return acc; + }, {}); + }; + return JSON.stringify(sort(stripped)); + })(); + const wrongTypSig = await new jose.CompactSign(new TextEncoder().encode(sortedJson)) + .setProtectedHeader({ alg: 'EdDSA', kid: 'k', typ: 'JWT' }) + .sign(privateKey as Parameters[0]); + const tampered = { ...profile, signature: wrongTypSig, extras: { rate: 1.5 } } as never; + await expect(verifyUCPProfile(tampered, buildJWKSResponse([publicJWK]))) + .rejects.toMatchObject({ name: 'UCPVerificationError', code: 'wrong_typ' }); + }); + + it('mixed alg=HS256 + typ=JWT JWS emits wrong_typ (typ-first like Python)', async () => { + // Hand-craft a JWS whose protected header carries BOTH a wrong typ + // ("JWT") AND a disallowed alg ("HS256"). Python's _peek_jws_header + // checks typ before alg, so it surfaces wrong_typ; Node must do the + // same so cross-SDK error codes stay aligned for any caller routing + // on `code`. + const jose = await import('jose'); + const { publicJWK } = await generateUCPSigningKey({ kid: 'k' }); + const profile = buildUCPProfile({ ...baseInput, signing_keys: [publicJWK] }); + const stripped = { ...profile } as Record; + delete stripped.signature; + const sortedJson = (() => { + const sort = (v: unknown): unknown => { + if (v === null || typeof v !== 'object') return v; + if (Array.isArray(v)) return v.map(sort); + return Object.keys(v as Record).sort().reduce>((acc, k) => { + acc[k] = sort((v as Record)[k]); + return acc; + }, {}); + }; + return JSON.stringify(sort(stripped)); + })(); + const sharedSecret = new Uint8Array(32).fill(0xab); + const mixedSig = await new jose.CompactSign(new TextEncoder().encode(sortedJson)) + .setProtectedHeader({ alg: 'HS256', kid: 'k', typ: 'JWT' }) + .sign(sharedSecret); + await expect( + verifyUCPProfile({ ...profile, signature: mixedSig } as never, buildJWKSResponse([publicJWK])), + ).rejects.toMatchObject({ name: 'UCPVerificationError', code: 'wrong_typ' }); + }); + + it('mixed crit + wrong typ JWS emits wrong_typ (typ-first like Python)', async () => { + // Hand-craft a JWS whose protected header carries BOTH a wrong typ ("JWT") + // AND an unrecognized crit header ("fakething"). jose's compactVerify + // enforces `crit` BEFORE invoking the key-resolver callback, so without + // the pre-decode pass this would surface `unrecognized_critical_header`. + // Python's _peek_jws_header decodes manually and checks typ first; Node + // mirrors that ordering so the same input emits the same `code` in both + // SDKs. + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: 'real' }); + const profile = buildUCPProfile({ ...baseInput, signing_keys: [publicJWK] }); + const { base64url } = await import('jose'); + const { sign } = await import('node:crypto'); + function ss(v: unknown): string { + if (v === null || typeof v !== 'object') return JSON.stringify(v); + if (Array.isArray(v)) return `[${v.map(ss).join(',')}]`; + const o = v as Record; + return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${ss(o[k])}`).join(',')}}`; + } + const canonical = ss(profile); + const headerJson = JSON.stringify({ alg: 'EdDSA', typ: 'JWT', kid: 'real', crit: ['fakething'], fakething: 'x' }); + const headerB64 = base64url.encode(new TextEncoder().encode(headerJson)); + const payloadB64 = base64url.encode(new TextEncoder().encode(canonical)); + const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`); + const sigBytes = sign(null, data, privateKey as Parameters[2]); + const sigB64 = base64url.encode(sigBytes); + const jws = `${headerB64}.${payloadB64}.${sigB64}`; + const signed = { ...profile, signature: jws }; + + await expect(verifyUCPProfile(signed as never, buildJWKSResponse([publicJWK]))) + .rejects.toMatchObject({ name: 'UCPVerificationError', code: 'wrong_typ' }); + }); +}); + +describe('UCP signing — U+2028 / U+2029 rejection', () => { + // Modern V8 emits U+2028 / U+2029 raw from JSON.stringify, so on today's Node + // the divergence with Python json.dumps(ensure_ascii=False) is theoretical. + // The rejection mirrors core/api/src/lib/canonicalize.ts so the contract + // stays symmetric for any pre-ES2019 verifier (older V8, browser-side + // verifier code) where JSON.stringify still escapes these codepoints. + async function signWith(extras: unknown): Promise { + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: 'k' }); + const profile = buildUCPProfile({ ...baseInput, signing_keys: [publicJWK] }); + (profile as unknown as Record).extras = extras; + await signUCPProfile(profile, { signingKey: privateKey, kid: 'k' }); + } + + it('rejects strings containing U+2028 (LINE SEPARATOR) at top level', async () => { + await expect(signWith({ note: 'before
after' })) + .rejects.toThrow(/U\+2028/); + }); + + it('rejects strings containing U+2029 (PARAGRAPH SEPARATOR) at top level', async () => { + await expect(signWith({ note: 'before
after' })) + .rejects.toThrow(/U\+2029/); + }); + + it('rejects U+2028 nested inside an array', async () => { + await expect(signWith({ items: ['ok', 'bad
tail'] })) + .rejects.toThrow(/U\+2028/); + }); + + it('rejects U+2029 nested inside an array', async () => { + await expect(signWith({ items: ['ok', 'bad
tail'] })) + .rejects.toThrow(/U\+2029/); + }); + + it('rejects U+2028 nested inside an object value', async () => { + await expect(signWith({ deep: { inner: 'before
after' } })) + .rejects.toThrow(/U\+2028/); + }); + + it('rejects U+2029 nested inside an object value', async () => { + await expect(signWith({ deep: { inner: 'before
after' } })) + .rejects.toThrow(/U\+2029/); + }); + + it('accepts U+2027 (HYPHENATION POINT) as sanity case — different codepoint, not a target', async () => { + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: 'k' }); + const profile = buildUCPProfile({ ...baseInput, signing_keys: [publicJWK] }); + (profile as unknown as Record).extras = { note: 'before‧after' }; + const signed = await signUCPProfile(profile, { signingKey: privateKey, kid: 'k' }); + expect(await verifyUCPProfile(signed, buildJWKSResponse([publicJWK]))).toBe(true); + }); + + // Object-key rejection: same cross-language byte-parity rationale as the + // value-side rejection above. Python's _reject_unsafe_numbers recurses into + // dict keys, so a Node-signed profile with U+2028 / U+2029 in an object key + // would canonicalize cleanly here but throw body_mismatch on Python verify. + it('rejects an object key containing U+2028 (LINE SEPARATOR)', async () => { + await expect(signWith({ 'bad
key': 'value' })) + .rejects.toThrow(/U\+2028/); + }); + + it('rejects a nested object key containing U+2029 (PARAGRAPH SEPARATOR)', async () => { + await expect(signWith({ outer: { 'bad
key': 'value' } })) + .rejects.toThrow(/U\+2029/); + }); + + it('accepts an object key containing U+2027 (HYPHENATION POINT) as sanity case', async () => { + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: 'k' }); + const profile = buildUCPProfile({ ...baseInput, signing_keys: [publicJWK] }); + (profile as unknown as Record).extras = { 'fine‧key': 'value' }; + const signed = await signUCPProfile(profile, { signingKey: privateKey, kid: 'k' }); + expect(await verifyUCPProfile(signed, buildJWKSResponse([publicJWK]))).toBe(true); + }); +}); + +describe('UCP signing — JWK use/alg null treated as absent', () => { + // RFC 7517 lists `use` and `alg` as optional. JSON null for these fields is + // out-of-spec but harmless; treat null as absent so the Node verifier + // matches Python's `is not None` semantics and a JWK with explicit nulls + // doesn't reject in one language and pass in the other. + it('verifies successfully when matched JWK has use=null', async () => { + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: 'null-use' }); + const profile = buildUCPProfile({ ...baseInput, signing_keys: [publicJWK] }); + const signed = await signUCPProfile(profile, { signingKey: privateKey, kid: 'null-use' }); + const jwksWithNullUse = { keys: [{ ...(publicJWK as Record), use: null }] }; + expect(await verifyUCPProfile(signed, jwksWithNullUse as never)).toBe(true); + }); + + it('verifies successfully when matched JWK has alg=null', async () => { + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: 'null-alg', alg: 'EdDSA' }); + const profile = buildUCPProfile({ ...baseInput, signing_keys: [publicJWK] }); + const signed = await signUCPProfile(profile, { signingKey: privateKey, kid: 'null-alg', alg: 'EdDSA' }); + const jwksWithNullAlg = { keys: [{ ...(publicJWK as Record), alg: null }] }; + expect(await verifyUCPProfile(signed, jwksWithNullAlg as never)).toBe(true); + }); + + it('still rejects use=enc with unusable_key (sanity: non-null wrong values still fail)', async () => { + const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: 'enc-sanity', alg: 'EdDSA' }); + const profile = buildUCPProfile({ ...baseInput, signing_keys: [publicJWK] }); + const signed = await signUCPProfile(profile, { signingKey: privateKey, kid: 'enc-sanity', alg: 'EdDSA' }); + const badJWKS = { keys: [{ ...(publicJWK as Record), use: 'enc' }] }; + await expect(verifyUCPProfile(signed, badJWKS as never)) + .rejects.toMatchObject({ name: 'UCPVerificationError', code: 'unusable_key' }); + }); +}); diff --git a/tests/identity/ucp.test.ts b/tests/identity/ucp.test.ts index e759f05..e6bdc9a 100644 --- a/tests/identity/ucp.test.ts +++ b/tests/identity/ucp.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { AGENTSCORE_UCP_CAPABILITY, buildUCPProfile } from '../../src/identity/ucp'; +import { + AGENTSCORE_UCP_CAPABILITY, + buildUCPProfile, + type UCPCapabilityBinding, + type UCPPaymentHandlerBinding, + type UCPServiceBinding, +} from '../../src/identity/ucp'; import type { AgentScoreData } from '../../src/core'; const fullData: AgentScoreData = { @@ -16,28 +22,48 @@ const fullData: AgentScoreData = { }, }; +const sampleServiceBinding: UCPServiceBinding = { + version: '2026-04-08', + spec: 'https://ucp.dev/2026-04-08/specification/overview', + transport: 'mcp', + endpoint: 'https://agents.example/api/ucp/mcp', + schema: 'https://ucp.dev/services/shopping/openrpc.json', +}; + const baseInput = { - services: [{ type: 'rest', url: 'https://agents.example' }], + services: { 'dev.ucp.shopping': [sampleServiceBinding] }, signing_keys: [{ kid: 'me-2026', kty: 'EC', alg: 'ES256', crv: 'P-256', x: 'x', y: 'y' }], }; -describe('buildUCPProfile', () => { - it('emits a base profile with required fields when no AgentScore data', () => { +const agentscoreCap = (profile: ReturnType): UCPCapabilityBinding | undefined => { + return profile.ucp.capabilities[AGENTSCORE_UCP_CAPABILITY]?.[0]; +}; + +describe('buildUCPProfile (spec-compliant shape)', () => { + it('emits the spec envelope with `ucp` body + outer `signing_keys`', () => { const profile = buildUCPProfile(baseInput); - expect(profile.spec).toBe('https://ucp.dev/'); - expect(profile.version).toMatch(/^\d{4}-\d{2}-\d{2}$/); - expect(profile.services).toEqual(baseInput.services); + expect(profile.ucp).toBeDefined(); expect(profile.signing_keys).toEqual(baseInput.signing_keys); - expect(profile.capabilities).toEqual([]); - expect(profile.payment_handlers).toEqual([]); + expect(profile.ucp.version).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(profile.ucp.services).toEqual(baseInput.services); + expect(profile.ucp.capabilities).toEqual({}); + expect(profile.ucp.payment_handlers).toEqual({}); + // No top-level `spec` field per UCP spec — spec lives per-binding. + expect((profile as Record).spec).toBeUndefined(); + // No `version` at top level either; lives under `ucp`. + expect((profile as Record).version).toBeUndefined(); }); - it('appends agentscore-identity capability when data carries a resolved operator', () => { + it('appends sh.agentscore.identity capability when data carries a resolved operator', () => { const profile = buildUCPProfile({ ...baseInput, data: fullData }); - const cap = profile.capabilities.find((c) => c.name === AGENTSCORE_UCP_CAPABILITY); + const cap = agentscoreCap(profile); expect(cap).toBeDefined(); expect(cap?.version).toBe('1'); - expect(cap?.schema).toContain('agentscore-identity.v1.json'); + expect(cap?.spec).toContain('agentscore.sh'); + expect(cap?.schema).toContain('sh-agentscore-identity-v1.json'); + // Multi-parent extends — matches Shopify's dev.shopify.catalog.storefront pattern + // and UCP-canonical dev.ucp.shopping.discount (extends [checkout, cart]). + expect(cap?.extends).toEqual(['dev.ucp.shopping.checkout', 'dev.ucp.shopping.cart']); const claims = (cap as Record).claims as Record; expect(claims.operator_id).toBe('op_abc'); expect(claims.kyc_level).toBe('enhanced'); @@ -53,46 +79,166 @@ describe('buildUCPProfile', () => { ...baseInput, data: { decision: null, decision_reasons: [] }, }); - expect(profile.capabilities.find((c) => c.name === AGENTSCORE_UCP_CAPABILITY)).toBeUndefined(); + expect(agentscoreCap(profile)).toBeUndefined(); + expect(AGENTSCORE_UCP_CAPABILITY in profile.ucp.capabilities).toBe(false); }); - it('preserves caller-supplied capabilities and appends agentscore at end', () => { + it('preserves caller-supplied capabilities and merges agentscore in alongside', () => { + const checkoutBinding: UCPCapabilityBinding = { + version: '2026-04-08', + spec: 'https://ucp.dev/2026-04-08/specification/checkout', + schema: 'https://ucp.dev/2026-04-08/schemas/shopping/checkout.json', + }; const profile = buildUCPProfile({ ...baseInput, - capabilities: [{ name: 'checkout', version: '2' }], + capabilities: { 'dev.ucp.shopping.checkout': [checkoutBinding] }, data: fullData, }); - expect(profile.capabilities[0]?.name).toBe('checkout'); - expect(profile.capabilities[1]?.name).toBe(AGENTSCORE_UCP_CAPABILITY); + expect(profile.ucp.capabilities['dev.ucp.shopping.checkout']?.[0]?.version).toBe('2026-04-08'); + expect(agentscoreCap(profile)?.version).toBe('1'); }); - it('passes through name + payment_handlers + extras', () => { + it('passes through name + payment_handlers + extras + ucp_extras', () => { + const tempoHandler: UCPPaymentHandlerBinding = { + id: 'tempo', + version: '2026-04-08', + spec: 'https://agentscore.sh/specification/payment-handlers/tempo', + schema: 'https://agentscore.sh/schemas/payment-handlers/tempo.json', + config: { recipient: '0xtempo' }, + }; const profile = buildUCPProfile({ ...baseInput, name: 'Example Merchant', - payment_handlers: [ - { name: 'tempo', config: { recipient: '0xtempo' } }, - { name: 'stripe', config: { profile_id: 'prof_x' } }, - ], - extras: { custom_field: 'custom_value' }, + payment_handlers: { 'sh.agentscore.payment.tempo': [tempoHandler] }, + extras: { custom_top_level: 'top_value' }, + ucp_extras: { custom_ucp_field: 'ucp_value' }, + }); + expect(profile.ucp.name).toBe('Example Merchant'); + expect(profile.ucp.payment_handlers['sh.agentscore.payment.tempo']?.[0]?.id).toBe('tempo'); + expect((profile as Record).custom_top_level).toBe('top_value'); + expect((profile.ucp as Record).custom_ucp_field).toBe('ucp_value'); + }); + + it('payment_handler binding omits config when caller does not set it (cross-lang parity)', () => { + const tempoHandlerNoConfig: UCPPaymentHandlerBinding = { + id: 'tempo', + version: '2026-04-08', + spec: 'https://agentscore.sh/specification/payment-handlers/tempo', + schema: 'https://agentscore.sh/schemas/payment-handlers/tempo.json', + }; + const profile = buildUCPProfile({ + ...baseInput, + payment_handlers: { 'sh.agentscore.payment.tempo': [tempoHandlerNoConfig] }, }); - expect(profile.name).toBe('Example Merchant'); - expect(profile.payment_handlers).toHaveLength(2); - expect((profile as Record).custom_field).toBe('custom_value'); + const handler = profile.ucp.payment_handlers['sh.agentscore.payment.tempo']?.[0]; + expect(handler).toBeDefined(); + expect('config' in (handler as object)).toBe(false); }); - it('respects custom version override', () => { + it('respects custom version override under ucp.version', () => { const profile = buildUCPProfile({ ...baseInput, version: '2026-12-31' }); - expect(profile.version).toBe('2026-12-31'); + expect(profile.ucp.version).toBe('2026-12-31'); + }); + + it('respects agentscore_schema_url override on the auto-injected capability', () => { + const profile = buildUCPProfile({ + ...baseInput, + data: fullData, + agentscore_schema_url: 'https://custom.example/schema.json', + }); + expect(agentscoreCap(profile)?.schema).toBe('https://custom.example/schema.json'); }); - it('respects agentscoreSchemaUrl override', () => { + it('respects agentscore_spec_url override on the auto-injected capability', () => { const profile = buildUCPProfile({ ...baseInput, data: fullData, - agentscoreSchemaUrl: 'https://custom.example/schema.json', + agentscore_spec_url: 'https://custom.example/spec', + }); + expect(agentscoreCap(profile)?.spec).toBe('https://custom.example/spec'); + }); + + it('emits supported_versions map under ucp body when supplied', () => { + const profile = buildUCPProfile({ + ...baseInput, + supported_versions: { + '2026-04-08': 'https://merchant.example/.well-known/ucp/2026-04-08', + '2026-01-23': 'https://merchant.example/.well-known/ucp/2026-01-23', + }, + }); + expect(profile.ucp.supported_versions?.['2026-04-08']).toContain('/2026-04-08'); + }); + + it.each([['ucp'], ['signing_keys'], ['signature'], ['__proto__'], ['constructor'], ['prototype']])( + 'rejects extras key "%s" as a reserved top-level collision', + (k) => { + expect(() => buildUCPProfile({ ...baseInput, extras: { [k]: 'attacker' } })).toThrow( + /collides with a reserved profile field/, + ); + }, + ); + + it.each([ + ['version'], + ['name'], + ['services'], + ['capabilities'], + ['payment_handlers'], + ['supported_versions'], + ['__proto__'], + ['constructor'], + ['prototype'], + ])('rejects ucp_extras key "%s" as a reserved ucp-field collision', (k) => { + expect(() => buildUCPProfile({ ...baseInput, ucp_extras: { [k]: 'attacker' } })).toThrow( + /collides with a reserved `ucp` field/, + ); + }); + + // Empty-string and null normalization: the API can emit `account_verification` with + // either null or `""` for un-set fields, and the node + python siblings must produce + // the SAME canonical claims block for either shape so a profile signed in one + // language verifies in the other. + describe('account_verification missing-value normalization (cross-lang parity)', () => { + const baseDataWithOp = { + decision: 'allow', + decision_reasons: [], + resolved_operator: 'op_abc', + }; + + const claimsOf = (av: AgentScoreData['account_verification']): Record => { + const profile = buildUCPProfile({ + ...baseInput, + data: { ...baseDataWithOp, account_verification: av } as AgentScoreData, + }); + return (agentscoreCap(profile) as Record).claims as Record; + }; + + it('coerces empty-string kyc_level to "none"', () => { + expect(claimsOf({ kyc_level: '' }).kyc_level).toBe('none'); + }); + + it('coerces null age_bracket to "unknown"', () => { + expect(claimsOf({ age_bracket: null as unknown as string }).age_bracket).toBe('unknown'); + }); + + it('coerces empty-string age_bracket to "unknown"', () => { + expect(claimsOf({ age_bracket: '' }).age_bracket).toBe('unknown'); + }); + + it('coerces null jurisdiction to ""', () => { + expect(claimsOf({ jurisdiction: null as unknown as string }).jurisdiction).toBe(''); + }); + + it('coerces empty-string jurisdiction to ""', () => { + expect(claimsOf({ jurisdiction: '' }).jurisdiction).toBe(''); + }); + + it('coerces null verified_at to null', () => { + expect(claimsOf({ verified_at: null }).verified_at).toBeNull(); + }); + + it('coerces empty-string verified_at to null', () => { + expect(claimsOf({ verified_at: '' }).verified_at).toBeNull(); }); - const cap = profile.capabilities.find((c) => c.name === AGENTSCORE_UCP_CAPABILITY); - expect(cap?.schema).toBe('https://custom.example/schema.json'); }); }); diff --git a/tests/payment/headers.test.ts b/tests/payment/headers.test.ts index 31f6bdd..ccf452a 100644 --- a/tests/payment/headers.test.ts +++ b/tests/payment/headers.test.ts @@ -92,4 +92,47 @@ describe('buildPaymentHeaders', () => { expect(result['www-authenticate']).toContain('intent="session"'); expect(result['www-authenticate']).toContain(`expires="${expires}"`); }); + + it('forwards optional chainId / currency / decimals / method overrides', () => { + // Drives the four conditional spreads on the per-rail directiveInput. + const result = buildPaymentHeaders({ + orderId: 'ord_8', + realm: 'a.example', + rails: [{ + rail: 'x402-base-mainnet', + amountUsd: 1, + recipient: '0xa', + chainId: 999, + currency: '0xCustomToken000000000000000000000000000000', + decimals: 8, + method: 'x402/exact-evm', + }], + }); + const directive = result['www-authenticate']; + expect(directive).toContain('method="x402/exact-evm"'); + const requestMatch = /request="([^"]+)"/.exec(directive); + expect(requestMatch).toBeTruthy(); + const requestBlob = JSON.parse( + Buffer.from(requestMatch![1]!, 'base64url').toString(), + ); + expect(requestBlob.currency).toBe('0xCustomToken000000000000000000000000000000'); + expect(requestBlob.decimals).toBe(8); + expect(requestBlob.methodDetails?.chainId).toBe(999); + }); + + it('forwards optional x402.resource into the PAYMENT-REQUIRED header', () => { + // Drives the `...(input.x402.resource ? ... : {})` spread branch. + const result = buildPaymentHeaders({ + orderId: 'ord_9', + realm: 'a.example', + rails: [{ rail: 'x402-base-mainnet', amountUsd: 1, recipient: '0xa' }], + x402: { + accepts: [{ scheme: 'exact', network: 'eip155:8453' }], + version: 1, + resource: { url: 'https://api.example/buy', mimeType: 'application/json' }, + }, + }); + const decoded = JSON.parse(Buffer.from(result['PAYMENT-REQUIRED']!, 'base64').toString()); + expect(decoded.resource).toEqual({ url: 'https://api.example/buy', mimeType: 'application/json' }); + }); }); diff --git a/tests/signer-match.test.ts b/tests/signer-match.test.ts index 0994470..89f986a 100644 --- a/tests/signer-match.test.ts +++ b/tests/signer-match.test.ts @@ -311,6 +311,90 @@ describe('AgentScoreCore.verifyWalletSignerMatch — coverage paths', () => { }); expect(result.kind).toBe('api_error'); }); + + it('projects API-emitted signer_match wallet_auth_requires_wallet_signing verdict', async () => { + // Drives the `kind === 'wallet_auth_requires_wallet_signing'` branch in projectSignerMatch. + // The API itself decides to surface this verdict (e.g. server-side policy says wallet + // identity requires a wallet-signing rail even though a signer was provided). + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + decision: 'allow', + decision_reasons: [], + signer_match: { + kind: 'wallet_auth_requires_wallet_signing', + claimed_wallet: '0xaaa0000000000000000000000000000000000000', + agent_instructions: '{"recovery_action": "use_wallet_signing"}', + }, + }), + } as unknown as Response); + const core = createAgentScoreCore({ apiKey: API_KEY }); + const result = await core.verifyWalletSignerMatch({ + claimedWallet: '0xaaa0000000000000000000000000000000000000', + signer: '0xbbb0000000000000000000000000000000000000', + }); + expect(result.kind).toBe('wallet_auth_requires_wallet_signing'); + if (result.kind === 'wallet_auth_requires_wallet_signing') { + expect(result.claimedWallet).toBe('0xaaa0000000000000000000000000000000000000'); + expect(result.agentInstructions).toContain('use_wallet_signing'); + } + }); + + it('falls back to api_error when fallback resolveWalletToOperator fails after missing signer_match', async () => { + // Drives the `if (!claimedResolve.ok || !signerResolve.ok)` branch in the legacy + // 2-resolve fallback path, plus the resolveWalletToOperator catch (line 850). + // Sequence: first assess succeeds with NO signer_match → fallback runs two + // resolveWalletToOperator calls; the second throws so we hit { ok: false }. + let callIdx = 0; + global.fetch = vi.fn().mockImplementation(async (url: string) => { + if (typeof url === 'string' && url.includes('/v1/assess')) { + callIdx++; + if (callIdx === 1) { + // Initial verifyWalletSignerMatch assess — server omitted signer_match. + return { + ok: true, + status: 200, + json: async () => ({ + decision: 'allow', + decision_reasons: [], + resolved_operator: 'op_claimed', + }), + } as unknown as Response; + } + if (callIdx === 2) { + // First fallback resolve (claimed) — succeeds. + return { + ok: true, + status: 200, + json: async () => ({ + decision: 'allow', + decision_reasons: [], + resolved_operator: 'op_claimed', + }), + } as unknown as Response; + } + // Second fallback resolve (signer) — fails, hits resolveWalletToOperator catch. + throw new Error('upstream resolve outage'); + } + return { ok: true, status: 201, json: async () => ({}) } as unknown as Response; + }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const core = createAgentScoreCore({ apiKey: API_KEY }); + const result = await core.verifyWalletSignerMatch({ + claimedWallet: '0xddd0000000000000000000000000000000000000', + signer: '0xeee0000000000000000000000000000000000000', + }); + expect(result.kind).toBe('api_error'); + if (result.kind === 'api_error') { + expect(result.claimedWallet).toBe('0xddd0000000000000000000000000000000000000'); + } + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('resolveWalletToOperator failed'), + expect.anything(), + ); + warn.mockRestore(); + }); }); describe('AgentScoreCore.verifyWalletSignerMatch — telemetry', () => { diff --git a/tests/signer.test.ts b/tests/signer.test.ts index a425b5c..74ff4da 100644 --- a/tests/signer.test.ts +++ b/tests/signer.test.ts @@ -195,6 +195,53 @@ describe('extractPaymentSignerAddress — MPP path', () => { vi.doUnmock('@solana/kit'); }); + it('returns null when @solana/kit ships getBase64Codec but lacks getTransactionDecoder', async () => { + // Drives the second condition of the `!kit?.getBase64Codec || !kit.getTransactionDecoder + // || !kit.getCompiledTransactionMessageDecoder` short-circuit in extractSolanaSignerFromCredential. + vi.doMock('mppx', () => ({ + Credential: { + extractPaymentScheme: () => true, + fromRequest: () => ({ + payload: { transaction: 'AAAA', type: 'transaction' }, + }), + }, + })); + vi.doMock('@solana/kit', () => ({ + getBase64Codec: () => ({ encode: () => new Uint8Array([0]) }), + getCompiledTransactionMessageDecoder: () => ({ decode: () => ({}) }), + })); + const { extractPaymentSigner: freshExtract } = await import( + `../src/signer?solana-no-tx-decoder=${freshImportKey()}` + ); + const req = makeRequest({ authorization: 'Payment mpp-cred' }); + expect(await freshExtract(req)).toBeNull(); + vi.doUnmock('mppx'); + vi.doUnmock('@solana/kit'); + }); + + it('returns null when @solana/kit ships getBase64Codec + getTransactionDecoder but lacks getCompiledTransactionMessageDecoder', async () => { + // Drives the third condition of the same short-circuit. + vi.doMock('mppx', () => ({ + Credential: { + extractPaymentScheme: () => true, + fromRequest: () => ({ + payload: { transaction: 'AAAA', type: 'transaction' }, + }), + }, + })); + vi.doMock('@solana/kit', () => ({ + getBase64Codec: () => ({ encode: () => new Uint8Array([0]) }), + getTransactionDecoder: () => ({ decode: () => ({ messageBytes: new Uint8Array([0]) }) }), + })); + const { extractPaymentSigner: freshExtract } = await import( + `../src/signer?solana-no-msg-decoder=${freshImportKey()}` + ); + const req = makeRequest({ authorization: 'Payment mpp-cred' }); + expect(await freshExtract(req)).toBeNull(); + vi.doUnmock('mppx'); + vi.doUnmock('@solana/kit'); + }); + it('skips and warns when TransferChecked authority resolves through an address lookup table', async () => { // staticAccounts has only 4 entries; instruction asks for index 99 (i.e. lookup-table-loaded). // Path: bounds-check trips, console.warn, continue, no further match → null.