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 +4 -4
- data/README.md +2 -0
- data/lib/ai2web/ap2.rb +243 -0
- data/lib/ai2web/nlweb.rb +80 -0
- data/lib/ai2web/version.rb +1 -1
- data/lib/ai2web.rb +2 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 892b44b396f60be4ebd7af6bca0c19be6b1c3d8fdbdd2a66b2ed9235915c8690
|
|
4
|
+
data.tar.gz: a6705de41a0928bdddcf5787b13b72b53f0d45b6e47c4805deff898e5c650fad
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
+
[](https://launchpadly.co/startup/ai2web?ref=badge)
|
|
13
|
+
|
|
12
14
|
[](https://github.com/ai2web-foundation/ai2web-ruby/actions/workflows/ci.yml)
|
|
13
15
|
[](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
|
data/lib/ai2web/nlweb.rb
ADDED
|
@@ -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
|
data/lib/ai2web/version.rb
CHANGED
data/lib/ai2web.rb
CHANGED
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.
|
|
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-
|
|
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
|