ai2web 0.1.0 → 0.1.1

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: 8142d4c3ef57f6bb7ba24a60b478f4cab0e981c1acf0613f6ace7fe81b69a0a4
4
- data.tar.gz: c1ddfcec49f61c4ac55299ce2dfba7467c742faf48d65c7559e581ee4d467eb1
3
+ metadata.gz: 892b44b396f60be4ebd7af6bca0c19be6b1c3d8fdbdd2a66b2ed9235915c8690
4
+ data.tar.gz: a6705de41a0928bdddcf5787b13b72b53f0d45b6e47c4805deff898e5c650fad
5
5
  SHA512:
6
- metadata.gz: 82e19660de9ef91eb1a7167038ec9cb15db553d726936380e768305fcdb736cae9598445954d3433cc4915e05b677e977132002cbad2786a30ddfa26fc991177
7
- data.tar.gz: dbb147055be31d20c74aab55c6bee8c21dc6f0d6d6574f76a6e0ef35cf0b1280737527b151b14622dfb372032c68407086e215ee036630ca5805a81ca3b03937
6
+ metadata.gz: 675f6db1358bf674c7579b27db5b5ca46f4e44be3cd76abacf793f4e10a25ef4f2b83fff596bf8bd65010476004c6c3e7b629e31344e6b8ee47de50fca91c1d6
7
+ data.tar.gz: df4a693934c5e3838a2e016034a79d4ca838295a0842d77117fbd52ba1fe17551ac50791e9dc156c4830a896633bd31a12802d58f0b14901cad6b6b24673eb3d
data/README.md CHANGED
@@ -9,6 +9,8 @@
9
9
 
10
10
  # AI2Web Ruby SDK (`ai2web`)
11
11
 
