portage-ucp 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: add1fc4dcd1b12c3dea3ade248972c2fd2616233b074230838daccce41c9033d
4
- data.tar.gz: e75364c190c83ee0b9f6bc43f2aca2ef5067d8a4baf3ad5c1b6534fd1096abd7
3
+ metadata.gz: 36c3a77e9f625761b827d1e925d54217b7bff302f4a7a60540d4a8c5d1794fb9
4
+ data.tar.gz: 05a86e2740a327c63bb0e06aa450e2aa5207aa60ce637575133483ed8df4698b
5
5
  SHA512:
6
- metadata.gz: 72c18740172d1aeec163bbc7754d2190f59a92c8fa347256e344ff247ff30b8d4a046ad7f368c7b458b24be9e012748e045f7b4d059b9cef6df509c2aab200da
7
- data.tar.gz: 2fd42daa906727619d78a276926461c886ec9b20ce81e9bc5c99fe5b0e229172696edef94df5e9ef108ebc6cedcfb1435b62c6590ae96e2ccaaeb218b8778713
6
+ metadata.gz: d0d4746f829e60009463ee2748ba5c46ff6f3932b5a345f3f26d94e437a5132b39002945bf19abe1ee3d02c46e1760b52afa6910a15531df494c4e39dcca7787
7
+ data.tar.gz: 60898e67d72f545c24970415775506be4930336a4f325be61714cef0f810547f77b79951f7b48e3fb32b72cdd9adb9669b4f3af4f872be0a4bcb7ea60b325bf5
data/CHANGELOG.md CHANGED
@@ -4,6 +4,117 @@ 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
+
70
+ ## [0.3.0] - 2026-08-27
71
+
72
+ - `Portage::Ucp::Support::Retry` (`lib/portage/ucp/support/retry.rb`) —
73
+ bounded retry with backoff for adapters, plus normalized conflict/throttle
74
+ errors on `Support::ApiError` so a caller can distinguish "retry this" from
75
+ "don't."
76
+ - `Portage::Ucp::Support::SessionLock` (`lib/portage/ucp/support/session_lock.rb`)
77
+ — serializes per-cart/checkout mutations against a single upstream session,
78
+ used by the Shopify and Wix adapters to stop concurrent cart writes from
79
+ racing the same checkout.
80
+ - `Support::Idempotency` is now thread-safe under concurrent duplicate calls
81
+ — the dedup table write was not atomic, so two requests with the same
82
+ idempotency key arriving together could both miss the cache and both hit
83
+ the adapter.
84
+ - Conformance kit: the repeated-idempotency-key example no longer passes on
85
+ output equality alone. An adapter wired to a fixed-response test double
86
+ returns identical output whether or not it deduped, so the example now also
87
+ asserts the key reached `Support::Idempotency`'s dedup table when the
88
+ adapter includes that module, and `warn`s (rather than silently passing)
89
+ when it doesn't.
90
+
91
+ - `Portage::Ucp::ReferenceAdapter` (`lib/portage/ucp/reference_adapter.rb`) —
92
+ the in-memory `Adapter` roadmap §8 step 1 called for and design-log §17
93
+ flagged as missing outside `spec/support/fake_adapter.rb`, ships with the
94
+ gem now. Implements every capability including
95
+ `discount_codes_supported?`/`fulfillment_supported?`/`link_identity` — the
96
+ first adapter in this repo to back identity linking at all.
97
+ - `Portage::Ucp::RSpec`/`portage/ucp/rspec.rb` — the adapter conformance kit
98
+ design-log §17 called "the missing piece that turns 'any backend that
99
+ implements Adapter' from a README claim into something checked": an
100
+ `it_behaves_like "a portage adapter"` shared-examples suite checking the
101
+ contract's behavioral guarantees (idempotency dedup, the PAN guard,
102
+ schema-conformant wire output, `OutOfStockError` on a stale-stock line) —
103
+ not loaded by `require "portage/ucp"`, opt-in via `require
104
+ "portage/ucp/rspec"` since it pulls in RSpec itself. Exercised against
105
+ `ReferenceAdapter` in this gem's own suite
106
+ (`spec/reference_adapter_conformance_spec.rb`); wired into each adapter
107
+ gem's own spec suite (`spec/portage/ucp/<platform>/conformance_spec.rb`)
108
+ as follow-up.
109
+ - Conformance kit: `existing_variant_id` alongside `existing_product_id`, for
110
+ adapters (Shopify) where a catalog lookup id and a cart line-item id are
111
+ different GIDs. Defaults to `existing_product_id`, so every other adapter
112
+ is unaffected.
113
+ - `search_catalog`/`get_product` output is schema-wrapped like every other
114
+ capability now — previously returned a bare array/`Product` with no
115
+ `to_wire_h`, so the dispatcher's schema-wrap never touched it and nothing
116
+ caught it drifting from `catalog_search.json`/`catalog_lookup.json`.
117
+
7
118
  ## [0.2.0] - 2026-08-21
