portage-ucp 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7888232b409966ef29fae287d17d6af418dd676e12ad53a7ef7d30d7178f6ae6
4
- data.tar.gz: e967bbb103d6911ca4ed8d4fb1204687cd8a2c7a51e3ab7675db5420b7eb0623
3
+ metadata.gz: 4f3dd667eb94ccfb68762797d5020e233f282d941b4790503a812b01067be09f
4
+ data.tar.gz: bb3cb10f93333cb720172aa427f2b09b03d186c03b98485b63a8dca9dae8a302
5
5
  SHA512:
6
- metadata.gz: 3f78f566ac2ee41da063ae9a96a658707bf481f8f9ee791e288fc2d78ce0325ed147c2423a137b06350b3ddb5f64f09c17a5ba6967894e7795ef72c90d6f1384
7
- data.tar.gz: 61a0ec7f01125fb8da2e0eb74fdc03b50d4fc94e38a3863e8366b1932cc871ab3787253d3199845a4a18a1902245fb7b2d3ca336b2bd896a82c4ce011d187f5b
6
+ metadata.gz: 58b3c2ab0aad754862745bf0381e8e37987d2fa8c4da6f80c6460284cb4242d7f9c45e74b83918f6ab032770f38467f6bb62f08cefa06555e2de2dedead2e588
7
+ data.tar.gz: e9dd08412d7fb321da9118ceb01804a71894da1bca6a7263ffab5ed3efda335657e5c1c2d3bf52d027056bd4a88ab0e6889eacdb9c454103e595ed961a0b885a
data/CHANGELOG.md CHANGED
@@ -4,6 +4,35 @@ All notable changes to this project are documented here. Format loosely follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.0.0/); this project is
5
5
  pre-1.0, so APIs may still shift between minor versions.
6
6
 
7
+ ## [Unreleased]
8
+
9
+ ## [0.12.0] - 2026-10-05
10
+
11
+ - **Removed the unused `services/shopping/embedded.openrpc.json` schema.** Nothing loaded or listed it (`SchemaValidator` only reads `mcp.openrpc.json`); it just shipped in the gem. Patch-level.
12
+
13
+ - **Breaking: removed `Portage::Ucp::CapabilityNegotiator`.** Nothing in the gem, the CLI or any adapter ever called `#negotiate`, so no request path used it; it was public but dead. Anyone requiring it directly must carry their own version intersection. Minor-level change for a pre-1.0 gem.
14
+
15
+ - **Breaking: removed `Dispatcher#call`'s `agent_profile:` keyword and `SchemaValidator#valid?`.** `Dispatcher#call` accepted `agent_profile:` and ignored it (`Mcp::Server` still logs the profile itself, it just no longer passes it on); a caller passing it now gets an `ArgumentError`. Nothing called `SchemaValidator#valid?`; use `errors_for(...).empty?`. Minor-level change for a pre-1.0 gem.
16
+
17
+ - **Fixed: `Security::Signature` now raises `MalformedSignatureError` for a trusted JWK whose `x` or `y` isn't valid base64url.** It used to leak a raw `ArgumentError`, which `Rack::SignatureVerification` doesn't rescue, so the request failed with a 500 instead of the 401 `invalid_signature` every other bad signature gets. `Ap2::MandateSignature` already mapped the same input to `InvalidMandateError`. Patch-level.
18
+
19
+ - **Breaking: `Security::Signature::CURVES`, `Security::Signature::EC_PUBLIC_KEY_OID`, `Ap2::MandateSignature::CURVES` and `Ap2::MandateSignature::EC_PUBLIC_KEY_OID` moved to the new `Security::EcJwk`.** Both verifiers carried identical copies of the curve table and the JWK-to-public-key and raw-to-DER helpers; they now share `Security::EcJwk`, which takes the error class each one raises, so verification and its errors are unchanged. The unread `openssl_name` curve field is gone. Nothing outside the two verifiers read the constants; anyone who did should use `Security::EcJwk::CURVES`. Minor-level change for a pre-1.0 gem.
20
+
21
+ - **Security hardening: `Security::Signature` compares `Content-Digest` with `OpenSSL.fixed_length_secure_compare`** instead of a hand-rolled Ruby byte loop, keeping the length check first so a wrong-length digest is still a `DigestMismatchError`. No behaviour change otherwise. Patch-level.
22
+
23
+ ## [0.11.0] - 2026-09-29
24
+
25
+ - `Portage::Ucp::Check` (and `portage-ucp-check`) now follows a `<link rel="ucp" href="...">`
26
+ manifest pointer in the homepage when `/.well-known/ucp` is missing, the same fallback
27
+ `portage buy` uses. The report adds `manifest_url` when the manifest came from that link.
28
+
29
+ - Documentation only, no code change. Fixes two wrong comments. `Authenticator`
30
+ pointed at a `mutating_only:` option that `Mcp::Server.build` doesn't have:
31
+ it only authenticates tools whose adapter method takes `idempotency_key:`,
32
+ and no option changes that. `Manifest#to_h` read as if this manifest reports
33
+ `"version": "2026-08-25"`. That is what live Shopify stores serve. This
34
+ manifest reports `UCP_VERSION`, `"2026-04-08"`.
35
+
7
36
  ## [0.10.0] - 2026-09-25
8
37
 
9
38
  - **Proxy support** (`docs/plans/proxy-support.md` Phases 1 and 3).
data/README.md CHANGED
@@ -19,7 +19,7 @@ in this gem.
19
19
  | `Portage::Ucp::Adapter` | The contract your backend implements — override only the catalog/cart/checkout/order/identity methods you support; the rest stay unadvertised. |
20
20
  | `Portage::Ucp::CapabilityRegistry` | Figures out which capabilities an `Adapter` actually backs. |
21
21
  | `Portage::Ucp::Dispatcher` | Routes a capability+action call to the right `Adapter` method. |
22
- | `Portage::Ucp::Mcp::Server` | Wraps an `Adapter` as an MCP server — one `MCP::Tool` per advertised action, stdio or Streamable HTTP. |
22
+ | `Portage::Ucp::Mcp::Server` | Wraps an `Adapter` as an MCP server — one `MCP::Tool` per advertised action; serve it over stdio or Streamable HTTP with the `mcp` gem's transports. |
23
23
  | `Portage::Ucp::Manifest` | Builds the signed `/.well-known/ucp` discovery document. |
24
24
  | `Portage::Ucp::Rack::ManifestEndpoint` | Serves that manifest over Rack. |
25
25
  | `Portage::Ucp::Rack::WebhookEndpoint` | HMAC-verified inbound order-lifecycle webhooks. |
@@ -32,7 +32,7 @@ in this gem.
32
32
  | `Portage::Ucp::PolicyGuard` / `Portage::Ucp::Policy` | Per-transaction/rolling/velocity caps and a merchant allowlist, checked before `complete_checkout` dispatch; configured via `portage-cli`'s `portage policy show/set`. |
