Skip to content

feat(identity): UCP profile signing helpers (1.3.4)#12

Merged
vvillait88 merged 35 commits intomainfrom
feat/ucp-signing
May 10, 2026
Merged

feat(identity): UCP profile signing helpers (1.3.4)#12
vvillait88 merged 35 commits intomainfrom
feat/ucp-signing

Conversation

@vvillait88
Copy link
Copy Markdown
Contributor

Summary

  • Add four helpers for UCP §6 trust-mode profiles: generateUCPSigningKey, signUCPProfile, verifyUCPProfile, buildJWKSResponse. Profiles are JWS-signed (Compact Serialization) over a JCS-canonicalized body. Both EdDSA (Ed25519, default) and ES256 supported.
  • Cross-language byte parity with agentscore-commerce Python SDK 1.3.7 — profiles signed by either SDK verify in the other.
  • jose is an optional peer dep — vendors install it only when publishing signed UCP profiles. Unsigned buildUCPProfile keeps working.
  • /.well-known/jwks.json added to the default discovery-path allowlist.
  • Bumps to 1.3.4.

Test plan

  • Round-trip sign+verify for Ed25519 and ES256
  • Multi-key JWKS resolves by kid
  • Tamper detection via byte-equal canonical-body check after JWS verify
  • Missing key in JWKS rejects
  • Profile without signature rejects
  • Key-order-independent verification (canonicalization)
  • Full suite: 681 passed, 4 skipped

🤖 Generated with Claude Code

vvillait88 and others added 30 commits May 8, 2026 10:57
Add four helpers for UCP §6 trust-mode profiles: generateUCPSigningKey,
signUCPProfile, verifyUCPProfile, buildJWKSResponse. Profiles are signed
with JWS Compact Serialization over a JCS-canonicalized body; verifiers
look up the kid in the merchant's JWKS and validate against the canonical
body of the presented profile. Both EdDSA (Ed25519) and ES256 are
supported.

Cross-language byte parity with agentscore-commerce Python SDK: profiles
signed by either flavor verify in the other.

Bumps to 1.3.4. jose is an optional peer dep; vendors install it only when
publishing signed UCP profiles. /.well-known/jwks.json is added to the
default discovery path allowlist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- mppx 0.6.15 → 0.6.16 (dev)
- @types/node 25.6.0 → 25.6.2 (dev)
- override: fast-uri ^3.1.1 (resolves to 3.1.2) — addresses
  GHSA-q3j6-qgpj-74h6 in ajv's transitive dep, surfaced via the OSV
  dependency scan on this branch's CI

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reviewer audit surfaced three real issues + several gaps. Fixes:

Security:
- verifyUCPProfile restricts alg to ['EdDSA','ES256'] before importJWK and
  passes algorithms[] to compactVerify, closing the alg-confusion attack
  where a hostile oct JWK in signing_keys[] could mint HS256 signatures
  that verified.
- Enforces JWS protected header `typ='ucp-profile+jws'` (RFC 8725 §3.11).
- Rejects duplicate kids in JWKS (prevents ambiguous key resolution).
- canonicalizeProfile rejects non-integer Number values at sign-time
  (cross-language float canonicalization is not stable; use decimal
  strings for monetary fields).

Ergonomics:
- New UCPVerificationError with discriminated `code`
  (no_signature/missing_kid/kid_not_found/duplicate_kid/unsupported_alg/
  wrong_typ/signature_invalid/body_mismatch/malformed_jws). Consumers
  can branch without parsing message strings.

Docs:
- README documents alg/typ/kid enforcement, error codes, float defense,
  HSM/KMS integration, key rotation runbook, JWKS-vs-inline trust model.
- New examples/signed-ucp-merchant.ts demonstrates the full sign+verify
  flow with env-var-loaded key + ephemeral fallback + race-safe Promise
  cache + cache headers.

Tests:
- Added 12 security regression tests including a real HS256 alg-confusion
  attack assertion, typ check, dup-kid, malformed JWS, signature segment
  tampering, EdDSA determinism, ES256 non-determinism, float rejection.