8
119
 
9
120
  - `Portage::Ucp::OutOfStockError` — the contract for `#complete_checkout`
@@ -4,9 +4,11 @@ module Portage
4
4
  # Unoverridden methods leave that capability out of the manifest.
5
5
  class Adapter
6
6
  # --- Catalog (dev.ucp.shopping.catalog) ---
7
- # @return [Array<Portage::Ucp::Product>]
7
+ # @return [Portage::Ucp::CatalogSearchResult]
8
8
  def search_catalog(query:, limit:) = not_implemented
9
- # @return [Portage::Ucp::Product, nil]
9
+ # nil when the product isn't found, same not-found posture as
10
+ # get_cart/get_checkout/get_order.
11
+ # @return [Portage::Ucp::ProductDetail, nil]
10
12
  def get_product(product_id:) = not_implemented
11
13
 
12
14
  # --- Cart (dev.ucp.shopping.cart) ---
@@ -88,6 +90,19 @@ module Portage
88
90
  # @return [Portage::Ucp::Order]
89
91
  def refund_order(order_id:, line_items:, idempotency_key:, reason: nil) = not_implemented
90
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
+
91
106
  # --- Discount (dev.ucp.shopping.discount) ---
92
107
  # Extends Cart/Checkout with the `discount_codes:` param above rather
93
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
- def call(capability:, action:, arguments: {})
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 = @adapter.public_send(method_name, **arguments)
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 }
@@ -16,5 +16,27 @@ module Portage
16
16
  # standardized "out_of_stock"/"item_unavailable" error codes
17
17
  # (schemas/shopping/types/error_code.json).
18
18
  class OutOfStockError < Error; end
19
+ # Raised when a mutation collides with a concurrent write upstream (HTTP
20
+ # 409, or a platform's own optimistic-concurrency rejection) — the
21
+ # resource changed between this call's read and its write. Support::Retry
22
+ # deliberately never retries this itself: the state it read is already
23
+ # stale, so retrying blindly would just collide again. The caller is
24
+ # expected to re-read (get_cart/get_checkout) and resubmit against
25
+ # current state. Maps to UCP's freeform "conflict" error code —
26
+ # error_code.json's examples list isn't exhaustive ("freeform codes are
27
+ # permitted"); "conflict" follows the same snake_case convention as its
28
+ # "out_of_stock"/"payment_failed" examples, same justification
29
+ # OutOfStockError above relies on.
30
+ class ConflictError < Error; end
31
+ # Raised when Support::Retry exhausts its bounded backoff against a
32
+ # genuinely-retryable upstream throttle (Shopify GraphQL THROTTLED, HTTP
33
+ # 429, or cartSubmitForCompletion's SubmitThrottled pollAfter) and the
34
+ # platform still hasn't done the work. Named apart from
35
+ # RateLimitExceededError — that one is this gem's own pluggable
36
+ # RateLimiter rejecting a call before it ever reaches the network; this
37
+ # is the upstream platform itself refusing after every retry. Maps to
38
+ # UCP's freeform "rate_limited" error code, same convention as
39
+ # ConflictError above.
40
+ class UpstreamThrottledError < Error; end
19
41
  end
20
42
  end
@@ -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(dispatcher: Portage::Ucp::Dispatcher.new(adapter: adapter, registry: registry),
20
- authenticator: authenticator, rate_limiter: rate_limiter, logger: logger)
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
- Portage::Ucp::Observability.log(context.logger, "tool_called", capability: capability.name,
50
- action: action_name, arguments: kwargs)
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
- result = context.dispatcher.call(capability: capability.name, action: action_name, arguments: kwargs)
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 so a debugging trail never
10
- # leaks the exact values this gem is most careful never to log.
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[payment_token oauth_token authorization].freeze
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
- return respond(401, error: "invalid_signature") unless valid_signature?(body, env[@signature_header])
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
- @on_order_event.call(Portage::Ucp::Order.new(**payload))
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