ai2web 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8142d4c3ef57f6bb7ba24a60b478f4cab0e981c1acf0613f6ace7fe81b69a0a4
4
+ data.tar.gz: c1ddfcec49f61c4ac55299ce2dfba7467c742faf48d65c7559e581ee4d467eb1
5
+ SHA512:
6
+ metadata.gz: 82e19660de9ef91eb1a7167038ec9cb15db553d726936380e768305fcdb736cae9598445954d3433cc4915e05b677e977132002cbad2786a30ddfa26fc991177
7
+ data.tar.gz: dbb147055be31d20c74aab55c6bee8c21dc6f0d6d6574f76a6e0ef35cf0b1280737527b151b14622dfb372032c68407086e215ee036630ca5805a81ca3b03937
data/CHANGELOG.md ADDED
@@ -0,0 +1,19 @@
1
+ # Changelog
2
+
3
+ All notable changes to the AI2Web Ruby SDK are documented here. Versioning is independent per
4
+ language SDK; this SDK targets the AI2Web **v0.2** manifest and mirrors `@ai2web/core`.
5
+
6
+ ## [0.1.0] - 2026-07-19
7
+
8
+ Initial release. Full parity with the Python/TypeScript reference SDKs, verified against the shared
9
+ conformance contract (`test/conformance_cases.json`).
10
+
11
+ - Fluent manifest builder (`Ai2Web.ai2web`) incl. the v0.2 optional modules (`governance`,
12
+ `usage_policy`, `legal`, `agent_identity`, `knowledge`) and action `intent` / `bindings`.
13
+ - AI Readiness validator + scorer (`Ai2Web.validate`), spec §9/§11.
14
+ - Capability negotiation (`Ai2Web.negotiate`), spec §5.
15
+ - Framework-agnostic request handler (`Ai2Web.handle`) with action input-schema validation.
16
+ - SSRF guard (`Ai2Web.safe_public_url?` / `.assert_safe_public_url!` / `.same_origin?`).
17
+ - JSON-Schema-subset validator (`Ai2Web.validate_schema`).
18
+ - `llms.txt` and `agent.json` projections (`Ai2Web.to_llms_txt` / `.to_agent_json`), RFC-0015.
19
+ - Zero runtime dependencies; requires Ruby 3.0+.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AI2Web Foundation
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,85 @@
1
+ <div align="center">
2
+ <a href="https://ai2web.dev">
3
+ <picture>
4
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/ai2web-foundation/.github/main/profile/ai2web-logo-white.svg">
5
+ <img alt="AI2Web" src="https://raw.githubusercontent.com/ai2web-foundation/.github/main/profile/ai2web-logo-black.svg" width="200">
6
+ </picture>
7
+ </a>
8
+ </div>
9
+
10
+ # AI2Web Ruby SDK (`ai2web`)
11
+
12
+ [![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
+ [![Gem Version](https://img.shields.io/gem/v/ai2web)](https://rubygems.org/gems/ai2web)
14
+
15
+ The Ruby reference implementation of the [AI2Web protocol](https://github.com/ai2web-foundation/ai2web-spec) - for Rails, Sinatra, Rack, or plain Ruby. Mirrors `@ai2web/core`.
16
+
17
+ ```bash
18
+ gem install ai2web
19
+ # or add to your Gemfile:
20
+ gem "ai2web"
21
+ ```
22
+
23
+ ```ruby
24
+ require "ai2web"
25
+
26
+ manifest = Ai2Web.ai2web(name: "Example Store", url: "https://example.com", type: "ecommerce")
27
+ .capability("content")
28
+ .capability("commerce", endpoint: "/ai2w/products", checkout: true)
29
+ .transports(mcp: { enabled: true, endpoint: "/ai2w/mcp" }, rest: { enabled: true })
30
+ .auth(methods: %w[none oauth2], oauth2: { pkce: true, scopes: ["checkout"] })
31
+ .consent(requires_user_approval_for: ["purchase"])
32
+ .contact(support: "help@example.com")
33
+ .build
34
+
35
+ result = Ai2Web.validate(manifest) # { score: 90+, tier: "Standard", ... }
36
+
37
+ # Serve every AI2Web route from one call (framework-agnostic):
38
+ res = Ai2Web.handle({ manifest: manifest }, request.method, request.path, body, origin)
39
+ # => { status:, headers:, body: } -- render body as JSON (or text for /llms.txt)
40
+ ```
41
+
42
+ Inputs may use **symbol or string keys** - the builder normalises to the string-keyed JSON the
43
+ spec defines, so `validate` also works directly on a `JSON.parse`d manifest.
44
+
45
+ ## Modules
46
+
47
+ | Module | Entry points |
48
+ | --- | --- |
49
+ | `Ai2Web::Manifest` | `Ai2Web.ai2web(site)` - fluent capability-model builder |
50
+ | `Ai2Web::Validator` | `Ai2Web.validate(manifest)` + AI Readiness scoring (spec §9/§11) |
51
+ | `Ai2Web::Negotiator` | `Ai2Web.negotiate(manifest, agent)` capability negotiation (spec §5) |
52
+ | `Ai2Web::Server` | `Ai2Web.handle(opts, method, path, body, origin)` framework-agnostic router |
53
+ | `Ai2Web::Safety` | `Ai2Web.safe_public_url?(url)` / `.assert_safe_public_url!(url)` SSRF guard |
54
+ | `Ai2Web::Schema` | `Ai2Web.validate_schema(value, schema)` action input validation |
55
+ | `Ai2Web::Export` | `Ai2Web.to_llms_txt(m)` / `.to_agent_json(m)` projections (RFC-0015) |
56
+
57
+ The builder also carries the v0.2 optional modules - `.governance(...)`, `.usage_policy(...)`,
58
+ `.legal(...)`, `.agent_identity(...)`, `.knowledge(...)` - and actions accept `intent` and
59
+ `bindings`. All are additive: a minimal manifest stays valid without them.
60
+
61
+ ### Serving from Rack / Sinatra
62
+
63
+ ```ruby
64
+ res = Ai2Web.handle({ manifest: MANIFEST, actions: { "track_order" => ->(body) { track(body) } } },
65
+ env["REQUEST_METHOD"], env["PATH_INFO"], parsed_body, request_origin)
66
+
67
+ body = res[:body].is_a?(String) ? res[:body] : JSON.generate(res[:body])
68
+ [res[:status], res[:headers], [body]]
69
+ ```
70
+
71
+ `handle` routes `/ai2w`, `/.well-known/ai2w`, `/llms.txt`, `/.well-known/agent.json` (+ `/agent.json`),
72
+ `/ai2w/negotiate`, `/ai2w/actions/:name` and capability modules `/ai2w/:name`, validating action
73
+ input against each action's declared `input_schema` unless you pass `validate_input: false`.
74
+
75
+ ## Test
76
+
77
+ ```bash
78
+ ruby test/run.rb # dependency-free; includes the shared conformance contract
79
+ ```
80
+
81
+ Requires **Ruby 3.0+**. Zero runtime dependencies.
82
+
83
+ ## Licence
84
+
85
+ MIT.
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ai2Web
4
+ # Export adapters (RFC-0015): project the one canonical AI2Web manifest into other wire formats
5
+ # and discovery surfaces. Port of @ai2web/core export.ts.
6
+ #
7
+ # Each export is a best-effort projection; where a target cannot represent a field, it is omitted
8
+ # rather than misstated. The canonical /ai2w manifest stays authoritative for execution.
9
+ module Export
10
+ module_function
11
+
12
+ def enabled_capabilities(m)
13
+ caps = m["capabilities"].is_a?(Hash) ? m["capabilities"] : {}
14
+ caps.select { |_k, v| Util.enabled?(v) }.keys
15
+ end
16
+
17
+ # Project the manifest to an `llms.txt` document: a plain-text summary and set of links a model
18
+ # can read for content and guidance. Reads only; no actions are exposed here.
19
+ def to_llms_txt(manifest)
20
+ m = Util.deep_stringify(manifest)
21
+ site = m["site"].is_a?(Hash) ? m["site"] : {}
22
+ base = Util.trim_url(site["url"].to_s)
23
+ lines = ["# #{site["name"]}"]
24
+ lines += ["", "> #{site["description"]}"] if Util.truthy?(site["description"])
25
+
26
+ caps = enabled_capabilities(m)
27
+ lines += ["", "## Capabilities", *caps.map { |c| "- #{c}" }] unless caps.empty?
28
+
29
+ knowledge = m["knowledge"].is_a?(Array) ? m["knowledge"] : []
30
+ unless knowledge.empty?
31
+ lines << ""
32
+ lines << "## Knowledge"
33
+ knowledge.each do |k|
34
+ ref = k["ref"].to_s
35
+ ref = base + (ref.start_with?("/") ? "" : "/") + ref unless ref.start_with?("http")
36
+ lines << "- [#{k["name"] || k["id"]}](#{ref})"
37
+ end
38
+ end
39
+
40
+ actions = m["actions"].is_a?(Array) ? m["actions"] : []
41
+ unless actions.empty?
42
+ lines << ""
43
+ lines << "## Actions"
44
+ actions.each { |a| lines << "- #{a["name"]}: #{a["description"]}" }
45
+ end
46
+
47
+ lines += ["", "## Discovery", "- Manifest: #{base}/ai2w"]
48
+ "#{lines.join("\n")}\n"
49
+ end
50
+
51
+ # Project the manifest to a generic `agent.json` style capability document. Best-effort,
52
+ # format-neutral projection of identity, capabilities, actions (with bindings), knowledge and
53
+ # policies. Consent/governance a target cannot express are carried as a `policies` object
54
+ # rather than dropped silently.
55
+ def to_agent_json(manifest)
56
+ m = Util.deep_stringify(manifest)
57
+ site = m["site"].is_a?(Hash) ? m["site"] : {}
58
+ actions = m["actions"].is_a?(Array) ? m["actions"] : []
59
+ consent = m["consent"].is_a?(Hash) ? m["consent"] : {}
60
+ {
61
+ "schema" => "agent-capabilities",
62
+ "name" => site["name"],
63
+ "description" => site["description"],
64
+ "url" => site["url"],
65
+ "identity" => m["identity"],
66
+ "capabilities" => enabled_capabilities(m),
67
+ "actions" => actions.map do |a|
68
+ bindings = a["bindings"].is_a?(Array) && !a["bindings"].empty? ? a["bindings"] : [{ "kind" => "rest", "ref" => a["endpoint"] }]
69
+ {
70
+ "name" => a["name"],
71
+ "intent" => a["intent"],
72
+ "description" => a["description"],
73
+ "risk" => a["risk"],
74
+ "requires_consent" => a["requires_user_approval"],
75
+ "requires_auth" => a["requires_auth"],
76
+ "input_schema" => a["input_schema"],
77
+ "bindings" => bindings
78
+ }
79
+ end,
80
+ "knowledge" => m["knowledge"],
81
+ "transports" => m["transports"],
82
+ "policies" => {
83
+ "consent" => consent["requires_user_approval_for"],
84
+ "governance" => m["governance"],
85
+ "usage" => m["usage_policy"],
86
+ "legal" => m["legal"]
87
+ }
88
+ }
89
+ end
90
+ end
91
+
92
+ module_function
93
+
94
+ def to_llms_txt(manifest) = Export.to_llms_txt(manifest)
95
+ def to_agent_json(manifest) = Export.to_agent_json(manifest)
96
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ai2Web
6
+ # Fluent AI2Web (ai2w) manifest builder - "describe your website once".
7
+ #
8
+ # Every setter returns self, so calls chain. Inputs may use symbol or string keys; the builder
9
+ # stores string keys internally so the result round-trips cleanly as JSON.
10
+ #
11
+ # manifest = Ai2Web.ai2web(name: "Store", url: "https://store.example", type: "ecommerce")
12
+ # .capability("content")
13
+ # .capability("commerce", endpoint: "/ai2w/products", checkout: true)
14
+ # .contact(support: "help@store.example")
15
+ # .build
16
+ class Manifest
17
+ def initialize(site)
18
+ @m = { "protocol" => "ai2w", "version" => "0.2", "site" => Util.deep_stringify(site), "capabilities" => {} }
19
+ end
20
+
21
+ def self.for_site(name:, url:, type:, **extra)
22
+ new({ "name" => name, "url" => url, "type" => type }.merge(Util.deep_stringify(extra)))
23
+ end
24
+
25
+ def capability(name, value = true)
26
+ value = { "enabled" => true }.merge(Util.deep_stringify(value)) if value.is_a?(Hash)
27
+ @m["capabilities"][name.to_s] = value
28
+ self
29
+ end
30
+
31
+ def transports(t)
32
+ (@m["transports"] ||= {}).merge!(Util.deep_stringify(t))
33
+ self
34
+ end
35
+
36
+ def auth(a)
37
+ @m["auth"] = Util.deep_stringify(a)
38
+ self
39
+ end
40
+
41
+ def consent(c)
42
+ @m["consent"] = Util.deep_stringify(c)
43
+ self
44
+ end
45
+
46
+ def action(a)
47
+ (@m["actions"] ||= []) << Util.deep_stringify(a)
48
+ capability("actions", "endpoint" => "/ai2w/actions")
49
+ self
50
+ end
51
+
52
+ def events(e)
53
+ e = Util.deep_stringify(e)
54
+ @m["events"] = e
55
+ capability("events", "endpoint" => e["endpoint"] || "/ai2w/events")
56
+ self
57
+ end
58
+
59
+ def agent_service(s)
60
+ @m["agent_service"] = Util.deep_stringify(s)
61
+ self
62
+ end
63
+
64
+ def identity(i)
65
+ @m["identity"] = Util.deep_stringify(i)
66
+ self
67
+ end
68
+
69
+ def contact(c)
70
+ @m["contact"] = Util.deep_stringify(c)
71
+ self
72
+ end
73
+
74
+ # --- v0.2 optional modules (all additive; a minimal manifest stays valid without them). ---
75
+ def governance(g)
76
+ @m["governance"] = Util.deep_stringify(g)
77
+ self
78
+ end
79
+
80
+ def usage_policy(u)
81
+ @m["usage_policy"] = Util.deep_stringify(u)
82
+ self
83
+ end
84
+
85
+ def legal(l)
86
+ @m["legal"] = Util.deep_stringify(l)
87
+ self
88
+ end
89
+
90
+ def agent_identity(a)
91
+ base = @m["identity"].is_a?(Hash) ? @m["identity"] : {}
92
+ @m["identity"] = base.merge("agent" => Util.deep_stringify(a))
93
+ self
94
+ end
95
+
96
+ def knowledge(k)
97
+ @m["knowledge"] = Util.deep_stringify(k)
98
+ self
99
+ end
100
+
101
+ # Attach a vendor extension. The key is namespaced with `x-` if not already.
102
+ def extension(key, value)
103
+ key = key.to_s
104
+ key = "x-#{key}" unless key.start_with?("x-")
105
+ @m[key] = Util.deep_stringify(value)
106
+ self
107
+ end
108
+
109
+ def build = @m
110
+ def to_h = @m
111
+
112
+ # Serialize to a JSON string. Pretty-printed by default; pass `indent: 0` for compact output.
113
+ # Accepts (and ignores) a JSON generator state so the object still works when nested inside
114
+ # another `JSON.generate` call.
115
+ def to_json(*_args, indent: 2)
116
+ indent.to_i.positive? ? JSON.pretty_generate(@m) : JSON.generate(@m)
117
+ end
118
+ end
119
+
120
+ module_function
121
+
122
+ def ai2web(site) = Manifest.new(site)
123
+ def manifest(site) = Manifest.new(site)
124
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ai2Web
4
+ # Capability negotiation (spec section 5). Port of @ai2web/core negotiate().
5
+ module Negotiator
6
+ module_function
7
+
8
+ def endpoint_of(name, value)
9
+ return value["endpoint"] if value.is_a?(Hash) && value["endpoint"].is_a?(String)
10
+
11
+ "/ai2w/#{name}"
12
+ end
13
+
14
+ def negotiate(manifest, agent = nil)
15
+ m = Util.deep_stringify(manifest)
16
+ agent = Util.deep_stringify(agent || {})
17
+ caps = m["capabilities"].is_a?(Hash) ? m["capabilities"] : {}
18
+ site_caps = caps.select { |_k, v| Util.enabled?(v) }.keys
19
+
20
+ want_caps = agent.key?("capabilities") ? (agent["capabilities"] || []) : site_caps
21
+ capabilities = site_caps.select { |c| want_caps.include?(c) }
22
+ unsupported = want_caps.reject { |c| site_caps.include?(c) }
23
+
24
+ # Only transports explicitly enabled are negotiable.
25
+ transports = m["transports"].is_a?(Hash) ? m["transports"] : {}
26
+ site_transports = transports.select { |_k, v| v.is_a?(Hash) && v["enabled"] == true }.keys
27
+ want_transports = agent.key?("transports") ? (agent["transports"] || []) : site_transports
28
+ transport = want_transports.find { |t| site_transports.include?(t) }
29
+
30
+ auth_block = m["auth"].is_a?(Hash) ? m["auth"] : {}
31
+ site_auth = auth_block["methods"] || ["none"]
32
+ want_auth = agent.key?("auth") ? (agent["auth"] || []) : site_auth
33
+ auth =
34
+ if site_auth.include?("oauth2") && want_auth.include?("oauth2")
35
+ "oauth2"
36
+ else
37
+ picked = want_auth.find { |a| site_auth.include?(a) }
38
+ picked.nil? && site_auth.include?("none") ? "none" : picked
39
+ end
40
+
41
+ endpoints = {}
42
+ capabilities.each { |c| endpoints[c] = endpoint_of(c, caps[c]) }
43
+ if !transport.nil? && transports[transport].is_a?(Hash) && transports[transport]["endpoint"]
44
+ endpoints[transport] = transports[transport]["endpoint"]
45
+ end
46
+
47
+ {
48
+ negotiated: { transport: transport, capabilities: capabilities, auth: auth, endpoints: endpoints },
49
+ unsupported: unsupported
50
+ }
51
+ end
52
+ end
53
+
54
+ module_function
55
+
56
+ def negotiate(manifest, agent = nil) = Negotiator.negotiate(manifest, agent)
57
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Ai2Web
6
+ # SSRF guard. Parity with @ai2web/core safety.
7
+ #
8
+ # Blocks the obvious pivots (loopback, private ranges, cloud metadata, link-local, non-http
9
+ # schemes) AND the alternative IP encodings that HTTP clients resolve to those same addresses
10
+ # (decimal / hex / octal / short-form IPv4, and IPv4-mapped IPv6). This is a literal host/IP
11
+ # check - not by itself DNS-rebind safe.
12
+ module Safety
13
+ IPV4_RE = /\A(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\z/.freeze
14
+ IPV4_TAIL = /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\z/.freeze
15
+
16
+ module_function
17
+
18
+ # True if a standard dotted-quad is loopback/private/reserved (or has an invalid octet).
19
+ def ipv4_blocked?(host)
20
+ m = IPV4_RE.match(host)
21
+ return false unless m
22
+
23
+ parts = m.captures.map(&:to_i)
24
+ return true if parts.any? { |p| p > 255 } # not a real address; refuse
25
+
26
+ a, b = parts[0], parts[1]
27
+ return true if [0, 10, 127].include?(a)
28
+ return true if a == 169 && b == 254 # link-local + cloud metadata (169.254.169.254)
29
+ return true if a == 172 && b >= 16 && b <= 31
30
+ return true if a == 192 && b == 168
31
+ return true if a == 100 && b >= 64 && b <= 127 # CGNAT
32
+
33
+ false
34
+ end
35
+
36
+ def safe_public_url?(raw)
37
+ begin
38
+ u = URI.parse(raw.to_s)
39
+ rescue URI::InvalidURIError
40
+ return false
41
+ end
42
+ return false unless %w[https http].include?(u.scheme)
43
+
44
+ host = (u.host || "").downcase.sub(/\A\[/, "").sub(/\]\z/, "")
45
+ return false if host.empty? || host == "localhost" || host.end_with?(".localhost")
46
+
47
+ # IPv6 literal.
48
+ if host.include?(":")
49
+ # IPv4-mapped / compat (::ffff:a.b.c.d, ::a.b.c.d): range-check the embedded IPv4.
50
+ m = IPV4_TAIL.match(host)
51
+ return false if m && ipv4_blocked?(m[1])
52
+ return false if host == "::1" || host.start_with?("fc", "fd", "fe80")
53
+
54
+ return true
55
+ end
56
+
57
+ # Hex-encoded IP (0x7f000001, or a dotted octet like 0x7f): a client resolves these to an IP.
58
+ return false if host.match?(/(^|\.)0x/)
59
+
60
+ # Standard dotted-quad IPv4.
61
+ return !ipv4_blocked?(host) if IPV4_RE.match?(host)
62
+
63
+ # Any remaining all-numeric host is an alternative IPv4 encoding (decimal integer, octal, or
64
+ # short form like 127.1) that a client resolves to an IP. No real domain looks like this.
65
+ return false unless host.match?(/[a-z]/)
66
+
67
+ true
68
+ end
69
+
70
+ def assert_safe_public_url!(raw)
71
+ raise ArgumentError, "ai2w: refusing to fetch non-public or unsafe URL: #{raw}" unless safe_public_url?(raw)
72
+
73
+ raw
74
+ end
75
+
76
+ def same_origin?(a, b)
77
+ pa = URI.parse(a.to_s)
78
+ pb = URI.parse(b.to_s)
79
+ [pa.scheme, pa.host, pa.port] == [pb.scheme, pb.host, pb.port]
80
+ rescue URI::InvalidURIError
81
+ false
82
+ end
83
+ end
84
+
85
+ module_function
86
+
87
+ # Convenience top-level aliases so callers can use `Ai2Web.safe_public_url?` etc.
88
+ def safe_public_url?(raw) = Safety.safe_public_url?(raw)
89
+ def assert_safe_public_url!(raw) = Safety.assert_safe_public_url!(raw)
90
+ def same_origin?(a, b) = Safety.same_origin?(a, b)
91
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ai2Web
4
+ # Result of {Ai2Web.validate_schema}. `valid` is a boolean; `errors` is an array of strings.
5
+ SchemaResult = Struct.new(:valid, :errors) do
6
+ def valid? = valid
7
+ end
8
+
9
+ # Minimal JSON-Schema-subset validator for action input schemas. Port of @ai2web/core
10
+ # validateSchema: pragmatic (object with typed/required properties, primitives, arrays, enum)
11
+ # rather than the whole of JSON Schema. Used by the server to validate incoming requests
12
+ # against an action's declared input_schema.
13
+ module Schema
14
+ module_function
15
+
16
+ def type_of(value)
17
+ case value
18
+ when nil then "null"
19
+ when true, false then "boolean"
20
+ when Integer, Float then "number"
21
+ when String then "string"
22
+ when Array then "array"
23
+ when Hash then "object"
24
+ else "unknown"
25
+ end
26
+ end
27
+
28
+ # Validate a value against a JSON-Schema-subset. An empty/absent schema accepts anything.
29
+ def validate_schema(value, schema, path = "input")
30
+ errors = []
31
+ schema = Util.deep_stringify(schema)
32
+ return SchemaResult.new(true, errors) unless schema.is_a?(Hash) && !schema.empty?
33
+
34
+ value = Util.deep_stringify(value)
35
+ declared = schema["type"]
36
+ has_declared = declared.is_a?(String) && !declared.empty?
37
+
38
+ if has_declared
39
+ ok = if declared == "integer"
40
+ value.is_a?(Integer) && value != true && value != false
41
+ else
42
+ type_of(value) == declared
43
+ end
44
+ unless ok
45
+ errors << "#{path}: expected #{declared}, got #{type_of(value)}"
46
+ return SchemaResult.new(false, errors) # wrong base type: stop
47
+ end
48
+ end
49
+
50
+ enum = schema["enum"]
51
+ errors << "#{path}: value is not one of the allowed options" if enum.is_a?(Array) && !enum.include?(value)
52
+
53
+ is_object = value.is_a?(Hash)
54
+ if (declared == "object" || (!has_declared && is_object)) && is_object
55
+ props = schema["properties"].is_a?(Hash) ? schema["properties"] : {}
56
+ (schema["required"] || []).each do |key|
57
+ errors << "#{path}.#{key}: required" unless value.key?(key)
58
+ end
59
+ props.each do |key, sub|
60
+ errors.concat(validate_schema(value[key], sub, "#{path}.#{key}").errors) if value.key?(key)
61
+ end
62
+ end
63
+
64
+ if (declared == "array" || (!has_declared && value.is_a?(Array))) && value.is_a?(Array) && schema["items"]
65
+ value.each_with_index do |item, i|
66
+ errors.concat(validate_schema(item, schema["items"], "#{path}[#{i}]").errors)
67
+ end
68
+ end
69
+
70
+ SchemaResult.new(errors.empty?, errors)
71
+ end
72
+ end
73
+
74
+ module_function
75
+
76
+ def validate_schema(value, schema, path = "input") = Schema.validate_schema(value, schema, path)
77
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ai2Web
4
+ # Framework-agnostic AI2Web request handler. Port of @ai2web/server.
5
+ #
6
+ # Returns a Hash { status:, headers:, body: }; adapt it to Rails / Sinatra / Rack / etc. `body`
7
+ # is a Ruby Hash (serialize to JSON in your adapter) or a String for text responses.
8
+ module Server
9
+ CORS = {
10
+ "access-control-allow-origin" => "*",
11
+ "access-control-allow-methods" => "GET, POST, OPTIONS",
12
+ "access-control-allow-headers" => "content-type, authorization"
13
+ }.freeze
14
+
15
+ ACTION_RE = %r{\A/ai2w/actions/([a-z0-9_-]+)\z}i.freeze
16
+ MODULE_RE = %r{\A/ai2w/([a-z0-9_-]+)\z}i.freeze
17
+
18
+ module_function
19
+
20
+ def json(status, body)
21
+ { status: status, headers: { "content-type" => "application/json; charset=utf-8" }.merge(CORS), body: body }
22
+ end
23
+
24
+ def error(status, code, message, retryable: false)
25
+ json(status, { "error" => { "code" => code, "message" => message, "retryable" => retryable } })
26
+ end
27
+
28
+ def text(status, content_type, body)
29
+ { status: status, headers: { "content-type" => content_type }.merge(CORS), body: body }
30
+ end
31
+
32
+ def opt(opts, key)
33
+ opts[key] || opts[key.to_s]
34
+ end
35
+
36
+ def lookup(table, name)
37
+ table[name] || table[name.to_sym]
38
+ end
39
+
40
+ def handle(opts, method, path, body = nil, origin = nil)
41
+ manifest = Util.deep_stringify(opt(opts, :manifest))
42
+ modules = opt(opts, :modules) || {}
43
+ actions = opt(opts, :actions) || {}
44
+ validate_input = opts.key?(:validate_input) || opts.key?("validate_input") ? opt(opts, :validate_input) : true
45
+ declared_actions = {}
46
+ (manifest["actions"] || []).each { |a| declared_actions[a["name"]] = a if a.is_a?(Hash) }
47
+
48
+ trimmed = path.to_s.gsub(%r{\A/+|/+\z}, "")
49
+ path = trimmed.empty? ? "/" : "/#{trimmed}"
50
+ method = method.to_s.upcase
51
+
52
+ return { status: 204, headers: CORS.dup, body: nil } if method == "OPTIONS"
53
+
54
+ if path == "/.well-known/ai2w"
55
+ return json(200, { "ai2w" => "#{Util.trim_url(origin)}/ai2w" }) if origin && !origin.to_s.empty?
56
+
57
+ return json(200, manifest)
58
+ end
59
+
60
+ if ["/ai2w", "/ai", "/.ai"].include?(path)
61
+ return error(405, "invalid_request", "Use GET for the manifest.") if method != "GET"
62
+
63
+ return json(200, manifest)
64
+ end
65
+
66
+ # Multi-surface projections (RFC-0015): the one canonical manifest, emitted in other discovery
67
+ # formats so agents that speak llms.txt or agent.json need not parse ai2w first.
68
+ if path == "/llms.txt"
69
+ return error(405, "invalid_request", "Use GET for llms.txt.") if method != "GET"
70
+
71
+ return text(200, "text/plain; charset=utf-8", Ai2Web.to_llms_txt(manifest))
72
+ end
73
+ if ["/.well-known/agent.json", "/agent.json"].include?(path)
74
+ return error(405, "invalid_request", "Use GET for agent.json.") if method != "GET"
75
+
76
+ return json(200, Ai2Web.to_agent_json(manifest))
77
+ end
78
+
79
+ if path == "/ai2w/negotiate"
80
+ b = body.is_a?(Hash) ? Util.deep_stringify(body) : {}
81
+ agent = b["agent"].is_a?(Hash) ? b["agent"] : {}
82
+ supports = agent["supports"] || b["supports"] || b
83
+ supports = {} unless supports.is_a?(Hash)
84
+ return json(200, Ai2Web.negotiate(manifest, supports))
85
+ end
86
+
87
+ if (m = ACTION_RE.match(path))
88
+ name = m[1].tr("-", "_")
89
+ fn = lookup(actions, name)
90
+ return error(404, "unsupported_capability", "Unknown action '#{name}'.") unless fn
91
+
92
+ declared = declared_actions[name]
93
+ if Util.truthy?(validate_input) && declared && declared["input_schema"]
94
+ result = Ai2Web.validate_schema(body.nil? ? {} : body, declared["input_schema"])
95
+ unless result.valid
96
+ return error(400, "invalid_request",
97
+ "Request does not match the declared input schema: #{result.errors.join("; ")}.")
98
+ end
99
+ end
100
+ return json(200, fn.call(body))
101
+ end
102
+
103
+ if (m = MODULE_RE.match(path))
104
+ name = m[1]
105
+ fn = lookup(modules, name)
106
+ return error(404, "unsupported_capability", "Module '#{name}' not exposed.") unless fn
107
+
108
+ return json(200, fn.call(body))
109
+ end
110
+
111
+ error(404, "invalid_request", "No AI2Web route for #{path}.")
112
+ end
113
+ end
114
+
115
+ module_function
116
+
117
+ def handle(opts, method, path, body = nil, origin = nil) = Server.handle(opts, method, path, body, origin)
118
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ai2Web
4
+ # Small shared helpers. AI2Web manifests are JSON documents, so the SDK works internally with
5
+ # string-keyed hashes; +deep_stringify+ lets callers pass symbol keys and still get parity.
6
+ module Util
7
+ module_function
8
+
9
+ # Recursively convert every Hash key to a String (values untouched). Idempotent.
10
+ def deep_stringify(obj)
11
+ case obj
12
+ when Hash then obj.each_with_object({}) { |(k, v), h| h[k.to_s] = deep_stringify(v) }
13
+ when Array then obj.map { |e| deep_stringify(e) }
14
+ else obj
15
+ end
16
+ end
17
+
18
+ # Mirror of @ai2web/core `_has`: a capability/transport is on when it is `true` or an
19
+ # object with `enabled: true`.
20
+ def enabled?(value)
21
+ value == true || (value.is_a?(Hash) && value["enabled"] == true)
22
+ end
23
+
24
+ # Python-style truthiness, used only where the reference validator relies on `bool(...)`
25
+ # (empty hash/array/string are falsy). Keeps exact scoring/tier parity with the other SDKs.
26
+ def truthy?(value)
27
+ case value
28
+ when nil, false then false
29
+ when Hash, Array, String then !value.empty?
30
+ when Numeric then value != 0
31
+ else true
32
+ end
33
+ end
34
+
35
+ # Strip trailing slashes from a URL/base.
36
+ def trim_url(url)
37
+ url.to_s.sub(%r{/+\z}, "")
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ai2Web
4
+ # AI2Web validation + AI Readiness scoring.
5
+ #
6
+ # Port of @ai2web/core validateManifest (spec sections 9 & 11). MUST stay in exact parity with
7
+ # the TypeScript reference and ai2web-spec/conformance/cases.json.
8
+ #
9
+ # {Ai2Web.validate} returns a Hash:
10
+ # { valid: Boolean, errors: [String], checks: [{ ok:, points:, label:, hint: }],
11
+ # score: Integer (0..100), tier: "Invalid"|"Basic"|"Standard"|"Enterprise" }
12
+ module Validator
13
+ VERSION_RE = /\A\d+\.\d+(\.\d+)?\z/.freeze
14
+
15
+ module_function
16
+
17
+ def validate(manifest)
18
+ m = Util.deep_stringify(manifest)
19
+ errors = []
20
+ checks = []
21
+ caps = m["capabilities"].is_a?(Hash) ? m["capabilities"] : {}
22
+ cap = ->(name) { caps[name] }
23
+
24
+ errors << "protocol must be 'ai2w'" if m["protocol"] != "ai2w"
25
+ errors << "version missing/invalid" unless VERSION_RE.match?(m["version"].to_s)
26
+ site = m["site"].is_a?(Hash) ? m["site"] : {}
27
+ %w[name url type].each { |k| errors << "site.#{k} missing" unless Util.truthy?(site[k]) }
28
+ errors << "capabilities empty" unless caps.is_a?(Hash) && !caps.empty?
29
+
30
+ actions_exist =
31
+ Util.enabled?(cap.call("actions")) ||
32
+ (m["actions"].is_a?(Array) && !m["actions"].empty?) ||
33
+ Util.enabled?(cap.call("commerce")) ||
34
+ Util.enabled?(cap.call("booking"))
35
+
36
+ score = 0
37
+ add = lambda do |ok, points, label, hint|
38
+ checks << { ok: ok, points: points, label: label, hint: ok ? nil : hint }
39
+ score += points if ok
40
+ end
41
+
42
+ add.call(errors.empty?, 30, "Valid discovery manifest", "fix errors")
43
+ add.call(Util.enabled?(cap.call("content")), 6, "Content", "expose content module")
44
+ add.call(Util.enabled?(cap.call("commerce")) || Util.enabled?(cap.call("booking")) || Util.enabled?(cap.call("services")), 6,
45
+ "Products / services / booking", "expose a commerce/services/booking module")
46
+ add.call(Util.enabled?(cap.call("search")), 4, "Search", "add a search capability")
47
+ add.call(actions_exist, 5, "Actions", "declare actions")
48
+ add.call(Util.enabled?(cap.call("events")), 6, "Events / subscriptions", "publish subscribable events")
49
+ agent_service = m["agent_service"].is_a?(Hash) ? m["agent_service"] : {}
50
+ add.call(Util.truthy?(agent_service["enabled"]), 4, "Agent service (A2A)", "expose /ai2w/agent")
51
+
52
+ commerce = cap.call("commerce")
53
+ add.call(!Util.enabled?(commerce) || (commerce.is_a?(Hash) && commerce["checkout"] == true),
54
+ 4, "Checkout", "commerce present but checkout missing")
55
+
56
+ transports = m["transports"].is_a?(Hash) ? m["transports"] : {}
57
+ mcp = transports["mcp"].is_a?(Hash) ? transports["mcp"] : {}
58
+ rest = transports["rest"].is_a?(Hash) ? transports["rest"] : {}
59
+ add.call(mcp["enabled"] == true, 8, "MCP transport", "expose an MCP endpoint")
60
+ add.call(rest["enabled"] == true || Util.truthy?(transports["feeds"]), 4, "REST / feeds", "expose REST or feeds")
61
+
62
+ auth = m["auth"].is_a?(Hash) ? m["auth"] : {}
63
+ oauth2 = auth["oauth2"].is_a?(Hash) ? auth["oauth2"] : {}
64
+ oauth_ok = (auth["methods"] || []).include?("oauth2") && oauth2["pkce"] == true
65
+ consent = m["consent"].is_a?(Hash) ? m["consent"] : {}
66
+ consent_declared = ((consent["requires_user_approval_for"]) || []).length.positive?
67
+ add.call(!actions_exist || oauth_ok, 8, "OAuth2 + PKCE", "protected actions need oauth2+pkce")
68
+ add.call(!actions_exist || consent_declared, 7, "Consent declared", "declare consent for sensitive actions")
69
+
70
+ add.call(Util.truthy?(m["identity"]), 4, "Identity", "add identity (legal_name, policies)")
71
+ add.call(Util.truthy?(m["contact"]), 4, "Contact", "add support/security contact")
72
+
73
+ score = [100, score].min
74
+
75
+ basic = errors.empty?
76
+ standard = basic && Util.truthy?(m["transports"]) && (!actions_exist || consent_declared) && Util.truthy?(m["contact"])
77
+ enterprise = standard && Util.truthy?(m["identity"]) && Util.truthy?(m["auth"]) && Util.truthy?(m["rate_limits"])
78
+ tier = if enterprise then "Enterprise"
79
+ elsif standard then "Standard"
80
+ elsif basic then "Basic"
81
+ else "Invalid"
82
+ end
83
+
84
+ { valid: errors.empty?, errors: errors, checks: checks, score: score, tier: tier }
85
+ end
86
+ end
87
+
88
+ module_function
89
+
90
+ def validate(manifest) = Validator.validate(manifest)
91
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ai2Web
4
+ VERSION = "0.1.0"
5
+ end
data/lib/ai2web.rb ADDED
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ # AI2Web (ai2w) Ruby SDK.
4
+ #
5
+ # Describe your website once. AI2Web makes it understandable to every AI.
6
+ #
7
+ # Top-level API (all also reachable via their modules):
8
+ # Ai2Web.ai2web(site) -> Ai2Web::Manifest (fluent builder)
9
+ # Ai2Web.validate(manifest) -> Hash (AI Readiness score + tier)
10
+ # Ai2Web.negotiate(manifest, agent) -> Hash (capability negotiation)
11
+ # Ai2Web.handle(opts, method, path, body, origin) -> Hash (framework-agnostic router)
12
+ # Ai2Web.validate_schema(value, schema) -> Ai2Web::SchemaResult
13
+ # Ai2Web.safe_public_url?(url) / .assert_safe_public_url!(url) / .same_origin?(a, b)
14
+ # Ai2Web.to_llms_txt(manifest) / .to_agent_json(manifest) (RFC-0015 projections)
15
+ require_relative "ai2web/version"
16
+ require_relative "ai2web/util"
17
+ require_relative "ai2web/safety"
18
+ require_relative "ai2web/schema"
19
+ require_relative "ai2web/validator"
20
+ require_relative "ai2web/negotiator"
21
+ require_relative "ai2web/export"
22
+ require_relative "ai2web/manifest"
23
+ require_relative "ai2web/server"
metadata ADDED
@@ -0,0 +1,66 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ai2web
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - AI2Web Foundation
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-07-19 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: The Ruby implementation of the AI2Web protocol - describe your website
14
+ once and make it understandable and actionable to any AI agent. Fluent manifest
15
+ builder, AI Readiness validator/scorer, capability negotiation, a framework-agnostic
16
+ request handler for Rails/Sinatra/Rack, an SSRF guard, and llms.txt / agent.json
17
+ projections (RFC-0015). Mirrors @ai2web/core. Zero runtime dependencies.
18
+ email:
19
+ executables: []
20
+ extensions: []
21
+ extra_rdoc_files: []
22
+ files:
23
+ - CHANGELOG.md
24
+ - LICENSE
25
+ - README.md
26
+ - lib/ai2web.rb
27
+ - lib/ai2web/export.rb
28
+ - lib/ai2web/manifest.rb
29
+ - lib/ai2web/negotiator.rb
30
+ - lib/ai2web/safety.rb
31
+ - lib/ai2web/schema.rb
32
+ - lib/ai2web/server.rb
33
+ - lib/ai2web/util.rb
34
+ - lib/ai2web/validator.rb
35
+ - lib/ai2web/version.rb
36
+ homepage: https://ai2web.dev
37
+ licenses:
38
+ - MIT
39
+ metadata:
40
+ homepage_uri: https://ai2web.dev
41
+ source_code_uri: https://github.com/ai2web-foundation/ai2web-ruby
42
+ changelog_uri: https://github.com/ai2web-foundation/ai2web-ruby/blob/main/CHANGELOG.md
43
+ bug_tracker_uri: https://github.com/ai2web-foundation/ai2web-ruby/issues
44
+ documentation_uri: https://ai2web.dev/docs
45
+ rubygems_mfa_required: 'true'
46
+ post_install_message:
47
+ rdoc_options: []
48
+ require_paths:
49
+ - lib
50
+ required_ruby_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '3.0'
55
+ required_rubygems_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '0'
60
+ requirements: []
61
+ rubygems_version: 3.5.22
62
+ signing_key:
63
+ specification_version: 4
64
+ summary: AI2Web (ai2w) Ruby SDK - capability model, manifest builder, validator, negotiation,
65
+ server handler.
66
+ test_files: []