portage-ucp 0.1.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.
Files changed (112) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +15 -0
  3. data/LICENSE +21 -0
  4. data/README.md +97 -0
  5. data/exe/portage-ucp-check +25 -0
  6. data/lib/portage/ucp/adapter.rb +58 -0
  7. data/lib/portage/ucp/authenticator.rb +26 -0
  8. data/lib/portage/ucp/capabilities/cart.rb +16 -0
  9. data/lib/portage/ucp/capabilities/catalog.rb +11 -0
  10. data/lib/portage/ucp/capabilities/checkout.rb +17 -0
  11. data/lib/portage/ucp/capabilities/identity_linking.rb +13 -0
  12. data/lib/portage/ucp/capabilities/order.rb +11 -0
  13. data/lib/portage/ucp/capability.rb +27 -0
  14. data/lib/portage/ucp/capability_negotiator.rb +40 -0
  15. data/lib/portage/ucp/capability_registry.rb +21 -0
  16. data/lib/portage/ucp/check.rb +96 -0
  17. data/lib/portage/ucp/configuration.rb +35 -0
  18. data/lib/portage/ucp/dispatcher.rb +39 -0
  19. data/lib/portage/ucp/errors.rb +12 -0
  20. data/lib/portage/ucp/manifest.rb +58 -0
  21. data/lib/portage/ucp/mcp/server.rb +83 -0
  22. data/lib/portage/ucp/observability.rb +31 -0
  23. data/lib/portage/ucp/payment_token_guard.rb +41 -0
  24. data/lib/portage/ucp/rack/manifest_endpoint.rb +49 -0
  25. data/lib/portage/ucp/rack/webhook_endpoint.rb +54 -0
  26. data/lib/portage/ucp/rate_limiter.rb +27 -0
  27. data/lib/portage/ucp/resolver.rb +133 -0
  28. data/lib/portage/ucp/schema_validator.rb +53 -0
  29. data/lib/portage/ucp/support/amounts.rb +43 -0
  30. data/lib/portage/ucp/support/api_error.rb +39 -0
  31. data/lib/portage/ucp/support/checkout_state.rb +42 -0
  32. data/lib/portage/ucp/support/http_client.rb +54 -0
  33. data/lib/portage/ucp/support/idempotency.rb +26 -0
  34. data/lib/portage/ucp/support/line_item_status.rb +44 -0
  35. data/lib/portage/ucp/support/not_found.rb +25 -0
  36. data/lib/portage/ucp/support/token_exchange.rb +43 -0
  37. data/lib/portage/ucp/support/totals.rb +31 -0
  38. data/lib/portage/ucp/value_objects.rb +186 -0
  39. data/lib/portage/ucp/version.rb +5 -0
  40. data/lib/portage/ucp/wire_envelope.rb +25 -0
  41. data/lib/portage/ucp.rb +44 -0
  42. data/schemas/2026-04-08/schemas/capability.json +70 -0
  43. data/schemas/2026-04-08/schemas/payment_handler.json +67 -0
  44. data/schemas/2026-04-08/schemas/service.json +190 -0
  45. data/schemas/2026-04-08/schemas/shopping/cart.json +134 -0
  46. data/schemas/2026-04-08/schemas/shopping/catalog_lookup.json +207 -0
  47. data/schemas/2026-04-08/schemas/shopping/catalog_search.json +64 -0
  48. data/schemas/2026-04-08/schemas/shopping/checkout.json +131 -0
  49. data/schemas/2026-04-08/schemas/shopping/discount.json +148 -0
  50. data/schemas/2026-04-08/schemas/shopping/fulfillment.json +154 -0
  51. data/schemas/2026-04-08/schemas/shopping/order.json +113 -0
  52. data/schemas/2026-04-08/schemas/shopping/payment.json +16 -0
  53. data/schemas/2026-04-08/schemas/shopping/types/adjustment.json +69 -0
  54. data/schemas/2026-04-08/schemas/shopping/types/amount.json +8 -0
  55. data/schemas/2026-04-08/schemas/shopping/types/attribution.json +11 -0
  56. data/schemas/2026-04-08/schemas/shopping/types/available_payment_instrument.json +22 -0
  57. data/schemas/2026-04-08/schemas/shopping/types/business_fulfillment_config.json +38 -0
  58. data/schemas/2026-04-08/schemas/shopping/types/buyer.json +25 -0
  59. data/schemas/2026-04-08/schemas/shopping/types/category.json +20 -0
  60. data/schemas/2026-04-08/schemas/shopping/types/context.json +42 -0
  61. data/schemas/2026-04-08/schemas/shopping/types/description.json +22 -0
  62. data/schemas/2026-04-08/schemas/shopping/types/detail_option_value.json +22 -0
  63. data/schemas/2026-04-08/schemas/shopping/types/error_code.json +17 -0
  64. data/schemas/2026-04-08/schemas/shopping/types/error_response.json +31 -0
  65. data/schemas/2026-04-08/schemas/shopping/types/expectation.json +62 -0
  66. data/schemas/2026-04-08/schemas/shopping/types/fulfillment.json +25 -0
  67. data/schemas/2026-04-08/schemas/shopping/types/fulfillment_available_method.json +45 -0
  68. data/schemas/2026-04-08/schemas/shopping/types/fulfillment_destination.json +16 -0
  69. data/schemas/2026-04-08/schemas/shopping/types/fulfillment_event.json +67 -0
  70. data/schemas/2026-04-08/schemas/shopping/types/fulfillment_group.json +45 -0
  71. data/schemas/2026-04-08/schemas/shopping/types/fulfillment_method.json +67 -0
  72. data/schemas/2026-04-08/schemas/shopping/types/fulfillment_option.json +56 -0
  73. data/schemas/2026-04-08/schemas/shopping/types/info_code.json +13 -0
  74. data/schemas/2026-04-08/schemas/shopping/types/input_correlation.json +24 -0
  75. data/schemas/2026-04-08/schemas/shopping/types/item.json +33 -0
  76. data/schemas/2026-04-08/schemas/shopping/types/line_item.json +46 -0
  77. data/schemas/2026-04-08/schemas/shopping/types/link.json +25 -0
  78. data/schemas/2026-04-08/schemas/shopping/types/media.json +36 -0
  79. data/schemas/2026-04-08/schemas/shopping/types/message.json +18 -0
  80. data/schemas/2026-04-08/schemas/shopping/types/message_error.json +49 -0
  81. data/schemas/2026-04-08/schemas/shopping/types/message_info.json +37 -0
  82. data/schemas/2026-04-08/schemas/shopping/types/message_warning.json +53 -0
  83. data/schemas/2026-04-08/schemas/shopping/types/option_value.json +20 -0
  84. data/schemas/2026-04-08/schemas/shopping/types/order_confirmation.json +26 -0
  85. data/schemas/2026-04-08/schemas/shopping/types/order_line_item.json +69 -0
  86. data/schemas/2026-04-08/schemas/shopping/types/pagination.json +62 -0
  87. data/schemas/2026-04-08/schemas/shopping/types/payment_credential.json +17 -0
  88. data/schemas/2026-04-08/schemas/shopping/types/payment_instrument.json +58 -0
  89. data/schemas/2026-04-08/schemas/shopping/types/platform_fulfillment_config.json +14 -0
  90. data/schemas/2026-04-08/schemas/shopping/types/postal_address.json +44 -0
  91. data/schemas/2026-04-08/schemas/shopping/types/price.json +22 -0
  92. data/schemas/2026-04-08/schemas/shopping/types/price_filter.json +17 -0
  93. data/schemas/2026-04-08/schemas/shopping/types/price_range.json +21 -0
  94. data/schemas/2026-04-08/schemas/shopping/types/product.json +89 -0
  95. data/schemas/2026-04-08/schemas/shopping/types/product_option.json +25 -0
  96. data/schemas/2026-04-08/schemas/shopping/types/rating.json +34 -0
  97. data/schemas/2026-04-08/schemas/shopping/types/retail_location.json +28 -0
  98. data/schemas/2026-04-08/schemas/shopping/types/reverse_domain_name.json +8 -0
  99. data/schemas/2026-04-08/schemas/shopping/types/search_filters.json +20 -0
  100. data/schemas/2026-04-08/schemas/shopping/types/selected_option.json +25 -0
  101. data/schemas/2026-04-08/schemas/shopping/types/shipping_destination.json +26 -0
  102. data/schemas/2026-04-08/schemas/shopping/types/signals.json +22 -0
  103. data/schemas/2026-04-08/schemas/shopping/types/signed_amount.json +7 -0
  104. data/schemas/2026-04-08/schemas/shopping/types/total.json +75 -0
  105. data/schemas/2026-04-08/schemas/shopping/types/totals.json +98 -0
  106. data/schemas/2026-04-08/schemas/shopping/types/variant.json +193 -0
  107. data/schemas/2026-04-08/schemas/shopping/types/warning_code.json +13 -0
  108. data/schemas/2026-04-08/schemas/transports/embedded_config.json +27 -0
  109. data/schemas/2026-04-08/schemas/ucp.json +364 -0
  110. data/schemas/2026-04-08/services/shopping/embedded.openrpc.json +634 -0
  111. data/schemas/2026-04-08/services/shopping/mcp.openrpc.json +492 -0
  112. metadata +283 -0
