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