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,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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZeroClick
4
+ module Sellers
5
+ # Bump this to publish. The workflow reads it, asks RubyGems whether this
6
+ # version already exists, and publishes only when it does not.
7
+ VERSION = "0.1.0"
8
+ end
9
+ 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: []