portage-ucp 0.4.0 → 0.6.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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +130 -0
  3. data/README.md +22 -1
  4. data/lib/portage/ucp/adapter.rb +71 -1
  5. data/lib/portage/ucp/ap2/mandate.rb +21 -0
  6. data/lib/portage/ucp/ap2/mandate_guard.rb +34 -0
  7. data/lib/portage/ucp/capabilities/catalog.rb +2 -1
  8. data/lib/portage/ucp/capabilities/identity_linking.rb +2 -1
  9. data/lib/portage/ucp/capabilities/payment_enrollment.rb +17 -0
  10. data/lib/portage/ucp/capabilities/payment_method.rb +28 -0
  11. data/lib/portage/ucp/capabilities/shopper_data.rb +15 -0
  12. data/lib/portage/ucp/confirmer.rb +176 -0
  13. data/lib/portage/ucp/dispatcher.rb +212 -4
  14. data/lib/portage/ucp/errors.rb +48 -0
  15. data/lib/portage/ucp/mcp/server.rb +20 -7
  16. data/lib/portage/ucp/observability.rb +1 -0
  17. data/lib/portage/ucp/payment_enrollment_guard.rb +44 -0
  18. data/lib/portage/ucp/policy.rb +79 -0
  19. data/lib/portage/ucp/policy_guard.rb +152 -0
  20. data/lib/portage/ucp/rack/signature_verification.rb +74 -0
  21. data/lib/portage/ucp/reference_adapter.rb +107 -1
  22. data/lib/portage/ucp/rspec.rb +164 -3
  23. data/lib/portage/ucp/security/errors.rb +37 -0
  24. data/lib/portage/ucp/security/signature.rb +277 -0
  25. data/lib/portage/ucp/support/idempotency/file_store.rb +78 -0
  26. data/lib/portage/ucp/support/idempotency/memory_store.rb +34 -0
  27. data/lib/portage/ucp/support/idempotency.rb +34 -8
  28. data/lib/portage/ucp/support/order_ledger/file_store.rb +69 -0
  29. data/lib/portage/ucp/support/order_ledger/store.rb +23 -0
  30. data/lib/portage/ucp/support/order_ledger.rb +47 -0
  31. data/lib/portage/ucp/support/session_lock.rb +2 -2
  32. data/lib/portage/ucp/support/token_ref.rb +20 -0
  33. data/lib/portage/ucp/support/transaction_log/file_store.rb +112 -0
  34. data/lib/portage/ucp/support/transaction_log/store.rb +54 -0
  35. data/lib/portage/ucp/support/transaction_log.rb +108 -0
  36. data/lib/portage/ucp/value_objects.rb +55 -0
  37. data/lib/portage/ucp/version.rb +1 -1
  38. data/lib/portage/ucp.rb +21 -0
  39. data/schemas/2026-04-08/schemas/shopping/checkout.json +1 -1
  40. data/schemas/2026-04-08/schemas/shopping/types/link.json +1 -1
  41. metadata +28 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 36c3a77e9f625761b827d1e925d54217b7bff302f4a7a60540d4a8c5d1794fb9
4
- data.tar.gz: 05a86e2740a327c63bb0e06aa450e2aa5207aa60ce637575133483ed8df4698b
3
+ metadata.gz: f8228c4a5a32d4ed322103ebc5061bf3179700fae68cc414fd0e53a8cb7f900e
4
+ data.tar.gz: dfc1f273f7fc79a8af633d4341af409a1447550c56a2fb0e8c7d4301b85c65c4
5
5
  SHA512:
