portage-ucp 0.5.0 → 0.7.1

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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +143 -1
  3. data/README.md +19 -2
  4. data/lib/generators/portage/ucp/install/install_generator.rb +28 -0
  5. data/lib/generators/portage/ucp/install/templates/portage_ucp.rb +21 -0
  6. data/lib/portage/ucp/adapter.rb +55 -2
  7. data/lib/portage/ucp/ap2/mandate.rb +48 -0
  8. data/lib/portage/ucp/ap2/mandate_guard.rb +48 -0
  9. data/lib/portage/ucp/ap2/mandate_signature.rb +148 -0
  10. data/lib/portage/ucp/capabilities/identity_linking.rb +1 -1
  11. data/lib/portage/ucp/capabilities/payment_enrollment.rb +1 -1
  12. data/lib/portage/ucp/capabilities/payment_method.rb +28 -0
  13. data/lib/portage/ucp/capabilities/shopper_data.rb +15 -0
  14. data/lib/portage/ucp/configuration.rb +37 -1
  15. data/lib/portage/ucp/confirmer.rb +102 -0
  16. data/lib/portage/ucp/dispatcher.rb +87 -2
  17. data/lib/portage/ucp/errors.rb +14 -0
  18. data/lib/portage/ucp/mcp/server.rb +10 -2
  19. data/lib/portage/ucp/observability.rb +42 -1
  20. data/lib/portage/ucp/payment_enrollment_guard.rb +44 -0
  21. data/lib/portage/ucp/rack/signature_verification.rb +74 -0
  22. data/lib/portage/ucp/railtie.rb +14 -0
  23. data/lib/portage/ucp/reference_adapter.rb +86 -11
  24. data/lib/portage/ucp/rspec.rb +138 -0
  25. data/lib/portage/ucp/security/errors.rb +37 -0
  26. data/lib/portage/ucp/security/signature.rb +277 -0
  27. data/lib/portage/ucp/support/idempotency/file_store.rb +70 -18
  28. data/lib/portage/ucp/support/idempotency/memory_store.rb +11 -0
  29. data/lib/portage/ucp/support/idempotency.rb +53 -15
  30. data/lib/portage/ucp/support/order_ledger/file_store.rb +75 -0
  31. data/lib/portage/ucp/support/order_ledger/store.rb +30 -0
  32. data/lib/portage/ucp/support/order_ledger.rb +15 -39
  33. data/lib/portage/ucp/support/session_lock.rb +30 -4
  34. data/lib/portage/ucp/support/transaction_log/file_store.rb +118 -0
  35. data/lib/portage/ucp/support/transaction_log/store.rb +63 -0
  36. data/lib/portage/ucp/support/transaction_log.rb +33 -77
  37. data/lib/portage/ucp/value_objects.rb +36 -2
  38. data/lib/portage/ucp/version.rb +1 -1
  39. data/lib/portage/ucp.rb +13 -0
  40. data/schemas/2026-04-08/schemas/shopping/checkout.json +1 -1
  41. data/schemas/2026-04-08/schemas/shopping/types/link.json +1 -1
  42. metadata +18 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 11276545531e50962e8e16e8d1c966853adfa8ddb8c1aa3851f7fa89a494b33e
4
- data.tar.gz: 7421a957575b40d55200ce54e70335bec9a7c7a735cd0941eb2e1bdec0bdd535
3
+ metadata.gz: 826f590ea83ab80febf6e461f60d1eb580d7ebf460a419bafb81d0f713525e81
4
+ data.tar.gz: eb98a54978daf0904de0121101e98e597a67cd38dabd7440dd3813449b384635
5
5
  SHA512:
6
- metadata.gz: 52d85b70ca4e16311c318331bb0ca161c82ff9f833e4d08b5c3e262550ecaeccf334aaf35cf5e39f5a351e3a5aa58ed0a508615003c1da6d54d728a9dc34eda9
7
- data.tar.gz: 05e158b1864ae760e08ddb3ab76080a1effd59bfa475ebd64e23f0f2e30f6ff87b4afe44eef0112ca5dda0a6e579e64532df396743cab582373f90e44805b089
6
+ metadata.gz: fcb26f95ac6d46b622f3495b945f76549d4c7525c25fbe0552c3485659395e35e1e0330fe212052f253ba79680d498e9de0b179d91c9bee17a4f2796ec33c50c
7
+ data.tar.gz: 4726bf3de6e45993612ea7a2c4bfd57ef418ad1a7c1d1a17efdffa90d20991df0fe9f623548f81065383b31aa99fd7210c2512d7994f6521a4bb6cfcfc494c1c
data/CHANGELOG.md CHANGED
@@ -4,7 +4,149 @@ All notable changes to this project are documented here. Format loosely follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.0.0/); this project is
5
5
  pre-1.0, so APIs may still shift between minor versions.
6
6
 
