Class: KnoxCall::Client

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

Overview

KnoxCall API client.

client = KnoxCall::Client.new(tenant: "acme", access_token: "kc_live_...")
routes = client.routes.list
res    = client.call("route-uuid", path: "/v1/orders")

Credentials can be passed flat (access_token:/api_key: or client_id: + client_secret:), as a bootstrap: object, or resolved from KNOXCALL_* environment variables — KnoxCall::Client.new works zero-arg when KNOXCALL_TENANT plus a credential are set in the environment. Zero-arg resolution order (PARITY §2): KNOXCALL_ACCESS_TOKEN / KNOXCALL_API_KEY → the knoxcall login credentials file (~/.knoxcall/credentials.json) → KNOXCALL_CLIENT_ID + KNOXCALL_CLIENT_SECRET.

The client is Mutex-safe: a single instance can be shared across Puma / Sidekiq threads. The token cache is single-flight — concurrent callers block on one token request instead of stampeding the token endpoint.

Constant Summary collapse

RETRYABLE_STATUSES =

NOT 409 — a real conflict does not resolve by replaying

[408, 429, 500, 502, 503, 504].freeze
NOT_MODIFIED =

The value request returns for a 304 Not Modified when the caller opted in with allow_not_modified: (a conditional GET carrying If-None-Match). Internal: the one consumer is wrap.intercept_manifest(if_none_match:), which maps it to nil. Without the opt-in a 304 keeps its old shape (an empty body read as nil), so nothing else changes.

Object.new
DEFAULT_API_VERSION =

