knoxcall 0.0.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +201 -0
  3. data/README.md +439 -2
  4. data/exe/knoxcall +8 -0
  5. data/lib/knoxcall/bootstrap.rb +70 -0
  6. data/lib/knoxcall/bound_route.rb +47 -0
  7. data/lib/knoxcall/cli/ai.rb +79 -0
  8. data/lib/knoxcall/cli/ai_control.rb +275 -0
  9. data/lib/knoxcall/cli/common.rb +96 -0
  10. data/lib/knoxcall/cli/init.rb +94 -0
  11. data/lib/knoxcall/cli/login.rb +306 -0
  12. data/lib/knoxcall/cli/logout.rb +41 -0
  13. data/lib/knoxcall/cli/whoami.rb +29 -0
  14. data/lib/knoxcall/cli.rb +377 -0
  15. data/lib/knoxcall/client.rb +1025 -0
  16. data/lib/knoxcall/credentials_file.rb +442 -0
  17. data/lib/knoxcall/dpop.rb +79 -0
  18. data/lib/knoxcall/egress_observations.rb +372 -0
  19. data/lib/knoxcall/errors.rb +304 -0
  20. data/lib/knoxcall/intercept_patch.rb +181 -0
  21. data/lib/knoxcall/intercept_pipeline.rb +455 -0
  22. data/lib/knoxcall/intercept_resolver.rb +140 -0
  23. data/lib/knoxcall/intercept_store.rb +203 -0
  24. data/lib/knoxcall/login.rb +144 -0
  25. data/lib/knoxcall/resources/account.rb +12 -0
  26. data/lib/knoxcall/resources/agents.rb +25 -0
  27. data/lib/knoxcall/resources/ai_gateway.rb +417 -0
  28. data/lib/knoxcall/resources/api_keys.rb +35 -0
  29. data/lib/knoxcall/resources/audit_logs.rb +45 -0
  30. data/lib/knoxcall/resources/clients.rb +39 -0
  31. data/lib/knoxcall/resources/crypto.rb +122 -0
  32. data/lib/knoxcall/resources/dynamic_db.rb +68 -0
  33. data/lib/knoxcall/resources/environments.rb +16 -0
  34. data/lib/knoxcall/resources/logs.rb +51 -0
  35. data/lib/knoxcall/resources/oauth_clients.rb +34 -0
  36. data/lib/knoxcall/resources/opportunities.rb +61 -0
  37. data/lib/knoxcall/resources/pki.rb +41 -0
  38. data/lib/knoxcall/resources/roles.rb +27 -0
  39. data/lib/knoxcall/resources/routes.rb +53 -0
  40. data/lib/knoxcall/resources/secrets.rb +98 -0
  41. data/lib/knoxcall/resources/unwraps_envelope.rb +90 -0
  42. data/lib/knoxcall/resources/vaults.rb +77 -0
  43. data/lib/knoxcall/resources/webhooks.rb +48 -0
  44. data/lib/knoxcall/resources/workflows.rb +86 -0
  45. data/lib/knoxcall/resources/wrap.rb +352 -0
  46. data/lib/knoxcall/route_refusal.rb +67 -0
  47. data/lib/knoxcall/signup.rb +122 -0
  48. data/lib/knoxcall/token_exchange.rb +169 -0
  49. data/lib/knoxcall/ulid.rb +19 -0
  50. data/lib/knoxcall/warnings.rb +63 -0
  51. data/lib/knoxcall/workload_provider.rb +192 -0
  52. data/lib/knoxcall/wrap_faraday_adapter.rb +119 -0
  53. data/lib/knoxcall/wrap_faraday_middleware.rb +67 -0
  54. data/lib/knoxcall/wrap_transport.rb +139 -0
  55. data/lib/knoxcall.rb +45 -1
  56. metadata +70 -9