33
33
  | `Portage::Ucp::Support::OfferRanking` / `Portage::Ucp::Support::Escalation` | The offer-ranking rule (buyable, then priced, then cheapest, ties stable) and the escalation rule (`requires_escalation`, then a mismatch). `portage-ucp-decision` wraps both as typed verdicts; `portage-cli` calls them directly. |
34
34
  | `Portage::Ucp::PaymentEnrollmentGuard` | Validates every `create_payment_enrollment`/`get_payment_enrollment` result an `Adapter` returns — `status` must be `"pending"` (with a `setup_url`, no `payment_token`) or `"complete"` (with a `payment_token`, no `setup_url`). Runs automatically in `Dispatcher#call`. |
35
- | `Portage::Ucp::Ap2::PaymentMandate` / `Portage::Ucp::Ap2::MandateGuard` | A typed shape for an AP2 payment mandate, and shape-only validation (required fields + expiry — not cryptographic verification) run automatically on any `mandate:` argument passed through `Dispatcher#call`. |
35
+ | `Portage::Ucp::Ap2::PaymentMandate` / `Portage::Ucp::Ap2::MandateGuard` | A typed shape for an AP2 payment mandate, and validation (required fields + expiry, plus signature verification when `mandate_trusted_keys` is configured) run automatically on any `mandate:` argument passed through `Dispatcher#call`. |
36
36
 
37
37
  Security defaults are all locked down, not permissive-by-omission —
38
38
  `UnconfiguredAuthenticator` rejects every mutating call until you configure a real
@@ -1,5 +1,6 @@
1
1
  require "openssl"
2
2
  require "base64"
3
+ require_relative "../security/ec_jwk"
3
4
 
4
5
  module Portage
5
6
  module Ucp
@@ -14,13 +15,12 @@ module Portage
14
15
  #
15
16
  # Same ECDSA/JWK wire conventions as Security::Signature (P-256
16
17
  # mandatory/P-384 optional, raw r||s signature bytes, JWK x/y
17
- # coordinates) since that curve support is already proven against this
18
- # gem's RFC 9421 verifier — but deliberately a separate class, not a
19
- # shared one: a mandate isn't an HTTP request (no method/path/headers
20
- # to canonicalize, just PaymentMandate#signing_payload), and
21
- # Security::Signature's own class doc already frames every signing
22
- # story in this gem as deliberately distinct rather than unified under
23
- # one abstraction.
18
+ # coordinates), and the same Security::EcJwk helpers for them, since
19
+ # that curve support is already proven against this gem's RFC 9421
20
+ # verifier. The verifier itself stays separate: a mandate isn't an
21
+ # HTTP request (no method/path/headers to canonicalize, just
22
+ # PaymentMandate#signing_payload), and Security::Signature's own class
23
+ # doc frames every signing story in this gem as deliberately distinct.
24
24
  #
25
25
  # Trust anchor: `trusted_keys` here is the mandate *issuer's* key set
26
26
  # (the shopper's agent, or the agent platform vouching for it) — a
@@ -30,12 +30,6 @@ module Portage
30
30
  # convention, reused again here rather than inventing a second
31
31
  # differently-shaped key config for the same job).
32
32
  module MandateSignature
33
- CURVES = {
34
- "P-256" => { oid: "1.2.840.10045.3.1.7", coord: 32, digest: "SHA256" },
35
- "P-384" => { oid: "1.3.132.0.34", coord: 48, digest: "SHA384" }
36
- }.freeze
37
- EC_PUBLIC_KEY_OID = "1.2.840.10045.2.1".freeze
38
-
39
33
  # @param mandate [PaymentMandate]
40
34
  # @param trusted_keys [Array<Hash>, #call] JWK hash set (or
41
35
  # resolver #call(kid) => JWK hash or nil), keyed by `kid` — see
@@ -49,8 +43,8 @@ module Portage
49
43
  curve = curve_for!(jwk)
50
44
  raw_signature = sized_signature!(mandate, curve)
51
45
 
52
- key = ec_public_key(jwk, curve)
53
- der = raw_to_der(raw_signature, curve[:coord])
46
+ key = Security::EcJwk.public_key(jwk, curve, error: Portage::Ucp::InvalidMandateError)
47
+ der = Security::EcJwk.raw_to_der(raw_signature, curve[:coord])
54
48
  verified = key.verify(curve[:digest], der, mandate.signing_payload)
55
49
  raise Portage::Ucp::InvalidMandateError, "mandate signature does not verify" unless verified
56
50
 
@@ -75,7 +69,9 @@ module Portage
75
69
  raise Portage::Ucp::InvalidMandateError, "unsupported key type #{kty.inspect}" unless kty == "EC"
76
70
 
77
71
  crv = jwk["crv"] || jwk[:crv]
78
- CURVES.fetch(crv) { raise Portage::Ucp::InvalidMandateError, "unsupported curve #{crv.inspect}" }
72
+ Security::EcJwk::CURVES.fetch(crv) do
73
+ raise Portage::Ucp::InvalidMandateError, "unsupported curve #{crv.inspect}"
74
+ end
79
75
  end
80
76
  private_class_method :curve_for!
81
77
 
@@ -104,44 +100,6 @@ module Portage
104
100
  raise Portage::Ucp::InvalidMandateError, "mandate signature isn't valid base64"
105
101
  end
106
102
  private_class_method :decode_signature
107
-
108
- # Same DER-from-raw-JWK-coordinates construction as
109
- # Security::Signature#ec_public_key, for the same reason: the
110
- # OpenSSL::PKey::EC#public_key= setter no longer works since EC keys
111
- # became immutable in the openssl gem's OpenSSL 3.0 support.
112
- def self.ec_public_key(jwk, curve)
113
- x = decode_base64url(jwk["x"] || jwk[:x])
114
- y = decode_base64url(jwk["y"] || jwk[:y])
115
- raise Portage::Ucp::InvalidMandateError, "JWK missing x/y" if x.nil? || y.nil?
116
-
117
- octet_string = "\x04".b + x + y
118
- der = OpenSSL::ASN1::Sequence.new([
119
- OpenSSL::ASN1::Sequence.new([
120
- OpenSSL::ASN1::ObjectId.new(EC_PUBLIC_KEY_OID),
121
- OpenSSL::ASN1::ObjectId.new(curve[:oid])
122
- ]),
123
- OpenSSL::ASN1::BitString.new(octet_string)
124
- ]).to_der
125
- OpenSSL::PKey::EC.new(der)
126
- end
127
- private_class_method :ec_public_key
128
-
129
- def self.raw_to_der(raw_signature, coord)
130
- r = OpenSSL::BN.new(raw_signature.byteslice(0, coord), 2)
131
- s = OpenSSL::BN.new(raw_signature.byteslice(coord, coord), 2)
132
- OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::Integer.new(r), OpenSSL::ASN1::Integer.new(s)]).to_der
133
- end
134
- private_class_method :raw_to_der
135
-
136
- def self.decode_base64url(value)
137
- return nil unless value
138
-
139
- padded = value + ("=" * ((4 - (value.length % 4)) % 4))
140
- Base64.urlsafe_decode64(padded)
141
- rescue ArgumentError
142
- raise Portage::Ucp::InvalidMandateError, "JWK coordinate isn't valid base64url"
143
- end
144
- private_class_method :decode_base64url
145
103
  end