- New cross-language fixture corpus (tests/fixtures/cross-lang/) with 6
  signed profiles (3 Node + 3 Python) that both SDKs verify, locking in
  byte parity at CI level.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Second-pass review surfaced a critical Promise-cache regression + several
gap fixes. Net 12 new tests.

- loadSigningKey caches rejected Promise → fixed in 3 places
  (martin-estate / core/api / examples/signed-ucp-merchant.ts): on
  rejection, clear the cache so next caller retries instead of
  permanently poisoning the merchant.
- verifyUCPProfile: wrap JOSENotSupported (RFC 7515 §4.1.11 unrecognized
  `crit` header) into UCPVerificationError('unrecognized_critical_header').
  RFC 8725 §3.10 says verifiers MUST reject unknown crit; previously we
  leaked raw library exception.
- verifyUCPProfile: malformed JWKS shape (null, missing keys, non-array)
  now emits UCPVerificationError('malformed_jwks') instead of raw
  TypeError. New code added to the discriminated enum.
- verifyUCPProfile: reject JWKs with use != 'sig' as 'unusable_key'
  (RFC 7517 §4.2).
- signUCPProfile: enforce that opts.kid matches a JWK in
  profile.signing_keys[] at sign-time. Previously a kid-mismatch silently
  produced an envelope no verifier could resolve.
- canonicalize: reject NaN/Infinity Numbers in addition to non-integer
  Numbers. Lossy JSON serialization avoided.
- Cross-language fixture corpus: 6 → 12 fixtures covering minimal,
  ES256-rails, extras-int, capability, unicode, multi-key per language.
  Test boundary tightened from `>=6` to specific scenarios.
- Fix vacuous key-order-independence test that was a no-op
  (JSON.parse/stringify preserves order); now hand-constructs reverse
  insertion-order objects.
- Fix broken JSDoc subpath imports
  (`@agent-score/commerce/identity/ucp-jwks` → `@agent-score/commerce`).
- README KMS claim rewritten — `createPrivateKey` does NOT wrap remote
  KMS signers; spelled out the actual persistence pattern.
- Tampered-signature test now flips the FIRST char (not last) since the
  last base64url char is partial padding bits and may not affect the
  decoded signature.

Tests: 712 pass, 4 skipped. Lint + typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- verifyUCPProfile: non-string signature now emits typed
  UCPVerificationError(no_signature) instead of jose throwing a
  less-typed downstream error. Parity with Python.
- verifyUCPProfile: JWS header kid must be a non-empty string. Strict
  type check prevents non-string kid (number/bool/null) from
  accidentally matching JWKs with equal-typed kids.
- signUCPProfile: opts.kid must be a non-empty string. Empty string was
  silently passing the in-signing_keys check.
- buildUCPProfile: extras-collision filter rejects keys that match
  reserved profile fields (signing_keys, services, etc.) so a careless
  `extras: { signing_keys: [...] }` can't silently destroy the explicit
  field.
- New ucpSigningKeyFromJWK helper (parity with Python's
  UCPSigningKey.from_jwk classmethod). Validates kid+kty, rejects oct
  symmetric keys.

Tests: 712 pass, 4 skipped. Lint + typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Defensive guards + test parity with the Python sibling.

ucp-jwks.ts:
- verifyUCPProfile guards profile shape at entry: reject null,
  non-object, and arrays with a typed UCPVerificationError(no_signature)
  rather than throwing TypeError on `delete stripped.signature`.
- JWKS-entry filter skips null and non-object entries so a malformed
  keys[] cannot crash on `.kid` access; surfaces as kid_not_found.
- signUCPProfile validates alg against ALLOWED_ALGS at runtime, matching
  the verifier's allowlist; an out-of-band alg now fails at sign time.
- kid_not_found and duplicate_kid messages JSON.stringify the kid value
  to avoid log injection from an attacker-controlled string.

Tests:
- ucpSigningKeyFromJWK round-trip coverage for EdDSA + ES256, and
  rejection of oct, missing kid, missing kty, and non-object inputs.
- verifyUCPProfile rejects use=enc as unusable_key.
- verifyUCPProfile rejects non-string signature values (42, null, [],
  {}) as no_signature.
