Module: KnoxCall::InterceptResolver

Defined in:
lib/knoxcall/intercept_resolver.rb

Overview

The route-aware interception decision table — pure, no I/O (docs/internal/sdk-wrapping/route-aware-interception-plan.md §2.2, PARITY §21.1). One request in, one decision out: send it DIRECT (untouched), through a ROUTE (the manifest says an intercept-enabled Route covers this host + path; the Route injects the stored secret), or through the EPHEMERAL proxy (the caller listed the host, no Route covers it; the SDK's own credential is lifted out-of-band). The order of the rules is the feature.

Every SDK's resolver passes the SAME fixtures — sdk/fixtures/intercept- resolver.json (spec/intercept_resolver_spec.rb runs them here) — so this is the Ruby copy of a contract whose reference is the Node SDK's src/intercept-resolver.ts, not a private heuristic.

Defined Under Namespace

Classes: Decision

Constant Summary collapse

MODES =
%i[direct route ephemeral].freeze
REASONS =
%i[kill_switch unparseable own_host route_around outside_context
manifest no_base_path_match no_route unlisted].freeze

Class Method Summary collapse

Class Method Details

.entries_for_host(manifest, host) ⇒ Object

Manifest entries for a host in the order the server sorts them — longest base_path first, then base_path, then slug — so the first entry whose base covers the path is the longest-prefix, lowest-slug match.



73
74
75
76
77
78
79
# File 'lib/knoxcall/intercept_resolver.rb', line 73

def entries_for_host(manifest, host)
  return [] if manifest.nil?

  routes = entry_value(manifest, :routes) || []
  routes.select { |e| WrapTransport.normalize_host(entry_value(e, :host)) == host }
        .sort_by { |e| bp = entry_value(e, :base_path).to_s; [-bp.length, bp, entry_value(e, :slug).to_s] }
end

.entry_value(entry, key) ⇒ Object

Read a manifest-entry field tolerating string and symbol keys (the parsed manifest is string-keyed; a hand-built one may not be).



48
49
50
51
52
# File 'lib/knoxcall/intercept_resolver.rb', line 48

def entry_value(entry, key)
  return nil unless entry.is_a?(Hash)

  entry.key?(key.to_s) ? entry[key.to_s] : entry[key.to_sym]
end

.kill_switch? ⇒ Boolean

KNOXCALL_INTERCEPT=off (or 0 / false): every interceptor and route-aware transport becomes pass-through, per request, with no deploy.

Returns:

  • (Boolean)


36
37
38
# File 'lib/knoxcall/intercept_resolver.rb', line 36

def kill_switch?
  %w[off 0 false].include?(ENV.fetch("KNOXCALL_INTERCEPT", "").strip.downcase)
end

.platform_host?(host) ⇒ Boolean

KnoxCall's own domains are never intercepted, whatever a manifest or a host list says (anti-recursion).

Returns:

  • (Boolean)


42
43
44
# File 'lib/knoxcall/intercept_resolver.rb', line 42

def platform_host?(host)
  host == "knoxcall.com" || host.end_with?(".knoxcall.com")
end

.rebase_path(request_path, base_path) ⇒ Object

The request path with the route's base prefix removed (leading slash kept), or nil when the request is not under the base. "/crm/v3" covers "/crm/v3" and "/crm/v3/x", never "/crm/v30" — the boundary is a path segment. Mirrors the server's rebasePath (src/lib/route-target-host.ts).



58
59
60
61
62
63
64
65
66
67
68
# File 'lib/knoxcall/intercept_resolver.rb', line 58

def rebase_path(request_path, base_path)
  req_path = request_path.to_s.empty? ? "/" : request_path.to_s
  if base_path.nil? || base_path == "/" || base_path == ""
    return req_path.start_with?("/") ? req_path : "/#{req_path}"
  end
  return "/" if req_path == base_path
  return nil unless req_path.start_with?("#{base_path}/")

  rest = req_path[base_path.length..]
  rest.empty? ? "/" : rest
end

.resolve(url:, method:, hosts:, manifest:, own_hosts:, route_around:, kill_switch: false, require_context: false, in_context: false) ⇒ Decision

Apply the decision table. First match wins.

Parameters:

  • url (String) —

    the request URL

  • method (String) —

    the HTTP method (carried for hooks; not a rule input today)

  • hosts (Set<String>, :all) —

    the caller's explicit host list (normalised), or :all for the explicit-transport form where every request is listed

  • manifest (Hash, nil) —

    the intercept manifest (=> [...])

  • own_hosts (Set<String>) —

    the client's own hosts (management + data plane)

  • route_around (Array<Hash>) —

    route-around rules

  • kill_switch (Boolean) (defaults to: false)
  • require_context (Boolean) (defaults to: false) —

    only intercept inside routed { }

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

    whether this request is inside routed { }

Returns:



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
# File 'lib/knoxcall/intercept_resolver.rb', line 94

def resolve(url:, method:, hosts:, manifest:, own_hosts:, route_around:,
            kill_switch: false, require_context: false, in_context: false)
  _ = method
  return Decision.new(mode: :direct, reason: :kill_switch, host: "") if kill_switch

  u = begin
    URI.parse(url.to_s)
  rescue URI::InvalidURIError
    nil
  end
  unless u && %w[http https].include?(u.scheme.to_s.downcase)
    return Decision.new(mode: :direct, reason: :unparseable, host: "")
  end
  host = WrapTransport.normalize_host(u.host)
  return Decision.new(mode: :direct, reason: :unparseable, host: "") if host.empty?

  if platform_host?(host) || own_hosts.include?(host)
    return Decision.new(mode: :direct, reason: :own_host, host: host)
  end

  around = WrapTransport.match_route_around(url.to_s, route_around)
  if around
    return Decision.new(mode: :direct, reason: :route_around, host: host,
                        route_around_reason: WrapTransport.rule_value(around, :reason))
  end

  return Decision.new(mode: :direct, reason: :outside_context, host: host) if require_context && !in_context

  entries = entries_for_host(manifest, host)
  entries.each do |entry|
    rebased = rebase_path(u.path, entry_value(entry, :base_path))
    next if rebased.nil?

    path = u.query ? "#{rebased}?#{u.query}" : rebased
    return Decision.new(mode: :route, reason: :manifest, host: host,
                        slug: entry_value(entry, :slug), path: path, entry: entry)
  end

  listed = hosts == :all || hosts.include?(host)
  if listed
    return Decision.new(mode: :ephemeral, reason: entries.empty? ? :no_route : :no_base_path_match, host: host)
  end

  Decision.new(mode: :direct, reason: :unlisted, host: host)
end