zeroclick-sellers 0.4.0 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 21a687776a099cffab368909929c6181de3425e83d6fe94bcfaba52cc40e76a8
4
- data.tar.gz: 15b009873e6410a7703a6385ebdfc0d737f1be8d3d475b18ed22e96683117904
3
+ metadata.gz: c4d8cedea5994f6b40e5d7505027dfe9bf1fa9df5dfe32863070534bee183859
4
+ data.tar.gz: ff203775e81db08e529b7fe0908da260c3142b7e1a82783f9eaac4cf3624fdcd
5
5
  SHA512:
6
- metadata.gz: 10cc75f795e921fa55d77b5dce9f101d1f3c4afadb191fe70546db9bd01dd309fe8453cfedd24de1d1991e1716567de26378b17cb87fb8338866f1132d4e96bd
7
- data.tar.gz: 82a1860e38b1a2b53195b1aa0c17830b60f2c7ad5cdc80efc280c6547678f74771ed799a42ddabaf405c1df3f00f36de6344402e3c104c03078cc94e2d00f90e
6
+ metadata.gz: ccdf34f7bfccb29b898b0e71212b4573124c58b852417a883a812d24520a776710a73a1c46c2a5741e85bbad2760bc20dfd97047639a7e59e5623884937cc778
7
+ data.tar.gz: 0ddb34edef539de4e5cbdd1d78747e6424bf4f95db680fde8dbf1cb7db2492b350711ee83c93653ca0df7edf64e96ab57174587c22cf881c579e46f8c6e92c78
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ require_relative "errors"
7
+ require_relative "usage"
8
+
9
+ module ZeroClick
10
+ module Sellers
11
+ # Agentify: serve a marketing page as agent-optimized markdown.
12
+ #
13
+ # Detection is one pure boolean over two request headers; the conversion is
14
+ # a GET against ZeroClick's Agentify API, authenticated with a zc_ key
15
+ # carrying the agentify:convert scope. As in Usage, transport and
16
+ # interpretation are separate: interpret_markdown is pure, so what a
17
+ # response MEANS is decided in one place and tested without a socket.
18
+ module Agentify
19
+ # Calendar version of the detection rules below, matching the
20
+ # `detectionVersion` in agentify-detection-vectors.json. The vector file
21
+ # is generated by packages/sellers-python/tests/vectors/
22
+ # generate_agentify_detection_vectors.py — the authority on this
23
+ # contract — and a vector test pins this constant and the substring list
24
+ # to it, so an update cannot land here without regenerating the vectors
25
+ # (and vice versa).
26
+ DETECTION_VERSION = "2026-08-25.01"
27
+
28
+ # Case-insensitive substrings that mark an AI agent's user-agent: the
29
+ # AI-operator crawlers, assistant fetchers, and browser agents. Classic
30
+ # search crawlers (googlebot, bingbot, ...) are deliberately absent —
31
+ # serving them different content than browsers is cloaking — and so are
32
+ # generic http tools (curl, python, ...), which ask for markdown via
33
+ # Accept when they want it.
34
+ AI_USER_AGENT_SUBSTRINGS = %w[
35
+ anthropic
36
+ bytespider
37
+ ccbot
38
+ chatgpt
39
+ claude
40
+ duckassistbot
41
+ google-cloudvertexbot
42
+ googleagent-mariner
43
+ gptbot
44
+ meta-externalagent
45
+ meta-externalfetcher
46
+ mistralai
47
+ oai-searchbot
48
+ openai
49
+ perplexity
50
+ ].freeze
51
+
52
+ MARKDOWN_PATH = "/v1/agentify/markdown"
53
+
54
+ # The converted document plus the response headers worth forwarding to
55
+ # the agent — the customer's CDN caches the markdown too.
56
+ class MarkdownResult
57
+ attr_reader :markdown, :cache_control, :content_type, :etag
58
+
59
+ def initialize(markdown:, cache_control: nil, content_type: nil, etag: nil)
60
+ @markdown = markdown
61
+ @cache_control = cache_control
62
+ @content_type = content_type
63
+ @etag = etag
64
+ freeze
65
+ end
66
+ end
67
+
68
+ module_function
69
+
70
+ # Whether a request should be answered with agentified markdown: the
71
+ # client either negotiates for it (Accept prefers text/markdown over
72
+ # text/html) or announces an AI agent user-agent. A pure predicate over
73
+ # the two header values — absent or empty headers are simply false,
74
+ # never an error.
75
+ def wants_agent_markdown?(accept: nil, user_agent: nil)
76
+ prefers_markdown?(accept) || ai_user_agent?(user_agent)
77
+ end
78
+
79
+ # Whether text/markdown outranks text/html in an Accept header.
80
+ #
81
+ # Agent fetchers (Claude's WebFetch and kin) send `Accept:
82
+ # text/markdown, text/html, */*`; browsers never list text/markdown.
83
+ # Markdown wins only when explicitly listed with q > 0 and not outranked
84
+ # by text/html (higher q, or listed first on a tie). Ported from the
85
+ # platform's negotiation and pinned by the shared detection vectors: the
86
+ # first entry of each media type and the first parseable q parameter of
87
+ # an entry count.
88
+ def prefers_markdown?(accept)
89
+ return false if accept.nil? || accept.empty?
90
+
91
+ markdown = nil
92
+ html = nil
93
+ accept.downcase.split(",").each_with_index do |raw_entry, index|
94
+ parts = raw_entry.strip.split(";")
95
+ media_type = parts[0].to_s.strip
96
+ q = 1.0
97
+ parts[1..].each do |raw_param|
98
+ param = raw_param.strip
99
+ next unless param.start_with?("q=")
100
+
101
+ # An unparseable q contributes nothing: the default of 1 stands
102
+ # unless a later q parameter parses.
103
+ value = q_value(param)
104
+ next if value.nil?
105
+
106
+ q = value
107
+ break
108
+ end
109
+ entry = { q: q, index: index }
110
+ markdown ||= entry if media_type == "text/markdown"
111
+ html ||= entry if media_type == "text/html"
112
+ end
113
+
114
+ return false if markdown.nil? || markdown[:q] <= 0
115
+ return true if html.nil? || html[:q] <= 0
116
+
117
+ markdown[:q] > html[:q] || (markdown[:q] == html[:q] && markdown[:index] < html[:index])
118
+ end
119
+
120
+ def ai_user_agent?(user_agent)
121
+ return false if user_agent.nil? || user_agent.empty?
122
+
123
+ lowered = user_agent.downcase
124
+ AI_USER_AGENT_SUBSTRINGS.any? { |token| lowered.include?(token) }
125
+ end
126
+
127
+ # The q of one already-trimmed "q=..." parameter, or nil when unusable.
128
+ def q_value(param)
129
+ value = Float(param[2..])
130
+ value.nan? || value.infinite? ? nil : value
131
+ rescue ArgumentError, TypeError
132
+ nil
133
+ end
134
+
135
+ # ----------------------------------------------------------- build
136
+
137
+ # Reject anything but an absolute http(s) URL before it reaches the API.
138
+ def validate_page_url!(url, operation:)
139
+ parsed = begin
140
+ URI.parse(url.to_s)
141
+ rescue URI::InvalidURIError
142
+ nil
143
+ end
144
+ return if parsed.is_a?(URI::HTTP) && !parsed.host.nil? && !parsed.host.empty?
145
+
146
+ raise Error.new("malformed_input", operation: operation,
147
+ message: "url must be an absolute http(s) URL, got #{url.inspect}")
148
+ end
149
+
150
+ def request_headers(api_key)
151
+ {
152
+ "accept" => "text/markdown",
153
+ "authorization" => "Bearer #{api_key}",
154
+ "user-agent" => Usage::USER_AGENT
155
+ }
156
+ end
157
+
158
+ # ------------------------------------------------------- interpret
159
+
160
+ def interpret_markdown(status, body, headers)
161
+ raise Error.new("api_status_error", operation: "fetch_agentify_markdown", status: status) unless (200..299).cover?(status)
162
+
163
+ MarkdownResult.new(
164
+ markdown: body,
165
+ cache_control: headers["cache-control"],
166
+ content_type: headers["content-type"],
167
+ etag: headers["etag"]
168
+ )
169
+ end
170
+
171
+ # ------------------------------------------------------- transport
172
+
173
+ # Returns [status, body, headers] with lowercase header names. Raises
174
+ # only api_transport_error — an HTTP status is data here, not a failure,
175
+ # and interpretation happens above.
176
+ def get(base_url:, path:, query:, api_key:, timeout:, operation:)
177
+ uri = URI.join(base_url, path)
178
+ uri.query = URI.encode_www_form(query)
179
+
180
+ http = Net::HTTP.new(uri.host, uri.port)
181
+ http.use_ssl = uri.scheme == "https"
182
+ # Both halves, or a server that accepts the connection and then stalls
183
+ # hangs the request past the caller's budget.
184
+ http.open_timeout = timeout
185
+ http.read_timeout = timeout
186
+ http.write_timeout = timeout
187
+
188
+ request = Net::HTTP::Get.new(uri)
189
+ request_headers(api_key).each { |name, value| request[name] = value }
190
+
191
+ response = http.request(request)
192
+ headers = response.each_header.to_h { |name, value| [name.downcase, value] }
193
+ [response.code.to_i, response.body.to_s, headers]
194
+ rescue *Usage::TRANSPORT_ERRORS => e
195
+ raise Error.new("api_transport_error", operation: operation, cause: e.message)
196
+ ensure
197
+ http&.finish if http&.started?
198
+ end
199
+ end
200
+ end
201
+ end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "agentify"
3
4
  require_relative "contracts"