6
- metadata.gz: d0d4746f829e60009463ee2748ba5c46ff6f3932b5a345f3f26d94e437a5132b39002945bf19abe1ee3d02c46e1760b52afa6910a15531df494c4e39dcca7787
7
- data.tar.gz: 60898e67d72f545c24970415775506be4930336a4f325be61714cef0f810547f77b79951f7b48e3fb32b72cdd9adb9669b4f3af4f872be0a4bcb7ea60b325bf5
6
+ metadata.gz: 4b7bb63032f231ed97eee56d0ed1d5d85a0fed3fae9c5590a22b153285ce13cc9c362903971bbb9d0a1f9fd0ecb5372e6412f6c38c76ed4f1e17927491006803
7
+ data.tar.gz: 3046889323d2b87b8ec26523e93bd33ff4edad0345bb858e5f2a75f6c9e0d288e0918cb8b0ee0b3dee467fa47023d7469a254a3e0bfc0ada5aaa527907c1c6fc
data/CHANGELOG.md CHANGED
@@ -6,6 +6,136 @@ pre-1.0, so APIs may still shift between minor versions.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.6.0] - 2026-09-15
10
+
11
+ - **Breaking:** `PaymentEnrollment` results are now validated by the new
12
+ `PaymentEnrollmentGuard` (design-log §33/Phase B) — every
13
+ `create_payment_enrollment`/`get_payment_enrollment` response an adapter
14
+ returns is checked via `Dispatcher#call`: `status` must be `"pending"` or
15
+ `"complete"`; `"pending"` must carry a `setup_url` and no `payment_token`;
16
+ `"complete"` must carry a `payment_token` and no `setup_url`. Raises the
17
+ new `Portage::Ucp::InvalidPaymentEnrollmentError` on a violation. A value
18
+ that constructed fine before (e.g. `status: "banana"`, or `"complete"`
19
+ with no token) now raises the first time it crosses `Dispatcher#call` —
20
+ consumers of `PaymentEnrollment` (`portage-cli`, `portage-ucp-client`)
21
+ are unaffected since they only branch on the CLI's own wire-level status
22
+ string, never construct the object themselves.
23
+ - Added `Portage::Ucp::Ap2` — an AP2 mandate shape (design-log §33/Phase B,
24
+ citing the AP2/UCP payment-handler gap already confirmed at design-log
25
+ §29/§30). `Ap2::PaymentMandate` (amount, currency, merchant, expires_at,
26
+ signature) and `Ap2::MandateGuard.validate!` (required fields + expiry —
27
+ mandate-*shape* validation, not cryptographic AP2 verification; no key
28
+ infrastructure or trust anchor exists in this repo to verify a signature
29
+ against). `create_payment_enrollment`/`complete_checkout` take an
30
+ optional `mandate:` kwarg, shape-checked by `Dispatcher#call` before any
31
+ adapter sees it; `ReferenceAdapter` accepts one and echoes it back on the
32
+ enrollment it returns. `PaymentEnrollment` gains an optional `mandate:`
33
+ field for that echo (safe to add — it's a Portage extension, not a
34
+ UCP-schema-validated type).
35
+ - Adapter conformance kit (`lib/portage/ucp/rspec.rb`) gains an
36
+ `app.portage-ucp.payment_enrollment` example proving an adapter's
37
+ enrollment responses satisfy `PaymentEnrollmentGuard` — previously the
38
+ kit had zero enrollment coverage; the only existing assertions lived in
39
+ this gem's own `reference_adapter_conformance_spec.rb`, which ships to
40
+ nobody.
41
+ - `Support::TransactionLog` and `Support::OrderLedger` are now backed by a
42
+ pluggable `Store` (design-log §33) — new `Store`/`FileStore` pair on
43
+ each, mirroring `portage-ucp-journal`. `Dispatcher.new(transaction_log:,
44
+ order_ledger:)` / `TransactionLog.new(store:)` / `OrderLedger.new(store:)`
45
+ are the injection points; `path:`/`clock:` still work unchanged as a
46
+ shorthand for the file-backed default. Pure extract-interface refactor —
47
+ no behavior change, no new construction-site requirements.
48
+ - Added `app.portage-ucp.payment_method`, `app.portage-ucp.saved_address`, and
49
+ `app.portage-ucp.shopper_data` — Portage-owned extensions (design-log §22
50
+ item 7) for saved payment references, saved addresses, and shopper data
51
+ erasure, shipped together per §16. `oauth_token:` is the authorization
52
+ boundary on every method, including the two `list_*` reads, since
53
+ `Mcp::Server` only authorizes/rate-limits calls carrying an
54
+ `idempotency_key`. `save_payment_method`'s `payment_token:` runs through
55
+ the existing `PaymentTokenGuard` via `Dispatcher#call`, same as
56
+ `complete_checkout`. `ReferenceAdapter` implements all three; `Adapter`'s
57
+ stubs raise `NotImplementedError` until overridden.
58
+ - `Dispatcher.new` gains an optional `journal:` argument (`nil` by default,
59
+ never `require`d from this gem) — after a successful `complete_checkout`
60
+ with a settled order, `@journal.record_checkout(shop:, source:, checkout:,
61
+ idempotency_key:)` runs alongside the existing `order_ledger` write, same
62
+ after-settle/outside-the-rescue posture. `source` is `"native_ucp"` or
63
+ `"adapter:<platform>"`, read off the adapter's class. Pairs with the new
64
+ `portage-ucp-journal` gem's `PurchaseJournal` (design-log §22,
65
+ docs/plans/storage-abstraction-journal.md) — core takes on no new runtime
66
+ dependency; a consumer wires the journal in from their own app.
67
+ - Added `Portage::Ucp::Security::Signature` and
68
+ `Portage::Ucp::Rack::SignatureVerification` — verifies RFC 9421 HTTP
69
+ Message Signatures on inbound requests per UCP's signature spec
70
+ (`ucp.dev/2026-04-08/specification/signatures/`), the cryptographic
71
+ proof-of-consent piece design-log §22 named as the one genuine security
72
+ hole remaining before 1.0. Verify-before-parse, same posture as
73
+ `Rack::WebhookEndpoint`; trusted keys reuse `Manifest#signing_keys`'
74
+ current+next JWK-array shape rather than a second key config.
75
+ - Added `Confirmer::Webhook` (design-log §22 slice, Phase C) — out-of-band
76
+ approval for the confirmation gate, alongside `Terminal`/`AutoApprove`.
77
+ POSTs `{amount, currency, merchant, idempotency_key}` to a configured
78
+ URL, then polls a configured status endpoint (or calls a caller-supplied
79
+ `wait:` callback, for push-based transports) until approve/deny/timeout.
80
+ Fails closed on timeout, same as `Terminal`, but with its own longer
81
+ default timeout (900s vs. `Terminal`'s 120s) — a human already at a
82
+ terminal isn't the same wait as noticing and acting on a Slack message.
83
+ Built on `Support::HttpClient`, no new runtime dependency. Non-2xx
84
+ responses from the confirm/status calls themselves raise the new
85
+ `Confirmer::WebhookApiError`, distinct from `ConfirmationDeniedError`:
86
+ one means the out-of-band approver couldn't be reached, the other means
87
+ it was reached and said no (or never answered).
88
+
89
+ ## [0.5.0] - 2026-09-14
90
+
91
+ - Added `Adapter#create_payment_enrollment(idempotency_key:)` /
92
+ `#get_payment_enrollment(enrollment_id:)`, advertised as a new
93
+ `app.portage-ucp.payment_enrollment` capability — a Portage extension, not
94
+ part of the UCP spec. Starts a card-on-file enrollment without the card
95
+ ever touching this process: `#create_payment_enrollment` returns a
96
+ gateway-hosted `setup_url`, and the caller polls `#get_payment_enrollment`
97
+ until `status` leaves `"pending"` and a `payment_token` appears.
98
+ Implemented in `ReferenceAdapter` as a worked example (docs/plans/agentic-payments.md
99
+ Phase 1).
100
+ - Made the idempotency dedup store pluggable — `Support::Idempotency` now
101
+ takes a `store:` (defaulting to the existing in-memory behavior via the new
102
+ `MemoryStore`), with a `FileStore` alternative for dedup that survives a
103
+ process restart.
104
+ - Added a durable `Support::TransactionLog`, wired into
105
+ `Dispatcher#complete_checkout` dispatch — a transaction is reserved before
106
+ dispatch and marked settled/failed after, so a crash mid-charge leaves a
107
+ diagnosable record instead of silence.
108
+ - Extended `TransactionLog` to also record policy decisions and confirmation
109
+ outcomes on the same transaction record `PolicyGuard`/`Confirmer` gate.
110
+ - Added `PolicyGuard`, wired into `Dispatcher` just before `complete_checkout`
111
+ dispatch — enforces `Policy`'s per-transaction/rolling caps, velocity, and
112
+ merchant allowlist (docs/plans/agentic-payments.md Phase 2). Configured via
113
+ `portage-cli`'s `portage policy show/set`.
114
+ - Added per-token enrollment scopes to `Policy` — a merchant/max-amount/
115
+ currency scope can be bound to a token at enrollment time and is checked by
116
+ `PolicyGuard` keyed by the same `token_ref` derived from the token at
117
+ charge time.
118
+ - Added a `Confirmer` interface, wired into `Dispatcher` right after
119
+ `PolicyGuard.check!` passes and before `complete_checkout` dispatch.
120
+ `Confirmer::Terminal` blocks the process on stdin and fails closed on
121
+ anything but an explicit `"y"` (no answer, `"n"`, or EOF all deny);
122
+ `Confirmer::AutoApprove` is for specs/conformance kits (docs/plans/agentic-payments.md
123
+ Phase 3).
124
+ - Added a durable `Support::OrderLedger`, wired into the `complete_checkout`
125
+ settle path — written after the transaction record is already complete, so
126
+ a failed snapshot write surfaces without flipping an already-settled charge
127
+ to failed.
128
+ - `Mcp::Server.call_tool` now extracts `ucp-agent.profile` from `_meta` the
129
+ same way it already does `correlation_id` from `traceparent`, logging/
130
+ forwarding it as `agent_profile` through every transport (http, stdio,
131
+ loopback) and `Client::Session`. Additive only — `Dispatcher` accepts and
132
+ threads it without yet acting on it.
133
+ - Added `Adapter#lookup_catalog(ids:)`, advertised alongside `search_catalog`
134
+ — fetches several known product ids in one round trip instead of one
135
+ `get_product` call per id. Implemented in `Shopify::Adapter` via the Admin
136
+ API's `nodes(ids:)` field, reusing the same `Mapper.product` shape
137
+ `get_product`/`search_catalog` already use.
138
+
9
139
  ## [0.4.0] - 2026-08-28
10
140
 
11
141
  - Added `Adapter#reorder(order_id:, idempotency_key:)`, advertised as a new
data/README.md CHANGED
@@ -23,13 +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. |
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. |
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. |
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. |
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`. |
28
35
 
29
36
  Security defaults are all locked down, not permissive-by-omission —
30
37
  `UnconfiguredAuthenticator` rejects every mutating call until you configure a real
31
38
  one, `PaymentTokenGuard` rejects raw card numbers before they reach your `Adapter`,
32
- 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
33
42
  [Security hooks](https://github.com/tomtom87/Portage#security-hooks--nothing-is-permissive-by-default)
34
43
  section.
35
44
 
@@ -71,6 +80,18 @@ and the [detailed walkthrough](https://github.com/tomtom87/Portage/blob/main/doc
71
80
  for the full agent-side conversation, manifest/webhook Rack mounting, and a real
72
81
  adapter to model your own against.
73
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
+
74
95
  ## Checking any store
75
96
 
76
97
  ```bash
@@ -10,6 +10,11 @@ module Portage
10
10
  # get_cart/get_checkout/get_order.
11
11
  # @return [Portage::Ucp::ProductDetail, nil]
12
12
  def get_product(product_id:) = not_implemented
13
+ # Batch fetch — same result shape as search_catalog, for a caller that
14
+ # already has a set of product ids (e.g. hydrating a cart/order) and
15
+ # wants one round trip instead of N #get_product calls.
16
+ # @return [Portage::Ucp::CatalogSearchResult]
17
+ def lookup_catalog(product_ids:) = not_implemented
13
18
 
14
19
  # --- Cart (dev.ucp.shopping.cart) ---
15
20
  # Full-replacement semantics, matching UCP's real cart methods: create/
@@ -66,8 +71,16 @@ module Portage
66
71
  # than a generic/platform error, so callers can distinguish a stale-stock
67
72
  # failure from e.g. a declined payment.
68
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.
69
82
  # @return [Portage::Ucp::Checkout]
70
- 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
71
84
  # @return [Portage::Ucp::Checkout]
72
85
  def cancel_checkout(checkout_id:, idempotency_key:) = not_implemented
73
86
 
@@ -123,6 +136,63 @@ module Portage
123
136
  # @return [Portage::Ucp::Identity] linked profile for an exchanged OAuth token
124
137
  def link_identity(oauth_token:) = not_implemented
125
138
 
139
+ # --- Payment Enrollment (app.portage-ucp.payment_enrollment — Portage
140
+ # extension, not part of the UCP spec) ---
141
+ # Starts a card-on-file enrollment. Card data never touches this
142
+ # process: #create_payment_enrollment returns a `setup_url` for a
143
+ # gateway-hosted page where the human enters their card, and the
144
+ # caller polls #get_payment_enrollment until `status` leaves
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.
151
+ # @return [Portage::Ucp::PaymentEnrollment]
152
+ def create_payment_enrollment(idempotency_key:, mandate: nil) = not_implemented
153
+ # @return [Portage::Ucp::PaymentEnrollment, nil] nil if the enrollment isn't found
154
+ def get_payment_enrollment(enrollment_id:) = not_implemented
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
+
126
196
  private
127
197
 
128
198
  def not_implemented
@@ -0,0 +1,21 @@
1
+ module Portage
2
+ module Ucp
3
+ module Ap2
4
+ # A typed shape for an AP2 payment mandate (design-log §33/Phase B,
5
+ # citing design-log.md:1984-2117's already-confirmed AP2 gap) — the
6
+ # cart/intent authorization a shopper's agent presents alongside (or
7
+ # instead of) a bare `payment_token`. `amount`/`currency` mirror the
8
+ # minor-unit-integer convention `Total#amount` already uses elsewhere
9
+ # in this gem (see value_objects.rb); `signature` is carried opaquely
10
+ # — MandateGuard checks the mandate's *shape*, never the signature
11
+ # itself, since verifying it needs a trust anchor this gem doesn't
12
+ # have (see MandateGuard's own comment).
13
+ PaymentMandate = Data.define(:amount, :currency, :merchant, :expires_at, :signature) do
14
+ def to_wire_h
15
+ { "amount" => amount, "currency" => currency, "merchant" => merchant,
16
+ "expires_at" => expires_at, "signature" => signature }
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,34 @@
1
+ require "time"
2
+
3
+ module Portage
4
+ module Ucp
5
+ module Ap2
6
+ # Mandate-*shape* validation (design-log §33/Phase B) — required
7
+ # fields present and not expired. This is deliberately NOT
8
+ # cryptographic AP2 verification: `signature` is carried opaquely
9
+ # (see PaymentMandate) and this guard never inspects it. No key
10
+ # infrastructure or trust anchor exists in this repo to verify a
11
+ # signature against — that's a real external dependency and an
12
+ # explicit non-goal here, the same carve-out the existing plan draws
13
+ # around crypto-signing the confirmation payload. A real PSP adapter
14
+ # is expected to verify a mandate's signature against the issuing
15
+ # agent's trust anchor before ever handing it to this gem.
16
+ module MandateGuard
17
+ REQUIRED_FIELDS = %i[amount currency merchant expires_at signature].freeze
18
+
19
+ def self.validate!(mandate)
20
+ missing = REQUIRED_FIELDS.reject { |field| mandate.public_send(field) }
21
+ unless missing.empty?
22
+ raise Portage::Ucp::InvalidMandateError,
23
+ "payment mandate is missing required field(s): #{missing.join(', ')}"
24
+ end
25
+
26
+ return unless Time.now >= Time.parse(mandate.expires_at)
27
+
28
+ raise Portage::Ucp::InvalidMandateError,
29
+ "payment mandate expired at #{mandate.expires_at}"
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -4,7 +4,8 @@ module Portage
4
4
  CATALOG = Portage::Ucp::Capability.new(
5
5
  name: "dev.ucp.shopping.catalog",
6
6
  version: "1",
7
- actions: { "search_catalog" => :search_catalog, "get_product" => :get_product }
7
+ actions: { "search_catalog" => :search_catalog, "get_product" => :get_product,
8
+ "lookup_catalog" => :lookup_catalog }
8
9
  )
9
10
  end
10
11
  end
@@ -7,7 +7,8 @@ module Portage
7
7
  actions: { "link_identity" => :link_identity }
8
8
  )