7
- ## [Unreleased]
7
+ ## [0.7.1] - 2026-09-16
8
+
9
+ - No behavior change — 0.7.0 was built and pushed with `gem build` run from
10
+ the workspace root instead of this gem's own directory, so
11
+ `spec.files = Dir["lib/**/*.rb", ...]` resolved against the wrong working
12
+ directory and packaged an empty gem (no `lib/`). 0.7.0 has been yanked;
13
+ 0.7.1 repackages the exact same 0.7.0 code correctly.
14
+
15
+ ## [0.7.0] - 2026-09-16
16
+
17
+ - Added `Ap2::MandateSignature` — real ECDSA (P-256/P-384) verification of a
18
+ `PaymentMandate#signature` against a JWK trust-anchor set (an array of JWKs,
19
+ or a `#call(kid)` resolver), reusing the same wire conventions as the
20
+ existing RFC 9421 `Security::Signature`. `MandateGuard.validate!` gains a
21
+ `require_signature:` (default `false`) fail-closed option — with it set,
22
+ a mandate that no trust key can verify raises `InvalidMandateError` instead
23
+ of silently falling back to shape-only validation. `Configuration` gains
24
+ `mandate_trusted_keys`/`require_mandate_signature` accessors, and
25
+ `Dispatcher.new` gains matching `mandate_trust_keys:`/
26
+ `require_mandate_signature:` kwargs. 0.6.0 shipped AP2 mandates shape-only
27
+ ("no cryptographic verification" — see that entry below); this closes the
28
+ gap. Also hardens the shape-only path itself: rejects non-EC JWKs before
29
+ trusting `crv`/`x`/`y`, rescues `ArgumentError` in `decode_base64url` so a
30
+ malformed base64url value raises `InvalidMandateError` rather than
31
+ crashing, and stops leaking the raw curve/digest hash into the
32
+ wrong-length error message.
33
+ - Closed a cross-process idempotency race: `FileStore` gains
34
+ `#fetch_or_store`, an atomic check-then-set under one `LOCK_EX` (the
35
+ previous fetch-then-store used two separate lock acquisitions, leaving a
36
+ window for a duplicate charge). `Idempotency#dedup` now goes through it.
37
+ `FileStore` persistence now writes to a temp file and `File.rename`s it
38
+ into place instead of truncating in place, and locks a sidecar `.lock`
39
+ file rather than the data file itself, so a lock held across a rename
40
+ can't go stale. A poisoned or truncated data file is now rescued and
41
+ treated as empty on read instead of permanently breaking every subsequent
42
+ `portage` invocation. `Configuration` gains `idempotency_provider` so a
43
+ caller can share one store process-wide instead of every
44
+ `Idempotency`-including instance getting its own fresh `MemoryStore`.
45
+ Per-key idempotency locks and `SessionLock`'s per-session locks are now
46
+ refcounted and reaped after use instead of growing unbounded for the life
47
+ of a long-running process.
48
+ - Added `Rails::Railtie` and a `rails g portage:ucp:install` generator,
49
+ guarded so they only load when Rails is already present (the gemspec adds
50
+ no Rails dependency of its own) — writes a `config/initializers/
51
+ portage_ucp.rb` stub and mounts the manifest/webhook Rack endpoints with
52
+ TODO placeholders for a Rails host to fill in.
53
+ - Added opt-in OpenTelemetry span emission: `Configuration#tracer` (`nil` by
54
+ default, no OTel dependency added). When set to something responding to
55
+ `#in_span(name, attributes:)`, `Observability.log` also emits a span
56
+ alongside its existing JSON logging; a no-op otherwise.
57
+ - `Mcp::Server.build` now names `journal:` explicitly rather than leaving it
58
+ to fall through `**server_opts`, forwarding it straight to
59
+ `Dispatcher.new` — `Client.for_adapter`/`Loopback` already splatted
60
+ `server_opts` through, but nothing constructed a `Dispatcher` with a
61
+ journal regardless of what a caller passed. Closes design-log §37's named
62
+ gap; see `portage-cli`'s own changelog for the loopback buy path this now
63
+ lets `portage-cli` wire up.
64
+ - Added `#each_record`/`#all` to `TransactionLog`/`OrderLedger` (and to both
65
+ classes' `Store` abstraction) — read-only enumeration over every stored
66
+ record, additive alongside the existing keyed `#find`/`#completed_since`.
67
+ §22 item 6's console is the second real consumer the `Store` docs said
68
+ would trigger this; `FileStore` implements it under the same shared-file
69
+ lock as every other read.
70
+
71
+ ## [0.6.0] - 2026-09-15
72
+
73
+ - **Breaking:** `PaymentEnrollment` results are now validated by the new
74
+ `PaymentEnrollmentGuard` (design-log §33/Phase B) — every
75
+ `create_payment_enrollment`/`get_payment_enrollment` response an adapter
76
+ returns is checked via `Dispatcher#call`: `status` must be `"pending"` or
77
+ `"complete"`; `"pending"` must carry a `setup_url` and no `payment_token`;
78
+ `"complete"` must carry a `payment_token` and no `setup_url`. Raises the
79
+ new `Portage::Ucp::InvalidPaymentEnrollmentError` on a violation. A value
80
+ that constructed fine before (e.g. `status: "banana"`, or `"complete"`
81
+ with no token) now raises the first time it crosses `Dispatcher#call` —
82
+ consumers of `PaymentEnrollment` (`portage-cli`, `portage-ucp-client`)
83
+ are unaffected since they only branch on the CLI's own wire-level status
84
+ string, never construct the object themselves.
85
+ - Added `Portage::Ucp::Ap2` — an AP2 mandate shape (design-log §33/Phase B,
86
+ citing the AP2/UCP payment-handler gap already confirmed at design-log
87
+ §29/§30). `Ap2::PaymentMandate` (amount, currency, merchant, expires_at,
88
+ signature) and `Ap2::MandateGuard.validate!` (required fields + expiry —
89
+ mandate-*shape* validation, not cryptographic AP2 verification; no key
90
+ infrastructure or trust anchor exists in this repo to verify a signature
91
+ against). `create_payment_enrollment`/`complete_checkout` take an
92
+ optional `mandate:` kwarg, shape-checked by `Dispatcher#call` before any
93
+ adapter sees it; `ReferenceAdapter` accepts one and echoes it back on the
94
+ enrollment it returns. `PaymentEnrollment` gains an optional `mandate:`
95
+ field for that echo (safe to add — it's a Portage extension, not a
96
+ UCP-schema-validated type).
97
+ - Adapter conformance kit (`lib/portage/ucp/rspec.rb`) gains an
98
+ `app.portage-ucp.payment_enrollment` example proving an adapter's
99
+ enrollment responses satisfy `PaymentEnrollmentGuard` — previously the
100
+ kit had zero enrollment coverage; the only existing assertions lived in
101
+ this gem's own `reference_adapter_conformance_spec.rb`, which ships to
102
+ nobody.
103
+ - `Support::TransactionLog` and `Support::OrderLedger` are now backed by a
104
+ pluggable `Store` (design-log §33) — new `Store`/`FileStore` pair on
105
+ each, mirroring `portage-ucp-journal`. `Dispatcher.new(transaction_log:,
106
+ order_ledger:)` / `TransactionLog.new(store:)` / `OrderLedger.new(store:)`
107
+ are the injection points; `path:`/`clock:` still work unchanged as a
108
+ shorthand for the file-backed default. Pure extract-interface refactor —
109
+ no behavior change, no new construction-site requirements.
110
+ - Added `app.portage-ucp.payment_method`, `app.portage-ucp.saved_address`, and
111
+ `app.portage-ucp.shopper_data` — Portage-owned extensions (design-log §22
112
+ item 7) for saved payment references, saved addresses, and shopper data
113
+ erasure, shipped together per §16. `oauth_token:` is the authorization
114
+ boundary on every method, including the two `list_*` reads, since
115
+ `Mcp::Server` only authorizes/rate-limits calls carrying an
116
+ `idempotency_key`. `save_payment_method`'s `payment_token:` runs through
117
+ the existing `PaymentTokenGuard` via `Dispatcher#call`, same as
118
+ `complete_checkout`. `ReferenceAdapter` implements all three; `Adapter`'s
119
+ stubs raise `NotImplementedError` until overridden.
120
+ - `Dispatcher.new` gains an optional `journal:` argument (`nil` by default,
121
+ never `require`d from this gem) — after a successful `complete_checkout`
122
+ with a settled order, `@journal.record_checkout(shop:, source:, checkout:,
123
+ idempotency_key:)` runs alongside the existing `order_ledger` write, same
124
+ after-settle/outside-the-rescue posture. `source` is `"native_ucp"` or
125
+ `"adapter:<platform>"`, read off the adapter's class. Pairs with the new
126
+ `portage-ucp-journal` gem's `PurchaseJournal` (design-log §22,
127
+ docs/plans/storage-abstraction-journal.md) — core takes on no new runtime
128
+ dependency; a consumer wires the journal in from their own app.
129
+ - Added `Portage::Ucp::Security::Signature` and
130
+ `Portage::Ucp::Rack::SignatureVerification` — verifies RFC 9421 HTTP
131
+ Message Signatures on inbound requests per UCP's signature spec
132
+ (`ucp.dev/2026-04-08/specification/signatures/`), the cryptographic
133
+ proof-of-consent piece design-log §22 named as the one genuine security
134
+ hole remaining before 1.0. Verify-before-parse, same posture as
135
+ `Rack::WebhookEndpoint`; trusted keys reuse `Manifest#signing_keys`'
136
+ current+next JWK-array shape rather than a second key config.
137
+ - Added `Confirmer::Webhook` (design-log §22 slice, Phase C) — out-of-band
138
+ approval for the confirmation gate, alongside `Terminal`/`AutoApprove`.
139
+ POSTs `{amount, currency, merchant, idempotency_key}` to a configured
140
+ URL, then polls a configured status endpoint (or calls a caller-supplied
141
+ `wait:` callback, for push-based transports) until approve/deny/timeout.
142
+ Fails closed on timeout, same as `Terminal`, but with its own longer
143
+ default timeout (900s vs. `Terminal`'s 120s) — a human already at a
144
+ terminal isn't the same wait as noticing and acting on a Slack message.
145
+ Built on `Support::HttpClient`, no new runtime dependency. Non-2xx
146
+ responses from the confirm/status calls themselves raise the new
147
+ `Confirmer::WebhookApiError`, distinct from `ConfirmationDeniedError`:
148
+ one means the out-of-band approver couldn't be reached, the other means
149
+ it was reached and said no (or never answered).
8
150
 
9
151
  ## [0.5.0] - 2026-09-14
10
152
 
data/README.md CHANGED
@@ -23,17 +23,22 @@ in this gem.
23
23
  | `Portage::Ucp::Manifest` | Builds the signed `/.well-known/ucp` discovery document. |
24
24
  | `Portage::Ucp::Rack::ManifestEndpoint` | Serves that manifest over Rack. |
25
25
  | `Portage::Ucp::Rack::WebhookEndpoint` | HMAC-verified inbound order-lifecycle webhooks. |
26
+ | `Portage::Ucp::Security::Signature` / `Portage::Ucp::Rack::SignatureVerification` | Verifies RFC 9421 HTTP Message Signatures on inbound requests per UCP's signature spec — cryptographic proof the call carries a signed AP2/UCP authorization, not just an authenticated caller. Wrap your mounted MCP/UCP endpoint with the Rack middleware; verification runs before the body is parsed. |
26
27
  | `Portage::Ucp::SchemaValidator` | Validates data against UCP's own vendored JSON Schemas/OpenRPC docs, offline. |
27
28
  | `Portage::Ucp::Resolver` / `exe/portage-ucp-check` | Probes any store's homepage/`.well-known/ucp` and recommends the matching adapter gem. |
28
29
  | `Portage::Ucp::Support::TransactionLog` | Durable pre/post-dispatch record of every `complete_checkout` call — reserved before dispatch, marked settled/failed after, so a crash mid-charge is diagnosable rather than silently lost. |
29
30
  | `Portage::Ucp::Support::OrderLedger` | Durable snapshot written after settlement, alongside (not instead of) the transaction record — a failed snapshot write surfaces without flipping an already-settled charge to failed. |
30
- | `Portage::Ucp::Confirmer` | Gate run just before `complete_checkout` dispatch, after `PolicyGuard`. `Confirmer::Terminal` blocks on stdin and fails closed on anything but an explicit `"y"`; `Confirmer::AutoApprove` is for specs/conformance kits that need a real `confirm!` without blocking. |
31
+ | `Portage::Ucp::Confirmer` | Gate run just before `complete_checkout` dispatch, after `PolicyGuard`. `Confirmer::Terminal` blocks on stdin and fails closed on anything but an explicit `"y"`; `Confirmer::AutoApprove` is for specs/conformance kits that need a real `confirm!` without blocking; `Confirmer::Webhook` is an out-of-band transport (POST + poll a status URL, or a caller-supplied `wait:` callback) for a Slack/WhatsApp/etc. approval flow — same fail-closed-on-timeout contract, its own longer default timeout. |
31
32
  | `Portage::Ucp::PolicyGuard` / `Portage::Ucp::Policy` | Per-transaction/rolling/velocity caps and a merchant allowlist, checked before `complete_checkout` dispatch; configured via `portage-cli`'s `portage policy show/set`. |
33
+ | `Portage::Ucp::PaymentEnrollmentGuard` | Validates every `create_payment_enrollment`/`get_payment_enrollment` result an `Adapter` returns — `status` must be `"pending"` (with a `setup_url`, no `payment_token`) or `"complete"` (with a `payment_token`, no `setup_url`). Runs automatically in `Dispatcher#call`. |
34
+ | `Portage::Ucp::Ap2::PaymentMandate` / `Portage::Ucp::Ap2::MandateGuard` | A typed shape for an AP2 payment mandate, and shape-only validation (required fields + expiry — not cryptographic verification) run automatically on any `mandate:` argument passed through `Dispatcher#call`. |
32
35
 
33
36
  Security defaults are all locked down, not permissive-by-omission —
34
37
  `UnconfiguredAuthenticator` rejects every mutating call until you configure a real
35
38
  one, `PaymentTokenGuard` rejects raw card numbers before they reach your `Adapter`,
36
- and manifest signing is opt-in. Full detail in the root README's
39
+ `PaymentEnrollmentGuard`/`Ap2::MandateGuard` reject malformed enrollments/mandates
40
+ before they cross the same boundary, and manifest signing is opt-in. Full detail in
41
+ the root README's
37
42
  [Security hooks](https://github.com/tomtom87/Portage#security-hooks--nothing-is-permissive-by-default)
38
43
  section.
39
44
 
@@ -75,6 +80,18 @@ and the [detailed walkthrough](https://github.com/tomtom87/Portage/blob/main/doc
75
80
  for the full agent-side conversation, manifest/webhook Rack mounting, and a real
76
81
  adapter to model your own against.
77
82
 
83
+ ## Swapping the store
84
+
85
+ `Support::TransactionLog` and `Support::OrderLedger` each accept a `store:`
86
+ (`Dispatcher.new(transaction_log:, order_ledger:)` is the injection point).
87
+ `FileStore` — whole-file `flock` + JSON, `chmod 0600` — is the shipped
88
+ default for both; nothing else ships today. Write your own subclass of
89
+ `Support::TransactionLog::Store` / `Support::OrderLedger::Store` for a real
90
+ database, Redis, or an in-memory double for tests — same posture as
91
+ `portage-ucp-journal`'s `Store`/`FileStore` seam, no bundled second backend
92
+ (see that gem's README). `path:`/`clock:` still work as a shorthand that
93
+ builds a `FileStore` under the hood, so existing callers are unaffected.
94
+
78
95
  ## Checking any store
79
96
 
80
97
  ```bash
@@ -0,0 +1,28 @@
1
+ require "rails/generators"
2
+
3
+ module Portage
4
+ module Ucp
5
+ module Generators
6
+ # rails g portage:ucp:install — writes the initializer stub and mounts
7
+ # the discovery/webhook Rack endpoints. Leaves authenticator, rate
8
+ # limiter and business identity as TODOs: none of those have a safe
9
+ # default a generator could pick for the host app (see
10
+ # UnconfiguredAuthenticator/NullRateLimiter in configuration.rb).
11
+ class InstallGenerator < ::Rails::Generators::Base
12
+ source_root File.expand_path("templates", __dir__)
13
+
14
+ def copy_initializer
15
+ template "portage_ucp.rb", "config/initializers/portage_ucp.rb"
16
+ end
17
+
18
+ def add_routes
19
+ route <<~RUBY.strip
20
+ # TODO: replace YOUR_ADAPTER with your Portage::Ucp::Adapter instance.
21
+ mount Portage::Ucp::Rack::ManifestEndpoint.new(manifest: Portage::Ucp::Manifest.new(adapter: YOUR_ADAPTER)) => "/.well-known/ucp"
22
+ mount Portage::Ucp::Rack::WebhookEndpoint.new(secret: Rails.application.credentials.dig(:portage_ucp, :webhook_secret), on_order_event: ->(order) { }) => "/webhooks/portage_ucp"
23
+ RUBY
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,21 @@
1
+ Portage::Ucp.configure do |config|
2
+ # TODO: authenticator must return an auth context (any truthy value) or
3
+ # raise Portage::Ucp::AuthenticationError. Left unset, every mutating
4
+ # capability call is rejected (UnconfiguredAuthenticator).
5
+ # config.authenticator = ->(server_context) { ... }
6
+
7
+ # TODO: rate_limiter#check!(key, capability) raises
8
+ # Portage::Ucp::RateLimitExceededError to block a call. Left unset, no
9
+ # limiting is applied (NullRateLimiter).
10
+ # config.rate_limiter = MyRateLimiter.new
11
+
12
+ # TODO: business identity advertised in the /.well-known/ucp manifest.
13
+ # config.business = { name: "Your Business" }
14
+
15
+ # config.signer = MySigner.new
16
+ # config.signing_keys = []
17
+ # config.payment_handlers = []
18
+ # config.services = []
19
+ # config.mandate_trusted_keys = []
20
+ # config.require_mandate_signature = false
21
+ end
@@ -71,8 +71,16 @@ module Portage
71
71
  # than a generic/platform error, so callers can distinguish a stale-stock
72
72
  # failure from e.g. a declined payment.
73
73
  # @raise [Portage::Ucp::OutOfStockError] if a line item is no longer available
74
+ # @param mandate [Portage::Ucp::Ap2::PaymentMandate, nil] an AP2
75
+ # mandate authorizing this charge, alongside or instead of relying
76
+ # on `payment_token` alone (design-log §33/Phase B). Validated for
77
+ # shape (not cryptographically) by Dispatcher#call via
78
+ # Ap2::MandateGuard before an adapter ever sees it. Optional and
79
+ # `nil` by default — no adapter in this repo has a real PSP to
80
+ # verify a mandate's signature against, so this is a pass-through
81
+ # slot, same posture as `payment_token` on this same method.
74
82
  # @return [Portage::Ucp::Checkout]
75
- def complete_checkout(checkout_id:, payment_token:, idempotency_key:) = not_implemented
83
+ def complete_checkout(checkout_id:, payment_token:, idempotency_key:, mandate: nil) = not_implemented
76
84
  # @return [Portage::Ucp::Checkout]
77
85
  def cancel_checkout(checkout_id:, idempotency_key:) = not_implemented
78
86
 
@@ -135,11 +143,56 @@ module Portage
135
143
  # gateway-hosted page where the human enters their card, and the
136
144
  # caller polls #get_payment_enrollment until `status` leaves
137
145
  # "pending". See docs/plans/agentic-payments.md Phase 1.
146
+ # @param mandate [Portage::Ucp::Ap2::PaymentMandate, nil] an AP2
147
+ # mandate presented at enrollment time (design-log §33/Phase B),
148
+ # same optional/pass-through posture as `complete_checkout`'s
149
+ # `mandate:` above — validated for shape by Dispatcher#call, never
150
+ # cryptographically, before an adapter ever sees it.
138
151
  # @return [Portage::Ucp::PaymentEnrollment]
139
- def create_payment_enrollment(idempotency_key:) = not_implemented
152
+ def create_payment_enrollment(idempotency_key:, mandate: nil) = not_implemented
140
153
  # @return [Portage::Ucp::PaymentEnrollment, nil] nil if the enrollment isn't found
141
154
  def get_payment_enrollment(enrollment_id:) = not_implemented
142
155
 
156
+ # --- Payment Method / Saved Address / Shopper Data (app.portage-ucp.*
157
+ # — Portage extensions, not part of the UCP spec) ---
158
+ # `oauth_token:` — not `subject:` — is the authorization boundary on
159
+ # every method below, including the two list_* reads. Mcp::Server
160
+ # treats a call as mutating only when it takes an idempotency_key
161
+ # (mcp/server.rb:38), and both the authorize and rate_limit guards
162
+ # skip non-mutating calls entirely. A bare `subject:` string would let
163
+ # any caller who knows (or guesses) a subject enumerate another
164
+ # shopper's saved payment references and addresses — the exact risk
165
+ # §16 (design-log.md:816-819) calls out lookups against this data for.
166
+ # Carrying the credential on every call closes that: possession of a
167
+ # subject grants nothing without a valid oauth_token to derive it from.
168
+ # Do not "simplify" this back to `subject:`.
169
+ #
170
+ # @return [Portage::Ucp::PaymentMethodRef] stores an already-tokenized
171
+ # payment_token; PaymentTokenGuard runs on it via Dispatcher#call
172
+ # before this method is ever reached (dispatcher.rb:75), so a raw PAN
173
+ # never arrives here.
174
+ def save_payment_method(oauth_token:, payment_token:, idempotency_key:) = not_implemented
175
+ # @return [Array<Portage::Ucp::PaymentMethodRef>]
176
+ def list_payment_methods(oauth_token:) = not_implemented
177
+ # @return [Boolean]
178
+ def delete_payment_method(oauth_token:, payment_method_id:, idempotency_key:) = not_implemented
179
+
180
+ # @return [Portage::Ucp::SavedAddress]
181
+ def save_address(oauth_token:, address:, idempotency_key:) = not_implemented
182
+ # @return [Array<Portage::Ucp::SavedAddress>]
183
+ def list_addresses(oauth_token:) = not_implemented
184
+ # @return [Boolean]
185
+ def delete_address(oauth_token:, address_id:, idempotency_key:) = not_implemented
186
+
187
+ # Erases every payment method, address, and linked identity Portage
188
+ # holds for this shopper. Idempotent and safe to repeat: a second call
189
+ # on an already-erased subject returns zero counts, never raises. A
190
+ # real adapter must forward the deletion to the PSP/platform's own
191
+ # API — Portage core never holds the credential behind psp_reference,
192
+ # only the opaque reference itself.
193
+ # @return [Portage::Ucp::ShopperDataErasure]
194
+ def delete_shopper_data(oauth_token:, idempotency_key:) = not_implemented
195
+
143
196
  private
144
197
 
145
198
  def not_implemented
@@ -0,0 +1,48 @@
1
+ require "json"
2
+
3
+ module Portage
4
+ module Ucp
5
+ module Ap2
6
+ # A typed shape for an AP2 payment mandate (design-log §33/Phase B,
7
+ # citing design-log.md:1984-2117's already-confirmed AP2 gap) — the
8
+ # cart/intent authorization a shopper's agent presents alongside (or
9
+ # instead of) a bare `payment_token`. `amount`/`currency` mirror the
10
+ # minor-unit-integer convention `Total#amount` already uses elsewhere
11
+ # in this gem (see value_objects.rb).
12
+ #
13
+ # `kid` identifies which of the issuing agent's keys `signature` was
14
+ # made with — optional (defaults nil) so existing callers that only
15
+ # ever exercised shape validation keep constructing a PaymentMandate
16
+ # without it; it's required the moment a caller actually wants
17
+ # Ap2::MandateSignature/MandateGuard's `trusted_keys:` to verify the
18
+ # signature cryptographically. `signature` itself is still carried
19
+ # opaquely here — this Data class doesn't verify anything, it's just
20
+ # the shape; see MandateGuard and Ap2::MandateSignature for the two
21
+ # tiers of validation (shape-only vs shape+crypto).
22
+ PaymentMandate = Data.define(:amount, :currency, :merchant, :expires_at, :signature, :kid) do
23
+ def initialize(kid: nil, **rest)
24
+ super
25
+ end
26
+
27
+ def to_wire_h
28
+ { "amount" => amount, "currency" => currency, "merchant" => merchant,
29
+ "expires_at" => expires_at, "signature" => signature, "kid" => kid }
30
+ end
31
+
32
+ # The exact bytes Ap2::MandateSignature verifies `signature`
33
+ # against — every field but `signature` itself, canonicalized the
34
+ # same way Manifest#sign canonicalizes its own payload (JSON.generate
35
+ # over a fixed-order hash, no key sorting beyond that fixed order,
36
+ # since both sides of a mandate are expected to agree on the field
37
+ # order out of band rather than this gem inventing a JCS-style
38
+ # canonicalization scheme it doesn't need elsewhere).
39
+ def signing_payload
40
+ JSON.generate(
41
+ { "amount" => amount, "currency" => currency, "merchant" => merchant,
42
+ "expires_at" => expires_at, "kid" => kid }
43
+ )
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,48 @@
1
+ require "time"
2
+
3
+ module Portage
4
+ module Ucp
5
+ module Ap2
6
+ # Mandate validation (design-log §33/Phase B): required fields present,
7
+ # not expired, and — when the caller supplies a trust anchor —
8
+ # cryptographic proof via Ap2::MandateSignature. `trusted_keys` has no
9
+ # default because this gem has no key infrastructure of its own to
10
+ # default it to (§9's convention: keys are always consumer-provided,
11
+ # never generated or assumed here); a caller that omits it gets
12
+ # shape-only validation, same posture this guard always had, not a
13
+ # silent downgrade. A real PSP adapter is expected to pass the issuing
14
+ # agent's trust anchor (its own store, or a resolver against the
15
+ # agent's own manifest) once it has one. A caller that wants that
16
+ # omission to be an error instead — fail closed rather than silently
17
+ # downgrade to shape-only — sets `require_signature: true`.
18
+ module MandateGuard
19
+ REQUIRED_FIELDS = %i[amount currency merchant expires_at signature].freeze
20
+
21
+ # @param trusted_keys [Array<Hash>, #call, nil] forwarded to
22
+ # Ap2::MandateSignature.verify! when present — see module doc.
23
+ # @param require_signature [Boolean] when true, `trusted_keys`
24
+ # resolving to nil raises InvalidMandateError instead of falling
25
+ # back to shape-only validation.
26
+ def self.validate!(mandate, trusted_keys: nil, require_signature: false)
27
+ missing = REQUIRED_FIELDS.reject { |field| mandate.public_send(field) }
28
+ unless missing.empty?
29
+ raise Portage::Ucp::InvalidMandateError,
30
+ "payment mandate is missing required field(s): #{missing.join(', ')}"
31
+ end
32
+
33
+ if Time.now >= Time.parse(mandate.expires_at)
34
+ raise Portage::Ucp::InvalidMandateError,
35
+ "payment mandate expired at #{mandate.expires_at}"
36
+ end
37
+
38
+ if trusted_keys
39
+ Ap2::MandateSignature.verify!(mandate, trusted_keys: trusted_keys)
40
+ elsif require_signature
41
+ raise Portage::Ucp::InvalidMandateError,
42
+ "payment mandate signature verification is required but no trusted_keys are configured"
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,148 @@
1
+ require "openssl"
2
+ require "base64"
3
+
4
+ module Portage
5
+ module Ucp
6
+ module Ap2
7
+ # Cryptographic verification of a PaymentMandate's `signature` — the
8
+ # piece MandateGuard's own comment (and PaymentMandate's, before this
9
+ # file existed) flagged as deliberately missing: "no key infrastructure
10
+ # or trust anchor exists in this repo to verify a signature against."
11
+ # A caller that now has a trust anchor (a real PSP adapter, or a
12
+ # platform-provided key resolver) passes it as `trusted_keys:` to get
13
+ # actual proof instead of shape-only validation.
14
+ #
15
+ # Same ECDSA/JWK wire conventions as Security::Signature (P-256
16
+ # mandatory/P-384 optional, raw r||s signature bytes, JWK x/y
17
+ # coordinates) since that curve support is already proven against this
18
+ # gem's RFC 9421 verifier — but deliberately a separate class, not a
19
+ # shared one: a mandate isn't an HTTP request (no method/path/headers
20
+ # to canonicalize, just PaymentMandate#signing_payload), and
21
+ # Security::Signature's own class doc already frames every signing
22
+ # story in this gem as deliberately distinct rather than unified under
23
+ # one abstraction.
24
+ #
25
+ # Trust anchor: `trusted_keys` here is the mandate *issuer's* key set
26
+ # (the shopper's agent, or the agent platform vouching for it) — a
27
+ # different trust root than Security::Signature's `trusted_keys` (the
28
+ # calling platform's own request-signing key), even though both reuse
29
+ # the same flat-JWK-array-or-#call(kid)-resolver shape (§9's
30
+ # convention, reused again here rather than inventing a second
31
+ # differently-shaped key config for the same job).
32
+ module MandateSignature
33
+ CURVES = {
34
+ "P-256" => { oid: "1.2.840.10045.3.1.7", coord: 32, digest: "SHA256" },
35
+ "P-384" => { oid: "1.3.132.0.34", coord: 48, digest: "SHA384" }
36
+ }.freeze
37
+ EC_PUBLIC_KEY_OID = "1.2.840.10045.2.1".freeze
38
+
39
+ # @param mandate [PaymentMandate]
40
+ # @param trusted_keys [Array<Hash>, #call] JWK hash set (or
41
+ # resolver #call(kid) => JWK hash or nil), keyed by `kid` — see
42
+ # module doc.
43
+ # @return [true] never a falsy result; raises
44
+ # Portage::Ucp::InvalidMandateError on any failure so a caller
45
+ # can't accidentally treat "didn't check" as "checked and passed"
46
+ # (same convention as Security::Signature.verify!).
47
+ def self.verify!(mandate, trusted_keys:)
48
+ jwk = trust_key!(mandate, trusted_keys)
49
+ curve = curve_for!(jwk)
50
+ raw_signature = sized_signature!(mandate, curve)
51
+
52
+ key = ec_public_key(jwk, curve)
53
+ der = raw_to_der(raw_signature, curve[:coord])
54
+ verified = key.verify(curve[:digest], der, mandate.signing_payload)
55
+ raise Portage::Ucp::InvalidMandateError, "mandate signature does not verify" unless verified
56
+
57
+ true
58
+ rescue OpenSSL::PKey::PKeyError, OpenSSL::PKey::EC::Point::Error, OpenSSL::ASN1::ASN1Error => e
59
+ raise Portage::Ucp::InvalidMandateError, "mandate signature verification failed: #{e.message}"
60
+ end
61
+
62
+ def self.trust_key!(mandate, trusted_keys)
63
+ kid = mandate.kid
64
+ raise Portage::Ucp::InvalidMandateError, "mandate has no kid to resolve a trust key by" unless kid
65
+
66
+ jwk = resolve_key(trusted_keys, kid)
67
+ raise Portage::Ucp::InvalidMandateError, "no trusted key for mandate kid #{kid.inspect}" unless jwk
68
+
69
+ jwk
70
+ end
71
+ private_class_method :trust_key!
72
+
73
+ def self.curve_for!(jwk)
74
+ kty = jwk["kty"] || jwk[:kty]
75
+ raise Portage::Ucp::InvalidMandateError, "unsupported key type #{kty.inspect}" unless kty == "EC"
76
+
77
+ crv = jwk["crv"] || jwk[:crv]
78
+ CURVES.fetch(crv) { raise Portage::Ucp::InvalidMandateError, "unsupported curve #{crv.inspect}" }
79
+ end
80
+ private_class_method :curve_for!
81
+
82
+ def self.sized_signature!(mandate, curve)
83
+ raw_signature = decode_signature(mandate.signature)
84
+ unless raw_signature.bytesize == curve[:coord] * 2
85
+ raise Portage::Ucp::InvalidMandateError, "mandate signature is the wrong length for #{curve[:digest]}"
86
+ end
87
+
88
+ raw_signature
89
+ end
90
+ private_class_method :sized_signature!
91
+
92
+ def self.resolve_key(trusted_keys, kid)
93
+ if trusted_keys.respond_to?(:call)
94
+ trusted_keys.call(kid)
95
+ else
96
+ Array(trusted_keys).find { |k| (k["kid"] || k[:kid]) == kid }
97
+ end
98
+ end
99
+ private_class_method :resolve_key
100
+
101
+ def self.decode_signature(value)
102
+ Base64.strict_decode64(value.to_s)
103
+ rescue ArgumentError
104
+ raise Portage::Ucp::InvalidMandateError, "mandate signature isn't valid base64"
105
+ end
106
+ private_class_method :decode_signature
107
+
108
+ # Same DER-from-raw-JWK-coordinates construction as
109
+ # Security::Signature#ec_public_key, for the same reason: the
110
+ # OpenSSL::PKey::EC#public_key= setter no longer works since EC keys
111
+ # became immutable in the openssl gem's OpenSSL 3.0 support.
112
+ def self.ec_public_key(jwk, curve)
113
+ x = decode_base64url(jwk["x"] || jwk[:x])
114
+ y = decode_base64url(jwk["y"] || jwk[:y])
115
+ raise Portage::Ucp::InvalidMandateError, "JWK missing x/y" if x.nil? || y.nil?
116
+
117
+ octet_string = "\x04".b + x + y
118
+ der = OpenSSL::ASN1::Sequence.new([
119
+ OpenSSL::ASN1::Sequence.new([
120
+ OpenSSL::ASN1::ObjectId.new(EC_PUBLIC_KEY_OID),
121
+ OpenSSL::ASN1::ObjectId.new(curve[:oid])
122
+ ]),
123
+ OpenSSL::ASN1::BitString.new(octet_string)
124
+ ]).to_der
125
+ OpenSSL::PKey::EC.new(der)
126
+ end
127
+ private_class_method :ec_public_key
128
+
129
+ def self.raw_to_der(raw_signature, coord)
130
+ r = OpenSSL::BN.new(raw_signature.byteslice(0, coord), 2)
131
+ s = OpenSSL::BN.new(raw_signature.byteslice(coord, coord), 2)
132
+ OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::Integer.new(r), OpenSSL::ASN1::Integer.new(s)]).to_der
133
+ end
134
+ private_class_method :raw_to_der
135
+
136
+ def self.decode_base64url(value)
137
+ return nil unless value
138
+
139
+ padded = value + ("=" * ((4 - (value.length % 4)) % 4))
140
+ Base64.urlsafe_decode64(padded)
141
+ rescue ArgumentError
142
+ raise Portage::Ucp::InvalidMandateError, "JWK coordinate isn't valid base64url"
143
+ end
144
+ private_class_method :decode_base64url
145
+ end
146
+ end
147
+ end
148
+ end
@@ -8,7 +8,7 @@ module Portage
8
8
  )
9
9
 
10
10
  ALL = [CATALOG, CART, CHECKOUT, ORDER, IDENTITY_LINKING, DISCOUNT, FULFILLMENT, REORDER,
11
- PAYMENT_ENROLLMENT].freeze
11
+ PAYMENT_ENROLLMENT, PAYMENT_METHOD, SAVED_ADDRESS, SHOPPER_DATA].freeze
12
12
  end
