Module: KnoxCall

Defined in:
lib/knoxcall.rb,
lib/knoxcall/cli.rb,
lib/knoxcall/dpop.rb,
lib/knoxcall/ulid.rb,
lib/knoxcall/login.rb,
lib/knoxcall/cli/ai.rb,
lib/knoxcall/client.rb,
lib/knoxcall/errors.rb,
lib/knoxcall/signup.rb,
lib/knoxcall/cli/init.rb,
lib/knoxcall/warnings.rb,
lib/knoxcall/bootstrap.rb,
lib/knoxcall/cli/login.rb,
lib/knoxcall/cli/common.rb,
lib/knoxcall/cli/logout.rb,
lib/knoxcall/cli/whoami.rb,
lib/knoxcall/bound_route.rb,
lib/knoxcall/resources/pki.rb,
lib/knoxcall/route_refusal.rb,
lib/knoxcall/cli/ai_control.rb,
lib/knoxcall/resources/logs.rb,
lib/knoxcall/resources/wrap.rb,
lib/knoxcall/token_exchange.rb,
lib/knoxcall/wrap_transport.rb,
lib/knoxcall/intercept_patch.rb,
lib/knoxcall/intercept_store.rb,
lib/knoxcall/resources/roles.rb,
lib/knoxcall/credentials_file.rb,
lib/knoxcall/resources/agents.rb,
lib/knoxcall/resources/crypto.rb,
lib/knoxcall/resources/routes.rb,
lib/knoxcall/resources/vaults.rb,
lib/knoxcall/resources/account.rb,
lib/knoxcall/resources/clients.rb,
lib/knoxcall/resources/secrets.rb,
lib/knoxcall/workload_provider.rb,
lib/knoxcall/intercept_pipeline.rb,
lib/knoxcall/intercept_resolver.rb,
lib/knoxcall/resources/api_keys.rb,
lib/knoxcall/resources/webhooks.rb,
lib/knoxcall/egress_observations.rb,
lib/knoxcall/resources/workflows.rb,
lib/knoxcall/resources/ai_gateway.rb,
lib/knoxcall/resources/audit_logs.rb,
lib/knoxcall/resources/dynamic_db.rb,
lib/knoxcall/wrap_faraday_adapter.rb,
lib/knoxcall/resources/environments.rb,
lib/knoxcall/resources/oauth_clients.rb,
lib/knoxcall/resources/opportunities.rb,
lib/knoxcall/wrap_faraday_middleware.rb,
lib/knoxcall/resources/unwraps_envelope.rb

Defined Under Namespace

Modules: CLI, CredentialsFile, EgressObservations, Intercept, InterceptContext, InterceptResolver, Resources, RouteRefusal, ULID, Warnings, WrapTransport Classes: AIGatewayError, APIError, AccessToken, AuthenticationError, BoundRoute, Client, ClientCredentials, ConflictError, ConnectionTimeoutError, DpopKeyPair, EgressObservationReporter, Error, InterceptHandle, InterceptManifestStore, InterceptPipeline, NetworkError, NotAuthenticatedError, NotFoundError, OIDCTokenExchange, PaymentRequiredError, PermissionDeniedError, RateLimitError, ServerError, SignupError, StaleAssertionError, StoredCredentials, TokenError, TokenExchangeError, ValidationError, WebhookSignatureVerificationError, WorkloadCredentialProvider, WrapSandboxMismatchError

Constant Summary collapse

VERSION =
"0.1.0"
SDK_VERSION =
"knoxcall-ruby/#{VERSION}"
DEFAULT_API_BASE =
"https://api.knoxcall.com"
DEFAULT_CLOUD_HOST =
"knoxcall.com"
PermissionError =