- JWKS keys[] containing null or string entries surfaces kid_not_found.
- JWS protected header that decodes to a JSON array is rejected as a
  typed UCPVerificationError instead of leaking a TypeError.

Docs + example:
- README error-code list now includes unusable_key, malformed_jwks, and
  unrecognized_critical_header with one-line explanations.
- signed-ucp-merchant.ts demonstrates ucpSigningKeyFromJWK on the
  signing_keys[] entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tighten stableStringify rejection set + verifier alg consistency + lock
codepoint-aware key sort with a non-ASCII fixture.

stableStringify now rejects undefined, function, Symbol, and Date values
explicitly so silently-dropped fields or {} from Date#toJSON cannot break
cross-language byte parity. Adds Number.isSafeInteger check after the
existing isInteger guard to reject integers above 2^53 (which Node has
already silently rounded by the time stableStringify sees them).

Object key sort switches from default (UTF-16 code units) to Unicode
codepoint order so Node matches Python json.dumps(sort_keys=True) for
supplementary-plane keys.

verifyUCPProfile additionally rejects when a matched JWK declares an
alg that disagrees with the JWS protected-header alg (RFC 7517 4.4).

Adds node-i18n-keys cross-lang fixture (BMP Latin-1 cafe + BMP CJK +
non-BMP wine glass + ASCII low/high) as a regression lock for the
codepoint sort. Verified the same fixture passes the python-commerce
verifier; matching py-i18n-keys lands on that side in lockstep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tighten cross-lang fixture corpus + extras-collision defenses + stableStringify
rejection set so cross-language byte parity has airtight regression coverage.

Cross-lang fixture corpus: previous emoji/i18n fixtures used chars (cafe, JP, wine
glass, CJK Compat) where UTF-16 first-unit sort and Unicode codepoint sort produce
identical canonical bodies, so the codepoint-sort fix was silently untested.
Replaces node-i18n-keys.json with node-emoji-keys.json using a key set that
genuinely distinguishes the two: BMP private use (U+E000, codepoint 57344)
alongside supplementary-plane wine glass (U+1F377, codepoint 127863, UTF-16 first
unit 55356). Codepoint sort puts U+E000 BEFORE U+1F377; UTF-16 first-unit sort
reverses that. Both repos now ship both node-emoji-keys.json and py-emoji-keys.json
in their fixture corpus so each repo's verifier validates the OTHER language's
canonical body. A regression in either language's key sort surfaces here.

buildUCPProfile RESERVED set adds __proto__, constructor, prototype so vendor
extras can't slip prototype-pollution payloads into the canonical body and
surprise downstream consumers.

stableStringify gains explicit reject branches for BigInt (raw TypeError from
JSON.stringify), Map / Set / WeakMap / WeakSet (silently produce {}), and typed
arrays (produce numeric-keyed objects). Each emits the same shaped Error as the
existing rejects so callers can branch on a consistent surface.

Drops the redundant Number.MAX_SAFE_INTEGER + 2 case (collapsed to the same float
as MAX_SAFE_INTEGER + 1); replaces with 2**60 which is genuinely distinct.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tell publishers to 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 during
rotation. Mirrors the python-commerce sibling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Close cross-language byte-parity hole between node-commerce and python-commerce
on int handling. Node's stableStringify already rejects integers outside
Number.MAX_SAFE_INTEGER (2^53 - 1); Python's _reject_floats only walked for
float, so a Python-signed profile with an oversized int produced a valid
self-verifying envelope that Node could not parse. The Python fix lands in
python-commerce; this commit ships the matching cross-lang fixture
(int-boundary) on the Node side plus a regenerator script for symmetry.

Cross-lang fixture: tests/fixtures/cross-lang/{node,py}-int-boundary.json
exercise max_safe_int / min_safe_int / small_int / neg_small_int / zero. Both
fixtures verify cleanly in both languages. py-int-boundary.json was generated
by the python-commerce companion script and copied here.

Cross-lang corpus meta-test now asserts both int-boundary fixtures exist.

