zeroclick-sellers 0.6.0 → 0.8.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: 6127c1b9413ac6cbf8d9690e972e319418e2bee6ba3a025ba057c6752d04a370
4
- data.tar.gz: 384bfdd1ba3393497943aa9c37138f0b9fae69098a2404b3dfd6b2e51f61493b
3
+ metadata.gz: 2865ea4c52917887161bb00e22f46bdd275f28a6a7ac3a2e72d680b3db32323c
4
+ data.tar.gz: 4ca1a1c4665a099a54e15fb459a36eaa951feb718331fa6303cd4e81b2356d22
5
5
  SHA512:
6
- metadata.gz: 446af6c8cb1563130c222f09ec8ee8eeb4308636782925acd269d7353a1ed3cbc52f7aa8ac033fb0a1eb6bb4fe268b2d5178a5b483e7e7cf9348c18ea291a99d
7
- data.tar.gz: 40f965870f70c61902865c586d930c39f4c1165a943244dfc6647d05abdbc93bb2faf65f184188cc80e03e45a860b5c410c5db8d2c97f48545206a5a663657cc
6
+ metadata.gz: 1b9b122f2cea8b056f79fe2aa56af4d674818148ae8e6b60adb4ac9b000520f06d360c22980c0c6900e87bc02882218d1e9e4815248b8d65d5319608e1a4d0a4
7
+ data.tar.gz: 81c0463cda57c90787550a9215a786d41d555486eaef5af19f081b965af84608d87819f2f053be1195425e815aa985d777761897090049bbced8dcb74981e73c
@@ -191,7 +191,9 @@ module ZeroClick
191
191
  # +seller+ is the seller's public id; +path+ must be an absolute path.
192
192
  def report_page_view(seller:, path:, status: nil, duration_ms: nil, representation: nil,
193
193
  journey_id: nil, captured_at: nil, user_agent: nil, accept: nil,
194
- client_ip: nil, country: nil, referrer_host: nil)
194
+ client_ip: nil, country: nil, referrer_host: nil, host: nil,
195
+ eval_tag: nil, eval_signature: nil, eval_signature_input: nil,
196
+ eval_signature_agent: nil, source: nil, source_signature: nil)
195
197
  if @page_views_key.nil? || @page_views_key.empty?
196
198
  raise Error.new("page_views_not_configured", operation: "report_page_view")
197
199
  end
@@ -200,7 +202,10 @@ module ZeroClick
200
202
  seller: seller, path: path, status: status, duration_ms: duration_ms,
201
203
  representation: representation, journey_id: journey_id, captured_at: captured_at,
202
204
  user_agent: user_agent, accept: accept, client_ip: client_ip,
203
- country: country, referrer_host: referrer_host
205
+ country: country, referrer_host: referrer_host, host: host,
206
+ eval_tag: eval_tag, eval_signature: eval_signature,
207
+ eval_signature_input: eval_signature_input, eval_signature_agent: eval_signature_agent,
208
+ source: source, source_signature: source_signature
204
209
  )
