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.
- checksums.yaml +4 -4
- data/LICENSE +201 -0
- data/README.md +439 -2
- data/exe/knoxcall +8 -0
- data/lib/knoxcall/bootstrap.rb +70 -0
- data/lib/knoxcall/bound_route.rb +47 -0
- data/lib/knoxcall/cli/ai.rb +79 -0
- data/lib/knoxcall/cli/ai_control.rb +275 -0
- data/lib/knoxcall/cli/common.rb +96 -0
- data/lib/knoxcall/cli/init.rb +94 -0
- data/lib/knoxcall/cli/login.rb +306 -0
- data/lib/knoxcall/cli/logout.rb +41 -0
- data/lib/knoxcall/cli/whoami.rb +29 -0
- data/lib/knoxcall/cli.rb +377 -0
- data/lib/knoxcall/client.rb +1025 -0
- data/lib/knoxcall/credentials_file.rb +442 -0
- data/lib/knoxcall/dpop.rb +79 -0
- data/lib/knoxcall/egress_observations.rb +372 -0
- data/lib/knoxcall/errors.rb +304 -0
- data/lib/knoxcall/intercept_patch.rb +181 -0
- data/lib/knoxcall/intercept_pipeline.rb +455 -0
- data/lib/knoxcall/intercept_resolver.rb +140 -0
- data/lib/knoxcall/intercept_store.rb +203 -0
- data/lib/knoxcall/login.rb +144 -0
- data/lib/knoxcall/resources/account.rb +12 -0
- data/lib/knoxcall/resources/agents.rb +25 -0
- data/lib/knoxcall/resources/ai_gateway.rb +417 -0
- data/lib/knoxcall/resources/api_keys.rb +35 -0
- data/lib/knoxcall/resources/audit_logs.rb +45 -0
- data/lib/knoxcall/resources/clients.rb +39 -0
- data/lib/knoxcall/resources/crypto.rb +122 -0
- data/lib/knoxcall/resources/dynamic_db.rb +68 -0
- data/lib/knoxcall/resources/environments.rb +16 -0
- data/lib/knoxcall/resources/logs.rb +51 -0
- data/lib/knoxcall/resources/oauth_clients.rb +34 -0
- data/lib/knoxcall/resources/opportunities.rb +61 -0
- data/lib/knoxcall/resources/pki.rb +41 -0
- data/lib/knoxcall/resources/roles.rb +27 -0
- data/lib/knoxcall/resources/routes.rb +53 -0
- data/lib/knoxcall/resources/secrets.rb +98 -0
- data/lib/knoxcall/resources/unwraps_envelope.rb +90 -0
- data/lib/knoxcall/resources/vaults.rb +77 -0
- data/lib/knoxcall/resources/webhooks.rb +48 -0
- data/lib/knoxcall/resources/workflows.rb +86 -0
- data/lib/knoxcall/resources/wrap.rb +352 -0
- data/lib/knoxcall/route_refusal.rb +67 -0
- data/lib/knoxcall/signup.rb +122 -0
- data/lib/knoxcall/token_exchange.rb +169 -0
- data/lib/knoxcall/ulid.rb +19 -0
- data/lib/knoxcall/warnings.rb +63 -0
- data/lib/knoxcall/workload_provider.rb +192 -0
- data/lib/knoxcall/wrap_faraday_adapter.rb +119 -0
- data/lib/knoxcall/wrap_faraday_middleware.rb +67 -0
- data/lib/knoxcall/wrap_transport.rb +139 -0
- data/lib/knoxcall.rb +45 -1
- 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
|