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, agentify_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, agentify_timeout_seconds: DEFAULT_AGENTIFY_TIMEOUT_SECONDS, on_allowance_unavailable: nil, clock: -> { Time.now.to_i }, http_post: Usage.method(:post), http_get: Agentify.method(:get)) ⇒ 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.



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
77
78
79
80
81
82
83
84
85
86
87
88
89
# File 'lib/zeroclick/sellers/client.rb', line 26

def initialize(api_key: nil,
               usage_read_key: nil,
               usage_write_key: nil,
               agentify_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,
               agentify_timeout_seconds: DEFAULT_AGENTIFY_TIMEOUT_SECONDS,
               on_allowance_unavailable: nil,
               clock: -> { Time.now.to_i },
               # The transports, injectable so every decision this class
               # makes about a response is testable without a socket.
               # http_post must return [status, body]; http_get must
               # return [status, body, headers]. Either may raise Error.
               http_post: Usage.method(:post),
               http_get: Agentify.method(:get))
  usage_read_key ||= api_key
  usage_write_key ||= api_key
  # Unlike the usage keys, agentify has no construction-time
  # requirement: the capability is optional, so a missing key surfaces
  # as a typed error at call time instead of failing every create that
  # never agentifies.
  agentify_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
  @agentify_key = agentify_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
  @agentify_timeout = agentify_timeout_seconds
  @on_allowance_unavailable = on_allowance_unavailable
  @clock = clock
  @http_post = http_post
  @http_get = http_get
  freeze
end

Instance Method Details

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



176
177
178
179
180
181
182
183
184
185
186
187
188
# File 'lib/zeroclick/sellers/client.rb', line 176

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

#fetch_agentify_markdown(url, seller: nil) ⇒ Object

Convert a public marketing page via GET /v1/agentify/markdown.

Requires an API key with the agentify:convert scope (+agentify_key+, falling back to the both-scopes api_key). seller is the seller whose storefront the document inlines (its public id); an organization with exactly one active seller never needs it. Returns an Agentify::MarkdownResult carrying the markdown plus the cache headers worth forwarding.



157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
# File 'lib/zeroclick/sellers/client.rb', line 157

def fetch_agentify_markdown(url, seller: nil)
  if @agentify_key.nil? || @agentify_key.empty?
    raise Error.new("agentify_not_configured", operation: "fetch_agentify_markdown")
  end

  Agentify.validate_page_url!(url, operation: "fetch_agentify_markdown")
  query = { "url" => url.to_s }
  query["seller"] = seller unless seller.nil?
  status, body, headers = @http_get.call(
    base_url: @api_base_url,
    path: Agentify::MARKDOWN_PATH,
    query: query,
    api_key: @agentify_key,
    timeout: @agentify_timeout,
    operation: "fetch_agentify_markdown"
  )
  Agentify.interpret_markdown(status, body, headers)
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.



104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
# File 'lib/zeroclick/sellers/client.rb', line 104

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.



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

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 --------------------------------------------------



214
215
216
# File 'lib/zeroclick/sellers/client.rb', line 214

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.



193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
# File 'lib/zeroclick/sellers/client.rb', line 193

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



91
92
93
94
95
96
97
98
99
# File 'lib/zeroclick/sellers/client.rb', line 91

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



218
219
220
# File 'lib/zeroclick/sellers/client.rb', line 218

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