zeroclick-sellers 0.5.0 → 0.7.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: c4d8cedea5994f6b40e5d7505027dfe9bf1fa9df5dfe32863070534bee183859
4
- data.tar.gz: ff203775e81db08e529b7fe0908da260c3142b7e1a82783f9eaac4cf3624fdcd
3
+ metadata.gz: 27bd4f5d8f10f026b12a89ee8681fef45ffbf58129df180f40903a55ec9743d3
4
+ data.tar.gz: 1ea73b94f08215d28c2974ac4937bdc423aa2f52a66c688a5746656ad837c8f2
5
5
  SHA512:
6
- metadata.gz: ccdf34f7bfccb29b898b0e71212b4573124c58b852417a883a812d24520a776710a73a1c46c2a5741e85bbad2760bc20dfd97047639a7e59e5623884937cc778
7
- data.tar.gz: 0ddb34edef539de4e5cbdd1d78747e6424bf4f95db680fde8dbf1cb7db2492b350711ee83c93653ca0df7edf64e96ab57174587c22cf881c579e46f8c6e92c78
6
+ metadata.gz: 73ef4fc05515a9d11212ca90c7df1c9f97c157bdc2628c4b1bb81738849552fbd7aca334986fc60f1c4d0d5cb6ca311b7663c453c1633a756950e82af4f06b42
7
+ data.tar.gz: 00dbf768601b7808759518ad6a507e0d238f4c6c65aecdd91ce5106488841a19538f5ea49ea5df93e776a049330f32f834d7d9216dd0ce58b212bf6da24e1748
@@ -54,13 +54,19 @@ module ZeroClick
54
54
  # The converted document plus the response headers worth forwarding to
55
55
  # the agent — the customer's CDN caches the markdown too.
56
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)
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)
60
65
  @markdown = markdown
61
66
  @cache_control = cache_control
62
67
  @content_type = content_type
63
68
  @etag = etag
69
+ @zcj_id = zcj_id
64
70
  freeze
65
71
  end
66
72
  end
@@ -164,7 +170,8 @@ module ZeroClick
164
170
  markdown: body,
165
171
  cache_control: headers["cache-control"],
166
172
  content_type: headers["content-type"],
167
- etag: headers["etag"]
173
+ etag: headers["etag"],
174
+ zcj_id: headers["x-zcj-id"]
168
175
  )
169
176
  end
170
177
 
@@ -3,6 +3,7 @@
3
3
  require_relative "agentify"
4
4
  require_relative "contracts"
5
5
  require_relative "errors"
6
+ require_relative "page_views"
6
7
  require_relative "responses"
7
8
  require_relative "usage"
8
9
  require_relative "verify"
@@ -27,6 +28,7 @@ module ZeroClick
27
28
  usage_read_key: nil,
28
29
  usage_write_key: nil,
29
30
  agentify_key: nil,
31
+ page_views_key: nil,
30
32
  signing_secrets: nil,
31
33
  resolve_signing_secret: nil,
32
34
  api_base_url: DEFAULT_API_BASE_URL,
@@ -34,6 +36,7 @@ module ZeroClick
34
36
  allowance_unavailable_policy: "allow",
35
37
  check_timeout_seconds: DEFAULT_CHECK_TIMEOUT_SECONDS,
36
38
  agentify_timeout_seconds: DEFAULT_AGENTIFY_TIMEOUT_SECONDS,
39
+ page_view_timeout_seconds: DEFAULT_PAGE_VIEW_TIMEOUT_SECONDS,
37
40
  on_allowance_unavailable: nil,
38
41
  clock: -> { Time.now.to_i },
39
42
  # The transports, injectable so every decision this class
@@ -44,11 +47,12 @@ module ZeroClick
44
47
  http_get: Agentify.method(:get))
45
48
  usage_read_key ||= api_key
46
49
  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.
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.
51
54
  agentify_key ||= api_key
55
+ page_views_key ||= api_key
52
56
 
53
57
  if usage_read_key.nil? || usage_read_key.empty?