205
210
  status_code, body = @http_post.call(
206
211
  base_url: @api_base_url,
@@ -22,6 +22,14 @@ module ZeroClick
22
22
  # - the app runs FIRST, so the beacon carries the real status and
23
23
  # duration; the visitor's user-agent, accept, and connecting IP are
24
24
  # forwarded for server-side classification;
25
+ # - the visitor's country rides along when the edge resolved it: the
26
+ # header Cloudflare, CloudFront, Vercel or App Engine sets, or
27
+ # +resolve_country+; a host whose edge sets none sends no country;
28
+ # - a ZeroClick agent-eval request's tag and signature are forwarded
29
+ # with the page's host, so the API can keep ZeroClick's own eval
30
+ # traffic out of the seller's numbers; a browser-based run, which
31
+ # cannot sign, is told by the +src+ marker on its start URL,
32
+ # forwarded with the host and the signature beside it;
25
33
  # - ANY beacon failure — missing key, network error, non-204, timeout,
26
34
  # a bad resolve_url or clock — is handed to +on_error+ and swallowed.
27
35
  # The middleware must never break or alter the customer's site.
@@ -43,6 +51,27 @@ module ZeroClick
43
51
  # duration. Injectable so a test can make both deterministic.
44
52
  DEFAULT_CLOCK = -> { Process.clock_gettime(Process::CLOCK_REALTIME, :float_millisecond) }
45
53
 
54
+ # ZeroClick's own start marker: the one query value the middleware
55
+ # forwards. A browser-based eval run's requests leave from the agent
56
+ # vendor's cloud, out of reach of the signed tag, so agent-eval marks
57
+ # its start URL as ?src=ze-<experiment>-<n> instead, and the API keeps
58
+ # the whole visit out of the seller's numbers from it.
59
+ EVAL_SOURCE_PREFIX = "ze-"
60
+
61
+ # The vendor request headers (as Rack env keys) that carry the
62
+ # visitor's country as the edge resolved it, outermost layer first: a
63
+ # CDN in front of a platform sees the true client, while the platform
64
+ # behind it geolocates the CDN's edge, so the outer header wins when
65
+ # both are present. Each is the vendor's documented header, forwarded
66
+ # raw; the API clamps it to a 2-letter code and drops the vendors'
67
+ # unknown sentinels (Cloudflare XX, App Engine ZZ).
68
+ COUNTRY_HEADERS = %w[
69
+ HTTP_CF_IPCOUNTRY
70
+ HTTP_CLOUDFRONT_VIEWER_COUNTRY
71
+ HTTP_X_VERCEL_IP_COUNTRY
72
+ HTTP_X_APPENGINE_COUNTRY
73
+ ].freeze
74
+
46
75
  # +seller+ may be a client, a callable returning one, or omitted to
47
76
  # resolve the process-wide ZeroClick::Sellers.seller on the first
48
77
  # request (the Rails path — see Base#initialize for why).
@@ -51,15 +80,21 @@ module ZeroClick
51
80
  # by the wire contract). +clock+ returns epoch milliseconds.
52
81
  # +resolve_url+ is a callable taking the Rack env and returning the
53
82
  # 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)
83
+ # the host the runtime sees; its path is reported and, with an eval
84
+ # tag, its host. +on_error+ observes the fire-and-forget
85
+ # path; it is never re-raised. +resolve_country+ is a callable taking
86
+ # the Rack env and returning the visitor's country, replacing the
87
+ # vendor-header read (COUNTRY_HEADERS) with the seller's own source:
88
+ # exactly one header, or a runtime that exposes the country another
89
+ # way. Return nil for unknown.
90
+ def initialize(app, seller_id:, seller: nil, clock: nil, resolve_url: nil, on_error: nil, resolve_country: nil)
57
91
  @app = app
58
92
  @seller_source = seller
59
93
  @seller_id = seller_id
60
94
  @clock = clock || DEFAULT_CLOCK
61
95
  @resolve_url = resolve_url
62
96
  @on_error = on_error
97
+ @resolve_country = resolve_country
63
98
  end
64
99
 
65
100
  def call(env)
@@ -86,7 +121,8 @@ module ZeroClick
86
121
  return unless env["REQUEST_METHOD"] == "GET"
87
122
 
88
123
  url = @resolve_url ? @resolve_url.call(env) : Agentify.public_url(env)
89
- path = URI.parse(url.to_s).path
124
+ uri = URI.parse(url.to_s)
125
+ path = uri.path
90
126
  path = "/" if path.nil? || path.empty?
91
127
  return if ASSET_PATH.match?(path)
92
128
 
@@ -100,12 +136,69 @@ module ZeroClick
100
136
  user_agent: env["HTTP_USER_AGENT"],
101
137
  accept: env["HTTP_ACCEPT"],
102
138
  client_ip: client_ip_of(env),
103
- country: env["HTTP_CF_IPCOUNTRY"],
139
+ country: country_of(env),
104
140
  referrer_host: referrer_host_of(env),
105
- journey_id: journey_id_of(headers)
141
+ journey_id: journey_id_of(headers),
142
+ **start_marker_of(uri),
143
+ **eval_run_of(env, uri)
106
144
  )
107
145
  end
108
146
 
147
+ # ZeroClick's own agent-eval run, when the visitor carried its tag: the
148
+ # tag, the signature agent-eval puts on a seller's site (under
149
+ # ZeroClick-only header names, never Web Bot Auth's) and the public
150
+ # host it was signed for. Forwarded as sent; the API labels the view
151
+ # only when the signature verifies, so a forged tag labels nothing. A
152
+ # value outside what the beacon accepts (empty, or longer than
153
+ # agent-eval ever sends) is left out rather than sent, so a visitor's
154
+ # odd header can never cost the page view itself.
155
+ def eval_run_of(env, uri)
156
+ tag = capped(env["HTTP_X_ZEROCLICK_EVAL"], 512)
157
+ return {} if tag.nil?
158
+
159
+ {
160
+ host: capped(host_of(uri), 300),
161
+ eval_tag: tag,
162
+ eval_signature: capped(env["HTTP_X_ZEROCLICK_EVAL_SIGNATURE"], 1_024),
163
+ eval_signature_input: capped(env["HTTP_X_ZEROCLICK_EVAL_SIGNATURE_INPUT"], 1_024),
164
+ eval_signature_agent: capped(env["HTTP_X_ZEROCLICK_EVAL_SIGNATURE_AGENT"], 512)
165
+ }
166
+ end
167
+
168
+ def capped(value, max)
169
+ value && !value.empty? && value.length <= max ? value : nil
170
+ end
171
+
172
+ # The public host the page was requested on, as the JS SDK's URL.host
173
+ # reads it: the host and any non-default port.
174
+ def host_of(uri)
175
+ return nil if uri.host.nil? || uri.host.empty?
176
+
177
+ uri.port && uri.port != uri.default_port ? "#{uri.host}:#{uri.port}" : uri.host
178
+ end
179
+
180
+ # ZeroClick's own start marker, keyed like report_page_view: empty
181
+ # without one. The +src+ query value when it is ours, the page's host
182
+ # and the +signature+ agent-eval puts beside the marker, as sent: the
183
+ # only query values the beacon carries. Any other +src+ stays on the
184
+ # site, and a marker or signature past what the beacon accepts (or a
185
+ # query that does not decode) is left out rather than sent.
186
+ def start_marker_of(uri)
187
+ return {} if uri.query.nil? || uri.query.empty?
188
+
189
+ values = URI.decode_www_form(uri.query)
190
+ src = values.assoc("src")&.last
191
+ return {} unless src&.start_with?(EVAL_SOURCE_PREFIX) && src.length <= 32
192
+
193
+ {
194
+ host: capped(host_of(uri), 300),
195
+ source: src,
196
+ source_signature: capped(values.assoc("signature")&.last, 256)
197
+ }
198
+ rescue ArgumentError
199
+ {}
200
+ end
201
+
109
202
  # What the app served for this page-content request, read off the
