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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +130 -0
- data/README.md +22 -1
- data/lib/portage/ucp/adapter.rb +71 -1
- data/lib/portage/ucp/ap2/mandate.rb +21 -0
- data/lib/portage/ucp/ap2/mandate_guard.rb +34 -0
- data/lib/portage/ucp/capabilities/catalog.rb +2 -1
- data/lib/portage/ucp/capabilities/identity_linking.rb +2 -1
- data/lib/portage/ucp/capabilities/payment_enrollment.rb +17 -0
- data/lib/portage/ucp/capabilities/payment_method.rb +28 -0
- data/lib/portage/ucp/capabilities/shopper_data.rb +15 -0
- data/lib/portage/ucp/confirmer.rb +176 -0
- data/lib/portage/ucp/dispatcher.rb +212 -4
- data/lib/portage/ucp/errors.rb +48 -0
- data/lib/portage/ucp/mcp/server.rb +20 -7
- data/lib/portage/ucp/observability.rb +1 -0
- data/lib/portage/ucp/payment_enrollment_guard.rb +44 -0
- data/lib/portage/ucp/policy.rb +79 -0
- data/lib/portage/ucp/policy_guard.rb +152 -0
- data/lib/portage/ucp/rack/signature_verification.rb +74 -0
- data/lib/portage/ucp/reference_adapter.rb +107 -1
- data/lib/portage/ucp/rspec.rb +164 -3
- data/lib/portage/ucp/security/errors.rb +37 -0
- data/lib/portage/ucp/security/signature.rb +277 -0
- data/lib/portage/ucp/support/idempotency/file_store.rb +78 -0
- data/lib/portage/ucp/support/idempotency/memory_store.rb +34 -0
- data/lib/portage/ucp/support/idempotency.rb +34 -8
- data/lib/portage/ucp/support/order_ledger/file_store.rb +69 -0
- data/lib/portage/ucp/support/order_ledger/store.rb +23 -0
- data/lib/portage/ucp/support/order_ledger.rb +47 -0
- data/lib/portage/ucp/support/session_lock.rb +2 -2
- data/lib/portage/ucp/support/token_ref.rb +20 -0
- data/lib/portage/ucp/support/transaction_log/file_store.rb +112 -0
- data/lib/portage/ucp/support/transaction_log/store.rb +54 -0
- data/lib/portage/ucp/support/transaction_log.rb +108 -0
- data/lib/portage/ucp/value_objects.rb +55 -0
- data/lib/portage/ucp/version.rb +1 -1
- data/lib/portage/ucp.rb +21 -0
- data/schemas/2026-04-08/schemas/shopping/checkout.json +1 -1
- data/schemas/2026-04-08/schemas/shopping/types/link.json +1 -1
- metadata +28 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f8228c4a5a32d4ed322103ebc5061bf3179700fae68cc414fd0e53a8cb7f900e
|
|
4
|
+
data.tar.gz: dfc1f273f7fc79a8af633d4341af409a1447550c56a2fb0e8c7d4301b85c65c4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
data/lib/portage/ucp/adapter.rb
CHANGED
|
@@ -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
|
|
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
|