146
104
  end
147
105
  end
@@ -4,10 +4,11 @@ module Portage
4
4
  # tool call, return an auth context (any truthy value) or raise
5
5
  # Portage::Ucp::AuthenticationError. There is deliberately no default that
6
6
  # allows anonymous mutation — an unconfigured server rejects every
7
- # mutating capability call (see UNCONFIGURED below). Read-only catalog
8
- # calls MAY be left open at the consumer's explicit choice by not
9
- # requiring authentication for those specific actions (see
10
- # Mcp::Server.build's `mutating_only:`).
7
+ # mutating capability call (see UnconfiguredAuthenticator below).
8
+ # Mcp::Server.build calls it only for mutating tools: those whose
9
+ # adapter method takes `idempotency_key:`. Every other tool (catalog
10
+ # search, product, cart, checkout and order lookups) never reaches it,
11
+ # and Server.build has no option to change that.
11
12
  class Authenticator
12
13
  def call(_server_context)
13
14
  raise NotImplementedError, "#{self.class} must implement #call"
@@ -8,7 +8,9 @@ module Portage
8
8
  # `portage-ucp-check <url>` — point it at any storefront and find out what
9
9
  # portage-ucp can do with it. Checks for a native `/.well-known/ucp`
10
10
  # manifest first (the store may already speak UCP without this gem, see
11
- # README's "Why /.well-known/ucp?"); if there isn't one, detects the
11
+ # README's "Why /.well-known/ucp?"), then a `<link rel="ucp" href=...>`
12
+ # manifest pointer in the homepage (the same fallback `portage buy`
13
+ # follows); if there isn't one, detects the
12
14
  # commerce platform from the page itself (see Resolver) and names
13
15
  # the matching portage-ucp-<adapter> gem — probing it live when that
14
16
  # adapter's env vars are already set, so "best match" means a
@@ -30,6 +32,10 @@ module Portage
30
32
  return { url: @uri.to_s, native_ucp: manifest } if manifest
31
33
 
32
34
  body, headers = fetch_homepage
35
+ linked = manifest_link(body)
36
+ manifest = linked && fetch_json(linked)
37
+ return { url: @uri.to_s, native_ucp: manifest, manifest_url: linked.to_s } if manifest
38
+
33
39
  platform = Resolver.detect_platform(body, headers)
34
40
 
35
41
  report = { url: @uri.to_s, native_ucp: nil, platform: platform&.name, recommended_gem: platform&.gem }
@@ -42,7 +48,11 @@ module Portage
42
48
  def fetch_manifest
43
49
  manifest_uri = @uri.dup
44
50
  manifest_uri.path = MANIFEST_PATH
45
- response = get(manifest_uri)
51
+ fetch_json(manifest_uri)
52
+ end
53
+
54
+ def fetch_json(uri)
55
+ response = get(uri)
46
56
  return nil unless response.is_a?(Net::HTTPSuccess)
47
57
 
48
58
  JSON.parse(response.body)
@@ -59,6 +69,18 @@ module Portage
59
69
  [nil, {}]
60
70
  end
61
71
 
72
+ # `<link rel="ucp" href="...">` in either attribute order, resolved
73
+ # against the store URL.
74
+ def manifest_link(body)
75
+ return nil unless body
76
+
77
+ match = body.match(/<link[^>]+rel=["']ucp["'][^>]+href=["']([^"']+)["']/i) ||
78
+ body.match(/<link[^>]+href=["']([^"']+)["'][^>]+rel=["']ucp["']/i)
79
+ match && URI.join(@uri, match[1])
80
+ rescue URI::Error
81
+ nil
82
+ end
83
+
62
84
  def probe(platform)
63
85
  env = Resolver.env_for(platform)
64
86
  missing = Resolver.missing_env(platform, env)
@@ -76,13 +76,7 @@ module Portage
76
76
  # Dispatcher.call is also the conformance kit's
77
77
  # (lib/portage/ucp/rspec.rb) and specs' direct entry point, outside
78
78
  # any MCP request (§23).
79
- # @param agent_profile [String, nil] caller-supplied `ucp-agent.profile`
80
- # hint from `_meta` (see Mcp::Server.agent_profile_for). Dispatcher
81
- # has no direct Observability.log call of its own to thread this
82
- # into — accepted here purely so callers that already pass
83
- # correlation_id: have a matching, equally optional slot; existing
84
- # callers that omit it are unaffected.
85
- def call(capability:, action:, arguments: {}, correlation_id: nil, agent_profile: nil)
79
+ def call(capability:, action:, arguments: {}, correlation_id: nil)
86
80
  capability_definition = @registry.find(capability)
87
81
  raise UnknownCapabilityError, capability if capability_definition.nil?
88
82
 
@@ -32,10 +32,11 @@ module Portage
32
32
  end
33
33
 
34
34
  # Nests everything under a "ucp" envelope, and keys `capabilities` by
35
- # capability name (matching what live Shopify UCP rollouts — Casper,
36
- # Allbirds, Glossier, and 34+ others — actually serve as of manifest
37
- # "version": "2026-08-25"; see docs/well-known-ucp.md and
38
- # portage-ucp-client's Client.discover, which this shape now matches).
35
+ # capability name. That is the shape live Shopify UCP rollouts (Casper,
36
+ # Allbirds, Glossier, and 34+ others) serve under their own manifest
37
+ # "version": "2026-08-25", and the shape portage-ucp-client's
38
+ # Client.discover reads. Only the shape is borrowed: `version` here is
39
+ # still UCP_VERSION ("2026-04-08"), the spec revision this gem targets.
39
40
  def to_h
40
41
  ucp = {
41
42
  version: UCP_VERSION,
@@ -70,7 +70,7 @@ module Portage
70
70
  capability: capability.name, action: action_name, arguments: kwargs)
71
71
 
72
72
  result = context.dispatcher.call(capability: capability.name, action: action_name, arguments: kwargs,
73
- correlation_id: correlation_id, agent_profile: agent_profile)
73
+ correlation_id: correlation_id)
74
74
  ::MCP::Tool::Response.new(result[:content], structured_content: result[:structuredContent])
75
75
  end
76
76
 
@@ -34,10 +34,6 @@ module Portage
34
34
  schemer.validate(data).map { |error| JSONSchemer::Errors.pretty(error) }
35
35
  end
36
36
 
37
- def valid?(relative_path, data)
38
- errors_for(relative_path, data).empty?
39
- end
40
-
41
37
  # Method names declared by a vendored OpenRPC document, e.g.
42
38
  # "services/shopping/mcp.openrpc.json" -> %w[create_checkout get_cart ...].
43
39
  def method_names(relative_path)
@@ -0,0 +1,56 @@
1
+ require "openssl"
2
+ require "base64"
3
+
4
+ module Portage
5
+ module Ucp
6
+ module Security
7
+ # The ECDSA/JWK wire conventions Security::Signature (RFC 9421
8
+ # requests) and Ap2::MandateSignature (payment mandates) share: P-256
9
+ # mandatory, P-384 optional, a public key from the JWK's raw x/y
10
+ # coordinates, and raw r||s signature bytes (never ASN.1/DER on the
11
+ # wire). `coord` is the byte length of each of r/s and of x/y.
12
+ #
13
+ # Each caller passes the error class it raises for a malformed JWK, so
14
+ # a bad key stays a SignatureError for one and an InvalidMandateError
15
+ # for the other.
16
+ module EcJwk
17
+ CURVES = {
18
+ "P-256" => { oid: "1.2.840.10045.3.1.7", coord: 32, digest: "SHA256" },
19
+ "P-384" => { oid: "1.3.132.0.34", coord: 48, digest: "SHA384" }
20
+ }.freeze
21
+ EC_PUBLIC_KEY_OID = "1.2.840.10045.2.1".freeze
22
+
23
+ module_function
24
+
25
+ # Builds the public key from raw DER rather than
26
+ # OpenSSL::PKey::EC#public_key= — that setter no longer works since
27
+ # EC keys became immutable in the openssl gem's OpenSSL 3.0 support.
28
+ def public_key(jwk, curve, error:)
29
+ x = decode_base64url(jwk["x"] || jwk[:x], error: error)
30
+ y = decode_base64url(jwk["y"] || jwk[:y], error: error)
31
+ raise error, "JWK missing x/y" if x.nil? || y.nil?
32
+
33
+ octet_string = "\x04".b + x + y
34
+ algorithm = OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::ObjectId.new(EC_PUBLIC_KEY_OID),
35
+ OpenSSL::ASN1::ObjectId.new(curve[:oid])])
36
+ OpenSSL::PKey::EC.new(OpenSSL::ASN1::Sequence.new([algorithm, OpenSSL::ASN1::BitString.new(octet_string)]).to_der)
37
+ end
38
+
39
+ def raw_to_der(raw_signature, coord)
40
+ r = OpenSSL::BN.new(raw_signature.byteslice(0, coord), 2)
41
+ s = OpenSSL::BN.new(raw_signature.byteslice(coord, coord), 2)
42
+ OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::Integer.new(r), OpenSSL::ASN1::Integer.new(s)]).to_der
43
+ end
44
+
45
+ def decode_base64url(value, error:)
46
+ return nil unless value
47
+
48
+ padded = value + ("=" * ((4 - (value.length % 4)) % 4))
49
+ Base64.urlsafe_decode64(padded)
50
+ rescue ArgumentError
51
+ raise error, "JWK coordinate isn't valid base64url"
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -1,5 +1,6 @@
1
1
  require "openssl"
