portage-ucp 0.9.0 → 0.10.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9c0a19a01df5cf624b7e08f79b9a8e21a8d9d348ce68469c932233f77e8f65b7
4
- data.tar.gz: ab6342cc1a9045662a0caba73858f2d1fc97f253c2df1593ab1ca629cc870055
3
+ metadata.gz: 7888232b409966ef29fae287d17d6af418dd676e12ad53a7ef7d30d7178f6ae6
4
+ data.tar.gz: e967bbb103d6911ca4ed8d4fb1204687cd8a2c7a51e3ab7675db5420b7eb0623
5
5
  SHA512:
6
- metadata.gz: 830f760577b2d590337cb38eced77dc7d8a4b102f0e946dd6e8fcfce24078ebb754334615707217ce6b2e9ce1f966b45e830c6dfd14e7dd220515a72644375b8
7
- data.tar.gz: 28139b13e1cc2716f16be0b3bfe35182e9bd48b5508a5e61f4bde1a164e93caf6d9b525d87def91153fea157b789b591fdb179251eb11d377a749b1eb6d1d413
6
+ metadata.gz: 3f78f566ac2ee41da063ae9a96a658707bf481f8f9ee791e288fc2d78ce0325ed147c2423a137b06350b3ddb5f64f09c17a5ba6967894e7795ef72c90d6f1384
7
+ data.tar.gz: 61a0ec7f01125fb8da2e0eb74fdc03b50d4fc94e38a3863e8366b1932cc871ab3787253d3199845a4a18a1902245fb7b2d3ca336b2bd896a82c4ce011d187f5b
data/CHANGELOG.md CHANGED
@@ -4,6 +4,60 @@ All notable changes to this project are documented here. Format loosely follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.0.0/); this project is
5
5
  pre-1.0, so APIs may still shift between minor versions.
6
6
 