@@ -0,0 +1,26 @@
1
+ module Portage
2
+ module Ucp
3
+ module Support
4
+ # §9a: mutating Adapter methods must dedup by idempotency_key so an
5
+ # agent's retry on a dropped connection can't double-charge. None of
6
+ # the commerce APIs behind the bundled adapter gems takes an
7
+ # idempotency key natively (Shopify's cartSubmitForCompletion attemptId
8
+ # is the one partial exception), so every adapter keeps an in-process
9
+ # dedup table — this is that table.
10
+ #
11
+ # In-process only: a multi-process deployment that must dedup across
12
+ # workers needs a shared store, which is a consumer concern the same
13
+ # way RateLimiter is.
14
+ module Idempotency
15
+ private
16
+
17
+ def dedup(idempotency_key)
18
+ @idempotency_results ||= {}
19
+ return @idempotency_results[idempotency_key] if @idempotency_results.key?(idempotency_key)
20
+
21
+ @idempotency_results[idempotency_key] = yield
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,44 @@
1
+ module Portage
2
+ module Ucp
3
+ module Support
4
+ # schemas/shopping/types/order_line_item.json's `status` derived from
5
+ # the line's own quantity tracking, for platforms that report
6
+ # per-line fulfilled quantities.
7
+ #
8
+ # Platforms whose core has no per-line fulfillment (WooCommerce,
9
+ # BigCommerce) instead map their order-level status enum onto this same
10
+ # vocabulary with a table of their own, then hand every line the same
11
+ # coarse value. The tables themselves stay in their gems — the enums
12
+ # are platform-specific — but #from_table and #fulfilled_quantity are
13
+ # what those gems do with them, and that part is not.
14
+ module LineItemStatus
15
+ module_function
16
+
17
+ def derive(total:, fulfilled:)
18
+ return "removed" if total.zero?
19
+ return "fulfilled" if fulfilled == total
20
+ return "partial" if fulfilled.positive?
21
+
22
+ "processing"
23
+ end
24
+
25
+ # "processing" is the fallback for an unmapped platform status on
26
+ # purpose: every table here is written against a documented enum that
27
+ # the platform is free to extend, and an order in a status we've never
28
+ # seen is far more likely to be in flight than cancelled or delivered.
29
+ # Guessing "removed" would tell a caller an order is dead when it
30
+ # isn't.
31
+ def from_table(table, key)
32
+ table.fetch(key, "processing")
33
+ end
34
+
35
+ # Platforms without per-line fulfillment still owe
36
+ # schemas/shopping/types/order_line_item.json a `fulfilled` count, so
37
+ # the order-level status stands in for it: all of the line or none.
38
+ def fulfilled_quantity(status, quantity)
39
+ status == "fulfilled" ? quantity : 0
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,25 @@
1
+ module Portage
2
+ module Ucp
3
+ module Support
4
+ # UCP's read methods return nil for a missing resource (see
5
+ # Portage::Ucp::Adapter's `@return [..., nil]` annotations), but REST
6
+ # APIs signal that with a 404 rather than an empty success body — so
7
+ # every REST-backed adapter wrapped its reads in the same
8
+ # rescue-404-return-nil block.
9
+ #
10
+ # Only 404 is swallowed: a 401 or 500 is a real failure the caller
11
+ # needs to see, not an absent product.
12
+ module NotFound
13
+ private
14
+
15
+ def nil_on_not_found
16
+ yield
17
+ rescue Portage::Ucp::Support::ApiError => e
18
+ raise unless e.status == 404
19
+
20
+ nil
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,43 @@
1
+ require "net/http"
2
+ require "json"
3
+ require "uri"
4
+
5
+ module Portage
6
+ module Ucp
7
+ module Support
8
+ # The POST-JSON-and-parse half of every adapter gem's
9
+ # AccessTokenFetcher. What each gem keeps is the part that genuinely
10
+ # differs: its endpoint, its grant payload, and how it maps the
11
+ # response body onto its own Result struct (Magento reports no
12
+ # `expires_in` at all; Etsy rotates the refresh_token on every use).
13
+ #
14
+ # Separate from Support::HttpClient because the failure mode is
15
+ # different: a token exchange has no ApiError/status contract to honor
16
+ # — a non-2xx here means "these credentials don't work", which is the
17
+ # gem's plain Error, not something a caller retries on a 404.
18
+ module TokenExchange
19
+ private
20
+
21
+ # @param error_class [Class] the gem's own Error
22
+ # @param description [String] what failed, e.g. "token refresh failed"
23
+ # @param form [Boolean] send the grant as form-encoded rather than
24
+ # JSON. OAuth 2.0 specifies form encoding for the token endpoint and
25
+ # Shopify follows it to the letter; the others accept JSON, so JSON
26
+ # stays the default rather than churn three working callers.
27
+ # @return [Hash, String] the parsed response body
28
+ def exchange(endpoint, payload, error_class:, description: "token exchange failed", form: false)
29
+ uri = URI(endpoint)
30
+ request = Net::HTTP::Post.new(uri)
31
+ request["Content-Type"] = form ? "application/x-www-form-urlencoded" : "application/json"
32
+ request.body = form ? URI.encode_www_form(payload) : JSON.generate(payload)
33
+
34
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
35
+ body = JSON.parse(response.body)
36
+ raise error_class, "#{description}: #{body}" unless response.is_a?(Net::HTTPSuccess)
37
+
38
+ body
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,31 @@
1
+ module Portage
2
+ module Ucp
3
+ module Support
4
+ # Builders for the two Total-array shapes the shopping schemas use.
5
+ # Both encode rules that were previously restated in every mapper: the
6
+ # top-level array is exactly one subtotal plus one total with an
7
+ # optional tax entry between them, and a line item's array is the same
8
+ # amount reported twice unless the line carries its own discount.
9
+ module Totals
10
+ module_function
11
+
12
+ # Top-level cost breakdown for a Cart/Checkout/Order. The tax entry
13
+ # is emitted only when positive — a zero-tax order shouldn't claim a
14
+ # tax line it doesn't have.
15
+ def summary(subtotal:, total:, tax: nil)
16
+ entries = [Portage::Ucp::Total.new(type: "subtotal", amount: subtotal)]
17
+ entries << Portage::Ucp::Total.new(type: "tax", amount: tax) if tax&.positive?
18
+ entries << Portage::Ucp::Total.new(type: "total", amount: total)
19
+ entries
20
+ end
21
+
22
+ # A line item's own totals. `total` defaults to `subtotal` for the
23
+ # common case where nothing discounts the line.
24
+ def line(subtotal, total = subtotal)
25
+ [Portage::Ucp::Total.new(type: "subtotal", amount: subtotal),
26
+ Portage::Ucp::Total.new(type: "total", amount: total)]
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,186 @@
1
+ module Portage
2
+ module Ucp
3
+ # Internal arithmetic helper only — never appears in a wire shape directly.
4
+ Money = Data.define(:amount_minor, :currency)
5
+
6
+ Product = Data.define(:id, :title, :description, :price, :available, :variants, :url)
7
+
8
+ # One cost-breakdown entry (schemas/shopping/types/total.json). `amount` is a
9
+ # signed integer in the parent object's currency's minor units.
10
+ Total = Data.define(:type, :amount, :display_text) do
11
+ def initialize(type:, amount:, display_text: nil) = super
12
+
13
+ def to_wire_h
14
+ h = { "type" => type, "amount" => amount }
15
+ h["display_text"] = display_text if display_text
16
+ h
17
+ end
18
+ end
19
+
20
+ # Product identity on a line item (schemas/shopping/types/item.json). `price`
21
+ # is a bare integer minor-unit amount — the parent object's currency applies.
22
+ Item = Data.define(:id, :title, :price, :image_url) do
23
+ def initialize(id:, title:, price:, image_url: nil) = super
24
+
25
+ def to_wire_h
26
+ h = { "id" => id, "title" => title, "price" => price }
27
+ h["image_url"] = image_url if image_url
28
+ h
29
+ end
30
+ end
31
+
32
+ # schemas/shopping/types/line_item.json — id/item/quantity/totals, not a
33
+ # flat product_id/unit_price/total triple.
34
+ LineItem = Data.define(:id, :item, :quantity, :totals) do
35
+ def to_wire_h
36
+ { "id" => id, "item" => item.to_wire_h, "quantity" => quantity,
37
+ "totals" => totals.map(&:to_wire_h) }
38
+ end
39
+ end
40
+
41
+ # schemas/shopping/types/link.json
42
+ Link = Data.define(:type, :url, :title) do
43
+ def initialize(type:, url:, title: nil) = super
44
+
45
+ def to_wire_h
46
+ h = { "type" => type, "url" => url }
47
+ h["title"] = title if title
48
+ h
49
+ end
50
+ end
51
+
52
+ # schemas/shopping/cart.json — requires ucp/id/line_items/currency/totals.
53
+ # The "ucp" envelope is added centrally by Portage::Ucp::WireEnvelope, not stored
54
+ # on the value object itself.
55
+ Cart = Data.define(:id, :line_items, :currency, :totals) do
56
+ def to_wire_h
57
+ { "id" => id, "line_items" => line_items.map(&:to_wire_h), "currency" => currency,
58
+ "totals" => totals.map(&:to_wire_h) }
59
+ end
60
+ end
61
+
62
+ # schemas/shopping/types/order_confirmation.json — requires id/permalink_url.
63
+ # Carried on Checkout#order once complete_checkout actually produces an
64
+ # order; nil until then.
65
+ OrderConfirmation = Data.define(:id, :permalink_url, :label) do
66
+ def initialize(id:, permalink_url:, label: nil) = super
67
+
68
+ def to_wire_h
69
+ h = { "id" => id, "permalink_url" => permalink_url }
70
+ h["label"] = label if label
71
+ h
72
+ end
73
+ end
74
+
75
+ # schemas/shopping/checkout.json — requires ucp/id/line_items/status/
76
+ # currency/totals/links. `status` is the real enum (incomplete,
77
+ # requires_escalation, ready_for_complete, complete_in_progress, completed,
78
+ # canceled) — not the old ad hoc "pending"/"completed" strings. `order` is
79
+ # the schema's optional order_confirmation — set once complete_checkout
80
+ # actually produces an order, nil otherwise.
81
+ Checkout = Data.define(:id, :status, :line_items, :currency, :totals, :links, :order) do
82
+ def initialize(id:, status:, line_items:, currency:, totals:, links:, order: nil) = super
83
+
84
+ def to_wire_h
85
+ h = { "id" => id, "status" => status, "line_items" => line_items.map(&:to_wire_h),
86
+ "currency" => currency, "totals" => totals.map(&:to_wire_h), "links" => links.map(&:to_wire_h) }
87
+ h["order"] = order.to_wire_h if order
88
+ h
89
+ end
90
+ end
91
+
92
+ # schemas/shopping/types/order_line_item.json — distinct from LineItem:
93
+ # quantity is {original,total,fulfilled} tracking, plus a derived status
94
+ # enum, not a bare integer.
95
+ OrderLineItem = Data.define(:id, :item, :quantity, :totals, :status, :parent_id) do
96
+ def initialize(id:, item:, quantity:, totals:, status:, parent_id: nil) = super
97
+
98
+ def to_wire_h
99
+ q = { "total" => quantity[:total], "fulfilled" => quantity[:fulfilled] }
100
+ q["original"] = quantity[:original] if quantity[:original]
101
+ h = { "id" => id, "item" => item.to_wire_h, "quantity" => q, "totals" => totals.map(&:to_wire_h),
102
+ "status" => status }
103
+ h["parent_id"] = parent_id if parent_id
104
+ h
105
+ end
106
+ end
107
+
108
+ # schemas/shopping/types/expectation.json — buyer-facing delivery grouping.
109
+ Expectation = Data.define(:id, :line_items, :method_type, :destination, :description, :fulfillable_on) do
110
+ def initialize(id:, line_items:, method_type:, destination:, description: nil, fulfillable_on: nil) = super
111
+
112
+ def to_wire_h
113
+ h = { "id" => id, "line_items" => line_items, "method_type" => method_type, "destination" => destination }
114
+ h["description"] = description if description
115
+ h["fulfillable_on"] = fulfillable_on if fulfillable_on
116
+ h
117
+ end
118
+ end
119
+
120
+ # schemas/shopping/types/fulfillment_event.json — append-only shipment event.
121
+ FulfillmentEvent = Data.define(:id, :occurred_at, :type, :line_items, :tracking_number, :tracking_url, :carrier,
122
+ :description) do
123
+ def initialize(id:, occurred_at:, type:, line_items:, tracking_number: nil, tracking_url: nil, carrier: nil,
124
+ description: nil)
125
+ super
126
+ end
127
+
128
+ def to_wire_h
129
+ h = { "id" => id, "occurred_at" => occurred_at, "type" => type, "line_items" => line_items }
130
+ h["tracking_number"] = tracking_number if tracking_number
131
+ h["tracking_url"] = tracking_url if tracking_url
132
+ h["carrier"] = carrier if carrier
133
+ h["description"] = description if description
134
+ h
135
+ end
136
+ end
137
+
138
+ # schemas/shopping/order.json#/properties/fulfillment — no required inner
139
+ # fields (expectations/events are both optional), so an adapter that
140
+ # doesn't model buyer-facing delivery expectations yet can pass
141
+ # Fulfillment.new and still be schema-conformant.
142
+ Fulfillment = Data.define(:expectations, :events) do
143
+ def initialize(expectations: [], events: []) = super
144
+
145
+ def to_wire_h
146
+ { "expectations" => expectations.map(&:to_wire_h), "events" => events.map(&:to_wire_h) }
147
+ end
148
+ end
149
+
150
+ # schemas/shopping/types/adjustment.json — post-order event independent of
151
+ # fulfillment (refund, return, credit, price_adjustment, dispute,
152
+ # cancellation, ...). `status` is pending/completed/failed.
153
+ Adjustment = Data.define(:id, :type, :occurred_at, :status, :line_items, :totals, :description) do
154
+ def initialize(id:, type:, occurred_at:, status:, line_items: nil, totals: nil, description: nil) = super
155
+
156
+ def to_wire_h
157
+ h = { "id" => id, "type" => type, "occurred_at" => occurred_at, "status" => status }
158
+ h["line_items"] = line_items if line_items
159
+ h["totals"] = totals.map(&:to_wire_h) if totals
160
+ h["description"] = description if description
161
+ h
162
+ end
163
+ end
164
+
165
+ # schemas/shopping/order.json — requires ucp/id/checkout_id/permalink_url/
166
+ # line_items/fulfillment/currency/totals. `adjustments` is optional —
167
+ # omitted from the wire payload when empty.
168
+ Order = Data.define(:id, :checkout_id, :permalink_url, :line_items, :fulfillment, :currency, :totals,
169
+ :adjustments) do
170
+ def initialize(id:, checkout_id:, permalink_url:, line_items:, fulfillment:, currency:, totals:,
171
+ adjustments: [])
172
+ super
173
+ end
174
+
175
+ def to_wire_h
176
+ h = { "id" => id, "checkout_id" => checkout_id, "permalink_url" => permalink_url,
177
+ "line_items" => line_items.map(&:to_wire_h), "fulfillment" => fulfillment.to_wire_h,
178
+ "currency" => currency, "totals" => totals.map(&:to_wire_h) }
179
+ h["adjustments"] = adjustments.map(&:to_wire_h) unless adjustments.empty?
180
+ h
181
+ end
182
+ end
183
+
184
+ Identity = Data.define(:subject, :email, :linked_at)
185
+ end
186
+ end
@@ -0,0 +1,5 @@
1
+ module Portage
2
+ module Ucp
3
+ VERSION = "0.1.0".freeze
4
+ end
5
+ end
@@ -0,0 +1,25 @@
1
+ module Portage
2
+ module Ucp
3
+ # Adds the "ucp" response envelope UCP requires on Cart/Checkout/Order
4
+ # payloads (schemas/ucp.json's response_cart_schema/response_checkout_schema/
5
+ # response_order_schema) — centralized here so the version string and each
6
+ # capability's minimum envelope shape live in one place, not duplicated
7
+ # across value objects.
8
+ module WireEnvelope
9
+ SPEC_VERSION = "2026-04-08".freeze
10
+
11
+ ENVELOPES = {
12
+ "dev.ucp.shopping.cart" => -> { { "version" => SPEC_VERSION } },
13
+ "dev.ucp.shopping.checkout" => -> { { "version" => SPEC_VERSION, "payment_handlers" => {} } },
14
+ "dev.ucp.shopping.order" => -> { { "version" => SPEC_VERSION } }
15
+ }.freeze
16
+
17
+ def self.wrap(capability_name, payload_hash)
18
+ envelope = ENVELOPES[capability_name]
19
+ return payload_hash unless envelope
20
+
21
+ payload_hash.merge("ucp" => envelope.call)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,44 @@
1
+ require_relative "ucp/version"
2
+ require_relative "ucp/configuration"
3
+ require_relative "ucp/errors"
4
+ require_relative "ucp/value_objects"
5
+ require_relative "ucp/wire_envelope"
6
+ require_relative "ucp/adapter"
7
+ # Shared building blocks for adapter gems (Portage::Ucp::Support) — nothing in
8
+ # the core gem's own request path uses them, they exist so the bundled
9
+ # Shopify/Wix/WooCommerce/... gems don't each re-derive the same money
10
+ # conversion, dedup table and HTTP plumbing.
11
+ require_relative "ucp/support/amounts"
12
+ require_relative "ucp/support/totals"
13
+ require_relative "ucp/support/line_item_status"
14
+ require_relative "ucp/support/idempotency"
15
+ require_relative "ucp/support/checkout_state"
16
+ require_relative "ucp/support/api_error"
17
+ require_relative "ucp/support/not_found"
18
+ require_relative "ucp/support/http_client"
19
+ require_relative "ucp/support/token_exchange"
20
+ require_relative "ucp/payment_token_guard"
21
+ require_relative "ucp/authenticator"
22
+ require_relative "ucp/rate_limiter"
23
+ require_relative "ucp/observability"
24
+ require_relative "ucp/capability"
25
+ require_relative "ucp/manifest"
26
+ require_relative "ucp/schema_validator"
27
+ require_relative "ucp/capabilities/catalog"
28
+ require_relative "ucp/capabilities/cart"
29
+ require_relative "ucp/capabilities/checkout"
30
+ require_relative "ucp/capabilities/order"
31
+ require_relative "ucp/capabilities/identity_linking"
32
+ require_relative "ucp/capability_registry"
33
+ require_relative "ucp/capability_negotiator"
34
+ require_relative "ucp/dispatcher"
35
+ require_relative "ucp/mcp/server"
36
+ require_relative "ucp/rack/manifest_endpoint"
37
+ require_relative "ucp/rack/webhook_endpoint"
38
+ require_relative "ucp/resolver"
39
+ require_relative "ucp/check"
40
+
41
+ module Portage
42
+ module Ucp
43
+ end
44
+ end
@@ -0,0 +1,70 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://ucp.dev/2026-04-08/schemas/capability.json",
4
+ "title": "UCP Capability",
5
+ "description": "Schema for UCP capabilities and extensions. Extensions are capabilities with an 'extends' field. Uses reverse-domain naming for governance.",
6
+ "$defs": {
7
+ "base": {
8
+ "allOf": [
9
+ {
10
+ "$ref": "https://ucp.dev/2026-04-08/schemas/ucp.json#/$defs/entity"
11
+ },
12
+ {
13
+ "type": "object",
14
+ "properties": {
15
+ "extends": {
16
+ "oneOf": [
17
+ {
18
+ "type": "string",
19
+ "pattern": "^[a-z][a-z0-9]*(?:\\.[a-z][a-z0-9_]*)+$"
20
+ },
21
+ {
22
+ "type": "array",
23
+ "items": {
24
+ "type": "string",
25
+ "pattern": "^[a-z][a-z0-9]*(?:\\.[a-z][a-z0-9_]*)+$"
26
+ },
27
+ "minItems": 1
28
+ }
29
+ ],
30
+ "description": "Parent capability(s) this extends. Present for extensions, absent for root capabilities. Use array for multi-parent extensions."
31
+ }
32
+ }
33
+ }
34
+ ]
35
+ },
36
+ "platform_schema": {
37
+ "title": "Capability (Platform Schema)",
38
+ "description": "Full capability declaration for platform-level discovery. Includes spec/schema URLs for agent fetching.",
39
+ "allOf": [
40
+ {
41
+ "$ref": "#/$defs/base"
42
+ },
43
+ {
44
+ "required": [
45
+ "spec",
46
+ "schema"
47
+ ]
48
+ }
49
+ ]
50
+ },
51
+ "business_schema": {
52
+ "title": "Capability (Business Schema)",
53
+ "description": "Capability configuration for business/merchant level. May include business-specific config overrides.",
54
+ "allOf": [
55
+ {
56
+ "$ref": "#/$defs/base"
57
+ }
58
+ ]
59
+ },
60
+ "response_schema": {
61
+ "title": "Capability (Response Schema)",
62
+ "description": "Capability reference in responses. Only name/version required to confirm active capabilities.",
63
+ "allOf": [
64
+ {
65
+ "$ref": "#/$defs/base"
66
+ }
67
+ ]
68
+ }
69
+ }
70
+ }
@@ -0,0 +1,67 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://ucp.dev/2026-04-08/schemas/payment_handler.json",
4
+ "title": "Payment Handler",
5
+ "description": "Schema for UCP payment handlers. Handlers define how payment instruments are processed.",
6
+ "$defs": {
7
+ "base": {
8
+ "allOf": [
9
+ {
10
+ "$ref": "https://ucp.dev/2026-04-08/schemas/ucp.json#/$defs/entity"
11
+ },
12
+ {
13
+ "type": "object",
14
+ "required": [
15
+ "id"
16
+ ]
17
+ },
18
+ {
19
+ "type": "object",
20
+ "properties": {
21
+ "available_instruments": {
22
+ "type": "array",
23
+ "items": {
24
+ "$ref": "https://ucp.dev/2026-04-08/schemas/shopping/types/available_payment_instrument.json"
25
+ },
26
+ "description": "Instrument types this handler supports, with optional constraints. When absent, every instrument should be considered available.",
27
+ "minItems": 1
28
+ }
29
+ }
30
+ }
31
+ ]
32
+ },
33
+ "platform_schema": {
34
+ "title": "Payment Handler (Platform Schema)",
35
+ "description": "Platform declaration for discovery profiles. May include partial config state required for discovery.",
36
+ "allOf": [
37
+ {
38
+ "$ref": "#/$defs/base"
39
+ },
40
+ {
41
+ "required": [
42
+ "spec",
43
+ "schema"
44
+ ]
45
+ }
46
+ ]
47
+ },
48
+ "business_schema": {
49
+ "title": "Payment Handler (Business Schema)",
50
+ "description": "Business declaration for discovery profiles. May include partial config state required for discovery.",
51
+ "allOf": [
52
+ {
53
+ "$ref": "#/$defs/base"
54
+ }
55
+ ]
56
+ },
57
+ "response_schema": {
58
+ "title": "Payment Handler (Response Schema)",
59
+ "description": "Handler reference in responses. May include full config state for runtime usage of the handler.",
60
+ "allOf": [
61
+ {
62
+ "$ref": "#/$defs/base"
63
+ }
64
+ ]
65
+ }
66
+ }
67
+ }