Deprecated: pre-release name (also shadows Ruby's ::PermissionError when the module is included). Use PermissionDeniedError. Remove before 2.0.

PermissionDeniedError
AccessTokenBootstrap =

Deprecated aliases — the pre-release *Bootstrap names used by the other KnoxCall SDKs. Remove before 2.0.

AccessToken
OidcTokenExchangeBootstrap =
OIDCTokenExchange
ClientCredentialsBootstrap =
ClientCredentials
TOKEN_EXCHANGE_GRANT =

The only grant_type POST /v1/oauth/token accepts.

"urn:ietf:params:oauth:grant-type:token-exchange".freeze
ID_TOKEN_TYPE =

The only subject_token_type it accepts.

"urn:ietf:params:oauth:token-type:id_token".freeze
KNOXCALL_AUDIENCE =

The default (and only supported) audience.

"knoxcall:gateway".freeze
TENANT_SLUG_RE =

A tenant slug becomes a hostname, so it must be a bare DNS label: a slug adopted from config or an environment variable that is not one ("evil.com#") would send the workload OIDC token to an attacker host.

/\A[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\z/i.freeze

Class Method Summary collapse

Class Method Details

._signup_post(path, payload, what, base_url, timeout) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

The shared credential-less POST. Note what it does NOT treat as an error: a 202. Both endpoints use it for a normal, credential-less success, so any sub-400 response carrying a data object is returned unchanged.



33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
# File 'lib/knoxcall/signup.rb', line 33

def self.(path, payload, what, base_url, timeout)
  uri = URI.parse((base_url || DEFAULT_API_BASE).chomp("/") + path)
  req = Net::HTTP::Post.new(uri)
  req["Content-Type"] = "application/json"
  req["Accept"] = "application/json"
  req["User-Agent"] = SDK_VERSION
  req.body = JSON.generate(payload)

  resp = begin
    http = Net::HTTP.new(uri.host, uri.port)
    http.use_ssl = uri.scheme == "https"
    http.open_timeout = timeout
    http.read_timeout = timeout
    http.start { |h| h.request(req) }
  rescue Net::OpenTimeout, Net::ReadTimeout => e
    raise ConnectionTimeoutError, "#{what} request timed out: #{e.message}"
  rescue OpenSSL::SSL::SSLError, EOFError, SocketError, SystemCallError, IOError => e
    raise NetworkError, "#{what} request failed: #{e.class}: #{e.message}"
  end

  parsed = begin
    JSON.parse(resp.body.to_s)
  rescue JSON::ParserError
    nil
  end
  status = resp.code.to_i
  data = parsed.is_a?(Hash) && parsed["data"].is_a?(Hash) ? parsed["data"] : nil
  if status >= 400 || data.nil?
    err = parsed.is_a?(Hash) && parsed["error"].is_a?(Hash) ? parsed["error"] : {}
    message = err["message"].is_a?(String) ? err["message"] : "#{what} failed with status #{status}"
    request_id = err["request_id"] || resp["x-request-id"]
    raise SignupError.new(
      message,
      status_code: status,
      error_type: err["type"].is_a?(String) ? err["type"] : nil,
      request_id: request_id.is_a?(String) ? request_id : nil,
      body: parsed
    )
  end
  data
end

.ai_gateway_error_body?(body) ⇒ Boolean

Does this parsed body look like the AI data plane's envelope?

error and code must both be present AND equal: the pre-AIGW-163 auth shape had a code that was not the error, and accepting it would make code mean two things again.

Returns:

  • (Boolean)


121
122
123
124
125
126
127
128
# File 'lib/knoxcall/errors.rb', line 121

def self.ai_gateway_error_body?(body)
  return false unless body.is_a?(Hash)

  body["error"].is_a?(String) &&
    body["code"].is_a?(String) &&
    body["error_description"].is_a?(String) &&
    body["error"] == body["code"]
end

.ai_gateway_error_from(status, body, headers = nil) ⇒ Object

Type a refusal a provider client received from an agent's data-plane URL.

Returns nil when body is not the data plane's envelope, so a caller falls through to its own handling rather than being handed a mislabelled error:

res = Net::HTTP.post(URI("#{agent['agent_url']}/v1/messages"), payload)
if res.code.to_i >= 400
err = KnoxCall.ai_gateway_error_from(res.code.to_i, JSON.parse(res.body), res)
sleep(err.retry_after || 60) if err&.code == "budget_exceeded"
raise err || "HTTP #{res.code}"
end

headers accepts a Hash or anything with [] (a Net::HTTPResponse), or nil.



144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
# File 'lib/knoxcall/errors.rb', line 144

def self.ai_gateway_error_from(status, body, headers = nil)
  return nil unless ai_gateway_error_body?(body)

  read = lambda do |name|
    next nil if headers.nil?

    value = begin
      headers[name] || headers[name.downcase]
    rescue StandardError
      nil
    end
    value.is_a?(String) ? value : nil
  end

  raw_retry = (read.call("Retry-After") || "").strip
  # An HTTP-date Retry-After is legal but is not delta-seconds; nil is the
  # right reading of one we cannot use.
  retry_after = raw_retry.match?(/\A\d+\z/) ? raw_retry.to_i : nil
  request_id = read.call("X-Request-Id")
  request_id = body["request_id"] if request_id.nil? && body["request_id"].is_a?(String)

  AIGatewayError.new(
    body["error_description"],
    status,
    error_description: body["error_description"],
    retry_after: retry_after,
    code: body["code"],
    request_id: request_id,
    headers: headers.is_a?(Hash) ? headers : nil,
    body: body
  )
end

.claim_signup(claim_handle, base_url: nil, timeout: 30) ⇒ Hash

Poll a claim handle returned by signup.

Returns {"status" => "pending", ...} — a 202, and a normal SUCCESS — until the emailed sign-in link has been clicked, then once returns {"status" => "ready", ...} with the tenant and a one-time Test-mode API key. Polling again after that raises SignupError (409); an unknown or expired handle raises it with 404.

Do not poll faster than the poll_after_seconds that signup returned.

Parameters:

  • claim_handle (String) —

    the handle returned by signup

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

    management API base (default https://api.knoxcall.com)

  • timeout (Numeric) (defaults to: 30) —

    open/read timeout in seconds (default 30)

Returns:

  • (Hash) —

    status plus either the pending fields or tenant, starter, sandbox, documentation

Raises:



119
120
121
# File 'lib/knoxcall/signup.rb', line 119

def self.(claim_handle, base_url: nil, timeout: 30)
  ("/v1/signup/claim", { claim_handle: claim_handle }, "Signup claim", base_url, timeout)
end

.ensure_login(tenant: nil, sandbox: false, base_url: nil, profile: nil, mode: "auto", timeout: 300.0, allow_non_interactive: false, open_browser: nil, client_options: {}) ⇒ KnoxCall::Client

Return a client from an already-stored credential for the profile when one is present (no prompt, no network), otherwise run the interactive #login once. The ergonomic "make sure I'm authenticated, then give me a client" entry point. Accepts the same options as #login.

Returns:



84
85
86
87
88
89
90
91
92
93
94
# File 'lib/knoxcall/login.rb', line 84

def (tenant: nil, sandbox: false, base_url: nil, profile: nil,
                 mode: "auto", timeout: 300.0, allow_non_interactive: false,
                 open_browser: nil, client_options: {})
  resolved_profile = CredentialsFile.resolve_profile(profile)
  if CredentialsFile.profile_available?(CredentialsFile.resolve_path, resolved_profile)
    return client_from_profile(resolved_profile, sandbox, client_options)
  end
  (tenant: tenant, sandbox: sandbox, base_url: base_url, profile: profile,
        mode: mode, timeout: timeout, allow_non_interactive: allow_non_interactive,
        open_browser: open_browser, client_options: client_options)
end

.error_from_response(resp) ⇒ Object

Build the typed error for a >= 400 Net::HTTPResponse. Mirrors the Node SDK's errorFromResponse (sdk/knoxcall-node/src/error.ts).



240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
# File 'lib/knoxcall/errors.rb', line 240

def self.error_from_response(resp)
  status = resp.code.to_i
  data = begin
    JSON.parse(resp.body.to_s)
  rescue JSON::ParserError
    nil
  end

  headers = {}
  resp.each_header { |k, v| headers[k.downcase] = v }

  body = data.is_a?(Hash) ? data : {}
  err = body["error"]

  # The KnoxCall /v1 API returns errors as {error:{type,message,request_id}}
  # (the `error` value is a HASH) — Shape A, the canonical envelope. We stay
  # tolerant of the flat shapes some non-/v1 surfaces still use:
  #   B: {error:"<message>", statusCode, errorId}
  #   C: {error:"<code>", message}   — `error` is a CODE, `message` is human.
  if err.is_a?(Hash)
    msg = presence(err["message"]) || presence(err["type"]) || "HTTP #{status}"
    code = err["type"].is_a?(String) ? err["type"] : nil
    body_request_id = err["request_id"].is_a?(String) ? err["request_id"] : nil
  else
    # Flat shapes. Prefer a human `error_description`/`message` over the bare
    # `error` (a code string in Shape C, a message in Shape B), so Shape C
    # surfaces the human text — not the code — while still recording the code.
    msg = presence(body["error_description"]) ||
          presence(body["message"]) ||
          presence(err) ||
          "HTTP #{status}"
    # AIGW-163: the AI data plane sends the code in BOTH `error` and `code`.
    # Prefer the explicit `code` — a future surface could carry one that is
    # not mirrored, and reading the mirror would silently lose it.
    code = presence(body["code"]) || (err.is_a?(String) ? err : nil)
    body_request_id =
      (body["request_id"].is_a?(String) ? body["request_id"] : nil) ||
      (body["errorId"].is_a?(String) ? body["errorId"] : nil)
  end

  # Correlation id: prefer the X-Request-Id response header (now always emitted
  # by the API) then fall back to the id carried in the body.
  request_id = headers["x-request-id"] || body_request_id

  klass = case status
          when 401 then AuthenticationError
          when 402 then PaymentRequiredError
          when 403 then PermissionDeniedError
          when 404 then NotFoundError
          when 409 then ConflictError
          when 422 then ValidationError
          when 429 then RateLimitError
          when 500.. then ServerError
          else APIError
          end
  klass.new(msg, status, headers: headers, body: data, code: code, request_id: request_id)
end

.exchange_base_url(tenant, sandbox, base_url) ⇒ Object

The data-plane origin for exchange_token. There is no default.



149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
# File 'lib/knoxcall/token_exchange.rb', line 149

def self.exchange_base_url(tenant, sandbox, base_url)
  return base_url.chomp("/") if base_url && !base_url.empty?

  if tenant.nil? || tenant.empty?
    raise ArgumentError,
          "exchange_token needs a tenant slug or a base_url: POST /v1/oauth/token is " \
          "served only on the tenant data-plane host " \
          "(https://{tenant}.knoxcall.com). Pointing it at api.knoxcall.com answers " \
          "401, which reads like a rejected subject_token but means the endpoint is " \
          "not there."
  end
  unless TENANT_SLUG_RE.match?(tenant)
    raise ArgumentError,
          "invalid tenant slug #{tenant.inspect} - expected a DNS label; refusing to " \
          "send a subject token to a host derived from it"
  end

  host = sandbox ? "sandbox-#{tenant}" : tenant
  "https://#{host}.knoxcall.com"
end

.exchange_token(subject_token:, resource: nil, audience: KNOXCALL_AUDIENCE, tenant: nil, sandbox: false, base_url: nil, timeout: 30) ⇒ Hash

Exchange a CI OIDC token for a short-lived AI-gateway capability token (RFC 8693 token exchange, AIGW-26).

res = KnoxCall.exchange_token(subject_token: ci_id_token, tenant: "acme")
# res["access_token"] is an agent-kind token for POST /v1/ai/...

Like KnoxCall.signup this is a module-level function and NOT a method on a constructed client, and for a stronger reason: the whole point is that CI holds no KnoxCall credential. Constructing a client to reach this endpoint would require the very secret the flow exists to remove — so NO Authorization header is sent. The subject_token IS the credential, verified against the issuer's published JWKS.

Pass resource — the resource field of an MCP server's create/get response — to narrow the minted token to tool kind, confined to exactly that one /v1/mcp/ and refused on /v1/ai. Leave it nil (the default) for an +agent+-kind token: an EMPTY STRING is sent through and refused invalid_target, because dropping it silently would mint an UNCONFINED token while the caller believes it is audience-restricted.

THE HOST MATTERS, and getting it wrong looks like a credential failure. /v1/oauth/token is part of the DATA plane: src/server.ts hands /v1/ai/, /v1/mcp/ and /v1/oauth/ to the proxy router only when the request lands on a tenant data-plane host ({slug}.knoxcall.com, sandbox-{slug}...). Verified against a running server on 2026-08-25: the same request answers 400 invalid_grant on acme.knoxcall.com and 401 on api.knoxcall.com - a caller who points this at the management host reads that 401 as "my CI token was rejected" when the endpoint is simply not served there. So tenant (or an explicit base_url) is REQUIRED: there is no safe default to guess.

NOT the tenant OAuth 2.1 token endpoint at https://api.knoxcall.com/oauth/token (root host, no /v1), which mints kc_ MANAGEMENT tokens from client_credentials and friends.

Returns the RFC 8693 §2.2.1 body — access_token, issued_token_type, token_type, expires_in, scope. A BARE OAuth body, not the meta envelope the rest of /v1 returns.

Parameters:

  • subject_token (String) —

    the workload's OIDC id_token, JWS-compact

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

    RFC 8707 resource indicator; nil to omit

  • audience (String) (defaults to: KNOXCALL_AUDIENCE) —

    defaults to KNOXCALL_AUDIENCE

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

    tenant slug; becomes https://tenant.knoxcall.com

  • sandbox (Boolean) (defaults to: false) —
  • base_url (String, nil) (defaults to: nil) —

    full data-plane origin; wins over tenant

  • timeout (Numeric) (defaults to: 30) —

    open/read timeout in seconds (default 30)

Returns:

  • (Hash) —

    the RFC 8693 response body

Raises:

  • (TokenExchangeError) —

    on any refusal — carries status_code and error_type (the RFC 6749 §5.2 code)

  • (ArgumentError) —

    when neither tenant nor base_url is given, or the tenant slug is not a DNS label

  • (NetworkError, ConnectionTimeoutError) —

    on transport failure



69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
# File 'lib/knoxcall/token_exchange.rb', line 69

def self.exchange_token(subject_token:, resource: nil, audience: KNOXCALL_AUDIENCE,
                        tenant: nil, sandbox: false, base_url: nil, timeout: 30)
  origin = exchange_base_url(tenant, sandbox, base_url)
  # the request carries the workload OIDC id_token, which IS a credential -- the
  # whole point of the flow. PARITY 15 already warns when a CLIENT is constructed
  # against plaintext http to a non-loopback host, and this function deliberately
  # constructs no client, so without this the control exists on one path and is
  # simply absent on the parallel one. A warning rather than a refusal because the
  # acceptance harness and local dev legitimately use http://127.0.0.1.
  if Warnings.insecure_remote_url?(origin)
    Warnings.warn_once(
      "KNOXCALL_INSECURE_TRANSPORT",
      "KnoxCall: exchanging a workload OIDC token over plaintext HTTP to #{origin} - the " \
      "subject token is a credential and is readable on the wire. Use https://."
    )
  end
  uri = URI.parse(origin + "/v1/oauth/token")
  payload = {
    "grant_type" => TOKEN_EXCHANGE_GRANT,
    "subject_token" => subject_token,
    "subject_token_type" => ID_TOKEN_TYPE,
    "audience" => audience
  }
  payload["resource"] = resource unless resource.nil?

  req = Net::HTTP::Post.new(uri)
  req["Content-Type"] = "application/json"
  req["Accept"] = "application/json"
  req["User-Agent"] = SDK_VERSION
  req.body = JSON.generate(payload)

  resp = begin
    http = Net::HTTP.new(uri.host, uri.port)
    http.use_ssl = uri.scheme == "https"
    http.open_timeout = timeout
    http.read_timeout = timeout
    http.start { |h| h.request(req) }
  rescue Net::OpenTimeout, Net::ReadTimeout => e
    raise ConnectionTimeoutError, "token exchange timed out: #{e.message}"
  rescue OpenSSL::SSL::SSLError, EOFError, SocketError, SystemCallError, IOError => e
    raise NetworkError, "token exchange failed: #{e.class}: #{e.message}"
  end

  # A non-JSON error page (a proxy 502) parses to nil and falls through to
  # the status check rather than masking the status.
  parsed = begin
    JSON.parse(resp.body.to_s)
  rescue JSON::ParserError
    nil
  end
  status = resp.code.to_i
  token = parsed.is_a?(Hash) && parsed["access_token"].is_a?(String) ? parsed["access_token"] : nil

  if status >= 400 || token.nil? || token.empty?
    # AIGW-163: this endpoint is on the TENANT DATA PLANE, so when the AI
    # gateway has failed to boot it is answered by the plane's 503 sentinel —
    # the data-plane envelope with code "ai_gateway_unavailable" and a
    # Retry-After — not by an RFC 6749 error. Typing it means a CI job is told
    # to wait rather than handed a generic exchange failure. The discriminator
    # is exact: an RFC 6749 body carries no `code` at all.
    ai_err = KnoxCall.ai_gateway_error_from(status, parsed, resp)
    raise ai_err if ai_err

    code = parsed.is_a?(Hash) && parsed["error"].is_a?(String) ? parsed["error"] : "token_exchange_failed"
    message = if parsed.is_a?(Hash) && parsed["error_description"].is_a?(String)
                parsed["error_description"]
              else
                "Token exchange failed with status #{status}"
              end
    raise TokenExchangeError.new(message, status_code: status, error_type: code, body: parsed)
  end
  parsed
end

.login(tenant: nil, sandbox: false, base_url: nil, profile: nil, mode: "auto", timeout: 300.0, allow_non_interactive: false, open_browser: nil, client_options: {}) ⇒ KnoxCall::Client

Run the interactive browser (loopback) or device-code login, persist the credential to ~/.knoxcall/credentials.json, and return a ready client bound to the written profile.

Parameters:

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

    tenant hint for the authorize URL (optional; discovered otherwise)

  • sandbox (Boolean) (defaults to: false) —

    target the sandbox host / Test data plane

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

    management base URL override (default production/sandbox, or KNOXCALL_BASE_URL)

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

    credentials-file profile to write/read (default: KNOXCALL_PROFILE or "default")

  • mode ("auto", "browser", "device") (defaults to: "auto") —

    "auto" opens a browser on a desktop TTY, else falls back to the device flow

  • timeout (Numeric) (defaults to: 300.0) —

    loopback wait timeout for the browser flow (s)

  • allow_non_interactive (Boolean) (defaults to: false) —

    bypass the TTY / CI / KNOXCALL_NO_INTERACTIVE guard (default false)

  • open_browser (#call, nil) (defaults to: nil) —

    custom browser launcher (defaults to the OS opener) — receives the authorize URL

  • client_options (Hash) (defaults to: {}) —

    extra options forwarded to the returned KnoxCall::Client (symbol keys)

Returns:

Raises:

  • (NotAuthenticatedError) —

    when prompting is unsafe (no TTY, CI, or KNOXCALL_NO_INTERACTIVE) and allow_non_interactive is not set



47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# File 'lib/knoxcall/login.rb', line 47

def (tenant: nil, sandbox: false, base_url: nil, profile: nil,
          mode: "auto", timeout: 300.0, allow_non_interactive: false,
          open_browser: nil, client_options: {})
  mode = mode.to_s
  unless %w[auto browser device].include?(mode)
    raise ArgumentError, %(mode must be "auto", "browser", or "device" (got #{mode.inspect}))
  end
  interactive_guard(allow_non_interactive)

  resolved_base = (base_url || CLI::Common.default_base_url(sandbox)).chomp("/")
  resolved_profile = CredentialsFile.resolve_profile(profile)
  use_device = mode == "device" || (mode == "auto" && !desktop_browser?)

  token_body =
    if use_device
      CLI::.device_flow(resolved_base)
    else
      CLI::.auth_code_flow(resolved_base, tenant: tenant,
                                               open_browser: open_browser, timeout: timeout)
    end

  CLI::Common.(
    path: CredentialsFile.resolve_path,
    profile: resolved_profile,
    base_url: resolved_base,
    token_body: token_body,
    fallback_tenant: tenant
  )
  client_from_profile(resolved_profile, sandbox, client_options)
end

.signup(input, base_url: nil, timeout: 30) ⇒ Hash

Start creating a KnoxCall account.

Always answers 202 with an opaque claim_handle and emails a sign-in link — no account, tenant or credential exists until that link is clicked. Collect the starter kit afterwards with claim_signup. Rate limited to 3 signups/hour/IP.

Enumeration-safe: the reply is identical for an address that already has an account (it receives a sign-in link and a handle that stays "pending").

input fields: email (required), tenant_name (required), full_name, tenant_slug (omit to have one derived — recommended), country, region ("us"|"eu"|"au").

Parameters:

  • input (Hash) —

    the signup fields (symbol or string keys)

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

    management API base (default https://api.knoxcall.com)

  • timeout (Numeric) (defaults to: 30) —

    open/read timeout in seconds (default 30)

Returns:

  • (Hash) —

    status, claim_handle, claim_path, poll_after_seconds, expires_at, message, documentation — and never a credential

Raises:



97
98
99
# File 'lib/knoxcall/signup.rb', line 97

def self.(input, base_url: nil, timeout: 30)
  ("/v1/signup", input, "Signup", base_url, timeout)
end