7
+ ## [0.10.0] - 2026-09-25
8
+
9
+ - **Proxy support** (`docs/plans/proxy-support.md` Phases 1 and 3).
10
+ `Support::Connection.start(uri, route:, proxy: ProxyConfig.current, ...)`
11
+ replaces every raw `Net::HTTP.start` call site. It resolves
12
+ `HTTPS_PROXY`/`https_proxy` for https targets (Net::HTTP's own `:ENV`
13
+ mode never read them) and honors `NO_PROXY`. It also supports
14
+ `ProxyConfig` routes and named chains: `:forward` proxies (natively, or
15
+ through a hand-rolled CONNECT tunnel when `proxy_headers` are set or
16
+ hops are chained), `:gateway` mode, `ca_file` and timeout overrides.
17
+ A hop failure raises `Portage::Ucp::ProxyError` naming the hop and the
18
+ redacted proxy host. `ProxyConfig::Profile` refuses `Authorization`,
19
+ `User-Agent`, `X-Shopify-*-Access-Token` and `X-Payment-Token` in
20
+ proxy/forward headers. `Support::HttpClient#json_request` takes
21
+ `route:` (default `:platform`); `Check` uses `:probe` and
22
+ `Support::TokenExchange` uses `:payment`.
23
+ - `Rack::ForwardedRequest`, a fail-closed helper that trusts
24
+ `X-Forwarded-*`/`Forwarded` only from a configured `trusted_proxies`
25
+ list, resolves the right-most untrusted hop as the client IP, and lets
26
+ `X-Forwarded-Host` replace an endpoint's origin only for a trusted peer
27
+ and an allowlisted host. `Rack::WebhookEndpoint` uses it.
28
+ - `Support::PassthroughContext`, a fiber-local scope that carries an
29
+ inbound request's allowlisted, trusted-peer-only headers and
30
+ `Forwarded`/`X-Forwarded-For` chain onto the outbound
31
+ `Support::Connection` calls made while serving it.
32
+ - Adapter and CLI gems that call `Support::Connection` need `~> 0.10`.
33
+
34
+ - `Support::TransactionLog#reserve`/`#complete` accept a fixed allowlist of
35
+ optional attributes (`OPTIONAL_ATTRIBUTES`: `settled_by`, `handoff_reason`,
36
+ `store_url`, `expires_at`, `resolution`, `counts_toward_caps`) beyond
37
+ their existing keywords, raising `ArgumentError` on anything else — the
38
+ extension point `docs/plans/handoff-reconcile.md`'s Phase 1/2 use to
39
+ record and settle a shopper-completed hand-off as a transaction record,
40
+ not a parallel path. `#completed_since` now excludes
41
+ `counts_toward_caps: false` records (Phase 2's `warn` spend mode); a
42
+ record with neither field set reads exactly as before. No change to any
43
+ existing caller.
44
+ - Fix: `Support::HttpClient#json_request` opened every request with no
45
+ `open_timeout`/`read_timeout` of its own, so a hung upstream fell back to
46
+ Net::HTTP's own 60s defaults on both — a full minute an agent loop could
47
+ be stuck mid-checkout before anything reacted. It now defaults to a 5s
48
+ open / 30s read timeout (mirroring `Check#get`'s own explicit precedent),
49
+ overridable per call via `open_timeout:`/`read_timeout:` keywords.
50
+ `Support::Retry#retryable_error?` now also treats `Net::OpenTimeout` and
51
+ `Net::ReadTimeout` as retryable — neither carries a `status`, but both are
52
+ exactly the "the upstream didn't do the work, try again" case the module
53
+ exists for.
54
+ - Documentation only, no code change. Fixes the README's "Usage" snippet:
55
+ it called `server.start` on the `MCP::Server` `Mcp::Server.build` returns,
56
+ but that class (mcp gem 0.25.0) has no `#start` — only
57
+ `MCP::Server::Transports::StdioTransport#open` reads stdio frames. Every
58
+ bundled adapter's `exe/` had the same bug; this just fixes the README to
59
+ match.
60
+
7
61
  ## [0.9.0] - 2026-09-23
8
62
 
9
63
  - New `Support::OfferRanking` and `Support::Escalation`: the offer-ranking
data/README.md CHANGED
@@ -73,7 +73,7 @@ Portage::Ucp.configure do |config|
73
73
  end
74
74
 
75
75
  server = Portage::Ucp::Mcp::Server.build(adapter: MyAdapter.new)
76
- server.start
76
+ MCP::Server::Transports::StdioTransport.new(server).open
77
77
  ```
78
78
 
79
79
  See the root README's [Usage](https://github.com/tomtom87/Portage#usage)
@@ -1,6 +1,7 @@
1
1
  require "net/http"
2
2
  require "uri"
3
3
  require "json"
4
+ require_relative "support/connection"
4
5
 
5
6
  module Portage
6
7
  module Ucp
@@ -79,8 +80,7 @@ module Portage
79
80
  def get(uri, limit = REDIRECT_LIMIT)
80
81
  raise Portage::Ucp::Error, "too many redirects" if limit.zero?
81
82
 
82
- response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
83
- open_timeout: 5, read_timeout: 5) do |http|
83
+ response = Support::Connection.start(uri, route: :probe, open_timeout: 5, read_timeout: 5) do |http|
84
84
  http.get(uri.request_uri, { "User-Agent" => "portage-ucp-check" })
85
85
  end
86
86
 
@@ -129,10 +129,10 @@ module Portage
129
129
  # or status HTTP call itself fails.
130
130
  # @return [Hash] `{approved: true}` on approval.
131
131
  def confirm!(amount:, currency:, merchant:, idempotency_key:)
132
- json_request(Net::HTTP::Post, @confirm_url,
133
- body: { amount: amount, currency: currency, merchant: merchant,
134
- idempotency_key: idempotency_key },
135
- headers: @headers)
132
+ json_request(Net::HTTP::Post, @confirm_url, route: :notify,
133
+ body: { amount: amount, currency: currency, merchant: merchant,
134
+ idempotency_key: idempotency_key },
135
+ headers: @headers)
136
136
 
137
137
  status = @wait ? @wait.call(idempotency_key) : poll(idempotency_key)
138
138
  return { approved: true } if status == "approved"
@@ -146,7 +146,7 @@ module Portage
146
146
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @timeout_seconds
147
147
 
148
148
  loop do
149
- response = json_request(Net::HTTP::Get, status_url_for(idempotency_key), headers: @headers)
149
+ response = json_request(Net::HTTP::Get, status_url_for(idempotency_key), route: :notify, headers: @headers)
150
150
  return response["status"] if %w[approved denied].include?(response["status"])
151
151
  return "timeout" if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
152
152
 
@@ -87,5 +87,40 @@ module Portage
87
87
  @decision = decision
88
88
  end
89
89
  end
90
+
91
+ # Raised by Support::Connection (docs/plans/proxy-support.md Phase 1)
92
+ # when any hop of a proxy connection fails — a plain-forward/gateway
93
+ # dial that can't reach its proxy at all, or (most commonly) a
94
+ # hand-rolled CONNECT tunnel hop answering anything but 200. Named by
95
+ # 1-indexed hop position (never 0-indexed — "hop 2" means the second
96
+ # proxy a request passes through, matching how a human would describe a
97
+ # chain) and the *redacted* proxy host (`host:port`, or
98
+ # `http://***@host:port` when the profile carried credentials) — never
99
+ # the real credentials, same posture as `doctor`'s proxy_finding in
100
+ # portage-cli. `status` is the CONNECT response's HTTP status when the
101
+ # hop answered at all (e.g. 407 for "proxy authentication required"),
102
+ # or nil when the hop never answered (DNS failure, connection refused).
103
+ class ProxyError < Error
104
+ attr_reader :hop_index, :status
105
+
106
+ def initialize(hop_index:, host:, status: nil, detail: nil)
107
+ @hop_index = hop_index
108
+ @status = status
109
+ super(build_message(host, detail))
110
+ end
111
+
112
+ private
113
+
114
+ def build_message(host, detail)
115
+ redacted = Support::Connection.redact(host)
116
+ outcome = if status
117
+ reason = Support::Connection::REASONS[status]
118
+ "CONNECT failed with #{status}#{" (#{reason})" if reason}"
119
+ else
120
+ "CONNECT failed#{" (#{detail})" if detail}"
121
+ end
122
+ "hop #{hop_index} (#{redacted}): #{outcome}"
123
+ end
124
+ end
90
125
  end
91
126
  end
@@ -0,0 +1,258 @@
1
+ require "ipaddr"
2
+ require "rack"
3
+
4
+ module Portage
5
+ module Ucp
6
+ module Rack
7
+ # Phase 3 of docs/plans/proxy-support.md: the one seam every inbound
8
+ # Rack endpoint (WebMCP's CallEndpoint, Rack::WebhookEndpoint, and
9
+ # whatever HTTP transport fronts Mcp::Server) uses to make sense of
10
+ # `X-Forwarded-*`/`Forwarded` (RFC 7239) headers a reverse proxy sits
11
+ # in front of them adds.
12
+ #
13
+ # Fail-closed by construction: with no `trusted_proxies` configured
14
+ # (the default — `[]`), or when the immediate socket peer isn't in
15
+ # that list, every method here answers exactly as if no forwarded
16
+ # headers were present at all — behavior stays unchanged from before
17
+ # this class existed. Only once the peer itself is a trusted proxy do
18
+ # `X-Forwarded-*`/`Forwarded` get any say in the resolved client IP,
19
+ # scheme, or host.
20
+ #
21
+ # A consumer sets `trusted_proxies:` wherever it builds one of the
22
+ # endpoints above — e.g. `CallEndpoint.new(catalog: catalog,
23
+ # trusted_proxies: ["10.0.0.0/8"])` — naming the CIDR(s) of its own
24
+ # load balancer/reverse proxy, never a wildcard or the public
25
+ # Internet.
26
+ class ForwardedRequest
27
+ FOR_HEADER = "HTTP_X_FORWARDED_FOR".freeze
28
+ HOST_HEADER = "HTTP_X_FORWARDED_HOST".freeze
29
+ PROTO_HEADER = "HTTP_X_FORWARDED_PROTO".freeze
30
+ FORWARDED_HEADER = "HTTP_FORWARDED".freeze
31
+
32
+ # @raise [Portage::Ucp::Support::ProxyConfig::ConfigError] naming the
33
+ # offending header, same posture as every other protected-header
34
+ # check in the proxy-support plan — a passthrough list can never
35
+ # contain Authorization/User-Agent/X-Shopify-*-Access-Token/
36
+ # X-Payment-Token, checked once here at config-resolution time
37
+ # rather than silently dropped per-request later.
38
+ def self.validate_passthrough!(header_names)
39
+ offender = Array(header_names).find { |name| Support::ProxyConfig.protected_header?(name) }
40
+ return unless offender
41
+
42
+ raise Support::ProxyConfig::ConfigError,
43
+ "#{offender} is a protected header and cannot be set via passthrough.headers"
44
+ end
45
+
46
+ # @param request [Rack::Request]
47
+ # @param trusted_proxies [Array<String>] CIDR blocks (or bare IPs,
48
+ # which IPAddr treats as a /32 or /128) naming the reverse proxies
49
+ # allowed to set forwarding headers. Empty (the default) means
50
+ # fail-closed: nothing is ever trusted.
51
+ def initialize(request, trusted_proxies: [])
52
+ @request = request
53
+ @trusted_proxies = Array(trusted_proxies).filter_map { |cidr| safe_ipaddr(cidr) }
54
+ end
55
+
56
+ # @return [Boolean] whether the immediate socket peer is one of the
57
+ # configured `trusted_proxies`. Reads `REMOTE_ADDR` straight off
58
+ # the env (`Rack::Request#get_header`, not `#ip`) rather than
59
+ # `request.ip` — Rack::Request#ip applies its *own*, separate
60
+ # `Rack::Request.ip_filter` (a process-wide default that already
61
+ # trusts RFC 1918/loopback ranges out of the box, independent of
62
+ # whatever this class is configured with) and would otherwise let
63
+ # Rack itself decide a header is trustworthy before this class
64
+ # ever gets a say. Fail-closed has to mean fail-closed regardless
65
+ # of Rack's own defaults.
66
+ def peer_trusted?
67
+ return @peer_trusted if defined?(@peer_trusted)
68
+
69
+ @peer_trusted = trusted_ip?(raw_remote_addr)
70
+ end
71
+
72
+ # @return [String] the resolved client IP: standard reverse-proxy
73
+ # chain parsing, walking the `X-Forwarded-For`/`Forwarded` chain
74
+ # from the right and skipping any entry that is itself a trusted
75
+ # proxy — the first untrusted entry from the right is the real
76
+ # client. Falls back to the plain socket peer when the peer isn't
77
+ # trusted, no forwarding header is present, or every entry in the
78
+ # chain is itself a trusted proxy (nothing untrusted to report).
79
+ def client_ip
80
+ return strip_port(raw_remote_addr) unless peer_trusted?
81
+
82
+ chain = forwarded_for_chain
83
+ return strip_port(raw_remote_addr) if chain.empty?
84
+
85
+ chain.reverse_each { |candidate| return candidate unless trusted_ip?(candidate) }
86
+ chain.first
87
+ end
88
+
89
+ # @return [String] "http"/"https" — from `X-Forwarded-Proto`/
90
+ # `Forwarded;proto=` only when the peer is trusted, the raw
91
+ # connection scheme otherwise (never Rack::Request#scheme, which
92
+ # is itself forwarded-header-aware via Rack's own ip_filter — see
93
+ # #peer_trusted?).
94
+ def scheme
95
+ return raw_scheme unless peer_trusted?
96
+
97
+ header_value(PROTO_HEADER) || forwarded_pair("proto") || raw_scheme
98
+ end
99
+
100
+ # @return [String] "host[:port]" — from `X-Forwarded-Host`/
101
+ # `Forwarded;host=` only when the peer is trusted, the raw `Host`
102
+ # header otherwise (never Rack::Request#host_with_port, same
103
+ # reason as #scheme above).
104
+ def host
105
+ return raw_host unless peer_trusted?
106
+
107
+ header_value(HOST_HEADER) || forwarded_pair("host") || raw_host
108
+ end
109
+
110
+ # @return [Boolean] true only when the peer is trusted AND the
111
+ # forwarded host is itself in `allowed` — the gate that keeps a
112
+ # spoofed `X-Forwarded-Host` from ever widening what "own origin"
113
+ # means unless a consumer explicitly opted that host in.
114
+ def forwarded_host_allowed?(allowed)
115
+ return false unless peer_trusted?
116
+
117
+ forwarded_host = header_value(HOST_HEADER) || forwarded_pair("host")
118
+ return false if forwarded_host.nil?
119
+
120
+ Array(allowed).map(&:to_s).include?(forwarded_host)
121
+ end
122
+
123
+ # @return [String] what a same-origin check should compare against
124
+ # for "the endpoint's own origin" — the raw scheme/host unchanged,
125
+ # unless `forwarded_host_allowed?(forwarded_host_allowed)` says
126
+ # the forwarded host is explicitly trusted for this purpose, in
127
+ # which case the forwarded scheme/host replace it. Never widens
128
+ # `allowed_origins` itself — only ever changes what "own origin"
129
+ # resolves to.
130
+ def own_origin(forwarded_host_allowed: [])
131
+ return "#{raw_scheme}://#{raw_host}" unless forwarded_host_allowed?(forwarded_host_allowed)
132
+
133
+ "#{scheme}://#{host}"
134
+ end
135
+
136
+ # @param header_names [Array<String>] allowlisted inbound header
137
+ # names (already validated with .validate_passthrough! at
138
+ # config-resolution time). Returns {} from an untrusted peer, no
139
+ # matter what the caller asks for.
140
+ # @return [Hash{String=>String}]
141
+ def passthrough_headers(header_names)
142
+ return {} unless peer_trusted?
143
+
144
+ Array(header_names).each_with_object({}) do |name, out|
145
+ value = @request.get_header(rack_env_key(name))
146
+ out[name.to_s] = value if value
147
+ end
148
+ end
149
+
150
+ private
151
+
152
+ # The literal `REMOTE_ADDR`/`HTTP_HOST`/scheme env entries, bypassing
153
+ # Rack::Request's own forwarded-aware `#ip`/`#host`/`#scheme` (see
154
+ # #peer_trusted?'s comment) — this class makes its own trust
155
+ # decision from `trusted_proxies` alone, never Rack's separate
156
+ # `Rack::Request.ip_filter` default.
157
+ def raw_remote_addr
158
+ @request.get_header("REMOTE_ADDR").to_s
159
+ end
160
+
161
+ def raw_host
162
+ host_port = @request.get_header("HTTP_HOST") ||
163
+ "#{@request.get_header('SERVER_NAME')}:#{@request.get_header('SERVER_PORT')}"
164
+ strip_default_port(host_port)
165
+ end
166
+
167
+ def raw_scheme
168
+ return "https" if @request.get_header("HTTPS") == "on"
169
+
170
+ @request.get_header("rack.url_scheme") || "http"
171
+ end
172
+
173
+ # Mirrors Rack::Request#host_with_port's own "omit :80/:443 for the
174
+ # matching scheme" behavior, so #own_origin's unforwarded fallback
175
+ # matches what a plain, un-proxied request's origin has always
176
+ # looked like.
177
+ def strip_default_port(host_port)
178
+ host, port = host_port.to_s.split(":", 2)
179
+ return host_port if port.nil?
180
+ return host if raw_scheme == "http" && port == "80"
181
+ return host if raw_scheme == "https" && port == "443"
182
+
183
+ host_port
184
+ end
185
+
186
+ def rack_env_key(name)
187
+ "HTTP_#{name.to_s.upcase.tr('-', '_')}"
188
+ end
189
+
190
+ def header_value(env_key)
191
+ value = @request.get_header(env_key)
192
+ value.nil? || value.empty? ? nil : value
193
+ end
194
+
195
+ def forwarded_for_chain
196
+ xff = header_value(FOR_HEADER)
197
+ return xff.split(",").map { |entry| strip_port(entry) }.reject(&:empty?) if xff
198
+
199
+ forwarded_pairs("for").map { |value| strip_port(value) }
200
+ end
201
+
202
+ # Extracts every `for=` value, in order (oldest hop first, per RFC
203
+ # 7239 — each proxy appends, never prepends).
204
+ def forwarded_pairs(key)
205
+ raw = header_value(FORWARDED_HEADER)
206
+ return [] unless raw
207
+
208
+ raw.split(",").filter_map { |element| pair_from(element, key) }
209
+ end
210
+
211
+ # The single most-recent (right-most) `Forwarded` element's value
212
+ # for `key` — used for scheme/host, which only the immediate
213
+ # (trusted) hop's own element should set.
214
+ def forwarded_pair(key)
215
+ raw = header_value(FORWARDED_HEADER)
216
+ return nil unless raw
217
+
218
+ raw.split(",").filter_map { |element| pair_from(element, key) }.last
219
+ end
220
+
221
+ def pair_from(element, key)
222
+ match = element.match(/#{key}="?([^;,"]+)"?/i)
223
+ return nil unless match
224
+
225
+ match[1].strip.sub(/\A\[/, "").sub(/\]\z/, "")
226
+ end
227
+
228
+ def trusted_ip?(ip_string)
229
+ return false if @trusted_proxies.empty?
230
+
231
+ addr = safe_ipaddr(strip_port(ip_string))
232
+ return false unless addr
233
+
234
+ @trusted_proxies.any? { |cidr| cidr.include?(addr) }
235
+ end
236
+
237
+ def safe_ipaddr(value)
238
+ IPAddr.new(value.to_s)
239
+ rescue IPAddr::Error
240
+ nil
241
+ end
242
+
243
+ # Handles a bracketed IPv6 literal (`[::1]:8080`), a plain
244
+ # `host:port` pair, and a bare address (IPv4, or IPv6 with no
245
+ # port) — an unbracketed IPv6 address has more than one colon, so
246
+ # only a single colon is ever treated as a port separator.
247
+ def strip_port(value)
248
+ value = value.to_s.strip
249
+ return value if value.empty?
250
+ return value[/\A\[(.*)\]/, 1] || value if value.start_with?("[")
251
+ return value.split(":").first if value.count(":") == 1
252
+
253
+ value
254
+ end
255
+ end
256
+ end
257
+ end
258
+ end
@@ -1,6 +1,7 @@
1
1
  require "json"
2
2
  require "openssl"
3
3
  require "rack"
4
+ require_relative "forwarded_request"
4
5
 
5
6
  module Portage
6
7
  module Ucp
@@ -20,42 +21,77 @@ module Portage
20
21
  # a logger threaded to it, not a reason to invent a second config
21
22
  # option: `logger:` already exists on Portage::Ucp.configuration and
22
23
  # every other collaborator that logs takes it the same way.
24
+ #
25
+ # Phase 3 of docs/plans/proxy-support.md: `trusted_proxies:` lets a
26
+ # consumer behind a reverse proxy get the real client IP into its own
27
+ # logs/`on_order_event` handling (`ForwardedRequest#client_ip`, fail-
28
+ # closed with no config change otherwise) and, when
29
+ # `passthrough_headers:` is also set, forwards that request's
30
+ # allowlisted headers (trusted peer only) to any outbound call
31
+ # `on_order_event` makes via Support::Connection, through the same
32
+ # fiber-local Support::PassthroughContext CallEndpoint uses — cleared
33
+ # again once `on_order_event` returns.
23
34
  class WebhookEndpoint
35
+ # @param trusted_proxies [Array<String>] CIDRs of the consumer's own
36
+ # reverse proxy/load balancer — see ForwardedRequest.
37
+ # @param passthrough_headers [Array<String>] inbound header names to
38
+ # forward to outbound calls made from `on_order_event`, from a
39
+ # trusted peer only. Never a protected header (raises at
40
+ # construction time — see ForwardedRequest.validate_passthrough!).
41
+ # @param passthrough_forwarded ["append", "replace", "drop"] see
42
+ # Support::PassthroughContext.
24
43
  def initialize(secret:, on_order_event:, signature_header: "HTTP_X_UCP_SIGNATURE",
25
- logger: Portage::Ucp.configuration.logger)
44
+ logger: Portage::Ucp.configuration.logger, trusted_proxies: [],
45
+ passthrough_headers: [], passthrough_forwarded: "drop")
26
46
  @secret = secret
27
47
  @on_order_event = on_order_event
28
48
  @signature_header = signature_header
29
49
  @logger = logger
50
+ @trusted_proxies = trusted_proxies
51
+ @passthrough_headers = passthrough_headers
52
+ @passthrough_forwarded = passthrough_forwarded
53
+ ForwardedRequest.validate_passthrough!(@passthrough_headers)
30
54
  end
31
55
 
32
56
  def call(env)
33
57
  request = ::Rack::Request.new(env)
34
58
  return respond(404, error: "not_found") unless request.post?
35
59
 
60
+ forwarded = ForwardedRequest.new(request, trusted_proxies: @trusted_proxies)
36
61
  body = request.body.read
37
62
  unless valid_signature?(body, env[@signature_header])
38
- Portage::Ucp::Observability.log(@logger, "order_webhook_rejected", reason: "invalid_signature")
63
+ Portage::Ucp::Observability.log(@logger, "order_webhook_rejected", reason: "invalid_signature",
64
+ client_ip: forwarded.client_ip)
39
65
  return respond(401, error: "invalid_signature")
40
66
  end
41
67
 
42
- handle_order_event(body)
68
+ handle_order_event(body, forwarded)
43
69
  end
44
70
 
45
71
  private
46
72
 
47
- def handle_order_event(body)
73
+ def handle_order_event(body, forwarded)
48
74
  payload = JSON.parse(body, symbolize_names: true)
49
75
  order = Portage::Ucp::Order.new(**payload)
50
76
  Portage::Ucp::Observability.log(@logger, "order_webhook_received", order_id: order.id,
51
- checkout_id: order.checkout_id)
52
- @on_order_event.call(order)
77
+ checkout_id: order.checkout_id,
78
+ client_ip: forwarded.client_ip)
79
+ with_passthrough(forwarded) { @on_order_event.call(order) }
53
80
  respond(200, ok: true)
54
81
  rescue JSON::ParserError, ArgumentError
55
- Portage::Ucp::Observability.log(@logger, "order_webhook_rejected", reason: "bad_request")
82
+ Portage::Ucp::Observability.log(@logger, "order_webhook_rejected", reason: "bad_request",
83
+ client_ip: forwarded.client_ip)
56
84
  respond(400, error: "bad_request")
57
85
  end
58
86
 
87
+ def with_passthrough(forwarded, &)
88
+ return yield unless forwarded.peer_trusted? && @passthrough_headers.any?
89
+
90
+ headers = forwarded.passthrough_headers(@passthrough_headers)
91
+ Portage::Ucp::Support::PassthroughContext.with(headers: headers, forwarded: @passthrough_forwarded,
92
+ chain_entry: forwarded.client_ip, &)
93
+ end
94
+
59
95
  def valid_signature?(body, signature)
60
96
  return false unless signature
61
97