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,90 @@
1
+ require "uri"
2
+
3
+ module KnoxCall
4
+ module Resources
5
+ # The server wraps every JSON success response in +{ "data": ..., "meta": ... }+
6
+ # (see sdk/PARITY.md §4). Resource methods unwrap:
7
+ #
8
+ # - single-object methods return +data+ (via {#unwrap});
9
+ # - paginated lists return the envelope as-is —
10
+ # <tt>{"data" => [...], "meta" => {"total", "page", "per_page",
11
+ # "total_pages", "request_id"}}</tt> — and take +page+ / +per_page+
12
+ # params (server default 20, cap 100);
13
+ # - bare-array endpoints unwrap +data+ to a plain Array (no page params);
14
+ # - the +each+/+each_*+ auto-pagers walk pages until
15
+ # +page >= meta.total_pages+ or an empty page.
16
+ #
17
+ # TWO endpoints are keyset/cursor paginated instead, deliberately:
18
+ # <tt>GET /v1/audit-logs/events</tt> and <tt>GET /v1/logs</tt>. Offset
19
+ # pagination over a table that is being written to skips and repeats rows
20
+ # with no way to tell which — fine for a console, wrong for a feed. Use
21
+ # {#paginate_cursor} for those. (Earlier revisions of this comment said
22
+ # there was no cursor pagination anywhere on the API; that stopped being
23
+ # true when the audit event feed shipped.)
24
+ module UnwrapsEnvelope
25
+ private
26
+
27
+ # Unwrap +data+ from a {data, meta} envelope. Tolerates a bare payload
28
+ # (self-hosted / older servers) by passing it through unchanged.
29
+ def unwrap(response)
30
+ response.is_a?(Hash) && response.key?("data") ? response["data"] : response
31
+ end
32
+
33
+ # Unwrap +data+ and fold the response's top-level +warning+ into it —
34
+ # the oauth-clients create/rotate-secret endpoints bypass the standard
35
+ # success() wrapper server-side and carry the warning beside +data+.
36
+ def unwrap_with_warning(response)
37
+ data = unwrap(response)
38
+ if data.is_a?(Hash) && response.is_a?(Hash) && response["warning"].is_a?(String)
39
+ data = data.merge("warning" => response["warning"])
40
+ end
41
+ data
42
+ end
43
+
44
+ # Page-based auto-pager (PARITY §4): starts at +params[:page]+ (default
45
+ # 1), fetches a page via the block, yields each row, and stops when
46
+ # +page >= meta.total_pages+ or a page comes back empty (defensive).
47
+ # Lazy: nothing is fetched until the Enumerator is consumed.
48
+ def paginate(params, &fetch_page)
49
+ Enumerator.new do |yielder|
50
+ page = (params[:page] || 1).to_i
51
+ page = 1 if page < 1
52
+ loop do
53
+ result = fetch_page.call(params.merge(page: page))
54
+ rows = result.is_a?(Hash) && result["data"].is_a?(Array) ? result["data"] : []
55
+ rows.each { |row| yielder << row }
56
+ total_pages = result.is_a?(Hash) ? result.dig("meta", "total_pages") : nil
57
+ break if rows.empty? || !total_pages.is_a?(Numeric) || page >= total_pages
58
+ page += 1
59
+ end
60
+ end
61
+ end
62
+
63
+ # Cursor auto-pager for the keyset feeds. Starts at +params[:cursor]+,
64
+ # yields each row, and STOPS when the server reports
65
+ # +meta.next_cursor == nil+ — which means the feed is drained to the
66
+ # watermark, NOT that it has ended. To keep following it, call again
67
+ # later with the last cursor you saw; an Enumerator that blocked forever
68
+ # would be unusable from a batch job.
69
+ #
70
+ # +next_cursor+ is OPAQUE: it is passed back verbatim and never parsed.
71
+ # Delivery is at-least-once — dedupe on +meta.dedupe_on+.
72
+ # Lazy: nothing is fetched until the Enumerator is consumed.
73
+ def paginate_cursor(params, &fetch_page)
74
+ Enumerator.new do |yielder|
75
+ cursor = params[:cursor]
76
+ loop do
77
+ result = fetch_page.call(cursor.nil? ? params : params.merge(cursor: cursor))
78
+ rows = result.is_a?(Hash) && result["data"].is_a?(Array) ? result["data"] : []
79
+ rows.each { |row| yielder << row }
80
+ nxt = result.is_a?(Hash) ? result.dig("meta", "next_cursor") : nil
81
+ break if nxt.nil?
82
+ cursor = nxt
83
+ end
84
+ end
85
+ end
86
+
87
+ def encode(s) = URI.encode_www_form_component(s.to_s)
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,77 @@
1
+ module KnoxCall
2
+ module Resources
3
+ class Vaults
4
+ include UnwrapsEnvelope
5
+
6
+ def initialize(client) = @client = client
7
+
8
+ # Paginated. Params: page (default 1), per_page (default 20, max 100).
9
+ # Returns the {data, meta} envelope.
10
+ def list(**params) = @client.request("GET", "/v1/vaults", query: params.empty? ? nil : params)
11
+
12
+ # Yield every vault, walking pages transparently. Returns a lazy
13
+ # Enumerator when no block is given.
14
+ def each(**params, &block)
15
+ enum = paginate(params) { |p| list(**p) }
16
+ block ? enum.each(&block) : enum
17
+ end
18
+
19
+ # Returns the vault plus "stats" => {token_count, active_count, expiring_in_24h}.
20
+ def get(name_or_id) = unwrap(@client.request("GET", "/v1/vaults/#{encode(name_or_id)}"))
21
+ def create(**input) = unwrap(@client.request("POST", "/v1/vaults", body: input))
22
+ def update(name_or_id, **input) = unwrap(@client.request("PATCH", "/v1/vaults/#{encode(name_or_id)}", body: input))
23
+ # Returns {"deleted" => true}.
24
+ def delete(name_or_id) = unwrap(@client.request("DELETE", "/v1/vaults/#{encode(name_or_id)}"))
25
+ # Returns {"new_version" => Integer}.
26
+ def rotate(name_or_id) = unwrap(@client.request("POST", "/v1/vaults/#{encode(name_or_id)}/rotate", body: {}))
27
+
28
+ # -- Token operations --
29
+
30
+ # Returns {"id", "token", "expires_at", "created_at"}.
31
+ # card_exp_month / card_exp_year are the CARD's own expiry, for a `pan`
32
+ # vault only -- not ttl_seconds, which is how long the TOKEN lives. Both
33
+ # or neither; the year is four digits (2029, never 29). Supplying them
34
+ # subscribes the token to the `vault.token.expiring` webhook, emitted 60
35
+ # and 30 days before the card expires. Offering them to a non-`pan` vault
36
+ # raises a validation_error. The result carries "card_expires_on".
37
+ #
38
+ # The result also carries "card_funding_type" ("credit" / "debit" /
39
+ # "prepaid") and "card_issuing_country" (ISO 3166-1 alpha-2), derived
40
+ # from the card's first six digits. BOTH ARE nil ON EVERY TOKEN TODAY
41
+ # and will be until KnoxCall licenses a BIN table -- treat them as
42
+ # optional indefinitely.
43
+ def tokenize(name_or_id, value:, metadata: nil, ttl_seconds: nil, card_exp_month: nil, card_exp_year: nil)
44
+ body = { value: value }
45
+ body[:metadata] = metadata if metadata
46
+ body[:ttl_seconds] = ttl_seconds if ttl_seconds
47
+ body[:card_exp_month] = card_exp_month if card_exp_month
48
+ body[:card_exp_year] = card_exp_year if card_exp_year
49
+ unwrap(@client.request("POST", "/v1/vaults/#{encode(name_or_id)}/tokens", body: body))
50
+ end
51
+ # Returns {"tokens" => [...], "count" => Integer}. Each value needs "value";
52
+ # optional "metadata", "ttl_seconds", and -- for a `pan` vault --
53
+ # "card_exp_month" / "card_exp_year". A refusal names the offending index
54
+ # and rolls the whole batch back.
55
+ def bulk_tokenize(name_or_id, values) = unwrap(@client.request("POST", "/v1/vaults/#{encode(name_or_id)}/tokens/bulk", body: { values: values }))
56
+
57
+ # Paginated. Params: page (default 1), per_page (default 20, max 100).
58
+ # Returns the {data, meta} envelope.
59
+ def list_tokens(name_or_id, **params) = @client.request("GET", "/v1/vaults/#{encode(name_or_id)}/tokens", query: params.empty? ? nil : params)
60
+
61
+ # Yield every token row, walking pages transparently. Returns a lazy
62
+ # Enumerator when no block is given.
63
+ def each_token(name_or_id, **params, &block)
64
+ enum = paginate(params) { |p| list_tokens(name_or_id, **p) }
65
+ block ? enum.each(&block) : enum
66
+ end
67
+
68
+ # Reveal the original value. Returns {"id", "token", "value",
69
+ # "value_b64", "metadata", "expires_at", "created_at", "crypto_key_version"}.
70
+ def detokenize(name_or_id, id_or_token) = unwrap(@client.request("GET", "/v1/vaults/#{encode(name_or_id)}/tokens/#{encode(id_or_token)}"))
71
+ # Returns {"updated" => true}.
72
+ def update_token(name_or_id, id_or_token, metadata:) = unwrap(@client.request("PATCH", "/v1/vaults/#{encode(name_or_id)}/tokens/#{encode(id_or_token)}", body: { metadata: metadata }))
73
+ # Returns {"deleted" => true}.
74
+ def delete_token(name_or_id, id_or_token) = unwrap(@client.request("DELETE", "/v1/vaults/#{encode(name_or_id)}/tokens/#{encode(id_or_token)}"))
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,48 @@
1
+ module KnoxCall
2
+ module Resources
3
+ class Webhooks
4
+ include UnwrapsEnvelope
5
+
6
+ def initialize(client) = @client = client
7
+
8
+ # Paginated. Params: page (default 1), per_page (default 20, max 100).
9
+ # Returns the {data, meta} envelope.
10
+ def list(**params) = @client.request("GET", "/v1/webhooks", query: params.empty? ? nil : params)
11
+
12
+ # Yield every webhook, walking pages transparently. Returns a lazy
13
+ # Enumerator when no block is given.
14
+ def each(**params, &block)
15
+ enum = paginate(params) { |p| list(**p) }
16
+ block ? enum.each(&block) : enum
17
+ end
18
+
19
+ def get(webhook_id) = unwrap(@client.request("GET", "/v1/webhooks/#{encode(webhook_id)}"))
20
+ # The response's "secret_key" (the HMAC endpoint secret) is shown
21
+ # exactly once — store it now.
22
+ def create(**input) = unwrap(@client.request("POST", "/v1/webhooks", body: input))
23
+ def update(webhook_id, **input) = unwrap(@client.request("PATCH", "/v1/webhooks/#{encode(webhook_id)}", body: input))
24
+ # Returns {"deleted" => true}.
25
+ def delete(webhook_id) = unwrap(@client.request("DELETE", "/v1/webhooks/#{encode(webhook_id)}"))
26
+
27
+ # Paginated delivery logs. Params: page, per_page.
28
+ def get_logs(webhook_id, **params) = @client.request("GET", "/v1/webhooks/#{encode(webhook_id)}/logs", query: params.empty? ? nil : params)
29
+
30
+ # Yield every delivery-log row, walking pages transparently. Returns a
31
+ # lazy Enumerator when no block is given.
32
+ def each_log(webhook_id, **params, &block)
33
+ enum = paginate(params) { |p| get_logs(webhook_id, **p) }
34
+ block ? enum.each(&block) : enum
35
+ end
36
+
37
+ # Returns {"event_types" => [{"value", "label", "description"}, ...]}.
38
+ def list_event_types = unwrap(@client.request("GET", "/v1/webhooks/event-types"))
39
+ # Fire a synthetic webhook.test event; returns the delivery result.
40
+ def test(webhook_id) = unwrap(@client.request("POST", "/v1/webhooks/#{encode(webhook_id)}/test"))
41
+
42
+ # Verify an incoming webhook delivery AND parse it in one step — see
43
+ # {KnoxCall::Client.construct_event} for the full contract (formats,
44
+ # tolerance, returned Hash shape).
45
+ def construct_event(...) = Client.construct_event(...)
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,86 @@
1
+ module KnoxCall
2
+ module Resources
3
+ # Workflows control plane (server: src/client-api/workflows.ts; node
4
+ # reference src/resources/workflows.ts, PARITY §11). Automations built in
5
+ # the workflow builder: list/get/create/update/delete, execute (queue a
6
+ # run), and the execution (run) sub-collection.
7
+ #
8
+ # client.workflows.list(page: 1, per_page: 20)
9
+ # wf = client.workflows.create(name: "Nightly sync", definition: { nodes: [...], edges: [...] })
10
+ # run = client.workflows.execute(wf["id"], input: { since: "2026-08-01" })
11
+ # client.workflows.each_execution(wf["id"]) { |ex| puts ex["status"] }
12
+ #
13
+ # A workflow row is a plain Hash with string keys: "id", "name",
14
+ # "description", "definition", "environment", "enabled", "version",
15
+ # "sandbox", "timeout_seconds", "published_at", "created_at", "updated_at"
16
+ # (plus "run_count" on list). An execution row: "id", "workflow_id",
17
+ # "status", "trigger_type", "started_at", "completed_at",
18
+ # "execution_time_ms", "error_message", "workflow_version", "created_at"
19
+ # (plus "node_executions" on get_execution).
20
+ #
21
+ # Mutating methods carry the ULID idempotency key like every other resource
22
+ # (added by Client#request on non-GET/HEAD methods), so a replayed execute
23
+ # returns the same execution.
24
+ #
25
+ # The sandbox/test client (KnoxCall::Client.new(sandbox: true)) is scoped to
26
+ # the Test data plane server-side; no env parameter is threaded through.
27
+ class Workflows
28
+ include UnwrapsEnvelope
29
+
30
+ def initialize(client) = @client = client
31
+
32
+ # -- Workflows ---------------------------------------------------------
33
+
34
+ # Paginated. Params: page (default 1), per_page (default 20, max 100),
35
+ # plus endpoint filters. Returns the {data, meta} envelope.
36
+ def list(**params) = @client.request("GET", "/v1/workflows", query: params.empty? ? nil : params)
37
+
38
+ # Yield every workflow, walking pages transparently. Returns a lazy
39
+ # Enumerator when no block is given. Also available as +iterate+ for
40
+ # cross-SDK naming parity (node/python name the auto-pager iterate).
41
+ def each(**params, &block)
42
+ enum = paginate(params) { |p| list(**p) }
43
+ block ? enum.each(&block) : enum
44
+ end
45
+ alias_method :iterate, :each
46
+
47
+ def get(workflow_id) = unwrap(@client.request("GET", "/v1/workflows/#{encode(workflow_id)}"))
48
+
49
+ # body: name:, definition: ({nodes:, edges:}), plus optional description:,
50
+ # trigger_config:, environment:, enabled:.
51
+ def create(**input) = unwrap(@client.request("POST", "/v1/workflows", body: input))
52
+ def update(workflow_id, **input) = unwrap(@client.request("PATCH", "/v1/workflows/#{encode(workflow_id)}", body: input))
53
+ # Returns {"id", "deleted" => true}.
54
+ def delete(workflow_id) = unwrap(@client.request("DELETE", "/v1/workflows/#{encode(workflow_id)}"))
55
+
56
+ # Queue a run. Idempotent — Client#request attaches a ULID key that is
57
+ # stable across retries. The optional +input+ becomes the run's {input}
58
+ # body (omitted entirely when nil, matching the node reference). Returns
59
+ # the execution ack {"id", "workflow_id", "status"}.
60
+ def execute(workflow_id, input: nil)
61
+ body = input.nil? ? {} : { input: input }
62
+ unwrap(@client.request("POST", "/v1/workflows/#{encode(workflow_id)}/execute", body: body))
63
+ end
64
+
65
+ # -- Executions (runs) -------------------------------------------------
66
+
67
+ # Paginated. Params: page, per_page, plus filters. Returns the
68
+ # {data, meta} envelope.
69
+ def list_executions(workflow_id, **params) = @client.request("GET", "/v1/workflows/#{encode(workflow_id)}/executions", query: params.empty? ? nil : params)
70
+
71
+ # Yield every execution for a workflow, walking pages transparently.
72
+ # Returns a lazy Enumerator when no block is given. Also available as
73
+ # +iterate_executions+ for cross-SDK naming parity.
74
+ def each_execution(workflow_id, **params, &block)
75
+ enum = paginate(params) { |p| list_executions(workflow_id, **p) }
76
+ block ? enum.each(&block) : enum
77
+ end
78
+ alias_method :iterate_executions, :each_execution
79
+
80
+ # Executions are addressed by their own id (not nested under the workflow).
81
+ def get_execution(execution_id) = unwrap(@client.request("GET", "/v1/workflows/executions/#{encode(execution_id)}"))
82
+ # Returns {"id", "status"}.
83
+ def cancel_execution(execution_id) = unwrap(@client.request("POST", "/v1/workflows/executions/#{encode(execution_id)}/cancel"))
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,352 @@
1
+ module KnoxCall
2
+ module Resources
3
+ # Wrap-credential escrow (POST /v1/wrap/credentials).
4
+ #
5
+ # Hands a raw provider credential to KnoxCall for custody: the +value+ is
6
+ # sent ONCE, stored under the given +name+, pinned to the supplied upstream
7
+ # +hosts+, and never returned. The response carries only the escrowed
8
+ # secret's metadata ({secret_id, name, provider, allowed_hosts, sandbox});
9
+ # thereafter the credential is referenced by name and injected by the proxy.
10
+ class Wrap
11
+ include UnwrapsEnvelope
12
+
13
+ def initialize(client) = @client = client
14
+
15
+ # Escrow a provider credential.
16
+ #
17
+ # @param provider [String] free-form provider label (e.g. "stripe")
18
+ # @param name [String] secret name the escrowed key is stored and
19
+ # later referenced under
20
+ # @param value [String] the raw provider credential — sent once, never
21
+ # returned
22
+ # @param hosts [Array<String>] the allowed upstream hostnames (the
23
+ # load-bearing pin)
24
+ # @return [Hash] the escrowed secret's metadata: {"secret_id", "name",
25
+ # "provider", "allowed_hosts", "sandbox"} — the value is never echoed
26
+ def escrow(provider:, name:, value:, hosts:)
27
+ unwrap(@client.request("POST", "/v1/wrap/credentials",
28
+ body: { provider: provider, name: name, value: value, hosts: hosts }))
29
+ end
30
+
31
+ # Mint a base-URL gateway token bound to an escrowed credential
32
+ # (POST /v1/wrap/tokens).
33
+ #
34
+ # For SDKs that expose ONLY a base-URL override and no +fetch+/transport
35
+ # hook (Resend, Mailgun, Airtable, …): set the returned +base_url+ as the
36
+ # wrapped SDK's base URL, and the SDK's own key becomes a placeholder —
37
+ # KnoxCall injects the escrowed +secret+ server-side. ESCROW-ONLY: the
38
+ # +secret+ must already be escrowed (see {#escrow}).
39
+ #
40
+ # The returned +token+ (also embedded in +base_url+) is a bearer
41
+ # credential — treat it as a secret, never store or log it.
42
+ #
43
+ # @param secret [String] the escrowed credential (name or id) to inject
44
+ # @param host [String, nil] upstream host to pin (optional when the
45
+ # credential allows exactly one)
46
+ # @param ttl_seconds [Integer, nil] token TTL in seconds (omit for a
47
+ # non-expiring token)
48
+ # @param label [String, nil] human label for the token list
49
+ # @param style [String, nil] which +base_url+ form to return: "path"
50
+ # (+…/wg/<token>/<host>+) is always available; "subdomain"
51
+ # (+<label>.wrap.<domain>+) is only available when the operator enabled
52
+ # the wildcard-subdomain gateway (else the call 400s). Omit to let the
53
+ # server choose (subdomain when enabled, else path). NOTE: the subdomain
54
+ # form carries the token in the TLS SNI (plaintext on the wire) — weaker
55
+ # token confidentiality than the path form; prefer a short +ttl_seconds+.
56
+ # @return [Hash] {"id", "token", "base_url", "base_url_style", "host",
57
+ # "secret_id", "sandbox", "expires_at"} — "base_url_style" ("path" or
58
+ # "subdomain") reports which form was returned
59
+ def gateway_url(secret:, host: nil, ttl_seconds: nil, label: nil, style: nil)
60
+ body = { secret: secret, host: host, ttl_seconds: ttl_seconds, label: label, style: style }.compact
61
+ unwrap(@client.request("POST", "/v1/wrap/tokens", body: body))
62
+ end
63
+
64
+ # The intercept manifest (GET /v1/wrap/intercept-manifest): which upstream
65
+ # hosts an intercept-enabled Route covers in this space, for one
66
+ # environment (default: the tenant's default), and the slug to send them
67
+ # under. What a route-aware interceptor polls; "version" doubles as the
68
+ # ETag. Scope: routes:read.
69
+ #
70
+ # Conditional form: pass +if_none_match:+ (the "version" you hold — not
71
+ # an ETag) and the SDK sends +If-None-Match: W/"<version>"+; a 304
72
+ # returns +nil+ — keep what you hold. Everything else (auth, the one
73
+ # re-auth on 401, retries, a 200 with a newer manifest) is exactly the
74
+ # unconditional call, which never returns nil.
75
+ #
76
+ # @param environment [String, nil] the environment to resolve for
77
+ # @param if_none_match [String, nil] the manifest version you hold
78
+ # @return [Hash, nil] {"version", "ttl_seconds", "environment", "sandbox",
79
+ # "routes" => [{"host", "base_path", "slug", "route_id",
80
+ # "requires_clients", "allowed_methods", "updated_at"}]}; nil only for
81
+ # a 304 to a conditional call
82
+ def intercept_manifest(environment: nil, if_none_match: nil)
83
+ query = environment.nil? ? nil : { environment: environment }
84
+ if if_none_match.nil? || if_none_match.to_s.empty?
85
+ return unwrap(@client.request("GET", "/v1/wrap/intercept-manifest", query: query))
86
+ end
87
+
88
+ # Conditional form (PARITY §21.1): the held version as the server's weak
89
+ # ETag; its 304 comes back as NOT_MODIFIED, mapped to nil.
90
+ res = @client.request("GET", "/v1/wrap/intercept-manifest", query: query,
91
+ headers: { "If-None-Match" => self.class.manifest_etag(if_none_match) },
92
+ allow_not_modified: true)
93
+ res.equal?(KnoxCall::Client::NOT_MODIFIED) ? nil : unwrap(res)
94
+ end
95
+
96
+ # The weak ETag the manifest endpoint sets for a +version+ (src/client-api/wrap.ts).
97
+ def self.manifest_etag(version) = %(W/"#{version}")
98
+
99
+ # Report uncovered-egress observations (POST /v1/wrap/egress-observations;
100
+ # PARITY §21.3) — the thin typed wrapper the interceptor's reporter uses,
101
+ # exported so an integrator can report by hand. At most 200 observations
102
+ # per call. The body carries names, never values: a credential header's
103
+ # NAME, the host, the first path segment, the method and counts. Scope:
104
+ # routes:read.
105
+ #
106
+ # @param observations [Array<Hash>] each {host:, first_segment:, method:,
107
+ # header_name:, count:, first_seen:, last_seen:} (ISO-8601 UTC times)
108
+ # @param sdk [String, nil] "<language>/<version>"; defaults to this SDK's
109
+ # @return [Hash] {"accepted", "dropped", "reasons", "redacted"} — "redacted"
110
+ # (when present) counts accepted entries whose content the server
111
+ # reduced, by reason (e.g. "first_segment_looks_like_credential")
112
+ def report_egress_observations(observations, sdk: nil)
113
+ body = { sdk: sdk || "ruby/#{KnoxCall::VERSION}", observations: Array(observations) }
114
+ unwrap(@client.request("POST", "/v1/wrap/egress-observations", body: body))
115
+ end
116
+
117
+ # List this space's gateway tokens (GET /v1/wrap/tokens).
118
+ #
119
+ # Metadata only — the token itself is never stored or returned.
120
+ #
121
+ # @return [Array<Hash>] each {"id", "secret_id", "host", "label",
122
+ # "created_at", "expires_at", "revoked_at", "last_used_at"}
123
+ def list_gateway_tokens = unwrap(@client.request("GET", "/v1/wrap/tokens"))["tokens"]
124
+
125
+ # Revoke a single gateway token by id (DELETE /v1/wrap/tokens/{id}).
126
+ # Immediately invalidates it.
127
+ #
128
+ # @param id [String] the wrap-token id (from {#gateway_url} or
129
+ # {#list_gateway_tokens})
130
+ # @return [Hash] {"id", "revoked"}
131
+ def revoke_gateway_token(id) = unwrap(@client.request("DELETE", "/v1/wrap/tokens/#{encode(id)}"))
132
+
133
+ # Build a +Faraday::Connection+ whose terminal adapter routes each request
134
+ # through KnoxCall — the Ruby analogue of the Node SDK's +wrap.fetch()+
135
+ # (PARITY §18, §21.1). Hand the returned connection to any third-party SDK
136
+ # that accepts an injected +Faraday::Connection+; the SDK keeps its own
137
+ # serialization, retries, idempotency keys and error types — only its HTTP
138
+ # transport is swapped.
139
+ #
140
+ # conn = knox.wrap.faraday_connection(url: "https://api.example.com")
141
+ # sdk = SomeSDK.new(connection: conn) # SDK-specific injection point
142
+ #
143
+ # With +routes: :auto+ the connection is ROUTE-AWARE: the intercept
144
+ # manifest (GET /v1/wrap/intercept-manifest, the client's environment) is
145
+ # refreshed lazily at its TTL and a request whose host + path an
146
+ # intercept-enabled Route covers goes through that Route (the path rebased
147
+ # under the Route's base path, query kept; the Route injects the stored
148
+ # secret — no provider credential travels). Every other request goes
149
+ # through the ephemeral proxy exactly as before — an explicit transport
150
+ # treats every host as listed. The default stays +routes: :off+, so an
151
+ # existing connection keeps its behaviour. The controls live on
152
+ # +conn.knoxcall+ (an {InterceptPipeline}): +ready+, +refresh+,
153
+ # +manifest+, +stop+.
154
+ #
155
+ # For an SDK that builds its own Faraday stack see {#faraday_middleware};
156
+ # for the opt-in process-wide seam see {#intercept!}. An SDK that exposes
157
+ # only a base-URL override (Resend, Mailgun, Airtable, …) uses
158
+ # {#gateway_url}.
159
+ #
160
+ # Requires the OPTIONAL +faraday+ gem (KnoxCall itself has no runtime
161
+ # dependency on it); a clear {KnoxCall::Error} is raised if it is missing.
162
+ #
163
+ # Credential handling mirrors the Node contract:
164
+ # - transit mode (default): the wrapped SDK's own +Authorization+ header is
165
+ # lifted out-of-band into +X-Knox-Upstream-Authorization+ (never
166
+ # forwarded raw, never logged), with a both-must-agree Test/Live check
167
+ # against the client's +sandbox+ flag;
168
+ # - escrow mode (+credential: {secret:}+, or per host via +hosts:+): the raw
169
+ # key stays in KnoxCall custody and only the escrowed secret name travels.
170
+ #
171
+ # Requests matching a route-around rule (raw-card PCI endpoints by default),
172
+ # the client's own hosts, and — with KNOXCALL_INTERCEPT=off — everything
173
+ # are sent to the provider DIRECTLY, untouched — pre-send decisions.
174
+ #
175
+ # Unavailability (decision D4): route mode and escrow fail CLOSED (a
176
+ # +Faraday::ConnectionFailed+); +unavailable: :direct+ opts TRANSIT traffic
177
+ # into going direct instead, firing +on_fallback+.
178
+ #
179
+ # NOTE: because the shared +ephemeral()+ path defaults a missing
180
+ # +Content-Type+ to +application/json+ when a body is present, a wrapped
181
+ # SDK that sends a body with NO Content-Type at all will have one added.
182
+ # Every mainstream SDK (Stripe, OpenAI, …) sets its own Content-Type, which
183
+ # is preserved verbatim, so this affects only exotic transports.
184
+ #
185
+ # @param url [String, nil] base URL for the connection (the provider's API
186
+ # base, exactly as the wrapped SDK expects)
187
+ # @param routes [Symbol] +:auto+ consults the intercept manifest; +:off+
188
+ # (default) is the ephemeral-only transport
189
+ # @param hosts [Array<String>, Hash, nil] per-host options for the ephemeral
190
+ # path: +{"api.resend.com" => {credential: {secret: "resend-key"}}}+
191
+ # (escrow) or +{unavailable: :direct}+ (transit only)
192
+ # @param require_context [Boolean] only intercept inside {#routed}
193
+ # @param unavailable [Symbol] +:error+ (default) or +:direct+ (transit only)
194
+ # @param credential [Hash, nil] escrow mode {secret:, scheme:}; omit for
195
+ # transit mode
196
+ # @param route [String, nil] legacy: send EVERY non-direct request via this
197
+ # durable route slug (x-knoxcall-route), full path, no manifest lookup
198
+ # @param route_around [Array<Hash>, nil] extra route-around rules, each
199
+ # {host:, path_prefix:(optional), reason:}; matching requests go direct
200
+ # @param disable_default_route_around [Boolean] drop the built-in raw-card
201
+ # (PCI) defaults
202
+ # @param auto_switch [Boolean] legacy (routes: :off only): switch a host onto
203
+ # its promoted durable route after the server advertises one
204
+ # @param direct_adapter the Faraday adapter used for direct calls (default
205
+ # +Faraday.default_adapter+)
206
+ # @param on_route_around [#call, nil] {url:, host:, reason:}
207
+ # @param on_promoted [#call, nil] {host:, slug:}
208
+ # @param on_reroute [#call, nil] {host:, url:, mode:, slug:, reason:} before a KnoxCall send
209
+ # @param on_refresh [#call, nil] {reason:, version:, added:, removed:} after a manifest change
210
+ # @param on_manifest_error [#call, nil] the exception of a failed manifest refresh
211
+ # @param on_unmatched_path [#call, nil] {host:, url:} once per host + first path segment
212
+ # @param on_refused [#call, nil] {host:, url:, slug:, status:, redecided:} after a refusal refresh
213
+ # @param on_fallback [#call, nil] {host:, url:, error:} when a transit request went direct
214
+ # @param observe_uncovered [Boolean] report uncovered egress (PARITY §21.3):
215
+ # calls sent DIRECT because their host was +:unlisted+ while carrying a
216
+ # credential-bearing header — host, first path segment, method and the
217
+ # header NAME (never its value; never the query; never the body) — are
218
+ # counted and posted to +POST /v1/wrap/egress-observations+ about once a
219
+ # minute. ON by default for {#intercept!}, {#faraday_middleware} and
220
+ # +routes: :auto+; +false+ or KNOXCALL_OBSERVE_UNCOVERED=off (read when
221
+ # the connection is built) turns it off; nothing is reported while
222
+ # KNOXCALL_INTERCEPT=off. A 403 from the endpoint stops reporting for
223
+ # the life of the connection (warned once).
224
+ # @param on_observation_flush [#call, nil] {accepted:, dropped:} after each accepted report
225
+ # @param faraday_options [Hash] extra options forwarded to +Faraday.new+
226
+ # @yield [Faraday::Connection::Builder] optional block to add
227
+ # request/response middleware ABOVE the KnoxCall transport — do NOT add
228
+ # another adapter (KnoxCall is the terminal adapter)
229
+ # @return [Faraday::Connection] with a +knoxcall+ singleton method
230
+ # @raise [KnoxCall::Error] if the +faraday+ gem is not installed
231
+ # @raise [KnoxCall::WrapSandboxMismatchError] on a malformed route_around or listed host
232
+ # @raise [TypeError] on a malformed escrow credential
233
+ def faraday_connection(url: nil, routes: :off, hosts: nil, require_context: false, unavailable: :error,
234
+ credential: nil, route: nil, route_around: nil,
235
+ disable_default_route_around: false, auto_switch: false,
236
+ direct_adapter: nil, on_route_around: nil, on_promoted: nil,
237
+ on_reroute: nil, on_refresh: nil, on_manifest_error: nil,
238
+ on_unmatched_path: nil, on_refused: nil, on_fallback: nil,
239
+ observe_uncovered: true, on_observation_flush: nil,
240
+ **faraday_options, &block)
241
+ require_faraday!
242
+ # A typo'd hook (`on_reroutes:`) would otherwise vanish into Faraday's
243
+ # options and never fire — fail loud instead.
244
+ typos = faraday_options.keys.select { |k| k.to_s.start_with?("on_") }
245
+ raise ArgumentError, "unknown intercept hook(s): #{typos.join(', ')}" unless typos.empty?
246
+
247
+ pipeline = KnoxCall::InterceptPipeline.new(
248
+ client: @client, all_hosts: true, hosts: hosts, routes: routes, credential: credential,
249
+ route: route, auto_switch: auto_switch, route_around: route_around,
250
+ disable_default_route_around: disable_default_route_around,
251
+ require_context: require_context, unavailable: unavailable,
252
+ on_route_around: on_route_around, on_promoted: on_promoted, on_reroute: on_reroute,
253
+ on_refresh: on_refresh, on_manifest_error: on_manifest_error,
254
+ on_unmatched_path: on_unmatched_path, on_refused: on_refused, on_fallback: on_fallback,
255
+ observe_uncovered: observe_uncovered, on_observation_flush: on_observation_flush
256
+ )
257
+
258
+ conn = Faraday.new(url: url, **faraday_options) do |f|
259
+ # Caller middleware sits ABOVE our transport; KnoxCall is terminal.
260
+ block&.call(f)
261
+ f.adapter(KnoxCall::Resources::Wrap::FaradayAdapter, pipeline: pipeline, direct_adapter: direct_adapter)
262
+ end
263
+ conn.define_singleton_method(:knoxcall) { pipeline }
264
+ conn
265
+ end
266
+
267
+ # A Faraday MIDDLEWARE for a stack an SDK builds itself and lets you add
268
+ # middleware to. Returns the +[klass, options]+ pair Faraday's builder
269
+ # takes; a request a Route covers, or whose host is listed, is answered
270
+ # from KnoxCall without reaching the SDK's own adapter — everything else
271
+ # continues down the stack untouched.
272
+ #
273
+ # conn.builder.insert_before(Faraday::Adapter, *knox.wrap.faraday_middleware(hosts: ["api.resend.com"]))
274
+ # # or, building a stack yourself:
275
+ # Faraday.new { |f| f.use(*knox.wrap.faraday_middleware); f.adapter :net_http }
276
+ #
277
+ # Route discovery is ON by default here (+routes: :auto+) — only listed
278
+ # or route-covered hosts are touched (decision D2). The pipeline (ready /
279
+ # refresh / manifest / stop) is +options[:pipeline]+ of the returned pair.
280
+ #
281
+ # @param hosts [Array<String>, Hash, nil] hosts to cover even when no Route does
282
+ # @param routes [Symbol] +:auto+ (default) or +:off+
283
+ # @param opts [Hash] the remaining {#faraday_connection} options except
284
+ # +url:+, +direct_adapter:+ and the Faraday options
285
+ # @return [Array(Class, Hash)] +[FaradayMiddleware, {pipeline: …}]+
286
+ def faraday_middleware(hosts: nil, routes: :auto, **opts)
287
+ require_faraday!
288
+ pipeline = KnoxCall::InterceptPipeline.new(client: @client, all_hosts: false, hosts: hosts, routes: routes, **opts)
289
+ [KnoxCall::Resources::Wrap::FaradayMiddleware, { pipeline: pipeline }]
290
+ end
291
+
292
+ # Install the opt-in, EXPERIMENTAL process-wide seam (founder decision D7,
293
+ # 2026-09-25): +Net::HTTP#request+ is prepended so an UNTOUCHED
294
+ # third-party SDK on Net::HTTP — Faraday's default adapter, +rest-client+,
295
+ # +httparty+, raw Net::HTTP — has its calls sent through the Route that
296
+ # covers them, through the ephemeral proxy for hosts listed here that no
297
+ # Route covers, and left alone otherwise (decision D2: never "all egress").
298
+ # Typhoeus / Curb / +http.rb+ have their own socket layer and are NOT
299
+ # reached — point those SDKs at {#gateway_url}.
300
+ #
301
+ # stop = knox.wrap.intercept!(hosts: ["api.resend.com"])
302
+ # stop.ready # first manifest loaded
303
+ # Net::HTTP.get(URI("https://api.hubapi.com/crm/v3/objects/contacts")) # via the covering Route
304
+ # stop.uninstall
305
+ #
306
+ # One handle per process (a second call raises {KnoxCall::Error});
307
+ # +uninstall+ restores pass-through. This is a convenience, not a security
308
+ # boundary: it is a process global and composes with other Net::HTTP
309
+ # patchers in install order. Route mode is the custody path — the key
310
+ # never enters your process.
311
+ #
312
+ # @param hosts [Array<String>, Hash, nil] as {#faraday_connection}
313
+ # @param stacks [Array<Symbol>] which seams to install; only +:net_http+ exists
314
+ # @param routes [Symbol] +:auto+ (default) or +:off+ (listed hosts, ephemeral only)
315
+ # @param require_context [Boolean] only intercept inside {#routed}
316
+ # @param opts [Hash] +unavailable:+, +credential:+, +route_around:+,
317
+ # +disable_default_route_around:+ and the +on_*+ hooks of {#faraday_connection}
318
+ # @return [KnoxCall::InterceptHandle]
319
+ def intercept!(hosts: nil, stacks: [:net_http], routes: :auto, require_context: false, **opts)
320
+ unknown = Array(stacks).map(&:to_sym) - [:net_http]
321
+ raise ArgumentError, "unknown intercept stack(s): #{unknown.join(', ')} (only :net_http exists)" unless unknown.empty?
322
+
323
+ require "knoxcall/intercept_patch"
324
+ pipeline = KnoxCall::InterceptPipeline.new(client: @client, all_hosts: false, hosts: hosts, routes: routes,
325
+ require_context: require_context, **opts)
326
+ KnoxCall::Intercept.install(pipeline)
327
+ end
328
+
329
+ # Run the block in a "routed" scope. With +require_context: true+, only
330
+ # egress performed inside {#routed} (on this thread) is intercepted — you
331
+ # mark the CALL SITE, not the SDK.
332
+ def routed(&block) = KnoxCall::InterceptContext.routed(&block)
333
+
334
+ private
335
+
336
+ # Lazily load the optional faraday gem (and the adapter + middleware, which
337
+ # reference Faraday at load time). Raises a clear, actionable error when it
338
+ # is not installed rather than a bare LoadError.
339
+ def require_faraday!
340
+ require "faraday"
341
+ require "knoxcall/wrap_faraday_adapter"
342
+ require "knoxcall/wrap_faraday_middleware"
343
+ rescue LoadError => e
344
+ raise KnoxCall::Error,
345
+ "wrap.faraday_connection requires the optional `faraday` gem, which is not installed. " \
346
+ "Add `gem \"faraday\"` to your Gemfile — KnoxCall has no runtime dependency on it. For " \
347
+ "third-party SDKs that accept only a base-URL override (no injected Faraday connection), " \
348
+ "use wrap.gateway_url instead. (#{e.class}: #{e.message})"
349
+ end
350
+ end
351
+ end
352
+ end