54
58
  raise Error.new("malformed_input", operation: "create",
@@ -74,6 +78,7 @@ module ZeroClick
74
78
  @usage_read_key = usage_read_key
75
79
  @usage_write_key = usage_write_key
76
80
  @agentify_key = agentify_key
81
+ @page_views_key = page_views_key
77
82
  @signing_secrets = signing_secrets&.to_h&.freeze
78
83
  @resolve_signing_secret = resolve_signing_secret
79
84
  @api_base_url = api_base_url
@@ -81,6 +86,7 @@ module ZeroClick
81
86
  @policy = allowance_unavailable_policy.to_s
82
87
  @timeout = check_timeout_seconds
83
88
  @agentify_timeout = agentify_timeout_seconds
89
+ @page_view_timeout = page_view_timeout_seconds
84
90
  @on_allowance_unavailable = on_allowance_unavailable
85
91
  @clock = clock
86
92
  @http_post = http_post
@@ -173,6 +179,45 @@ module ZeroClick
173
179
  Agentify.interpret_markdown(status, body, headers)
174
180
  end
175
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, host: nil,
195
+ eval_tag: nil, eval_signature: nil, eval_signature_input: nil,
196
+ eval_signature_agent: nil, source: nil, source_signature: nil)
197
+ if @page_views_key.nil? || @page_views_key.empty?
198
+ raise Error.new("page_views_not_configured", operation: "report_page_view")
199
+ end
200
+
201
+ payload = PageViews.build_payload(
202
+ seller: seller, path: path, status: status, duration_ms: duration_ms,
203
+ representation: representation, journey_id: journey_id, captured_at: captured_at,
204
+ user_agent: user_agent, accept: accept, client_ip: client_ip,
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
209
+ )
210
+ status_code, body = @http_post.call(
211
+ base_url: @api_base_url,
212
+ path: PageViews::REPORT_PATH,
213
+ api_key: @page_views_key,
214
+ payload: payload,
215
+ timeout: @page_view_timeout,
216
+ operation: "report_page_view"
217
+ )
218
+ PageViews.interpret_report(status_code, body)
219
+ end
220
+
176
221
  def check_allowance(zc_request_id:, service_slug:, usage:)
177
222
  status, body = @http_post.call(
178
223
  base_url: @api_base_url,
@@ -13,6 +13,9 @@ module ZeroClick
13
13
  # optional browser render, LLM cleanup), so agentify calls get a larger
14
14
  # timeout default than the allowance check.
15
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
16
19
 
17
20
  # Reasons the allowance API gives for refusing. Any other reason on the wire
18
21
  # is treated as a malformed response rather than silently passed through.
@@ -14,6 +14,8 @@ module ZeroClick
14
14
  "api_status_error" => "The ZeroClick API returned an unsuccessful status",
15
15
  "api_transport_error" => "The ZeroClick API request failed",
16
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)",
17
19
  "signing_secret_resolution_failed" => "The signing secret could not be resolved"
18
20
  }.freeze
19
21
 
@@ -72,6 +72,12 @@ module ZeroClick
72
72
  }
73
73
  headers["cache-control"] = result.cache_control unless result.cache_control.nil?
74
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
75
81
  [200, headers, [result.markdown]]
76
82
  end
77
83
 