2
2
  require "base64"
3
+ require_relative "ec_jwk"
3
4
 
4
5
  module Portage
5
6
  module Ucp
@@ -43,16 +44,6 @@ module Portage
43
44
  # the gem doesn't assume single-platform trust, without inventing
44
45
  # multi-platform discovery logic that isn't pinned anywhere yet.
45
46
  class Signature
46
- # ECDSA only, per the spec: P-256 mandatory, P-384 optional. `coord`
47
- # is the byte length of each of r/s in the required raw r||s
48
- # signature encoding (never ASN.1/DER on the wire) and of the JWK's
49
- # x/y coordinates.
50
- CURVES = {
51
- "P-256" => { openssl_name: "prime256v1", oid: "1.2.840.10045.3.1.7", coord: 32, digest: "SHA256" },
52
- "P-384" => { openssl_name: "secp384r1", oid: "1.3.132.0.34", coord: 48, digest: "SHA384" }
53
- }.freeze
54
- EC_PUBLIC_KEY_OID = "1.2.840.10045.2.1".freeze
55
-
56
47
  SIGNATURE_INPUT_FORMAT = /\A(?<label>[!\w-]+)=\((?<components>[^)]*)\)(?<params>.*)\z/
57
48
  SIGNATURE_FORMAT = %r{\A[!\w-]+=:(?<value>[A-Za-z0-9+/=]+):\z}
58
49
 
@@ -220,56 +211,25 @@ module Portage
220
211
 
221
212
  def verify_ecdsa!(jwk, base, raw_signature)
222
213
  crv = jwk["crv"] || jwk[:crv]
223
- curve = CURVES.fetch(crv) { raise MalformedSignatureError, "unsupported curve #{crv.inspect}" }
214
+ curve = EcJwk::CURVES.fetch(crv) { raise MalformedSignatureError, "unsupported curve #{crv.inspect}" }
224
215
  unless raw_signature.bytesize == curve[:coord] * 2
225
216
  raise InvalidSignatureError,
226
217
  "signature is the wrong length for #{crv}"
227
218
  end
228
219
 
229
- key = ec_public_key(jwk, curve)
230
- der = raw_to_der(raw_signature, curve[:coord])
220
+ key = EcJwk.public_key(jwk, curve, error: MalformedSignatureError)
221
+ der = EcJwk.raw_to_der(raw_signature, curve[:coord])
231
222
  verified = key.verify(curve[:digest], der, base)
232
223
  raise InvalidSignatureError, "signature does not verify" unless verified
233
224
  rescue OpenSSL::PKey::PKeyError, OpenSSL::PKey::EC::Point::Error, OpenSSL::ASN1::ASN1Error => e
234
225
  raise InvalidSignatureError, "signature verification failed: #{e.message}"
235
226
  end
236
227
 
237
- # Builds the public key from raw DER rather than
238
- # OpenSSL::PKey::EC#public_key= — that setter no longer works since
239
- # EC keys became immutable in the openssl gem's OpenSSL 3.0 support.
240
- def ec_public_key(jwk, curve)
241
- x = decode_base64url(jwk["x"] || jwk[:x])
242
- y = decode_base64url(jwk["y"] || jwk[:y])
243
- raise MalformedSignatureError, "JWK missing x/y" if x.nil? || y.nil?
244
-
245
- octet_string = "\x04".b + x + y
246
- der = OpenSSL::ASN1::Sequence.new([
247
- OpenSSL::ASN1::Sequence.new([
248
- OpenSSL::ASN1::ObjectId.new(EC_PUBLIC_KEY_OID),
249
- OpenSSL::ASN1::ObjectId.new(curve[:oid])
250
- ]),
251
- OpenSSL::ASN1::BitString.new(octet_string)
252
- ]).to_der
253
- OpenSSL::PKey::EC.new(der)
254
- end
255
-
256
- def raw_to_der(raw_signature, coord)
257
- r = OpenSSL::BN.new(raw_signature.byteslice(0, coord), 2)
258
- s = OpenSSL::BN.new(raw_signature.byteslice(coord, coord), 2)
259
- OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::Integer.new(r), OpenSSL::ASN1::Integer.new(s)]).to_der
260
- end
261
-
262
- def decode_base64url(value)
263
- return nil unless value
264
-
265
- padded = value + ("=" * ((4 - (value.length % 4)) % 4))
266
- Base64.urlsafe_decode64(padded)
267
- end
268
-
228
+ # The length guard comes first: `expected` is from the sender's
229
+ # Content-Digest header, and fixed_length_secure_compare raises on
230
+ # a length mismatch.
269
231
  def digests_match?(expected, actual)