The dated API version this SDK is built against. Sent as the KnoxCall-Version header on every management request so the SDK stays pinned to a known API shape even after the server ships a newer default (see the server's src/client-api/versioning.ts). Must be a version the server's registry knows, or requests are rejected 400.

"2026-08-05"
RETRY_AFTER_CAP_SECONDS =

Honor a server Retry-After up to this long; beyond it, fail fast so callers can apply their own scheduling instead of blocking a worker.

30.0
REFRESH_AHEAD_SECONDS =
300.0
STALE_TOKEN_MIN_REMAINING_SECONDS =

A cached token inside the refresh-ahead window is still usable this long before real expiry; used when the token endpoint is down.

10.0
METHOD_CLASSES =
{
  "GET" => Net::HTTP::Get,
  "HEAD" => Net::HTTP::Head,
  "POST" => Net::HTTP::Post,
  "PUT" => Net::HTTP::Put,
  "PATCH" => Net::HTTP::Patch,
  "DELETE" => Net::HTTP::Delete,
  "OPTIONS" => Net::HTTP::Options
}.freeze
CONNECT_ERRORS =

Transport failures where the connection was never established — the request never left the machine, so a retry is safe for any method.

[Errno::ECONNREFUSED, Net::OpenTimeout, SocketError].freeze
SANDBOX_PROXY_HOSTS =

Management hosts whose data plane lives on a per-tenant subdomain — sandbox hosts use the sandbox- prefixed shape (node core.ts is the reference; any other host is self-hosted and proxies on itself).

%w[sandbox.knoxcall.com sandbox-staging.knoxcall.com].freeze
PLAIN_PROXY_HOSTS =
%w[api.knoxcall.com api-staging.knoxcall.com].freeze
NON_TENANT_LABELS =

The labels under knoxcall.com that are NOT a tenant's data-plane host (management + marketing); see .data_plane_path_prefix.

%w[api sandbox api-staging sandbox-staging www staging admin].freeze
CLOUD_TENANT_HOST_RE =
/\A([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.knoxcall\.com\z/
TENANT_SLUG_RE =

A tenant slug becomes a data-plane hostname (https://.knoxcall.com), so before it is interpolated into a host it MUST be a bare DNS label — a hostile slug adopted from a token response, /v1/account, or the credentials file (e.g. "evil.com#") would otherwise misdirect the tenant's bearer token to an attacker-controlled host (PARITY §2). Anchored with \A..\z (never ^..$) so a value embedding a newline can't satisfy a line-anchored match; case-insensitive to mirror node core.ts.

/\A[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\z/i
CONSTRUCT_EVENT_FORMATS =
%w[legacy stripe github slack aws-sns custom].freeze
PROXY_AUTH_HEADERS =

Auth-bearing headers the proxy data plane consumes to identify the caller (PARITY §5). The SDK's own credential is the SOLE authority on the data plane, so any caller-supplied copy of these is stripped from call()/ephemeral() headers before the SDK sets its own — otherwise an integrator forwarding untrusted end-user headers could inject an alternate proxy identity (x-knoxcall-agent-*) or, on the legacy-key path, a Bearer Authorization the proxy would honor over the SDK's own x-knoxcall-key.

%w[
  authorization dpop x-knoxcall-key x-knoxcall-agent-id x-knoxcall-agent-token
].freeze
SDK_MARKER_HEADERS =

Markers the SDK owns on the data plane (PARITY §21.2). Not auth — the server treats them as informational — but a caller-supplied copy is stripped the same way, so an app cannot relabel its own calls as interceptor traffic through the headers Hash. The interceptors set the marker through call(..., _origin: SDK_INTERCEPT_ORIGIN), never headers.

%w[x-knoxcall-origin].freeze
SDK_INTERCEPT_ORIGIN =

The one value +call+'s internal _origin: accepts: the route-aware interceptors' reroute marker, sent as x-knoxcall-origin: sdk-intercept so the API Log can show which Route calls the SDK rerouted from a third-party SDK and which were direct. Internal — nothing public sets it.

"sdk-intercept"

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(opts = {}) ⇒ Client

Returns a new instance of Client.



143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
# File 'lib/knoxcall/client.rb', line 143

def initialize(opts = {})
  # Tenant is optional: when absent it is discovered from the first token
  # response (or /v1/account for pre-acquired tokens). Only the data-plane
  # hostname needs it client-side; management calls resolve the tenant
  # server-side from the credential.
  @tenant = opts[:tenant] || ENV["KNOXCALL_TENANT"]
  @tenant = nil if @tenant && @tenant.empty?
  # Default environment for data-plane calls; per-call and bound-route
  # values win, and nil means the server picks the tenant default.
  @environment = opts[:environment] || ENV["KNOXCALL_ENVIRONMENT"]

  # Mutual exclusion is enforced on the EXPLICITLY passed options before
  # any env fill, so a stray environment variable never masks a caller
  # mistake — and env fill is skipped entirely once anything explicit
  # (flat or bootstrap:) was passed.
  explicit = {
    client_id: opts[:client_id],
    client_secret: opts[:client_secret],
    access_token: opts[:access_token],
    api_key: opts[:api_key]
  }.compact
  if opts[:bootstrap] && !explicit.empty?
    raise ArgumentError, "bootstrap: cannot be combined with #{explicit.keys.join(', ')}"
  end
  if explicit.key?(:access_token) && explicit.key?(:api_key)
    raise ArgumentError,
          "pass either access_token or api_key, not both (they are two spellings of the same credential)"
  end
  token = explicit[:access_token] || explicit[:api_key]
  if token && (explicit.key?(:client_id) || explicit.key?(:client_secret))
    raise ArgumentError, "a token credential cannot be combined with client_id/client_secret"
  end
  if explicit.key?(:client_id) != explicit.key?(:client_secret)
    raise ArgumentError, "client_id and client_secret must be provided together"
  end

  @bootstrap = opts[:bootstrap]
  if @bootstrap || !explicit.empty?
    @api_key       = token
    @client_id     = explicit[:client_id]
    @client_secret = explicit[:client_secret]
  else
    # Two spellings, one behavior; KNOXCALL_ACCESS_TOKEN wins when both are set.
    @api_key = ENV["KNOXCALL_ACCESS_TOKEN"] || ENV["KNOXCALL_API_KEY"]
    if @api_key.nil? && CredentialsFile.available?
      # Chain slot 2 (PARITY §2): the credentials file written by
      # `knoxcall login`. Ruby has no cloud auto-detect, so zero-arg
      # resolution is: env access token → credentials file → env
      # client-credentials — a file check only, no network I/O. Present =
      # file exists AND the selected profile parses; anything
      # missing/malformed skips the provider silently.
      @bootstrap = StoredCredentials.new
    end
    unless @bootstrap
      @client_id     = ENV["KNOXCALL_CLIENT_ID"]
      @client_secret = ENV["KNOXCALL_CLIENT_SECRET"]
    end
  end

  @timeout = opts[:timeout] || 30

  @retry_max_attempts = opts[:retry_max_attempts] || 3
  @retry_base_delay   = opts[:retry_base_delay]   || 0.1
  @retry_max_delay    = opts[:retry_max_delay]    || 5.0

  # DPoP (RFC 9449, PARITY §7): "auto" starts Bearer and upgrades when
  # the oauth client requires proofs; "always" generates the keypair up
  # front; "never" opts out (a DPoP-bound token then raises).
  @dpop_mode = (opts[:dpop] || "auto").to_s
  unless %w[auto always never].include?(@dpop_mode)
    raise ArgumentError,
          %(invalid dpop mode #{opts[:dpop].inspect} — expected "auto", "always", or "never")
  end
  @dpop_key = @dpop_mode == "always" ? DpopKeyPair.generate : nil
  # Hook for JSON-encoding caller-specific objects (called for values
  # JSON.generate can't represent natively; must return an encodable value).
  @json_encoder = opts[:json_encoder]

  # Dated API version pinned on every management request via the
  # `KnoxCall-Version` header; caller may override, else DEFAULT_API_VERSION.
  @api_version = opts[:api_version] || DEFAULT_API_VERSION

  # Sandbox / Test mode (Stripe-style isolated environment): defaults the
  # management base to https://sandbox.knoxcall.com and the data plane to
  # https://sandbox-{tenant}.knoxcall.com. Requires a tk_test_… API key.
  # An explicit base_url (or the base-URL env vars) wins over the sandbox
  # default — mirrors node core.ts.
  sandbox = opts[:sandbox] == true
  # Exposed via attr_reader :sandbox — the wrap Faraday transport reads it
  # for the both-must-agree Test/Live key check (PARITY §18). Always a
  # boolean, never nil.
  @sandbox = sandbox
  default_base = sandbox ? "https://sandbox.#{DEFAULT_CLOUD_HOST}" : DEFAULT_API_BASE
  # KNOXCALL_BASE_URL is canonical; KNOXCALL_API_BASE_URL is the legacy
  # spelling and loses when both are set.
  @base_url = (opts[:base_url] || ENV["KNOXCALL_BASE_URL"] ||
               ENV["KNOXCALL_API_BASE_URL"] || default_base).chomp("/")

  # The credentials file's tenant/base_url seed the client only when the
  # caller didn't set them explicitly — constructor options, env vars,
  # and sandbox: always win. Seeding runs before the proxy-host
  # derivation below, so the data plane follows the seeded values.
  if @bootstrap.is_a?(StoredCredentials)
    base_url_explicit = !!(opts[:base_url] || ENV["KNOXCALL_BASE_URL"] ||
                           ENV["KNOXCALL_API_BASE_URL"] || sandbox)
    seed_from_stored_credentials(@bootstrap, base_url_explicit: base_url_explicit)
  end

  base_host = begin
    URI.parse(@base_url).host.to_s.downcase
  rescue URI::InvalidURIError
    ""
  end
  # Subdomain shape to derive once the tenant is known (:plain/:sandbox).
  @proxy_shape = nil
  # nil proxy_base_url = derive lazily once the tenant is discovered.
  @proxy_base_url =
    if opts[:proxy_base_url]
      opts[:proxy_base_url].chomp("/")
    elsif (p = ENV["KNOXCALL_PROXY_BASE_URL"])
      p.chomp("/")
    elsif SANDBOX_PROXY_HOSTS.include?(base_host)
      # Sandbox hosts: the per-tenant proxy lives on the sandbox-
      # prefixed subdomain. Validate the slug before it becomes a host.
      @proxy_shape = :sandbox
      @tenant ? "https://sandbox-#{assert_tenant_slug(@tenant)}.#{DEFAULT_CLOUD_HOST}" : nil
    elsif PLAIN_PROXY_HOSTS.include?(base_host)
      @proxy_shape = :plain
      @tenant ? "https://#{assert_tenant_slug(@tenant)}.#{DEFAULT_CLOUD_HOST}" : nil
    else
      # Local dev / self-hosted: the proxy runs on the same host, so no
      # tenant is needed.
      @base_url
    end

  # Plaintext http:// to a non-loopback host sends credentials and tokens
  # in the clear — warn once (never block: http://localhost is the normal
  # dev case). Both the management base and the resolved data plane are
  # checked. @proxy_base_url may still be nil here (derived lazily once the
  # tenant is discovered), but any lazy derivation yields an https:// cloud
  # host, so there is nothing plaintext left unchecked.
  if Warnings.insecure_remote_url?(@base_url)
    Warnings.warn_once(
      "KNOXCALL_INSECURE_BASE_URL",
      "KnoxCall base URL #{@base_url} uses plaintext http:// to a non-loopback host — " \
      "credentials and access tokens will be sent unencrypted. Use https:// " \
      "(plain http:// is only safe for localhost)."
    )
  end
  if Warnings.insecure_remote_url?(@proxy_base_url)
    Warnings.warn_once(
      "KNOXCALL_INSECURE_PROXY_URL",
      "KnoxCall proxy base URL #{@proxy_base_url} uses plaintext http:// to a non-loopback host — " \
      "proxied requests and the SDK credential will be sent unencrypted. Use https:// " \
      "(plain http:// is only safe for localhost)."
    )
  end

  @token_cache = nil
  @token_mutex = Mutex.new
  @discovery_mutex = Mutex.new

  @routes       = Resources::Routes.new(self)
  @secrets      = Resources::Secrets.new(self)
  @webhooks     = Resources::Webhooks.new(self)
  @workflows    = Resources::Workflows.new(self)
  @clients      = Resources::Clients.new(self)
  @oauth_clients = Resources::OAuthClients.new(self)
  @environments = Resources::Environments.new(self)
  @api_keys     = Resources::ApiKeys.new(self)
  @roles        = Resources::Roles.new(self)
  @account      = Resources::Account.new(self)
  @audit_logs   = Resources::AuditLogs.new(self)
  # Per-call proxy request log + Merkle inclusion proofs. Not the change
  # log — that is +audit_logs+.
  @logs         = Resources::Logs.new(self)
  @agents       = Resources::Agents.new(self)
  @crypto       = Resources::Crypto.new(self)
  @pki          = Resources::Pki.new(self)
  @vaults       = Resources::Vaults.new(self)
  @dynamic_db   = Resources::DynamicDb.new(self)
  @ai_gateway   = Resources::AiGateway.new(self)
  @wrap         = Resources::Wrap.new(self)
  @opportunities = Resources::Opportunities.new(self)
end

Instance Attribute Details

#account ⇒ Object (readonly)

Returns the value of attribute account.



139
140
141
# File 'lib/knoxcall/client.rb', line 139

def 
  @account
end

#agents ⇒ Object (readonly)

Returns the value of attribute agents.



139
140
141
# File 'lib/knoxcall/client.rb', line 139

def agents
  @agents
end

#ai_gateway ⇒ Object (readonly)

Returns the value of attribute ai_gateway.



139
140
141
# File 'lib/knoxcall/client.rb', line 139

def ai_gateway
  @ai_gateway
end

#api_keys ⇒ Object (readonly)

Returns the value of attribute api_keys.



139
140
141
# File 'lib/knoxcall/client.rb', line 139

def api_keys
  @api_keys
end

#api_version ⇒ Object (readonly)

Returns the value of attribute api_version.



139
140
141
# File 'lib/knoxcall/client.rb', line 139

def api_version
  @api_version
end

#audit_logs ⇒ Object (readonly)

Returns the value of attribute audit_logs.



139
140
141
# File 'lib/knoxcall/client.rb', line 139

def audit_logs
  @audit_logs
end

#base_url ⇒ Object (readonly)

The management base URL, the data-plane base URL (nil until the tenant is discovered) and the default environment — read by the route-aware wrap pipeline (own-host refusal; the manifest's environment).



138
139
140
# File 'lib/knoxcall/client.rb', line 138

def base_url
  @base_url
end

#clients ⇒ Object (readonly)

Returns the value of attribute clients.



139
140
141
# File 'lib/knoxcall/client.rb', line 139

def clients
  @clients
end

#crypto ⇒ Object (readonly)

Returns the value of attribute crypto.



139
140
141
# File 'lib/knoxcall/client.rb', line 139

def crypto
  @crypto
end

#dynamic_db ⇒ Object (readonly)

Returns the value of attribute dynamic_db.



139
140
141
# File 'lib/knoxcall/client.rb', line 139

def dynamic_db
  @dynamic_db
end

#environment ⇒ Object (readonly)

The management base URL, the data-plane base URL (nil until the tenant is discovered) and the default environment — read by the route-aware wrap pipeline (own-host refusal; the manifest's environment).



138
139
140
# File 'lib/knoxcall/client.rb', line 138

def environment
  @environment
end

#environments ⇒ Object (readonly)

Returns the value of attribute environments.



139
140
141
# File 'lib/knoxcall/client.rb', line 139

def environments
  @environments
end

#logs ⇒ Object (readonly)

Returns the value of attribute logs.



139
140
141
# File 'lib/knoxcall/client.rb', line 139

def logs
  @logs
end

#oauth_clients ⇒ Object (readonly)

Returns the value of attribute oauth_clients.



139
140
141
# File 'lib/knoxcall/client.rb', line 139

def oauth_clients
  @oauth_clients
end

#opportunities ⇒ Object (readonly)

Returns the value of attribute opportunities.



139
140
141
# File 'lib/knoxcall/client.rb', line 139

def opportunities
  @opportunities
end

#pki ⇒ Object (readonly)

Returns the value of attribute pki.



139
140
141
# File 'lib/knoxcall/client.rb', line 139

def pki
  @pki
end

#proxy_base_url ⇒ Object (readonly)

The management base URL, the data-plane base URL (nil until the tenant is discovered) and the default environment — read by the route-aware wrap pipeline (own-host refusal; the manifest's environment).



138
139
140
# File 'lib/knoxcall/client.rb', line 138

def proxy_base_url
  @proxy_base_url
end

#roles ⇒ Object (readonly)

Returns the value of attribute roles.



139
140
141
# File 'lib/knoxcall/client.rb', line 139

def roles
  @roles
end

#routes ⇒ Object (readonly)

Returns the value of attribute routes.



139
140
141
# File 'lib/knoxcall/client.rb', line 139

def routes
  @routes
end

#sandbox ⇒ Object (readonly)

Returns the value of attribute sandbox.



139
140
141
# File 'lib/knoxcall/client.rb', line 139

def sandbox
  @sandbox
end

#secrets ⇒ Object (readonly)

Returns the value of attribute secrets.



139
140
141
# File 'lib/knoxcall/client.rb', line 139

def secrets
  @secrets
end

#tenant ⇒ Object (readonly)

Returns the value of attribute tenant.



139
140
141
# File 'lib/knoxcall/client.rb', line 139

def tenant
  @tenant
end

#vaults ⇒ Object (readonly)

Returns the value of attribute vaults.



139
140
141
# File 'lib/knoxcall/client.rb', line 139

def vaults
  @vaults
end

#webhooks ⇒ Object (readonly)

Returns the value of attribute webhooks.



139
140
141
# File 'lib/knoxcall/client.rb', line 139

def webhooks
  @webhooks
end

#workflows ⇒ Object (readonly)

Returns the value of attribute workflows.



139
140
141
# File 'lib/knoxcall/client.rb', line 139

def workflows
  @workflows
end

#wrap ⇒ Object (readonly)

Returns the value of attribute wrap.



139
140
141
# File 'lib/knoxcall/client.rb', line 139

def wrap
  @wrap
end

Class Method Details

.construct_event(raw_body, headers, secret, format: "legacy", tolerance_seconds: 300, header_name: nil) ⇒ Hash

Verify an incoming webhook delivery AND parse it in one step.

Pass the RAW request body (never re-serialized JSON), the request headers (looked up case-insensitively), and the endpoint secret. On success returns the delivery envelope as a Hash with string keys:

{
"event"        => String,   # e.g. "request.success", "audit.event" —
                            # open list, unknown types parse fine
"timestamp"    => String,   # ISO-8601
"webhook_id"   => String,   # present on request.* events
"webhook_name" => String,   # present on request.* events
"data"         => Hash      # request.*: {"route_id", "route_name",
                            #   "environment", "request" => {"method", "path", "ip"},
                            #   "response" => {"status", "latency_ms"}}
                            # audit.event: {"id", "action", "resource_type",
                            #   "resource_id", "details", "ip_address"}
}

Also available as client.construct_event and client.webhooks.construct_event.

Parameters:

  • raw_body (String) —

    the raw request body bytes

  • headers (Hash) —

    request headers, any casing (values may be arrays)

  • secret (String) —

    the webhook's endpoint secret

  • format (String) (defaults to: "legacy") —

    one of legacy|stripe|github|slack|aws-sns|custom (default "legacy") — must match the webhook's configured hmac_format

  • tolerance_seconds (Integer, nil) (defaults to: 300) —

    replay window, default 300. For stripe/slack the check runs against the signed header timestamp; for the other formats against the envelope's "timestamp" field. Pass nil (or 0) to disable all timestamp checks.

  • header_name (String, nil) (defaults to: nil) —

    required when format is "custom", ignored otherwise

Returns:

  • (Hash) —

    the parsed delivery envelope

Raises:

  • (WebhookSignatureVerificationError) —

    on ANY verification failure (missing header, signature mismatch, stale timestamp, body not a JSON object) — never returns a partial event, and the message never echoes the signature or secret

  • (ArgumentError) —

    on option misuse (unknown format, missing header_name for "custom")



576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
# File 'lib/knoxcall/client.rb', line 576

def self.construct_event(raw_body, headers, secret, format: "legacy", tolerance_seconds: 300, header_name: nil)
  unless CONSTRUCT_EVENT_FORMATS.include?(format)
    raise ArgumentError, "format must be one of #{CONSTRUCT_EVENT_FORMATS.join(', ')}"
  end
  # Explicit nil/0 disables replay protection entirely (documented).
  tolerance = tolerance_seconds && tolerance_seconds.to_i.positive? ? tolerance_seconds.to_i : nil
  now = Time.now.to_i

  # Case-insensitive header lookup; multi-value headers use the first value.
  lower = {}
  (headers || {}).each do |name, value|
    lower[name.to_s.downcase] = (value.is_a?(Array) ? value.first : value).to_s
  end
  fetch_header = lambda do |name|
    value = lower[name.downcase]
    if value.nil? || value.strip.empty?
      raise WebhookSignatureVerificationError, "missing signature header #{name}"
    end
    value.strip
  end
  # hex signatures arrive as `<prefix><hex>` (e.g. sha256=…, v0=…); the
  # prefix is shape, not signature — strip it when present.
  strip_prefix = ->(value, prefix) { value.start_with?(prefix) ? value[prefix.length..] : value }

  case format
  when "legacy", "github", "custom"
    signature_header =
      case format
      when "legacy" then "X-Webhook-Signature"
      when "github" then "X-Hub-Signature-256"
      else
        header_name or raise ArgumentError, "header_name is required when format is \"custom\""
      end
    signature = strip_prefix.call(fetch_header.call(signature_header), "sha256=")
    expected = OpenSSL::HMAC.hexdigest("SHA256", secret, raw_body)
    unless OpenSSL.secure_compare(expected, signature)
      raise WebhookSignatureVerificationError, "signature mismatch (#{format} format)"
    end
  when "aws-sns"
    signature = fetch_header.call("x-amz-sns-signature")
    expected = [OpenSSL::HMAC.digest("SHA256", secret, raw_body)].pack("m0")
    unless OpenSSL.secure_compare(expected, signature)
      raise WebhookSignatureVerificationError, "signature mismatch (aws-sns format)"
    end
  when "stripe"
    # `t=<ts>,v1=<hex>` — multiple comma-separated pairs allowed; any
    # matching v1 passes (mirrors Stripe's own secret rotation).
    ts = nil
    candidates = []
    fetch_header.call("Stripe-Signature").split(",").each do |part|
      part = part.strip
      ts = part[2..] if part.start_with?("t=")
      candidates << part[3..] if part.start_with?("v1=")
    end
    unless ts&.match?(/\A\d+\z/) && !candidates.empty?
      raise WebhookSignatureVerificationError, "malformed Stripe-Signature header"
    end
    if tolerance && (now - ts.to_i).abs > tolerance
      raise WebhookSignatureVerificationError, "timestamp outside tolerance (stripe format)"
    end
    expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{ts}.#{raw_body}")
    matched = false
    candidates.each do |candidate|
      # no early break — check every candidate, constant-time each
      matched = true if OpenSSL.secure_compare(expected, candidate)
    end
    raise WebhookSignatureVerificationError, "signature mismatch (stripe format)" unless matched
  when "slack"
    ts = fetch_header.call("X-Slack-Request-Timestamp")
    unless ts.match?(/\A\d+\z/)
      raise WebhookSignatureVerificationError, "malformed X-Slack-Request-Timestamp header"
    end
    if tolerance && (now - ts.to_i).abs > tolerance
      raise WebhookSignatureVerificationError, "timestamp outside tolerance (slack format)"
    end
    signature = strip_prefix.call(fetch_header.call("X-Slack-Signature"), "v0=")
    expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "v0:#{ts}:#{raw_body}")
    unless OpenSSL.secure_compare(expected, signature)
      raise WebhookSignatureVerificationError, "signature mismatch (slack format)"
    end
  end

  event = begin
    JSON.parse(raw_body)
  rescue JSON::ParserError
    nil
  end
  raise WebhookSignatureVerificationError, "delivery body is not a JSON object" unless event.is_a?(Hash)

  # Formats without a signed timestamp: enforce the replay window against
  # the envelope's own ISO-8601 timestamp field.
  if tolerance && %w[legacy github aws-sns custom].include?(format)
    envelope_ts = begin
      event["timestamp"].is_a?(String) ? Time.iso8601(event["timestamp"]).to_i : nil
    rescue ArgumentError
      nil
    end
    if envelope_ts.nil?
      raise WebhookSignatureVerificationError,
            "delivery timestamp missing or invalid (pass tolerance_seconds: nil to skip replay checks)"
    end
    if (now - envelope_ts).abs > tolerance
      raise WebhookSignatureVerificationError, "timestamp outside tolerance (#{format} format)"
    end
  end

  event
end

.data_plane_path_prefix(proxy_base_url) ⇒ Object

Where the data plane lives under a proxy base (PARITY §5).

On a KnoxCall CLOUD tenant host the proxy is served ONLY under /api (+https://slug.knoxcall.com/api/+: server.ts strips the prefix, and every other path on that host is the dashboard). call therefore places the upstream path under /api whenever the base names such a host and carries no path of its own — the derived plain/sandbox shapes and an explicit override alike, any port. Every other base is used verbatim: self-hosted mounts the proxy at /, and a base that already carries a path IS the entry point (the agent bundle spells the same base as …knoxcall.com/api). Until 2026-09-25 nothing added the prefix, so the documented path: "/users" answered the dashboard HTML on every tenant host; the live smokes hid it by hard-coding path: "/api/get".



88
89
90
91
92
93
94
95
96
97
98
# File 'lib/knoxcall/client.rb', line 88

def self.data_plane_path_prefix(proxy_base_url)
  uri = URI.parse(proxy_base_url.to_s)
  return "" unless uri.path.nil? || uri.path.empty? || uri.path == "/"

  m = CLOUD_TENANT_HOST_RE.match(uri.host.to_s.downcase)
  return "" if m.nil? || NON_TENANT_LABELS.include?(m[1])

  "/api"
rescue URI::InvalidURIError
  ""
end

.verify_signature(raw_body, signature, secret, tolerance_seconds: 300, timestamp: nil) ⇒ Object

Verify a KnoxCall webhook HMAC-SHA256 signature.



516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
# File 'lib/knoxcall/client.rb', line 516

def self.verify_signature(raw_body, signature, secret, tolerance_seconds: 300, timestamp: nil)
  parts = {}
  signature.split(",").each do |part|
    part = part.strip
    parts[:t]  = part[2..] if part.start_with?("t=")
    parts[:v1] = part[3..] if part.start_with?("v1=")
  end

  return false unless parts[:t] && parts[:v1]

  if tolerance_seconds > 0 && timestamp
    return false if (timestamp - parts[:t].to_i).abs > tolerance_seconds
  end

  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{parts[:t]}.#{raw_body}")
  OpenSSL::HMAC.hexdigest("SHA256", secret, parts[:v1]) == OpenSSL::HMAC.hexdigest("SHA256", secret, expected)
end

Instance Method Details

#adopt_tenant(cached) ⇒ Object

Learn the tenant from a token response when constructed without one. Called with @token_mutex held.



359
360
361
362
# File 'lib/knoxcall/client.rb', line 359

def adopt_tenant(cached)
  @tenant ||= cached[:tenant] if cached[:tenant]
  cached
end

#call(route, method: "GET", path: "/", body: nil, headers: {}, environment: nil, query: nil, timeout: nil, _origin: nil) ⇒ Object

Make a proxied request through a KnoxCall route. Pass the route UUID (preferred) or name.

Returns the raw Net::HTTPResponse — the proxied upstream's status belongs to the caller and is never raised. Transport failures map to NetworkError / ConnectionTimeoutError and are retried only when safe; a rejected token is purged and re-minted once. timeout: overrides the client timeout for this call.

_origin: is internal: the route-aware interceptors pass SDK_INTERCEPT_ORIGIN so the request carries x-knoxcall-origin: sdk-intercept (PARITY §21.2). A direct call sends nothing — absence IS "direct" on the server.



468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
# File 'lib/knoxcall/client.rb', line 468

def call(route, method: "GET", path: "/", body: nil, headers: {}, environment: nil, query: nil, timeout: nil,
         _origin: nil)
  sdk_headers = { "x-knoxcall-route" => route }
  environment ||= @environment
  sdk_headers["x-knoxcall-environment"] = environment if environment
  unless _origin.nil?
    unless _origin == SDK_INTERCEPT_ORIGIN
      raise ArgumentError, "unknown call origin #{_origin.inspect}; the only marker is #{SDK_INTERCEPT_ORIGIN.inspect}"
    end

    sdk_headers["x-knoxcall-origin"] = SDK_INTERCEPT_ORIGIN
  end

  # +path+ is the UPSTREAM path; the entry point is the SDK's to add (PARITY §5).
  base = ensure_proxy_base_url
  proxy_send(method, base + Client.data_plane_path_prefix(base) + normalize_path(path),
             headers: headers, sdk_headers: sdk_headers,
             query: query, body: body, timeout: timeout,
             legacy_key_as_header: true)
end

#construct_event ⇒ Object



685
# File 'lib/knoxcall/client.rb', line 685

def construct_event(...) = self.class.construct_event(...)

#ensure_proxy_base_url ⇒ Object

Resolve the data-plane base URL, discovering the tenant if needed: the token response carries the slug; pre-acquired tokens (and older servers) fall back to one GET /v1/account. @discovery_mutex guarantees the discovery runs at most once even under concurrent first calls.



368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
# File 'lib/knoxcall/client.rb', line 368

def ensure_proxy_base_url
  return @proxy_base_url if @proxy_base_url

  @discovery_mutex.synchronize do
    return @proxy_base_url if @proxy_base_url # discovered while we waited

    token if @tenant.nil? # may adopt the tenant from the token response
    if @tenant.nil?
       = request("GET", "/v1/account")
      slug = .is_a?(Hash) ? .dig("data", "slug") : nil
      unless slug.is_a?(String) && !slug.empty?
        raise Error,
              "could not discover the tenant from the credential — " \
              "pass tenant: ... or set the KNOXCALL_TENANT environment variable"
      end
      @tenant = slug
    end
    @proxy_base_url =
      if @proxy_shape == :sandbox
        "https://sandbox-#{assert_tenant_slug(@tenant)}.#{DEFAULT_CLOUD_HOST}"
      else
        "https://#{assert_tenant_slug(@tenant)}.#{DEFAULT_CLOUD_HOST}"
      end
  end
end

#ephemeral(upstream_url, method: "GET", body: nil, headers: {}, encrypted: nil, timeout_ms: nil, timeout: nil, mode: nil, upstream_authorization: nil, upstream_auth_secret: nil, upstream_auth_scheme: nil) ⇒ Object

Make a one-shot proxied request via the Ephemeral Proxy.



500
501
502
503
504
505
506
507
508
509
510
511
512
513
# File 'lib/knoxcall/client.rb', line 500

def ephemeral(upstream_url, method: "GET", body: nil, headers: {}, encrypted: nil, timeout_ms: nil, timeout: nil,
              mode: nil, upstream_authorization: nil, upstream_auth_secret: nil, upstream_auth_scheme: nil)
  sdk_headers = { "X-Knox-Proxy-URL" => upstream_url }
  sdk_headers["X-Knox-Encrypted"] = encrypted if encrypted
  sdk_headers["X-Knox-Timeout-Ms"] = timeout_ms.to_s if timeout_ms
  sdk_headers["X-Knox-Proxy-Mode"] = "transparent" if mode == "transparent"
  sdk_headers["X-Knox-Upstream-Authorization"] = upstream_authorization unless upstream_authorization.nil?
  sdk_headers["X-Knox-Upstream-Auth-Secret"] = upstream_auth_secret unless upstream_auth_secret.nil?
  sdk_headers["X-Knox-Upstream-Auth-Scheme"] = upstream_auth_scheme unless upstream_auth_scheme.nil?

  proxy_send(method, @base_url + "/v1/proxy",
             headers: headers, sdk_headers: sdk_headers,
             body: body, timeout: timeout)
end

#inspect ⇒ Object

Never dump credentials or the cached token when the client is inspected (consoles, loggers, exception trackers capturing locals).



331
332
333
# File 'lib/knoxcall/client.rb', line 331

def inspect
  "#<KnoxCall::Client tenant=#{@tenant.inspect} base_url=#{@base_url.inspect}>"
end

#purge_token ⇒ Object



353
354
355
# File 'lib/knoxcall/client.rb', line 353

def purge_token
  @token_mutex.synchronize { @token_cache = nil }
end

#request(method, path, query: nil, body: nil, headers: nil, allow_not_modified: false) ⇒ Object

Management API request: typed errors on HTTP failure, retries on 408/429/5xx with half-jitter backoff, one transparent re-auth on 401, and a per-logical-request idempotency key on mutating methods.

With allow_not_modified: true a 304 Not Modified is a success with no body and returns NOT_MODIFIED instead of reading the empty body; auth, the one transparent re-auth on 401 and the retry policy are unchanged, and headers (the If-None-Match) ride on every attempt.



404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
# File 'lib/knoxcall/client.rb', line 404

def request(method, path, query: nil, body: nil, headers: nil, allow_not_modified: false)
  method = method.to_s.upcase
  idem_key = ULID.generate unless %w[GET HEAD].include?(method)

  reauth_done = false
  attempt = 0
  loop do
    attempt += 1
    begin
      tok = token
      uri = URI.parse(@base_url + normalize_path(path))
      uri.query = URI.encode_www_form(query.compact) if query && !query.empty?

      req = build_request(method, uri)
      (headers || {}).each { |k, v| req[k] = v }
      req["Accept"] ||= "application/json"
      # Pin the API version so a newer server default can't silently change
      # the response shape under us; a caller-set header still wins.
      req["KnoxCall-Version"] ||= @api_version
      req["X-Idempotency-Key"] = idem_key if idem_key
      # SDK-set Authorization always wins (Net::HTTP headers are
      # case-insensitive); caller-set Content-Type is respected.
      req["Authorization"] = "#{tok[:token_type]} #{tok[:access_token]}"
      req["DPoP"] = dpop_proof(method, uri.to_s, tok[:access_token]) if tok[:token_type] == "DPoP"
      encode_body(body, req)

      resp = perform(uri, req)
      # A conditional GET the server answered "unchanged": success, no body.
      return NOT_MODIFIED if allow_not_modified && resp.code.to_i == 304
      return handle_response(resp)
    rescue AuthenticationError
      # One transparent re-auth: purge the cached token so the immediate
      # retry runs with freshly minted credentials.
      purge_token
      raise if reauth_done || attempt >= @retry_max_attempts
      reauth_done = true
    rescue APIError => e
      raise unless attempt < @retry_max_attempts && RETRYABLE_STATUSES.include?(e.status_code)
      sleep retry_delay(e, attempt)
    rescue ConnectionTimeoutError, Net::OpenTimeout, Net::ReadTimeout => e
      raise as_sdk_error(e) unless attempt < @retry_max_attempts
      sleep backoff_delay(attempt)
    rescue NetworkError, OpenSSL::SSL::SSLError, EOFError, SocketError, SystemCallError, IOError => e
      raise as_sdk_error(e) unless attempt < @retry_max_attempts
      sleep backoff_delay(attempt)
    end
  end
end

#route(route, environment: nil, headers: {}, timeout: nil) ⇒ Object

Bind a route (and optional call defaults) once, then make plain HTTP-verb calls against it:

printnode = client.route("3f1e2c9a-...", environment: "production")
computers = JSON.parse(printnode.get("/computers").body)
printnode.post("/printjobs", body: payload)


495
496
497
# File 'lib/knoxcall/client.rb', line 495

def route(route, environment: nil, headers: {}, timeout: nil)
  BoundRoute.new(self, route, environment: environment, headers: headers, timeout: timeout)
end

#token ⇒ Object

-- Token management -------------------------------------------------------



337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
# File 'lib/knoxcall/client.rb', line 337

def token
  @token_mutex.synchronize do
    cached = @token_cache
    return adopt_tenant(cached) if cached && token_fresh?(cached)
    begin
      adopt_tenant(@token_cache = fetch_token)
    rescue Error
      # Token endpoint unreachable or erroring during the refresh-ahead
      # window: a cached token that hasn't actually expired is still
      # good — use it rather than failing the caller's request.
      raise unless cached && cached[:expires_at] - Time.now > STALE_TOKEN_MIN_REMAINING_SECONDS
      adopt_tenant(cached)
    end
  end
end

#verify_signature ⇒ Object



534
# File 'lib/knoxcall/client.rb', line 534

def verify_signature(...) = self.class.verify_signature(...)