portage-ucp 0.5.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 +80 -0
- data/README.md +19 -2
- data/lib/portage/ucp/adapter.rb +55 -2
- data/lib/portage/ucp/ap2/mandate.rb +21 -0
- data/lib/portage/ucp/ap2/mandate_guard.rb +34 -0
- data/lib/portage/ucp/capabilities/identity_linking.rb +1 -1
- data/lib/portage/ucp/capabilities/payment_enrollment.rb +1 -1
- 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 +102 -0
- data/lib/portage/ucp/dispatcher.rb +65 -2
- data/lib/portage/ucp/errors.rb +13 -0
- data/lib/portage/ucp/observability.rb +1 -0
- data/lib/portage/ucp/payment_enrollment_guard.rb +44 -0
- data/lib/portage/ucp/rack/signature_verification.rb +74 -0
- data/lib/portage/ucp/reference_adapter.rb +86 -11
- data/lib/portage/ucp/rspec.rb +138 -0
- data/lib/portage/ucp/security/errors.rb +37 -0
- data/lib/portage/ucp/security/signature.rb +277 -0
- 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 +11 -43
- data/lib/portage/ucp/support/session_lock.rb +2 -2
- 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 +26 -78
- data/lib/portage/ucp/value_objects.rb +36 -2
- data/lib/portage/ucp/version.rb +1 -1
- data/lib/portage/ucp.rb +8 -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 +14 -2
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,86 @@ 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
|
+
|
|
9
89
|
## [0.5.0] - 2026-09-14
|
|
10
90
|
|
|
11
91
|
- Added `Adapter#create_payment_enrollment(idempotency_key:)` /
|
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
|
-
|
|
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
|
data/lib/portage/ucp/adapter.rb
CHANGED
|
@@ -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,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
|
|
@@ -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
|
-
|
|
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
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "timeout"
|
|
2
|
+
require "net/http"
|
|
2
3
|
|
|
3
4
|
module Portage
|
|
4
5
|
module Ucp
|
|
@@ -69,6 +70,107 @@ module Portage
|
|
|
69
70
|
{ approved: true }
|
|
70
71
|
end
|
|
71
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
|
|
72
174
|
end
|
|
73
175
|
end
|
|
74
176
|
end
|
|
@@ -29,9 +29,15 @@ module Portage
|
|
|
29
29
|
# store (Phase 1, docs/plans/order-ledger.md). Injectable for the
|
|
30
30
|
# same reason as `transaction_log` — defaults to the real
|
|
31
31
|
# `~/.portage/orders.json`.
|
|
32
|
+
# @param journal [#record_checkout, nil] optional buyer-side purchase
|
|
33
|
+
# journal (docs/plans/storage-abstraction-journal.md) — the
|
|
34
|
+
# `portage-ucp-journal` gem's `PurchaseJournal`, or anything
|
|
35
|
+
# duck-typed the same way. `nil` by default and never `require`d
|
|
36
|
+
# from core (§2: core stays dependency-light); a consumer wires
|
|
37
|
+
# one in from their own app after requiring that gem themselves.
|
|
32
38
|
def initialize(adapter:, registry: CapabilityRegistry.default, logger: Portage::Ucp.configuration.logger,
|
|
33
39
|
shop: nil, transaction_log: Support::TransactionLog.new, policy: Policy.load,
|
|
34
|
-
confirmer: Confirmer::Terminal.new, order_ledger: Support::OrderLedger.new)
|
|
40
|
+
confirmer: Confirmer::Terminal.new, order_ledger: Support::OrderLedger.new, journal: nil)
|
|
35
41
|
@adapter = adapter
|
|
36
42
|
@registry = registry
|
|
37
43
|
@logger = logger
|
|
@@ -40,6 +46,7 @@ module Portage
|
|
|
40
46
|
@policy = policy
|
|
41
47
|
@confirmer = confirmer
|
|
42
48
|
@order_ledger = order_ledger
|
|
49
|
+
@journal = journal
|
|
43
50
|
end
|
|
44
51
|
|
|
45
52
|
# @param correlation_id [String, nil] threaded through to the adapter
|
|
@@ -65,18 +72,37 @@ module Portage
|
|
|
65
72
|
method_name = capability_definition.actions[action]
|
|
66
73
|
raise UnknownActionError, action if method_name.nil?
|
|
67
74
|
|
|
68
|
-
|
|
75
|
+
validate_inbound_boundaries!(arguments)
|
|
69
76
|
|
|
70
77
|
result = if action == PAYMENT_COMPLETING_ACTION
|
|
71
78
|
call_and_log_transaction(method_name, arguments, correlation_id)
|
|
72
79
|
else
|
|
73
80
|
call_adapter(method_name, arguments, correlation_id)
|
|
74
81
|
end
|
|
82
|
+
validate_outbound_boundaries!(result)
|
|
75
83
|
wrap(capability, result)
|
|
76
84
|
end
|
|
77
85
|
|
|
78
86
|
private
|
|
79
87
|
|
|
88
|
+
# PaymentTokenGuard/Ap2::MandateGuard, run before any adapter method
|
|
89
|
+
# (design-log §9/§33) — whichever action carries the argument, not
|
|
90
|
+
# gated to complete_checkout, so misuse is caught at the one seam
|
|
91
|
+
# every call already passes through.
|
|
92
|
+
def validate_inbound_boundaries!(arguments)
|
|
93
|
+
Portage::Ucp::PaymentTokenGuard.validate!(arguments[:payment_token]) if arguments.key?(:payment_token)
|
|
94
|
+
Portage::Ucp::Ap2::MandateGuard.validate!(arguments[:mandate]) if arguments[:mandate]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Outbound counterpart to the above (design-log §33) — every adapter's
|
|
98
|
+
# create_payment_enrollment/get_payment_enrollment result is held to
|
|
99
|
+
# PaymentEnrollmentGuard here, not just ReferenceAdapter's own.
|
|
100
|
+
def validate_outbound_boundaries!(result)
|
|
101
|
+
return unless result.is_a?(Portage::Ucp::PaymentEnrollment)
|
|
102
|
+
|
|
103
|
+
Portage::Ucp::PaymentEnrollmentGuard.validate!(result)
|
|
104
|
+
end
|
|
105
|
+
|
|
80
106
|
# Reserve-then-commit around the one action that dispatches a charge:
|
|
81
107
|
# the `pending` record lands *before* `call_adapter` runs, so a crash
|
|
82
108
|
# during the adapter's own gateway round-trip leaves that record
|
|
@@ -110,6 +136,16 @@ module Portage
|
|
|
110
136
|
# checkout produces an order (e.g. cart-only flows), so skip
|
|
111
137
|
# silently when absent.
|
|
112
138
|
@order_ledger.record(idempotency_key: idempotency_key, order: result.order) if result.order
|
|
139
|
+
|
|
140
|
+
# Same after-settle, outside-the-rescue posture as the order_ledger
|
|
141
|
+
# write immediately above, for the same reason: the money has
|
|
142
|
+
# already moved, so a lost journal write must never flip a settled
|
|
143
|
+
# charge to `failed`. @journal is nil unless a consumer opted in
|
|
144
|
+
# (see #initialize) — never required from core.
|
|
145
|
+
if @journal && result.order
|
|
146
|
+
@journal.record_checkout(shop: @shop, source: journal_source(@adapter), checkout: result,
|
|
147
|
+
idempotency_key: idempotency_key)
|
|
148
|
+
end
|
|
113
149
|
result
|
|
114
150
|
end
|
|
115
151
|
|
|
@@ -180,6 +216,20 @@ module Portage
|
|
|
180
216
|
result.respond_to?(:currency) ? result.currency : nil
|
|
181
217
|
end
|
|
182
218
|
|
|
219
|
+
# "native_ucp" vs "adapter:<platform>" (design-log §22) — Dispatcher
|
|
220
|
+
# has no separate platform-identity concept of its own, so this reads
|
|
221
|
+
# it off the adapter's class: the in-repo ReferenceAdapter (and any
|
|
222
|
+
# anonymous/no-name adapter, e.g. a test double) counts as native_ucp,
|
|
223
|
+
# every namespaced adapter (Portage::Ucp::Shopify::Adapter, ...)
|
|
224
|
+
# reports its namespace.
|
|
225
|
+
def journal_source(adapter)
|
|
226
|
+
name = adapter.class.name
|
|
227
|
+
return "native_ucp" if name.nil? || name == "Portage::Ucp::ReferenceAdapter"
|
|
228
|
+
|
|
229
|
+
platform = name.split("::")[-2]
|
|
230
|
+
"adapter:#{(platform || name).downcase}"
|
|
231
|
+
end
|
|
232
|
+
|
|
183
233
|
def call_adapter(method_name, arguments, correlation_id)
|
|
184
234
|
unless @adapter.is_a?(Portage::Ucp::Support::CheckoutState)
|
|
185
235
|
return @adapter.public_send(method_name, **arguments)
|
|
@@ -190,7 +240,15 @@ module Portage
|
|
|
190
240
|
end
|
|
191
241
|
end
|
|
192
242
|
|
|
243
|
+
# Every existing action returns either a single to_wire_h-capable
|
|
244
|
+
# object or a bare Array of plain values (never a Data object) — the
|
|
245
|
+
# new list_payment_methods/list_addresses are the first actions to
|
|
246
|
+
# return Array<to_wire_h-capable>, which needs its own branch here:
|
|
247
|
+
# left to the plain `result.inspect` fallback below, a Data-object
|
|
248
|
+
# array would reach structuredContent unserialized to JSON.
|
|
193
249
|
def wrap(capability_name, result)
|
|
250
|
+
return wrap_list(result) if result.is_a?(Array) && result.all? { |item| item.respond_to?(:to_wire_h) }
|
|
251
|
+
|
|
194
252
|
unless result.respond_to?(:to_wire_h)
|
|
195
253
|
return { content: [{ type: "text", text: result.inspect }], structuredContent: result }
|
|
196
254
|
end
|
|
@@ -198,6 +256,11 @@ module Portage
|
|
|
198
256
|
payload = Portage::Ucp::WireEnvelope.wrap(capability_name, result.to_wire_h)
|
|
199
257
|
{ content: [{ type: "text", text: payload.inspect }], structuredContent: payload }
|
|
200
258
|
end
|
|
259
|
+
|
|
260
|
+
def wrap_list(result)
|
|
261
|
+
payload = result.map(&:to_wire_h)
|
|
262
|
+
{ content: [{ type: "text", text: payload.inspect }], structuredContent: payload }
|
|
263
|
+
end
|
|
201
264
|
end
|
|
202
265
|
end
|
|
203
266
|
end
|
data/lib/portage/ucp/errors.rb
CHANGED
|
@@ -7,6 +7,19 @@ module Portage
|
|
|
7
7
|
class CapabilityNotAdvertisedError < Error; end
|
|
8
8
|
class AuthenticationError < Error; end
|
|
9
9
|
class RawPanRejectedError < Error; end
|
|
10
|
+
# Raised by PaymentEnrollmentGuard.validate! (design-log §33/Phase B)
|
|
11
|
+
# when an Adapter's create_payment_enrollment/get_payment_enrollment
|
|
12
|
+
# result doesn't hold the shape `PaymentEnrollment` itself never
|
|
13
|
+
# enforces — value_objects.rb has no validation of any kind, same
|
|
14
|
+
# raise-free posture as every other Data.define there, so this is the
|
|
15
|
+
# guard's own error, not value_objects.rb's.
|
|
16
|
+
class InvalidPaymentEnrollmentError < Error; end
|
|
17
|
+
# Raised by Ap2::MandateGuard.validate! (design-log §33/Phase B) — a
|
|
18
|
+
# mandate that's expired or missing a required field. Mandate-shape
|
|
19
|
+
# validation only, not cryptographic AP2 verification: no key
|
|
20
|
+
# infrastructure or trust anchor exists in this repo to verify a
|
|
21
|
+
# mandate's signature against (see Ap2::MandateGuard's own comment).
|
|
22
|
+
class InvalidMandateError < Error; end
|
|
10
23
|
class RateLimitExceededError < Error; end
|
|
11
24
|
# Raised by #complete_checkout when the platform rejects completion
|
|
12
25
|
# because a line item is out of stock or otherwise unavailable —
|