scripts/generate-int-boundary-fixture.ts: one-shot regenerator.

Tests: 757 pass + 4 skipped (was 753 + 4). ESLint + tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wrap verifier-side canonicalize call so JCS-incompatible Numbers in a
received profile (non-integer, non-finite, oversized) emit a typed
UCPVerificationError(body_mismatch) instead of a raw Error. Consumers
catching UCPVerificationError no longer have a leak path.

Add regression test for error-precedence parity with python-commerce:
verify(null, malformedJwks) returns no_signature in both languages
(profile-shape check fires first).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…sibling

Reorder verifyUCPProfile so JWS header validation (typ/alg/kid via
compactVerify callback) runs before canonicalizing the stripped profile
body. Matches python-commerce's _peek_jws_header order. A profile carrying
both a malformed body (e.g. extras.rate=1.5) and a malformed JWS header
(typ="JWT") now surfaces wrong_typ in both SDKs instead of body_mismatch
in node and wrong_typ in python.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reorder the compactVerify resolver to check typ before alg before kid,
matching python-commerce's _peek_jws_header. Drop the `algorithms`
option since jose enforces it before invoking the resolver, which would
short-circuit typ. A profile carrying both alg=HS256 and typ=JWT now
emits wrong_typ in both SDKs instead of diverging.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rity

A JWS with both an unrecognized `crit` header AND a wrong `typ` was emitting
`unrecognized_critical_header` in Node but `wrong_typ` in python-commerce.
jose's compactVerify enforces `crit` before invoking the key-resolver
callback, so the typ check inside the resolver never ran.

Mirror python-commerce's `_peek_jws_header`: pre-decode the JWS protected
header in our code and check typ, alg, kid, crit up-front. compactVerify
still runs for signature + body verification. Cross-SDK callers routing on
the typed `code` now see the same value for the same input.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the rejection in core/api/src/lib/canonicalize.ts so the SDK
canonicalizer stays byte-symmetric across the Node and Python siblings
on any pre-ES2019 V8 (where JSON.stringify still escapes these
codepoints) or browser-side verifier path; today's V8 emits them raw,
so the divergence is theoretical but the contract is now consistent
with the upstream check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…hon sibling

The API can return account_verification with either null or empty-string for
un-set fields depending on the row state. The node builder used `??` (collapse
null/undefined only, pass empty-string verbatim); the python sibling used
dict.get(k) or DEFAULT for kyc_level/verified_at but dict.get(k, DEFAULT) for
age_bracket/jurisdiction (returns None verbatim if key is present-but-null).

For the same AssessResult shape, that meant the two SDKs emitted different
canonical claims blocks, so a profile signed in one language failed verify in
the other.

Switch node to `||` for the four account_verification fields (kyc_level,
age_bracket, jurisdiction, verified_at) so null AND empty-string both fall
through to the schema default, matching the python alignment shipped in the
same round.

Adds a data-driven-claims cross-lang fixture that, unlike the rest of the
corpus, exercises buildUCPProfile's actual data path (constructs a synthetic
AgentScoreData with the API "missing" sentinels and lets the builder
coalesce). Both languages now emit identical canonical bytes for the input.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
stableStringify now rejects U+2028 / U+2029 in object keys (previously
checked only string values); object keys flow through JSON.stringify(k)
which on modern V8 emits them raw, while Python's _reject_unsafe_numbers
recurses into dict keys and rejects them, so a Node-signed profile with
a separator-bearing key would fail Python verify with body_mismatch.

Verifier now treats explicit JSON null on JWK.use / JWK.alg as absent
(skip-on-null) rather than rejecting; both fields are optional per RFC
7517 and Python ucp_jwks.py uses ``is not None`` semantics, so the Node
``!== undefined`` check was the only side that rejected null and broke
language symmetry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
RFC 7515 §4.1.11 mandates that `crit` be a non-empty array of strings if
present. The previous check (`Array.isArray(header.crit) && header.crit.length > 0`)
only triggered the unrecognized-header branch on a well-formed crit array
and silently accepted `crit: null`, `crit: []`, `crit: "fakething"`, and
`crit: [42]`. python-commerce rejects all four shapes with malformed_jws
per the same RFC; the parity gap meant a malformed JWS could verify on
node but fail on python (or vice versa) for the same input.

