Module: ZeroClick::Sellers

Defined in:
lib/zeroclick/sellers.rb,
lib/zeroclick/sellers/usage.rb,
lib/zeroclick/sellers/client.rb,
lib/zeroclick/sellers/errors.rb,
lib/zeroclick/sellers/verify.rb,
lib/zeroclick/sellers/railtie.rb,
lib/zeroclick/sellers/version.rb,
lib/zeroclick/sellers/stateful.rb,
lib/zeroclick/sellers/contracts.rb,
lib/zeroclick/sellers/responses.rb,
lib/zeroclick/sellers/encryption.rb,
lib/zeroclick/sellers/middleware.rb,
lib/zeroclick/sellers/stateful/money.rb,
lib/zeroclick/sellers/stateful/verify.rb,
lib/zeroclick/sellers/stateful/dispatch.rb,
lib/zeroclick/sellers/stateful/contracts.rb,
lib/zeroclick/sellers/stateful/responses.rb,
lib/zeroclick/sellers/stateful/entitlement.rb

Defined Under Namespace

Modules: Encryption, Middleware, Responses, Stateful, Usage, Verify Classes: Allow, AllowanceDecision, Client, Deny, Error, Railtie, ReportUsageResult, Request, Response, SyncUsageItem, UsageItem, VerifyFailure, VerifyOk, ZeroClickContext

Constant Summary collapse

ALLOWANCE_API_ERROR_CODES =

Errors meaning "the allowance API did not give us an answer", as opposed to "the allowance API said no". Only these are subject to the configured allowance-unavailable policy; anything else propagates.

%w[
  api_transport_error
  api_status_error
  api_response_invalid
].freeze
VERSION =

Bump this to publish. The workflow reads it, asks RubyGems whether this version already exists, and publishes only when it does not.

"0.3.0"
DEFAULT_API_BASE_URL =
"https://api.zeroclick.io"
DEFAULT_TOLERANCE_SECONDS =
300
DEFAULT_CHECK_TIMEOUT_SECONDS =
1.5
USAGE_DENIAL_REASONS =

Reasons the allowance API gives for refusing. Any other reason on the wire is treated as a malformed response rather than silently passed through.

%w[
  service_not_found
  access_not_found
  access_inactive
  plan_expired
  meter_not_found
  meter_not_priced
  usage_exhausted
].freeze
ALLOWANCE_UNAVAILABLE_POLICIES =
%w[allow deny throw].freeze

Class Method Summary collapse

Class Method Details

.configure(**options) ⇒ Object

Store the options a later .seller call will build from. Used by the Railtie; call it directly in a non-Rails app if you want the same process-wide singleton.



32
33
34
35
36
# File 'lib/zeroclick/sellers.rb', line 32

def configure(**options)
  @config = options
  @seller = nil
  self
end

.create(**options) ⇒ Object

Create a seller client. See Client#initialize for the options.



25
26
27
# File 'lib/zeroclick/sellers.rb', line 25

def create(**options)
  Client.new(**options)
end

.decrypt_request(body, resolve_private_key:) ⇒ Object

Decrypt a Compact JWE request body. See Encryption.decrypt_request.



383
384
385
# File 'lib/zeroclick/sellers/encryption.rb', line 383

def self.decrypt_request(body, resolve_private_key:)
  Encryption.decrypt_request(body, resolve_private_key: resolve_private_key)
end

.encrypt_response(response, envelope) ⇒ Object

Encrypt a response to the buyer's reply key. See Encryption.encrypt_response.



388
389
390
# File 'lib/zeroclick/sellers/encryption.rb', line 388

def self.encrypt_response(response, envelope)
  Encryption.encrypt_response(response, envelope)
end

.error?(error, code = nil) ⇒ Boolean

True when error is an SDK error, optionally of a specific code.

Returns:

  • (Boolean)


44
45
46
# File 'lib/zeroclick/sellers/errors.rb', line 44

def self.error?(error, code = nil)
  error.is_a?(Error) && (code.nil? || error.code == code.to_s)
end

.normalize_usage(usage, operation:, allow_empty:) ⇒ Object



230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
# File 'lib/zeroclick/sellers/contracts.rb', line 230

def normalize_usage(usage, operation:, allow_empty:)
  items = Array(usage)
  if items.empty? && !allow_empty
    raise Error.new("malformed_input", operation: operation,
                                       message: "usage must declare at least one meter")
  end

  seen = {}
  items.each do |item|
    unless item.is_a?(UsageItem)
      raise Error.new("malformed_input", operation: operation,
                                         message: "usage entries must be UsageItem instances")
    end
    if seen.key?(item.meter_slug)
      raise Error.new("malformed_input", operation: operation,
                                         message: "Duplicate meterSlug entries are not allowed",
                                         meter_slug: item.meter_slug)
    end
    seen[item.meter_slug] = true
  end
  items.freeze
end

.require_positive_integer!(value, field_name, operation) ⇒ Object

Raises:



221
222
223
224
225
226
227
228
# File 'lib/zeroclick/sellers/contracts.rb', line 221

def require_positive_integer!(value, field_name, operation)
  # true is not an Integer in Ruby, so unlike Python there is no bool
  # subclass to exclude here.
  return if value.is_a?(Integer) && value.positive?

  raise Error.new("malformed_input", operation: operation,
                                     message: "#{field_name} must be a positive integer")
end

.require_slug!(value, field_name, operation) ⇒ Object

Raises:



214
215
216
217
218
219
# File 'lib/zeroclick/sellers/contracts.rb', line 214

def require_slug!(value, field_name, operation)
  return if value.is_a?(String) && !value.empty? && value == value.strip

  raise Error.new("malformed_input", operation: operation,
                                     message: "#{field_name} must be a non-empty string without surrounding whitespace")
end

.secrets_from_env(value = ENV.fetch("ZEROCLICK_SIGNING_SECRETS", nil)) ⇒ Object

Parse ZEROCLICK_SIGNING_SECRETS, formatted kid1:secret1,kid2:secret2.

Rotation is why this is a map rather than a single secret: during a rotation both the old and the new kid must verify, or every in-flight request fails.



55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
# File 'lib/zeroclick/sellers.rb', line 55

def secrets_from_env(value = ENV.fetch("ZEROCLICK_SIGNING_SECRETS", nil))
  if value.nil? || value.strip.empty?
    raise Error.new("malformed_input", operation: "secrets_from_env",
                                       message: "ZEROCLICK_SIGNING_SECRETS is unset or empty")
  end

  value.split(",").each_with_object({}) do |pair, out|
    kid, separator, secret = pair.partition(":")
    kid = kid.strip
    secret = secret.strip
    if separator.empty? || kid.empty? || secret.empty?
      raise Error.new("malformed_input", operation: "secrets_from_env",
                                         message: "Expected ZEROCLICK_SIGNING_SECRETS as kid1:secret1,kid2:secret2")
    end
    out[kid] = secret
  end
end

.sellerObject

The process-wide client built from .configure. Lazy, so boot does not depend on credentials being present in every environment.



40
41
42
43
44
45
46
47
48
# File 'lib/zeroclick/sellers.rb', line 40

def seller
  @seller ||= begin
    if @config.nil? || @config.empty?
      raise Error.new("malformed_input", operation: "seller",
                                         message: "Call ZeroClick::Sellers.configure first (or config.zeroclick under Rails)")
    end
    create(**@config)
  end
end

.verify_request(request, **options) ⇒ Object

Verify a ZeroClick-signed request. See Verify.call.



173
174
175
# File 'lib/zeroclick/sellers/verify.rb', line 173

def self.verify_request(request, **options)
  Verify.call(request, **options)
end