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,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