Shape-check first (gate on `'crit' in header` so explicit null is caught),
then the unrecognized-extension check. Adds parametrized vitest cases
mirroring the four python-commerce parity inputs; existing
`crit: ['unknown']` -> `unrecognized_critical_header` test still passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New `typed-claims` cross-lang fixture exercises the typed-field-only read
path (`AgentScoreData.account_verification` populated directly, no raw
fallback) that the existing `data-driven-claims` fixture didn't cover (it
uses the python `raw=` shape on the python side). `buildUCPProfile` reads
the typed fields directly without consulting raw, so both languages must
produce byte-identical canonical bytes for the typed path or cross-lang
verify silently drifts in production.

Pairs with the python sibling commit that fixes the typed-empty-dict
read-order bug and preserves empty `UCPPaymentHandler.config` for
byte-parity with the TypeScript serializer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace em-dashes with periods/semicolons/colons/parens across README,
CLAUDE, examples/README, CONTRIBUTING. Drop the stale `1.0.0` Stability
header reference. Add the `signed-ucp-merchant.ts` example to both the
top-level README count (7 -> 8) and the examples + CLAUDE tables.
Refresh the test count (~360 -> ~750) to match reality.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds focused tests for fallback paths in a2a card identity, payment
headers optional spreads, openapi snippet section toggles, robots_tag
custom-paths handling across adapters, signer @solana/kit short-circuit
conditions, ucp signature_invalid mapping, and core verifyWalletSignerMatch
edges (API-emitted wallet_auth_requires_wallet_signing verdict + fallback
resolve catch). Lifts global branch coverage from 89.86% to 91.45%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
….4.0)

UCP §6 does not define a profile-as-JWS typ, and UCP capability names must follow
reverse-DNS namespacing (`^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9_]*)+$`). The previous
`typ: ucp-profile+jws` and `agentscore-identity` capability name implied
UCP-canonical signing/slots they aren't. Renamed to vendor-namespaced honest
forms:

- JWS protected header `typ`: `ucp-profile+jws` -> `agentscore-profile+jws`
  (matches the `agentscore-risk-signal+jws` pattern AP2 already uses).
- Capability name: `agentscore-identity` -> `sh.agentscore.identity`
  (passes the UCP regex; namespace authority `agentscore.sh`).
- Schema URL path: `agentscore-identity.v1.json` ->
  `sh-agentscore-identity-v1.json` (path matches the new capability name; URL
  hosted under namespace authority `agentscore.sh` per UCP convention).

All 10 cross-lang fixture scenarios re-signed via the new
`scripts/regenerate-cross-lang-fixtures.ts` orchestrator. Hand-crafted
`*-capability.json` fixtures bumped to the new name to keep the corpus honest
about what callers should publish.

808 tests pass. Cross-lang verify against the python sibling's regenerated
`py-*` corpus passes byte-identically for the data-driven and typed-claims
fixtures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The capability scenario in the cross-lang orchestrator hand-crafted the
old `https://agentscore.sh/schema/identity/1` URL while the SDK's
buildUCPProfile auto-injects the new
`https://agentscore.sh/schemas/ucp/sh-agentscore-identity-v1.json`. The
inconsistency made the corpus dishonest about what callers should
publish.

Update the orchestrator script to use the canonical URL and regenerate
all 20 fixtures so canonical bytes stay byte-identical between Node
and Python siblings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…moji-keys + int-boundary

Picks up the py-side regenerator fix that unwrapped doubly-nested `extras`
in the emoji-keys and int-boundary scenarios. Node fixtures regenerate with
fresh keypairs as a side effect of running the corpus together; structural
shapes of the eight unchanged scenarios are identical to the prior corpus.

py-emoji-keys.json and py-int-boundary.json now match node-* canonical
bodies byte-for-byte (modulo per-run keypair material), so cross-lang
verify covers the non-ASCII-keys-interleaved-with-canonical-fields case
in both directions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…s not set it