@@ -0,0 +1,244 @@
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
+ # - a ZeroClick agent-eval request's tag and signature are forwarded
26
+ # with the page's host, so the API can keep ZeroClick's own eval
27
+ # traffic out of the seller's numbers; a browser-based run, which
28
+ # cannot sign, is told by the +src+ marker on its start URL,
29
+ # forwarded with the host and the signature beside it;
30
+ # - ANY beacon failure — missing key, network error, non-204, timeout,
31
+ # a bad resolve_url or clock — is handed to +on_error+ and swallowed.
32
+ # The middleware must never break or alter the customer's site.
33
+ #
34
+ # Compose it as the OUTER middleware around Agentify — mount PageViews
35
+ # before Agentify — so the agent-markdown variant's re-exposed x-zcj-id is
36
+ # visible on the response and stitches the view onto the same journey.
37
+ #
38
+ # Like Agentify it never reads the request body, so no buffering ceiling
39
+ # applies.
40
+ class PageViews
41
+ # A page-content request has no asset extension on its final path
42
+ # segment. This skips /_astro/app.css, /favicon.ico, /logo.png and the
43
+ # like while still reporting /pricing, /blog/post, and / for both
44
+ # browsers and agents.
45
+ ASSET_PATH = /\.[a-z0-9]+$/i
46
+
47
+ # Wall clock in epoch milliseconds, for the observation time and the
48
+ # duration. Injectable so a test can make both deterministic.
49
+ DEFAULT_CLOCK = -> { Process.clock_gettime(Process::CLOCK_REALTIME, :float_millisecond) }
50
+
51
+ # ZeroClick's own start marker: the one query value the middleware
52
+ # forwards. A browser-based eval run's requests leave from the agent
53
+ # vendor's cloud, out of reach of the signed tag, so agent-eval marks
54
+ # its start URL as ?src=ze-<experiment>-<n> instead, and the API keeps
55
+ # the whole visit out of the seller's numbers from it.
56
+ EVAL_SOURCE_PREFIX = "ze-"
57
+
58
+ # +seller+ may be a client, a callable returning one, or omitted to
59
+ # resolve the process-wide ZeroClick::Sellers.seller on the first
60
+ # request (the Rails path — see Base#initialize for why).
61
+ #
62
+ # +seller_id+ is the seller's public id, sent in every beacon (required
63
+ # by the wire contract). +clock+ returns epoch milliseconds.
64
+ # +resolve_url+ is a callable taking the Rack env and returning the
65
+ # public URL of the request, for servers behind a proxy that rewrites
66
+ # the host the runtime sees; its path is reported and, with an eval
67
+ # tag, its host. +on_error+ observes the fire-and-forget
68
+ # path; it is never re-raised.
69
+ def initialize(app, seller_id:, seller: nil, clock: nil, resolve_url: nil, on_error: nil)
70
+ @app = app
71
+ @seller_source = seller
72
+ @seller_id = seller_id
73
+ @clock = clock || DEFAULT_CLOCK
74
+ @resolve_url = resolve_url
75
+ @on_error = on_error
76
+ end
77
+
78
+ def call(env)
79
+ started_at = @clock.call
80
+ status, headers, body = @app.call(env)
81
+
82
+ # The whole observation path is wrapped: a bad resolve_url, an
83
+ # unparseable URL, a misbehaving clock, or the beacon delivery itself
84
+ # must never break the page. Every failure — synchronous or from the
85
+ # blocking beacon — is routed to on_error, and the app's response is
86
+ # returned untouched regardless.
87
+ begin
88
+ observe(env, started_at: started_at, status: status, headers: headers)
89
+ rescue StandardError => e
90
+ @on_error&.call(e, env)
91
+ end
92
+
93
+ [status, headers, body]
94
+ end
95
+
96
+ private
97
+
98
+ def observe(env, started_at:, status:, headers:)
99
+ return unless env["REQUEST_METHOD"] == "GET"
100
+
101
+ url = @resolve_url ? @resolve_url.call(env) : Agentify.public_url(env)
102
+ uri = URI.parse(url.to_s)
103
+ path = uri.path
104
+ path = "/" if path.nil? || path.empty?
105
+ return if ASSET_PATH.match?(path)
106
+
107
+ seller.report_page_view(
108
+ seller: @seller_id,
109
+ path: path,
110
+ status: status.to_i,
111
+ duration_ms: [0, (@clock.call - started_at).round].max,
112
+ representation: representation_of(headers),
113
+ captured_at: Time.at(started_at / 1000.0).utc.iso8601(3),
114
+ user_agent: env["HTTP_USER_AGENT"],
115
+ accept: env["HTTP_ACCEPT"],
116
+ client_ip: client_ip_of(env),
117
+ country: env["HTTP_CF_IPCOUNTRY"],
118
+ referrer_host: referrer_host_of(env),
119
+ journey_id: journey_id_of(headers),
120
+ **start_marker_of(uri),
121
+ **eval_run_of(env, uri)
122
+ )
123
+ end
124
+
125
+ # ZeroClick's own agent-eval run, when the visitor carried its tag: the
126
+ # tag, the signature agent-eval puts on a seller's site (under
127
+ # ZeroClick-only header names, never Web Bot Auth's) and the public
128
+ # host it was signed for. Forwarded as sent; the API labels the view
129
+ # only when the signature verifies, so a forged tag labels nothing. A
130
+ # value outside what the beacon accepts (empty, or longer than
131
+ # agent-eval ever sends) is left out rather than sent, so a visitor's
132
+ # odd header can never cost the page view itself.
133
+ def eval_run_of(env, uri)
134
+ tag = capped(env["HTTP_X_ZEROCLICK_EVAL"], 512)
135
+ return {} if tag.nil?
136
+
137
+ {
138
+ host: capped(host_of(uri), 300),
139
+ eval_tag: tag,
140
+ eval_signature: capped(env["HTTP_X_ZEROCLICK_EVAL_SIGNATURE"], 1_024),
141
+ eval_signature_input: capped(env["HTTP_X_ZEROCLICK_EVAL_SIGNATURE_INPUT"], 1_024),
142
+ eval_signature_agent: capped(env["HTTP_X_ZEROCLICK_EVAL_SIGNATURE_AGENT"], 512)
143
+ }
144
+ end
145
+
146
+ def capped(value, max)
147
+ value && !value.empty? && value.length <= max ? value : nil
148
+ end
149
+
150
+ # The public host the page was requested on, as the JS SDK's URL.host
151
+ # reads it: the host and any non-default port.
152
+ def host_of(uri)
153
+ return nil if uri.host.nil? || uri.host.empty?
154
+
155
+ uri.port && uri.port != uri.default_port ? "#{uri.host}:#{uri.port}" : uri.host
156
+ end
157
+
158
+ # ZeroClick's own start marker, keyed like report_page_view: empty
159
+ # without one. The +src+ query value when it is ours, the page's host
160
+ # and the +signature+ agent-eval puts beside the marker, as sent: the
161
+ # only query values the beacon carries. Any other +src+ stays on the
162
+ # site, and a marker or signature past what the beacon accepts (or a
163
+ # query that does not decode) is left out rather than sent.
164
+ def start_marker_of(uri)
165
+ return {} if uri.query.nil? || uri.query.empty?
166
+
167
+ values = URI.decode_www_form(uri.query)
168
+ src = values.assoc("src")&.last
169
+ return {} unless src&.start_with?(EVAL_SOURCE_PREFIX) && src.length <= 32
170
+
171
+ {
172
+ host: capped(host_of(uri), 300),
173
+ source: src,
174
+ source_signature: capped(values.assoc("signature")&.last, 256)
175
+ }
176
+ rescue ArgumentError
177
+ {}
178
+ end
179
+
180
+ # What the app served for this page-content request, read off the
181
+ # response content-type so it reflects the real representation
182
+ # regardless of what produced it.
183
+ def representation_of(headers)
184
+ header_value(headers, "content-type").to_s.include?("markdown") ? "markdown" : "html"
185
+ end
186
+
187
+ # The journey id an upstream Agentify conversion minted for this same
188
+ # request, re-exposed by Middleware::Agentify. Present only for the
189
+ # agent-markdown variant; omitted otherwise.
190
+ def journey_id_of(headers)
191
+ header_value(headers, "x-zcj-id")
192
+ end
193
+
194
+ # The forwarded client IP, from the edge's connecting-IP headers. The
195
+ # API turns it into the viewer id and never stores it raw; nil when the
196
+ # runtime exposes no client address.
197
+ def client_ip_of(env)
198
+ direct = env["HTTP_CF_CONNECTING_IP"] || env["HTTP_X_REAL_IP"]
199
+ return direct.strip if direct && !direct.strip.empty?
200
+
201
+ forwarded = env["HTTP_X_FORWARDED_FOR"]
202
+ return nil if forwarded.nil?
203
+
204
+ first = forwarded.split(",").first.to_s.strip
205
+ first.empty? ? nil : first
206
+ end
207
+
208
+ # The referrer reduced to its host at the edge, so the full referrer
209
+ # (its path and query, which can carry identifying values) never leaves
210
+ # the site. A malformed referrer yields no host rather than the full URL.
211
+ def referrer_host_of(env)
212
+ referer = env["HTTP_REFERER"]
213
+ return nil if referer.nil? || referer.empty?
214
+
215
+ uri = begin
216
+ URI.parse(referer)
217
+ rescue URI::InvalidURIError
218
+ nil
219
+ end
220
+ uri && host_of(uri)
221
+ end
222
+
223
+ # Case-insensitive lookup over the app's response headers.
224
+ def header_value(headers, name)
225
+ target = name.downcase
226
+ headers.each { |key, value| return value if key.to_s.downcase == target }
227
+ nil
228
+ end
229
+
230
+ # Resolved once, on first use rather than at wire-up, exactly as
231
+ # Base#seller does — see that comment for the Rails ordering.
232
+ def seller
233
+ @seller ||= if @seller_source.nil?
234
+ Sellers.seller
235
+ elsif @seller_source.respond_to?(:call)
236
+ @seller_source.call
237
+ else
238
+ @seller_source
239
+ end
240
+ end
241
+ end
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,80 @@
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
+ # +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.
34
+ def build_payload(seller:, path:, status: nil, duration_ms: nil, representation: nil,
35
+ journey_id: nil, captured_at: nil, user_agent: nil, accept: 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)
39
+ Sellers.require_slug!(seller, "seller", "report_page_view")
40
+ unless path.is_a?(String) && path.start_with?("/")
41
+ raise Error.new("malformed_input", operation: "report_page_view",
42
+ message: "path must be a string beginning with \"/\"")
43
+ end
44
+ if !representation.nil? && !REPRESENTATIONS.include?(representation)
45
+ raise Error.new("malformed_input", operation: "report_page_view",
46
+ message: "representation must be \"html\" or \"markdown\"")
47
+ end
48
+
49
+ payload = { "seller" => seller, "path" => path }
50
+ payload["status"] = status unless status.nil?
51
+ payload["durationMs"] = duration_ms unless duration_ms.nil?
52
+ payload["representation"] = representation unless representation.nil?
53
+ payload["journeyId"] = journey_id unless journey_id.nil?
54
+ payload["capturedAt"] = captured_at unless captured_at.nil?
55
+ payload["userAgent"] = user_agent unless user_agent.nil?
56
+ payload["accept"] = accept unless accept.nil?
57
+ payload["clientIp"] = client_ip unless client_ip.nil?
58
+ payload["country"] = country unless country.nil?
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?
67
+ payload
68
+ end
69
+
70
+ # A 204 carries no body, so there is nothing to parse; a non-2xx is the
71
+ # only failure a well-formed beacon can hit here. Distinct from
72
+ # Usage.interpret_report, which requires a JSON body.
73
+ def interpret_report(status, _body)
74
+ return if (200..299).cover?(status)
75
+
76
+ raise Error.new("api_status_error", operation: "report_page_view", status: status)
77
+ end
78
+ end
79
+ end
80
+ 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.5.0"
7
+ VERSION = "0.7.0"
8
8
  end
9
9
  end
@@ -7,9 +7,11 @@ require_relative "sellers/responses"
7
7
  require_relative "sellers/verify"
8
8
  require_relative "sellers/usage"
9
9
  require_relative "sellers/agentify"
10
+ require_relative "sellers/page_views"
10
11
  require_relative "sellers/client"
11
12
  require_relative "sellers/middleware"
12
13
  require_relative "sellers/middleware/agentify"
14
+ require_relative "sellers/middleware/page_views"
13
15
 
14
16
  # The ZeroClick billing guard for Ruby backends.
15
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.5.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ZeroClick
@@ -30,6 +30,8 @@ files:
30
30
  - lib/zeroclick/sellers/errors.rb
31
31
  - lib/zeroclick/sellers/middleware.rb
32
32
  - lib/zeroclick/sellers/middleware/agentify.rb
33
+ - lib/zeroclick/sellers/middleware/page_views.rb
34
+ - lib/zeroclick/sellers/page_views.rb
33
35
  - lib/zeroclick/sellers/railtie.rb
34
36
  - lib/zeroclick/sellers/responses.rb
35
37
  - lib/zeroclick/sellers/stateful.rb