Class: KnoxCall::Client
- Inherits:
-
Object
- Object
- KnoxCall::Client
- 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
requestreturns for a 304 Not Modified when the caller opted in withallow_not_modified:(a conditional GET carrying If-None-Match). Internal: the one consumer iswrap.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-Versionheader 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 asx-knoxcall-origin: sdk-interceptso 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
-
#account ⇒ Object
readonly
Returns the value of attribute account.
-
#agents ⇒ Object
readonly
Returns the value of attribute agents.
-
#ai_gateway ⇒ Object
readonly
Returns the value of attribute ai_gateway.
-
#api_keys ⇒ Object
readonly
Returns the value of attribute api_keys.
-
#api_version ⇒ Object
readonly
Returns the value of attribute api_version.
-
#audit_logs ⇒ Object
readonly
Returns the value of attribute audit_logs.
-
#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).
-
#clients ⇒ Object
readonly
Returns the value of attribute clients.
-
#crypto ⇒ Object
readonly
Returns the value of attribute crypto.
-
#dynamic_db ⇒ Object
readonly
Returns the value of attribute dynamic_db.
-
#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).
-
#environments ⇒ Object
readonly
Returns the value of attribute environments.
-
#logs ⇒ Object
readonly
Returns the value of attribute logs.
-
#oauth_clients ⇒ Object
readonly
Returns the value of attribute oauth_clients.
-
#opportunities ⇒ Object
readonly
Returns the value of attribute opportunities.
-
#pki ⇒ Object
readonly
Returns the value of attribute pki.
-
#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).
-
#roles ⇒ Object
readonly
Returns the value of attribute roles.
-
#routes ⇒ Object
readonly
Returns the value of attribute routes.
-
#sandbox ⇒ Object
readonly
Returns the value of attribute sandbox.
-
#secrets ⇒ Object
readonly
Returns the value of attribute secrets.
-
#tenant ⇒ Object
readonly
Returns the value of attribute tenant.
-
#vaults ⇒ Object
readonly
Returns the value of attribute vaults.
-
#webhooks ⇒ Object
readonly
Returns the value of attribute webhooks.
-
#workflows ⇒ Object
readonly
Returns the value of attribute workflows.
-
#wrap ⇒ Object
readonly
Returns the value of attribute wrap.
Class Method Summary collapse
-
.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.
-
.data_plane_path_prefix(proxy_base_url) ⇒ Object
Where the data plane lives under a proxy base (PARITY §5).
-
.verify_signature(raw_body, signature, secret, tolerance_seconds: 300, timestamp: nil) ⇒ Object
Verify a KnoxCall webhook HMAC-SHA256 signature.
Instance Method Summary collapse
-
#adopt_tenant(cached) ⇒ Object
Learn the tenant from a token response when constructed without one.
-
#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.
- #construct_event ⇒ Object
-
#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.
-
#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.
-
#initialize(opts = {}) ⇒ Client
constructor
A new instance of Client.
-
#inspect ⇒ Object
Never dump credentials or the cached token when the client is inspected (consoles, loggers, exception trackers capturing locals).
- #purge_token ⇒ Object
-
#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.
-
#route(route, environment: nil, headers: {}, timeout: nil) ⇒ Object
Bind a route (and optional call defaults) once, then make plain HTTP-verb calls against it:.
-
#token ⇒ Object
-- Token management -------------------------------------------------------.
- #verify_signature ⇒ Object
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 @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.
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/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 && return false if ( - 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? account = request("GET", "/v1/account") slug = account.is_a?(Hash) ? account.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"] = unless .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(...) |