Cross-lang parity test: Node's `UCPPaymentHandler.config` is a TypeScript
optional property, so `{ name: 'tempo' }` ships a wire profile without the
`config` key. Python's UCPPaymentHandler.to_dict() now matches by omitting
empty configs; this test pins the Node side of the contract so a future
refactor that auto-emits `config: {}` would fail loudly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…pe parity

- Remove standalone generate-*-fixture.ts scripts that duplicate scenarios
  already covered by regenerate-cross-lang-fixtures.ts; standalones used
  bare JSON.stringify (no ensureAscii equivalent matters here, but the
  divergent code path would silently drift if a future unicode field
  landed). Orchestrator is now the single source of truth.

- Add a design note to src/identity/ucp.ts explaining the per-element
  shape choice: Node interfaces (UCPSigningKey / UCPService /
  UCPCapability) accept canonical fields plus vendor extras flat in the
  same object via index signatures, with no separate extras slot. The
  python sibling models these as dataclasses with an explicit extras
  dict and to_dict() reserved-key collision guards. Both designs offer
  equivalent guarantees: in Node the canonical names are positional
  fields in the interface, so a duplicate is a static type error or a
  trivial self-overwrite in object-literal syntax; there is no separate
  map that could shadow a reserved key. The top-level
  BuildUCPProfileInput.extras DOES carry a runtime guard because that
  is a dedicated map populatable via spread.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…thon parity)

Field name on BuildUCPProfileInput now matches the python sibling's
build_ucp_profile(agentscore_schema_url=...) keyword arg. Vendors switching
languages get the same param name in both SDKs. Also updates the test that
asserted the camelCase form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…keyed bindings)

The pre-refactor `buildUCPProfile` emitted a flat top-level body with services /
capabilities / payment_handlers as ARRAYS, which doesn't match the UCP §6 spec.
Verified against the live Pura Vida reference profile at
puravidabracelets.com/.well-known/ucp (Shopify's UCP integration): the spec body
nests under `ucp` with services / capabilities / payment_handlers as MAPS keyed
by reverse-DNS service / capability / handler name.

A trust-mode UCP verifier reading the old shape would skip us as malformed
before even checking the signature, so cross-language byte-parity tests were
passing while the published profile was non-parseable.

This is a breaking change to the SDK surface:

Output shape change:
- Top-level: { ucp: { version, services, capabilities, payment_handlers, name?,
  supported_versions? }, signing_keys: [...], signature?: "..." }
- services: Record<string, UCPServiceBinding[]> keyed by service name
  (e.g., 'dev.ucp.shopping')
- capabilities: Record<string, UCPCapabilityBinding[]> keyed by capability name
- payment_handlers: Record<string, UCPPaymentHandlerBinding[]> keyed by handler
  reverse-DNS name
- Each binding carries spec-required fields (id, version, spec, schema for
  handlers; version, spec, schema for capabilities)

Type renames:
- UCPService → UCPServiceBinding
- UCPCapability → UCPCapabilityBinding
- UCPPaymentHandler → UCPPaymentHandlerBinding
- New: UCPProfileBody (the inner `ucp` envelope)

Auto-injected sh.agentscore.identity capability now extends both
dev.ucp.shopping.checkout AND dev.ucp.shopping.cart (matching Shopify's
dev.shopify.catalog.storefront multi-parent pattern in the live ecosystem). New
optional `agentscore_spec_url` input, plus `supported_versions` map at profile
root (Pura Vida pattern), plus `ucp_extras` for vendor extras inside the ucp
envelope.

Cross-language byte-parity preserved with python-commerce sibling (mirror
refactor in agentscore-commerce).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ant shape

README and the canonical signed-ucp-merchant example now show services /
payment_handlers as MAPS keyed by reverse-DNS name (matches the
buildUCPProfile output and the live Pura Vida reference profile). README
section on profile-body signing reframed: not "UCP §6 trust-mode requires
signing" (it doesn't — Pura Vida ships unsigned in production); instead
"vendor extension for trust-mode verifiers that opt into auditable profiles".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…t shape

Both regen scripts (Node + Python) were still emitting the OLD flat-array
shape; the existing 20 fixtures verified cryptographically (signature is
shape-agnostic) but tested the wrong shape. Updated regen script to use the
spec-compliant input (services / capabilities / payment_handlers as MAPS
keyed by reverse-DNS service / capability / handler name) and regenerated
all 10 node-* fixtures + synced the 10 py-* fixtures from the python sibling.

Also corrected two stale docstrings in examples/README.md and
examples/signed-ucp-merchant.ts that claimed "UCP §6 trust-mode requires
JWS signature" — UCP doesn't mandate signing; that's an AgentScore vendor
extension for opt-in trust-mode verifiers (Pura Vida and Shopify-backed UCP
merchants ship unsigned in production today).

Cross-lang.test.ts: 21/21 pass. The corpus now actually tests cross-language
byte-parity for the spec-compliant shape, not just signature verification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
vvillait88 and others added 5 commits May 10, 2026 06:29
…, not UCP-required

UCP §6 does not mandate profile-body signing; agentscore-profile+jws is our
vendor extension for opt-in trust-mode verifiers. Pura Vida and Shopify-backed
UCP merchants ship unsigned in production. Aligns CLAUDE.md table description
with the in-file docstring already corrected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ling)

