zeroclick-sellers 0.4.1 → 0.6.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: 516aac7120c0e979c3d70f57051151667cd4a3e5945ecca29e3202af78ef04df
4
- data.tar.gz: 65f2ed28d951f1b9108f03c8dd64cead2ee3cf96ed8f4580b710d3d33ad3802e
3
+ metadata.gz: 6127c1b9413ac6cbf8d9690e972e319418e2bee6ba3a025ba057c6752d04a370
4
+ data.tar.gz: 384bfdd1ba3393497943aa9c37138f0b9fae69098a2404b3dfd6b2e51f61493b
5
5
  SHA512:
6
- metadata.gz: e07f7f2cc9135b43b93f27babfe4109003ac388503d10f13085378b472c6837ae144542afe66b3c1a725199f96554cf2c73da7fe6a45d95dad76fac9b22a4bd3
7
- data.tar.gz: ce8aa5c0cab3f67cfec797a1b05da45b581e3c77c96c685ced835e95e411c2585c934dd85215d97cc096b50e76e5399d18ca85e8bc80d2a04186af1e4c6a5b34
6
+ metadata.gz: 446af6c8cb1563130c222f09ec8ee8eeb4308636782925acd269d7353a1ed3cbc52f7aa8ac033fb0a1eb6bb4fe268b2d5178a5b483e7e7cf9348c18ea291a99d
7
+ data.tar.gz: 40f965870f70c61902865c586d930c39f4c1165a943244dfc6647d05abdbc93bb2faf65f184188cc80e03e45a860b5c410c5db8d2c97f48545206a5a663657cc
@@ -0,0 +1,208 @@
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, :zcj_id
58
+
59
+ # +zcj_id+ is the journey id the conversion minted (x-zcj-id), or nil
60
+ # when the chain is off for this seller. Forwarded onto the agent's
61
+ # response so a wrapping page-view beacon can stitch this marketing
62
+ # view to the same journey as the storefront visit and purchase the
63
+ # agent reaches through the inlined links.
64
+ def initialize(markdown:, cache_control: nil, content_type: nil, etag: nil, zcj_id: nil)
65
+ @markdown = markdown
66
+ @cache_control = cache_control
67
+ @content_type = content_type
68
+ @etag = etag
69
+ @zcj_id = zcj_id
70
+ freeze
71
+ end
72
+ end
73
+
74
+ module_function
75
+
76
+ # Whether a request should be answered with agentified markdown: the
77
+ # client either negotiates for it (Accept prefers text/markdown over
78
+ # text/html) or announces an AI agent user-agent. A pure predicate over
79
+ # the two header values — absent or empty headers are simply false,
80
+ # never an error.
81
+ def wants_agent_markdown?(accept: nil, user_agent: nil)
82
+ prefers_markdown?(accept) || ai_user_agent?(user_agent)
83
+ end
84
+
85
+ # Whether text/markdown outranks text/html in an Accept header.
86
+ #
87
+ # Agent fetchers (Claude's WebFetch and kin) send `Accept:
88
+ # text/markdown, text/html, */*`; browsers never list text/markdown.
89
+ # Markdown wins only when explicitly listed with q > 0 and not outranked
90
+ # by text/html (higher q, or listed first on a tie). Ported from the
91
+ # platform's negotiation and pinned by the shared detection vectors: the
92
+ # first entry of each media type and the first parseable q parameter of
93
+ # an entry count.
94
+ def prefers_markdown?(accept)
95
+ return false if accept.nil? || accept.empty?
96
+
97
+ markdown = nil
98
+ html = nil
99
+ accept.downcase.split(",").each_with_index do |raw_entry, index|
100
+ parts = raw_entry.strip.split(";")
101
+ media_type = parts[0].to_s.strip
102
+ q = 1.0
103
+ parts[1..].each do |raw_param|
104
+ param = raw_param.strip
105
+ next unless param.start_with?("q=")
106
+
107
+ # An unparseable q contributes nothing: the default of 1 stands
108
+ # unless a later q parameter parses.
109
+ value = q_value(param)
110
+ next if value.nil?
111
+
112
+ q = value
113
+ break
114
+ end
115
+ entry = { q: q, index: index }
116
+ markdown ||= entry if media_type == "text/markdown"
117
+ html ||= entry if media_type == "text/html"
118
+ end
119
+
120
+ return false if markdown.nil? || markdown[:q] <= 0
121
+ return true if html.nil? || html[:q] <= 0
122
+
123
+ markdown[:q] > html[:q] || (markdown[:q] == html[:q] && markdown[:index] < html[:index])
124
+ end
125
+
126
+ def ai_user_agent?(user_agent)
127
+ return false if user_agent.nil? || user_agent.empty?
128
+
129
+ lowered = user_agent.downcase
130
+ AI_USER_AGENT_SUBSTRINGS.any? { |token| lowered.include?(token) }
131
+ end
132
+
133
+ # The q of one already-trimmed "q=..." parameter, or nil when unusable.
134
+ def q_value(param)
135
+ value = Float(param[2..])
136
+ value.nan? || value.infinite? ? nil : value
137
+ rescue ArgumentError, TypeError
138
+ nil
139
+ end
140
+
141
+ # ----------------------------------------------------------- build
142
+
143
+ # Reject anything but an absolute http(s) URL before it reaches the API.
144
+ def validate_page_url!(url, operation:)
145
+ parsed = begin
146
+ URI.parse(url.to_s)
147
+ rescue URI::InvalidURIError
148
+ nil
149
+ end
150
+ return if parsed.is_a?(URI::HTTP) && !parsed.host.nil? && !parsed.host.empty?
151
+
152
+ raise Error.new("malformed_input", operation: operation,
153
+ message: "url must be an absolute http(s) URL, got #{url.inspect}")
154
+ end
155
+
156
+ def request_headers(api_key)
157
+ {
158
+ "accept" => "text/markdown",
159
+ "authorization" => "Bearer #{api_key}",
160
+ "user-agent" => Usage::USER_AGENT
161
+ }
162
+ end
163
+
164
+ # ------------------------------------------------------- interpret
165
+
166
+ def interpret_markdown(status, body, headers)
167
+ raise Error.new("api_status_error", operation: "fetch_agentify_markdown", status: status) unless (200..299).cover?(status)
168
+
169
+ MarkdownResult.new(
170
+ markdown: body,
171
+ cache_control: headers["cache-control"],
172
+ content_type: headers["content-type"],
173
+ etag: headers["etag"],
174
+ zcj_id: headers["x-zcj-id"]
175
+ )
176
+ end
177
+
178
+ # ------------------------------------------------------- transport
179
+
180
+ # Returns [status, body, headers] with lowercase header names. Raises
181
+ # only api_transport_error — an HTTP status is data here, not a failure,
182
+ # and interpretation happens above.
183
+ def get(base_url:, path:, query:, api_key:, timeout:, operation:)
184
+ uri = URI.join(base_url, path)
185
+ uri.query = URI.encode_www_form(query)
186
+
187
+ http = Net::HTTP.new(uri.host, uri.port)
188
+ http.use_ssl = uri.scheme == "https"
189
+ # Both halves, or a server that accepts the connection and then stalls
190
+ # hangs the request past the caller's budget.
191
+ http.open_timeout = timeout
192
+ http.read_timeout = timeout
193
+ http.write_timeout = timeout
194
+
195
+ request = Net::HTTP::Get.new(uri)
196
+ request_headers(api_key).each { |name, value| request[name] = value }
197
+
198
+ response = http.request(request)
199
+ headers = response.each_header.to_h { |name, value| [name.downcase, value] }
200
+ [response.code.to_i, response.body.to_s, headers]
201
+ rescue *Usage::TRANSPORT_ERRORS => e
202
+ raise Error.new("api_transport_error", operation: operation, cause: e.message)
203
+ ensure
204
+ http&.finish if http&.started?
205
+ end
206
+ end
207
+ end
208
+ end
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "agentify"
3
4
  require_relative "contracts"