270
- return false unless expected.bytesize == actual.bytesize
271
-
272
- expected.bytes.zip(actual.bytes).reduce(0) { |acc, (byte_a, byte_b)| acc | (byte_a ^ byte_b) }.zero?
232
+ expected.bytesize == actual.bytesize && OpenSSL.fixed_length_secure_compare(expected, actual)
273
233
  end
274
234
  end
275
235
  end
@@ -208,17 +208,13 @@ module Portage
208
208
  case spec
209
209
  when Profile then spec
210
210
  when :direct, "direct" then Profile::DIRECT
211
- when Hash then Profile.new(**symbolize_keys(spec))
211
+ when Hash then Profile.new(**spec.transform_keys(&:to_sym))
212
212
  when String, Symbol
213
213
  @profiles[spec.to_s] || raise(ConfigError, "unknown proxy profile #{spec.inspect}")
214
214
  else
215
215
  raise ConfigError, "invalid proxy profile #{spec.inspect}"
216
216
  end
217
217
  end
218
-
219
- def symbolize_keys(hash)
220
- hash.each_with_object({}) { |(key, value), out| out[key.to_sym] = value }
221
- end
222
218
  end
223
219
  end
224
220
  end
@@ -1,5 +1,5 @@
1
1
  module Portage
2
2
  module Ucp
3
- VERSION = "0.10.0".freeze
3
+ VERSION = "0.12.0".freeze
4
4
  end
5
5
  end
data/lib/portage/ucp.rb CHANGED
@@ -62,7 +62,6 @@ require_relative "ucp/capabilities/payment_method"
62
62
  require_relative "ucp/capabilities/shopper_data"
63
63
  require_relative "ucp/capabilities/identity_linking"
64
64
  require_relative "ucp/capability_registry"
65
- require_relative "ucp/capability_negotiator"
66
65
  require_relative "ucp/dispatcher"
67
66
  require_relative "ucp/mcp/server"
68
67
  require_relative "ucp/rack/forwarded_request"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: portage-ucp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tom Whitbread
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-25 00:00:00.000000000 Z
11
+ date: 2026-10-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -168,7 +168,6 @@ files:
168
168
  - lib/portage/ucp/capabilities/reorder.rb
169
169
  - lib/portage/ucp/capabilities/shopper_data.rb
170
170
  - lib/portage/ucp/capability.rb
171
- - lib/portage/ucp/capability_negotiator.rb
172
171
  - lib/portage/ucp/capability_registry.rb
173
172
  - lib/portage/ucp/check.rb
174
173
  - lib/portage/ucp/configuration.rb
@@ -192,6 +191,7 @@ files:
192
191
  - lib/portage/ucp/resolver.rb
193
192
  - lib/portage/ucp/rspec.rb
194
193
  - lib/portage/ucp/schema_validator.rb
194
+ - lib/portage/ucp/security/ec_jwk.rb
195
195
  - lib/portage/ucp/security/errors.rb
196
196
  - lib/portage/ucp/security/signature.rb
197
197
  - lib/portage/ucp/support/amounts.rb
@@ -290,7 +290,6 @@ files:
290
290
  - schemas/2026-04-08/schemas/shopping/types/warning_code.json
291
291
  - schemas/2026-04-08/schemas/transports/embedded_config.json
292
292
  - schemas/2026-04-08/schemas/ucp.json
293
- - schemas/2026-04-08/services/shopping/embedded.openrpc.json
294
293
  - schemas/2026-04-08/services/shopping/mcp.openrpc.json
295
294
  homepage: https://github.com/tomtom87/Portage/tree/main/portage-ucp
296
295
  licenses:
