Class: ZeroClick::Sellers::Client

Inherits:
Object
  • Object
show all
Defined in:
lib/zeroclick/sellers/client.rb

Overview

The facade a seller uses. Blocking, because Ruby web apps are.

Instance Method Summary collapse

Constructor Details

#initialize(api_key: nil, usage_read_key: nil, usage_write_key: nil, signing_secrets: nil, resolve_signing_secret: nil, api_base_url: DEFAULT_API_BASE_URL, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS, allowance_unavailable_policy: "allow", check_timeout_seconds: DEFAULT_CHECK_TIMEOUT_SECONDS, on_allowance_unavailable: nil, clock: -> { Time.now.to_i }, http_post: Usage.method(:post)) ⇒ Client

Usage keys can be split by direction: the read key covers allowance checks (#guard, #check_allowance), the write key covers usage reporting — often a separate process, such as a Sidekiq worker. api_key remains a single both-scopes credential and backfills either side.

Provide exactly one of signing_secrets (Hash of kid => secret) or resolve_signing_secret (callable taking a kid).

allowance_unavailable_policy decides what happens when the allowance API fails to give an answer: "allow" (default), "deny" or "throw". It is applied only AFTER a signature verifies, which is what keeps a fail-open allowance policy from becoming a fail-open signature policy.



25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# File 'lib/zeroclick/sellers/client.rb', line 25

def initialize(api_key: nil,
               usage_read_key: nil,
               usage_write_key: nil,
               signing_secrets: nil,
               resolve_signing_secret: nil,
               api_base_url: DEFAULT_API_BASE_URL,
               tolerance_seconds: DEFAULT_TOLERANCE_SECONDS,
               allowance_unavailable_policy: "allow",
               check_timeout_seconds: DEFAULT_CHECK_TIMEOUT_SECONDS,
               on_allowance_unavailable: nil,
               clock: -> { Time.now.to_i },
               # The transport, injectable so every decision this class
               # makes about a response is testable without a socket.
               # Must return [status, body] or raise Error.
               http_post: Usage.method(:post))
  usage_read_key ||= api_key
  usage_write_key ||= api_key

  if usage_read_key.nil? || usage_read_key.empty?
    raise Error.new("malformed_input", operation: "create",
                                       message: "Provide usage_read_key (or a both-scopes api_key) for allowance checks")
  end
  if usage_write_key.nil? || usage_write_key.empty?
    raise Error.new("malformed_input", operation: "create",
                                       message: "Provide usage_write_key (or a both-scopes api_key) for usage reporting")
  end
  if signing_secrets.nil? == resolve_signing_secret.nil?
    raise Error.new("malformed_input", operation: "create",
                                       message: "Provide exactly one of signing_secrets or resolve_signing_secret")
  end
  if !signing_secrets.nil? && signing_secrets.empty?
    raise Error.new("malformed_input", operation: "create",
                                       message: "At least one signing secret is required")
  end
  unless ALLOWANCE_UNAVAILABLE_POLICIES.include?(allowance_unavailable_policy.to_s)
    raise Error.new("malformed_input", operation: "create",
                                       message: "allowance_unavailable_policy must be allow, deny or throw")
  end

  @usage_read_key = usage_read_key
  @usage_write_key = usage_write_key
  @signing_secrets = signing_secrets&.to_h&.freeze
  @resolve_signing_secret = resolve_signing_secret
  @api_base_url = api_base_url
  @tolerance_seconds = tolerance_seconds
  @policy = allowance_unavailable_policy.to_s
  @timeout = check_timeout_seconds
  @on_allowance_unavailable = on_allowance_unavailable
  @clock = clock
  @http_post = http_post
  freeze
end

Instance Method Details

#check_allowance(zc_request_id:, service_slug:, usage:) ⇒ Object



136
137
138
139
140
141
142
143
144
145
146
147
148
# File 'lib/zeroclick/sellers/client.rb', line 136

def check_allowance(zc_request_id:, service_slug:, usage:)
  status, body = @http_post.call(
    base_url: @api_base_url,
    path: Usage::CHECK_PATH,
    api_key: @usage_read_key,
    payload: Usage.build_check_payload(
      zc_request_id: zc_request_id, service_slug: service_slug, usage: usage
    ),
    timeout: @timeout,
    operation: "check_allowance"
  )
  Usage.interpret_check(status, body)
end

#guard(request, service_slug:, usage:, plan_slug: nil) ⇒ Object

Verify, then check the buyer can pay for usage before you do the work.

Returns Allow or Deny. Deny#response is ready to return as-is.



91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
# File 'lib/zeroclick/sellers/client.rb', line 91

def guard(request, service_slug:, usage:, plan_slug: nil)
  items = Sellers.normalize_usage(usage, operation: "guard", allow_empty: false)
  verification = verify_request(request)
  return Deny.new(verification.reason, verification.response) unless verification.ok?

  begin
    status, body = @http_post.call(
      base_url: @api_base_url,
      path: Usage::CHECK_PATH,
      api_key: @usage_read_key,
      payload: Usage.build_check_payload(
        zc_request_id: verification.context.zc_request_id,
        service_slug: service_slug,
        usage: items
      ),
      timeout: @timeout,
      operation: "check_allowance"
    )
    decision = Usage.interpret_check(status, body)
  rescue Error => e
    return on_allowance_error(e, verification.context)
  end

  return Allow.new(verification.context, "allowed") if decision.allowed?

  Deny.new(
    decision.reason || "allowance_denied",
    Responses.payment_required(service_slug: service_slug, usage: items, plan_slug: plan_slug)
  )
end

#guard_identity(request, service_slug:) ⇒ Object

Guard a free endpoint that must still know which buyer is calling. Makes no network call.



124
125
126
127
128
129
130
131
132
133
134
# File 'lib/zeroclick/sellers/client.rb', line 124

def guard_identity(request, service_slug:)
  verification = verify_request(request)
  return Deny.new(verification.reason, verification.response) unless verification.ok?

  agent_id = verification.context.zc_agent_id
  if agent_id.nil? || agent_id.empty?
    return Deny.new("identity_required", Responses.payment_required(service_slug: service_slug, usage: []))
  end

  Allow.new(verification.context, "not_required")
end

#payment_required(service_slug:, usage:, plan_slug: nil) ⇒ Object

-- response helpers --------------------------------------------------



174
175
176
# File 'lib/zeroclick/sellers/client.rb', line 174

def payment_required(service_slug:, usage:, plan_slug: nil)
  Responses.payment_required(service_slug: service_slug, usage: usage, plan_slug: plan_slug)
end

#report_usage(zc_agent_id:, idempotency_key:, service_slug:, meter_slug:, quantity:, occurred_at: nil) ⇒ Object

Report usage asynchronously. idempotency_key is seller-owned and must be derived from the request — the SDK never invents one, and never retries on your behalf.



153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
# File 'lib/zeroclick/sellers/client.rb', line 153

def report_usage(zc_agent_id:, idempotency_key:, service_slug:, meter_slug:, quantity:, occurred_at: nil)
  status, body = @http_post.call(
    base_url: @api_base_url,
    path: Usage::REPORT_PATH,
    api_key: @usage_write_key,
    payload: Usage.build_report_payload(
      zc_agent_id: zc_agent_id,
      idempotency_key: idempotency_key,
      service_slug: service_slug,
      meter_slug: meter_slug,
      quantity: quantity,
      occurred_at: occurred_at
    ),
    timeout: @timeout,
    operation: "report_usage"
  )
  Usage.interpret_report(status, body)
end

#verify_request(request) ⇒ Object



78
79
80
81
82
83
84
85
86
# File 'lib/zeroclick/sellers/client.rb', line 78

def verify_request(request)
  Verify.call(
    request,
    signing_secrets: @signing_secrets,
    resolve_signing_secret: @resolve_signing_secret,
    tolerance_seconds: @tolerance_seconds,
    clock: @clock
  )
end

#with_usage(response, usage) ⇒ Object



178
179
180
# File 'lib/zeroclick/sellers/client.rb', line 178

def with_usage(response, usage)
  Responses.with_usage(response, usage)
end