portage-ucp 0.11.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 +4 -4
- data/CHANGELOG.md +14 -0
- data/lib/portage/ucp/ap2/mandate_signature.rb +12 -54
- data/lib/portage/ucp/dispatcher.rb +1 -7
- data/lib/portage/ucp/mcp/server.rb +1 -1
- data/lib/portage/ucp/schema_validator.rb +0 -4
- data/lib/portage/ucp/security/ec_jwk.rb +56 -0
- data/lib/portage/ucp/security/signature.rb +8 -48
- data/lib/portage/ucp/support/proxy_config.rb +1 -5
- data/lib/portage/ucp/version.rb +1 -1
- data/lib/portage/ucp.rb +0 -1
- metadata +3 -4
- data/lib/portage/ucp/capability_negotiator.rb +0 -70
- data/schemas/2026-04-08/services/shopping/embedded.openrpc.json +0 -634
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4f3dd667eb94ccfb68762797d5020e233f282d941b4790503a812b01067be09f
|
|
4
|
+
data.tar.gz: bb3cb10f93333cb720172aa427f2b09b03d186c03b98485b63a8dca9dae8a302
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 58b3c2ab0aad754862745bf0381e8e37987d2fa8c4da6f80c6460284cb4242d7f9c45e74b83918f6ab032770f38467f6bb62f08cefa06555e2de2dedead2e588
|
|
7
|
+
data.tar.gz: e9dd08412d7fb321da9118ceb01804a71894da1bca6a7263ffab5ed3efda335657e5c1c2d3bf52d027056bd4a88ab0e6889eacdb9c454103e595ed961a0b885a
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,20 @@ pre-1.0, so APIs may still shift between minor versions.
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
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
|
+
|
|
9
23
|
## [0.11.0] - 2026-09-29
|
|
10
24
|
|
|
11
25
|
- `Portage::Ucp::Check` (and `portage-ucp-check`) now follows a `<link rel="ucp" href="...">`
|
|
@@ -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)
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
# to canonicalize, just
|
|
21
|
-
# Security::Signature's own class
|
|
22
|
-
# story in this gem as deliberately distinct
|
|
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 =
|
|
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)
|
|
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
|
|
@@ -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
|
-
|
|
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
|
|
|
@@ -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
|
|
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 =
|
|
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
|
-
#
|
|
238
|
-
#
|
|
239
|
-
#
|
|
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
|
-
|
|
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(**
|
|
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
|
data/lib/portage/ucp/version.rb
CHANGED
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.
|
|
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-
|
|
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
|
-
}
|