12
+ [![AI2Web on Launchpadly - Product of the Week (Gold)](https://launchpadly.co/embed/badges/startup/ai2web.svg?variant=product-week-gold)](https://launchpadly.co/startup/ai2web?ref=badge)
13
+
12
14
  [![CI](https://github.com/ai2web-foundation/ai2web-ruby/actions/workflows/ci.yml/badge.svg)](https://github.com/ai2web-foundation/ai2web-ruby/actions/workflows/ci.yml)
13
15
  [![Gem Version](https://img.shields.io/gem/v/ai2web)](https://rubygems.org/gems/ai2web)
14
16
 
data/lib/ai2web/ap2.rb ADDED
@@ -0,0 +1,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "digest"
5
+ require "base64"
6
+ require "json"
7
+ require "securerandom"
8
+
9
+ module Ai2Web
10
+ # AP2 (Agent Payments Protocol, Google - v0.2.0) merchant primitives.
11
+ #
12
+ # AP2 is mandate-based: the merchant prices a buyer agent's Intent Mandate as a CartContents
13
+ # (a W3C PaymentRequest, amounts in decimal major units) and digitally signs it into a
14
+ # CartMandate - a short-lived guarantee of items and price - then settles a user-signed Payment
15
+ # Mandate. This module provides the reusable, app-agnostic core: build the mandate objects, sign
16
+ # a CartContents as an RS256 JWT (cart_hash over the canonical contents), publish the public key
17
+ # as a JWKS, verify a Cart Mandate, and parse a Payment Mandate. Signing uses the OpenSSL
18
+ # standard library, so the SDK keeps zero third-party dependencies.
19
+ module Ap2
20
+ EXTENSION_URI = "https://github.com/google-agentic-commerce/ap2/v1"
21
+ VERSION = "0.2.0"
22
+ DEFAULT_TTL = 900
23
+
24
+ module_function
25
+
26
+ # The transports.ap2 advertisement to merge into a manifest.
27
+ def transport(overrides = {})
28
+ {
29
+ "enabled" => true,
30
+ "version" => VERSION,
31
+ "extension" => EXTENSION_URI,
32
+ "agent_card" => "/ai2w/ap2/agent-card",
33
+ "cart" => "/ai2w/ap2/cart",
34
+ "payment" => "/ai2w/ap2/payment",
35
+ "jwks" => "/ai2w/ap2/jwks"
36
+ }.merge(overrides.each_with_object({}) { |(k, v), o| o[k.to_s] = v })
37
+ end
38
+
39
+ # Build an AP2 IntentMandate (classic v0.2.0 shape).
40
+ def intent_mandate(description, merchants: nil, skus: nil, items: nil, requires_refundability: false,
41
+ user_cart_confirmation_required: true, expires_in: DEFAULT_TTL, now: nil)
42
+ ts = now || Time.now.to_i
43
+ m = {
44
+ "natural_language_description" => description,
45
+ "intent_expiry" => iso(ts + expires_in),
46
+ "user_cart_confirmation_required" => user_cart_confirmation_required
47
+ }
48
+ m["merchants"] = merchants if merchants && !merchants.empty?
49
+ m["skus"] = skus if skus && !skus.empty?
50
+ m["items"] = items if items && !items.empty?
51
+ m["requires_refundability"] = true if requires_refundability
52
+ m
53
+ end
54
+
55
+ # AP2 PaymentCurrencyAmount: decimal major units, ISO 4217.
56
+ def amount(value, currency)
57
+ { "currency" => currency.upcase, "value" => value.round(2) }
58
+ end
59
+
60
+ # Build a CartContents (W3C PaymentRequest) from line items. Each item is
61
+ # { label:, unit_amount:, quantity: 1 } (string or symbol keys).
62
+ def cart_contents(items, currency, merchant_name, id: nil, payment_details_id: nil, expires_in: DEFAULT_TTL, now: nil)
63
+ ts = now || Time.now.to_i
64
+ display = []
65
+ total = 0.0
66
+ items.each do |it|
67
+ qty = [(it[:quantity] || it["quantity"] || 1).to_i, 1].max
68
+ unit = (it[:unit_amount] || it["unit_amount"] || it[:amount] || it["amount"] || 0).to_f
69
+ line = unit * qty
70
+ label = (it[:label] || it["label"] || "Item").to_s
71
+ label = "#{label} x#{qty}" if qty > 1
72
+ display << { "label" => label, "amount" => amount(line, currency) }
73
+ total += line
74
+ end
75
+ {
76
+ "id" => id || "cart_#{SecureRandom.hex(10)}",
77
+ "user_cart_confirmation_required" => true,
78
+ "payment_request" => {
79
+ "method_data" => [{ "supported_methods" => "card", "data" => {} }],
80
+ "details" => {
81
+ "id" => payment_details_id || "pr_#{SecureRandom.hex(10)}",
82
+ "display_items" => display,
83
+ "total" => { "label" => "Total", "amount" => amount(total, currency) }
84
+ },
85
+ "options" => { "request_shipping" => true }
86
+ },
87
+ "cart_expiry" => iso(ts + expires_in),
88
+ "merchant_name" => merchant_name
89
+ }
90
+ end
91
+
92
+ # The merchant_authorization JWT (RS256) over the canonical CartContents.
93
+ def sign_cart(contents, private_key_pem, kid: nil, iss: nil, aud: "ap2-network", expires_in: DEFAULT_TTL, now: nil)
94
+ key = OpenSSL::PKey::RSA.new(private_key_pem)
95
+ ts = now || Time.now.to_i
96
+ header = { "alg" => "RS256", "typ" => "JWT", "kid" => kid || kid_of(key) }
97
+ claims = {
98
+ "iss" => iss || contents["merchant_name"] || "",
99
+ "sub" => contents["id"] || "",
100
+ "aud" => aud,
101
+ "iat" => ts,
102
+ "exp" => ts + expires_in,
103
+ "jti" => SecureRandom.hex(12),
104
+ "cart_hash" => b64url(Digest::SHA256.digest(canonical(contents)))
105
+ }
106
+ signing_input = "#{b64url(canonical(header))}.#{b64url(canonical(claims))}"
107
+ sig = key.sign(OpenSSL::Digest.new("SHA256"), signing_input)
108
+ "#{signing_input}.#{b64url(sig)}"
109
+ end
110
+
111
+ # Sign CartContents into a CartMandate (contents + merchant_authorization).
112
+ def cart_mandate(contents, private_key_pem, **opts)
113
+ { "contents" => contents, "merchant_authorization" => sign_cart(contents, private_key_pem, **opts) }
114
+ end
115
+
116
+ # JWKS publishing the cart-signing public key, for verifiers.
117
+ def jwks(private_key_pem, kid: nil)
118
+ key = OpenSSL::PKey::RSA.new(private_key_pem)
119
+ pub = key.public_key
120
+ {
121
+ "keys" => [{
122
+ "kty" => "RSA",
123
+ "use" => "sig",
124
+ "alg" => "RS256",
125
+ "kid" => kid || kid_of(key),
126
+ "n" => b64url(pub.n.to_s(2)),
127
+ "e" => b64url(pub.e.to_s(2))
128
+ }]
129
+ }
130
+ end
131
+
132
+ # Verify a CartMandate's signature (against a public or private PEM) and its cart_hash binding,
133
+ # and that it has not expired.
134
+ def verify_cart_mandate(mandate, key_pem)
135
+ parts = mandate["merchant_authorization"].to_s.split(".")
136
+ return false unless parts.length == 3
137
+
138
+ key = begin
139
+ OpenSSL::PKey::RSA.new(key_pem)
140
+ rescue StandardError
141
+ return false
142
+ end
143
+ sig = begin
144
+ b64url_decode(parts[2])
145
+ rescue StandardError
146
+ return false
147
+ end
148
+ return false unless key.verify(OpenSSL::Digest.new("SHA256"), sig, "#{parts[0]}.#{parts[1]}")
149
+
150
+ claims = begin
151
+ JSON.parse(b64url_decode(parts[1]))
152
+ rescue StandardError
153
+ return false
154
+ end
155
+ ch = claims["cart_hash"].to_s
156
+ return false if ch.empty?
157
+ return false if claims["exp"] && Time.now.to_i > claims["exp"].to_i
158
+
159
+ expected = b64url(Digest::SHA256.digest(canonical(mandate["contents"] || {})))
160
+ ch == expected
161
+ end
162
+
163
+ # Extract the salient fields of a PaymentMandate for settlement.
164
+ def payment_details(payment_mandate)
165
+ c = payment_mandate["payment_mandate_contents"] || {}
166
+ resp = c["payment_response"] || {}
167
+ total = c["payment_details_total"] || {}
168
+ {
169
+ "payment_mandate_id" => c["payment_mandate_id"],
170
+ "payment_details_id" => c["payment_details_id"],
171
+ "total" => total["amount"],
172
+ "method" => resp["method_name"],
173
+ "payer_email" => resp["payer_email"],
174
+ "payer_name" => resp["payer_name"]
175
+ }
176
+ end
177
+
178
+ # --- helpers ---
179
+
180
+ # JCS (RFC 8785) canonicalisation, so a cart_hash is byte-identical across every SDK: object
181
+ # keys sorted, no whitespace, minimal string escaping, integers without a decimal point,
182
+ # currency amounts as a short decimal.
183
+ def canonical(value)
184
+ case value
185
+ when nil then "null"
186
+ when true then "true"
187
+ when false then "false"
188
+ when Integer then value.to_s
189
+ when Float then jcs_number(value)
190
+ when String then jcs_string(value)
191
+ when Array then "[#{value.map { |e| canonical(e) }.join(',')}]"
192
+ when Hash
193
+ pairs = value.map { |k, v| [k.to_s, v] }.sort_by { |k, _| k }
194
+ "{#{pairs.map { |k, v| "#{jcs_string(k)}:#{canonical(v)}" }.join(',')}}"
195
+ else "null"
196
+ end
197
+ end
198
+
199
+ def jcs_number(x)
200
+ return x.to_i.to_s if x == x.to_i && x.abs < 1e15
201
+
202
+ format("%.2f", x).sub(/0+\z/, "").sub(/\.\z/, "")
203
+ end
204
+
205
+ def jcs_string(str)
206
+ out = +'"'
207
+ str.each_char do |ch|
208
+ o = ch.ord
209
+ if ch == '"'
210
+ out << '\\"'
211
+ elsif ch == "\\"
212
+ out << "\\\\"
213
+ elsif o == 0x08 then out << "\\b"
214
+ elsif o == 0x09 then out << "\\t"
215
+ elsif o == 0x0A then out << "\\n"
216
+ elsif o == 0x0C then out << "\\f"
217
+ elsif o == 0x0D then out << "\\r"
218
+ elsif o < 0x20 then out << format("\\u%04x", o)
219
+ else out << ch
220
+ end
221
+ end
222
+ out << '"'
223
+ out
224
+ end
225
+
226
+ def b64url(bin)
227
+ Base64.urlsafe_encode64(bin, padding: false)
228
+ end
229
+
230
+ def b64url_decode(str)
231
+ Base64.urlsafe_decode64(str + ("=" * ((4 - (str.length % 4)) % 4)))
232
+ end
233
+
234
+ def iso(timestamp)
235
+ Time.at(timestamp).utc.strftime("%Y-%m-%dT%H:%M:%S+00:00")
236
+ end
237
+
238
+ def kid_of(key)
239
+ pub = key.public_key
240
+ Digest::SHA256.hexdigest(pub.n.to_s(2) + pub.e.to_s(2))[0, 16]
241
+ end
242
+ end
243
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Ai2Web
6
+ # NLWeb (nlweb.ai) interop primitives.
7
+ #
8
+ # NLWeb turns a site's content into a natural-language, schema.org-flavoured query endpoint (its
9
+ # `ask` API). These helpers let an AI2Web site advertise an NLWeb surface in its manifest and
10
+ # serve a minimal, NLWeb-compatible `ask` response over its own content, so agents that speak
11
+ # NLWeb can query the site without it deploying the full NLWeb stack.
12
+ #
13
+ # The search itself is application-specific (a pure toolkit): the app finds the matching content
14
+ # items and passes them in; #ask_response shapes them into NLWeb's result envelope (list mode,
15
+ # schema.org Item results; pass an answer for generate mode). NLWeb defines no discovery file, so
16
+ # #transport is an AI2Web convention pointing at the site's `/ask` (and `/mcp`) URLs.
17
+ module Nlweb
18
+ VERSION = "0.55"
19
+ DEFAULT_ASK = "/ai2w/nlweb/ask"
20
+ DEFAULT_MCP = "/ai2w/nlweb/mcp"
21
+
22
+ module_function
23
+
24
+ # The transports.nlweb advertisement to merge into a manifest.
25
+ def transport(overrides = {})
26
+ {
27
+ "enabled" => true,
28
+ "version" => VERSION,
29
+ "ask" => DEFAULT_ASK,
30
+ "mcp" => DEFAULT_MCP,
31
+ "modes" => ["list"]
32
+ }.merge(overrides.each_with_object({}) { |(k, v), o| o[k.to_s] = v })
33
+ end
34
+
35
+ # Wrap one content item into an NLWeb result Item.
36
+ def item(content, site: nil, site_url: nil)
37
+ c = stringify(content)
38
+ schema = c["schema_object"].is_a?(Hash) ? c["schema_object"] : schema_object(c)
39
+ {
40
+ "@type" => "Item",
41
+ "url" => (c["url"] || "").to_s,
42
+ "name" => (c["name"] || c["title"] || "").to_s,
43
+ "site" => (c["site"] || site || "").to_s,
44
+ "siteUrl" => (c["siteUrl"] || site_url || "").to_s,
45
+ "score" => c.key?("score") ? c["score"].to_i : 100,
46
+ "description" => (c["description"] || "").to_s,
47
+ "schema_object" => schema
48
+ }
49
+ end
50
+
51
+ # Build a minimal buffered NLWeb ask response (list mode) from matched content items.
52
+ def ask_response(query, items, site: nil, site_url: nil, query_id: nil, answer: nil)
53
+ results = items.map { |it| item(it.is_a?(Hash) ? it : {}, site: site, site_url: site_url) }
54
+ resp = {
55
+ "query" => query,
56
+ "query_id" => query_id || "q_#{SecureRandom.hex(8)}",
57
+ "message_type" => "result",
58
+ "results" => results
59
+ }
60
+ resp["answer"] = { "@type" => "GeneratedAnswer", "answer" => answer.to_s, "items" => results } if answer && !answer.to_s.empty?
61
+ resp
62
+ end
63
+
64
+ def schema_object(content)
65
+ c = stringify(content)
66
+ obj = { "@type" => (c["type"] || "Thing").to_s }
67
+ name = c["name"] || c["title"]
68
+ obj["name"] = name if name
69
+ obj["url"] = c["url"] if c["url"]
70
+ obj["description"] = c["description"] if c["description"]
71
+ obj
72
+ end
73
+
74
+ def stringify(hash)
75
+ return {} unless hash.is_a?(Hash)
76
+
77
+ hash.each_with_object({}) { |(k, v), o| o[k.to_s] = v }
78
+ end
79
+ end
80
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ai2Web
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
5
5
  end
data/lib/ai2web.rb CHANGED
@@ -21,3 +21,5 @@ require_relative "ai2web/negotiator"
21
21
  require_relative "ai2web/export"
22
22
  require_relative "ai2web/manifest"
23
23
  require_relative "ai2web/server"
24
+ require_relative "ai2web/ap2"
25
+ require_relative "ai2web/nlweb"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ai2web
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - AI2Web Foundation
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-19 00:00:00.000000000 Z
11
+ date: 2026-07-21 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: The Ruby implementation of the AI2Web protocol - describe your website
14
14
  once and make it understandable and actionable to any AI agent. Fluent manifest
@@ -24,9 +24,11 @@ files:
24
24
  - LICENSE
25
25
  - README.md
26
26
  - lib/ai2web.rb
27
+ - lib/ai2web/ap2.rb
27
28
  - lib/ai2web/export.rb
28
29
  - lib/ai2web/manifest.rb
29
30
  - lib/ai2web/negotiator.rb
31
+ - lib/ai2web/nlweb.rb
30
32
  - lib/ai2web/safety.rb
31
33
  - lib/ai2web/schema.rb
32
34
  - lib/ai2web/server.rb