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

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(timestamp)
  Time.at(timestamp).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