110
203
  # response content-type so it reflects the real representation
111
204
  # regardless of what produced it.
@@ -134,6 +227,20 @@ module ZeroClick
134
227
  first.empty? ? nil : first
135
228
  end
136
229
 
230
+ # The visitor's country as the edge resolved it: the first present of
231
+ # COUNTRY_HEADERS, or whatever +resolve_country+ returns. nil when the
232
+ # edge set none of them, so a host without one sends no country rather
233
+ # than a guess; an empty value is left out the same way.
234
+ def country_of(env)
235
+ value = if @resolve_country
236
+ @resolve_country.call(env)
237
+ else
238
+ COUNTRY_HEADERS.lazy.map { |key| env[key] }.find { |v| v && !v.strip.empty? }
239
+ end
240
+ value = value&.strip
241
+ value.nil? || value.empty? ? nil : value
242
+ end
243
+
137
244
  # The referrer reduced to its host at the edge, so the full referrer
138
245
  # (its path and query, which can carry identifying values) never leaves
139
246
  # the site. A malformed referrer yields no host rather than the full URL.
@@ -146,11 +253,7 @@ module ZeroClick
146
253
  rescue URI::InvalidURIError
147
254
  nil
148
255
  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
256
+ uri && host_of(uri)
154
257
  end
155
258
 
156
259
  # Case-insensitive lookup over the app's response headers.
@@ -27,9 +27,15 @@ module ZeroClick
27
27
  # Build the wire body: camelCase string keys, every absent optional key
28
28
  # omitted. +seller+ is the seller's public id and +path+ must be an
29
29
  # absolute path, both rejected before any request leaves the process.
30
+ # +host+ and the +eval_*+ values describe ZeroClick's own agent-eval run
31
+ # when the visitor carried its tag: the public host the run signed for
32
+ # and the tag with its signature, as sent. The API labels the view as
33
+ # eval traffic only when the signature verifies.
30
34
  def build_payload(seller:, path:, status: nil, duration_ms: nil, representation: nil,
31
35
  journey_id: nil, captured_at: nil, user_agent: nil, accept: nil,
32
- client_ip: nil, country: nil, referrer_host: nil)
36
+ client_ip: nil, country: nil, referrer_host: nil, host: nil,
37
+ eval_tag: nil, eval_signature: nil, eval_signature_input: nil,
38
+ eval_signature_agent: nil, source: nil, source_signature: nil)
33
39
  Sellers.require_slug!(seller, "seller", "report_page_view")
34
40
  unless path.is_a?(String) && path.start_with?("/")
35
41
  raise Error.new("malformed_input", operation: "report_page_view",
@@ -51,6 +57,13 @@ module ZeroClick
51
57
  payload["clientIp"] = client_ip unless client_ip.nil?
52
58
  payload["country"] = country unless country.nil?
53
59
  payload["referrerHost"] = referrer_host unless referrer_host.nil?
60
+ payload["host"] = host unless host.nil?
61
+ payload["evalTag"] = eval_tag unless eval_tag.nil?
62
+ payload["evalSignature"] = eval_signature unless eval_signature.nil?
63
+ payload["evalSignatureInput"] = eval_signature_input unless eval_signature_input.nil?
64
+ payload["evalSignatureAgent"] = eval_signature_agent unless eval_signature_agent.nil?
65
+ payload["source"] = source unless source.nil?
66
+ payload["sourceSignature"] = source_signature unless source_signature.nil?
54
67
  payload
55
68
  end
56
69
 
@@ -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.6.0"
7
+ VERSION = "0.8.0"
8
8
  end
9
9
  end
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.6.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ZeroClick
@@ -15,7 +15,7 @@ description: |
15
15
  and report what was used. Ships Rack middleware, so it works in Rails,
16
16
  Sinatra, Hanami and Roda alike.
17
17
  email:
18
- - support@zeroclick.ai
18
+ - help@zeroclick.ai
19
19
  executables: []
20
20
  extensions: []
21
21
  extra_rdoc_files: []