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,83 @@
|
|
|
1
|
+
require "mcp"
|
|
2
|
+
|
|
3
|
+
module Portage
|
|
4
|
+
module Ucp
|
|
5
|
+
module Mcp
|
|
6
|
+
# Builds an MCP::Server (from the `mcp` gem) whose tools are generated
|
|
7
|
+
# from the CapabilityRegistry's advertised actions for a given adapter,
|
|
8
|
+
# so tools/list and tools/call stay in sync with the Dispatcher/registry
|
|
9
|
+
# instead of being hand-duplicated per capability.
|
|
10
|
+
class Server
|
|
11
|
+
# Collaborators shared by every generated tool, bundled so build_tool
|
|
12
|
+
# doesn't need one keyword argument per collaborator.
|
|
13
|
+
Context = Struct.new(:dispatcher, :authenticator, :rate_limiter, :logger, keyword_init: true)
|
|
14
|
+
|
|
15
|
+
def self.build(adapter:, registry: Portage::Ucp.configuration.registry,
|
|
16
|
+
authenticator: Portage::Ucp.configuration.authenticator,
|
|
17
|
+
rate_limiter: Portage::Ucp.configuration.rate_limiter,
|
|
18
|
+
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)
|
|
21
|
+
tools = registry.advertised(adapter).flat_map do |capability|
|
|
22
|
+
capability.actions.map do |action_name, method_name|
|
|
23
|
+
build_tool(adapter: adapter, capability: capability, action_name: action_name,
|
|
24
|
+
method_name: method_name, context: context)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
::MCP::Server.new(name: "portage-ucp", tools: tools, **server_opts)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.build_tool(adapter:, capability:, action_name:, method_name:, context:)
|
|
32
|
+
keyword_params = adapter.method(method_name).parameters.select { |type, _| %i[key keyreq].include?(type) }
|
|
33
|
+
required = keyword_params.select { |type, _| type == :keyreq }.map { |_, name| name.to_s }
|
|
34
|
+
properties = keyword_params.to_h { |_, name| [name.to_s, {}] }
|
|
35
|
+
mutating = keyword_params.any? { |_, name| name == :idempotency_key }
|
|
36
|
+
|
|
37
|
+
::MCP::Tool.define(
|
|
38
|
+
name: action_name,
|
|
39
|
+
description: "#{capability.name}##{action_name}",
|
|
40
|
+
input_schema: { type: "object", properties: properties, required: required }
|
|
41
|
+
) do |**kwargs|
|
|
42
|
+
Portage::Ucp::Mcp::Server.call_tool(context: context, capability: capability, action_name: action_name,
|
|
43
|
+
mutating: mutating, kwargs: kwargs)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.call_tool(context:, capability:, action_name:, mutating:, kwargs:)
|
|
48
|
+
server_context = kwargs.delete(:server_context)
|
|
49
|
+
Portage::Ucp::Observability.log(context.logger, "tool_called", capability: capability.name,
|
|
50
|
+
action: action_name, arguments: kwargs)
|
|
51
|
+
|
|
52
|
+
rejection = authorize(context.authenticator, server_context, mutating: mutating) ||
|
|
53
|
+
rate_limit(context.rate_limiter, server_context, capability.name, mutating: mutating)
|
|
54
|
+
return rejection if rejection
|
|
55
|
+
|
|
56
|
+
result = context.dispatcher.call(capability: capability.name, action: action_name, arguments: kwargs)
|
|
57
|
+
::MCP::Tool::Response.new(result[:content], structured_content: result[:structuredContent])
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.authorize(authenticator, server_context, mutating:)
|
|
61
|
+
return unless mutating
|
|
62
|
+
|
|
63
|
+
authenticator.call(server_context)
|
|
64
|
+
nil
|
|
65
|
+
rescue Portage::Ucp::AuthenticationError => e
|
|
66
|
+
::MCP::Tool::Response.new([{ type: "text", text: e.message }], error: true)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @param key [Object] whatever the consumer's RateLimiter derives an
|
|
70
|
+
# identity from — the gem hands over the raw MCP server_context
|
|
71
|
+
# rather than inventing its own per-session/per-key extraction (§9).
|
|
72
|
+
def self.rate_limit(rate_limiter, key, capability_name, mutating:)
|
|
73
|
+
return unless mutating
|
|
74
|
+
|
|
75
|
+
rate_limiter.check!(key, capability_name)
|
|
76
|
+
nil
|
|
77
|
+
rescue Portage::Ucp::RateLimitExceededError => e
|
|
78
|
+
::MCP::Tool::Response.new([{ type: "text", text: e.message }], error: true)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "logger"
|
|
3
|
+
|
|
4
|
+
module Portage
|
|
5
|
+
module Ucp
|
|
6
|
+
# Structured log events (§12), emitted through a consumer-injected logger
|
|
7
|
+
# (defaults to Logger.new($stdout)) so the gem instruments nothing to a
|
|
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.
|
|
11
|
+
module Observability
|
|
12
|
+
REDACTED_KEYS = %w[payment_token oauth_token authorization].freeze
|
|
13
|
+
REDACTED = "[REDACTED]".freeze
|
|
14
|
+
|
|
15
|
+
def self.log(logger, event, **fields)
|
|
16
|
+
logger.info(JSON.generate({ event: event }.merge(redact(fields))))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.redact(value)
|
|
20
|
+
case value
|
|
21
|
+
when Hash
|
|
22
|
+
value.to_h { |key, val| [key, REDACTED_KEYS.include?(key.to_s.downcase) ? REDACTED : redact(val)] }
|
|
23
|
+
when Array
|
|
24
|
+
value.map { |val| redact(val) }
|
|
25
|
+
else
|
|
26
|
+
value
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Ucp
|
|
3
|
+
# PCI boundary guard (§9). complete_checkout's payment_token must be a
|
|
4
|
+
# single-use, tokenized credential from a UCP payment handler / AP2
|
|
5
|
+
# exchange — never a raw PAN. This can't prove a string *is* an opaque
|
|
6
|
+
# token, but it can catch the clearest misintegration: something that
|
|
7
|
+
# looks exactly like a card number (digits only, 12-19 characters,
|
|
8
|
+
# Luhn-valid) gets rejected before it ever reaches an Adapter, so a
|
|
9
|
+
# misintegrated agent can't push card numbers through the gem.
|
|
10
|
+
module PaymentTokenGuard
|
|
11
|
+
PAN_LENGTHS = (12..19)
|
|
12
|
+
|
|
13
|
+
def self.validate!(token)
|
|
14
|
+
return unless looks_like_pan?(token)
|
|
15
|
+
|
|
16
|
+
raise Portage::Ucp::RawPanRejectedError,
|
|
17
|
+
"payment_token looks like a raw PAN (digits only, Luhn-valid) — " \
|
|
18
|
+
"complete_checkout requires a tokenized credential, never card data"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.looks_like_pan?(token)
|
|
22
|
+
digits = token.to_s
|
|
23
|
+
return false unless digits.match?(/\A\d+\z/) && PAN_LENGTHS.cover?(digits.length)
|
|
24
|
+
|
|
25
|
+
luhn_valid?(digits)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.luhn_valid?(digits)
|
|
29
|
+
sum = digits.reverse.chars.map(&:to_i).each_with_index.sum do |digit, index|
|
|
30
|
+
next digit if index.even?
|
|
31
|
+
|
|
32
|
+
doubled = digit * 2
|
|
33
|
+
doubled > 9 ? doubled - 9 : doubled
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
(sum % 10).zero?
|
|
37
|
+
end
|
|
38
|
+
private_class_method :luhn_valid?
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "rack"
|
|
3
|
+
|
|
4
|
+
module Portage
|
|
5
|
+
module Ucp
|
|
6
|
+
module Rack
|
|
7
|
+
# Rack app serving GET /.well-known/ucp — the signed UCP discovery document.
|
|
8
|
+
# Mount it however suits the consumer's app (in-process or standalone, §7.3);
|
|
9
|
+
# this gem makes no assumption about host framework or process model.
|
|
10
|
+
class ManifestEndpoint
|
|
11
|
+
# @param allow_insecure [Boolean] TLS termination is the consumer's job
|
|
12
|
+
# (§9), so by default this refuses to serve payment-handler
|
|
13
|
+
# declarations over plaintext HTTP rather than silently leaking them.
|
|
14
|
+
# Set true only for local development over http://localhost.
|
|
15
|
+
def initialize(manifest:, allow_insecure: false)
|
|
16
|
+
@manifest = manifest
|
|
17
|
+
@allow_insecure = allow_insecure
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call(env)
|
|
21
|
+
request = ::Rack::Request.new(env)
|
|
22
|
+
return not_found unless request.get?
|
|
23
|
+
|
|
24
|
+
payload = @manifest.to_h
|
|
25
|
+
return insecure_rejected if payment_handlers_over_plaintext?(payload, request)
|
|
26
|
+
|
|
27
|
+
[200, { "content-type" => "application/json" }, [JSON.generate(payload)]]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def payment_handlers_over_plaintext?(payload, request)
|
|
33
|
+
return false if @allow_insecure || request.ssl?
|
|
34
|
+
|
|
35
|
+
!Array(payload[:payment_handlers]).empty?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def not_found
|
|
39
|
+
[404, { "content-type" => "application/json" }, [JSON.generate(error: "not_found")]]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def insecure_rejected
|
|
43
|
+
[496, { "content-type" => "application/json" },
|
|
44
|
+
[JSON.generate(error: "payment_handlers require TLS — refusing to serve over plaintext HTTP")]]
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "openssl"
|
|
3
|
+
require "rack"
|
|
4
|
+
|
|
5
|
+
module Portage
|
|
6
|
+
module Ucp
|
|
7
|
+
module Rack
|
|
8
|
+
# Receives backend order-lifecycle webhooks (§11). Verifies the HMAC
|
|
9
|
+
# signature against the raw body *before* parsing anything, per the
|
|
10
|
+
# standard webhook guardrail: never trust a payload before you've
|
|
11
|
+
# authenticated it. Normalizes to a Portage::Ucp::Order and hands off to a
|
|
12
|
+
# consumer-supplied `on_order_event` callback — the gem doesn't assume
|
|
13
|
+
# anything about how the consumer stores or reacts to order events.
|
|
14
|
+
class WebhookEndpoint
|
|
15
|
+
def initialize(secret:, on_order_event:, signature_header: "HTTP_X_UCP_SIGNATURE")
|
|
16
|
+
@secret = secret
|
|
17
|
+
@on_order_event = on_order_event
|
|
18
|
+
@signature_header = signature_header
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(env)
|
|
22
|
+
request = ::Rack::Request.new(env)
|
|
23
|
+
return respond(404, error: "not_found") unless request.post?
|
|
24
|
+
|
|
25
|
+
body = request.body.read
|
|
26
|
+
return respond(401, error: "invalid_signature") unless valid_signature?(body, env[@signature_header])
|
|
27
|
+
|
|
28
|
+
handle_order_event(body)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def handle_order_event(body)
|
|
34
|
+
payload = JSON.parse(body, symbolize_names: true)
|
|
35
|
+
@on_order_event.call(Portage::Ucp::Order.new(**payload))
|
|
36
|
+
respond(200, ok: true)
|
|
37
|
+
rescue JSON::ParserError, ArgumentError
|
|
38
|
+
respond(400, error: "bad_request")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def valid_signature?(body, signature)
|
|
42
|
+
return false unless signature
|
|
43
|
+
|
|
44
|
+
expected = OpenSSL::HMAC.hexdigest("SHA256", @secret, body)
|
|
45
|
+
::Rack::Utils.secure_compare(expected, signature)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def respond(status, body)
|
|
49
|
+
[status, { "content-type" => "application/json" }, [JSON.generate(body)]]
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Ucp
|
|
3
|
+
# @abstract Pluggable rate-limit hook for mutating capabilities (§9). The
|
|
4
|
+
# gem bundles no limiter (no storage assumption) — #check! is called
|
|
5
|
+
# before every mutating tool call and must raise
|
|
6
|
+
# Portage::Ucp::RateLimitExceededError to block it. The default, NullRateLimiter,
|
|
7
|
+
# never limits: a consumer opts in to limiting by configuring one, same
|
|
8
|
+
# as the manifest/webhook signing story elsewhere in §9.
|
|
9
|
+
class RateLimiter
|
|
10
|
+
# @param key [String] a per-session/per-caller identifier the consumer
|
|
11
|
+
# derives from the MCP server_context (§9 says "per-session/per-key" —
|
|
12
|
+
# which one is up to the consumer's auth setup).
|
|
13
|
+
# @param capability [String] the capability name being called, e.g.
|
|
14
|
+
# "dev.ucp.shopping.cart", so limits can be scoped per capability.
|
|
15
|
+
def check!(_key, _capability)
|
|
16
|
+
raise NotImplementedError, "#{self.class} must implement #check!"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# The default: never limits. Present so an unconfigured server behaves
|
|
21
|
+
# exactly as it did before this hook existed, rather than silently
|
|
22
|
+
# blocking mutations the way UnconfiguredAuthenticator does for auth.
|
|
23
|
+
class NullRateLimiter < RateLimiter
|
|
24
|
+
def check!(_key, _capability); end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Ucp
|
|
3
|
+
# Shared platform-detection + adapter-building logic for anything that
|
|
4
|
+
# needs to go from "a URL" to "a live Adapter for whatever commerce
|
|
5
|
+
# platform that URL runs on" — originally `Check`-only, now also used by
|
|
6
|
+
# `portage buy`'s adapter-fallback step. Neither caller should duplicate
|
|
7
|
+
# the platform table; both go through here.
|
|
8
|
+
class Resolver
|
|
9
|
+
Platform = Struct.new(:name, :gem, :require_path, :namespace, :markers, :env, :required, :build_client,
|
|
10
|
+
:build_adapter, keyword_init: true)
|
|
11
|
+
|
|
12
|
+
# One entry per adapter gem: how to recognize the platform from its
|
|
13
|
+
# homepage, which env vars its Client/Adapter need (mirroring each
|
|
14
|
+
# gem's exe/, see their READMEs), and how to build a live instance from
|
|
15
|
+
# those env vars.
|
|
16
|
+
PLATFORMS = [
|
|
17
|
+
Platform.new(
|
|
18
|
+
name: "Shopify", gem: "portage-ucp-shopify", require_path: "portage/ucp/shopify", namespace: "Shopify",
|
|
19
|
+
markers: [/cdn\.shopify\.com/i, /Shopify\.theme/i, /\.myshopify\.com/i],
|
|
20
|
+
env: { shop_domain: "SHOPIFY_SHOP_DOMAIN", admin_access_token: "SHOPIFY_ADMIN_ACCESS_TOKEN",
|
|
21
|
+
storefront_access_token: "SHOPIFY_STOREFRONT_ACCESS_TOKEN" },
|
|
22
|
+
required: %i[shop_domain],
|
|
23
|
+
build_client: lambda { |ns, env|
|
|
24
|
+
ns::Client.new(shop_domain: env.fetch(:shop_domain), admin_access_token: env[:admin_access_token],
|
|
25
|
+
storefront_access_token: env[:storefront_access_token])
|
|
26
|
+
},
|
|
27
|
+
build_adapter: ->(ns, client, _env) { ns::Adapter.new(client: client) }
|
|
28
|
+
),
|
|
29
|
+
Platform.new(
|
|
30
|
+
name: "Wix", gem: "portage-ucp-wix", require_path: "portage/ucp/wix", namespace: "Wix",
|
|
31
|
+
markers: [/static\.parastorage\.com/i, /wixstatic\.com/i, /X-Wix-Request-Id/i],
|
|
32
|
+
env: { access_token: "WIX_ACCESS_TOKEN" },
|
|
33
|
+
required: %i[access_token],
|
|
34
|
+
build_client: ->(ns, env) { ns::Client.new(access_token: env.fetch(:access_token)) },
|
|
35
|
+
build_adapter: ->(ns, client, _env) { ns::Adapter.new(client: client) }
|
|
36
|
+
),
|
|
37
|
+
Platform.new(
|
|
38
|
+
name: "WooCommerce", gem: "portage-ucp-woocommerce", require_path: "portage/ucp/woocommerce",
|
|
39
|
+
namespace: "WooCommerce",
|
|
40
|
+
markers: [/woocommerce/i, %r{wp-content/plugins/woocommerce}i],
|
|
41
|
+
env: { site_url: "WOOCOMMERCE_SITE_URL", consumer_key: "WOOCOMMERCE_CONSUMER_KEY",
|
|
42
|
+
consumer_secret: "WOOCOMMERCE_CONSUMER_SECRET", currency: "WOOCOMMERCE_CURRENCY" },
|
|
43
|
+
required: %i[site_url consumer_key consumer_secret],
|
|
44
|
+
build_client: lambda { |ns, env|
|
|
45
|
+
ns::Client.new(site_url: env.fetch(:site_url), consumer_key: env.fetch(:consumer_key),
|
|
46
|
+
consumer_secret: env.fetch(:consumer_secret))
|
|
47
|
+
},
|
|
48
|
+
build_adapter: lambda { |ns, client, env|
|
|
49
|
+
ns::Adapter.new(client: client, site_url: env.fetch(:site_url), currency: env.fetch(:currency, "USD"))
|
|
50
|
+
}
|
|
51
|
+
),
|
|
52
|
+
Platform.new(
|
|
53
|
+
name: "BigCommerce", gem: "portage-ucp-bigcommerce", require_path: "portage/ucp/bigcommerce",
|
|
54
|
+
namespace: "BigCommerce",
|
|
55
|
+
markers: [/cdn11\.bigcommerce\.com/i, /bigcommerce/i],
|
|
56
|
+
env: { store_hash: "BIGCOMMERCE_STORE_HASH", client_id: "BIGCOMMERCE_CLIENT_ID",
|
|
57
|
+
access_token: "BIGCOMMERCE_ACCESS_TOKEN", site_url: "BIGCOMMERCE_SITE_URL",
|
|
58
|
+
currency: "BIGCOMMERCE_CURRENCY" },
|
|
59
|
+
required: %i[store_hash client_id access_token site_url],
|
|
60
|
+
build_client: lambda { |ns, env|
|
|
61
|
+
ns::Client.new(store_hash: env.fetch(:store_hash), client_id: env.fetch(:client_id),
|
|
62
|
+
access_token: env.fetch(:access_token))
|
|
63
|
+
},
|
|
64
|
+
build_adapter: lambda { |ns, client, env|
|
|
65
|
+
ns::Adapter.new(client: client, site_url: env.fetch(:site_url), currency: env.fetch(:currency, "USD"))
|
|
66
|
+
}
|
|
67
|
+
),
|
|
68
|
+
Platform.new(
|
|
69
|
+
name: "Magento", gem: "portage-ucp-magento", require_path: "portage/ucp/magento", namespace: "Magento",
|
|
70
|
+
markers: [/Mage\.Cookies/i, /Magento_/i],
|
|
71
|
+
env: { base_url: "MAGENTO_BASE_URL", admin_token: "MAGENTO_ADMIN_TOKEN", currency: "MAGENTO_CURRENCY" },
|
|
72
|
+
required: %i[base_url],
|
|
73
|
+
build_client: lambda { |ns, env|
|
|
74
|
+
ns::Client.new(base_url: env.fetch(:base_url), admin_token: env[:admin_token])
|
|
75
|
+
},
|
|
76
|
+
build_adapter: lambda { |ns, client, env|
|
|
77
|
+
ns::Adapter.new(client: client, site_url: env.fetch(:base_url), currency: env.fetch(:currency, "USD"))
|
|
78
|
+
}
|
|
79
|
+
),
|
|
80
|
+
Platform.new(
|
|
81
|
+
name: "Etsy", gem: "portage-ucp-etsy", require_path: "portage/ucp/etsy", namespace: "Etsy",
|
|
82
|
+
markers: [/etsy\.com/i],
|
|
83
|
+
env: { access_token: "ETSY_ACCESS_TOKEN", api_key: "ETSY_API_KEY", shop_id: "ETSY_SHOP_ID" },
|
|
84
|
+
required: %i[access_token api_key shop_id],
|
|
85
|
+
build_client: lambda { |ns, env|
|
|
86
|
+
ns::Client.new(access_token: env.fetch(:access_token), api_key: env.fetch(:api_key))
|
|
87
|
+
},
|
|
88
|
+
build_adapter: ->(ns, client, env) { ns::Adapter.new(client: client, shop_id: env.fetch(:shop_id)) }
|
|
89
|
+
),
|
|
90
|
+
Platform.new(
|
|
91
|
+
name: "Instagram/Facebook Shops", gem: "portage-ucp-instagram", require_path: "portage/ucp/instagram",
|
|
92
|
+
namespace: "Instagram",
|
|
93
|
+
markers: [%r{instagram\.com/[^/]+/shop}i, %r{facebook\.com/.*/shop}i],
|
|
94
|
+
env: { access_token: "META_ACCESS_TOKEN", catalog_id: "META_CATALOG_ID" },
|
|
95
|
+
required: %i[access_token catalog_id],
|
|
96
|
+
build_client: ->(ns, env) { ns::Client.new(access_token: env.fetch(:access_token)) },
|
|
97
|
+
build_adapter: ->(ns, client, env) { ns::Adapter.new(client: client, catalog_id: env.fetch(:catalog_id)) }
|
|
98
|
+
)
|
|
99
|
+
].freeze
|
|
100
|
+
|
|
101
|
+
# @return [Platform, nil] the first platform whose markers match the
|
|
102
|
+
# given homepage body/headers, or nil if nothing recognizable was found.
|
|
103
|
+
def self.detect_platform(body, headers)
|
|
104
|
+
haystack = [body, headers.to_a.flatten.join(" ")].compact.join(" ")
|
|
105
|
+
PLATFORMS.find { |platform| platform.markers.any? { |marker| haystack =~ marker } }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @return [Hash<Symbol, String, nil>] the platform's env vars read from
|
|
109
|
+
# the process environment, keyed the same as Platform#env/#required.
|
|
110
|
+
def self.env_for(platform)
|
|
111
|
+
platform.env.transform_values { |var| ENV.fetch(var, nil) }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @return [Array<String>] the env var names still missing for this
|
|
115
|
+
# platform to be usable, or an empty array if all required vars are set.
|
|
116
|
+
def self.missing_env(platform, env)
|
|
117
|
+
platform.required.select { |key| env[key].nil? }.map { |key| platform.env[key] }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Builds a live Adapter for the given platform from the given env hash
|
|
121
|
+
# (as returned by .env_for). Raises LoadError if the adapter gem isn't
|
|
122
|
+
# installed — callers decide how to handle that (e.g. Check reports it
|
|
123
|
+
# as a skipped probe rather than crashing).
|
|
124
|
+
# @return [Portage::Ucp::Adapter]
|
|
125
|
+
def self.build_adapter(platform, env)
|
|
126
|
+
require platform.require_path
|
|
127
|
+
namespace = Portage::Ucp.const_get(platform.namespace)
|
|
128
|
+
client = platform.build_client.call(namespace, env)
|
|
129
|
+
platform.build_adapter.call(namespace, client, env)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "json_schemer"
|
|
3
|
+
|
|
4
|
+
module Portage
|
|
5
|
+
module Ucp
|
|
6
|
+
# Validates data against UCP's own published JSON Schemas / OpenRPC docs —
|
|
7
|
+
# offline, against the copy vendored under schemas/<version>/ (see §13:
|
|
8
|
+
# ucpchecker.com is a manual pre-release check only, never a CI gate; this
|
|
9
|
+
# is the CI-safe equivalent).
|
|
10
|
+
#
|
|
11
|
+
# The vendored tree mirrors https://ucp.dev/<version>/... path-for-path
|
|
12
|
+
# (schemas/2026-04-08/schemas/shopping/cart.json <->
|
|
13
|
+
# https://ucp.dev/2026-04-08/schemas/shopping/cart.json), so every $ref
|
|
14
|
+
# inside a vendored document — however deeply nested — resolves to another
|
|
15
|
+
# vendored file with a single prefix rewrite, no network access needed.
|
|
16
|
+
class SchemaValidator
|
|
17
|
+
def initialize(version: "2026-04-08", root: File.join(__dir__, "..", "..", "..", "schemas"))
|
|
18
|
+
@base_url = "https://ucp.dev/#{version}/"
|
|
19
|
+
@base_dir = File.expand_path(File.join(root, version))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @param relative_path [String] e.g. "schemas/shopping/cart.json", matching
|
|
23
|
+
# the vendored path under schemas/<version>/.
|
|
24
|
+
# @return [Array<String>] human-readable validation error messages: empty
|
|
25
|
+
# means `data` conforms.
|
|
26
|
+
def errors_for(relative_path, data)
|
|
27
|
+
schemer = JSONSchemer.schema(load(relative_path), ref_resolver: method(:resolve_ref))
|
|
28
|
+
schemer.validate(data).map { |error| JSONSchemer::Errors.pretty(error) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def valid?(relative_path, data)
|
|
32
|
+
errors_for(relative_path, data).empty?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Method names declared by a vendored OpenRPC document, e.g.
|
|
36
|
+
# "services/shopping/mcp.openrpc.json" -> %w[create_checkout get_cart ...].
|
|
37
|
+
def method_names(relative_path)
|
|
38
|
+
load(relative_path).fetch("methods").map { |m| m.fetch("name") }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def resolve_ref(uri)
|
|
44
|
+
relative_path = uri.to_s.delete_prefix(@base_url).sub(/#.*/, "")
|
|
45
|
+
load(relative_path)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def load(relative_path)
|
|
49
|
+
JSON.parse(File.read(File.join(@base_dir, relative_path)))
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
require "bigdecimal"
|
|
2
|
+
|
|
3
|
+
module Portage
|
|
4
|
+
module Ucp
|
|
5
|
+
module Support
|
|
6
|
+
# Money arithmetic every adapter gem's Mapper needs. UCP wire shapes
|
|
7
|
+
# carry integer minor units (schemas/shopping/types/total.json), while
|
|
8
|
+
# commerce APIs hand back either decimal strings ("12.50") or already-
|
|
9
|
+
# minor-unit integers-as-strings ("1250") — one conversion per shape,
|
|
10
|
+
# defined once here rather than re-derived per gem.
|
|
11
|
+
#
|
|
12
|
+
# Mappers keep their own one-line `money`/`minor_units` wrappers around
|
|
13
|
+
# these: the wrapper's signature is platform-shaped (a GraphQL price
|
|
14
|
+
# node, an amount plus a threaded-in currency), only the arithmetic is
|
|
15
|
+
# shared.
|
|
16
|
+
module Amounts
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# Decimal-string (or numeric) major units -> integer minor units.
|
|
20
|
+
# nil/"" is 0 rather than an exception: several APIs omit a money
|
|
21
|
+
# field entirely instead of sending a zero.
|
|
22
|
+
def decimal_to_minor(amount)
|
|
23
|
+
return 0 if amount.nil? || amount == ""
|
|
24
|
+
|
|
25
|
+
(BigDecimal(amount.to_s) * 100).to_i
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Already-minor-unit values (e.g. WooCommerce's Store API sends "500"
|
|
29
|
+
# for $5.00, tagged with its own `currency_minor_unit`) — an integer
|
|
30
|
+
# parse, no BigDecimal scaling.
|
|
31
|
+
def subunits_to_minor(amount)
|
|
32
|
+
return 0 if amount.nil?
|
|
33
|
+
|
|
34
|
+
amount.to_i
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def money(amount, currency)
|
|
38
|
+
Portage::Ucp::Money.new(amount_minor: decimal_to_minor(amount), currency: currency)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Ucp
|
|
3
|
+
module Support
|
|
4
|
+
# Mixed into each adapter gem's own ApiError, which stays a subclass of
|
|
5
|
+
# that gem's Error (so `rescue Portage::Ucp::Wix::Error` keeps catching
|
|
6
|
+
# everything the Wix gem raises) — a module rather than a base class
|
|
7
|
+
# precisely to leave that hierarchy alone.
|
|
8
|
+
#
|
|
9
|
+
# Carries the two things every gem's ApiError carried identically: the
|
|
10
|
+
# HTTP status (which Support::NotFound#nil_on_not_found reads) and the parsed
|
|
11
|
+
# body. What differs per platform is only where the human-readable
|
|
12
|
+
# message lives inside that body, which is the `detail` hook.
|
|
13
|
+
module ApiError
|
|
14
|
+
attr_reader :status, :body
|
|
15
|
+
|
|
16
|
+
def initialize(status, body)
|
|
17
|
+
@status = status
|
|
18
|
+
@body = body
|
|
19
|
+
super("#{api_label} API error (#{status}): #{detail(body)}")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
# Defaults to the including gem's module name — Portage::Ucp::Wix::
|
|
25
|
+
# ApiError reports "Wix". Override where the API's public name isn't
|
|
26
|
+
# the gem's (e.g. Instagram calls into Meta's Graph API).
|
|
27
|
+
def api_label
|
|
28
|
+
self.class.name.split("::")[-2]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Override with the platform's error-body shape, e.g.
|
|
32
|
+
# `body["message"] || body`.
|
|
33
|
+
def detail(body)
|
|
34
|
+
body
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Ucp
|
|
3
|
+
module Support
|
|
4
|
+
# Adapter-side bookkeeping for the two things UCP's schemas require but
|
|
5
|
+
# most commerce APIs don't model:
|
|
6
|
+
#
|
|
7
|
+
# - Checkout#status. Shopify, WooCommerce and BigCommerce all treat a
|
|
8
|
+
# checkout as the cart (or as the cart plus billing data), with no
|
|
9
|
+
# lifecycle enum of its own, so the adapter tracks status itself
|
|
10
|
+
# across create/update/complete/cancel.
|
|
11
|
+
# - Order#checkout_id. Nothing on those platforms' Order links back to
|
|
12
|
+
# the cart/checkout that produced it, so the adapter records the pair
|
|
13
|
+
# at completion time — the only moment both ids are in hand.
|
|
14
|
+
#
|
|
15
|
+
# Both default rather than raise on an unknown id ("incomplete" and "",
|
|
16
|
+
# respectively): a checkout this process didn't create is still
|
|
17
|
+
# schema-valid to report, just not information-complete.
|
|
18
|
+
module CheckoutState
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def checkout_status(checkout_id)
|
|
22
|
+
(@checkout_status ||= {}).fetch(checkout_id, "incomplete")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def record_checkout_status(checkout_id, status)
|
|
26
|
+
(@checkout_status ||= {})[checkout_id] = status
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Keyed by String: adapters are called with an order id straight off
|
|
30
|
+
# a JSON body, which is an Integer on some platforms and a String on
|
|
31
|
+
# others depending on the call path.
|
|
32
|
+
def record_order_checkout(order_id, checkout_id)
|
|
33
|
+
(@order_checkout_ids ||= {})[order_id.to_s] = checkout_id
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def checkout_id_for(order_id)
|
|
37
|
+
(@order_checkout_ids ||= {}).fetch(order_id.to_s, "")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "json"
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Portage
|
|
6
|
+
module Ucp
|
|
7
|
+
module Support
|
|
8
|
+
# The plain Net::HTTP + JSON request/response handling every adapter
|
|
9
|
+
# gem's Client repeated. Deliberately Net::HTTP rather than a per-
|
|
10
|
+
# platform vendor gem: a generic adapter any Ruby app can drop in
|
|
11
|
+
# shouldn't drag in framework coupling, and it stays trivially
|
|
12
|
+
# stubbable with WebMock.
|
|
13
|
+
#
|
|
14
|
+
# An including Client supplies two things — its auth headers and paths
|
|
15
|
+
# (by building the uri/headers it passes to #json_request) and
|
|
16
|
+
# #api_error_class, the gem's own ApiError to raise on a non-2xx.
|
|
17
|
+
module HttpClient
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
# @param basic_auth [Array(String, String), nil] user/password pair
|
|
21
|
+
# for APIs authorized with HTTP Basic rather than a header token
|
|
22
|
+
# (e.g. WooCommerce's Admin consumer key/secret).
|
|
23
|
+
# @param raw [Boolean] return the Net::HTTPResponse itself instead of
|
|
24
|
+
# the parsed body — for callers that need response headers (e.g.
|
|
25
|
+
# WooCommerce's Cart-Token session threading). They call #parse!
|
|
26
|
+
# themselves once they're done reading headers.
|
|
27
|
+
def json_request(http_method, uri, body: nil, headers: {}, basic_auth: nil, raw: false)
|
|
28
|
+
uri = URI(uri.to_s)
|
|
29
|
+
request = http_method.new(uri)
|
|
30
|
+
request.basic_auth(*basic_auth) if basic_auth
|
|
31
|
+
headers.each { |name, value| request[name] = value }
|
|
32
|
+
request["Content-Type"] ||= "application/json"
|
|
33
|
+
request.body = JSON.generate(body) if body
|
|
34
|
+
|
|
35
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
|
|
36
|
+
raw ? response : parse!(response)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# An empty body is `{}` rather than a parse error: several APIs
|
|
40
|
+
# answer a successful DELETE with 204 and no content at all.
|
|
41
|
+
def parse!(response)
|
|
42
|
+
parsed = response.body.nil? || response.body.empty? ? {} : JSON.parse(response.body)
|
|
43
|
+
raise api_error_class.new(response.code.to_i, parsed) unless response.is_a?(Net::HTTPSuccess)
|
|
44
|
+
|
|
45
|
+
parsed
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def api_error_class
|
|
49
|
+
raise Portage::Ucp::NotImplementedError, "#{self.class} must implement #api_error_class"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|