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 +4 -4
- data/lib/zeroclick/sellers/agentify.rb +201 -0
- data/lib/zeroclick/sellers/client.rb +43 -3
- data/lib/zeroclick/sellers/contracts.rb +4 -0
- data/lib/zeroclick/sellers/errors.rb +2 -0
- data/lib/zeroclick/sellers/middleware/agentify.rb +107 -0
- data/lib/zeroclick/sellers/usage.rb +7 -1
- data/lib/zeroclick/sellers/version.rb +1 -1
- data/lib/zeroclick/sellers.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c4d8cedea5994f6b40e5d7505027dfe9bf1fa9df5dfe32863070534bee183859
|
|
4
|
+
data.tar.gz: ff203775e81db08e529b7fe0908da260c3142b7e1a82783f9eaac4cf3624fdcd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
39
|
+
# The transports, injectable so every decision this class
|
|
37
40
|
# makes about a response is testable without a socket.
|
|
38
|
-
#
|
|
39
|
-
|
|
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
|
|
data/lib/zeroclick/sellers.rb
CHANGED
|
@@ -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
|
+
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
|