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, page_views_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, page_view_timeout_seconds: DEFAULT_PAGE_VIEW_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.



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
90
91
92
93
94
95
# File 'lib/zeroclick/sellers/client.rb', line 27

def initialize(api_key: nil,
               usage_read_key: nil,
               usage_write_key: nil,
               agentify_key: nil,
               page_views_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,
               page_view_timeout_seconds: DEFAULT_PAGE_VIEW_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 and page views have no
  # construction-time requirement: each capability is optional, so a
  # missing key surfaces as a typed error at call time instead of failing
  # every create that never uses it.
  agentify_key ||= api_key
  page_views_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
  @page_views_key = page_views_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
  @page_view_timeout = page_view_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



216
217
218
219
220
221
222
223
224
225
226
227
228
# File 'lib/zeroclick/sellers/client.rb', line 216

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.



163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
# File 'lib/zeroclick/sellers/client.rb', line 163

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.



110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
# File 'lib/zeroclick/sellers/client.rb', line 110

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.



143
144
145
146
147
148
149
150
151
152
153
# File 'lib/zeroclick/sellers/client.rb', line 143

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



254
255
256
# File 'lib/zeroclick/sellers/client.rb', line 254

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

#report_page_view(seller:, path:, status: nil, duration_ms: nil, representation: nil, journey_id: nil, captured_at: nil, user_agent: nil, accept: nil, client_ip: nil, country: nil, referrer_host: nil) ⇒ Object

Report one marketing-site page view to POST /v1/page-views.

Requires an API key with the page-views:write scope (+page_views_key+, falling back to the both-scopes api_key). Fire-and-forget by design: the API answers 204 without waiting for warehouse delivery. Raises a typed Error on transport failure or a non-2xx status so a direct caller can observe problems; the Middleware::PageViews wrapper swallows those so a beacon failure never affects the page being observed.

seller is the seller's public id; path must be an absolute path.



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

def report_page_view(seller:, path:, status: nil, duration_ms: nil, representation: nil,
                     journey_id: nil, captured_at: nil, user_agent: nil, accept: nil,
                     client_ip: nil, country: nil, referrer_host: nil)
  if @page_views_key.nil? || @page_views_key.empty?
    raise Error.new("page_views_not_configured", operation: "report_page_view")
  end

  payload = PageViews.build_payload(
    seller: seller, path: path, status: status, duration_ms: duration_ms,
    representation: representation, journey_id: journey_id, captured_at: captured_at,
    user_agent: user_agent, accept: accept, client_ip: client_ip,
    country: country, referrer_host: referrer_host
  )
  status_code, body = @http_post.call(
    base_url: @api_base_url,
    path: PageViews::REPORT_PATH,
    api_key: @page_views_key,
    payload: payload,
    timeout: @page_view_timeout,
    operation: "report_page_view"
  )
  PageViews.interpret_report(status_code, body)
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.



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

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



97
98
99
100
101
102
103
104
105
# File 'lib/zeroclick/sellers/client.rb', line 97

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



258
259
260
# File 'lib/zeroclick/sellers/client.rb', line 258

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