@@ -1,70 +0,0 @@
1
- module Portage
2
- module Ucp
3
- # Reconciles UCP's capability-version negotiation with MCP's own
4
- # transport-level capability negotiation (§10) — they answer different
5
- # questions and both apply regardless of transport:
6
- # - MCP `initialize` negotiates protocol features (tools/resources/prompts).
7
- # - This negotiator picks, per capability, which *version* both sides speak:
8
- # the platform's advertised versions intersected with the versions this
9
- # server (its registry + adapter) actually advertises.
10
- #
11
- # Over HTTP the platform's advertised versions come from the `UCP-Agent`
12
- # header; over stdio there's no header, so they arrive in `initialize`
13
- # params instead. Either way this class only deals in the parsed version
14
- # list — transports are responsible for extracting it from their own
15
- # request shape.
16
- #
17
- # §23 step 3: §12 promises a `capability_negotiated` event here, but
18
- # #negotiate has no call site anywhere in this gem outside its own spec
19
- # — nothing in the request path (transport `initialize` handling, or
20
- # otherwise) invokes it yet. Wiring the event would mean building that
21
- # call site first, which is a bigger change than threading a logger
22
- # through an existing collaborator; left undone, and cut from §12
23
- # rather than promised.
24
- #
25
- # §25: the reason there's no call site is structural, not just
26
- # unbuilt. `mcp` 0.25.0's `MCP::Server#init`
27
- # (lib/mcp/server.rb:608) never reads `params[:_meta]` and never calls
28
- # `add_instrumentation_data`, unlike `call_tool` — so nothing at
29
- # initialize time reaches `around_request`/`instrumentation_callback`
30
- # or the `_meta` mechanism `Mcp::Server.correlation_id_for` already
31
- # relies on (§23/§24). And over HTTP,
32
- # `StreamableHTTPTransport#handle_initialization`
33
- # (lib/mcp/server/transports/streamable_http_transport.rb:812) builds
34
- # a `Rack::Request` with full headers but only pulls `HTTP_ORIGIN` out
35
- # of it before handing the raw JSON body string to
36
- # `ServerSession#handle_json` — a `UCP-Agent` header never crosses into
37
- # the server at all. Wiring #negotiate for real means monkeypatching
38
- # both (subclass/prepend `MCP::Server#init` to capture
39
- # `clientInfo`/`_meta`, and `StreamableHTTPTransport#handle_initialization`
40
- # to capture the `UCP-Agent` header), coupling this gem to `mcp`
41
- # internals that could silently break on a `mcp` gem upgrade.
42
- # Revisiting this needs either an upstream `mcp` gem hook at
43
- # initialize time, or a deliberate decision to accept the monkeypatch
44
- # coupling — not something to build silently as a side effect of
45
- # another task.
46
- class CapabilityNegotiator
47
- def initialize(registry: CapabilityRegistry.default)
48
- @registry = registry
49
- end
50
-
51
- # @param adapter [Portage::Ucp::Adapter]
52
- # @param platform_versions [Hash<String, Array<String>>] capability name =>
53
- # versions the platform/agent advertises support for. A capability
54
- # absent from this hash falls back to "offer all business-advertised
55
- # versions, newest-first" (§10's stdio fallback, applied uniformly).
56
- # @return [Hash<String, String>] capability name => negotiated version.
57
- # A capability with no version in common is omitted, not raised on —
58
- # it simply isn't usable for this session.
59
- def negotiate(adapter:, platform_versions: {})
60
- @registry.advertised(adapter).each_with_object({}) do |capability, negotiated|
61
- business_versions = [capability.version]
62
- offered = platform_versions[capability.name]
63
-
64
- version = offered ? (offered & business_versions).first : business_versions.max
65
- negotiated[capability.name] = version if version
66
- end
67
- end
68
- end
69
- end
70
- end
@@ -1,634 +0,0 @@
1
- {
2
- "openrpc": "1.3.2",
3
- "info": {
4
- "title": "UCP Shopping Embedded Protocol",
5
- "description": "Embedded Protocol (EP) methods for the UCP shopping service. Methods are sent from Merchant to Host via postMessage using JSON-RPC 2.0. Method prefixes indicate capability scope: ep.{capability} (i.e. ep.checkout). Schema references are logical pointers - actual payload shape is determined by negotiated capabilities.\n\nEmbedded Protocol is a client-to-client postMessage interface, so there is no endpoint URL to access this API. Platforms detect merchant support for this protocol based on the presence of `services[\"dev.ucp.shopping\"][transport=embedded]` in discovery responses.",
6
- "version": "2026-04-08"
7
- },
8
- "servers": [],
9
- "methods": [
10
- {
11
- "name": "ec.ready",
12
- "summary": "Handshake from merchant to host",
13
- "description": "Initiates the Embedded Checkout Protocol. Merchant declares which delegations it accepts and requests any additional authorization, host responds with optional channel upgrade, auth credential, and initial checkout state.",
14
- "params": [
15
- {
16
- "name": "delegate",
17
- "required": true,
18
- "schema": {
19
- "type": "array",
20
- "description": "Delegation types the merchant accepts. Must be subset of checkout.embedded.delegations.",
21
- "items": {
22
- "type": "string",
23
- "pattern": "^[a-z_]+(?:\\.[a-z_]+)*$"
24
- },
25
- "uniqueItems": true
26
- }
27
- },
28
- {
29
- "name": "auth",
30
- "schema": {
31
- "$ref": "#/components/schemas/auth"
32
- }
33
- }
34
- ],
35
- "result": {
36
- "name": "readyResult",
37
- "schema": {
38
- "oneOf": [
39
- {
40
- "type": "object",
41
- "description": "Handshake response from host.",
42
- "required": [
43
- "ucp"
44
- ],
45
- "properties": {
46
- "ucp": {
47
- "$ref": "#/components/schemas/ucp_success"
48
- },
49
- "upgrade": {
50
- "type": "object",
51
- "description": "Channel upgrade instructions. If present, switch to provided MessagePort.",
52
- "properties": {
53
- "port": {
54
- "type": "object",
55
- "description": "MessagePort for upgraded channel. Runtime type is MessagePort."
56
- }
57
- }
58
- },
59
- "credential": {
60
- "type": "string",
61
- "description": "Requested authorization. Some common examples include API key and OAuth token."
62
- },
63
- "checkout": {
64
- "type": "object",
65
- "description": "Initial delegation state from host. Fields are permitted only when the corresponding delegation is accepted.",
66
- "properties": {
67
- "payment": {
68
- "$ref": "#/components/schemas/payment_instrument_selection"
69
- },
70
- "fulfillment": {
71
- "$ref": "https://ucp.dev/2026-04-08/schemas/shopping/types/fulfillment.json"
72
- }
73
- }
74
- }
75
- }
76
- },
77
- {
78
- "$ref": "https://ucp.dev/2026-04-08/schemas/shopping/types/error_response.json"
79
- }
80
- ]
81
- }
82
- }
83
- },
84
- {
85
- "name": "ec.auth",
86
- "summary": "Authorization exchange between host & business.",
87
- "description": "Augments Embedded Checkout Protocol by doing auth exchanges per business requirement. Can be used by business to refresh a previously exchanged authorization.",
88
- "params": [
89
- {
90
- "name": "type",
91
- "schema": {
92
- "$ref": "#/components/schemas/type"
93
- }
94
- }
95
- ],
96
- "result": {
97
- "name": "authResult",
98
- "schema": {
99
- "oneOf": [
100
- {
101
- "type": "object",
102
- "description": "Auth response from host containing the requested authorization data.",
103
- "required": [
104
- "ucp",
105
- "credential"
106
- ],
107
- "properties": {
108
- "ucp": {
109
- "$ref": "#/components/schemas/ucp_success"
110
- },
111
- "credential": {
112
- "type": "string",
113
- "description": "Requested authorization. Some common examples include API key and OAuth token."
114
- }
115
- }
116
- },
117
- {
118
- "$ref": "https://ucp.dev/2026-04-08/schemas/shopping/types/error_response.json"
119
- }
120
- ]
121
- }
122
- }
123
- },
124
- {
125
- "name": "ec.error",
126
- "summary": "A session-level error has occurred.",
127
- "description": "Business notifies host that a session-level error, unrelated to the checkout resource, has occurred.",
128
- "params": [
129
- {
130
- "name": "error",
131
- "required": true,
132
- "schema": {
133
- "$ref": "https://ucp.dev/2026-04-08/schemas/shopping/types/error_response.json",
134
- "description": "Session-level error raised by the business."
135
- }
136
- }
137
- ]
138
- },
139
- {
140
- "name": "ec.start",
141
- "summary": "Checkout visible to buyer",
142
- "description": "Merchant notifies host that checkout UI is visible and ready for interaction. Sent after successful ec.ready handshake.",
143
- "params": [
144
- {
145
- "name": "checkout",
146
- "required": true,
147
- "schema": {
148
- "$ref": "https://ucp.dev/2026-04-08/schemas/shopping/checkout.json",
149
- "description": "Current checkout state."
150
- }
151
- }
152
- ]
153
- },
154
- {
155
- "name": "ec.complete",
156
- "summary": "Checkout completed successfully",
157
- "description": "Merchant notifies host that order has been placed successfully.",
158
- "params": [
159
- {
160
- "name": "checkout",
161
- "required": true,
162
- "schema": {
163
- "$ref": "https://ucp.dev/2026-04-08/schemas/shopping/checkout.json",
164
- "description": "Final checkout state."
165
- }
166
- }
167
- ]
168
- },
169
- {
170
- "name": "ec.messages.change",
171
- "summary": "Checkout messages changed",
172
- "description": "Merchant notifies host that checkout.messages has changed. Includes errors, warnings, and info. Host should update UI accordingly.",
173
- "params": [
174
- {
175
- "name": "checkout",
176
- "required": true,
177
- "schema": {
178
- "$ref": "https://ucp.dev/2026-04-08/schemas/shopping/checkout.json",
179
- "description": "Current checkout state with updated messages."
180
- }
181
- }
182
- ]
183
- },
184
- {
185
- "name": "ec.line_items.change",
186
- "summary": "Line items changed",
187
- "description": "Merchant notifies host that checkout.line_items has changed (item added, removed, quantity updated).",
188
- "params": [
189
- {
190
- "name": "checkout",
191
- "required": true,
192
- "schema": {
193
- "$ref": "https://ucp.dev/2026-04-08/schemas/shopping/checkout.json",
194
- "description": "Current checkout state with updated line items."
195
- }
196
- }
197
- ]
198
- },
199
- {
200
- "name": "ec.buyer.change",
201
- "summary": "Buyer information changed",
202
- "description": "Merchant notifies host that checkout.buyer has changed (email, phone, address updated).",
203
- "params": [
204
- {
205
- "name": "checkout",
206
- "required": true,
207
- "schema": {
208
- "$ref": "https://ucp.dev/2026-04-08/schemas/shopping/checkout.json",
209
- "description": "Current checkout state with updated buyer."
210
- }
211
- }
212
- ]
213
- },
214
- {
215
- "name": "ec.totals.change",
216
- "summary": "Checkout totals changed",
217
- "description": "Merchant notifies host that checkout.totals has changed. Covers all total line updates including taxes, fees, discounts, and fulfillment costs. For changes that also affect a specific domain (e.g., fulfillment cost update), this message MUST be sent after the domain-specific change message (e.g., ec.fulfillment.change).",
218
- "params": [
219
- {
220
- "name": "checkout",
221
- "required": true,
222
- "schema": {
223
- "$ref": "https://ucp.dev/2026-04-08/schemas/shopping/checkout.json",
224
- "description": "Current checkout state with updated totals."
225
- }
226
- }
227
- ]
228
- },
229
- {
230
- "name": "ec.payment.change",
231
- "summary": "Payment state changed",
232
- "description": "Merchant notifies host that checkout.payment has changed (instrument selected, status updated).",
233
- "params": [
234
- {
235
- "name": "checkout",
236
- "required": true,
237
- "schema": {
238
- "$ref": "https://ucp.dev/2026-04-08/schemas/shopping/checkout.json",
239
- "description": "Current checkout state with updated payment."
240
- }
241
- }
242
- ]
243
- },
244
- {
245
- "name": "ec.payment.instruments_change_request",
246
- "summary": "Request payment instrument change",
247
- "description": "Merchant requests host to present payment instrument selection UI for the buyer to change their payment method.",
248
- "params": [
249
- {
250
- "name": "checkout",
251
- "required": true,
252
- "schema": {
253
- "$ref": "https://ucp.dev/2026-04-08/schemas/shopping/checkout.json",
254
- "description": "Current checkout state."
255
- }
256
- }
257
- ],
258
- "result": {
259
- "name": "instrumentsChangeResult",
260
- "schema": {
261
- "oneOf": [
262
- {
263
- "type": "object",
264
- "description": "Checkout state after instrument selection.",
265
- "required": [
266
- "ucp",
267
- "checkout"
268
- ],
269
- "properties": {
270
- "ucp": {
271
- "$ref": "#/components/schemas/ucp_success"
272
- },
273
- "checkout": {
274
- "type": "object",
275
- "description": "Partial checkout update with payment instrument selection.",
276
- "properties": {
277
- "payment": {
278
- "$ref": "#/components/schemas/payment_instrument_selection"
279
- }
280
- }
281
- }
282
- }
283
- },
284
- {
285
- "$ref": "https://ucp.dev/2026-04-08/schemas/shopping/types/error_response.json"
286
- }
287
- ]
288
- }
289
- }
290
- },
291
- {
292
- "name": "ec.payment.credential_request",
293
- "summary": "Request payment credential",
294
- "description": "Merchant requests host to collect payment credential for selected instrument (CVV, 3DS, wallet confirmation).",
295
- "params": [
296
- {
297
- "name": "checkout",
298
- "required": true,
299
- "schema": {
300
- "$ref": "https://ucp.dev/2026-04-08/schemas/shopping/checkout.json",
301
- "description": "Current checkout state with selected instrument."
302
- }
303
- }
304
- ],
305
- "result": {
306
- "name": "credentialResult",
307
- "schema": {
308
- "oneOf": [
309
- {
310
- "type": "object",
311
- "description": "Checkout state with payment credential ready for completion.",
312
- "required": [
313
- "ucp",
314
- "checkout"
315
- ],
316
- "properties": {
317
- "ucp": {
318
- "$ref": "#/components/schemas/ucp_success"
319
- },
320
- "checkout": {
321
- "type": "object",
322
- "description": "Partial checkout update with payment credential.",
323
- "properties": {
324
- "payment": {
325
- "$ref": "#/components/schemas/payment_instruments"
326
- }
327
- }
328
- }
329
- }
330
- },
331
- {
332
- "$ref": "https://ucp.dev/2026-04-08/schemas/shopping/types/error_response.json"
333
- }
334
- ]
335
- }
336
- }
337
- },
338
- {
339
- "name": "ec.window.open_request",
340
- "summary": "Request window open",
341
- "description": "The buyer activated a link within checkout. The host MUST present the content to the buyer and respond with a success result, or respond with a window_open_rejected_error error if host policy prevented the navigation.",
342
- "params": [
343
- {
344
- "name": "url",
345
- "required": true,
346
- "schema": {
347
- "type": "string",
348
- "format": "uri",
349
- "description": "The URL of the resource to present."
350
- }
351
- }
352
- ],
353
- "result": {
354
- "name": "windowOpenResult",
355
- "schema": {
356
- "oneOf": [
357
- {
358
- "type": "object",
359
- "description": "Acknowledgement that the host handled the request.",
360
- "required": [
361
- "ucp"
362
- ],
363
- "properties": {
364
- "ucp": {
365
- "$ref": "#/components/schemas/ucp_success"
366
- }
367
- }
368
- },
369
- {
370
- "$ref": "https://ucp.dev/2026-04-08/schemas/shopping/types/error_response.json"
371
- }
372
- ]
373
- }
374
- }
375
- },
376
- {
377
- "$ref": "https://ucp.dev/2026-04-08/schemas/shopping/fulfillment.json#/embedded/methods/ec.fulfillment.change"
378
- },
379
- {
380
- "$ref": "https://ucp.dev/2026-04-08/schemas/shopping/fulfillment.json#/embedded/methods/ec.fulfillment.address_change_request"
381
- },
382
- {
383
- "name": "ep.cart.ready",
384
- "summary": "Handshake from business to host",
385
- "description": "Initiates the Embedded Cart Protocol. Business declares which delegation it supports and requests any additional authorization, host responds with optional channel upgrade and auth credential.",
386
- "params": [
387
- {
388
- "name": "delegate",
389
- "required": true,
390
- "schema": {
391
- "type": "array",
392
- "description": "Delegation types the merchant accepts.",
393
- "items": {
394
- "type": "string",
395
- "pattern": "^[a-z_]+(?:\\.[a-z_]+)*$"
396
- },
397
- "uniqueItems": true
398
- }
399
- },
400
- {
401
- "name": "auth",
402
- "schema": {
403
- "$ref": "#/components/schemas/auth"
404
- }
405
- }
406
- ],
407
- "result": {
408
- "name": "readyResult",
409
- "schema": {
410
- "oneOf": [
411
- {
412
- "type": "object",
413
- "description": "Handshake response from host.",
414
- "required": [
415
- "ucp"
416
- ],
417
- "properties": {
418
- "ucp": {
419
- "$ref": "#/components/schemas/ucp_success"
420
- },
421
- "upgrade": {
422
- "type": "object",
423
- "description": "Channel upgrade instructions. If present, switch to provided MessagePort.",
424
- "properties": {
425
- "port": {
426
- "type": "object",
427
- "description": "MessagePort for upgraded channel. Runtime type is MessagePort."
428
- }
429
- }
430
- },
431
- "credential": {
432
- "type": "string",
433
- "description": "Requested authorization. Some common examples include API key and OAuth token."
434
- }
435
- }
436
- },
437
- {
438
- "$ref": "https://ucp.dev/2026-04-08/schemas/shopping/types/error_response.json"
439
- }
440
- ]
441
- }
442
- }
443
- },
444
- {
445
- "name": "ep.cart.auth",
446
- "summary": "Authorization exchange between host & business.",
447
- "description": "Augments Embedded Cart Protocol by doing auth exchanges per business requirement. Can be used by business to refresh a previously exchanged authorization.",
448
- "params": [
449
- {
450
- "name": "type",
451
- "schema": {
452
- "$ref": "#/components/schemas/type"
453
- }
454
- }
455
- ],
456
- "result": {
457
- "name": "authResult",
458
- "schema": {
459
- "oneOf": [
460
- {
461
- "type": "object",
462
- "description": "Auth response from host containing the requested authorization data.",
463
- "required": [
464
- "ucp",
465
- "credential"
466
- ],
467
- "properties": {
468
- "ucp": {
469
- "$ref": "#/components/schemas/ucp_success"
470
- },
471
- "credential": {
472
- "type": "string",
473
- "description": "Requested authorization. Some common examples include API key and OAuth token."
474
- }
475
- }
476
- },
477
- {
478
- "$ref": "https://ucp.dev/2026-04-08/schemas/shopping/types/error_response.json"
479
- }
480
- ]
481
- }
482
- }
483
- },
484
- {
485
- "name": "ep.cart.error",
486
- "summary": "A session-level error has occurred.",
487
- "description": "Business notifies host that a session-level error, unrelated to the cart resource, has occurred.",
488
- "params": [
489
- {
490
- "name": "error",
491
- "required": true,
492
- "schema": {
493
- "$ref": "https://ucp.dev/2026-04-08/schemas/shopping/types/error_response.json",
494
- "description": "Session-level error raised by the business."
495
- }
496
- }
497
- ]
498
- },
499
- {
500
- "name": "ep.cart.start",
501
- "summary": "Cart visible to buyer",
502
- "description": "Business notifies host that cart UI is visible and ready for interaction.",
503
- "params": [
504
- {
505
- "name": "cart",
506
- "required": true,
507
- "schema": {
508
- "$ref": "https://ucp.dev/2026-04-08/schemas/shopping/cart.json",
509
- "description": "Current cart state."
510
- }
511
- }
512
- ]
513
- },
514
- {
515
- "name": "ep.cart.complete",
516
- "summary": "Cart completed successfully",
517
- "description": "Business notifies host that cart building has completed.",
518
- "params": [
519
- {
520
- "name": "cart",
521
- "required": true,
522
- "schema": {
523
- "$ref": "https://ucp.dev/2026-04-08/schemas/shopping/cart.json",
524
- "description": "Final cart state."
525
- }
526
- }
527
- ]
528
- },
529
- {
530
- "name": "ep.cart.line_items.change",
531
- "summary": "Line items changed",
532
- "description": "Business notifies host that cart.line_items has changed (item added, removed, quantity updated).",
533
- "params": [
534
- {
535
- "name": "cart",
536
- "required": true,
537
- "schema": {
538
- "$ref": "https://ucp.dev/2026-04-08/schemas/shopping/cart.json",
539
- "description": "Current cart state with updated line items."
540
- }
541
- }
542
- ]
543
- },
544
- {
545
- "name": "ep.cart.buyer.change",
546
- "summary": "Buyer information changed",
547
- "description": "Business notifies host that cart.buyer has changed (email, phone, name updated).",
548
- "params": [
549
- {
550
- "name": "cart",
551
- "required": true,
552
- "schema": {
553
- "$ref": "https://ucp.dev/2026-04-08/schemas/shopping/cart.json",
554
- "description": "Current cart state with updated buyer."
555
- }
556
- }
557
- ]
558
- },
559
- {
560
- "name": "ep.cart.messages.change",
561
- "summary": "Cart messages changed",
562
- "description": "Business notifies host that cart.messages has changed. Includes errors, warnings, and info. Host should update UI accordingly.",
563
- "params": [
564
- {
565
- "name": "cart",
566
- "required": true,
567
- "schema": {
568
- "$ref": "https://ucp.dev/2026-04-08/schemas/shopping/cart.json",
569
- "description": "Current cart state with updated messages."
570
- }
571
- }
572
- ]
573
- }
574
- ],
575
- "components": {
576
- "schemas": {
577
- "ucp_success": {
578
- "$ref": "https://ucp.dev/2026-04-08/schemas/ucp.json#/$defs/success"
579
- },
580
- "payment_instruments": {
581
- "type": "object",
582
- "description": "Payment instruments from host.",
583
- "properties": {
584
- "instruments": {
585
- "type": "array",
586
- "items": {
587
- "$ref": "https://ucp.dev/2026-04-08/schemas/shopping/types/payment_instrument.json#/$defs/selected_payment_instrument"
588
- },
589
- "description": "Available payment instruments."
590
- }
591
- }
592
- },
593
- "payment_instrument_selection": {
594
- "description": "Payment instruments with selected instrument ID.",
595
- "allOf": [
596
- {
597
- "$ref": "#/components/schemas/payment_instruments"
598
- },
599
- {
600
- "properties": {
601
- "selected_instrument_id": {
602
- "type": "string",
603
- "description": "ID of the selected payment instrument."
604
- }
605
- }
606
- }
607
- ]
608
- },
609
- "type": {
610
- "type": "string",
611
- "description": "The type of authorization data business is requesting from the host.",
612
- "examples": [
613
- "oauth",
614
- "api_key",
615
- "jwt"
616
- ]
617
- },
618
- "auth": {
619
- "type": "object",
620
- "properties": {
621
- "type": {
622
- "$ref": "#/components/schemas/type"
623
- }
624
- }
625
- }
626
- }
627
- },
628
- "x-delegations": [
629
- "payment.instruments_change",
630
- "payment.credential",
631
- "fulfillment.address_change",
632
- "window.open"
633
- ]
634
- }