portage-cli 0.7.0 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +107 -0
- data/README.md +146 -1
- data/lib/portage/cli/buy.rb +187 -41
- data/lib/portage/cli/doctor.rb +87 -1
- data/lib/portage/cli/find.rb +2 -1
- data/lib/portage/cli/handoff_reconciler/wire_adapters.rb +50 -0
- data/lib/portage/cli/handoff_reconciler.rb +249 -0
- data/lib/portage/cli/handoff_spend_mode.rb +28 -0
- data/lib/portage/cli/handoff_wait_timeout.rb +49 -0
- data/lib/portage/cli/handoff_waiter.rb +103 -0
- data/lib/portage/cli/homepage_fetch.rb +41 -0
- data/lib/portage/cli/macos_notifier.rb +37 -0
- data/lib/portage/cli/notifier.rb +6 -3
- data/lib/portage/cli/payment_methods.rb +6 -4
- data/lib/portage/cli/permissive_authenticator.rb +16 -0
- data/lib/portage/cli/proxy_doctor.rb +149 -0
- data/lib/portage/cli/proxy_settings/password_ref.rb +48 -0
- data/lib/portage/cli/proxy_settings.rb +350 -0
- data/lib/portage/cli/reconcile_notifier.rb +65 -0
- data/lib/portage/cli/reconcile_notify.rb +38 -0
- data/lib/portage/cli/search_backends.rb +6 -4
- data/lib/portage/cli/user_agent.rb +39 -0
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli/webmcp.rb +25 -0
- data/lib/portage/cli/webmcp_checkout_mode.rb +28 -0
- data/lib/portage/cli.rb +236 -10
- metadata +36 -6
data/lib/portage/cli/doctor.rb
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
require "portage/ucp"
|
|
2
|
+
require "uri"
|
|
2
3
|
require_relative "confidence_check"
|
|
4
|
+
require_relative "user_agent"
|
|
5
|
+
require_relative "proxy_settings"
|
|
3
6
|
|
|
4
7
|
module Portage
|
|
5
8
|
module Cli
|
|
@@ -15,8 +18,9 @@ module Portage
|
|
|
15
18
|
class Doctor
|
|
16
19
|
Finding = Struct.new(:check, :message, keyword_init: true)
|
|
17
20
|
|
|
18
|
-
def initialize(adapter_class: nil)
|
|
21
|
+
def initialize(adapter_class: nil, proxy_settings: ProxySettings.new)
|
|
19
22
|
@adapter_class = adapter_class
|
|
23
|
+
@proxy_settings = proxy_settings
|
|
20
24
|
end
|
|
21
25
|
|
|
22
26
|
def call
|
|
@@ -26,6 +30,9 @@ module Portage
|
|
|
26
30
|
signing_keys_finding,
|
|
27
31
|
payment_handlers_finding,
|
|
28
32
|
decision_backend_finding,
|
|
33
|
+
user_agent_finding,
|
|
34
|
+
proxy_finding,
|
|
35
|
+
*proxy_doctor_findings,
|
|
29
36
|
*capability_findings
|
|
30
37
|
].compact
|
|
31
38
|
end
|
|
@@ -71,6 +78,80 @@ module Portage
|
|
|
71
78
|
Finding.new(check: "decision_backend", message: e.message)
|
|
72
79
|
end
|
|
73
80
|
|
|
81
|
+
# PORTAGE_USER_AGENT / config.json's "user_agent" (UserAgent) is sent
|
|
82
|
+
# as-is on every outbound request. Net::HTTP raises ArgumentError on a
|
|
83
|
+
# header value containing CR/LF rather than send it, so a bad override
|
|
84
|
+
# would otherwise surface for the first time mid-checkout instead of
|
|
85
|
+
# here.
|
|
86
|
+
def user_agent_finding
|
|
87
|
+
value = Portage::Cli::UserAgent.value
|
|
88
|
+
return unless value =~ /[\r\n]/
|
|
89
|
+
|
|
90
|
+
Finding.new(check: "user_agent",
|
|
91
|
+
message: "Configured User-Agent (PORTAGE_USER_AGENT or user_agent in " \
|
|
92
|
+
"~/.portage/config.json) contains a newline — every outbound request will " \
|
|
93
|
+
"raise instead of sending.")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Phase 0 of docs/plans/proxy-support.md: every raw Net::HTTP.start call
|
|
97
|
+
# site in portage-cli/portage-ucp/the adapter gems resolves its proxy
|
|
98
|
+
# from Ruby stdlib's own `:ENV` default, which — confirmed against a
|
|
99
|
+
# real local proxy — only ever reads `http_proxy`/`HTTP_PROXY`, for
|
|
100
|
+
# *both* http and https targets, and never `https_proxy`/`HTTPS_PROXY`.
|
|
101
|
+
# That's surprising enough (and the failure silent enough — requests
|
|
102
|
+
# just go direct) that it's worth a doctor check on both sides: report
|
|
103
|
+
# the effective proxy when one is actually active, and flag the classic
|
|
104
|
+
# footgun of setting only HTTPS_PROXY and expecting it to do anything
|
|
105
|
+
# here.
|
|
106
|
+
def proxy_finding
|
|
107
|
+
http_proxy = ENV["http_proxy"] || ENV.fetch("HTTP_PROXY", nil)
|
|
108
|
+
return https_only_proxy_finding if !http_proxy && https_env_proxy_set?
|
|
109
|
+
|
|
110
|
+
return unless http_proxy
|
|
111
|
+
|
|
112
|
+
Finding.new(check: "proxy",
|
|
113
|
+
message: "Outbound requests proxy through #{redact_proxy_url(http_proxy)} " \
|
|
114
|
+
"(from #{ENV['http_proxy'] ? 'http_proxy' : 'HTTP_PROXY'}, used for both http:// " \
|
|
115
|
+
"and https:// targets)#{no_proxy_suffix}.")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def https_only_proxy_finding
|
|
119
|
+
Finding.new(check: "proxy",
|
|
120
|
+
message: "HTTPS_PROXY/https_proxy is set but http_proxy/HTTP_PROXY is not — Ruby's " \
|
|
121
|
+
"Net::HTTP only ever reads http_proxy for its :ENV proxy mode (for both http:// " \
|
|
122
|
+
"and https:// targets), so every portage-cli/portage-ucp/adapter call site is " \
|
|
123
|
+
"proxying nothing right now. Set http_proxy (it covers https:// targets too) if " \
|
|
124
|
+
"that traffic should go through a proxy. (portage-ucp-client's own UCP/MCP tool " \
|
|
125
|
+
"calls are the one exception — they're Faraday-based and do read https_proxy.)")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def https_env_proxy_set?
|
|
129
|
+
!(ENV["https_proxy"].to_s.empty? && ENV["HTTPS_PROXY"].to_s.empty?)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def no_proxy_suffix
|
|
133
|
+
no_proxy = ENV["no_proxy"] || ENV.fetch("NO_PROXY", nil)
|
|
134
|
+
no_proxy ? "; no_proxy=#{no_proxy}" : ""
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# `http://***@host:port` — never the real credentials (see the plan's
|
|
138
|
+
# "Credentials stay safe" constraint).
|
|
139
|
+
def redact_proxy_url(url)
|
|
140
|
+
uri = URI.parse(url.include?("://") ? url : "http://#{url}")
|
|
141
|
+
userinfo = uri.userinfo ? "***@" : ""
|
|
142
|
+
port = uri.port ? ":#{uri.port}" : ""
|
|
143
|
+
"#{uri.scheme}://#{userinfo}#{uri.host}#{port}"
|
|
144
|
+
rescue URI::InvalidURIError
|
|
145
|
+
"(unparseable proxy URL)"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Phase 2's own checks against whatever ProxySettings resolved
|
|
149
|
+
# (flags/env/config.json), kept separate from proxy_finding above —
|
|
150
|
+
# see ProxyDoctor's own comment for why the two don't merge.
|
|
151
|
+
def proxy_doctor_findings
|
|
152
|
+
ProxyDoctor.new(proxy_settings: @proxy_settings).findings
|
|
153
|
+
end
|
|
154
|
+
|
|
74
155
|
def payment_handlers_finding
|
|
75
156
|
return unless Array(config.payment_handlers).empty?
|
|
76
157
|
|
|
@@ -106,3 +187,8 @@ module Portage
|
|
|
106
187
|
end
|
|
107
188
|
end
|
|
108
189
|
end
|
|
190
|
+
|
|
191
|
+
# Loaded after Doctor closes — ProxyDoctor calls Doctor::Finding.new, so
|
|
192
|
+
# Doctor has to exist first (this file requires proxy_settings, not
|
|
193
|
+
# proxy_doctor, at the top for exactly this reason).
|
|
194
|
+
require_relative "proxy_doctor"
|
data/lib/portage/cli/find.rb
CHANGED
|
@@ -5,6 +5,7 @@ require "portage/ucp/client"
|
|
|
5
5
|
require_relative "search_backends"
|
|
6
6
|
require_relative "probe_cache"
|
|
7
7
|
require_relative "decisions"
|
|
8
|
+
require_relative "user_agent"
|
|
8
9
|
|
|
9
10
|
module Portage
|
|
10
11
|
module Cli
|
|
@@ -128,7 +129,7 @@ module Portage
|
|
|
128
129
|
end
|
|
129
130
|
|
|
130
131
|
def discover(origin)
|
|
131
|
-
Portage::Ucp::Client.discover(origin)
|
|
132
|
+
Portage::Ucp::Client.discover(origin, headers: UserAgent.headers)
|
|
132
133
|
rescue StandardError
|
|
133
134
|
nil
|
|
134
135
|
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Cli
|
|
3
|
+
class HandoffReconciler
|
|
4
|
+
# Adapts a wire hash (from #get_checkout/#get_order) to what
|
|
5
|
+
# OrderLedger#record/PurchaseJournal#record_checkout duck-type against
|
|
6
|
+
# (Portage::Ucp::Order/Checkout value objects) — see
|
|
7
|
+
# docs/plans/handoff-reconcile.md, "The journal needs a
|
|
8
|
+
# hash-to-value-object step". Deliberately not a second, parallel
|
|
9
|
+
# entry shape: both still go through PurchaseJournal#record_checkout /
|
|
10
|
+
# OrderLedger#record's own logic (`to_wire_h`) — these just wrap the
|
|
11
|
+
# already-wire-shaped hash to answer the handful of methods each one
|
|
12
|
+
# calls.
|
|
13
|
+
WireOrder = Struct.new(:wire) do
|
|
14
|
+
def id = wire["id"]
|
|
15
|
+
def to_wire_h = wire
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
WireCheckout = Struct.new(:wire) do
|
|
19
|
+
def currency = wire["currency"]
|
|
20
|
+
def order = wire["order"] && WireOrderRef.new(wire["order"])
|
|
21
|
+
|
|
22
|
+
def line_items
|
|
23
|
+
Array(wire["line_items"]).map { |li| WireLineItem.new(li) }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
WireOrderRef = Struct.new(:wire) do
|
|
28
|
+
def id = wire["id"]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
WireLineItem = Struct.new(:wire) do
|
|
32
|
+
def item = WireItem.new(wire["item"] || {})
|
|
33
|
+
def quantity = wire["quantity"]
|
|
34
|
+
|
|
35
|
+
def totals
|
|
36
|
+
Array(wire["totals"]).map { |t| WireTotal.new(t) }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
WireItem = Struct.new(:wire) do
|
|
41
|
+
def id = wire["id"]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
WireTotal = Struct.new(:wire) do
|
|
45
|
+
def type = wire["type"]
|
|
46
|
+
def amount = wire["amount"]
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
require "uri"
|
|
3
|
+
require "portage/ucp"
|
|
4
|
+
require "portage/ucp/client"
|
|
5
|
+
require "portage/ucp/journal"
|
|
6
|
+
require_relative "user_agent"
|
|
7
|
+
require_relative "homepage_fetch"
|
|
8
|
+
require_relative "handoff_spend_mode"
|
|
9
|
+
require_relative "reconcile_notifier"
|
|
10
|
+
require_relative "permissive_authenticator"
|
|
11
|
+
require_relative "handoff_reconciler/wire_adapters"
|
|
12
|
+
|
|
13
|
+
module Portage
|
|
14
|
+
module Cli
|
|
15
|
+
# docs/plans/handoff-reconcile.md Phase 1 — resolves a single pending
|
|
16
|
+
# `settled_by: "shopper"` TransactionLog record: did the shopper finish
|
|
17
|
+
# the checkout `portage buy` handed them, in the browser, on their own?
|
|
18
|
+
#
|
|
19
|
+
# Never infers success from a vanished checkout, a 404, or a plain
|
|
20
|
+
# expiry with no answer — those settle `failed` with `resolution:
|
|
21
|
+
# "unknown"`/`"expired"`, never `complete`. Only a `completed` status the
|
|
22
|
+
# store itself reports settles `complete`. Idempotent: a record that's
|
|
23
|
+
# already terminal (`complete`/`failed`) is a no-op, so `portage buy
|
|
24
|
+
# --wait` and a cron `portage orders reconcile` racing each other never
|
|
25
|
+
# double-settle.
|
|
26
|
+
class HandoffReconciler
|
|
27
|
+
# Raised when the store can't be reached again at all (no native UCP
|
|
28
|
+
# manifest, no matching/configured platform adapter) — the record
|
|
29
|
+
# stays pending; a later run (or a shorter-lived credential/env
|
|
30
|
+
# problem being fixed) may still resolve it.
|
|
31
|
+
class ReconnectError < StandardError; end
|
|
32
|
+
|
|
33
|
+
# `checkout_status` is the store's own raw status while a record stays
|
|
34
|
+
# pending (`incomplete`, `ready_for_complete`, …) — nil once settled,
|
|
35
|
+
# or when the checkout was never found at all. Phase 3's
|
|
36
|
+
# `HandoffWaiter` reads it to know when to emit a `handoff_status`
|
|
37
|
+
# NDJSON event under `portage buy --wait --json`.
|
|
38
|
+
Result = Struct.new(:idempotency_key, :settled, :status, :resolution, :order_id, :amount, :currency,
|
|
39
|
+
:checkout_status, :note, keyword_init: true) do
|
|
40
|
+
def to_h
|
|
41
|
+
{ idempotency_key: idempotency_key, settled: settled, status: status, resolution: resolution,
|
|
42
|
+
order_id: order_id, amount: amount, currency: currency, checkout_status: checkout_status,
|
|
43
|
+
note: note }.compact
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @param spend_mode [String] one of HandoffSpendMode::MODES — Phase 2.
|
|
48
|
+
def initialize(transaction_log: Portage::Ucp::Support::TransactionLog.new,
|
|
49
|
+
order_ledger: Portage::Ucp::Support::OrderLedger.new,
|
|
50
|
+
journal: Portage::Ucp::Journal::PurchaseJournal.new,
|
|
51
|
+
notifier: ReconcileNotifier.new, spend_mode: HandoffSpendMode.resolve, clock: -> { Time.now })
|
|
52
|
+
@transaction_log = transaction_log
|
|
53
|
+
@order_ledger = order_ledger
|
|
54
|
+
@journal = journal
|
|
55
|
+
@notifier = notifier
|
|
56
|
+
@spend_mode = spend_mode
|
|
57
|
+
@clock = clock
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @param record [Hash] a TransactionLog record, as returned by
|
|
61
|
+
# `#find`/`#all` — expected to have `settled_by: "shopper"`, but
|
|
62
|
+
# this checks rather than assumes (see .each_pending_shopper_record).
|
|
63
|
+
# @return [Result]
|
|
64
|
+
def call(record)
|
|
65
|
+
if terminal?(record)
|
|
66
|
+
return Result.new(idempotency_key: record["idempotency_key"], settled: false, status: record["status"],
|
|
67
|
+
note: "already settled")
|
|
68
|
+
end
|
|
69
|
+
unless record["settled_by"] == "shopper"
|
|
70
|
+
return Result.new(idempotency_key: record["idempotency_key"], settled: false,
|
|
71
|
+
note: "not a shopper handoff")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
reconcile(record)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Every `pending`, `settled_by: "shopper"` record in the log — what
|
|
78
|
+
# `portage orders reconcile` (no `--checkout`) iterates.
|
|
79
|
+
def self.each_pending_shopper_record(transaction_log)
|
|
80
|
+
return enum_for(:each_pending_shopper_record, transaction_log) unless block_given?
|
|
81
|
+
|
|
82
|
+
transaction_log.each_record do |record|
|
|
83
|
+
yield record if record["settled_by"] == "shopper" && record["status"] == "pending"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def terminal?(record) = Portage::Ucp::Support::TransactionLog::TERMINAL_STATUSES.include?(record["status"])
|
|
90
|
+
|
|
91
|
+
def reconcile(record)
|
|
92
|
+
session = reconnect(record["store_url"])
|
|
93
|
+
checkout = session.get_checkout(checkout_id: record["checkout_id"])
|
|
94
|
+
settle_from_checkout(record, checkout)
|
|
95
|
+
rescue StandardError => e
|
|
96
|
+
# A reconnect failure (no automated path back into the store) and a
|
|
97
|
+
# transport error mid-call are both treated exactly like "not
|
|
98
|
+
# found" — see the class comment and the plan's outcome table.
|
|
99
|
+
# Distinguishing a 404 from a timeout from "can't reconnect at all"
|
|
100
|
+
# buys nothing here: either way, this run learned nothing new about
|
|
101
|
+
# whether the shopper paid, and the record's own `expires_at` is
|
|
102
|
+
# still the only thing allowed to turn that into a settle.
|
|
103
|
+
settle_not_found(record, note: e.message)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def settle_from_checkout(record, checkout)
|
|
107
|
+
case checkout && checkout["status"]
|
|
108
|
+
when "completed" then settle_complete(record, checkout)
|
|
109
|
+
when "canceled" then settle_failed(record, resolution: nil)
|
|
110
|
+
when nil then settle_not_found(record)
|
|
111
|
+
else
|
|
112
|
+
if past_expiry?(record)
|
|
113
|
+
settle_failed(record, resolution: "expired")
|
|
114
|
+
else
|
|
115
|
+
still_pending(record, checkout_status: checkout["status"])
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def settle_not_found(record, note: nil)
|
|
121
|
+
return still_pending(record, note: note) unless past_expiry?(record)
|
|
122
|
+
|
|
123
|
+
settle_failed(record, resolution: "unknown", note: note)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def past_expiry?(record)
|
|
127
|
+
expires_at = record["expires_at"]
|
|
128
|
+
return false unless expires_at
|
|
129
|
+
|
|
130
|
+
Time.parse(expires_at) < @clock.call
|
|
131
|
+
rescue ArgumentError
|
|
132
|
+
false
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def still_pending(record, note: nil, checkout_status: nil)
|
|
136
|
+
Result.new(idempotency_key: record["idempotency_key"], settled: false, status: "pending",
|
|
137
|
+
checkout_status: checkout_status, note: note)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def settle_failed(record, resolution:, note: nil)
|
|
141
|
+
updated = @transaction_log.complete(idempotency_key: record["idempotency_key"], status: "failed",
|
|
142
|
+
resolution: resolution)
|
|
143
|
+
notify(record, updated, result: "failed")
|
|
144
|
+
Result.new(idempotency_key: record["idempotency_key"], settled: true, status: "failed",
|
|
145
|
+
resolution: resolution, note: note)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# The store's own reported amount, at settle time — not the
|
|
149
|
+
# handoff-time snapshot on `record` (the shopper may have changed
|
|
150
|
+
# quantity/shipping in the browser; see the plan's "Non-negotiable
|
|
151
|
+
# constraints").
|
|
152
|
+
def settle_complete(record, checkout)
|
|
153
|
+
amount = Portage::Ucp::Support::Totals.amount(checkout["totals"])
|
|
154
|
+
currency = checkout["currency"]
|
|
155
|
+
counts = @spend_mode != "warn"
|
|
156
|
+
|
|
157
|
+
updated = @transaction_log.complete(idempotency_key: record["idempotency_key"], status: "complete",
|
|
158
|
+
amount: amount, currency: currency, counts_toward_caps: counts)
|
|
159
|
+
|
|
160
|
+
order_id = record_order(record, checkout)
|
|
161
|
+
record_journal(record, checkout)
|
|
162
|
+
notify(record, updated, result: "complete", order_id: order_id)
|
|
163
|
+
|
|
164
|
+
Result.new(idempotency_key: record["idempotency_key"], settled: true, status: "complete", order_id: order_id,
|
|
165
|
+
amount: amount, currency: currency)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Never blocks the settle above on this — the transaction record is
|
|
169
|
+
# already `complete` by the time this runs. A failure here just means
|
|
170
|
+
# the order snapshot/journal entry didn't get written this round; the
|
|
171
|
+
# money is still correctly recorded either way.
|
|
172
|
+
def record_order(record, checkout)
|
|
173
|
+
order = checkout["order"]
|
|
174
|
+
return nil unless order && order["id"]
|
|
175
|
+
|
|
176
|
+
session = reconnect(record["store_url"])
|
|
177
|
+
order_wire = session.get_order(order_id: order["id"])
|
|
178
|
+
return order["id"] unless order_wire
|
|
179
|
+
|
|
180
|
+
@order_ledger.record(idempotency_key: record["idempotency_key"], order: WireOrder.new(order_wire))
|
|
181
|
+
order["id"]
|
|
182
|
+
rescue StandardError
|
|
183
|
+
order["id"]
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def record_journal(record, checkout)
|
|
187
|
+
return unless @journal && checkout["order"]
|
|
188
|
+
|
|
189
|
+
@journal.record_checkout(shop: record["shop"], source: "handoff_reconcile",
|
|
190
|
+
checkout: WireCheckout.new(checkout), idempotency_key: record["idempotency_key"])
|
|
191
|
+
rescue StandardError
|
|
192
|
+
nil
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Best-effort, same posture as Buy#hand_off's own notifier call — a
|
|
196
|
+
# failed webhook POST never raises out of #call/#reconcile.
|
|
197
|
+
def notify(record, updated, result:, order_id: nil)
|
|
198
|
+
payload = { event: "checkout_reconciled", result: result, reason: record["handoff_reason"],
|
|
199
|
+
checkout_id: record["checkout_id"], shop: record["shop"], store_url: record["store_url"],
|
|
200
|
+
amount: updated["amount"], currency: updated["currency"], order_id: order_id,
|
|
201
|
+
resolution: updated["resolution"] }
|
|
202
|
+
if result == "complete" && record["handoff_reason"] == "policy_blocked" &&
|
|
203
|
+
@spend_mode != "warn"
|
|
204
|
+
payload[:cap_overridden_by_shopper] =
|
|
205
|
+
true
|
|
206
|
+
end
|
|
207
|
+
@notifier.call(payload.compact)
|
|
208
|
+
rescue StandardError
|
|
209
|
+
nil
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# --- reconnecting to the store (see the plan's "Reconciling needs the
|
|
213
|
+
# store again") ---
|
|
214
|
+
|
|
215
|
+
def reconnect(store_url)
|
|
216
|
+
raise ReconnectError, "no store_url on this record" if store_url.to_s.empty?
|
|
217
|
+
|
|
218
|
+
uri = URI.parse(store_url)
|
|
219
|
+
native_session(uri) || adapter_session(uri) ||
|
|
220
|
+
raise(ReconnectError, "no automated path back into #{store_url}")
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def native_session(uri)
|
|
224
|
+
Portage::Ucp::Client.discover(uri.to_s, headers: UserAgent.headers)
|
|
225
|
+
rescue Portage::Ucp::Client::DiscoveryError
|
|
226
|
+
nil
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Mirrors Buy's own own-store loopback fallback (#adapter_flow) — a
|
|
230
|
+
# later process re-discovers the platform from the homepage the same
|
|
231
|
+
# way, and must not assume the original process's session or
|
|
232
|
+
# credentials still exist, only that this platform's env vars are
|
|
233
|
+
# still set (see the plan's non-negotiable constraint).
|
|
234
|
+
def adapter_session(uri)
|
|
235
|
+
body, headers = HomepageFetch.call(uri)
|
|
236
|
+
platform = body && Portage::Ucp::Resolver.detect_platform(body, headers)
|
|
237
|
+
return nil unless platform
|
|
238
|
+
|
|
239
|
+
env = Portage::Ucp::Resolver.env_for(platform)
|
|
240
|
+
return nil unless Portage::Ucp::Resolver.missing_env(platform, env).empty?
|
|
241
|
+
|
|
242
|
+
adapter = Portage::Ucp::Resolver.build_adapter(platform, env)
|
|
243
|
+
Portage::Ucp::Client.for_adapter(adapter, authenticator: PermissiveAuthenticator.new)
|
|
244
|
+
rescue StandardError
|
|
245
|
+
nil
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
require_relative "config"
|
|
2
|
+
require_relative "setting"
|
|
3
|
+
|
|
4
|
+
module Portage
|
|
5
|
+
module Cli
|
|
6
|
+
# docs/plans/handoff-reconcile.md Phase 2 — how a shopper-paid,
|
|
7
|
+
# reconciled handoff counts toward the buyer's own spend cap/velocity
|
|
8
|
+
# limit (Portage::Ucp::PolicyGuard). Same Setting precedence as
|
|
9
|
+
# CheckoutHandoff/Notifier: flag > PORTAGE_HANDOFF_SPEND_MODE > config.json's
|
|
10
|
+
# "handoff_spend_mode". Unset, or set to anything not in MODES, is
|
|
11
|
+
# `block`, the safer default (a shopper's own completed purchase is real
|
|
12
|
+
# spend; hiding it from the cap would just let the next *agent* purchase
|
|
13
|
+
# blow through it unnoticed).
|
|
14
|
+
module HandoffSpendMode
|
|
15
|
+
ENV_VAR = "PORTAGE_HANDOFF_SPEND_MODE".freeze
|
|
16
|
+
CONFIG_KEY = "handoff_spend_mode".freeze
|
|
17
|
+
MODES = %w[block warn precheck].freeze
|
|
18
|
+
DEFAULT = "block".freeze
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def resolve(override: nil, config: Config.load)
|
|
23
|
+
mode = Setting.resolve(override: override, env: ENV_VAR, config: config, config_key: CONFIG_KEY)
|
|
24
|
+
MODES.include?(mode) ? mode : DEFAULT
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
require_relative "config"
|
|
2
|
+
require_relative "setting"
|
|
3
|
+
|
|
4
|
+
module Portage
|
|
5
|
+
module Cli
|
|
6
|
+
# docs/plans/handoff-reconcile.md Phase 3, decision 5 — the ceiling on
|
|
7
|
+
# `portage buy --wait`'s poll loop (see HandoffWaiter). Same Setting
|
|
8
|
+
# precedence as HandoffSpendMode/CheckoutHandoff: flag (--wait-timeout)
|
|
9
|
+
# > PORTAGE_HANDOFF_WAIT_TIMEOUT > config.json's "handoff_wait_timeout".
|
|
10
|
+
# Unset is 30 minutes; `off` (or `0`) removes the ceiling entirely, so
|
|
11
|
+
# the wait runs until the checkout's own `expires_at` instead.
|
|
12
|
+
module HandoffWaitTimeout
|
|
13
|
+
ENV_VAR = "PORTAGE_HANDOFF_WAIT_TIMEOUT".freeze
|
|
14
|
+
CONFIG_KEY = "handoff_wait_timeout".freeze
|
|
15
|
+
DEFAULT = 1800 # 30 minutes.
|
|
16
|
+
OFF_VALUES = %w[off 0].freeze
|
|
17
|
+
DURATION_UNITS = { "s" => 1, "m" => 60, "h" => 3600 }.freeze
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# @return [Integer, nil] seconds to wait, or nil for "no ceiling" —
|
|
22
|
+
# the caller then waits only on the checkout's own `expires_at`.
|
|
23
|
+
def resolve(override: nil, config: Config.load)
|
|
24
|
+
raw = Setting.resolve(override: override, env: ENV_VAR, config: config, config_key: CONFIG_KEY)
|
|
25
|
+
raw.nil? ? DEFAULT : parse(raw)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def parse(raw)
|
|
29
|
+
text = raw.to_s.strip.downcase
|
|
30
|
+
return nil if OFF_VALUES.include?(text)
|
|
31
|
+
|
|
32
|
+
seconds = parse_duration(text)
|
|
33
|
+
seconds&.positive? ? seconds : DEFAULT
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Accepts a bare integer (seconds) or a suffixed duration (`30m`,
|
|
37
|
+
# `1h`, `45s`) — `--wait-timeout` is a human-typed flag, not a raw
|
|
38
|
+
# second count like `--rolling-window-seconds`.
|
|
39
|
+
def parse_duration(text)
|
|
40
|
+
return text.to_i if text.match?(/\A\d+\z/)
|
|
41
|
+
|
|
42
|
+
match = text.match(/\A(\d+)([smh])\z/)
|
|
43
|
+
return nil unless match
|
|
44
|
+
|
|
45
|
+
match[1].to_i * DURATION_UNITS.fetch(match[2])
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
require_relative "handoff_reconciler"
|
|
3
|
+
require_relative "handoff_wait_timeout"
|
|
4
|
+
|
|
5
|
+
module Portage
|
|
6
|
+
module Cli
|
|
7
|
+
# docs/plans/handoff-reconcile.md Phase 3 — `portage buy --wait`'s poll
|
|
8
|
+
# loop. Polls a single pending shopper record through HandoffReconciler
|
|
9
|
+
# with backoff (2s -> 30s, plus jitter) until it settles or its deadline
|
|
10
|
+
# passes. Every decision about what a poll *means* stays
|
|
11
|
+
# HandoffReconciler's; this only decides when to poll again and when to
|
|
12
|
+
# stop.
|
|
13
|
+
#
|
|
14
|
+
# Ctrl-C: Ruby's default SIGINT handling raises Interrupt on the main
|
|
15
|
+
# thread, most likely while this is asleep between polls. Caught here so
|
|
16
|
+
# it never crashes the process with a backtrace — the record is already
|
|
17
|
+
# pending in the transaction log either way (see the plan's
|
|
18
|
+
# non-negotiable "the handoff itself never waits on reconcile"), so
|
|
19
|
+
# there's nothing to undo. It's left exactly as it is for a later
|
|
20
|
+
# `portage orders reconcile`; this never settles on interrupt.
|
|
21
|
+
class HandoffWaiter
|
|
22
|
+
INITIAL_BACKOFF = 2
|
|
23
|
+
MAX_BACKOFF = 30
|
|
24
|
+
|
|
25
|
+
def initialize(reconciler:, transaction_log:, wait_timeout_override: nil, clock: -> { Time.now },
|
|
26
|
+
sleeper: ->(seconds) { Kernel.sleep(seconds) })
|
|
27
|
+
@reconciler = reconciler
|
|
28
|
+
@transaction_log = transaction_log
|
|
29
|
+
@wait_timeout_override = wait_timeout_override
|
|
30
|
+
@clock = clock
|
|
31
|
+
@sleeper = sleeper
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @param record [Hash] the pending TransactionLog record to poll —
|
|
35
|
+
# typically fresh from the same log this was built with.
|
|
36
|
+
# @yield [Symbol, HandoffReconciler::Result] `:status` on every
|
|
37
|
+
# store-reported checkout status change (never on the first poll's
|
|
38
|
+
# initial status), `:settled` exactly once, when the loop ends
|
|
39
|
+
# because the record actually settled (never on a timeout or
|
|
40
|
+
# interrupt).
|
|
41
|
+
# @return [HandoffReconciler::Result] the last poll's result — settled
|
|
42
|
+
# if it settled, otherwise still pending (deadline reached or
|
|
43
|
+
# interrupted).
|
|
44
|
+
def call(record, &on_event)
|
|
45
|
+
deadline = compute_deadline(record)
|
|
46
|
+
backoff = INITIAL_BACKOFF
|
|
47
|
+
last_checkout_status = nil
|
|
48
|
+
loop do
|
|
49
|
+
current = @transaction_log.find(record["idempotency_key"]) || record
|
|
50
|
+
result = @reconciler.call(current)
|
|
51
|
+
last_checkout_status = emit_status(result, last_checkout_status, on_event)
|
|
52
|
+
return emit_settled(result, on_event) if result.settled
|
|
53
|
+
return result if @clock.call >= deadline
|
|
54
|
+
|
|
55
|
+
@sleeper.call(backoff_with_jitter(backoff))
|
|
56
|
+
backoff = [backoff * 2, MAX_BACKOFF].min
|
|
57
|
+
end
|
|
58
|
+
rescue Interrupt
|
|
59
|
+
HandoffReconciler::Result.new(idempotency_key: record["idempotency_key"], settled: false, status: "pending",
|
|
60
|
+
note: "wait interrupted")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def emit_status(result, last_checkout_status, on_event)
|
|
66
|
+
return last_checkout_status unless result.checkout_status && result.checkout_status != last_checkout_status
|
|
67
|
+
|
|
68
|
+
on_event&.call(:status, result)
|
|
69
|
+
result.checkout_status
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def emit_settled(result, on_event)
|
|
73
|
+
on_event&.call(:settled, result)
|
|
74
|
+
result
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The earlier of `handoff_wait_timeout` (default 30m, `off` removes
|
|
78
|
+
# it) and the checkout's own `expires_at`. If somehow neither is set
|
|
79
|
+
# (no expires_at on the record, and the timeout resolved to nil), the
|
|
80
|
+
# default timeout still applies as a floor — this loop must not spin
|
|
81
|
+
# forever with no deadline at all.
|
|
82
|
+
def compute_deadline(record)
|
|
83
|
+
started = @clock.call
|
|
84
|
+
timeout = HandoffWaitTimeout.resolve(override: @wait_timeout_override)
|
|
85
|
+
candidates = [(started + timeout if timeout), expires_at(record)].compact
|
|
86
|
+
candidates.min || (started + HandoffWaitTimeout::DEFAULT)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def expires_at(record)
|
|
90
|
+
value = record["expires_at"]
|
|
91
|
+
return nil unless value
|
|
92
|
+
|
|
93
|
+
Time.parse(value)
|
|
94
|
+
rescue ArgumentError, TypeError
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def backoff_with_jitter(backoff)
|
|
99
|
+
backoff + Random.rand(backoff * 0.25)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "uri"
|
|
3
|
+
require "portage/ucp/support/connection"
|
|
4
|
+
require_relative "user_agent"
|
|
5
|
+
|
|
6
|
+
module Portage
|
|
7
|
+
module Cli
|
|
8
|
+
# Shared "fetch this store's homepage, for platform sniffing" logic —
|
|
9
|
+
# originally private to Buy (#fetch_homepage), extracted so
|
|
10
|
+
# HandoffReconciler's own adapter-fallback reconnect (see
|
|
11
|
+
# docs/plans/handoff-reconcile.md, "Reconciling needs the store again")
|
|
12
|
+
# doesn't duplicate it.
|
|
13
|
+
module HomepageFetch
|
|
14
|
+
REDIRECT_LIMIT = 5
|
|
15
|
+
|
|
16
|
+
# @return [Array(String, Hash), Array(nil, Hash)] the body and response
|
|
17
|
+
# headers, or [nil, {}] on any failure/redirect exhaustion — same
|
|
18
|
+
# "no automated path, not a crash" posture as the rest of the buy
|
|
19
|
+
# flow.
|
|
20
|
+
def self.call(uri, limit: REDIRECT_LIMIT)
|
|
21
|
+
return [nil, {}] if limit.zero?
|
|
22
|
+
|
|
23
|
+
response = Portage::Ucp::Support::Connection.start(uri, route: :store, open_timeout: 5,
|
|
24
|
+
read_timeout: 5) do |http|
|
|
25
|
+
http.get(uri.request_uri, UserAgent.headers)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
case response
|
|
29
|
+
when Net::HTTPRedirection
|
|
30
|
+
call(URI.join(uri, response["location"]), limit: limit - 1)
|
|
31
|
+
when Net::HTTPSuccess
|
|
32
|
+
[response.body, response.to_hash]
|
|
33
|
+
else
|
|
34
|
+
[nil, {}]
|
|
35
|
+
end
|
|
36
|
+
rescue StandardError
|
|
37
|
+
[nil, {}]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|