4
5
  require_relative "errors"
6
+ require_relative "page_views"
5
7
  require_relative "responses"
6
8
  require_relative "usage"
7
9
  require_relative "verify"
@@ -25,20 +27,32 @@ module ZeroClick
25
27
  def initialize(api_key: nil,
26
28
  usage_read_key: nil,
27
29
  usage_write_key: nil,
30
+ agentify_key: nil,
31
+ page_views_key: nil,
28
32
  signing_secrets: nil,
29
33
  resolve_signing_secret: nil,
30
34
  api_base_url: DEFAULT_API_BASE_URL,
31
35
  tolerance_seconds: DEFAULT_TOLERANCE_SECONDS,
32
36
  allowance_unavailable_policy: "allow",
33
37
  check_timeout_seconds: DEFAULT_CHECK_TIMEOUT_SECONDS,
38
+ agentify_timeout_seconds: DEFAULT_AGENTIFY_TIMEOUT_SECONDS,
39
+ page_view_timeout_seconds: DEFAULT_PAGE_VIEW_TIMEOUT_SECONDS,
34
40
  on_allowance_unavailable: nil,
35
41
  clock: -> { Time.now.to_i },
36
- # The transport, injectable so every decision this class
42
+ # The transports, injectable so every decision this class
37
43
  # makes about a response is testable without a socket.
38
- # Must return [status, body] or raise Error.
39
- http_post: Usage.method(:post))
44
+ # http_post must return [status, body]; http_get must
45
+ # return [status, body, headers]. Either may raise Error.
46
+ http_post: Usage.method(:post),
47
+ http_get: Agentify.method(:get))
40
48
  usage_read_key ||= api_key