4
5
  require_relative "errors"
5
6
  require_relative "responses"
@@ -25,20 +26,29 @@ module ZeroClick
25
26
  def initialize(api_key: nil,
26
27
  usage_read_key: nil,
27
28
  usage_write_key: nil,
29
+ agentify_key: nil,
28
30
  signing_secrets: nil,
29
31
  resolve_signing_secret: nil,
30
32
  api_base_url: DEFAULT_API_BASE_URL,
31
33
  tolerance_seconds: DEFAULT_TOLERANCE_SECONDS,
32
34
  allowance_unavailable_policy: "allow",
33
35
  check_timeout_seconds: DEFAULT_CHECK_TIMEOUT_SECONDS,
36
+ agentify_timeout_seconds: DEFAULT_AGENTIFY_TIMEOUT_SECONDS,
34
37
  on_allowance_unavailable: nil,
35
38
  clock: -> { Time.now.to_i },
36
- # The transport, injectable so every decision this class
39
+ # The transports, injectable so every decision this class
37
40
  # makes about a response is testable without a socket.
38
- # Must return [status, body] or raise Error.
39
- http_post: Usage.method(:post))
41
+ # http_post must return [status, body]; http_get must
42
+ # return [status, body, headers]. Either may raise Error.
43
+ http_post: Usage.method(:post),
44
+ http_get: Agentify.method(:get))
40
45
  usage_read_key ||= api_key