9
9
 
10
- ALL = [CATALOG, CART, CHECKOUT, ORDER, IDENTITY_LINKING, DISCOUNT, FULFILLMENT, REORDER].freeze
10
+ ALL = [CATALOG, CART, CHECKOUT, ORDER, IDENTITY_LINKING, DISCOUNT, FULFILLMENT, REORDER,
11
+ PAYMENT_ENROLLMENT, PAYMENT_METHOD, SAVED_ADDRESS, SHOPPER_DATA].freeze
11
12
  end
12
13
  end
13
14
  end
@@ -0,0 +1,17 @@
1
+ module Portage
2
+ module Ucp
3
+ module Capabilities
4
+ # Portage-only extension, not part of the UCP spec (no ucp.dev
5
+ # reverse-domain meaning to borrow), same posture as Capabilities::REORDER.
6
+ # Advertised only if the adapter overrides Adapter#create_payment_enrollment
7
+ # — an adapter with no real gateway support for it simply never shows it
8
+ # in the manifest (see docs/plans/agentic-payments.md Phase 1).
9
+ PAYMENT_ENROLLMENT = Portage::Ucp::Capability.new(
10
+ name: "app.portage-ucp.payment_enrollment",
11
+ version: "1",
12
+ actions: { "create_payment_enrollment" => :create_payment_enrollment,
13
+ "get_payment_enrollment" => :get_payment_enrollment }
14
+ )
15
+ end
16
+ end
17
+ 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
@@ -0,0 +1,176 @@
1
+ require "timeout"
2
+ require "net/http"
3
+
4
+ module Portage
5
+ module Ucp
6
+ # Phase 3 (docs/plans/agentic-payments.md) — the last gate before a
7
+ # payment-completing dispatch, run by Dispatcher right after PolicyGuard
8
+ # passes. Transport-agnostic by design: Dispatcher only ever calls
9
+ # `#confirm!(amount:, currency:, merchant:, idempotency_key:)` on
10
+ # whatever object it's given, so a future WhatsApp/Slack confirmer is a
11
+ # separate gem implementing the same method, no core change required.
12
+ # `idempotency_key` rides along even though this phase's Terminal
13
+ # implementation ignores it — an async transport needs it to bind a
14
+ # reply back to the request that asked.
15
+ module Confirmer
16
+ # Blocks the CLI process on stdin: "Approve? [y/N]". Fail-closed — no
17
+ # answer within `timeout_seconds` denies, same as an explicit "n",
18
+ # never leaves a payment pending on an agent that's still waiting.
19
+ # Checkout staleness while the operator was thinking isn't handled
20
+ # here: the confirmed amount is re-validated by nothing in this class,
21
+ # but the adapter call `call_adapter` makes right after this passes
22
+ # hits the real backend, which is the thing that'd reject a checkout
23
+ # that expired mid-wait — no separate re-check needed.
24
+ class Terminal
25
+ DEFAULT_TIMEOUT_SECONDS = 120
26
+
27
+ def initialize(timeout_seconds: DEFAULT_TIMEOUT_SECONDS, input: $stdin, output: $stdout)
28
+ @timeout_seconds = timeout_seconds
29
+ @input = input
30
+ @output = output
31
+ end
32
+
33
+ # @raise [Portage::Ucp::ConfirmationDeniedError] on "n", timeout, or
34
+ # EOF (stdin closed out from under a headless run) — anything that
35
+ # isn't an explicit "y" denies.
36
+ # @return [Hash] `{approved: true}` on "y".
37
+ def confirm!(amount:, currency:, merchant:, idempotency_key:)
38
+ @output.print("Approve payment of #{amount} #{currency} to #{merchant.inspect}? [y/N] ")
39
+ @output.flush
40
+
41
+ answer = read_with_timeout
42
+ return { approved: true } if answer&.strip&.downcase == "y"
43
+
44
+ deny!(answer.nil? ? :timeout : :denied, idempotency_key)
45
+ end
46
+
47
+ private
48
+
49
+ def read_with_timeout
50
+ Timeout.timeout(@timeout_seconds) { @input.gets }
51
+ rescue Timeout::Error
52
+ nil
53
+ end
54
+
55
+ def deny!(reason, idempotency_key)
56
+ message = reason == :timeout ? "confirmation timed out after #{@timeout_seconds}s" : "confirmation denied"
57
+ raise Portage::Ucp::ConfirmationDeniedError.new(
58
+ message, reason: reason, decision: { approved: false, reason: reason, idempotency_key: idempotency_key }
59
+ )
60
+ end
61
+ end
62
+
63
+ # Always approves. For adapter conformance suites and Dispatcher specs
64
+ # that need a real `confirm!` call to return without blocking on
65
+ # stdin — never wire this into a real Dispatcher: confirmation
66
+ # defaulting to *on* is the thing that makes Phase 1's permanently-
67
+ # spendable stored token safe (see plan, "No arm step").
68
+ class AutoApprove
69
+ def confirm!(**)
70
+ { approved: true }
71
+ end
72
+ end
73
+
74
+ # Raised when the confirm or status HTTP call itself fails (non-2xx,
75
+ # not the 409 Support::HttpClient already normalizes to ConflictError)
76
+ # — a transport failure talking to the out-of-band approver, not a
77
+ # denial by it. Kept distinct from ConfirmationDeniedError: that one
78
+ # means "someone said no" or "no one answered in time," this one means
79
+ # "couldn't even ask."
80
+ class WebhookApiError < Portage::Ucp::Error
81
+ include Support::ApiError
82
+
83
+ private
84
+
85
+ def api_label
86
+ "Confirmer::Webhook"
87
+ end
88
+ end
89
+
90
+ # Out-of-band approval over plain HTTP: POST the request, then poll a
91
+ # status endpoint until the out-of-band channel (Slack, WhatsApp,
92
+ # whatever a caller wires up) records an answer. Core only ever speaks
93
+ # HTTP here — the actual notification transport is the caller's job,
94
+ # same "transport-agnostic by design" posture as this module's
95
+ # top-of-file comment.
96
+ #
97
+ # Deliberately its own timeout default, not Terminal::
98
+ # DEFAULT_TIMEOUT_SECONDS: 120s fits a human already at a keyboard, not
99
+ # someone who has to notice a Slack message and tap approve.
100
+ class Webhook
101
+ include Support::HttpClient
102
+
103
+ DEFAULT_TIMEOUT_SECONDS = 900
104
+ DEFAULT_POLL_INTERVAL_SECONDS = 5
105
+
106
+ # @param confirm_url [String] posted `{amount, currency, merchant,
107
+ # idempotency_key}` once, to kick off the out-of-band approval.
108
+ # @param status_url [String] polled (GET, `?idempotency_key=...`)
109
+ # for `{"status" => "approved" | "denied" | "pending"}` until it
110
+ # stops answering "pending" or `timeout_seconds` elapses.
111
+ # @param wait [#call, nil] escape hatch for push-based transports —
112
+ # when given, called with `idempotency_key` instead of polling,
113
+ # and must itself return `"approved"` or `"denied"` (blocking as
114
+ # long as it needs to; `timeout_seconds` isn't enforced around it,
115
+ # since a push transport is expected to enforce its own).
116
+ def initialize(confirm_url:, status_url:, timeout_seconds: DEFAULT_TIMEOUT_SECONDS,
117
+ poll_interval_seconds: DEFAULT_POLL_INTERVAL_SECONDS, headers: {}, wait: nil)
118
+ @confirm_url = confirm_url
119
+ @status_url = status_url
120
+ @timeout_seconds = timeout_seconds
121
+ @poll_interval_seconds = poll_interval_seconds
122
+ @headers = headers
123
+ @wait = wait
124
+ end
125
+
126
+ # @raise [Portage::Ucp::ConfirmationDeniedError] on an explicit
127
+ # deny, or on timeout (fail-closed, same as Terminal).
128
+ # @raise [Portage::Ucp::Confirmer::WebhookApiError] if the confirm
129
+ # or status HTTP call itself fails.
130
+ # @return [Hash] `{approved: true}` on approval.
131
+ def confirm!(amount:, currency:, merchant:, idempotency_key:)
132
+ json_request(Net::HTTP::Post, @confirm_url,
133
+ body: { amount: amount, currency: currency, merchant: merchant,
134
+ idempotency_key: idempotency_key },
135
+ headers: @headers)
136
+
137
+ status = @wait ? @wait.call(idempotency_key) : poll(idempotency_key)
138
+ return { approved: true } if status == "approved"
139
+
140
+ deny!(status == "denied" ? :denied : :timeout, idempotency_key)
141
+ end
142
+
143
+ private
144
+
145
+ def poll(idempotency_key)
146
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @timeout_seconds
147
+
148
+ loop do
149
+ response = json_request(Net::HTTP::Get, status_url_for(idempotency_key), headers: @headers)
150
+ return response["status"] if %w[approved denied].include?(response["status"])
151
+ return "timeout" if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
152
+
153
+ sleep(@poll_interval_seconds)
154
+ end
155
+ end
156
+
157
+ def status_url_for(idempotency_key)
158
+ uri = URI(@status_url)
159
+ uri.query = "idempotency_key=#{URI.encode_www_form_component(idempotency_key)}"
160
+ uri
161
+ end
162
+
163
+ def deny!(reason, idempotency_key)
164
+ message = reason == :timeout ? "confirmation timed out after #{@timeout_seconds}s" : "confirmation denied"
165
+ raise Portage::Ucp::ConfirmationDeniedError.new(
166
+ message, reason: reason, decision: { approved: false, reason: reason, idempotency_key: idempotency_key }
167
+ )
168
+ end
169
+
170
+ def api_error_class
171
+ WebhookApiError
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end