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.
- checksums.yaml +7 -0
- data/LICENSE +203 -0
- data/README.md +195 -0
- data/lib/zeroclick/sellers/client.rb +199 -0
- data/lib/zeroclick/sellers/contracts.rb +254 -0
- data/lib/zeroclick/sellers/errors.rb +48 -0
- data/lib/zeroclick/sellers/middleware.rb +247 -0
- data/lib/zeroclick/sellers/railtie.rb +46 -0
- data/lib/zeroclick/sellers/responses.rb +62 -0
- data/lib/zeroclick/sellers/usage.rb +154 -0
- data/lib/zeroclick/sellers/verify.rb +177 -0
- data/lib/zeroclick/sellers/version.rb +9 -0
- data/lib/zeroclick/sellers.rb +74 -0
- metadata +58 -0
|
@@ -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
|