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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +15 -0
- data/LICENSE +21 -0
- data/README.md +97 -0
- data/exe/portage-ucp-check +25 -0
- data/lib/portage/ucp/adapter.rb +58 -0
- data/lib/portage/ucp/authenticator.rb +26 -0
- data/lib/portage/ucp/capabilities/cart.rb +16 -0
- data/lib/portage/ucp/capabilities/catalog.rb +11 -0
- data/lib/portage/ucp/capabilities/checkout.rb +17 -0
- data/lib/portage/ucp/capabilities/identity_linking.rb +13 -0
- data/lib/portage/ucp/capabilities/order.rb +11 -0
- data/lib/portage/ucp/capability.rb +27 -0
- data/lib/portage/ucp/capability_negotiator.rb +40 -0
- data/lib/portage/ucp/capability_registry.rb +21 -0
- data/lib/portage/ucp/check.rb +96 -0
- data/lib/portage/ucp/configuration.rb +35 -0
- data/lib/portage/ucp/dispatcher.rb +39 -0
- data/lib/portage/ucp/errors.rb +12 -0
- data/lib/portage/ucp/manifest.rb +58 -0
- data/lib/portage/ucp/mcp/server.rb +83 -0
- data/lib/portage/ucp/observability.rb +31 -0
- data/lib/portage/ucp/payment_token_guard.rb +41 -0
- data/lib/portage/ucp/rack/manifest_endpoint.rb +49 -0
- data/lib/portage/ucp/rack/webhook_endpoint.rb +54 -0
- data/lib/portage/ucp/rate_limiter.rb +27 -0
- data/lib/portage/ucp/resolver.rb +133 -0
- data/lib/portage/ucp/schema_validator.rb +53 -0
- data/lib/portage/ucp/support/amounts.rb +43 -0
- data/lib/portage/ucp/support/api_error.rb +39 -0
- data/lib/portage/ucp/support/checkout_state.rb +42 -0
- data/lib/portage/ucp/support/http_client.rb +54 -0
- data/lib/portage/ucp/support/idempotency.rb +26 -0
- data/lib/portage/ucp/support/line_item_status.rb +44 -0
- data/lib/portage/ucp/support/not_found.rb +25 -0
- data/lib/portage/ucp/support/token_exchange.rb +43 -0
- data/lib/portage/ucp/support/totals.rb +31 -0
- data/lib/portage/ucp/value_objects.rb +186 -0
- data/lib/portage/ucp/version.rb +5 -0
- data/lib/portage/ucp/wire_envelope.rb +25 -0
- data/lib/portage/ucp.rb +44 -0
- data/schemas/2026-04-08/schemas/capability.json +70 -0
- data/schemas/2026-04-08/schemas/payment_handler.json +67 -0
- data/schemas/2026-04-08/schemas/service.json +190 -0
- data/schemas/2026-04-08/schemas/shopping/cart.json +134 -0
- data/schemas/2026-04-08/schemas/shopping/catalog_lookup.json +207 -0
- data/schemas/2026-04-08/schemas/shopping/catalog_search.json +64 -0
- data/schemas/2026-04-08/schemas/shopping/checkout.json +131 -0
- data/schemas/2026-04-08/schemas/shopping/discount.json +148 -0
- data/schemas/2026-04-08/schemas/shopping/fulfillment.json +154 -0
- data/schemas/2026-04-08/schemas/shopping/order.json +113 -0
- data/schemas/2026-04-08/schemas/shopping/payment.json +16 -0
- data/schemas/2026-04-08/schemas/shopping/types/adjustment.json +69 -0
- data/schemas/2026-04-08/schemas/shopping/types/amount.json +8 -0
- data/schemas/2026-04-08/schemas/shopping/types/attribution.json +11 -0
- data/schemas/2026-04-08/schemas/shopping/types/available_payment_instrument.json +22 -0
- data/schemas/2026-04-08/schemas/shopping/types/business_fulfillment_config.json +38 -0
- data/schemas/2026-04-08/schemas/shopping/types/buyer.json +25 -0
- data/schemas/2026-04-08/schemas/shopping/types/category.json +20 -0
- data/schemas/2026-04-08/schemas/shopping/types/context.json +42 -0
- data/schemas/2026-04-08/schemas/shopping/types/description.json +22 -0
- data/schemas/2026-04-08/schemas/shopping/types/detail_option_value.json +22 -0
- data/schemas/2026-04-08/schemas/shopping/types/error_code.json +17 -0
- data/schemas/2026-04-08/schemas/shopping/types/error_response.json +31 -0
- data/schemas/2026-04-08/schemas/shopping/types/expectation.json +62 -0
- data/schemas/2026-04-08/schemas/shopping/types/fulfillment.json +25 -0
- data/schemas/2026-04-08/schemas/shopping/types/fulfillment_available_method.json +45 -0
- data/schemas/2026-04-08/schemas/shopping/types/fulfillment_destination.json +16 -0
- data/schemas/2026-04-08/schemas/shopping/types/fulfillment_event.json +67 -0
- data/schemas/2026-04-08/schemas/shopping/types/fulfillment_group.json +45 -0
- data/schemas/2026-04-08/schemas/shopping/types/fulfillment_method.json +67 -0
- data/schemas/2026-04-08/schemas/shopping/types/fulfillment_option.json +56 -0
- data/schemas/2026-04-08/schemas/shopping/types/info_code.json +13 -0
- data/schemas/2026-04-08/schemas/shopping/types/input_correlation.json +24 -0
- data/schemas/2026-04-08/schemas/shopping/types/item.json +33 -0
- data/schemas/2026-04-08/schemas/shopping/types/line_item.json +46 -0
- data/schemas/2026-04-08/schemas/shopping/types/link.json +25 -0
- data/schemas/2026-04-08/schemas/shopping/types/media.json +36 -0
- data/schemas/2026-04-08/schemas/shopping/types/message.json +18 -0
- data/schemas/2026-04-08/schemas/shopping/types/message_error.json +49 -0
- data/schemas/2026-04-08/schemas/shopping/types/message_info.json +37 -0
- data/schemas/2026-04-08/schemas/shopping/types/message_warning.json +53 -0
- data/schemas/2026-04-08/schemas/shopping/types/option_value.json +20 -0
- data/schemas/2026-04-08/schemas/shopping/types/order_confirmation.json +26 -0
- data/schemas/2026-04-08/schemas/shopping/types/order_line_item.json +69 -0
- data/schemas/2026-04-08/schemas/shopping/types/pagination.json +62 -0
- data/schemas/2026-04-08/schemas/shopping/types/payment_credential.json +17 -0
- data/schemas/2026-04-08/schemas/shopping/types/payment_instrument.json +58 -0
- data/schemas/2026-04-08/schemas/shopping/types/platform_fulfillment_config.json +14 -0
- data/schemas/2026-04-08/schemas/shopping/types/postal_address.json +44 -0
- data/schemas/2026-04-08/schemas/shopping/types/price.json +22 -0
- data/schemas/2026-04-08/schemas/shopping/types/price_filter.json +17 -0
- data/schemas/2026-04-08/schemas/shopping/types/price_range.json +21 -0
- data/schemas/2026-04-08/schemas/shopping/types/product.json +89 -0
- data/schemas/2026-04-08/schemas/shopping/types/product_option.json +25 -0
- data/schemas/2026-04-08/schemas/shopping/types/rating.json +34 -0
- data/schemas/2026-04-08/schemas/shopping/types/retail_location.json +28 -0
- data/schemas/2026-04-08/schemas/shopping/types/reverse_domain_name.json +8 -0
- data/schemas/2026-04-08/schemas/shopping/types/search_filters.json +20 -0
- data/schemas/2026-04-08/schemas/shopping/types/selected_option.json +25 -0
- data/schemas/2026-04-08/schemas/shopping/types/shipping_destination.json +26 -0
- data/schemas/2026-04-08/schemas/shopping/types/signals.json +22 -0
- data/schemas/2026-04-08/schemas/shopping/types/signed_amount.json +7 -0
- data/schemas/2026-04-08/schemas/shopping/types/total.json +75 -0
- data/schemas/2026-04-08/schemas/shopping/types/totals.json +98 -0
- data/schemas/2026-04-08/schemas/shopping/types/variant.json +193 -0
- data/schemas/2026-04-08/schemas/shopping/types/warning_code.json +13 -0
- data/schemas/2026-04-08/schemas/transports/embedded_config.json +27 -0
- data/schemas/2026-04-08/schemas/ucp.json +364 -0
- data/schemas/2026-04-08/services/shopping/embedded.openrpc.json +634 -0
- data/schemas/2026-04-08/services/shopping/mcp.openrpc.json +492 -0
- 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,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
|
data/lib/portage/ucp.rb
ADDED
|
@@ -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
|
+
}
|