@@ -0,0 +1,1025 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "json"
4
+ require "openssl"
5
+ require "time"
6
+ require "date"
7
+
8
+ module KnoxCall
9
+ # KnoxCall API client.
10
+ #
11
+ # client = KnoxCall::Client.new(tenant: "acme", access_token: "kc_live_...")
12
+ # routes = client.routes.list
13
+ # res = client.call("route-uuid", path: "/v1/orders")
14
+ #
15
+ # Credentials can be passed flat (access_token:/api_key: or
16
+ # client_id: + client_secret:), as a bootstrap: object, or resolved from
17
+ # KNOXCALL_* environment variables — `KnoxCall::Client.new` works zero-arg
18
+ # when KNOXCALL_TENANT plus a credential are set in the environment.
19
+ # Zero-arg resolution order (PARITY §2): KNOXCALL_ACCESS_TOKEN /
20
+ # KNOXCALL_API_KEY → the `knoxcall login` credentials file
21
+ # (~/.knoxcall/credentials.json) → KNOXCALL_CLIENT_ID + KNOXCALL_CLIENT_SECRET.
22
+ #
23
+ # The client is Mutex-safe: a single instance can be shared across Puma /
24
+ # Sidekiq threads. The token cache is single-flight — concurrent callers
25
+ # block on one token request instead of stampeding the token endpoint.
26
+ class Client
27
+ RETRYABLE_STATUSES = [408, 429, 500, 502, 503, 504].freeze # NOT 409 — a real conflict does not resolve by replaying
28
+
29
+ # The value +request+ returns for a 304 Not Modified when the caller opted
30
+ # in with +allow_not_modified:+ (a conditional GET carrying If-None-Match).
31
+ # Internal: the one consumer is +wrap.intercept_manifest(if_none_match:)+,
32
+ # which maps it to nil. Without the opt-in a 304 keeps its old shape (an
33
+ # empty body read as nil), so nothing else changes.
34
+ NOT_MODIFIED = Object.new
35
+ def NOT_MODIFIED.inspect = "KnoxCall::Client::NOT_MODIFIED"
36
+ NOT_MODIFIED.freeze
37
+ # The dated API version this SDK is built against. Sent as the
38
+ # `KnoxCall-Version` header on every management request so the SDK stays
39
+ # pinned to a known API shape even after the server ships a newer default
40
+ # (see the server's src/client-api/versioning.ts). Must be a version the
41
+ # server's registry knows, or requests are rejected 400.
42
+ DEFAULT_API_VERSION = "2026-08-05"
43
+ # Honor a server Retry-After up to this long; beyond it, fail fast so
44
+ # callers can apply their own scheduling instead of blocking a worker.
45
+ RETRY_AFTER_CAP_SECONDS = 30.0
46
+ REFRESH_AHEAD_SECONDS = 300.0
47
+ # A cached token inside the refresh-ahead window is still usable this
48
+ # long before real expiry; used when the token endpoint is down.
49
+ STALE_TOKEN_MIN_REMAINING_SECONDS = 10.0
50
+
51
+ METHOD_CLASSES = {
52
+ "GET" => Net::HTTP::Get,
53
+ "HEAD" => Net::HTTP::Head,
54
+ "POST" => Net::HTTP::Post,
55
+ "PUT" => Net::HTTP::Put,
56
+ "PATCH" => Net::HTTP::Patch,
57
+ "DELETE" => Net::HTTP::Delete,
58
+ "OPTIONS" => Net::HTTP::Options
59
+ }.freeze
60
+
61
+ # Transport failures where the connection was never established — the
62
+ # request never left the machine, so a retry is safe for any method.
63
+ CONNECT_ERRORS = [Errno::ECONNREFUSED, Net::OpenTimeout, SocketError].freeze
64
+
65
+ # Management hosts whose data plane lives on a per-tenant subdomain —
66
+ # sandbox hosts use the sandbox- prefixed shape (node core.ts is the
67
+ # reference; any other host is self-hosted and proxies on itself).
68
+ SANDBOX_PROXY_HOSTS = %w[sandbox.knoxcall.com sandbox-staging.knoxcall.com].freeze
69
+ PLAIN_PROXY_HOSTS = %w[api.knoxcall.com api-staging.knoxcall.com].freeze
70
+ # The labels under knoxcall.com that are NOT a tenant's data-plane host
71
+ # (management + marketing); see .data_plane_path_prefix.
72
+ NON_TENANT_LABELS = %w[api sandbox api-staging sandbox-staging www staging admin].freeze
73
+ CLOUD_TENANT_HOST_RE = /\A([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.knoxcall\.com\z/
74
+
75
+ # Where the data plane lives under a proxy base (PARITY §5).
76
+ #
77
+ # On a KnoxCall CLOUD tenant host the proxy is served ONLY under +/api+
78
+ # (+https://{slug}.knoxcall.com/api/<upstream path>+: server.ts strips the
79
+ # prefix, and every other path on that host is the dashboard). +call+
80
+ # therefore places the upstream path under +/api+ whenever the base names
81
+ # such a host and carries no path of its own — the derived plain/sandbox
82
+ # shapes and an explicit override alike, any port. Every other base is used
83
+ # verbatim: self-hosted mounts the proxy at +/+, and a base that already
84
+ # carries a path IS the entry point (the agent bundle spells the same base
85
+ # as +…knoxcall.com/api+). Until 2026-09-25 nothing added the prefix, so the
86
+ # documented +path: "/users"+ answered the dashboard HTML on every tenant
87
+ # host; the live smokes hid it by hard-coding +path: "/api/get"+.
88
+ def self.data_plane_path_prefix(proxy_base_url)
89
+ uri = URI.parse(proxy_base_url.to_s)
90
+ return "" unless uri.path.nil? || uri.path.empty? || uri.path == "/"
91
+
92
+ m = CLOUD_TENANT_HOST_RE.match(uri.host.to_s.downcase)
93
+ return "" if m.nil? || NON_TENANT_LABELS.include?(m[1])
94
+
95
+ "/api"
96
+ rescue URI::InvalidURIError
97
+ ""
98
+ end
99
+
100
+ # A tenant slug becomes a data-plane hostname (https://<slug>.knoxcall.com),
101
+ # so before it is interpolated into a host it MUST be a bare DNS label — a
102
+ # hostile slug adopted from a token response, /v1/account, or the
103
+ # credentials file (e.g. "evil.com#") would otherwise misdirect the tenant's
104
+ # bearer token to an attacker-controlled host (PARITY §2). Anchored with
105
+ # \A..\z (never ^..$) so a value embedding a newline can't satisfy a
106
+ # line-anchored match; case-insensitive to mirror node core.ts.
107
+ TENANT_SLUG_RE = /\A[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\z/i
108
+
109
+ CONSTRUCT_EVENT_FORMATS = %w[legacy stripe github slack aws-sns custom].freeze
110
+
111
+ # Auth-bearing headers the proxy data plane consumes to identify the caller
112
+ # (PARITY §5). The SDK's own credential is the SOLE authority on the data
113
+ # plane, so any caller-supplied copy of these is stripped from
114
+ # call()/ephemeral() headers before the SDK sets its own — otherwise an
115
+ # integrator forwarding untrusted end-user headers could inject an alternate
116
+ # proxy identity (x-knoxcall-agent-*) or, on the legacy-key path, a Bearer
117
+ # Authorization the proxy would honor over the SDK's own x-knoxcall-key.
118
+ PROXY_AUTH_HEADERS = %w[
119
+ authorization dpop x-knoxcall-key x-knoxcall-agent-id x-knoxcall-agent-token
120
+ ].freeze
121
+
122
+ # Markers the SDK owns on the data plane (PARITY §21.2). Not auth — the
123
+ # server treats them as informational — but a caller-supplied copy is
124
+ # stripped the same way, so an app cannot relabel its own calls as
125
+ # interceptor traffic through the headers Hash. The interceptors set the
126
+ # marker through +call(..., _origin: SDK_INTERCEPT_ORIGIN)+, never headers.
127
+ SDK_MARKER_HEADERS = %w[x-knoxcall-origin].freeze
128
+
129
+ # The one value +call+'s internal +_origin:+ accepts: the route-aware
130
+ # interceptors' reroute marker, sent as +x-knoxcall-origin: sdk-intercept+
131
+ # so the API Log can show which Route calls the SDK rerouted from a
132
+ # third-party SDK and which were direct. Internal — nothing public sets it.
133
+ SDK_INTERCEPT_ORIGIN = "sdk-intercept"
134
+
135
+ # The management base URL, the data-plane base URL (nil until the tenant
136
+ # is discovered) and the default environment — read by the route-aware
137
+ # wrap pipeline (own-host refusal; the manifest's environment).
138
+ attr_reader :base_url, :proxy_base_url, :environment
139
+ attr_reader :tenant, :api_version, :sandbox, :routes, :secrets, :webhooks, :workflows, :clients, :oauth_clients,
140
+ :environments, :api_keys, :roles, :account, :audit_logs, :logs, :agents,
141
+ :crypto, :pki, :vaults, :dynamic_db, :ai_gateway, :wrap, :opportunities
142
+
143
+ def initialize(opts = {})
144
+ # Tenant is optional: when absent it is discovered from the first token
145
+ # response (or /v1/account for pre-acquired tokens). Only the data-plane
146
+ # hostname needs it client-side; management calls resolve the tenant
147
+ # server-side from the credential.
148
+ @tenant = opts[:tenant] || ENV["KNOXCALL_TENANT"]
149
+ @tenant = nil if @tenant && @tenant.empty?
150
+ # Default environment for data-plane calls; per-call and bound-route
151
+ # values win, and nil means the server picks the tenant default.
152
+ @environment = opts[:environment] || ENV["KNOXCALL_ENVIRONMENT"]
153
+
154
+ # Mutual exclusion is enforced on the EXPLICITLY passed options before
155
+ # any env fill, so a stray environment variable never masks a caller
156
+ # mistake — and env fill is skipped entirely once anything explicit
157
+ # (flat or bootstrap:) was passed.
158
+ explicit = {
159
+ client_id: opts[:client_id],
160
+ client_secret: opts[:client_secret],
161
+ access_token: opts[:access_token],
162
+ api_key: opts[:api_key]
163
+ }.compact
164
+ if opts[:bootstrap] && !explicit.empty?
165
+ raise ArgumentError, "bootstrap: cannot be combined with #{explicit.keys.join(', ')}"
166
+ end
167
+ if explicit.key?(:access_token) && explicit.key?(:api_key)
168
+ raise ArgumentError,
169
+ "pass either access_token or api_key, not both (they are two spellings of the same credential)"
170
+ end
171
+ token = explicit[:access_token] || explicit[:api_key]
172
+ if token && (explicit.key?(:client_id) || explicit.key?(:client_secret))
173
+ raise ArgumentError, "a token credential cannot be combined with client_id/client_secret"
174
+ end
175
+ if explicit.key?(:client_id) != explicit.key?(:client_secret)
176
+ raise ArgumentError, "client_id and client_secret must be provided together"
177
+ end
178
+
179
+ @bootstrap = opts[:bootstrap]
180
+ if @bootstrap || !explicit.empty?
181
+ @api_key = token
182
+ @client_id = explicit[:client_id]
183
+ @client_secret = explicit[:client_secret]
184
+ else
185
+ # Two spellings, one behavior; KNOXCALL_ACCESS_TOKEN wins when both are set.
186
+ @api_key = ENV["KNOXCALL_ACCESS_TOKEN"] || ENV["KNOXCALL_API_KEY"]
187
+ if @api_key.nil? && CredentialsFile.available?
188
+ # Chain slot 2 (PARITY §2): the credentials file written by
189
+ # `knoxcall login`. Ruby has no cloud auto-detect, so zero-arg
190
+ # resolution is: env access token → credentials file → env
191
+ # client-credentials — a file check only, no network I/O. Present =
192
+ # file exists AND the selected profile parses; anything
193
+ # missing/malformed skips the provider silently.
194
+ @bootstrap = StoredCredentials.new
195
+ end
196
+ unless @bootstrap
197
+ @client_id = ENV["KNOXCALL_CLIENT_ID"]
198
+ @client_secret = ENV["KNOXCALL_CLIENT_SECRET"]
199
+ end
200
+ end
201
+
202
+ @timeout = opts[:timeout] || 30
203
+
204
+ @retry_max_attempts = opts[:retry_max_attempts] || 3
205
+ @retry_base_delay = opts[:retry_base_delay] || 0.1
206
+ @retry_max_delay = opts[:retry_max_delay] || 5.0
207
+
208
+ # DPoP (RFC 9449, PARITY §7): "auto" starts Bearer and upgrades when
209
+ # the oauth client requires proofs; "always" generates the keypair up
210
+ # front; "never" opts out (a DPoP-bound token then raises).
211
+ @dpop_mode = (opts[:dpop] || "auto").to_s
212
+ unless %w[auto always never].include?(@dpop_mode)
213
+ raise ArgumentError,
214
+ %(invalid dpop mode #{opts[:dpop].inspect} — expected "auto", "always", or "never")
215
+ end
216
+ @dpop_key = @dpop_mode == "always" ? DpopKeyPair.generate : nil
217
+ # Hook for JSON-encoding caller-specific objects (called for values
218
+ # JSON.generate can't represent natively; must return an encodable value).
219
+ @json_encoder = opts[:json_encoder]
220
+
221
+ # Dated API version pinned on every management request via the
222
+ # `KnoxCall-Version` header; caller may override, else DEFAULT_API_VERSION.
223
+ @api_version = opts[:api_version] || DEFAULT_API_VERSION
224
+
225
+ # Sandbox / Test mode (Stripe-style isolated environment): defaults the
226
+ # management base to https://sandbox.knoxcall.com and the data plane to
227
+ # https://sandbox-{tenant}.knoxcall.com. Requires a tk_test_… API key.
228
+ # An explicit base_url (or the base-URL env vars) wins over the sandbox
229
+ # default — mirrors node core.ts.
230
+ sandbox = opts[:sandbox] == true
231
+ # Exposed via attr_reader :sandbox — the wrap Faraday transport reads it
232
+ # for the both-must-agree Test/Live key check (PARITY §18). Always a
233
+ # boolean, never nil.
234
+ @sandbox = sandbox
235
+ default_base = sandbox ? "https://sandbox.#{DEFAULT_CLOUD_HOST}" : DEFAULT_API_BASE
236
+ # KNOXCALL_BASE_URL is canonical; KNOXCALL_API_BASE_URL is the legacy
237
+ # spelling and loses when both are set.
238
+ @base_url = (opts[:base_url] || ENV["KNOXCALL_BASE_URL"] ||
239
+ ENV["KNOXCALL_API_BASE_URL"] || default_base).chomp("/")
240
+
241
+ # The credentials file's tenant/base_url seed the client only when the
242
+ # caller didn't set them explicitly — constructor options, env vars,
243
+ # and sandbox: always win. Seeding runs before the proxy-host
244
+ # derivation below, so the data plane follows the seeded values.
245
+ if @bootstrap.is_a?(StoredCredentials)
246
+ base_url_explicit = !!(opts[:base_url] || ENV["KNOXCALL_BASE_URL"] ||
247
+ ENV["KNOXCALL_API_BASE_URL"] || sandbox)
248
+ seed_from_stored_credentials(@bootstrap, base_url_explicit: base_url_explicit)
249
+ end
250
+
251
+ base_host = begin
252
+ URI.parse(@base_url).host.to_s.downcase
253
+ rescue URI::InvalidURIError
254
+ ""
255
+ end
256
+ # Subdomain shape to derive once the tenant is known (:plain/:sandbox).
257
+ @proxy_shape = nil
258
+ # nil proxy_base_url = derive lazily once the tenant is discovered.
259
+ @proxy_base_url =
260
+ if opts[:proxy_base_url]
261
+ opts[:proxy_base_url].chomp("/")
262
+ elsif (p = ENV["KNOXCALL_PROXY_BASE_URL"])
263
+ p.chomp("/")
264
+ elsif SANDBOX_PROXY_HOSTS.include?(base_host)
265
+ # Sandbox hosts: the per-tenant proxy lives on the sandbox-
266
+ # prefixed subdomain. Validate the slug before it becomes a host.
267
+ @proxy_shape = :sandbox
268
+ @tenant ? "https://sandbox-#{assert_tenant_slug(@tenant)}.#{DEFAULT_CLOUD_HOST}" : nil
269
+ elsif PLAIN_PROXY_HOSTS.include?(base_host)
270
+ @proxy_shape = :plain
271
+ @tenant ? "https://#{assert_tenant_slug(@tenant)}.#{DEFAULT_CLOUD_HOST}" : nil
272
+ else
273
+ # Local dev / self-hosted: the proxy runs on the same host, so no
274
+ # tenant is needed.
275
+ @base_url
276
+ end
277
+
278
+ # Plaintext http:// to a non-loopback host sends credentials and tokens
279
+ # in the clear — warn once (never block: http://localhost is the normal
280
+ # dev case). Both the management base and the resolved data plane are
281
+ # checked. @proxy_base_url may still be nil here (derived lazily once the
282
+ # tenant is discovered), but any lazy derivation yields an https:// cloud
283
+ # host, so there is nothing plaintext left unchecked.
284
+ if Warnings.insecure_remote_url?(@base_url)
285
+ Warnings.warn_once(
286
+ "KNOXCALL_INSECURE_BASE_URL",
287
+ "KnoxCall base URL #{@base_url} uses plaintext http:// to a non-loopback host — " \
288
+ "credentials and access tokens will be sent unencrypted. Use https:// " \
289
+ "(plain http:// is only safe for localhost)."
290
+ )
291
+ end
292
+ if Warnings.insecure_remote_url?(@proxy_base_url)
293
+ Warnings.warn_once(
294
+ "KNOXCALL_INSECURE_PROXY_URL",
295
+ "KnoxCall proxy base URL #{@proxy_base_url} uses plaintext http:// to a non-loopback host — " \
296
+ "proxied requests and the SDK credential will be sent unencrypted. Use https:// " \
297
+ "(plain http:// is only safe for localhost)."
298
+ )
299
+ end
300
+
301
+ @token_cache = nil
302
+ @token_mutex = Mutex.new
303
+ @discovery_mutex = Mutex.new
304
+
305
+ @routes = Resources::Routes.new(self)
306
+ @secrets = Resources::Secrets.new(self)
307
+ @webhooks = Resources::Webhooks.new(self)
308
+ @workflows = Resources::Workflows.new(self)
309
+ @clients = Resources::Clients.new(self)
310
+ @oauth_clients = Resources::OAuthClients.new(self)
311
+ @environments = Resources::Environments.new(self)
312
+ @api_keys = Resources::ApiKeys.new(self)
313
+ @roles = Resources::Roles.new(self)
314
+ @account = Resources::Account.new(self)
315
+ @audit_logs = Resources::AuditLogs.new(self)
316
+ # Per-call proxy request log + Merkle inclusion proofs. Not the change
317
+ # log — that is +audit_logs+.
318
+ @logs = Resources::Logs.new(self)
319
+ @agents = Resources::Agents.new(self)
320
+ @crypto = Resources::Crypto.new(self)
321
+ @pki = Resources::Pki.new(self)
322
+ @vaults = Resources::Vaults.new(self)
323
+ @dynamic_db = Resources::DynamicDb.new(self)
324
+ @ai_gateway = Resources::AiGateway.new(self)
325
+ @wrap = Resources::Wrap.new(self)
326
+ @opportunities = Resources::Opportunities.new(self)
327
+ end
328
+
329
+ # Never dump credentials or the cached token when the client is inspected
330
+ # (consoles, loggers, exception trackers capturing locals).
331
+ def inspect
332
+ "#<KnoxCall::Client tenant=#{@tenant.inspect} base_url=#{@base_url.inspect}>"
333
+ end
334
+
335
+ # -- Token management -------------------------------------------------------
336
+
337
+ def token
338
+ @token_mutex.synchronize do
339
+ cached = @token_cache
340
+ return adopt_tenant(cached) if cached && token_fresh?(cached)
341
+ begin
342
+ adopt_tenant(@token_cache = fetch_token)
343
+ rescue Error
344
+ # Token endpoint unreachable or erroring during the refresh-ahead
345
+ # window: a cached token that hasn't actually expired is still
346
+ # good — use it rather than failing the caller's request.
347
+ raise unless cached && cached[:expires_at] - Time.now > STALE_TOKEN_MIN_REMAINING_SECONDS
348
+ adopt_tenant(cached)
349
+ end
350
+ end
351
+ end
352
+
353
+ def purge_token
354
+ @token_mutex.synchronize { @token_cache = nil }
355
+ end
356
+
357
+ # Learn the tenant from a token response when constructed without one.
358
+ # Called with @token_mutex held.
359
+ def adopt_tenant(cached)
360
+ @tenant ||= cached[:tenant] if cached[:tenant]
361
+ cached
362
+ end
363
+
364
+ # Resolve the data-plane base URL, discovering the tenant if needed: the
365
+ # token response carries the slug; pre-acquired tokens (and older servers)
366
+ # fall back to one GET /v1/account. @discovery_mutex guarantees the
367
+ # discovery runs at most once even under concurrent first calls.
368
+ def ensure_proxy_base_url
369
+ return @proxy_base_url if @proxy_base_url
370
+
371
+ @discovery_mutex.synchronize do
372
+ return @proxy_base_url if @proxy_base_url # discovered while we waited
373
+
374
+ token if @tenant.nil? # may adopt the tenant from the token response
375
+ if @tenant.nil?
376
+ account = request("GET", "/v1/account")
377
+ slug = account.is_a?(Hash) ? account.dig("data", "slug") : nil
378
+ unless slug.is_a?(String) && !slug.empty?
379
+ raise Error,
380
+ "could not discover the tenant from the credential — " \
381
+ "pass tenant: ... or set the KNOXCALL_TENANT environment variable"
382
+ end
383
+ @tenant = slug
384
+ end
385
+ @proxy_base_url =
386
+ if @proxy_shape == :sandbox
387
+ "https://sandbox-#{assert_tenant_slug(@tenant)}.#{DEFAULT_CLOUD_HOST}"
388
+ else
389
+ "https://#{assert_tenant_slug(@tenant)}.#{DEFAULT_CLOUD_HOST}"
390
+ end
391
+ end
392
+ end
393
+
394
+ # -- HTTP core --------------------------------------------------------------
395
+
396
+ # Management API request: typed errors on HTTP failure, retries on
397
+ # 408/429/5xx with half-jitter backoff, one transparent re-auth on 401,
398
+ # and a per-logical-request idempotency key on mutating methods.
399
+ #
400
+ # With +allow_not_modified: true+ a 304 Not Modified is a success with no
401
+ # body and returns +NOT_MODIFIED+ instead of reading the empty body; auth,
402
+ # the one transparent re-auth on 401 and the retry policy are unchanged,
403
+ # and +headers+ (the If-None-Match) ride on every attempt.
404
+ def request(method, path, query: nil, body: nil, headers: nil, allow_not_modified: false)
405
+ method = method.to_s.upcase
406
+ idem_key = ULID.generate unless %w[GET HEAD].include?(method)
407
+
408
+ reauth_done = false
409
+ attempt = 0
410
+ loop do
411
+ attempt += 1
412
+ begin
413
+ tok = token
414
+ uri = URI.parse(@base_url + normalize_path(path))
415
+ uri.query = URI.encode_www_form(query.compact) if query && !query.empty?
416
+
417
+ req = build_request(method, uri)
418
+ (headers || {}).each { |k, v| req[k] = v }
419
+ req["Accept"] ||= "application/json"
420
+ # Pin the API version so a newer server default can't silently change
421
+ # the response shape under us; a caller-set header still wins.
422
+ req["KnoxCall-Version"] ||= @api_version
423
+ req["X-Idempotency-Key"] = idem_key if idem_key
424
+ # SDK-set Authorization always wins (Net::HTTP headers are
425
+ # case-insensitive); caller-set Content-Type is respected.
426
+ req["Authorization"] = "#{tok[:token_type]} #{tok[:access_token]}"
427
+ req["DPoP"] = dpop_proof(method, uri.to_s, tok[:access_token]) if tok[:token_type] == "DPoP"
428
+ encode_body(body, req)
429
+
430
+ resp = perform(uri, req)
431
+ # A conditional GET the server answered "unchanged": success, no body.
432
+ return NOT_MODIFIED if allow_not_modified && resp.code.to_i == 304
433
+ return handle_response(resp)
434
+ rescue AuthenticationError
435
+ # One transparent re-auth: purge the cached token so the immediate
436
+ # retry runs with freshly minted credentials.
437
+ purge_token
438
+ raise if reauth_done || attempt >= @retry_max_attempts
439
+ reauth_done = true
440
+ rescue APIError => e
441
+ raise unless attempt < @retry_max_attempts && RETRYABLE_STATUSES.include?(e.status_code)
442
+ sleep retry_delay(e, attempt)
443
+ rescue ConnectionTimeoutError, Net::OpenTimeout, Net::ReadTimeout => e
444
+ raise as_sdk_error(e) unless attempt < @retry_max_attempts
445
+ sleep backoff_delay(attempt)
446
+ rescue NetworkError, OpenSSL::SSL::SSLError, EOFError, SocketError, SystemCallError, IOError => e
447
+ raise as_sdk_error(e) unless attempt < @retry_max_attempts
448
+ sleep backoff_delay(attempt)
449
+ end
450
+ end
451
+ end
452
+
453
+ # -- Proxy helpers ----------------------------------------------------------
454
+
455
+ # Make a proxied request through a KnoxCall route.
456
+ # Pass the route UUID (preferred) or name.
457
+ #
458
+ # Returns the raw Net::HTTPResponse — the proxied upstream's status
459
+ # belongs to the caller and is never raised. Transport failures map to
460
+ # NetworkError / ConnectionTimeoutError and are retried only when safe;
461
+ # a rejected token is purged and re-minted once. `timeout:` overrides the
462
+ # client timeout for this call.
463
+ #
464
+ # +_origin:+ is internal: the route-aware interceptors pass
465
+ # +SDK_INTERCEPT_ORIGIN+ so the request carries
466
+ # +x-knoxcall-origin: sdk-intercept+ (PARITY §21.2). A direct call sends
467
+ # nothing — absence IS "direct" on the server.
468
+ def call(route, method: "GET", path: "/", body: nil, headers: {}, environment: nil, query: nil, timeout: nil,
469
+ _origin: nil)
470
+ sdk_headers = { "x-knoxcall-route" => route }
471
+ environment ||= @environment
472
+ sdk_headers["x-knoxcall-environment"] = environment if environment
473
+ unless _origin.nil?
474
+ unless _origin == SDK_INTERCEPT_ORIGIN
475
+ raise ArgumentError, "unknown call origin #{_origin.inspect}; the only marker is #{SDK_INTERCEPT_ORIGIN.inspect}"
476
+ end
477
+
478
+ sdk_headers["x-knoxcall-origin"] = SDK_INTERCEPT_ORIGIN
479
+ end
480
+
481
+ # +path+ is the UPSTREAM path; the entry point is the SDK's to add (PARITY §5).
482
+ base = ensure_proxy_base_url
483
+ proxy_send(method, base + Client.data_plane_path_prefix(base) + normalize_path(path),
484
+ headers: headers, sdk_headers: sdk_headers,
485
+ query: query, body: body, timeout: timeout,
486
+ legacy_key_as_header: true)
487
+ end
488
+
489
+ # Bind a route (and optional call defaults) once, then make plain
490
+ # HTTP-verb calls against it:
491
+ #
492
+ # printnode = client.route("3f1e2c9a-...", environment: "production")
493
+ # computers = JSON.parse(printnode.get("/computers").body)
494
+ # printnode.post("/printjobs", body: payload)
495
+ def route(route, environment: nil, headers: {}, timeout: nil)
496
+ BoundRoute.new(self, route, environment: environment, headers: headers, timeout: timeout)
497
+ end
498
+
499
+ # Make a one-shot proxied request via the Ephemeral Proxy.
500
+ def ephemeral(upstream_url, method: "GET", body: nil, headers: {}, encrypted: nil, timeout_ms: nil, timeout: nil,
501
+ mode: nil, upstream_authorization: nil, upstream_auth_secret: nil, upstream_auth_scheme: nil)
502
+ sdk_headers = { "X-Knox-Proxy-URL" => upstream_url }
503
+ sdk_headers["X-Knox-Encrypted"] = encrypted if encrypted
504
+ sdk_headers["X-Knox-Timeout-Ms"] = timeout_ms.to_s if timeout_ms
505
+ sdk_headers["X-Knox-Proxy-Mode"] = "transparent" if mode == "transparent"
506
+ sdk_headers["X-Knox-Upstream-Authorization"] = upstream_authorization unless upstream_authorization.nil?
507
+ sdk_headers["X-Knox-Upstream-Auth-Secret"] = upstream_auth_secret unless upstream_auth_secret.nil?
508
+ sdk_headers["X-Knox-Upstream-Auth-Scheme"] = upstream_auth_scheme unless upstream_auth_scheme.nil?
509
+
510
+ proxy_send(method, @base_url + "/v1/proxy",
511
+ headers: headers, sdk_headers: sdk_headers,
512
+ body: body, timeout: timeout)
513
+ end
514
+
515
+ # Verify a KnoxCall webhook HMAC-SHA256 signature.
516
+ def self.verify_signature(raw_body, signature, secret, tolerance_seconds: 300, timestamp: nil)
517
+ parts = {}
518
+ signature.split(",").each do |part|
519
+ part = part.strip
520
+ parts[:t] = part[2..] if part.start_with?("t=")
521
+ parts[:v1] = part[3..] if part.start_with?("v1=")
522
+ end
523
+
524
+ return false unless parts[:t] && parts[:v1]
525
+
526
+ if tolerance_seconds > 0 && timestamp
527
+ return false if (timestamp - parts[:t].to_i).abs > tolerance_seconds
528
+ end
529
+
530
+ expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{parts[:t]}.#{raw_body}")
531
+ OpenSSL::HMAC.hexdigest("SHA256", secret, parts[:v1]) == OpenSSL::HMAC.hexdigest("SHA256", secret, expected)
532
+ end
533
+
534
+ def verify_signature(...) = self.class.verify_signature(...)
535
+
536
+ # Verify an incoming webhook delivery AND parse it in one step.
537
+ #
538
+ # Pass the RAW request body (never re-serialized JSON), the request
539
+ # headers (looked up case-insensitively), and the endpoint secret. On
540
+ # success returns the delivery envelope as a Hash with string keys:
541
+ #
542
+ # {
543
+ # "event" => String, # e.g. "request.success", "audit.event" —
544
+ # # open list, unknown types parse fine
545
+ # "timestamp" => String, # ISO-8601
546
+ # "webhook_id" => String, # present on request.* events
547
+ # "webhook_name" => String, # present on request.* events
548
+ # "data" => Hash # request.*: {"route_id", "route_name",
549
+ # # "environment", "request" => {"method", "path", "ip"},
550
+ # # "response" => {"status", "latency_ms"}}
551
+ # # audit.event: {"id", "action", "resource_type",
552
+ # # "resource_id", "details", "ip_address"}
553
+ # }
554
+ #
555
+ # Also available as +client.construct_event+ and
556
+ # +client.webhooks.construct_event+.
557
+ #
558
+ # @param raw_body [String] the raw request body bytes
559
+ # @param headers [Hash] request headers, any casing (values may be arrays)
560
+ # @param secret [String] the webhook's endpoint secret
561
+ # @param format [String] one of legacy|stripe|github|slack|aws-sns|custom
562
+ # (default "legacy") — must match the webhook's configured hmac_format
563
+ # @param tolerance_seconds [Integer, nil] replay window, default 300. For
564
+ # stripe/slack the check runs against the signed header timestamp; for
565
+ # the other formats against the envelope's "timestamp" field. Pass nil
566
+ # (or 0) to disable all timestamp checks.
567
+ # @param header_name [String, nil] required when format is "custom",
568
+ # ignored otherwise
569
+ # @return [Hash] the parsed delivery envelope
570
+ # @raise [WebhookSignatureVerificationError] on ANY verification failure
571
+ # (missing header, signature mismatch, stale timestamp, body not a JSON
572
+ # object) — never returns a partial event, and the message never echoes
573
+ # the signature or secret
574
+ # @raise [ArgumentError] on option misuse (unknown format, missing
575
+ # header_name for "custom")
576
+ def self.construct_event(raw_body, headers, secret, format: "legacy", tolerance_seconds: 300, header_name: nil)
577
+ unless CONSTRUCT_EVENT_FORMATS.include?(format)
578
+ raise ArgumentError, "format must be one of #{CONSTRUCT_EVENT_FORMATS.join(', ')}"
579
+ end
580
+ # Explicit nil/0 disables replay protection entirely (documented).
581
+ tolerance = tolerance_seconds && tolerance_seconds.to_i.positive? ? tolerance_seconds.to_i : nil
582
+ now = Time.now.to_i
583
+
584
+ # Case-insensitive header lookup; multi-value headers use the first value.
585
+ lower = {}
586
+ (headers || {}).each do |name, value|
587
+ lower[name.to_s.downcase] = (value.is_a?(Array) ? value.first : value).to_s
588
+ end
589
+ fetch_header = lambda do |name|
590
+ value = lower[name.downcase]
591
+ if value.nil? || value.strip.empty?
592
+ raise WebhookSignatureVerificationError, "missing signature header #{name}"
593
+ end
594
+ value.strip
595
+ end
596
+ # hex signatures arrive as `<prefix><hex>` (e.g. sha256=…, v0=…); the
597
+ # prefix is shape, not signature — strip it when present.
598
+ strip_prefix = ->(value, prefix) { value.start_with?(prefix) ? value[prefix.length..] : value }
599
+
600
+ case format
601
+ when "legacy", "github", "custom"
602
+ signature_header =
603
+ case format
604
+ when "legacy" then "X-Webhook-Signature"
605
+ when "github" then "X-Hub-Signature-256"
606
+ else
607
+ header_name or raise ArgumentError, "header_name is required when format is \"custom\""
608
+ end
609
+ signature = strip_prefix.call(fetch_header.call(signature_header), "sha256=")
610
+ expected = OpenSSL::HMAC.hexdigest("SHA256", secret, raw_body)
611
+ unless OpenSSL.secure_compare(expected, signature)
612
+ raise WebhookSignatureVerificationError, "signature mismatch (#{format} format)"
613
+ end
614
+ when "aws-sns"
615
+ signature = fetch_header.call("x-amz-sns-signature")
616
+ expected = [OpenSSL::HMAC.digest("SHA256", secret, raw_body)].pack("m0")
617
+ unless OpenSSL.secure_compare(expected, signature)
618
+ raise WebhookSignatureVerificationError, "signature mismatch (aws-sns format)"
619
+ end
620
+ when "stripe"
621
+ # `t=<ts>,v1=<hex>` — multiple comma-separated pairs allowed; any
622
+ # matching v1 passes (mirrors Stripe's own secret rotation).
623
+ ts = nil
624
+ candidates = []
625
+ fetch_header.call("Stripe-Signature").split(",").each do |part|
626
+ part = part.strip
627
+ ts = part[2..] if part.start_with?("t=")
628
+ candidates << part[3..] if part.start_with?("v1=")
629
+ end
630
+ unless ts&.match?(/\A\d+\z/) && !candidates.empty?
631
+ raise WebhookSignatureVerificationError, "malformed Stripe-Signature header"
632
+ end
633
+ if tolerance && (now - ts.to_i).abs > tolerance
634
+ raise WebhookSignatureVerificationError, "timestamp outside tolerance (stripe format)"
635
+ end
636
+ expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{ts}.#{raw_body}")
637
+ matched = false
638
+ candidates.each do |candidate|
639
+ # no early break — check every candidate, constant-time each
640
+ matched = true if OpenSSL.secure_compare(expected, candidate)
641
+ end
642
+ raise WebhookSignatureVerificationError, "signature mismatch (stripe format)" unless matched
643
+ when "slack"
644
+ ts = fetch_header.call("X-Slack-Request-Timestamp")
645
+ unless ts.match?(/\A\d+\z/)
646
+ raise WebhookSignatureVerificationError, "malformed X-Slack-Request-Timestamp header"
647
+ end
648
+ if tolerance && (now - ts.to_i).abs > tolerance
649
+ raise WebhookSignatureVerificationError, "timestamp outside tolerance (slack format)"
650
+ end
651
+ signature = strip_prefix.call(fetch_header.call("X-Slack-Signature"), "v0=")
652
+ expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "v0:#{ts}:#{raw_body}")
653
+ unless OpenSSL.secure_compare(expected, signature)
654
+ raise WebhookSignatureVerificationError, "signature mismatch (slack format)"
655
+ end
656
+ end
657
+
658
+ event = begin
659
+ JSON.parse(raw_body)
660
+ rescue JSON::ParserError
661
+ nil
662
+ end
663
+ raise WebhookSignatureVerificationError, "delivery body is not a JSON object" unless event.is_a?(Hash)
664
+
665
+ # Formats without a signed timestamp: enforce the replay window against
666
+ # the envelope's own ISO-8601 timestamp field.
667
+ if tolerance && %w[legacy github aws-sns custom].include?(format)
668
+ envelope_ts = begin
669
+ event["timestamp"].is_a?(String) ? Time.iso8601(event["timestamp"]).to_i : nil
670
+ rescue ArgumentError
671
+ nil
672
+ end
673
+ if envelope_ts.nil?
674
+ raise WebhookSignatureVerificationError,
675
+ "delivery timestamp missing or invalid (pass tolerance_seconds: nil to skip replay checks)"
676
+ end
677
+ if (now - envelope_ts).abs > tolerance
678
+ raise WebhookSignatureVerificationError, "timestamp outside tolerance (#{format} format)"
679
+ end
680
+ end
681
+
682
+ event
683
+ end
684
+
685
+ def construct_event(...) = self.class.construct_event(...)
686
+
687
+ private
688
+
689
+ # Shared data-plane sender for call()/ephemeral(). Proxied responses are
690
+ # returned raw, but transport failures are mapped to SDK error classes
691
+ # and retried when safe, and a 401 triggers one token purge + re-mint so
692
+ # a revoked token can't wedge a long-lived client.
693
+ def proxy_send(method, url, headers:, sdk_headers:, query: nil, body: nil, timeout: nil, legacy_key_as_header: false)
694
+ method = method.to_s.upcase
695
+ reauth_done = false
696
+ attempt = 0
697
+ loop do
698
+ attempt += 1
699
+ tok = token
700
+
701
+ uri = URI.parse(url)
702
+ uri.query = URI.encode_www_form(query.compact) if query && !query.empty?
703
+
704
+ req = build_request(method, uri)
705
+ (headers || {}).each { |k, v| req[k] = v }
706
+ # The SDK credential is the sole data-plane auth authority (PARITY §5):
707
+ # drop any caller-supplied proxy-auth headers before we set our own, so
708
+ # an integrator forwarding untrusted end-user headers can never inject
709
+ # an alternate proxy identity (x-knoxcall-agent-*) or, on the
710
+ # legacy-key path, a Bearer Authorization the proxy would honor over our
711
+ # x-knoxcall-key. Net::HTTP header deletion is case-insensitive, so any
712
+ # casing the caller used is covered.
713
+ PROXY_AUTH_HEADERS.each { |h| req.delete(h) }
714
+ SDK_MARKER_HEADERS.each { |h| req.delete(h) }
715
+ # Explicit keyword arguments always win over the headers dict,
716
+ # and SDK-set auth headers always win.
717
+ sdk_headers.each { |k, v| req[k] = v }
718
+ # legacy_key_as_header is set on call() requests: the route proxy's
719
+ # OAuth detection matches the kc_ token prefix only, so a legacy
720
+ # tk_/AKE credential must travel as x-knoxcall-key (Bearer would
721
+ # fall through to the legacy path and 401). ephemeral() targets
722
+ # /v1/proxy, whose auth accepts any credential format as Bearer.
723
+ if legacy_key_as_header && tok[:token_type] == "Bearer" && !tok[:access_token].start_with?("kc_")
724
+ req["x-knoxcall-key"] = tok[:access_token]
725
+ else
726
+ req["Authorization"] = "#{tok[:token_type]} #{tok[:access_token]}"
727
+ req["DPoP"] = dpop_proof(method, uri.to_s, tok[:access_token]) if tok[:token_type] == "DPoP"
728
+ end
729
+ encode_body(body, req)
730
+
731
+ begin
732
+ resp = perform(uri, req, timeout: timeout)
733
+ rescue *CONNECT_ERRORS => e
734
+ # Never reached the wire — safe to retry, even for mutating methods.
735
+ raise as_sdk_error(e) unless attempt < @retry_max_attempts
736
+ sleep backoff_delay(attempt)
737
+ next
738
+ rescue Net::ReadTimeout, OpenSSL::SSL::SSLError, EOFError, SystemCallError, IOError => e
739
+ # Anything later (read timeout, idle-keepalive reset, ...) may have
740
+ # reached the upstream; only replay methods that are safe to repeat.
741
+ if %w[GET HEAD].include?(method) && attempt < @retry_max_attempts
742
+ sleep backoff_delay(attempt)
743
+ next
744
+ end
745
+ raise as_sdk_error(e)
746
+ end
747
+
748
+ # A 401 the UPSTREAM answered and the data plane relayed (the response
749
+ # block's X-Knox-Upstream-Status, or the ephemeral proxy's older
750
+ # X-Knox-Destination-Status) says nothing about OUR token: it is the
751
+ # caller's to handle, and spending the one re-mint on it would leave a
752
+ # real revocation un-recoverable on this call. Only a KnoxCall-origin
753
+ # 401 triggers the purge + re-mint. Mirrors node core.ts #proxySend.
754
+ if resp.code.to_i == 401 && !reauth_done && !upstream_answered?(resp)
755
+ purge_token
756
+ reauth_done = true
757
+ next
758
+ end
759
+ return resp
760
+ end
761
+ end
762
+
763
+ # Whether a data-plane response came from the upstream (relayed) rather
764
+ # than from KnoxCall itself.
765
+ def upstream_answered?(resp)
766
+ !resp["X-Knox-Upstream-Status"].nil? || !resp["X-Knox-Destination-Status"].nil?
767
+ end
768
+
769
+ # Validate a tenant slug before it is interpolated into a data-plane host
770
+ # (PARITY §2). Rejects with the base bootstrap error (KnoxCall::Error, the
771
+ # Ruby analogue of node's BootstrapError) so a hostile slug adopted from a
772
+ # token response, /v1/account, or the credentials file never reaches the
773
+ # wire. Returns the slug so it reads inline in the host interpolation.
774
+ def assert_tenant_slug(tenant)
775
+ unless tenant.is_a?(String) && TENANT_SLUG_RE.match?(tenant)
776
+ raise Error,
777
+ "invalid tenant slug #{tenant.inspect} — expected a DNS label; " \
778
+ "refusing to derive a data-plane host from it"
779
+ end
780
+ tenant
781
+ end
782
+
783
+ def resolve_bootstrap
784
+ @bootstrap ||=
785
+ if @api_key
786
+ AccessToken.new(access_token: @api_key)
787
+ elsif @client_id && @client_secret
788
+ ClientCredentials.new(client_id: @client_id, client_secret: @client_secret)
789
+ else
790
+ # Auto-detection found nothing usable — distinctly typed so callers
791
+ # can branch on "not logged in — offer KnoxCall.login" (PARITY §1/§14),
792
+ # while still a KnoxCall::Error subclass so existing rescues hold.
793
+ raise NotAuthenticatedError,
794
+ "No credentials: run `knoxcall login`, pass access_token/api_key or " \
795
+ "client_id + client_secret (or bootstrap:), or set " \
796
+ "KNOXCALL_ACCESS_TOKEN / KNOXCALL_API_KEY or " \
797
+ "KNOXCALL_CLIENT_ID + KNOXCALL_CLIENT_SECRET"
798
+ end
799
+ end
800
+
801
+ # Seed tenant/base_url from the `knoxcall login` credentials file when the
802
+ # caller did not set them explicitly (explicit constructor/env values
803
+ # always win). Missing/malformed file → no-op (the chain already vetted
804
+ # presence; an explicitly passed StoredCredentials fails later, at token
805
+ # fetch, with the re-login hint).
806
+ def seed_from_stored_credentials(stored, base_url_explicit:)
807
+ record = begin
808
+ CredentialsFile.read_profile(
809
+ CredentialsFile.resolve_path(stored.path),
810
+ CredentialsFile.resolve_profile(stored.profile)
811
+ )
812
+ rescue StandardError
813
+ nil
814
+ end
815
+ return unless record
816
+
817
+ file_tenant = record["tenant"]
818
+ @tenant ||= file_tenant if file_tenant.is_a?(String) && !file_tenant.empty?
819
+ file_base = record["base_url"]
820
+ if !base_url_explicit && file_base.is_a?(String) && !file_base.empty?
821
+ @base_url = file_base.chomp("/")
822
+ end
823
+ end
824
+
825
+ # Mints a token, handling the DPoP auto-upgrade (PARITY §7): in "auto"
826
+ # mode a first refusal with invalid_dpop_proof (the oauth client record
827
+ # requires DPoP) generates a keypair and retries the request ONCE — the
828
+ # client operates as DPoP thereafter. Called with @token_mutex held, so
829
+ # @dpop_key is read/written directly.
830
+ def fetch_token
831
+ bootstrap = resolve_bootstrap
832
+
833
+ if bootstrap.is_a?(AccessToken)
834
+ return { access_token: bootstrap.access_token, token_type: "Bearer",
835
+ expires_at: Time.now + 3600, lifetime: 3600.0,
836
+ tenant: nil } # pre-acquired tokens discover via /v1/account
837
+ end
838
+
839
+ if bootstrap.is_a?(StoredCredentials)
840
+ # Tokens come from the `knoxcall login` credentials file. The file is
841
+ # the cross-process cache and refresh authority (single-use rotated
842
+ # refresh tokens, refreshed under the file lock); scope/DPoP posture
843
+ # is whatever login negotiated. @token_mutex is held here, so the
844
+ # in-process side of the lock is already serialized.
845
+ return CredentialsFile.fetch_stored_token(
846
+ path: CredentialsFile.resolve_path(bootstrap.path),
847
+ profile: CredentialsFile.resolve_profile(bootstrap.profile),
848
+ token_endpoint: @base_url + "/oauth/token",
849
+ timeout: @timeout
850
+ )
851
+ end
852
+
853
+ begin
854
+ token_request(bootstrap, @dpop_key)
855
+ rescue APIError => e
856
+ if @dpop_mode == "auto" && @dpop_key.nil? &&
857
+ e.body.is_a?(Hash) && e.body["error"] == "invalid_dpop_proof"
858
+ key = DpopKeyPair.generate
859
+ tok = token_request(bootstrap, key)
860
+ @dpop_key = key
861
+ return tok
862
+ end
863
+ raise
864
+ end
865
+ end
866
+
867
+ def token_request(bootstrap, dpop_key)
868
+ uri = URI.parse(@base_url + "/oauth/token")
869
+ req = Net::HTTP::Post.new(uri)
870
+ req["Content-Type"] = "application/x-www-form-urlencoded"
871
+ req["Accept"] = "application/json"
872
+ req["User-Agent"] = SDK_VERSION
873
+ req["DPoP"] = dpop_key.sign("POST", uri.to_s) if dpop_key
874
+
875
+ case bootstrap
876
+ when ClientCredentials
877
+ basic = ["#{bootstrap.client_id}:#{bootstrap.client_secret}"].pack("m0")
878
+ req["Authorization"] = "Basic #{basic}"
879
+ req.body = URI.encode_www_form(grant_type: "client_credentials")
880
+ when OIDCTokenExchange
881
+ req.body = URI.encode_www_form(
882
+ grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
883
+ subject_token_type: "urn:ietf:params:oauth:token-type:id_token",
884
+ subject_token: bootstrap.subject_token,
885
+ audience: "knoxcall:api"
886
+ )
887
+ else
888
+ raise Error, "unsupported bootstrap: #{bootstrap.class}"
889
+ end
890
+
891
+ resp = begin
892
+ perform(uri, req)
893
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
894
+ raise ConnectionTimeoutError, "token request timed out: #{e.message}"
895
+ rescue OpenSSL::SSL::SSLError, EOFError, SocketError, SystemCallError, IOError => e
896
+ raise NetworkError, "token request failed: #{e.class}: #{e.message}"
897
+ end
898
+
899
+ raise KnoxCall.error_from_response(resp) if resp.code.to_i >= 400
900
+
901
+ data = begin
902
+ JSON.parse(resp.body.to_s)
903
+ rescue JSON::ParserError
904
+ nil
905
+ end
906
+ unless data.is_a?(Hash) && data["access_token"].is_a?(String) && !data["access_token"].empty?
907
+ # e.g. an HTML page from an edge proxy with a 200 status
908
+ raise TokenError, "token endpoint returned an unexpected response (status #{resp.code})"
909
+ end
910
+ token_type = data["token_type"].to_s.casecmp("dpop").zero? ? "DPoP" : (data["token_type"] || "Bearer")
911
+ if token_type == "DPoP" && dpop_key.nil?
912
+ # Sending "Authorization: DPoP <token>" without a proof 401-loops
913
+ # forever — fail loudly instead (mode "never", or a server that
914
+ # binds tokens without challenging first).
915
+ raise TokenError, "server issued a DPoP-bound token but this client holds no DPoP keypair — " \
916
+ 'construct with dpop: "auto" or "always"'
917
+ end
918
+
919
+ lifetime = begin
920
+ Float(data["expires_in"] || 3600)
921
+ rescue ArgumentError, TypeError
922
+ 3600.0
923
+ end
924
+ tenant = data["tenant"]
925
+ {
926
+ access_token: data["access_token"],
927
+ token_type: token_type,
928
+ expires_at: Time.now + lifetime,
929
+ lifetime: lifetime,
930
+ tenant: tenant.is_a?(String) && !tenant.empty? ? tenant : nil
931
+ }
932
+ end
933
+
934
+ # Fresh per-request proof for a DPoP-bound token (new jti/iat + ath).
935
+ def dpop_proof(method, url, access_token)
936
+ key = @dpop_key
937
+ raise TokenError, "DPoP-bound token held without a DPoP keypair — this is a bug, please report it" unless key
938
+ key.sign(method, url, access_token: access_token)
939
+ end
940
+
941
+ def token_fresh?(tok)
942
+ # For short-lived tokens a fixed 5-minute window would mean "always
943
+ # expired", forcing a token fetch per request; never use more than
944
+ # half the token's lifetime as the refresh-ahead window.
945
+ ahead = tok[:lifetime] ? [REFRESH_AHEAD_SECONDS, tok[:lifetime] / 2.0].min : REFRESH_AHEAD_SECONDS
946
+ tok[:expires_at] - Time.now > ahead
947
+ end
948
+
949
+ def build_request(method, uri)
950
+ klass = METHOD_CLASSES[method.to_s.upcase] or raise ArgumentError, "Unknown method: #{method}"
951
+ req = klass.new(uri)
952
+ req["User-Agent"] = SDK_VERSION
953
+ req
954
+ end
955
+
956
+ # Serialize a request body, defaulting Content-Type to JSON. Strings pass
957
+ # through untouched (pre-serialized payloads with a caller Content-Type);
958
+ # anything else is JSON-encoded with Time/Date/DateTime/BigDecimal
959
+ # handling plus the client's json_encoder hook.
960
+ def encode_body(body, req)
961
+ return if body.nil?
962
+ req["Content-Type"] = "application/json" unless req["Content-Type"]
963
+ req.body = body.is_a?(String) ? body : JSON.generate(encode_json_value(body))
964
+ end
965
+
966
+ def encode_json_value(v)
967
+ return v.to_f if defined?(BigDecimal) && v.is_a?(BigDecimal)
968
+ case v
969
+ when Hash then v.each_with_object({}) { |(k, val), h| h[k] = encode_json_value(val) }
970
+ when Array then v.map { |e| encode_json_value(e) }
971
+ when Time, DateTime then v.iso8601
972
+ when Date then v.iso8601
973
+ when String, Numeric, Symbol, true, false, nil then v
974
+ else
975
+ @json_encoder ? encode_json_value(@json_encoder.call(v)) : v
976
+ end
977
+ end
978
+
979
+ def perform(uri, req, timeout: nil)
980
+ t = timeout || @timeout
981
+ http = Net::HTTP.new(uri.host, uri.port)
982
+ http.use_ssl = uri.scheme == "https"
983
+ http.open_timeout = t
984
+ http.read_timeout = t
985
+ http.start { |h| h.request(req) }
986
+ end
987
+
988
+ def as_sdk_error(e)
989
+ return e if e.is_a?(Error)
990
+ if e.is_a?(Net::OpenTimeout) || e.is_a?(Net::ReadTimeout)
991
+ ConnectionTimeoutError.new("connection timed out: #{e.message}")
992
+ else
993
+ NetworkError.new("network error: #{e.class}: #{e.message}")
994
+ end
995
+ end
996
+
997
+ def retry_delay(err, attempt)
998
+ if err.is_a?(RateLimitError) && err.retry_after
999
+ return [err.retry_after, RETRY_AFTER_CAP_SECONDS].min
1000
+ end
1001
+ backoff_delay(attempt)
1002
+ end
1003
+
1004
+ def backoff_delay(attempt)
1005
+ # Half-jitter: random within [exp/2, exp] so a retry never fires
1006
+ # immediately but herds still spread out.
1007
+ exp = @retry_base_delay * (2**(attempt - 1))
1008
+ [@retry_max_delay, exp * (0.5 + rand / 2)].min
1009
+ end
1010
+
1011
+ def normalize_path(path)
1012
+ path.start_with?("/") ? path : "/#{path}"
1013
+ end
1014
+
1015
+ def handle_response(resp)
1016
+ raise KnoxCall.error_from_response(resp) if resp.code.to_i >= 400
1017
+ return nil if resp.body.nil? || resp.body.empty?
1018
+ begin
1019
+ JSON.parse(resp.body)
1020
+ rescue JSON::ParserError
1021
+ resp.body
1022
+ end
1023
+ end
1024
+ end
1025
+ end