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 +7 -0
- data/CHANGELOG.md +19 -0
- data/LICENSE +21 -0
- data/README.md +85 -0
- data/lib/ai2web/export.rb +96 -0
- data/lib/ai2web/manifest.rb +124 -0
- data/lib/ai2web/negotiator.rb +57 -0
- data/lib/ai2web/safety.rb +91 -0
- data/lib/ai2web/schema.rb +77 -0
- data/lib/ai2web/server.rb +118 -0
- data/lib/ai2web/util.rb +40 -0
- data/lib/ai2web/validator.rb +91 -0
- data/lib/ai2web/version.rb +5 -0
- data/lib/ai2web.rb +23 -0
- metadata +66 -0
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
|
+
[](https://github.com/ai2web-foundation/ai2web-ruby/actions/workflows/ci.yml)
|
|
13
|
+
[](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
|
data/lib/ai2web/util.rb
ADDED
|
@@ -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
|
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: []
|