41
49
  usage_write_key ||= api_key
50
+ # Unlike the usage keys, agentify and page views have no
51
+ # construction-time requirement: each capability is optional, so a
52
+ # missing key surfaces as a typed error at call time instead of failing
53
+ # every create that never uses it.
54
+ agentify_key ||= api_key
55
+ page_views_key ||= api_key
42
56
 
43
57
  if usage_read_key.nil? || usage_read_key.empty?
44
58
  raise Error.new("malformed_input", operation: "create",
@@ -63,15 +77,20 @@ module ZeroClick
63
77
 
64
78
  @usage_read_key = usage_read_key
65
79
  @usage_write_key = usage_write_key
80
+ @agentify_key = agentify_key
81
+ @page_views_key = page_views_key
66
82
  @signing_secrets = signing_secrets&.to_h&.freeze
67
83
  @resolve_signing_secret = resolve_signing_secret
68
84
  @api_base_url = api_base_url
69
85
  @tolerance_seconds = tolerance_seconds
70
86
  @policy = allowance_unavailable_policy.to_s
71
87
  @timeout = check_timeout_seconds
88
+ @agentify_timeout = agentify_timeout_seconds
89
+ @page_view_timeout = page_view_timeout_seconds
72
90
  @on_allowance_unavailable = on_allowance_unavailable
73
91
  @clock = clock
74
92
  @http_post = http_post
93
+ @http_get = http_get
75
94
  freeze
76
95
  end
77
96
 
@@ -133,6 +152,67 @@ module ZeroClick
133
152
  Allow.new(verification.context, "not_required")
134
153
  end
135
154
 
155
+ # Convert a public marketing page via GET /v1/agentify/markdown.
156
+ #
157
+ # Requires an API key with the agentify:convert scope (+agentify_key+,
158
+ # falling back to the both-scopes +api_key+). +seller+ is the seller
159
+ # whose storefront the document inlines (its public id); an organization
160
+ # with exactly one active seller never needs it. Returns an
161
+ # Agentify::MarkdownResult carrying the markdown plus the cache headers
162
+ # worth forwarding.
163
+ def fetch_agentify_markdown(url, seller: nil)
164
+ if @agentify_key.nil? || @agentify_key.empty?
165
+ raise Error.new("agentify_not_configured", operation: "fetch_agentify_markdown")
166
+ end
167
+
168
+ Agentify.validate_page_url!(url, operation: "fetch_agentify_markdown")
169
+ query = { "url" => url.to_s }
170
+ query["seller"] = seller unless seller.nil?
171
+ status, body, headers = @http_get.call(
172
+ base_url: @api_base_url,
173
+ path: Agentify::MARKDOWN_PATH,
174
+ query: query,
175
+ api_key: @agentify_key,
176
+ timeout: @agentify_timeout,
177
+ operation: "fetch_agentify_markdown"
178
+ )
179
+ Agentify.interpret_markdown(status, body, headers)
180
+ end
181
+
182
+ # Report one marketing-site page view to POST /v1/page-views.
183
+ #
184
+ # Requires an API key with the page-views:write scope (+page_views_key+,
185
+ # falling back to the both-scopes +api_key+). Fire-and-forget by design:
186
+ # the API answers 204 without waiting for warehouse delivery. Raises a
187
+ # typed Error on transport failure or a non-2xx status so a direct caller
188
+ # can observe problems; the Middleware::PageViews wrapper swallows those
189
+ # so a beacon failure never affects the page being observed.
190
+ #
191
+ # +seller+ is the seller's public id; +path+ must be an absolute path.
192
+ def report_page_view(seller:, path:, status: nil, duration_ms: nil, representation: nil,
193
+ journey_id: nil, captured_at: nil, user_agent: nil, accept: nil,
194
+ client_ip: nil, country: nil, referrer_host: nil)
195
+ if @page_views_key.nil? || @page_views_key.empty?
196
+ raise Error.new("page_views_not_configured", operation: "report_page_view")
197
+ end
198
+
199
+ payload = PageViews.build_payload(
200
+ seller: seller, path: path, status: status, duration_ms: duration_ms,
201
+ representation: representation, journey_id: journey_id, captured_at: captured_at,
202
+ user_agent: user_agent, accept: accept, client_ip: client_ip,
203
+ country: country, referrer_host: referrer_host
204
+ )
205
+ status_code, body = @http_post.call(
206
+ base_url: @api_base_url,
207
+ path: PageViews::REPORT_PATH,
208
+ api_key: @page_views_key,
209
+ payload: payload,
210
+ timeout: @page_view_timeout,
211
+ operation: "report_page_view"
212
+ )
213
+ PageViews.interpret_report(status_code, body)
214
+ end
215
+
136
216
  def check_allowance(zc_request_id:, service_slug:, usage:)
137
217
  status, body = @http_post.call(
138
218
  base_url: @api_base_url,
@@ -9,6 +9,13 @@ 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
16
+ # The page-view beacon is a cheap fire-and-forget 204; its default timeout
17
+ # is short so a slow ingress never delays the page it is observing.
18
+ DEFAULT_PAGE_VIEW_TIMEOUT_SECONDS = 2.0
12
19
 
13
20
  # Reasons the allowance API gives for refusing. Any other reason on the wire
14
21
  # is treated as a malformed response rather than silently passed through.
@@ -8,10 +8,14 @@ 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",
14
16
  "malformed_input" => "The SDK input is malformed",
17
+ "page_views_not_configured" => "No API key covers page-view calls (set page_views_key, or an api_key " \
18
+ "with the page-views:write scope)",
15
19
  "signing_secret_resolution_failed" => "The signing secret could not be resolved"
16
20
  }.freeze
17
21
 
@@ -0,0 +1,113 @@
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
+ unless result.zcj_id.nil?
76
+ # Re-expose the journey id on the agent's response so a wrapping
77
+ # page-view beacon can stamp this view onto the same journey.
78
+ headers["x-zcj-id"] = result.zcj_id
79
+ headers["access-control-expose-headers"] = "x-zcj-id"
80
+ end
81
+ [200, headers, [result.markdown]]
82
+ end
83
+
84
+ # The URL the agent requested, as far as this process can see it.
85
+ def self.public_url(env)
86
+ scheme = env["rack.url_scheme"] || "http"
87
+ host = env["HTTP_HOST"]
88
+ if host.nil? || host.empty?
89
+ host = env["SERVER_NAME"].to_s
90
+ port = env["SERVER_PORT"]
91
+ default_port = scheme == "https" ? "443" : "80"
92
+ host = "#{host}:#{port}" if port && port != default_port
93
+ end
94
+ "#{scheme}://#{host}#{Middleware.path_and_query_from_env(env)}"
95
+ end
96
+
97
+ private
98
+
99
+ # Resolved once, on first use rather than at wire-up, exactly as
100
+ # Base#seller does — see that comment for the Rails ordering.
101
+ def seller
102
+ @seller ||= if @seller_source.nil?
103
+ Sellers.seller
104
+ elsif @seller_source.respond_to?(:call)
105
+ @seller_source.call
106
+ else
107
+ @seller_source
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "uri"
5
+
6
+ require_relative "../middleware"
7
+ require_relative "agentify"
8
+
9
+ module ZeroClick
10
+ module Sellers
11
+ module Middleware
12
+ # Rack middleware that emits a fire-and-forget page-view beacon for every
13
+ # page-content request, without changing the response. Mount it in front
14
+ # of the marketing site:
15
+ #
16
+ # use ZeroClick::Sellers::Middleware::PageViews, seller: SELLER, seller_id: "sel_public"
17
+ #
18
+ # The contract, shared by every ZeroClick seller SDK:
19
+ #
20
+ # - only GET requests to non-asset paths are reported (an asset is a
21
+ # final path segment ending in an extension — .css, .ico, .png);
22
+ # - the app runs FIRST, so the beacon carries the real status and
23
+ # duration; the visitor's user-agent, accept, and connecting IP are
24
+ # forwarded for server-side classification;
25
+ # - ANY beacon failure — missing key, network error, non-204, timeout,
26
+ # a bad resolve_url or clock — is handed to +on_error+ and swallowed.
27
+ # The middleware must never break or alter the customer's site.
28
+ #
29
+ # Compose it as the OUTER middleware around Agentify — mount PageViews
30
+ # before Agentify — so the agent-markdown variant's re-exposed x-zcj-id is
31
+ # visible on the response and stitches the view onto the same journey.
32
+ #
33
+ # Like Agentify it never reads the request body, so no buffering ceiling
34
+ # applies.
35
+ class PageViews
36
+ # A page-content request has no asset extension on its final path
37
+ # segment. This skips /_astro/app.css, /favicon.ico, /logo.png and the
38
+ # like while still reporting /pricing, /blog/post, and / for both
39
+ # browsers and agents.
40
+ ASSET_PATH = /\.[a-z0-9]+$/i
41
+
42
+ # Wall clock in epoch milliseconds, for the observation time and the
43
+ # duration. Injectable so a test can make both deterministic.
44
+ DEFAULT_CLOCK = -> { Process.clock_gettime(Process::CLOCK_REALTIME, :float_millisecond) }
45
+
46
+ # +seller+ may be a client, a callable returning one, or omitted to
47
+ # resolve the process-wide ZeroClick::Sellers.seller on the first
48
+ # request (the Rails path — see Base#initialize for why).
49
+ #
50
+ # +seller_id+ is the seller's public id, sent in every beacon (required
51
+ # by the wire contract). +clock+ returns epoch milliseconds.
52
+ # +resolve_url+ is a callable taking the Rack env and returning the
53
+ # public URL of the request, for servers behind a proxy that rewrites
54
+ # the host the runtime sees. +on_error+ observes the fire-and-forget
55
+ # path; it is never re-raised.
56
+ def initialize(app, seller_id:, seller: nil, clock: nil, resolve_url: nil, on_error: nil)
57
+ @app = app
58
+ @seller_source = seller
59
+ @seller_id = seller_id
60
+ @clock = clock || DEFAULT_CLOCK
61
+ @resolve_url = resolve_url
62
+ @on_error = on_error
63
+ end
64
+
65
+ def call(env)
66
+ started_at = @clock.call
67
+ status, headers, body = @app.call(env)
68
+
69
+ # The whole observation path is wrapped: a bad resolve_url, an
70
+ # unparseable URL, a misbehaving clock, or the beacon delivery itself
71
+ # must never break the page. Every failure — synchronous or from the
72
+ # blocking beacon — is routed to on_error, and the app's response is
73
+ # returned untouched regardless.
74
+ begin
75
+ observe(env, started_at: started_at, status: status, headers: headers)
76
+ rescue StandardError => e
77
+ @on_error&.call(e, env)
78
+ end
79
+
80
+ [status, headers, body]
81
+ end
82
+
83
+ private
84
+
85
+ def observe(env, started_at:, status:, headers:)
86
+ return unless env["REQUEST_METHOD"] == "GET"
87
+
88
+ url = @resolve_url ? @resolve_url.call(env) : Agentify.public_url(env)
89
+ path = URI.parse(url.to_s).path
90
+ path = "/" if path.nil? || path.empty?
91
+ return if ASSET_PATH.match?(path)
92
+
93
+ seller.report_page_view(
94
+ seller: @seller_id,
95
+ path: path,
96
+ status: status.to_i,
97
+ duration_ms: [0, (@clock.call - started_at).round].max,
98
+ representation: representation_of(headers),
99
+ captured_at: Time.at(started_at / 1000.0).utc.iso8601(3),
100
+ user_agent: env["HTTP_USER_AGENT"],
101
+ accept: env["HTTP_ACCEPT"],
102
+ client_ip: client_ip_of(env),
103
+ country: env["HTTP_CF_IPCOUNTRY"],
104
+ referrer_host: referrer_host_of(env),
105
+ journey_id: journey_id_of(headers)
106
+ )
107
+ end
108
+
109
+ # What the app served for this page-content request, read off the
110
+ # response content-type so it reflects the real representation
111
+ # regardless of what produced it.
112
+ def representation_of(headers)
113
+ header_value(headers, "content-type").to_s.include?("markdown") ? "markdown" : "html"
114
+ end
115
+
116
+ # The journey id an upstream Agentify conversion minted for this same
117
+ # request, re-exposed by Middleware::Agentify. Present only for the
118
+ # agent-markdown variant; omitted otherwise.
119
+ def journey_id_of(headers)
120
+ header_value(headers, "x-zcj-id")
121
+ end
122
+
123
+ # The forwarded client IP, from the edge's connecting-IP headers. The
124
+ # API turns it into the viewer id and never stores it raw; nil when the
125
+ # runtime exposes no client address.
126
+ def client_ip_of(env)
127
+ direct = env["HTTP_CF_CONNECTING_IP"] || env["HTTP_X_REAL_IP"]
128
+ return direct.strip if direct && !direct.strip.empty?
129
+
130
+ forwarded = env["HTTP_X_FORWARDED_FOR"]
131
+ return nil if forwarded.nil?
132
+
133
+ first = forwarded.split(",").first.to_s.strip
134
+ first.empty? ? nil : first
135
+ end
136
+
137
+ # The referrer reduced to its host at the edge, so the full referrer
138
+ # (its path and query, which can carry identifying values) never leaves
139
+ # the site. A malformed referrer yields no host rather than the full URL.
140
+ def referrer_host_of(env)
141
+ referer = env["HTTP_REFERER"]
142
+ return nil if referer.nil? || referer.empty?
143
+
144
+ uri = begin
145
+ URI.parse(referer)
146
+ rescue URI::InvalidURIError
147
+ nil
148
+ end
149
+ host = uri&.host
150
+ return nil if host.nil? || host.empty?
151
+
152
+ # Host and any non-default port only, matching the JS SDK's URL.host.
153
+ uri.port && uri.port != uri.default_port ? "#{host}:#{uri.port}" : host
154
+ end
155
+
156
+ # Case-insensitive lookup over the app's response headers.
157
+ def header_value(headers, name)
158
+ target = name.downcase
159
+ headers.each { |key, value| return value if key.to_s.downcase == target }
160
+ nil
161
+ end
162
+
163
+ # Resolved once, on first use rather than at wire-up, exactly as
164
+ # Base#seller does — see that comment for the Rails ordering.
165
+ def seller
166
+ @seller ||= if @seller_source.nil?
167
+ Sellers.seller
168
+ elsif @seller_source.respond_to?(:call)
169
+ @seller_source.call
170
+ else
171
+ @seller_source
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "contracts"
4
+ require_relative "errors"
5
+
6
+ module ZeroClick
7
+ module Sellers
8
+ # POST /v1/page-views: one fire-and-forget marketing page-view beacon.
9
+ #
10
+ # As in Usage and Agentify, building the payload and interpreting the
11
+ # response are separate from transport, so what a response MEANS is decided
12
+ # in one pure place. Unlike the usage endpoints the API answers 204 with no
13
+ # body, so the success path parses nothing — see #interpret_report.
14
+ #
15
+ # The visitor's user-agent/accept/IP are forwarded so the API can classify
16
+ # the viewer and derive the shared viewer id; they are consumed there and
17
+ # never stored raw. Only the path is ever sent, never the query.
18
+ module PageViews
19
+ REPORT_PATH = "/v1/page-views"
20
+
21
+ # What the site actually served for a page-content request: the agent
22
+ # markdown variant, or the normal HTML.
23
+ REPRESENTATIONS = %w[html markdown].freeze
24
+
25
+ module_function
26
+
27
+ # Build the wire body: camelCase string keys, every absent optional key
28
+ # omitted. +seller+ is the seller's public id and +path+ must be an
29
+ # absolute path, both rejected before any request leaves the process.
30
+ def build_payload(seller:, path:, status: nil, duration_ms: nil, representation: nil,
31
+ journey_id: nil, captured_at: nil, user_agent: nil, accept: nil,
32
+ client_ip: nil, country: nil, referrer_host: nil)
33
+ Sellers.require_slug!(seller, "seller", "report_page_view")
34
+ unless path.is_a?(String) && path.start_with?("/")
35
+ raise Error.new("malformed_input", operation: "report_page_view",
36
+ message: "path must be a string beginning with \"/\"")
37
+ end
38
+ if !representation.nil? && !REPRESENTATIONS.include?(representation)
39
+ raise Error.new("malformed_input", operation: "report_page_view",
40
+ message: "representation must be \"html\" or \"markdown\"")
41
+ end
42
+
43
+ payload = { "seller" => seller, "path" => path }
44
+ payload["status"] = status unless status.nil?
45
+ payload["durationMs"] = duration_ms unless duration_ms.nil?
46
+ payload["representation"] = representation unless representation.nil?
47
+ payload["journeyId"] = journey_id unless journey_id.nil?
48
+ payload["capturedAt"] = captured_at unless captured_at.nil?
49
+ payload["userAgent"] = user_agent unless user_agent.nil?
50
+ payload["accept"] = accept unless accept.nil?
51
+ payload["clientIp"] = client_ip unless client_ip.nil?
52
+ payload["country"] = country unless country.nil?
53
+ payload["referrerHost"] = referrer_host unless referrer_host.nil?
54
+ payload
55
+ end
56
+
57
+ # A 204 carries no body, so there is nothing to parse; a non-2xx is the
58
+ # only failure a well-formed beacon can hit here. Distinct from
59
+ # Usage.interpret_report, which requires a JSON body.
60
+ def interpret_report(status, _body)
61
+ return if (200..299).cover?(status)
62
+
63
+ raise Error.new("api_status_error", operation: "report_page_view", status: status)
64
+ end
65
+ end
66
+ end
67
+ end
@@ -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.1"
7
+ VERSION = "0.6.0"
8
8
  end
9
9
  end
@@ -6,8 +6,12 @@ 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"
10
+ require_relative "sellers/page_views"
9
11
  require_relative "sellers/client"
10
12
  require_relative "sellers/middleware"
13
+ require_relative "sellers/middleware/agentify"
14
+ require_relative "sellers/middleware/page_views"
11
15
 
12
16
  # The ZeroClick billing guard for Ruby backends.
13
17
  #
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.1
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ZeroClick
@@ -23,11 +23,15 @@ 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
33
+ - lib/zeroclick/sellers/middleware/page_views.rb
34
+ - lib/zeroclick/sellers/page_views.rb
31
35
  - lib/zeroclick/sellers/railtie.rb
32
36
  - lib/zeroclick/sellers/responses.rb
33
37
  - lib/zeroclick/sellers/stateful.rb