zeroclick-sellers 0.1.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.
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "errors"
6
+
7
+ module ZeroClick
8
+ module Sellers
9
+ DEFAULT_API_BASE_URL = "https://api.zeroclick.io"
10
+ DEFAULT_TOLERANCE_SECONDS = 300
11
+ DEFAULT_CHECK_TIMEOUT_SECONDS = 1.5
12
+
13
+ # Reasons the allowance API gives for refusing. Any other reason on the wire
14
+ # is treated as a malformed response rather than silently passed through.
15
+ USAGE_DENIAL_REASONS = %w[
16
+ service_not_found
17
+ access_not_found
18
+ access_inactive
19
+ plan_expired
20
+ meter_not_found
21
+ meter_not_priced
22
+ usage_exhausted
23
+ ].freeze
24
+
25
+ ALLOWANCE_UNAVAILABLE_POLICIES = %w[allow deny throw].freeze
26
+
27
+ # A framework-neutral view of an inbound request.
28
+ #
29
+ # The SDK never loads Rails, Sinatra or Rack — everything flows through
30
+ # Request and Response, and each adapter does the (small) translation. That
31
+ # is what keeps one implementation serving every Ruby web framework.
32
+ class Request
33
+ attr_reader :method, :path_and_query, :headers, :body
34
+
35
+ # +path_and_query+ must be the RAW, percent-encoded path and query exactly
36
+ # as it arrived. Frameworks that hand you a decoded path will break
37
+ # verification for any route containing an encoded character — see
38
+ # Middleware.path_and_query_from_env.
39
+ #
40
+ # +body+ must be the raw bytes as received. Decode nothing before verifying.
41
+ def initialize(method:, path_and_query:, headers: {}, body: "")
42
+ unless body.is_a?(String)
43
+ raise Error.new("malformed_input", operation: "Request",
44
+ message: "body must be a String of raw bytes; decode nothing before verifying")
45
+ end
46
+
47
+ @method = method
48
+ @path_and_query = path_and_query
49
+ @headers = headers.each_with_object({}) { |(k, v), out| out[k.to_s.downcase] = v }.freeze
50
+ # ASCII-8BIT so the SHA-256 below hashes bytes, never a re-encoding.
51
+ @body = body.dup.force_encoding(Encoding::BINARY).freeze
52
+ freeze
53
+ end
54
+
55
+ def header(name)
56
+ @headers[name.to_s.downcase]
57
+ end
58
+ end
59
+
60
+ # A framework-neutral response the caller is expected to return as-is.
61
+ class Response
62
+ attr_reader :status, :body, :headers
63
+
64
+ def initialize(status:, body:, headers: {})
65
+ @status = status
66
+ @body = body
67
+ @headers = headers.freeze
68
+ freeze
69
+ end
70
+
71
+ def self.json(payload, status: 200, headers: {})
72
+ new(
73
+ status: status,
74
+ # Compact separators, matching the other SDKs byte for byte.
75
+ body: JSON.generate(payload),
76
+ headers: { "content-type" => "application/json" }.merge(headers)
77
+ )
78
+ end
79
+
80
+ def json_body
81
+ JSON.parse(@body)
82
+ end
83
+ end
84
+
85
+ # Proven facts about a verified request.
86
+ ZeroClickContext = Struct.new(
87
+ :zc_request_id,
88
+ :zc_agent_id,
89
+ :timestamp,
90
+ :kid,
91
+ # Same value as zc_agent_id, read from the header that will replace
92
+ # zc-agent-id.
93
+ :zc_anonymous_id,
94
+ # The buyer that owns zc_agent_id, present only once claimed. Unlike the
95
+ # other three it is NOT covered by the signature.
96
+ :zc_buyer_id,
97
+ keyword_init: true
98
+ )
99
+
100
+ # -- verification results ------------------------------------------------
101
+
102
+ class VerifyOk
103
+ attr_reader :context
104
+
105
+ def initialize(context)
106
+ @context = context
107
+ freeze
108
+ end
109
+
110
+ def ok? = true
111
+ end
112
+
113
+ class VerifyFailure
114
+ attr_reader :reason, :response
115
+
116
+ def initialize(reason, response)
117
+ @reason = reason
118
+ @response = response
119
+ freeze
120
+ end
121
+
122
+ def ok? = false
123
+ end
124
+
125
+ # -- guard results -------------------------------------------------------
126
+
127
+ class Allow
128
+ attr_reader :context, :allowance
129
+
130
+ # +allowance+ is "allowed", "unavailable" or "not_required" — how the
131
+ # decision was reached, which matters when the outage policy is fail-open.
132
+ def initialize(context, allowance)
133
+ @context = context
134
+ @allowance = allowance
135
+ freeze
136
+ end
137
+
138
+ def allow? = true
139
+ end
140
+
141
+ class Deny
142
+ attr_reader :reason, :response
143
+
144
+ def initialize(reason, response)
145
+ @reason = reason
146
+ @response = response
147
+ freeze
148
+ end
149
+
150
+ def allow? = false
151
+ end
152
+
153
+ AllowanceDecision = Struct.new(:allowed, :reason, keyword_init: true) do
154
+ def allowed? = allowed
155
+ end
156
+
157
+ ReportUsageResult = Struct.new(:recorded, :duplicate, :usage_event, keyword_init: true) do
158
+ def recorded? = recorded
159
+ def duplicate? = duplicate
160
+ end
161
+
162
+ # What a request will be charged for.
163
+ #
164
+ # Exactly one of +quantity+ or +max_quantity+, or neither to defer to the
165
+ # meter's configured per-request ceiling. Declaring both is meaningless.
166
+ class UsageItem
167
+ attr_reader :meter_slug, :quantity, :max_quantity
168
+
169
+ def initialize(meter_slug:, quantity: nil, max_quantity: nil)
170
+ Sellers.require_slug!(meter_slug, "meter_slug", "UsageItem")
171
+ if !quantity.nil? && !max_quantity.nil?
172
+ raise Error.new("malformed_input", operation: "UsageItem",
173
+ message: "Provide at most one of quantity or max_quantity",
174
+ meter_slug: meter_slug)
175
+ end
176
+ Sellers.require_positive_integer!(quantity, "quantity", "UsageItem") unless quantity.nil?
177
+ Sellers.require_positive_integer!(max_quantity, "max_quantity", "UsageItem") unless max_quantity.nil?
178
+
179
+ @meter_slug = meter_slug
180
+ @quantity = quantity
181
+ @max_quantity = max_quantity
182
+ freeze
183
+ end
184
+
185
+ def to_wire
186
+ wire = { "meterSlug" => @meter_slug }
187
+ wire["quantity"] = @quantity unless @quantity.nil?
188
+ wire["maxQuantity"] = @max_quantity unless @max_quantity.nil?
189
+ wire
190
+ end
191
+ end
192
+
193
+ # Actual usage reported alongside a successful response.
194
+ class SyncUsageItem
195
+ attr_reader :service_slug, :meter_slug, :quantity
196
+
197
+ def initialize(service_slug:, meter_slug:, quantity:)
198
+ Sellers.require_slug!(service_slug, "service_slug", "SyncUsageItem")
199
+ Sellers.require_slug!(meter_slug, "meter_slug", "SyncUsageItem")
200
+ Sellers.require_positive_integer!(quantity, "quantity", "SyncUsageItem")
201
+
202
+ @service_slug = service_slug
203
+ @meter_slug = meter_slug
204
+ @quantity = quantity
205
+ freeze
206
+ end
207
+
208
+ def to_wire
209
+ { "serviceSlug" => @service_slug, "meterSlug" => @meter_slug, "quantity" => @quantity }
210
+ end
211
+ end
212
+
213
+ class << self
214
+ def require_slug!(value, field_name, operation)
215
+ return if value.is_a?(String) && !value.empty? && value == value.strip
216
+
217
+ raise Error.new("malformed_input", operation: operation,
218
+ message: "#{field_name} must be a non-empty string without surrounding whitespace")
219
+ end
220
+
221
+ def require_positive_integer!(value, field_name, operation)
222
+ # true is not an Integer in Ruby, so unlike Python there is no bool
223
+ # subclass to exclude here.
224
+ return if value.is_a?(Integer) && value.positive?
225
+
226
+ raise Error.new("malformed_input", operation: operation,
227
+ message: "#{field_name} must be a positive integer")
228
+ end
229
+
230
+ def normalize_usage(usage, operation:, allow_empty:)
231
+ items = Array(usage)
232
+ if items.empty? && !allow_empty
233
+ raise Error.new("malformed_input", operation: operation,
234
+ message: "usage must declare at least one meter")
235
+ end
236
+
237
+ seen = {}
238
+ items.each do |item|
239
+ unless item.is_a?(UsageItem)
240
+ raise Error.new("malformed_input", operation: operation,
241
+ message: "usage entries must be UsageItem instances")
242
+ end
243
+ if seen.key?(item.meter_slug)
244
+ raise Error.new("malformed_input", operation: operation,
245
+ message: "Duplicate meterSlug entries are not allowed",
246
+ meter_slug: item.meter_slug)
247
+ end
248
+ seen[item.meter_slug] = true
249
+ end
250
+ items.freeze
251
+ end
252
+ end
253
+ end
254
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZeroClick
4
+ module Sellers
5
+ # Verification outcomes are *values*, not exceptions — a request with a bad
6
+ # signature is an expected event, not a programming mistake. Error is
7
+ # reserved for what the caller got wrong (malformed input) or what the
8
+ # environment did (the ZeroClick API unreachable or answering nonsense).
9
+ class Error < StandardError
10
+ DEFAULT_MESSAGES = {
11
+ "api_response_invalid" => "The ZeroClick API response is malformed",
12
+ "api_status_error" => "The ZeroClick API returned an unsuccessful status",
13
+ "api_transport_error" => "The ZeroClick API request failed",
14
+ "malformed_input" => "The SDK input is malformed",
15
+ "signing_secret_resolution_failed" => "The signing secret could not be resolved"
16
+ }.freeze
17
+
18
+ # A stable machine code. Match on this, never on the message.
19
+ attr_reader :code, :operation, :context
20
+
21
+ def initialize(code, operation:, message: nil, **context)
22
+ code = code.to_s
23
+ super(message || DEFAULT_MESSAGES.fetch(code))
24
+ @code = code
25
+ @operation = operation
26
+ @context = { operation: operation }.merge(context).freeze
27
+ end
28
+
29
+ def inspect
30
+ "#<ZeroClick::Sellers::Error code=#{@code.inspect} context=#{@context.inspect}>"
31
+ end
32
+ end
33
+
34
+ # Errors meaning "the allowance API did not give us an answer", as opposed to
35
+ # "the allowance API said no". Only these are subject to the configured
36
+ # allowance-unavailable policy; anything else propagates.
37
+ ALLOWANCE_API_ERROR_CODES = %w[
38
+ api_transport_error
39
+ api_status_error
40
+ api_response_invalid
41
+ ].freeze
42
+
43
+ # True when +error+ is an SDK error, optionally of a specific code.
44
+ def self.error?(error, code = nil)
45
+ error.is_a?(Error) && (code.nil? || error.code == code.to_s)
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ require_relative "contracts"
6
+ require_relative "errors"
7
+ require_relative "responses"
8
+
9
+ module ZeroClick
10
+ module Sellers
11
+ # Rack middleware. Rails is a Rack app, so this covers Rails, Sinatra,
12
+ # Hanami, Roda and anything else Rack — one implementation, every framework.
13
+ #
14
+ # Deliberately named Middleware rather than Rack: a nested ::ZeroClick::Sellers::Rack
15
+ # would shadow the real ::Rack for every constant lookup inside this file.
16
+ #
17
+ # Nothing here requires the rack gem. A Rack middleware is duck-typed — an
18
+ # object with #call(env) returning [status, headers, body] — so the SDK adds
19
+ # no runtime dependency, and these are testable with a plain Hash.
20
+ module Middleware
21
+ # What a guarded request proved about its caller, in the Rack env:
22
+ #
23
+ # zc = env["zeroclick.context"]
24
+ # zc.zc_agent_id # the buyer
25
+ # zc.zc_request_id # correlates with ZeroClick's logs; use for idempotency
26
+ CONTEXT_ENV_KEY = "zeroclick.context"
27
+
28
+ # Bounds the request body a guarded endpoint will read.
29
+ #
30
+ # Verification covers the whole body, so the whole body must be held in
31
+ # memory to check the signature. Without a ceiling that is an
32
+ # unauthenticated memory DoS: the attacker does not need a valid signature
33
+ # to make us buffer.
34
+ DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024 # 10 MiB
35
+
36
+ # Characters legal unencoded in a path, which must survive a re-encode.
37
+ PATH_SAFE = %r{[^a-zA-Z0-9/~\-._!$&'()*+,;=:@%]}
38
+
39
+ # Servers that expose the raw request target set one of these. First match
40
+ # wins; each carries the full target, query string included.
41
+ #
42
+ # Puma sets REQUEST_URI. Rails copies it to ORIGINAL_FULLPATH.
43
+ RAW_TARGET_KEYS = %w[REQUEST_URI ORIGINAL_FULLPATH].freeze
44
+
45
+ module_function
46
+
47
+ # Recover the raw, percent-encoded request target from a Rack env.
48
+ #
49
+ # Prefers the server's raw target. Falls back to re-encoding PATH_INFO,
50
+ # which is exact for every character except an encoded separator: a server
51
+ # that decodes %2F to / before we are called leaves nothing downstream
52
+ # able to tell that apart from a literal /. Deploy behind Puma (which sets
53
+ # REQUEST_URI) if your routes can contain one.
54
+ def path_and_query_from_env(env)
55
+ RAW_TARGET_KEYS.each do |key|
56
+ raw = env[key]
57
+ next if raw.nil? || raw.empty?
58
+
59
+ # Some servers give absolute-form ("http://host/path?q"). The
60
+ # signature covers the path and query only.
61
+ return raw.sub(%r{\Ahttps?://[^/]+}, "")
62
+ end
63
+
64
+ path = "#{env["SCRIPT_NAME"]}#{env["PATH_INFO"]}"
65
+ encoded = path.gsub(PATH_SAFE) { |char| format("%%%02X", char.ord) }
66
+ query = env["QUERY_STRING"]
67
+ query.nil? || query.empty? ? encoded : "#{encoded}?#{query}"
68
+ end
69
+
70
+ # Build a Request from a Rack env, and leave rack.input readable by the
71
+ # app behind us.
72
+ #
73
+ # It REPLACES rack.input rather than rewinding it. Rack 3 dropped the
74
+ # requirement that input be rewindable — a streaming server's input is
75
+ # consumed once and #rewind either raises or silently does nothing, which
76
+ # hands the application an empty body. Since verification must read the
77
+ # whole body anyway, handing back a fresh stream over those same bytes is
78
+ # both correct and free.
79
+ #
80
+ # Returns [request, too_large].
81
+ def request_from_env(env, max_body_bytes: DEFAULT_MAX_BODY_BYTES)
82
+ headers = {}
83
+ env.each do |key, value|
84
+ next unless key.is_a?(String) && key.start_with?("HTTP_")
85
+
86
+ headers[key[5..].tr("_", "-").downcase] = value
87
+ end
88
+ headers["content-type"] = env["CONTENT_TYPE"] if env["CONTENT_TYPE"]
89
+ headers["content-length"] = env["CONTENT_LENGTH"] if env["CONTENT_LENGTH"]
90
+
91
+ input = env["rack.input"]
92
+ body = +""
93
+ if input
94
+ # Read one byte past the ceiling so an over-size body is detected
95
+ # rather than silently truncated into an invalid signature.
96
+ body = input.read(max_body_bytes + 1) || +""
97
+ body = body.dup.force_encoding(Encoding::BINARY)
98
+ env["rack.input"] = StringIO.new(body)
99
+ end
100
+
101
+ [
102
+ Request.new(
103
+ method: env["REQUEST_METHOD"] || "GET",
104
+ path_and_query: path_and_query_from_env(env),
105
+ headers: headers,
106
+ body: body
107
+ ),
108
+ body.bytesize > max_body_bytes
109
+ ]
110
+ end
111
+
112
+ # Shared wire-up. Subclasses decide what guarding means.
113
+ class Base
114
+ # +seller+ may be a client, a callable returning one, or omitted.
115
+ #
116
+ # Omitting it is the Rails path, and the reason it is optional at all:
117
+ # `config.middleware.use` evaluates its arguments in the Application
118
+ # class body, which runs BEFORE the Railtie initializer that reads
119
+ # `config.zeroclick`. Passing `seller: ZeroClick::Sellers.seller` there
120
+ # raises on boot, every time. Omit it and the process-wide client is
121
+ # resolved on the first request instead, by which point configuration
122
+ # exists.
123
+ def initialize(app, service_slug:, seller: nil, max_body_bytes: DEFAULT_MAX_BODY_BYTES)
124
+ @app = app
125
+ @seller_source = seller
126
+ @service_slug = service_slug
127
+ @max_body_bytes = max_body_bytes
128
+ end
129
+
130
+ def call(env)
131
+ request, too_large = Middleware.request_from_env(env, max_body_bytes: @max_body_bytes)
132
+ # Before verification on purpose: an unauthenticated caller must not be
133
+ # able to make us buffer more than the ceiling.
134
+ return to_rack(Response.json({ "error" => "payload_too_large" }, status: 413)) if too_large
135
+
136
+ decision = decide(request)
137
+ return to_rack(decision.response) unless decision.allow?
138
+
139
+ env[CONTEXT_ENV_KEY] = decision.context
140
+ respond(env, decision)
141
+ end
142
+
143
+ private
144
+
145
+ # Resolved once, on first use rather than at wire-up. See #initialize.
146
+ def seller
147
+ @seller ||= if @seller_source.nil?
148
+ Sellers.seller
149
+ elsif @seller_source.respond_to?(:call)
150
+ @seller_source.call
151
+ else
152
+ @seller_source
153
+ end
154
+ end
155
+
156
+ # Default: the guard decided nothing further is owed.
157
+ def respond(env, _decision)
158
+ @app.call(env)
159
+ end
160
+
161
+ def to_rack(response)
162
+ [response.status, response.headers.dup, [response.body]]
163
+ end
164
+ end
165
+
166
+ # Guards a billable endpoint: verifies the signature, confirms the buyer
167
+ # can pay, and — if the app responds 2xx — reports the usage.
168
+ #
169
+ # use ZeroClick::Sellers::Middleware::Meter,
170
+ # seller: SELLER, service_slug: "extractor",
171
+ # usage: [ZeroClick::Sellers::UsageItem.new(meter_slug: "requests", quantity: 1)]
172
+ #
173
+ # Inside the app, the request body still reads normally: verification
174
+ # consumes rack.input and this replaces it with a fresh stream over the
175
+ # same bytes.
176
+ #
177
+ # Every usage item must carry an explicit quantity, and it refuses the
178
+ # others at wire-up. Meter settles usage automatically from what it
179
+ # declared, so an item with only a max_quantity ceiling — or with neither
180
+ # quantity nor max_quantity — has no settled amount, and inventing one
181
+ # would silently mis-bill a delivered 200. That is the exact failure this
182
+ # SDK exists to prevent, so it fails when you build the middleware rather
183
+ # than in production: declare a fixed quantity here and report the
184
+ # variable part with Client#report_usage.
185
+ class Meter < Base
186
+ def initialize(app, service_slug:, usage:, seller: nil, plan_slug: nil,
187
+ max_body_bytes: DEFAULT_MAX_BODY_BYTES)
188
+ super(app, seller: seller, service_slug: service_slug, max_body_bytes: max_body_bytes)
189
+ @usage = Sellers.normalize_usage(usage, operation: "Middleware::Meter", allow_empty: false)
190
+ @plan_slug = plan_slug
191
+
192
+ # Meter settles usage from what it declared, so every item must carry
193
+ # a definite quantity. An item that does not — a max_quantity ceiling,
194
+ # or one that defers to the meter's configured per-request default —
195
+ # has no settled amount here, and inventing one would silently
196
+ # mis-bill a delivered 200. That is the exact failure this SDK exists
197
+ # to prevent, so it fails at wire-up rather than in production.
198
+ indefinite = @usage.find { |item| item.quantity.nil? }
199
+ return unless indefinite
200
+
201
+ raise Error.new("malformed_input", operation: "Middleware::Meter",
202
+ meter_slug: indefinite.meter_slug,
203
+ message: "Middleware::Meter needs an explicit quantity on every usage item, and " \
204
+ "#{indefinite.meter_slug} has none. It settles usage from what it " \
205
+ "declares, so an item carrying a max_quantity ceiling — or neither " \
206
+ "quantity nor max_quantity — has no settled amount to report. Declare a " \
207
+ "fixed quantity here and report the variable part with Client#report_usage.")
208
+ end
209
+
210
+ private
211
+
212
+ def decide(request)
213
+ seller.guard(request, service_slug: @service_slug, usage: @usage, plan_slug: @plan_slug)
214
+ end
215
+
216
+ def respond(env, _decision)
217
+ status, headers, body = @app.call(env)
218
+ # Only a delivered response is billed. A 4xx/5xx did not do the work.
219
+ return [status, headers, body] unless (200..299).cover?(status.to_i)
220
+
221
+ headers = headers.dup
222
+ headers["zc-usage"] = Responses.usage_header(
223
+ @usage.map do |item|
224
+ SyncUsageItem.new(
225
+ service_slug: @service_slug,
226
+ meter_slug: item.meter_slug,
227
+ # Never nil: the constructor refused every item without one.
228
+ quantity: item.quantity
229
+ )
230
+ end
231
+ )
232
+ [status, headers, body]
233
+ end
234
+ end
235
+
236
+ # Guards a free endpoint that must still know which buyer is calling — a
237
+ # limits or account route. Makes no network call.
238
+ class Identify < Base
239
+ private
240
+
241
+ def decide(request)
242
+ seller.guard_identity(request, service_slug: @service_slug)
243
+ end
244
+ end
245
+ end
246
+ end
247
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ require_relative "../sellers"
6
+
7
+ module ZeroClick
8
+ module Sellers
9
+ # Rails ergonomics on top of the Rack middleware.
10
+ #
11
+ # Deliberately thin, and deliberately NOT loaded by `require "zeroclick/sellers"`
12
+ # — the gem must stay usable from Sinatra, Hanami and plain Rack without
13
+ # Rails on the load path. Rails apps opt in:
14
+ #
15
+ # # config/application.rb
16
+ # require "zeroclick/sellers/railtie"
17
+ #
18
+ # config.zeroclick.api_key = ENV.fetch("ZEROCLICK_API_KEY")
19
+ # config.zeroclick.signing_secrets = ZeroClick::Sellers.secrets_from_env
20
+ #
21
+ # Then guard routes in the usual Rails way:
22
+ #
23
+ # Note there is no `seller:` — `config.middleware.use` evaluates its
24
+ # arguments in the Application class body, which runs BEFORE the
25
+ # initializer below, so no client exists yet. The middleware resolves one
26
+ # on the first request instead.
27
+ #
28
+ # config.middleware.use ZeroClick::Sellers::Middleware::Meter,
29
+ # service_slug: "extractor",
30
+ # usage: [ZeroClick::Sellers::UsageItem.new(meter_slug: "requests", quantity: 1)]
31
+ #
32
+ # Inside a controller, the verified caller is on the request env:
33
+ #
34
+ # zc = request.env["zeroclick.context"]
35
+ class Railtie < ::Rails::Railtie
36
+ config.zeroclick = ActiveSupport::OrderedOptions.new
37
+
38
+ # Hands the app's config to the gem. The client itself is built lazily by
39
+ # ZeroClick::Sellers.seller, so boot does not depend on credentials being
40
+ # present in, say, a test or asset-precompile environment.
41
+ initializer "zeroclick.seller" do |app|
42
+ ZeroClick::Sellers.configure(**app.config.zeroclick.to_h.compact)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "contracts"
6
+ require_relative "errors"
7
+
8
+ module ZeroClick
9
+ module Sellers
10
+ # The exact responses ZeroClick expects a seller to return.
11
+ module Responses
12
+ module_function
13
+
14
+ # The 402 body ZeroClick answers with a payment challenge.
15
+ #
16
+ # An empty +usage+ is meaningful, not a mistake: it is the free
17
+ # identity-scoped refusal, which the proxy answers with a $0 identity
18
+ # challenge and retries with zc-agent-id attached.
19
+ def payment_required(service_slug:, usage:, plan_slug: nil)
20
+ items = Sellers.normalize_usage(usage, operation: "payment_required", allow_empty: true)
21
+ body = { "error" => "payment_required", "serviceSlug" => service_slug }
22
+ body["planSlug"] = plan_slug unless plan_slug.nil?
23
+ # Key order matters only for readability; the proxy parses by name.
24
+ body["usage"] = items.map(&:to_wire)
25
+ Response.json(body, status: 402)
26
+ end
27
+
28
+ def invalid_zeroclick_signature
29
+ Response.json({ "error" => "invalid_zeroclick_signature" }, status: 401)
30
+ end
31
+
32
+ def allowance_unavailable
33
+ Response.json({ "error" => "allowance_unavailable" }, status: 503)
34
+ end
35
+
36
+ # Build the zc-usage header value.
37
+ #
38
+ # Returned as a string rather than applied to a response object, so each
39
+ # adapter can attach it to its own framework's response without the SDK
40
+ # ever having to reconstruct one.
41
+ def usage_header(usage)
42
+ items = Array(usage)
43
+ items.each do |item|
44
+ unless item.is_a?(SyncUsageItem)
45
+ raise Error.new("malformed_input", operation: "usage_header",
46
+ message: "usage entries must be SyncUsageItem instances")
47
+ end
48
+ end
49
+ JSON.generate(items.map(&:to_wire))
50
+ end
51
+
52
+ # Attach reported usage to a successful response.
53
+ def with_usage(response, usage)
54
+ Response.new(
55
+ status: response.status,
56
+ body: response.body,
57
+ headers: response.headers.merge("zc-usage" => usage_header(usage))
58
+ )
59
+ end
60
+ end
61
+ end
62
+ end