41
46
  usage_write_key ||= api_key
47
+ # Unlike the usage keys, agentify has no construction-time
48
+ # requirement: the capability is optional, so a missing key surfaces
49
+ # as a typed error at call time instead of failing every create that
50
+ # never agentifies.
51
+ agentify_key ||= api_key
42
52
 
43
53
  if usage_read_key.nil? || usage_read_key.empty?
44
54
  raise Error.new("malformed_input", operation: "create",
@@ -63,15 +73,18 @@ module ZeroClick
63
73
 
64
74
  @usage_read_key = usage_read_key
65
75
  @usage_write_key = usage_write_key
76
+ @agentify_key = agentify_key
66
77
  @signing_secrets = signing_secrets&.to_h&.freeze
67
78
  @resolve_signing_secret = resolve_signing_secret
68
79
  @api_base_url = api_base_url
69
80
  @tolerance_seconds = tolerance_seconds
70
81
  @policy = allowance_unavailable_policy.to_s
71
82
  @timeout = check_timeout_seconds
83
+ @agentify_timeout = agentify_timeout_seconds
72
84
  @on_allowance_unavailable = on_allowance_unavailable
73
85
  @clock = clock
74
86
  @http_post = http_post
87
+ @http_get = http_get
75
88
  freeze
76
89
  end
77
90
 
@@ -133,6 +146,33 @@ module ZeroClick
133
146
  Allow.new(verification.context, "not_required")
134
147
  end
135
148
 
149
+ # Convert a public marketing page via GET /v1/agentify/markdown.
150
+ #
151
+ # Requires an API key with the agentify:convert scope (+agentify_key+,
152
+ # falling back to the both-scopes +api_key+). +seller+ is the seller
153
+ # whose storefront the document inlines (its public id); an organization
154
+ # with exactly one active seller never needs it. Returns an
155
+ # Agentify::MarkdownResult carrying the markdown plus the cache headers
156
+ # worth forwarding.
157
+ def fetch_agentify_markdown(url, seller: nil)
158
+ if @agentify_key.nil? || @agentify_key.empty?
159
+ raise Error.new("agentify_not_configured", operation: "fetch_agentify_markdown")
160
+ end
161
+
162
+ Agentify.validate_page_url!(url, operation: "fetch_agentify_markdown")
163
+ query = { "url" => url.to_s }
164
+ query["seller"] = seller unless seller.nil?
165
+ status, body, headers = @http_get.call(
166
+ base_url: @api_base_url,
167
+ path: Agentify::MARKDOWN_PATH,
168
+ query: query,
169
+ api_key: @agentify_key,
170
+ timeout: @agentify_timeout,
171
+ operation: "fetch_agentify_markdown"
172
+ )
173
+ Agentify.interpret_markdown(status, body, headers)
174
+ end
175
+
136
176
  def check_allowance(zc_request_id:, service_slug:, usage:)
137
177
  status, body = @http_post.call(
138
178
  base_url: @api_base_url,
@@ -9,6 +9,10 @@ module ZeroClick
9
9
  DEFAULT_API_BASE_URL = "https://api.zeroclick.io"
10
10
  DEFAULT_TOLERANCE_SECONDS = 300
11
11
  DEFAULT_CHECK_TIMEOUT_SECONDS = 1.5
12
+ # A cold conversion runs the full pipeline on the API side (page fetch,
13
+ # optional browser render, LLM cleanup), so agentify calls get a larger
14
+ # timeout default than the allowance check.
15
+ DEFAULT_AGENTIFY_TIMEOUT_SECONDS = 10.0
12
16
 
13
17
  # Reasons the allowance API gives for refusing. Any other reason on the wire
14
18
  # is treated as a malformed response rather than silently passed through.
@@ -8,6 +8,8 @@ module ZeroClick
8
8
  # environment did (the ZeroClick API unreachable or answering nonsense).
9
9
  class Error < StandardError
10
10
  DEFAULT_MESSAGES = {
11
+ "agentify_not_configured" => "No API key covers agentify calls (set agentify_key, or an api_key " \
12
+ "with the agentify:convert scope)",
11
13
  "api_response_invalid" => "The ZeroClick API response is malformed",
12
14
  "api_status_error" => "The ZeroClick API returned an unsuccessful status",
13
15
  "api_transport_error" => "The ZeroClick API request failed",
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../agentify"
4
+ require_relative "../middleware"
5
+
6
+ module ZeroClick
7
+ module Sellers
8
+ module Middleware
9
+ # Rack middleware that serves agent traffic agentified markdown and
10
+ # passes everyone else to the app untouched. Mount it in front of the
11
+ # marketing site, beside Meter/Identify:
12
+ #
13
+ # use ZeroClick::Sellers::Middleware::Agentify, seller: SELLER
14
+ #
15
+ # The contract, shared by every ZeroClick seller SDK:
16
+ #
17
+ # - only GET requests are considered; every other method passes
18
+ # through;
19
+ # - detection is Agentify.wants_agent_markdown? over the request's
20
+ # Accept and User-Agent headers — markdown-preferring or AI-agent
21
+ # traffic matches;
22
+ # - a match short-circuits with 200 text/markdown, forwarding the
23
+ # agentify response's cache-control and etag so the customer's CDN
24
+ # caches it too;
25
+ # - ANY agentify failure — missing key, network error, non-200,
26
+ # timeout — falls through to the app. The middleware must never
27
+ # break the customer's site; the worst case is an agent seeing HTML.
28
+ #
29
+ # Unlike Meter/Identify it never reads the request body — there is
30
+ # nothing to verify — so no buffering ceiling applies.
31
+ class Agentify
32
+ # +seller+ may be a client, a callable returning one, or omitted to
33
+ # resolve the process-wide ZeroClick::Sellers.seller on the first
34
+ # request (the Rails path — see Base#initialize for why).
35
+ #
36
+ # +seller_id+ is forwarded to the API's seller query param (its public
37
+ # id); needed only by multi-seller organizations. +resolve_url+ is a
38
+ # callable taking the Rack env and returning the public URL to
39
+ # convert, for servers behind a proxy that rewrites the scheme or host
40
+ # the runtime sees. +on_error+ observes the fail-open path; it is
41
+ # never re-raised.
42
+ def initialize(app, seller: nil, seller_id: nil, resolve_url: nil, on_error: nil)
43
+ @app = app
44
+ @seller_source = seller
45
+ @seller_id = seller_id
46
+ @resolve_url = resolve_url
47
+ @on_error = on_error
48
+ end
49
+
50
+ def call(env)
51
+ return @app.call(env) unless env["REQUEST_METHOD"] == "GET"
52
+
53
+ wanted = Sellers::Agentify.wants_agent_markdown?(
54
+ accept: env["HTTP_ACCEPT"], user_agent: env["HTTP_USER_AGENT"]
55
+ )
56
+ return @app.call(env) unless wanted
57
+
58
+ begin
59
+ url = @resolve_url ? @resolve_url.call(env) : Agentify.public_url(env)
60
+ result = seller.fetch_agentify_markdown(url, seller: @seller_id)
61
+ rescue StandardError => e
62
+ # Fail open — never break the site; the worst case is HTML.
63
+ @on_error&.call(e, env)
64
+ return @app.call(env)
65
+ end
66
+
67
+ headers = {
68
+ "content-type" => result.content_type || "text/markdown; charset=utf-8",
69
+ # The representation depends on both detection signals; without
70
+ # this a shared cache would hand the markdown to a browser.
71
+ "vary" => "accept, user-agent"
72
+ }
73
+ headers["cache-control"] = result.cache_control unless result.cache_control.nil?
74
+ headers["etag"] = result.etag unless result.etag.nil?
75
+ [200, headers, [result.markdown]]
76
+ end
77
+
78
+ # The URL the agent requested, as far as this process can see it.
79
+ def self.public_url(env)
80
+ scheme = env["rack.url_scheme"] || "http"
81
+ host = env["HTTP_HOST"]
82
+ if host.nil? || host.empty?
83
+ host = env["SERVER_NAME"].to_s
84
+ port = env["SERVER_PORT"]
85
+ default_port = scheme == "https" ? "443" : "80"
86
+ host = "#{host}:#{port}" if port && port != default_port
87
+ end
88
+ "#{scheme}://#{host}#{Middleware.path_and_query_from_env(env)}"
89
+ end
90
+
91
+ private
92
+
93
+ # Resolved once, on first use rather than at wire-up, exactly as
94
+ # Base#seller does — see that comment for the Rails ordering.
95
+ def seller
96
+ @seller ||= if @seller_source.nil?
97
+ Sellers.seller
98
+ elsif @seller_source.respond_to?(:call)
99
+ @seller_source.call
100
+ else
101
+ @seller_source
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -6,6 +6,7 @@ require "uri"
6
6
 
7
7
  require_relative "contracts"
8
8
  require_relative "errors"
9
+ require_relative "version"
9
10
 
10
11
  module ZeroClick
11
12
  module Sellers
@@ -35,13 +36,18 @@ module ZeroClick
35
36
  OpenSSL::SSL::SSLError
36
37
  ].freeze
37
38
 
39
+ # SDK self-identification, most significant token first per RFC 9110:
40
+ # the gem release, then the Ruby runtime.
41
+ USER_AGENT = "zeroclick-sellers-ruby/#{VERSION} ruby/#{RUBY_VERSION}".freeze
42
+
38
43
  module_function
39
44
 
40
45
  def request_headers(api_key)
41
46
  {
42
47
  "accept" => "application/json",
43
48
  "authorization" => "Bearer #{api_key}",
44
- "content-type" => "application/json"
49
+ "content-type" => "application/json",
50
+ "user-agent" => USER_AGENT
45
51
  }
46
52
  end
47
53
 
@@ -4,6 +4,6 @@ module ZeroClick
4
4
  module Sellers
5
5
  # Bump this to publish. The workflow reads it, asks RubyGems whether this
6
6
  # version already exists, and publishes only when it does not.
7
- VERSION = "0.4.0"
7
+ VERSION = "0.5.0"
8
8
  end
9
9
  end
@@ -6,8 +6,10 @@ require_relative "sellers/contracts"
6
6
  require_relative "sellers/responses"
7
7
  require_relative "sellers/verify"
8
8
  require_relative "sellers/usage"
9
+ require_relative "sellers/agentify"
9
10
  require_relative "sellers/client"
10
11
  require_relative "sellers/middleware"
12
+ require_relative "sellers/middleware/agentify"
11
13
 
12
14
  # The ZeroClick billing guard for Ruby backends.
13
15
  #
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zeroclick-sellers
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ZeroClick
@@ -23,11 +23,13 @@ files:
23
23
  - LICENSE
24
24
  - README.md
25
25
  - lib/zeroclick/sellers.rb
26
+ - lib/zeroclick/sellers/agentify.rb
26
27
  - lib/zeroclick/sellers/client.rb
27
28
  - lib/zeroclick/sellers/contracts.rb
28
29
  - lib/zeroclick/sellers/encryption.rb
29
30
  - lib/zeroclick/sellers/errors.rb
30
31
  - lib/zeroclick/sellers/middleware.rb
32
+ - lib/zeroclick/sellers/middleware/agentify.rb
31
33
  - lib/zeroclick/sellers/railtie.rb
32
34
  - lib/zeroclick/sellers/responses.rb
33
35
  - lib/zeroclick/sellers/stateful.rb