The Node example forced env UCP_SIGNING_KEY_ALG onto the JWK regardless of
what kty/crv the env JWK actually had. If a user set
UCP_SIGNING_KEY_JWK_PRIVATE to a P-256 (ES256) JWK but didn't set ALG=ES256,
importJWK would fail with a confusing error. Now detect from kty+crv
directly (Ed25519→EdDSA, P-256→ES256) and reject unsupported curves with a
clear message — matches the python sibling's behavior exactly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per UCP §A2A binding (https://ucp.dev/specification/checkout-a2a/):
"Businesses supporting UCP must advertise the extension and any optional
capabilities in their A2A Agent Card to allow platforms to activate the
extension."

The agent card MUST carry a top-level `extensions[]` array with an entry whose
`uri` matches `https://ucp.dev/2026-04-08/specification/reference`. Without it,
spec-strict A2A-first discovery cannot detect UCP support from the card alone.

Adds:
  - `extensions?: A2AAgentCardExtension[]` on `A2AAgentCard` + builder input
  - `UCP_A2A_EXTENSION_URI` constant pinned to the 2026-04-08 spec snapshot
  - `ucpA2AExtension(capabilities?)` helper that builds the canonical UCP
    entry, defaulting to empty capabilities for vendors that serve UCP at
    the discovery layer but haven't bound formal capabilities yet
  - Re-exports from package barrel + 5 new tests

Generic `extensions[]` field shape matches A2A v1.0 verbatim, so other A2A
extensions (not just UCP) compose the same way.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small parity/defensiveness fixes from the post-spec-audit review:

1. buildA2AAgentCard now skips emitting `extensions` when passed an empty
   array (matches python-commerce's to_dict behavior). Cross-language
   profiles canonicalize to identical bytes when both omit. Test added.

2. ucpSigningKeyFromJWK now validates that EC + OKP JWKs carry a non-empty
   `crv` field. Previously a malformed JWK without crv would silently land
   in signing_keys and fail at sign-time with an unclear error. Two tests
   added.

3. README example now shows `extensions: [ucpA2AExtension()]` on the
   buildA2AAgentCard call so vendors copying the snippet ship spec-compliant
   A2A cards out of the box.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Patch within the ^0.6.x range. wevm ecosystem upstream tracks the same
versions we already depend on for Tempo/Solana/EVM rails. Tests green
(823 pass, 4 skipped).

Major bumps deferred to dedicated PRs:
  - jose 5 -> 6 (type-generic position changes + KeyObject->CryptoKey
    runtime change in Node, requires test verification)
  - eslint 9 -> 10 + @eslint/js 9 -> 10 (config format changes)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vvillait88 vvillait88 merged commit bae438b into main May 10, 2026
6 checks passed
@vvillait88 vvillait88 deleted the feat/ucp-signing branch May 10, 2026 19:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant