Module: Ai2Web::Ap2
- Defined in:
- lib/ai2web/ap2.rb
Overview
AP2 (Agent Payments Protocol, Google - v0.2.0) merchant primitives.
AP2 is mandate-based: the merchant prices a buyer agent's Intent Mandate as a CartContents (a W3C PaymentRequest, amounts in decimal major units) and digitally signs it into a CartMandate - a short-lived guarantee of items and price - then settles a user-signed Payment Mandate. This module provides the reusable, app-agnostic core: build the mandate objects, sign a CartContents as an RS256 JWT (cart_hash over the canonical contents), publish the public key as a JWKS, verify a Cart Mandate, and parse a Payment Mandate. Signing uses the OpenSSL standard library, so the SDK keeps zero third-party dependencies.
Constant Summary collapse
- EXTENSION_URI =
"https://github.com/google-agentic-commerce/ap2/v1"- VERSION =
"0.2.0"- DEFAULT_TTL =
900
Class Method Summary collapse
-
.amount(value, currency) ⇒ Object
AP2 PaymentCurrencyAmount: decimal major units, ISO 4217.
- .b64url(bin) ⇒ Object
- .b64url_decode(str) ⇒ Object
-
.canonical(value) ⇒ Object
JCS (RFC 8785) canonicalisation, so a cart_hash is byte-identical across every SDK: object keys sorted, no whitespace, minimal string escaping, integers without a decimal point, currency amounts as a short decimal.
-
.cart_contents(items, currency, merchant_name, id: nil, payment_details_id: nil, expires_in: DEFAULT_TTL, now: nil) ⇒ Object
Build a CartContents (W3C PaymentRequest) from line items.
-
.cart_mandate(contents, private_key_pem, **opts) ⇒ Object
Sign CartContents into a CartMandate (contents + merchant_authorization).
-
.intent_mandate(description, merchants: nil, skus: nil, items: nil, requires_refundability: false, user_cart_confirmation_required: true, expires_in: DEFAULT_TTL, now: nil) ⇒ Object
Build an AP2 IntentMandate (classic v0.2.0 shape).
- .iso(timestamp) ⇒ Object
- .jcs_number(x) ⇒ Object
- .jcs_string(str) ⇒ Object
-
.jwks(private_key_pem, kid: nil) ⇒ Object
JWKS publishing the cart-signing public key, for verifiers.
- .kid_of(key) ⇒ Object
-
.payment_details(payment_mandate) ⇒ Object
Extract the salient fields of a PaymentMandate for settlement.
-
.sign_cart(contents, private_key_pem, kid: nil, iss: nil, aud: "ap2-network", expires_in: DEFAULT_TTL, now: nil) ⇒ Object
The merchant_authorization JWT (RS256) over the canonical CartContents.
-
.transport(overrides = {}) ⇒ Object
The transports.ap2 advertisement to merge into a manifest.
-
.verify_cart_mandate(mandate, key_pem) ⇒ Object
Verify a CartMandate's signature (against a public or private PEM) and its cart_hash binding, and that it has not expired.
Class Method Details
.amount(value, currency) ⇒ Object
AP2 PaymentCurrencyAmount: decimal major units, ISO 4217.
56 57 58 |
# File 'lib/ai2web/ap2.rb', line 56 def amount(value, currency) { "currency" => currency.upcase, "value" => value.round(2) } end |
.b64url(bin) ⇒ Object
226 227 228 |
# File 'lib/ai2web/ap2.rb', line 226 def b64url(bin) Base64.urlsafe_encode64(bin, padding: false) end |
.b64url_decode(str) ⇒ Object
230 231 232 |
# File 'lib/ai2web/ap2.rb', line 230 def b64url_decode(str) Base64.urlsafe_decode64(str + ("=" * ((4 - (str.length % 4)) % 4))) end |
.canonical(value) ⇒ Object
JCS (RFC 8785) canonicalisation, so a cart_hash is byte-identical across every SDK: object keys sorted, no whitespace, minimal string escaping, integers without a decimal point, currency amounts as a short decimal.
183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 |
# File 'lib/ai2web/ap2.rb', line 183 def canonical(value) case value when nil then "null" when true then "true" when false then "false" when Integer then value.to_s when Float then jcs_number(value) when String then jcs_string(value) when Array then "[#{value.map { |e| canonical(e) }.join(',')}]" when Hash pairs = value.map { |k, v| [k.to_s, v] }.sort_by { |k, _| k } "{#{pairs.map { |k, v| "#{jcs_string(k)}:#{canonical(v)}" }.join(',')}}" else "null" end end |
.cart_contents(items, currency, merchant_name, id: nil, payment_details_id: nil, expires_in: DEFAULT_TTL, now: nil) ⇒ Object
Build a CartContents (W3C PaymentRequest) from line items. Each item is { label:, unit_amount:, quantity: 1 } (string or symbol keys).
62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 |
# File 'lib/ai2web/ap2.rb', line 62 def cart_contents(items, currency, merchant_name, id: nil, payment_details_id: nil, expires_in: DEFAULT_TTL, now: nil) ts = now || Time.now.to_i display = [] total = 0.0 items.each do |it| qty = [(it[:quantity] || it["quantity"] || 1).to_i, 1].max unit = (it[:unit_amount] || it["unit_amount"] || it[:amount] || it["amount"] || 0).to_f line = unit * qty label = (it[:label] || it["label"] || "Item").to_s label = "#{label} x#{qty}" if qty > 1 display << { "label" => label, "amount" => amount(line, currency) } total += line end { "id" => id || "cart_#{SecureRandom.hex(10)}", "user_cart_confirmation_required" => true, "payment_request" => { "method_data" => [{ "supported_methods" => "card", "data" => {} }], "details" => { "id" => payment_details_id || "pr_#{SecureRandom.hex(10)}", "display_items" => display, "total" => { "label" => "Total", "amount" => amount(total, currency) } }, "options" => { "request_shipping" => true } }, "cart_expiry" => iso(ts + expires_in), "merchant_name" => merchant_name } end |
.cart_mandate(contents, private_key_pem, **opts) ⇒ Object
Sign CartContents into a CartMandate (contents + merchant_authorization).
112 113 114 |
# File 'lib/ai2web/ap2.rb', line 112 def cart_mandate(contents, private_key_pem, **opts) { "contents" => contents, "merchant_authorization" => sign_cart(contents, private_key_pem, **opts) } end |
.intent_mandate(description, merchants: nil, skus: nil, items: nil, requires_refundability: false, user_cart_confirmation_required: true, expires_in: DEFAULT_TTL, now: nil) ⇒ Object
Build an AP2 IntentMandate (classic v0.2.0 shape).
40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
# File 'lib/ai2web/ap2.rb', line 40 def intent_mandate(description, merchants: nil, skus: nil, items: nil, requires_refundability: false, user_cart_confirmation_required: true, expires_in: DEFAULT_TTL, now: nil) ts = now || Time.now.to_i m = { "natural_language_description" => description, "intent_expiry" => iso(ts + expires_in), "user_cart_confirmation_required" => user_cart_confirmation_required } m["merchants"] = merchants if merchants && !merchants.empty? m["skus"] = skus if skus && !skus.empty? m["items"] = items if items && !items.empty? m["requires_refundability"] = true if requires_refundability m end |
.iso(timestamp) ⇒ Object
234 235 236 |
# File 'lib/ai2web/ap2.rb', line 234 def iso() Time.at().utc.strftime("%Y-%m-%dT%H:%M:%S+00:00") end |
.jcs_number(x) ⇒ Object
199 200 201 202 203 |
# File 'lib/ai2web/ap2.rb', line 199 def jcs_number(x) return x.to_i.to_s if x == x.to_i && x.abs < 1e15 format("%.2f", x).sub(/0+\z/, "").sub(/\.\z/, "") end |
.jcs_string(str) ⇒ Object
205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 |
# File 'lib/ai2web/ap2.rb', line 205 def jcs_string(str) out = +'"' str.each_char do |ch| o = ch.ord if ch == '"' out << '\\"' elsif ch == "\\" out << "\\\\" elsif o == 0x08 then out << "\\b" elsif o == 0x09 then out << "\\t" elsif o == 0x0A then out << "\\n" elsif o == 0x0C then out << "\\f" elsif o == 0x0D then out << "\\r" elsif o < 0x20 then out << format("\\u%04x", o) else out << ch end end out << '"' out end |
.jwks(private_key_pem, kid: nil) ⇒ Object
JWKS publishing the cart-signing public key, for verifiers.
117 118 119 120 121 122 123 124 125 126 127 128 129 130 |
# File 'lib/ai2web/ap2.rb', line 117 def jwks(private_key_pem, kid: nil) key = OpenSSL::PKey::RSA.new(private_key_pem) pub = key.public_key { "keys" => [{ "kty" => "RSA", "use" => "sig", "alg" => "RS256", "kid" => kid || kid_of(key), "n" => b64url(pub.n.to_s(2)), "e" => b64url(pub.e.to_s(2)) }] } end |
.kid_of(key) ⇒ Object
238 239 240 241 |
# File 'lib/ai2web/ap2.rb', line 238 def kid_of(key) pub = key.public_key Digest::SHA256.hexdigest(pub.n.to_s(2) + pub.e.to_s(2))[0, 16] end |
.payment_details(payment_mandate) ⇒ Object
Extract the salient fields of a PaymentMandate for settlement.
164 165 166 167 168 169 170 171 172 173 174 175 176 |
# File 'lib/ai2web/ap2.rb', line 164 def payment_details(payment_mandate) c = payment_mandate["payment_mandate_contents"] || {} resp = c["payment_response"] || {} total = c["payment_details_total"] || {} { "payment_mandate_id" => c["payment_mandate_id"], "payment_details_id" => c["payment_details_id"], "total" => total["amount"], "method" => resp["method_name"], "payer_email" => resp["payer_email"], "payer_name" => resp["payer_name"] } end |
.sign_cart(contents, private_key_pem, kid: nil, iss: nil, aud: "ap2-network", expires_in: DEFAULT_TTL, now: nil) ⇒ Object
The merchant_authorization JWT (RS256) over the canonical CartContents.
93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 |
# File 'lib/ai2web/ap2.rb', line 93 def sign_cart(contents, private_key_pem, kid: nil, iss: nil, aud: "ap2-network", expires_in: DEFAULT_TTL, now: nil) key = OpenSSL::PKey::RSA.new(private_key_pem) ts = now || Time.now.to_i header = { "alg" => "RS256", "typ" => "JWT", "kid" => kid || kid_of(key) } claims = { "iss" => iss || contents["merchant_name"] || "", "sub" => contents["id"] || "", "aud" => aud, "iat" => ts, "exp" => ts + expires_in, "jti" => SecureRandom.hex(12), "cart_hash" => b64url(Digest::SHA256.digest(canonical(contents))) } signing_input = "#{b64url(canonical(header))}.#{b64url(canonical(claims))}" sig = key.sign(OpenSSL::Digest.new("SHA256"), signing_input) "#{signing_input}.#{b64url(sig)}" end |
.transport(overrides = {}) ⇒ Object
The transports.ap2 advertisement to merge into a manifest.
27 28 29 30 31 32 33 34 35 36 37 |
# File 'lib/ai2web/ap2.rb', line 27 def transport(overrides = {}) { "enabled" => true, "version" => VERSION, "extension" => EXTENSION_URI, "agent_card" => "/ai2w/ap2/agent-card", "cart" => "/ai2w/ap2/cart", "payment" => "/ai2w/ap2/payment", "jwks" => "/ai2w/ap2/jwks" }.merge(overrides.each_with_object({}) { |(k, v), o| o[k.to_s] = v }) end |
.verify_cart_mandate(mandate, key_pem) ⇒ Object
Verify a CartMandate's signature (against a public or private PEM) and its cart_hash binding, and that it has not expired.
134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 |
# File 'lib/ai2web/ap2.rb', line 134 def verify_cart_mandate(mandate, key_pem) parts = mandate["merchant_authorization"].to_s.split(".") return false unless parts.length == 3 key = begin OpenSSL::PKey::RSA.new(key_pem) rescue StandardError return false end sig = begin b64url_decode(parts[2]) rescue StandardError return false end return false unless key.verify(OpenSSL::Digest.new("SHA256"), sig, "#{parts[0]}.#{parts[1]}") claims = begin JSON.parse(b64url_decode(parts[1])) rescue StandardError return false end ch = claims["cart_hash"].to_s return false if ch.empty? return false if claims["exp"] && Time.now.to_i > claims["exp"].to_i expected = b64url(Digest::SHA256.digest(canonical(mandate["contents"] || {}))) ch == expected end |