portage-ucp 0.3.0 → 0.4.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 +4 -4
- data/CHANGELOG.md +63 -0
- data/lib/portage/ucp/adapter.rb +13 -0
- data/lib/portage/ucp/capabilities/identity_linking.rb +1 -1
- data/lib/portage/ucp/capabilities/reorder.rb +17 -0
- data/lib/portage/ucp/capability_negotiator.rb +30 -0
- data/lib/portage/ucp/dispatcher.rb +22 -3
- data/lib/portage/ucp/mcp/server.rb +39 -5
- data/lib/portage/ucp/observability.rb +12 -3
- data/lib/portage/ucp/rack/webhook_endpoint.rb +21 -3
- data/lib/portage/ucp/reference_adapter.rb +51 -0
- data/lib/portage/ucp/support/checkout_state.rb +41 -0
- data/lib/portage/ucp/value_objects.rb +22 -0
- data/lib/portage/ucp/version.rb +1 -1
- data/lib/portage/ucp.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 36c3a77e9f625761b827d1e925d54217b7bff302f4a7a60540d4a8c5d1794fb9
|
|
4
|
+
data.tar.gz: 05a86e2740a327c63bb0e06aa450e2aa5207aa60ce637575133483ed8df4698b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d0d4746f829e60009463ee2748ba5c46ff6f3932b5a345f3f26d94e437a5132b39002945bf19abe1ee3d02c46e1760b52afa6910a15531df494c4e39dcca7787
|
|
7
|
+
data.tar.gz: 60898e67d72f545c24970415775506be4930336a4f325be61714cef0f810547f77b79951f7b48e3fb32b72cdd9adb9669b4f3af4f872be0a4bcb7ea60b325bf5
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,69 @@ 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
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.4.0] - 2026-08-28
|
|
10
|
+
|
|
11
|
+
- Added `Adapter#reorder(order_id:, idempotency_key:)`, advertised as a new
|
|
12
|
+
`app.portage-ucp.reorder` capability — a Portage extension, not part of the
|
|
13
|
+
UCP spec. Hydrates a `Cart` from a previous order's line items, re-checking
|
|
14
|
+
each item's current price/availability rather than replaying the order's
|
|
15
|
+
historical totals, and reports anything no longer purchasable via the new
|
|
16
|
+
`ReorderResult#unavailable_items` rather than failing the whole call.
|
|
17
|
+
Implemented in `ReferenceAdapter` as a worked example.
|
|
18
|
+
- `Mcp::Server.call_tool` now emits a minimal pre-auth `tool_call_received`
|
|
19
|
+
event (capability, action, correlation id — no arguments) before
|
|
20
|
+
`authorize`/`rate_limit` run, moving the full `tool_called` event
|
|
21
|
+
(arguments included) below them. Previously the full event, arguments and
|
|
22
|
+
all, was logged before authorization, so an unauthenticated caller could
|
|
23
|
+
write attacker-chosen content into the operator's logs at whatever volume
|
|
24
|
+
the rate limiter would otherwise have refused (design-log §23).
|
|
25
|
+
- `Mcp::Server.correlation_id_for` stamps both events with a correlation id
|
|
26
|
+
read from the inbound W3C `traceparent` in `server_context[:_meta]`
|
|
27
|
+
(SEP-414, `MCP::TraceContext`), falling back to `SecureRandom.uuid` when
|
|
28
|
+
absent or malformed. Deliberately per-request, not per-session —
|
|
29
|
+
`Server::Context` is built once per process in `.build`, and `mcp`
|
|
30
|
+
0.25.0's Streamable HTTP transport is stateful and multi-session, so
|
|
31
|
+
memoizing an id there would stamp every session in the process with the
|
|
32
|
+
same value (design-log §23). Because `traceparent` is unauthenticated
|
|
33
|
+
input read before `authorize`/`rate_limit` run, it's validated against
|
|
34
|
+
the W3C Trace Context format before use rather than accepted as-is — a
|
|
35
|
+
malformed or oversized value falls back to a generated id instead of
|
|
36
|
+
reaching the pre-auth log or `Dispatcher`/`CheckoutState` unchecked.
|
|
37
|
+
- `Dispatcher` now threads its logger and each call's correlation id to the
|
|
38
|
+
adapter for the duration of that one call
|
|
39
|
+
(`Support::CheckoutState.with_observability`) so a
|
|
40
|
+
`checkout_state_transition` event (§12) fires from `record_checkout_status`
|
|
41
|
+
carrying the same correlation id as the `tool_called` event that triggered
|
|
42
|
+
it — without adding a `correlation_id:` kwarg to any checkout method, which
|
|
43
|
+
would have been a breaking change to the `Adapter` contract. Storage is
|
|
44
|
+
`Thread.current`, keyed per adapter object and restored on exit, rather
|
|
45
|
+
than an instance variable on the adapter: `Mcp::Server.build` constructs
|
|
46
|
+
one adapter per process, shared across every concurrent session, so an
|
|
47
|
+
instance variable would let two in-flight requests clobber each other's
|
|
48
|
+
correlation id — the same per-process-state trap §23 diagnosed for the
|
|
49
|
+
correlation id generator itself, one layer down. No `capability_negotiated`
|
|
50
|
+
event yet: `CapabilityNegotiator#negotiate` has no call site anywhere in
|
|
51
|
+
the gem outside its own spec, so there's nowhere to emit it from without
|
|
52
|
+
building that call site first (design-log §23).
|
|
53
|
+
- `Observability::REDACTED_KEYS` grows past the three credential keys to
|
|
54
|
+
cover the PII that actually flows through logged events — `email`
|
|
55
|
+
(`Identity`, §3) and `first_name`/`last_name`/`phone_number`/
|
|
56
|
+
`street_address`/`extended_address`/`address_locality`/`address_region`/
|
|
57
|
+
`address_country`/`postal_code` (`PostalAddress`, fulfillment
|
|
58
|
+
destinations). §12's "Money-adjacent PII" named no real key — `Money`/
|
|
59
|
+
`Total` carry only amounts and currency codes (design-log §23 step 4).
|
|
60
|
+
- `Rack::WebhookEndpoint` takes a `logger:` kwarg (defaulting to
|
|
61
|
+
`Portage::Ucp.configuration.logger`) and emits `order_webhook_received`
|
|
62
|
+
(order id, checkout id) on a verified payload and `order_webhook_rejected`
|
|
63
|
+
(reason: `invalid_signature` or `bad_request`) on the two rejection paths
|
|
64
|
+
— never the request body. No new `config.event_sink`: this endpoint is a
|
|
65
|
+
plain Rack app never built through `Mcp::Server.build`, so it can't reach
|
|
66
|
+
`mcp`'s own request hooks, and threading the gem's existing `logger:`
|
|
67
|
+
convention through one more constructor was the whole fix (design-log
|
|
68
|
+
§23 step 5).
|
|
69
|
+
|
|
7
70
|
## [0.3.0] - 2026-08-27
|
|
8
71
|
|
|
9
72
|
- `Portage::Ucp::Support::Retry` (`lib/portage/ucp/support/retry.rb`) —
|
data/lib/portage/ucp/adapter.rb
CHANGED
|
@@ -90,6 +90,19 @@ module Portage
|
|
|
90
90
|
# @return [Portage::Ucp::Order]
|
|
91
91
|
def refund_order(order_id:, line_items:, idempotency_key:, reason: nil) = not_implemented
|
|
92
92
|
|
|
93
|
+
# --- Reorder (app.portage-ucp.reorder — Portage extension, not part of
|
|
94
|
+
# the UCP spec: dev.ucp.dev has no reorder/cart-hydration capability as
|
|
95
|
+
# of the 2026-04-08 spec) ---
|
|
96
|
+
# Hydrates a cart from a previous order's line items so a caller doesn't
|
|
97
|
+
# have to re-walk get_order + create_cart itself. Re-checks each item's
|
|
98
|
+
# current price/availability rather than replaying the order's snapshot
|
|
99
|
+
# totals — order_line_item.json's totals are historical, not live, same
|
|
100
|
+
# posture as complete_checkout's stock re-check above — and drops
|
|
101
|
+
# anything no longer purchasable instead of failing the whole call,
|
|
102
|
+
# reporting what got dropped via ReorderResult#unavailable_items.
|
|
103
|
+
# @return [Portage::Ucp::ReorderResult, nil] nil if the order isn't found.
|
|
104
|
+
def reorder(order_id:, idempotency_key:) = not_implemented
|
|
105
|
+
|
|
93
106
|
# --- Discount (dev.ucp.shopping.discount) ---
|
|
94
107
|
# Extends Cart/Checkout with the `discount_codes:` param above rather
|
|
95
108
|
# than adding actions of its own — Capability::DISCOUNT advertises off
|
|
@@ -7,7 +7,7 @@ module Portage
|
|
|
7
7
|
actions: { "link_identity" => :link_identity }
|
|
8
8
|
)
|
|
9
9
|
|
|
10
|
-
ALL = [CATALOG, CART, CHECKOUT, ORDER, IDENTITY_LINKING, DISCOUNT, FULFILLMENT].freeze
|
|
10
|
+
ALL = [CATALOG, CART, CHECKOUT, ORDER, IDENTITY_LINKING, DISCOUNT, FULFILLMENT, REORDER].freeze
|
|
11
11
|
end
|
|
12
12
|
end
|
|
13
13
|
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Ucp
|
|
3
|
+
module Capabilities
|
|
4
|
+
# Portage-only extension, not part of the UCP spec (no ucp.dev
|
|
5
|
+
# reverse-domain meaning to borrow), so this is namespaced under the
|
|
6
|
+
# gem's own name instead of "dev.ucp.*". Advertised only if the
|
|
7
|
+
# adapter overrides Adapter#reorder, same as every other capability
|
|
8
|
+
# (Capability#advertised_for?) — an adapter that hasn't implemented it
|
|
9
|
+
# simply never shows it in the manifest.
|
|
10
|
+
REORDER = Portage::Ucp::Capability.new(
|
|
11
|
+
name: "app.portage-ucp.reorder",
|
|
12
|
+
version: "1",
|
|
13
|
+
actions: { "reorder" => :reorder }
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -13,6 +13,36 @@ module Portage
|
|
|
13
13
|
# params instead. Either way this class only deals in the parsed version
|
|
14
14
|
# list — transports are responsible for extracting it from their own
|
|
15
15
|
# request shape.
|
|
16
|
+
#
|
|
17
|
+
# §23 step 3: §12 promises a `capability_negotiated` event here, but
|
|
18
|
+
# #negotiate has no call site anywhere in this gem outside its own spec
|
|
19
|
+
# — nothing in the request path (transport `initialize` handling, or
|
|
20
|
+
# otherwise) invokes it yet. Wiring the event would mean building that
|
|
21
|
+
# call site first, which is a bigger change than threading a logger
|
|
22
|
+
# through an existing collaborator; left undone, and cut from §12
|
|
23
|
+
# rather than promised.
|
|
24
|
+
#
|
|
25
|
+
# §25: the reason there's no call site is structural, not just
|
|
26
|
+
# unbuilt. `mcp` 0.25.0's `MCP::Server#init`
|
|
27
|
+
# (lib/mcp/server.rb:608) never reads `params[:_meta]` and never calls
|
|
28
|
+
# `add_instrumentation_data`, unlike `call_tool` — so nothing at
|
|
29
|
+
# initialize time reaches `around_request`/`instrumentation_callback`
|
|
30
|
+
# or the `_meta` mechanism `Mcp::Server.correlation_id_for` already
|
|
31
|
+
# relies on (§23/§24). And over HTTP,
|
|
32
|
+
# `StreamableHTTPTransport#handle_initialization`
|
|
33
|
+
# (lib/mcp/server/transports/streamable_http_transport.rb:812) builds
|
|
34
|
+
# a `Rack::Request` with full headers but only pulls `HTTP_ORIGIN` out
|
|
35
|
+
# of it before handing the raw JSON body string to
|
|
36
|
+
# `ServerSession#handle_json` — a `UCP-Agent` header never crosses into
|
|
37
|
+
# the server at all. Wiring #negotiate for real means monkeypatching
|
|
38
|
+
# both (subclass/prepend `MCP::Server#init` to capture
|
|
39
|
+
# `clientInfo`/`_meta`, and `StreamableHTTPTransport#handle_initialization`
|
|
40
|
+
# to capture the `UCP-Agent` header), coupling this gem to `mcp`
|
|
41
|
+
# internals that could silently break on a `mcp` gem upgrade.
|
|
42
|
+
# Revisiting this needs either an upstream `mcp` gem hook at
|
|
43
|
+
# initialize time, or a deliberate decision to accept the monkeypatch
|
|
44
|
+
# coupling — not something to build silently as a side effect of
|
|
45
|
+
# another task.
|
|
16
46
|
class CapabilityNegotiator
|
|
17
47
|
def initialize(registry: CapabilityRegistry.default)
|
|
18
48
|
@registry = registry
|
|
@@ -4,12 +4,21 @@ module Portage
|
|
|
4
4
|
# through the CapabilityRegistry to the backing Adapter method, and wraps
|
|
5
5
|
# the result as MCP's dual content/structuredContent output (see §5).
|
|
6
6
|
class Dispatcher
|
|
7
|
-
def initialize(adapter:, registry: CapabilityRegistry.default)
|
|
7
|
+
def initialize(adapter:, registry: CapabilityRegistry.default, logger: Portage::Ucp.configuration.logger)
|
|
8
8
|
@adapter = adapter
|
|
9
9
|
@registry = registry
|
|
10
|
+
@logger = logger
|
|
10
11
|
end
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
# @param correlation_id [String, nil] threaded through to the adapter
|
|
14
|
+
# (via Support::CheckoutState.with_observability, scoped to this call
|
|
15
|
+
# only) so a checkout_state_transition event (§12) it emits during
|
|
16
|
+
# this call carries the same id as the tool_called event that
|
|
17
|
+
# triggered it. Optional, not correlation_id: required, since
|
|
18
|
+
# Dispatcher.call is also the conformance kit's
|
|
19
|
+
# (lib/portage/ucp/rspec.rb) and specs' direct entry point, outside
|
|
20
|
+
# any MCP request (§23).
|
|
21
|
+
def call(capability:, action:, arguments: {}, correlation_id: nil)
|
|
13
22
|
capability_definition = @registry.find(capability)
|
|
14
23
|
raise UnknownCapabilityError, capability if capability_definition.nil?
|
|
15
24
|
|
|
@@ -20,12 +29,22 @@ module Portage
|
|
|
20
29
|
|
|
21
30
|
Portage::Ucp::PaymentTokenGuard.validate!(arguments[:payment_token]) if arguments.key?(:payment_token)
|
|
22
31
|
|
|
23
|
-
result =
|
|
32
|
+
result = call_adapter(method_name, arguments, correlation_id)
|
|
24
33
|
wrap(capability, result)
|
|
25
34
|
end
|
|
26
35
|
|
|
27
36
|
private
|
|
28
37
|
|
|
38
|
+
def call_adapter(method_name, arguments, correlation_id)
|
|
39
|
+
unless @adapter.is_a?(Portage::Ucp::Support::CheckoutState)
|
|
40
|
+
return @adapter.public_send(method_name, **arguments)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
Portage::Ucp::Support::CheckoutState.with_observability(@adapter, @logger, correlation_id) do
|
|
44
|
+
@adapter.public_send(method_name, **arguments)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
29
48
|
def wrap(capability_name, result)
|
|
30
49
|
unless result.respond_to?(:to_wire_h)
|
|
31
50
|
return { content: [{ type: "text", text: result.inspect }], structuredContent: result }
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "mcp"
|
|
2
|
+
require "securerandom"
|
|
2
3
|
|
|
3
4
|
module Portage
|
|
4
5
|
module Ucp
|
|
@@ -16,8 +17,10 @@ module Portage
|
|
|
16
17
|
authenticator: Portage::Ucp.configuration.authenticator,
|
|
17
18
|
rate_limiter: Portage::Ucp.configuration.rate_limiter,
|
|
18
19
|
logger: Portage::Ucp.configuration.logger, **server_opts)
|
|
19
|
-
context = Context.new(
|
|
20
|
-
|
|
20
|
+
context = Context.new(
|
|
21
|
+
dispatcher: Portage::Ucp::Dispatcher.new(adapter: adapter, registry: registry, logger: logger),
|
|
22
|
+
authenticator: authenticator, rate_limiter: rate_limiter, logger: logger
|
|
23
|
+
)
|
|
21
24
|
tools = registry.advertised(adapter).flat_map do |capability|
|
|
22
25
|
capability.actions.map do |action_name, method_name|
|
|
23
26
|
build_tool(adapter: adapter, capability: capability, action_name: action_name,
|
|
@@ -46,17 +49,48 @@ module Portage
|
|
|
46
49
|
|
|
47
50
|
def self.call_tool(context:, capability:, action_name:, mutating:, kwargs:)
|
|
48
51
|
server_context = kwargs.delete(:server_context)
|
|
49
|
-
|
|
50
|
-
|
|
52
|
+
correlation_id = correlation_id_for(server_context)
|
|
53
|
+
Portage::Ucp::Observability.log(context.logger, "tool_call_received", capability: capability.name,
|
|
54
|
+
action: action_name,
|
|
55
|
+
correlation_id: correlation_id)
|
|
51
56
|
|
|
52
57
|
rejection = authorize(context.authenticator, server_context, mutating: mutating) ||
|
|
53
58
|
rate_limit(context.rate_limiter, server_context, capability.name, mutating: mutating)
|
|
54
59
|
return rejection if rejection
|
|
55
60
|
|
|
56
|
-
|
|
61
|
+
Portage::Ucp::Observability.log(context.logger, "tool_called", capability: capability.name,
|
|
62
|
+
action: action_name, arguments: kwargs,
|
|
63
|
+
correlation_id: correlation_id)
|
|
64
|
+
|
|
65
|
+
result = context.dispatcher.call(capability: capability.name, action: action_name, arguments: kwargs,
|
|
66
|
+
correlation_id: correlation_id)
|
|
57
67
|
::MCP::Tool::Response.new(result[:content], structured_content: result[:structuredContent])
|
|
58
68
|
end
|
|
59
69
|
|
|
70
|
+
# Per-request correlation only (§23): `Context` above is built once per
|
|
71
|
+
# process in `.build`, and mcp 0.25.0's Streamable HTTP transport is
|
|
72
|
+
# explicitly stateful/multi-session, so memoizing an id there would
|
|
73
|
+
# stamp every session in the process with the same value. Prefers the
|
|
74
|
+
# inbound W3C `traceparent` the MCP spec passes through `_meta`
|
|
75
|
+
# untouched (SEP-414, see `MCP::TraceContext`) so a caller that already
|
|
76
|
+
# traces its own calls gets one trace across both sides; generates a
|
|
77
|
+
# fallback only when absent.
|
|
78
|
+
#
|
|
79
|
+
# `traceparent` is unauthenticated input — reachable before
|
|
80
|
+
# `authorize`/`rate_limit` run, same as the pre-auth event this
|
|
81
|
+
# correlation id feeds. Validated against W3C Trace Context's own
|
|
82
|
+
# format before use, which is spec-correct behavior (a malformed
|
|
83
|
+
# traceparent MUST be treated as absent, restarting the trace), and
|
|
84
|
+
# incidentally closes off unbounded-length log writes and non-String
|
|
85
|
+
# values reaching Dispatcher/CheckoutState as a "correlation_id".
|
|
86
|
+
TRACEPARENT_FORMAT = /\A[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}\z/
|
|
87
|
+
|
|
88
|
+
def self.correlation_id_for(server_context)
|
|
89
|
+
meta = server_context[:_meta] if server_context.respond_to?(:[])
|
|
90
|
+
traceparent = meta && (meta[:traceparent] || meta["traceparent"])
|
|
91
|
+
traceparent.is_a?(String) && TRACEPARENT_FORMAT.match?(traceparent) ? traceparent : SecureRandom.uuid
|
|
92
|
+
end
|
|
93
|
+
|
|
60
94
|
def self.authorize(authenticator, server_context, mutating:)
|
|
61
95
|
return unless mutating
|
|
62
96
|
|
|
@@ -6,10 +6,19 @@ module Portage
|
|
|
6
6
|
# Structured log events (§12), emitted through a consumer-injected logger
|
|
7
7
|
# (defaults to Logger.new($stdout)) so the gem instruments nothing to a
|
|
8
8
|
# specific APM — it just exposes the events. Redacts payment_token,
|
|
9
|
-
# oauth_token, and Authorization by default
|
|
10
|
-
#
|
|
9
|
+
# oauth_token, and Authorization by default, plus the PII fields that
|
|
10
|
+
# actually appear on Portage::Ucp::Identity (email) and
|
|
11
|
+
# Portage::Ucp::PostalAddress (name/address/contact) — §23 step 4
|
|
12
|
+
# resolving §12's "Money-adjacent PII" phrase, which named no real key:
|
|
13
|
+
# Money/Total carry amounts and currency only, no PII; the PII that
|
|
14
|
+
# flows through the gem is on identity-linking results and fulfillment
|
|
15
|
+
# destinations instead.
|
|
11
16
|
module Observability
|
|
12
|
-
REDACTED_KEYS = %w[
|
|
17
|
+
REDACTED_KEYS = %w[
|
|
18
|
+
payment_token oauth_token authorization
|
|
19
|
+
email first_name last_name phone_number
|
|
20
|
+
street_address extended_address address_locality address_region address_country postal_code
|
|
21
|
+
].freeze
|
|
13
22
|
REDACTED = "[REDACTED]".freeze
|
|
14
23
|
|
|
15
24
|
def self.log(logger, event, **fields)
|
|
@@ -11,11 +11,22 @@ module Portage
|
|
|
11
11
|
# authenticated it. Normalizes to a Portage::Ucp::Order and hands off to a
|
|
12
12
|
# consumer-supplied `on_order_event` callback — the gem doesn't assume
|
|
13
13
|
# anything about how the consumer stores or reacts to order events.
|
|
14
|
+
#
|
|
15
|
+
# Emits Observability events (§12) directly via `logger:` rather than a
|
|
16
|
+
# new config.event_sink seam (§23 step 5): this endpoint is a plain Rack
|
|
17
|
+
# app, not built through Mcp::Server.build, so it never runs inside an
|
|
18
|
+
# MCP request and `mcp`'s own around_request/instrumentation_callback
|
|
19
|
+
# hooks can't see it — the same reason CheckoutState (§23 step 3) needed
|
|
20
|
+
# a logger threaded to it, not a reason to invent a second config
|
|
21
|
+
# option: `logger:` already exists on Portage::Ucp.configuration and
|
|
22
|
+
# every other collaborator that logs takes it the same way.
|
|
14
23
|
class WebhookEndpoint
|
|
15
|
-
def initialize(secret:, on_order_event:, signature_header: "HTTP_X_UCP_SIGNATURE"
|
|
24
|
+
def initialize(secret:, on_order_event:, signature_header: "HTTP_X_UCP_SIGNATURE",
|
|
25
|
+
logger: Portage::Ucp.configuration.logger)
|
|
16
26
|
@secret = secret
|
|
17
27
|
@on_order_event = on_order_event
|
|
18
28
|
@signature_header = signature_header
|
|
29
|
+
@logger = logger
|
|
19
30
|
end
|
|
20
31
|
|
|
21
32
|
def call(env)
|
|
@@ -23,7 +34,10 @@ module Portage
|
|
|
23
34
|
return respond(404, error: "not_found") unless request.post?
|
|
24
35
|
|
|
25
36
|
body = request.body.read
|
|
26
|
-
|
|
37
|
+
unless valid_signature?(body, env[@signature_header])
|
|
38
|
+
Portage::Ucp::Observability.log(@logger, "order_webhook_rejected", reason: "invalid_signature")
|
|
39
|
+
return respond(401, error: "invalid_signature")
|
|
40
|
+
end
|
|
27
41
|
|
|
28
42
|
handle_order_event(body)
|
|
29
43
|
end
|
|
@@ -32,9 +46,13 @@ module Portage
|
|
|
32
46
|
|
|
33
47
|
def handle_order_event(body)
|
|
34
48
|
payload = JSON.parse(body, symbolize_names: true)
|
|
35
|
-
|
|
49
|
+
order = Portage::Ucp::Order.new(**payload)
|
|
50
|
+
Portage::Ucp::Observability.log(@logger, "order_webhook_received", order_id: order.id,
|
|
51
|
+
checkout_id: order.checkout_id)
|
|
52
|
+
@on_order_event.call(order)
|
|
36
53
|
respond(200, ok: true)
|
|
37
54
|
rescue JSON::ParserError, ArgumentError
|
|
55
|
+
Portage::Ucp::Observability.log(@logger, "order_webhook_rejected", reason: "bad_request")
|
|
38
56
|
respond(400, error: "bad_request")
|
|
39
57
|
end
|
|
40
58
|
|
|
@@ -145,6 +145,21 @@ module Portage
|
|
|
145
145
|
end
|
|
146
146
|
end
|
|
147
147
|
|
|
148
|
+
# Not memoized against @carts' own id sequence namespace collision:
|
|
149
|
+
# next_id("cart") is shared with create_cart/update_cart, same as every
|
|
150
|
+
# other *_id prefix here.
|
|
151
|
+
def reorder(order_id:, idempotency_key:)
|
|
152
|
+
dedup(idempotency_key) do
|
|
153
|
+
order = @orders[order_id]
|
|
154
|
+
next nil unless order
|
|
155
|
+
|
|
156
|
+
available, unavailable = partition_reorderable(order.line_items)
|
|
157
|
+
cart = build_cart(next_id("cart"), available, nil)
|
|
158
|
+
@carts[cart.id] = cart
|
|
159
|
+
Portage::Ucp::ReorderResult.new(cart: cart, unavailable_items: unavailable)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
148
163
|
def discount_codes_supported? = true
|
|
149
164
|
def fulfillment_supported? = true
|
|
150
165
|
|
|
@@ -266,6 +281,42 @@ module Portage
|
|
|
266
281
|
[Portage::Ucp::Total.new(type: "subtotal", amount: 0), Portage::Ucp::Total.new(type: "total", amount: 0)]
|
|
267
282
|
end
|
|
268
283
|
|
|
284
|
+
# order.line_items here are the checkout's own response-shaped LineItem
|
|
285
|
+
# objects (see #store_order) — item.id is a variant id, not the
|
|
286
|
+
# product id build_cart's request-shaped hashes need (build_line_items'
|
|
287
|
+
# note), so this re-derives product_id by scanning @products the same
|
|
288
|
+
# way a real adapter would hit its platform's variant lookup.
|
|
289
|
+
def partition_reorderable(line_items)
|
|
290
|
+
available = []
|
|
291
|
+
unavailable = []
|
|
292
|
+
|
|
293
|
+
line_items.each do |line_item|
|
|
294
|
+
product_id = product_id_for_variant(line_item.item.id)
|
|
295
|
+
|
|
296
|
+
if product_id.nil?
|
|
297
|
+
unavailable << unavailable_reorder_item(line_item, "discontinued")
|
|
298
|
+
elsif line_item.item.id.start_with?(OUT_OF_STOCK_PREFIX)
|
|
299
|
+
unavailable << unavailable_reorder_item(line_item, "out_of_stock")
|
|
300
|
+
else
|
|
301
|
+
available << { product_id: product_id, quantity: line_item.quantity }
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
[available, unavailable]
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def unavailable_reorder_item(line_item, reason)
|
|
309
|
+
Portage::Ucp::UnavailableReorderItem.new(item_id: line_item.item.id, title: line_item.item.title,
|
|
310
|
+
reason: reason)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def product_id_for_variant(variant_id)
|
|
314
|
+
@products.each_value do |product|
|
|
315
|
+
return product.id if product.variants.any? { |variant| variant.id == variant_id }
|
|
316
|
+
end
|
|
317
|
+
nil
|
|
318
|
+
end
|
|
319
|
+
|
|
269
320
|
def raise_if_any_line_out_of_stock!(checkout)
|
|
270
321
|
unavailable = checkout.line_items.select { |li| li.item.id.start_with?(OUT_OF_STOCK_PREFIX) }
|
|
271
322
|
return if unavailable.empty?
|
|
@@ -16,6 +16,37 @@ module Portage
|
|
|
16
16
|
# respectively): a checkout this process didn't create is still
|
|
17
17
|
# schema-valid to report, just not information-complete.
|
|
18
18
|
module CheckoutState
|
|
19
|
+
# Dispatcher wraps each adapter call in .with_observability rather
|
|
20
|
+
# than writing [logger, correlation_id] onto an instance variable on
|
|
21
|
+
# the adapter. The adapter instance is shared across every session in
|
|
22
|
+
# the process (built once in Mcp::Server.build), so an instance
|
|
23
|
+
# variable is a race: two concurrent requests against the same
|
|
24
|
+
# adapter clobber each other's correlation id, and
|
|
25
|
+
# checkout_state_transition ends up stamped with the wrong request's
|
|
26
|
+
# id — the same per-process-state trap §23 diagnosed for the
|
|
27
|
+
# correlation id generator itself, one layer down. Storage is
|
|
28
|
+
# Thread.current, keyed by the adapter's object_id so multiple
|
|
29
|
+
# adapters (e.g. in specs) don't share a slot, and .with_observability
|
|
30
|
+
# restores whatever was there before on the way out so a stale value
|
|
31
|
+
# never leaks into an unrelated direct adapter call afterward.
|
|
32
|
+
#
|
|
33
|
+
# A `correlation_id:` kwarg on every checkout method would carry the
|
|
34
|
+
# same information, but those methods are the public Adapter
|
|
35
|
+
# contract (§9), and adding a required kwarg there breaks any
|
|
36
|
+
# existing adapter/caller (§23) — this stays out of that contract.
|
|
37
|
+
def self.with_observability(adapter, logger, correlation_id)
|
|
38
|
+
key = observability_key(adapter)
|
|
39
|
+
previous = Thread.current[key]
|
|
40
|
+
Thread.current[key] = [logger, correlation_id]
|
|
41
|
+
yield
|
|
42
|
+
ensure
|
|
43
|
+
Thread.current[key] = previous
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.observability_key(adapter)
|
|
47
|
+
:"portage_ucp_checkout_state_observability_#{adapter.object_id}"
|
|
48
|
+
end
|
|
49
|
+
|
|
19
50
|
private
|
|
20
51
|
|
|
21
52
|
def checkout_status(checkout_id)
|
|
@@ -24,6 +55,16 @@ module Portage
|
|
|
24
55
|
|
|
25
56
|
def record_checkout_status(checkout_id, status)
|
|
26
57
|
(@checkout_status ||= {})[checkout_id] = status
|
|
58
|
+
log_checkout_transition(checkout_id, status)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def log_checkout_transition(checkout_id, status)
|
|
62
|
+
logger, correlation_id = Thread.current[CheckoutState.observability_key(self)]
|
|
63
|
+
return unless logger
|
|
64
|
+
|
|
65
|
+
Portage::Ucp::Observability.log(logger, "checkout_state_transition", checkout_id: checkout_id,
|
|
66
|
+
status: status,
|
|
67
|
+
correlation_id: correlation_id)
|
|
27
68
|
end
|
|
28
69
|
|
|
29
70
|
# Keyed by String: adapters are called with an order id straight off
|
|
@@ -521,6 +521,28 @@ module Portage
|
|
|
521
521
|
end
|
|
522
522
|
end
|
|
523
523
|
|
|
524
|
+
# Portage extension (see Adapter#reorder / Capabilities::REORDER) — no
|
|
525
|
+
# schemas/ counterpart to cite, since it isn't part of the UCP spec.
|
|
526
|
+
# `reason` is a short adapter-chosen string (e.g. "out_of_stock",
|
|
527
|
+
# "discontinued"), not a closed enum — same posture as Adapter#cancel_order's
|
|
528
|
+
# `reason:`.
|
|
529
|
+
UnavailableReorderItem = Data.define(:item_id, :title, :reason) do
|
|
530
|
+
def to_wire_h = { "item_id" => item_id, "title" => title, "reason" => reason }
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
# Portage extension — the hydrated Cart plus what got dropped along the
|
|
534
|
+
# way, so a caller can tell the buyer what to expect before checkout
|
|
535
|
+
# rather than silently under-filling the cart.
|
|
536
|
+
ReorderResult = Data.define(:cart, :unavailable_items) do
|
|
537
|
+
def initialize(cart:, unavailable_items: []) = super
|
|
538
|
+
|
|
539
|
+
def to_wire_h
|
|
540
|
+
h = { "cart" => cart.to_wire_h }
|
|
541
|
+
h["unavailable_items"] = unavailable_items.map(&:to_wire_h) unless unavailable_items.empty?
|
|
542
|
+
h
|
|
543
|
+
end
|
|
544
|
+
end
|
|
545
|
+
|
|
524
546
|
Identity = Data.define(:subject, :email, :linked_at)
|
|
525
547
|
end
|
|
526
548
|
end
|
data/lib/portage/ucp/version.rb
CHANGED
data/lib/portage/ucp.rb
CHANGED
|
@@ -31,6 +31,7 @@ require_relative "ucp/capabilities/catalog"
|
|
|
31
31
|
require_relative "ucp/capabilities/cart"
|
|
32
32
|
require_relative "ucp/capabilities/checkout"
|
|
33
33
|
require_relative "ucp/capabilities/order"
|
|
34
|
+
require_relative "ucp/capabilities/reorder"
|
|
34
35
|
require_relative "ucp/capabilities/discount"
|
|
35
36
|
require_relative "ucp/capabilities/fulfillment"
|
|
36
37
|
require_relative "ucp/capabilities/identity_linking"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: portage-ucp
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tom Whitbread
|
|
@@ -156,6 +156,7 @@ files:
|
|
|
156
156
|
- lib/portage/ucp/capabilities/fulfillment.rb
|
|
157
157
|
- lib/portage/ucp/capabilities/identity_linking.rb
|
|
158
158
|
- lib/portage/ucp/capabilities/order.rb
|
|
159
|
+
- lib/portage/ucp/capabilities/reorder.rb
|
|
159
160
|
- lib/portage/ucp/capability.rb
|
|
160
161
|
- lib/portage/ucp/capability_negotiator.rb
|
|
161
162
|
- lib/portage/ucp/capability_registry.rb
|