13
13
  end
14
14
  end
@@ -10,7 +10,7 @@ module Portage
10
10
  name: "app.portage-ucp.payment_enrollment",
11
11
  version: "1",
12
12
  actions: { "create_payment_enrollment" => :create_payment_enrollment,
13
- "get_payment_enrollment" => :get_payment_enrollment }
13
+ "get_payment_enrollment" => :get_payment_enrollment }
14
14
  )
15
15
  end
16
16
  end
@@ -0,0 +1,28 @@
1
+ module Portage
2
+ module Ucp
3
+ module Capabilities
4
+ # Portage-only extensions, not part of the UCP spec (no ucp.dev
5
+ # reverse-domain meaning to borrow), same posture as
6
+ # Capabilities::PAYMENT_ENROLLMENT/REORDER. Advertised only if the
7
+ # adapter overrides at least one of its own action methods. Kept as
8
+ # two capabilities (payment methods vs. addresses) rather than one,
9
+ # per design-log §16/§22 item 7's "ship together, but they're
10
+ # separate concerns" — a consumer can still gate them independently.
11
+ PAYMENT_METHOD = Portage::Ucp::Capability.new(
12
+ name: "app.portage-ucp.payment_method",
13
+ version: "1",
14
+ actions: { "save_payment_method" => :save_payment_method,
15
+ "list_payment_methods" => :list_payment_methods,
16
+ "delete_payment_method" => :delete_payment_method }
17
+ )
18
+
19
+ SAVED_ADDRESS = Portage::Ucp::Capability.new(
20
+ name: "app.portage-ucp.saved_address",
21
+ version: "1",
22
+ actions: { "save_address" => :save_address,
23
+ "list_addresses" => :list_addresses,
24
+ "delete_address" => :delete_address }
25
+ )
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,15 @@
1
+ module Portage
2
+ module Ucp
3
+ module Capabilities
4
+ # Portage-only extension, not part of the UCP spec. Kept separate from
5
+ # PAYMENT_METHOD/SAVED_ADDRESS: this is the erasure duty over *both*
6
+ # of those plus the linked identity, not a peer capability alongside
7
+ # them (design-log §22 item 7).
8
+ SHOPPER_DATA = Portage::Ucp::Capability.new(
9
+ name: "app.portage-ucp.shopper_data",
10
+ version: "1",
11
+ actions: { "delete_shopper_data" => :delete_shopper_data }
12
+ )
13
+ end
14
+ end
15
+ end