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,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
require_relative "contracts"
|
|
8
|
+
require_relative "errors"
|
|
9
|
+
|
|
10
|
+
module ZeroClick
|
|
11
|
+
module Sellers
|
|
12
|
+
# The two ZeroClick usage endpoints.
|
|
13
|
+
#
|
|
14
|
+
# Transport and interpretation are deliberately separate: the build_* and
|
|
15
|
+
# interpret_* functions are pure, so every decision about what a response
|
|
16
|
+
# means is testable without a socket, and the client cannot drift from them.
|
|
17
|
+
module Usage
|
|
18
|
+
CHECK_PATH = "/v1/usage/check"
|
|
19
|
+
REPORT_PATH = "/v1/usage"
|
|
20
|
+
|
|
21
|
+
# report_usage answers these with a typed denial reason rather than an
|
|
22
|
+
# opaque failure, so they are read as JSON instead of treated as noise.
|
|
23
|
+
TYPED_REPORT_STATUSES = [402, 404, 409].freeze
|
|
24
|
+
|
|
25
|
+
# Errors that mean the request never got an answer. Anything raised here
|
|
26
|
+
# that is NOT in this list is a bug in the SDK, not an outage, and must
|
|
27
|
+
# not be laundered into a fail-open allow.
|
|
28
|
+
TRANSPORT_ERRORS = [
|
|
29
|
+
IOError,
|
|
30
|
+
SocketError,
|
|
31
|
+
SystemCallError,
|
|
32
|
+
Timeout::Error,
|
|
33
|
+
Net::HTTPBadResponse,
|
|
34
|
+
Net::HTTPHeaderSyntaxError,
|
|
35
|
+
OpenSSL::SSL::SSLError
|
|
36
|
+
].freeze
|
|
37
|
+
|
|
38
|
+
module_function
|
|
39
|
+
|
|
40
|
+
def request_headers(api_key)
|
|
41
|
+
{
|
|
42
|
+
"accept" => "application/json",
|
|
43
|
+
"authorization" => "Bearer #{api_key}",
|
|
44
|
+
"content-type" => "application/json"
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# ------------------------------------------------------------- build
|
|
49
|
+
|
|
50
|
+
def build_check_payload(zc_request_id:, service_slug:, usage:)
|
|
51
|
+
items = Sellers.normalize_usage(usage, operation: "check_allowance", allow_empty: false)
|
|
52
|
+
{
|
|
53
|
+
"zcRequestId" => zc_request_id,
|
|
54
|
+
"serviceSlug" => service_slug,
|
|
55
|
+
"usage" => items.map(&:to_wire)
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def build_report_payload(zc_agent_id:, idempotency_key:, service_slug:, meter_slug:, quantity:,
|
|
60
|
+
occurred_at: nil)
|
|
61
|
+
payload = {
|
|
62
|
+
"zcAgentId" => zc_agent_id,
|
|
63
|
+
"idempotencyKey" => idempotency_key,
|
|
64
|
+
"serviceSlug" => service_slug,
|
|
65
|
+
"meterSlug" => meter_slug,
|
|
66
|
+
"quantity" => quantity
|
|
67
|
+
}
|
|
68
|
+
payload["occurredAt"] = occurred_at unless occurred_at.nil?
|
|
69
|
+
payload
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# --------------------------------------------------------- interpret
|
|
73
|
+
|
|
74
|
+
def decode(body, operation:, status:)
|
|
75
|
+
JSON.parse(body)
|
|
76
|
+
rescue JSON::ParserError
|
|
77
|
+
raise Error.new("api_response_invalid", operation: operation, status: status)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def interpret_check(status, body)
|
|
81
|
+
raise Error.new("api_status_error", operation: "check_allowance", status: status) if status >= 400
|
|
82
|
+
|
|
83
|
+
payload = decode(body, operation: "check_allowance", status: status)
|
|
84
|
+
unless payload.is_a?(Hash) && [true, false].include?(payload["allowed"])
|
|
85
|
+
raise Error.new("api_response_invalid", operation: "check_allowance", status: status)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
reason = payload["reason"]
|
|
89
|
+
if !reason.nil? && !USAGE_DENIAL_REASONS.include?(reason)
|
|
90
|
+
raise Error.new("api_response_invalid", operation: "check_allowance", status: status, reason: reason)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
AllowanceDecision.new(allowed: payload["allowed"], reason: reason)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def interpret_report(status, body)
|
|
97
|
+
if status >= 400
|
|
98
|
+
unless TYPED_REPORT_STATUSES.include?(status)
|
|
99
|
+
raise Error.new("api_status_error", operation: "report_usage", status: status)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
payload = decode(body, operation: "report_usage", status: status)
|
|
103
|
+
reason = payload.is_a?(Hash) ? payload["error"] : nil
|
|
104
|
+
unless USAGE_DENIAL_REASONS.include?(reason)
|
|
105
|
+
raise Error.new("api_response_invalid", operation: "report_usage", status: status)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
raise Error.new("api_status_error", operation: "report_usage", status: status, reason: reason)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
payload = decode(body, operation: "report_usage", status: status)
|
|
112
|
+
unless payload.is_a?(Hash) &&
|
|
113
|
+
payload["recorded"] == true &&
|
|
114
|
+
[true, false].include?(payload["duplicate"]) &&
|
|
115
|
+
payload["usageEvent"].is_a?(Hash)
|
|
116
|
+
raise Error.new("api_response_invalid", operation: "report_usage", status: status)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
ReportUsageResult.new(
|
|
120
|
+
recorded: true,
|
|
121
|
+
duplicate: payload["duplicate"],
|
|
122
|
+
usage_event: payload["usageEvent"]
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# --------------------------------------------------------- transport
|
|
127
|
+
|
|
128
|
+
# Returns [status, body]. Raises only api_transport_error — an HTTP status
|
|
129
|
+
# is data here, not a failure, and interpretation happens above.
|
|
130
|
+
def post(base_url:, path:, api_key:, payload:, timeout:, operation:)
|
|
131
|
+
uri = URI.join(base_url, path)
|
|
132
|
+
|
|
133
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
134
|
+
http.use_ssl = uri.scheme == "https"
|
|
135
|
+
# Both halves, or a server that accepts the connection and then stalls
|
|
136
|
+
# hangs the request past the caller's budget.
|
|
137
|
+
http.open_timeout = timeout
|
|
138
|
+
http.read_timeout = timeout
|
|
139
|
+
http.write_timeout = timeout
|
|
140
|
+
|
|
141
|
+
request = Net::HTTP::Post.new(uri)
|
|
142
|
+
request_headers(api_key).each { |name, value| request[name] = value }
|
|
143
|
+
request.body = JSON.generate(payload)
|
|
144
|
+
|
|
145
|
+
response = http.request(request)
|
|
146
|
+
[response.code.to_i, response.body.to_s]
|
|
147
|
+
rescue *TRANSPORT_ERRORS => e
|
|
148
|
+
raise Error.new("api_transport_error", operation: operation, cause: e.message)
|
|
149
|
+
ensure
|
|
150
|
+
http&.finish if http&.started?
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
require_relative "contracts"
|
|
6
|
+
require_relative "errors"
|
|
7
|
+
require_relative "responses"
|
|
8
|
+
|
|
9
|
+
module ZeroClick
|
|
10
|
+
module Sellers
|
|
11
|
+
# zc-signature verification. The algorithm is pinned by
|
|
12
|
+
# test/vectors/signing-vectors.json, which every SDK executes.
|
|
13
|
+
module Verify
|
|
14
|
+
# Bounded on purpose. Ruby's Integer() would happily convert an unbounded
|
|
15
|
+
# digit run, and a multi-megabyte one turns an unauthenticated request
|
|
16
|
+
# into a denial-of-service. 20 digits is far past any real epoch second and
|
|
17
|
+
# matches the other SDKs, which reject anything longer as malformed.
|
|
18
|
+
TIMESTAMP_RE = /\A\d{1,20}\z/
|
|
19
|
+
HEX_64_RE = /\A[0-9a-f]{64}\z/
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# Parse `t=<unix>,kid=<id>,v1=<64 lowercase hex>`.
|
|
24
|
+
#
|
|
25
|
+
# Unknown members are IGNORED, deliberately. The proxy extends this header
|
|
26
|
+
# additively — `sb=1` marks sandbox traffic today — while signing a
|
|
27
|
+
# canonical string that is byte-identical either way. Demanding exactly
|
|
28
|
+
# three members failed every sandbox request as malformed_signature, so a
|
|
29
|
+
# seller on this SDK could never finish sandbox testing.
|
|
30
|
+
#
|
|
31
|
+
# What prevents an attacker smuggling a second v1 is DUPLICATE rejection,
|
|
32
|
+
# not the member count. Each check below defends one property:
|
|
33
|
+
#
|
|
34
|
+
# - the `=` and empty-key checks reject a structurally malformed member;
|
|
35
|
+
# - the duplicate check means a key can only ever be bound once, so a
|
|
36
|
+
# later member cannot shadow an earlier one;
|
|
37
|
+
# - the three-key presence check is what the discarded member count only
|
|
38
|
+
# ever implied;
|
|
39
|
+
# - only those three are returned, so an unknown member never reaches the
|
|
40
|
+
# caller.
|
|
41
|
+
def parse_signature_header(value)
|
|
42
|
+
# -1 keeps trailing empty fields, so "t=1,kid=k,v1=x," stays malformed
|
|
43
|
+
# rather than silently losing its empty member.
|
|
44
|
+
entries = value.split(",", -1)
|
|
45
|
+
return nil if entries.length < 3
|
|
46
|
+
|
|
47
|
+
parsed = {}
|
|
48
|
+
entries.each do |entry|
|
|
49
|
+
separator = entry.index("=")
|
|
50
|
+
return nil if separator.nil? || separator.zero?
|
|
51
|
+
|
|
52
|
+
key = entry[0...separator].strip
|
|
53
|
+
return nil if key.empty?
|
|
54
|
+
return nil if parsed.key?(key)
|
|
55
|
+
|
|
56
|
+
parsed[key] = entry[(separator + 1)..].strip
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
return nil unless parsed.key?("t") && parsed.key?("kid") && parsed.key?("v1")
|
|
60
|
+
return nil unless TIMESTAMP_RE.match?(parsed["t"])
|
|
61
|
+
return nil unless HEX_64_RE.match?(parsed["v1"])
|
|
62
|
+
|
|
63
|
+
kid = parsed["kid"]
|
|
64
|
+
return nil if kid.empty? || kid != kid.strip
|
|
65
|
+
|
|
66
|
+
{ "t" => parsed["t"], "kid" => kid, "v1" => parsed["v1"] }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The exact bytes that get signed: six newline-joined fields.
|
|
70
|
+
def canonical_string(timestamp:, method:, path_and_query:, body:, zc_request_id:, zc_agent_id:)
|
|
71
|
+
[
|
|
72
|
+
timestamp,
|
|
73
|
+
method.upcase,
|
|
74
|
+
path_and_query,
|
|
75
|
+
OpenSSL::Digest::SHA256.hexdigest(body),
|
|
76
|
+
zc_request_id,
|
|
77
|
+
zc_agent_id || ""
|
|
78
|
+
].join("\n")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Verify a ZeroClick-signed request.
|
|
82
|
+
#
|
|
83
|
+
# Returns a value rather than raising: an unsigned or badly signed request
|
|
84
|
+
# is an expected event. VerifyFailure#response is ready to return as-is.
|
|
85
|
+
#
|
|
86
|
+
# A verified request whose zc_agent_id is nil is a signed anonymous probe,
|
|
87
|
+
# which is valid — not a failure.
|
|
88
|
+
#
|
|
89
|
+
# Provide exactly one of +signing_secrets+ (a Hash of kid => secret) or
|
|
90
|
+
# +resolve_signing_secret+ (a callable taking a kid, returning the secret
|
|
91
|
+
# or nil).
|
|
92
|
+
def call(request,
|
|
93
|
+
signing_secrets: nil,
|
|
94
|
+
resolve_signing_secret: nil,
|
|
95
|
+
tolerance_seconds: DEFAULT_TOLERANCE_SECONDS,
|
|
96
|
+
clock: -> { Time.now.to_i })
|
|
97
|
+
if signing_secrets.nil? == resolve_signing_secret.nil?
|
|
98
|
+
raise Error.new("malformed_input", operation: "verify_request",
|
|
99
|
+
message: "Provide exactly one of signing_secrets or resolve_signing_secret")
|
|
100
|
+
end
|
|
101
|
+
unless request.is_a?(Request)
|
|
102
|
+
raise Error.new("malformed_input", operation: "verify_request",
|
|
103
|
+
message: "request must be a ZeroClick::Sellers::Request")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
resolve = resolve_signing_secret || ->(kid) { (signing_secrets || {})[kid] }
|
|
107
|
+
|
|
108
|
+
signature_header = request.header("zc-signature")
|
|
109
|
+
return VerifyFailure.new("missing_signature", Responses.invalid_zeroclick_signature) if signature_header.nil?
|
|
110
|
+
|
|
111
|
+
signature = parse_signature_header(signature_header)
|
|
112
|
+
return VerifyFailure.new("malformed_signature", Responses.invalid_zeroclick_signature) if signature.nil?
|
|
113
|
+
|
|
114
|
+
timestamp = Integer(signature["t"], 10)
|
|
115
|
+
now_seconds = clock.call.to_i
|
|
116
|
+
if (now_seconds - timestamp).abs > tolerance_seconds
|
|
117
|
+
return VerifyFailure.new("stale_timestamp", Responses.invalid_zeroclick_signature)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
zc_request_id = request.header("zc-request-id")
|
|
121
|
+
if zc_request_id.nil? || zc_request_id.empty?
|
|
122
|
+
return VerifyFailure.new("missing_request_id", Responses.invalid_zeroclick_signature)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
zc_agent_id = request.header("zc-agent-id")
|
|
126
|
+
|
|
127
|
+
begin
|
|
128
|
+
secret = resolve.call(signature["kid"])
|
|
129
|
+
rescue StandardError => e
|
|
130
|
+
raise Error.new("signing_secret_resolution_failed", operation: "verify_request",
|
|
131
|
+
kid: signature["kid"], cause: e.message)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
return VerifyFailure.new("unknown_kid", Responses.invalid_zeroclick_signature) if secret.nil?
|
|
135
|
+
|
|
136
|
+
unless secret.is_a?(String) && !secret.empty?
|
|
137
|
+
raise Error.new("signing_secret_resolution_failed", operation: "verify_request",
|
|
138
|
+
kid: signature["kid"])
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
expected = OpenSSL::HMAC.hexdigest(
|
|
142
|
+
"SHA256",
|
|
143
|
+
secret,
|
|
144
|
+
canonical_string(
|
|
145
|
+
timestamp: signature["t"],
|
|
146
|
+
method: request.method,
|
|
147
|
+
path_and_query: request.path_and_query,
|
|
148
|
+
body: request.body,
|
|
149
|
+
zc_request_id: zc_request_id,
|
|
150
|
+
zc_agent_id: zc_agent_id
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Constant time: a byte-at-a-time compare leaks the signature prefix.
|
|
155
|
+
unless OpenSSL.secure_compare(expected, signature["v1"])
|
|
156
|
+
return VerifyFailure.new("invalid_signature", Responses.invalid_zeroclick_signature)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
VerifyOk.new(
|
|
160
|
+
ZeroClickContext.new(
|
|
161
|
+
zc_request_id: zc_request_id,
|
|
162
|
+
zc_agent_id: zc_agent_id,
|
|
163
|
+
timestamp: timestamp,
|
|
164
|
+
kid: signature["kid"],
|
|
165
|
+
zc_anonymous_id: request.header("zc-anonymous-id"),
|
|
166
|
+
zc_buyer_id: request.header("zc-buyer-id")
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Verify a ZeroClick-signed request. See Verify.call.
|
|
173
|
+
def self.verify_request(request, **options)
|
|
174
|
+
Verify.call(request, **options)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "sellers/version"
|
|
4
|
+
require_relative "sellers/errors"
|
|
5
|
+
require_relative "sellers/contracts"
|
|
6
|
+
require_relative "sellers/responses"
|
|
7
|
+
require_relative "sellers/verify"
|
|
8
|
+
require_relative "sellers/usage"
|
|
9
|
+
require_relative "sellers/client"
|
|
10
|
+
require_relative "sellers/middleware"
|
|
11
|
+
|
|
12
|
+
# The ZeroClick billing guard for Ruby backends.
|
|
13
|
+
#
|
|
14
|
+
# It verifies that a request really came from ZeroClick, checks that the buyer
|
|
15
|
+
# can pay before you do the work, builds the refusal responses ZeroClick
|
|
16
|
+
# expects, and reports what was used.
|
|
17
|
+
#
|
|
18
|
+
# The core is stdlib-only — openssl, json, net/http — so it adds nothing to your
|
|
19
|
+
# dependency graph. The Rack middleware is duck-typed and needs no rack require;
|
|
20
|
+
# `zeroclick/sellers/railtie` is opt-in and is the only file that touches Rails.
|
|
21
|
+
module ZeroClick
|
|
22
|
+
module Sellers
|
|
23
|
+
class << self
|
|
24
|
+
# Create a seller client. See Client#initialize for the options.
|
|
25
|
+
def create(**options)
|
|
26
|
+
Client.new(**options)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Store the options a later .seller call will build from. Used by the
|
|
30
|
+
# Railtie; call it directly in a non-Rails app if you want the same
|
|
31
|
+
# process-wide singleton.
|
|
32
|
+
def configure(**options)
|
|
33
|
+
@config = options
|
|
34
|
+
@seller = nil
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The process-wide client built from .configure. Lazy, so boot does not
|
|
39
|
+
# depend on credentials being present in every environment.
|
|
40
|
+
def seller
|
|
41
|
+
@seller ||= begin
|
|
42
|
+
if @config.nil? || @config.empty?
|
|
43
|
+
raise Error.new("malformed_input", operation: "seller",
|
|
44
|
+
message: "Call ZeroClick::Sellers.configure first (or config.zeroclick under Rails)")
|
|
45
|
+
end
|
|
46
|
+
create(**@config)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Parse ZEROCLICK_SIGNING_SECRETS, formatted `kid1:secret1,kid2:secret2`.
|
|
51
|
+
#
|
|
52
|
+
# Rotation is why this is a map rather than a single secret: during a
|
|
53
|
+
# rotation both the old and the new kid must verify, or every in-flight
|
|
54
|
+
# request fails.
|
|
55
|
+
def secrets_from_env(value = ENV.fetch("ZEROCLICK_SIGNING_SECRETS", nil))
|
|
56
|
+
if value.nil? || value.strip.empty?
|
|
57
|
+
raise Error.new("malformed_input", operation: "secrets_from_env",
|
|
58
|
+
message: "ZEROCLICK_SIGNING_SECRETS is unset or empty")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
value.split(",").each_with_object({}) do |pair, out|
|
|
62
|
+
kid, separator, secret = pair.partition(":")
|
|
63
|
+
kid = kid.strip
|
|
64
|
+
secret = secret.strip
|
|
65
|
+
if separator.empty? || kid.empty? || secret.empty?
|
|
66
|
+
raise Error.new("malformed_input", operation: "secrets_from_env",
|
|
67
|
+
message: "Expected ZEROCLICK_SIGNING_SECRETS as kid1:secret1,kid2:secret2")
|
|
68
|
+
end
|
|
69
|
+
out[kid] = secret
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: zeroclick-sellers
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- ZeroClick
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: |
|
|
13
|
+
Verify that a request really came from ZeroClick, check that the buyer can
|
|
14
|
+
pay before you do the work, return the refusal responses ZeroClick expects,
|
|
15
|
+
and report what was used. Ships Rack middleware, so it works in Rails,
|
|
16
|
+
Sinatra, Hanami and Roda alike.
|
|
17
|
+
email:
|
|
18
|
+
- support@zeroclick.ai
|
|
19
|
+
executables: []
|
|
20
|
+
extensions: []
|
|
21
|
+
extra_rdoc_files: []
|
|
22
|
+
files:
|
|
23
|
+
- LICENSE
|
|
24
|
+
- README.md
|
|
25
|
+
- lib/zeroclick/sellers.rb
|
|
26
|
+
- lib/zeroclick/sellers/client.rb
|
|
27
|
+
- lib/zeroclick/sellers/contracts.rb
|
|
28
|
+
- lib/zeroclick/sellers/errors.rb
|
|
29
|
+
- lib/zeroclick/sellers/middleware.rb
|
|
30
|
+
- lib/zeroclick/sellers/railtie.rb
|
|
31
|
+
- lib/zeroclick/sellers/responses.rb
|
|
32
|
+
- lib/zeroclick/sellers/usage.rb
|
|
33
|
+
- lib/zeroclick/sellers/verify.rb
|
|
34
|
+
- lib/zeroclick/sellers/version.rb
|
|
35
|
+
homepage: https://zeroclick.io
|
|
36
|
+
licenses:
|
|
37
|
+
- Apache-2.0
|
|
38
|
+
metadata:
|
|
39
|
+
homepage_uri: https://zeroclick.io
|
|
40
|
+
rubygems_mfa_required: 'true'
|
|
41
|
+
rdoc_options: []
|
|
42
|
+
require_paths:
|
|
43
|
+
- lib
|
|
44
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: 3.1.0
|
|
49
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0'
|
|
54
|
+
requirements: []
|
|
55
|
+
rubygems_version: 3.6.9
|
|
56
|
+
specification_version: 4
|
|
57
|
+
summary: The ZeroClick billing guard for Ruby and Rails backends
|
|
58
|
+
test_files: []
|