portage-ucp 0.6.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 +63 -1
- 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/ap2/mandate.rb +33 -6
- data/lib/portage/ucp/ap2/mandate_guard.rb +28 -14
- data/lib/portage/ucp/ap2/mandate_signature.rb +148 -0
- data/lib/portage/ucp/configuration.rb +37 -1
- data/lib/portage/ucp/dispatcher.rb +24 -2
- data/lib/portage/ucp/errors.rb +5 -4
- data/lib/portage/ucp/mcp/server.rb +10 -2
- data/lib/portage/ucp/observability.rb +41 -1
- data/lib/portage/ucp/railtie.rb +14 -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 +6 -0
- data/lib/portage/ucp/support/order_ledger/store.rb +7 -0
- data/lib/portage/ucp/support/order_ledger.rb +8 -0
- data/lib/portage/ucp/support/session_lock.rb +29 -3
- data/lib/portage/ucp/support/transaction_log/file_store.rb +6 -0
- data/lib/portage/ucp/support/transaction_log/store.rb +9 -0
- data/lib/portage/ucp/support/transaction_log.rb +8 -0
- data/lib/portage/ucp/version.rb +1 -1
- data/lib/portage/ucp.rb +5 -0
- metadata +6 -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,69 @@ 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.
|
|
8
70
|
|
|
9
71
|
## [0.6.0] - 2026-09-15
|
|
10
72
|
|
|
@@ -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
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
1
3
|
module Portage
|
|
2
4
|
module Ucp
|
|
3
5
|
module Ap2
|
|
@@ -6,14 +8,39 @@ module Portage
|
|
|
6
8
|
# cart/intent authorization a shopper's agent presents alongside (or
|
|
7
9
|
# instead of) a bare `payment_token`. `amount`/`currency` mirror the
|
|
8
10
|
# minor-unit-integer convention `Total#amount` already uses elsewhere
|
|
9
|
-
# in this gem (see value_objects.rb)
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
|
|
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
|
+
|
|
14
27
|
def to_wire_h
|
|
15
28
|
{ "amount" => amount, "currency" => currency, "merchant" => merchant,
|
|
16
|
-
"expires_at" => expires_at, "signature" => signature }
|
|
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
|
+
)
|
|
17
44
|
end
|
|
18
45
|
end
|
|
19
46
|
end
|
|
@@ -3,30 +3,44 @@ require "time"
|
|
|
3
3
|
module Portage
|
|
4
4
|
module Ucp
|
|
5
5
|
module Ap2
|
|
6
|
-
# Mandate
|
|
7
|
-
#
|
|
8
|
-
# cryptographic
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
# agent's
|
|
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`.
|
|
16
18
|
module MandateGuard
|
|
17
19
|
REQUIRED_FIELDS = %i[amount currency merchant expires_at signature].freeze
|
|
18
20
|
|
|
19
|
-
|
|
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)
|
|
20
27
|
missing = REQUIRED_FIELDS.reject { |field| mandate.public_send(field) }
|
|
21
28
|
unless missing.empty?
|
|
22
29
|
raise Portage::Ucp::InvalidMandateError,
|
|
23
30
|
"payment mandate is missing required field(s): #{missing.join(', ')}"
|
|
24
31
|
end
|
|
25
32
|
|
|
26
|
-
|
|
33
|
+
if Time.now >= Time.parse(mandate.expires_at)
|
|
34
|
+
raise Portage::Ucp::InvalidMandateError,
|
|
35
|
+
"payment mandate expired at #{mandate.expires_at}"
|
|
36
|
+
end
|
|
27
37
|
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
30
44
|
end
|
|
31
45
|
end
|
|
32
46
|
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
|
|
@@ -9,7 +9,42 @@ module Portage
|
|
|
9
9
|
# through every call site.
|
|
10
10
|
class Configuration
|
|
11
11
|
attr_accessor :registry, :authenticator, :rate_limiter, :logger,
|
|
12
|
-
:business, :signer, :payment_handlers, :signing_keys, :services
|
|
12
|
+
:business, :signer, :payment_handlers, :signing_keys, :services,
|
|
13
|
+
:mandate_trusted_keys, :require_mandate_signature
|
|
14
|
+
|
|
15
|
+
# Unset by default — the gem has no OTel dependency of its own
|
|
16
|
+
# (Observability's JSON-to-Logger path needs nothing else). Set to
|
|
17
|
+
# anything responding to #in_span(name, attributes:) to also emit a
|
|
18
|
+
# span per Observability.log call, e.g.
|
|
19
|
+
# `Portage::Ucp.configuration.tracer = OpenTelemetry.tracer_provider.tracer("portage-ucp")`.
|
|
20
|
+
attr_accessor :tracer
|
|
21
|
+
|
|
22
|
+
# idempotency_provider has no default set here, unlike the other
|
|
23
|
+
# collaborators above — Support::Idempotency#idempotency_store falls
|
|
24
|
+
# back to a fresh per-instance MemoryStore when this is unset, so
|
|
25
|
+
# setting it is an opt-in to a shared/process-wide store rather than
|
|
26
|
+
# a default every adapter instance would otherwise get for free.
|
|
27
|
+
#
|
|
28
|
+
# WARNING: Support::Idempotency::FileStore#fetch_or_store holds one
|
|
29
|
+
# flock across the *entire* dedup'd call, including any network I/O
|
|
30
|
+
# the block does. That's fine for the single-host CLI it's documented
|
|
31
|
+
# for (one process at a time, effectively), but this attribute is
|
|
32
|
+
# process-wide Configuration — set FileStore here in a multi-request
|
|
33
|
+
# server and every checkout across every request/thread serializes
|
|
34
|
+
# behind that one lock. FileStore also has no TTL/eviction: the
|
|
35
|
+
# backing file grows forever, one entry per idempotency key ever
|
|
36
|
+
# seen. Don't set this to FileStore outside a short-lived CLI
|
|
37
|
+
# process; a long-lived server needs a store with per-key locking
|
|
38
|
+
# and eviction instead.
|
|
39
|
+
attr_accessor :idempotency_provider
|
|
40
|
+
|
|
41
|
+
# mandate_trusted_keys is likewise unset by default (see
|
|
42
|
+
# Dispatcher's mandate_trust_keys doc / Ap2::MandateGuard) — this gem
|
|
43
|
+
# has no AP2 issuer keys of its own. require_mandate_signature
|
|
44
|
+
# defaults to false so an unconfigured Dispatcher keeps today's
|
|
45
|
+
# shape-only posture; a caller flips it on to make MandateGuard
|
|
46
|
+
# raise instead of silently skipping crypto when trusted_keys
|
|
47
|
+
# resolves to nil (design-log §33 fail-closed option).
|
|
13
48
|
|
|
14
49
|
def initialize
|
|
15
50
|
@registry = CapabilityRegistry.default
|
|
@@ -19,6 +54,7 @@ module Portage
|
|
|
19
54
|
@payment_handlers = []
|
|
20
55
|
@signing_keys = []
|
|
21
56
|
@services = []
|
|
57
|
+
@require_mandate_signature = false
|
|
22
58
|
end
|
|
23
59
|
end
|
|
24
60
|
|
|
@@ -35,9 +35,26 @@ module Portage
|
|
|
35
35
|
# duck-typed the same way. `nil` by default and never `require`d
|
|
36
36
|
# from core (§2: core stays dependency-light); a consumer wires
|
|
37
37
|
# one in from their own app after requiring that gem themselves.
|
|
38
|
+
# @param mandate_trust_keys [Array<Hash>, #call, nil] forwarded to
|
|
39
|
+
# Ap2::MandateGuard.validate! as `trusted_keys:`. Defaults to
|
|
40
|
+
# Configuration#mandate_trusted_keys, which itself has no default —
|
|
41
|
+
# same reasoning as `journal`: this gem has no AP2 issuer keys of
|
|
42
|
+
# its own to default to, so an unconfigured Dispatcher gets
|
|
43
|
+
# shape-only mandate validation, not a silent skip of crypto a
|
|
44
|
+
# caller thought was on. A caller with a real trust anchor (a PSP
|
|
45
|
+
# adapter's own key store, or a resolver against the issuing
|
|
46
|
+
# agent's manifest) passes it here to get Ap2::MandateSignature's
|
|
47
|
+
# cryptographic check on every dispatch that carries a `mandate`.
|
|
48
|
+
# @param require_mandate_signature [Boolean] forwarded to
|
|
49
|
+
# Ap2::MandateGuard.validate! as `require_signature:`. Defaults to
|
|
50
|
+
# Configuration#require_mandate_signature (false) — set true to
|
|
51
|
+
# make a dispatch with a `mandate` but no resolvable trust anchor
|
|
52
|
+
# raise InvalidMandateError instead of downgrading to shape-only.
|
|
38
53
|
def initialize(adapter:, registry: CapabilityRegistry.default, logger: Portage::Ucp.configuration.logger,
|
|
39
54
|
shop: nil, transaction_log: Support::TransactionLog.new, policy: Policy.load,
|
|
40
|
-
confirmer: Confirmer::Terminal.new, order_ledger: Support::OrderLedger.new, journal: nil
|
|
55
|
+
confirmer: Confirmer::Terminal.new, order_ledger: Support::OrderLedger.new, journal: nil,
|
|
56
|
+
mandate_trust_keys: Portage::Ucp.configuration.mandate_trusted_keys,
|
|
57
|
+
require_mandate_signature: Portage::Ucp.configuration.require_mandate_signature)
|
|
41
58
|
@adapter = adapter
|
|
42
59
|
@registry = registry
|
|
43
60
|
@logger = logger
|
|
@@ -47,6 +64,8 @@ module Portage
|
|
|
47
64
|
@confirmer = confirmer
|
|
48
65
|
@order_ledger = order_ledger
|
|
49
66
|
@journal = journal
|
|
67
|
+
@mandate_trust_keys = mandate_trust_keys
|
|
68
|
+
@require_mandate_signature = require_mandate_signature
|
|
50
69
|
end
|
|
51
70
|
|
|
52
71
|
# @param correlation_id [String, nil] threaded through to the adapter
|
|
@@ -91,7 +110,10 @@ module Portage
|
|
|
91
110
|
# every call already passes through.
|
|
92
111
|
def validate_inbound_boundaries!(arguments)
|
|
93
112
|
Portage::Ucp::PaymentTokenGuard.validate!(arguments[:payment_token]) if arguments.key?(:payment_token)
|
|
94
|
-
|
|
113
|
+
return unless arguments[:mandate]
|
|
114
|
+
|
|
115
|
+
Portage::Ucp::Ap2::MandateGuard.validate!(arguments[:mandate], trusted_keys: @mandate_trust_keys,
|
|
116
|
+
require_signature: @require_mandate_signature)
|
|
95
117
|
end
|
|
96
118
|
|
|
97
119
|
# Outbound counterpart to the above (design-log §33) — every adapter's
|
data/lib/portage/ucp/errors.rb
CHANGED
|
@@ -15,10 +15,11 @@ module Portage
|
|
|
15
15
|
# guard's own error, not value_objects.rb's.
|
|
16
16
|
class InvalidPaymentEnrollmentError < Error; end
|
|
17
17
|
# Raised by Ap2::MandateGuard.validate! (design-log §33/Phase B) — a
|
|
18
|
-
# mandate that's expired
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
18
|
+
# mandate that's expired, missing a required field, or (when the caller
|
|
19
|
+
# supplies a `trusted_keys:` trust anchor) fails cryptographic
|
|
20
|
+
# verification via Ap2::MandateSignature. Shape-only vs shape+crypto is
|
|
21
|
+
# the caller's choice, not this error's — see Ap2::MandateGuard's own
|
|
22
|
+
# comment.
|
|
22
23
|
class InvalidMandateError < Error; end
|
|
23
24
|
class RateLimitExceededError < Error; end
|
|
24
25
|
# Raised by #complete_checkout when the platform rejects completion
|
|
@@ -13,12 +13,20 @@ module Portage
|
|
|
13
13
|
# doesn't need one keyword argument per collaborator.
|
|
14
14
|
Context = Struct.new(:dispatcher, :authenticator, :rate_limiter, :logger, keyword_init: true)
|
|
15
15
|
|
|
16
|
+
# @param journal [#record_checkout, nil] forwarded straight to
|
|
17
|
+
# `Dispatcher.new` (see its own doc comment) — `nil` by default and
|
|
18
|
+
# never `require`d from core (§2), same as `Dispatcher` itself.
|
|
19
|
+
# Named here explicitly, rather than left to fall through
|
|
20
|
+
# `**server_opts`, so a consumer wiring a real journal through
|
|
21
|
+
# `Client.for_adapter`/`Loopback` (both already forward
|
|
22
|
+
# `**server_opts` here) has a documented seam to do it at (§37).
|
|
16
23
|
def self.build(adapter:, registry: Portage::Ucp.configuration.registry,
|
|
17
24
|
authenticator: Portage::Ucp.configuration.authenticator,
|
|
18
25
|
rate_limiter: Portage::Ucp.configuration.rate_limiter,
|
|
19
|
-
logger: Portage::Ucp.configuration.logger, **server_opts)
|
|
26
|
+
logger: Portage::Ucp.configuration.logger, journal: nil, **server_opts)
|
|
20
27
|
context = Context.new(
|
|
21
|
-
dispatcher: Portage::Ucp::Dispatcher.new(adapter: adapter, registry: registry, logger: logger
|
|
28
|
+
dispatcher: Portage::Ucp::Dispatcher.new(adapter: adapter, registry: registry, logger: logger,
|
|
29
|
+
journal: journal),
|
|
22
30
|
authenticator: authenticator, rate_limiter: rate_limiter, logger: logger
|
|
23
31
|
)
|
|
24
32
|
tools = registry.advertised(adapter).flat_map do |capability|
|
|
@@ -23,7 +23,9 @@ module Portage
|
|
|
23
23
|
REDACTED = "[REDACTED]".freeze
|
|
24
24
|
|
|
25
25
|
def self.log(logger, event, **fields)
|
|
26
|
-
|
|
26
|
+
redacted = redact(fields)
|
|
27
|
+
logger.info(JSON.generate({ event: event }.merge(redacted)))
|
|
28
|
+
emit_span(event, redacted)
|
|
27
29
|
end
|
|
28
30
|
|
|
29
31
|
def self.redact(value)
|
|
@@ -36,6 +38,44 @@ module Portage
|
|
|
36
38
|
value
|
|
37
39
|
end
|
|
38
40
|
end
|
|
41
|
+
|
|
42
|
+
# Opt-in only — no OTel dependency in the gemspec, so this never fires
|
|
43
|
+
# unless a consumer sets Configuration#tracer to something responding
|
|
44
|
+
# to #in_span(name, attributes:), e.g.
|
|
45
|
+
# `OpenTelemetry.tracer_provider.tracer("portage-ucp")`. The JSON-to-
|
|
46
|
+
# Logger path above is unconditional and unaffected either way — this
|
|
47
|
+
# is an additional emitter over the same already-redacted event set,
|
|
48
|
+
# not a replacement for it.
|
|
49
|
+
def self.emit_span(event, fields)
|
|
50
|
+
tracer = Portage::Ucp.configuration.tracer
|
|
51
|
+
return unless tracer
|
|
52
|
+
|
|
53
|
+
# The span exists to record that this event happened, not to wrap
|
|
54
|
+
# any work of its own — #log is a fire-and-forget instrumentation
|
|
55
|
+
# call, so there's nothing to put in the block.
|
|
56
|
+
tracer.in_span(event, attributes: flatten_attributes(fields)) {} # rubocop:disable Lint/EmptyBlock
|
|
57
|
+
end
|
|
58
|
+
private_class_method :emit_span
|
|
59
|
+
|
|
60
|
+
# OTel span attributes must be a flat String => String/Numeric/Boolean
|
|
61
|
+
# (or homogeneous array thereof) map — nested hashes aren't valid, so
|
|
62
|
+
# this dot-joins nested keys instead of shipping them as-is.
|
|
63
|
+
def self.flatten_attributes(fields, prefix = nil)
|
|
64
|
+
fields.each_with_object({}) do |(key, value), attrs|
|
|
65
|
+
full_key = prefix ? "#{prefix}.#{key}" : key.to_s
|
|
66
|
+
case value
|
|
67
|
+
when Hash
|
|
68
|
+
attrs.merge!(flatten_attributes(value, full_key))
|
|
69
|
+
when Array
|
|
70
|
+
attrs[full_key] = value.map(&:to_s)
|
|
71
|
+
when nil
|
|
72
|
+
next
|
|
73
|
+
else
|
|
74
|
+
attrs[full_key] = value
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
private_class_method :flatten_attributes
|
|
39
79
|
end
|
|
40
80
|
end
|
|
41
81
|
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Ucp
|
|
3
|
+
# Registers `rails g portage:ucp:install` with a host Rails app. Only
|
|
4
|
+
# loaded when Rails is already present (see the guarded require at the
|
|
5
|
+
# bottom of ucp.rb) — this gem has no Rails dependency of its own (it's
|
|
6
|
+
# the protocol-only core, per the gemspec), so Rails integration is
|
|
7
|
+
# opt-in via whatever the host app already bundles.
|
|
8
|
+
class Railtie < ::Rails::Railtie
|
|
9
|
+
generators do
|
|
10
|
+
require "generators/portage/ucp/install/install_generator"
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -6,9 +6,16 @@ module Portage
|
|
|
6
6
|
module Idempotency
|
|
7
7
|
# Cross-process, cross-restart store for the common CLI case: one
|
|
8
8
|
# host, potentially many `portage` invocations, no shared server to
|
|
9
|
-
# hold an in-memory table for them. A
|
|
10
|
-
# stands in for that —
|
|
11
|
-
#
|
|
9
|
+
# hold an in-memory table for them. A dedicated `.lock` file under
|
|
10
|
+
# `flock` stands in for that — see `with_lock` for why it's a
|
|
11
|
+
# separate file from the data file. `#fetch`/`#store` each acquire
|
|
12
|
+
# the lock for their own call only, so pairing them (the old
|
|
13
|
+
# `Idempotency#dedup` shape) leaves a window between the two
|
|
14
|
+
# acquisitions where a second process can also read NOT_FOUND and
|
|
15
|
+
# also run the mutation — dedup now goes through `#fetch_or_store`
|
|
16
|
+
# instead, which holds one lock across the whole check-then-set and
|
|
17
|
+
# is what actually gives two processes racing on the same key the
|
|
18
|
+
# guarantee that only one of them runs it.
|
|
12
19
|
#
|
|
13
20
|
# Marshal, not JSON: dedup'd return values are arbitrary adapter
|
|
14
21
|
# domain objects (Data.define value objects, nested structures) that
|
|
@@ -24,37 +31,67 @@ module Portage
|
|
|
24
31
|
class FileStore
|
|
25
32
|
def initialize(path: File.join(Dir.home, ".portage", "idempotency.marshal"))
|
|
26
33
|
@path = path
|
|
34
|
+
@lock_path = "#{path}.lock"
|
|
27
35
|
end
|
|
28
36
|
|
|
29
37
|
def fetch(key)
|
|
30
|
-
with_lock(File::LOCK_SH) { |data
|
|
38
|
+
with_lock(File::LOCK_SH) { |data| data.key?(key) ? data[key] : NOT_FOUND }
|
|
31
39
|
end
|
|
32
40
|
|
|
33
41
|
def store(key, value)
|
|
34
|
-
with_lock(File::LOCK_EX) do |data
|
|
42
|
+
with_lock(File::LOCK_EX) do |data|
|
|
35
43
|
data[key] = value
|
|
36
|
-
persist(data
|
|
44
|
+
persist(data)
|
|
37
45
|
end
|
|
38
46
|
value
|
|
39
47
|
end
|
|
40
48
|
|
|
41
49
|
def include?(key)
|
|
42
|
-
with_lock(File::LOCK_SH) { |data
|
|
50
|
+
with_lock(File::LOCK_SH) { |data| data.key?(key) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Atomic check-then-set, unlike calling `fetch` then `store`: those
|
|
54
|
+
# are two separate `with_lock` acquisitions, so the shared lock is
|
|
55
|
+
# released between them and a second process can read NOT_FOUND in
|
|
56
|
+
# the gap and run the same mutation — the exact double-charge §9a
|
|
57
|
+
# exists to prevent. Holding one `LOCK_EX` across the read, the
|
|
58
|
+
# block (the actual mutation), and the write closes that window.
|
|
59
|
+
# Coarser-grained than a per-key lock — this blocks *every* key on
|
|
60
|
+
# the file while one mutation is in flight, not just this one — but
|
|
61
|
+
# correctness beats throughput for a single-host CLI process, and
|
|
62
|
+
# that's the case this store is documented for.
|
|
63
|
+
def fetch_or_store(key)
|
|
64
|
+
with_lock(File::LOCK_EX) do |data|
|
|
65
|
+
next data[key] if data.key?(key)
|
|
66
|
+
|
|
67
|
+
value = yield
|
|
68
|
+
data[key] = value
|
|
69
|
+
persist(data)
|
|
70
|
+
value
|
|
71
|
+
end
|
|
43
72
|
end
|
|
44
73
|
|
|
45
74
|
private
|
|
46
75
|
|
|
76
|
+
# Locks a sidecar `.lock` file rather than `@path` itself, because
|
|
77
|
+
# `persist` below replaces `@path` via rename to avoid ever leaving
|
|
78
|
+
# a torn write on disk. A lock held on the data file's inode
|
|
79
|
+
# wouldn't survive that swap — a second process already blocked on
|
|
80
|
+
# `flock` for the old inode would wake up into a stale, pre-write
|
|
81
|
+
# view instead of being serialized after the write. The lock file
|
|
82
|
+
# is never renamed, so it stays a stable thing to serialize on.
|
|
47
83
|
def with_lock(lock_mode)
|
|
48
84
|
FileUtils.mkdir_p(File.dirname(@path))
|
|
49
|
-
File.open(@
|
|
50
|
-
|
|
51
|
-
yield(read
|
|
85
|
+
File.open(@lock_path, File::RDWR | File::CREAT, 0o600) do |lock_file|
|
|
86
|
+
lock_file.flock(lock_mode)
|
|
87
|
+
yield(read)
|
|
52
88
|
end
|
|
53
89
|
end
|
|
54
90
|
|
|
55
|
-
def read
|
|
56
|
-
|
|
57
|
-
|
|
91
|
+
def read
|
|
92
|
+
return {} unless File.exist?(@path)
|
|
93
|
+
|
|
94
|
+
raw = File.read(@path)
|
|
58
95
|
return {} if raw.empty?
|
|
59
96
|
|
|
60
97
|
# rubocop:disable Security/MarshalLoad -- this file is written only
|
|
@@ -62,13 +99,28 @@ module Portage
|
|
|
62
99
|
# exposure); see the class comment on the trust boundary.
|
|
63
100
|
Marshal.load(raw)
|
|
64
101
|
# rubocop:enable Security/MarshalLoad
|
|
102
|
+
rescue StandardError
|
|
103
|
+
# A truncated write (crash mid-persist) or a renamed/removed class
|
|
104
|
+
# (Marshal replays the dumped class name) both poison this file
|
|
105
|
+
# forever without a rescue here — every future `portage`
|
|
106
|
+
# invocation would raise just from opening the store. Treat
|
|
107
|
+
# either as an empty store, same posture as OrderLedger::FileStore
|
|
108
|
+
# rescuing JSON::ParserError.
|
|
109
|
+
{}
|
|
65
110
|
end
|
|
66
111
|
|
|
67
|
-
def persist(data
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
112
|
+
def persist(data)
|
|
113
|
+
tmp_path = "#{@path}.#{Process.pid}.#{object_id}.tmp"
|
|
114
|
+
File.open(tmp_path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |tmp|
|
|
115
|
+
tmp.write(Marshal.dump(data))
|
|
116
|
+
tmp.flush
|
|
117
|
+
end
|
|
118
|
+
# Rename, not truncate+write in place: a crash between truncate
|
|
119
|
+
# and write used to leave a half-written file that `read` above
|
|
120
|
+
# would then have to poison-recover from. A rename is atomic, so
|
|
121
|
+
# readers only ever see the old complete file or the new complete
|
|
122
|
+
# one, never a torn one.
|
|
123
|
+
File.rename(tmp_path, @path)
|
|
72
124
|
File.chmod(0o600, @path)
|
|
73
125
|
end
|
|
74
126
|
end
|
|
@@ -21,6 +21,17 @@ module Portage
|
|
|
21
21
|
@mutex.synchronize { @data[key] = value }
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
+
# Atomic check-then-set: `Idempotency#dedup`'s own per-key `Mutex`
|
|
25
|
+
# already serializes same-process racers on this key, so a plain
|
|
26
|
+
# fetch-then-store pair is safe here — unlike `FileStore`, there's
|
|
27
|
+
# no second process able to interleave between the two steps.
|
|
28
|
+
def fetch_or_store(key)
|
|
29
|
+
cached = fetch(key)
|
|
30
|
+
return cached unless cached.equal?(NOT_FOUND)
|
|
31
|
+
|
|
32
|
+
store(key, yield)
|
|
33
|
+
end
|
|
34
|
+
|
|
24
35
|
# Duck-types Hash#include? so this can stand in wherever a caller
|
|
25
36
|
# (e.g. the conformance kit, `rspec.rb`) just wants to assert a key
|
|
26
37
|
# was dedup'd, without reaching for the internal `fetch` sentinel.
|
|
@@ -9,12 +9,17 @@ module Portage
|
|
|
9
9
|
# — this is that table, now backed by a pluggable store instead of a
|
|
10
10
|
# bare Hash.
|
|
11
11
|
#
|
|
12
|
-
# Defaults to `MemoryStore` (in-process,
|
|
13
|
-
# durability the old bare Hash had
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
# or
|
|
17
|
-
#
|
|
12
|
+
# Defaults to a fresh `MemoryStore` per including instance (in-process,
|
|
13
|
+
# lost on restart — the same durability the old bare Hash had, and
|
|
14
|
+
# the same isolation: one adapter instance's dedup table never leaks
|
|
15
|
+
# into another's). A consumer that needs dedup to survive a process
|
|
16
|
+
# restart or be shared across processes (the actual complaint behind
|
|
17
|
+
# this: a single-host CLI invoked fresh per command, or a
|
|
18
|
+
# multi-worker server) sets `Portage::Ucp.configuration.idempotency_provider`
|
|
19
|
+
# once via `configure { |c| c.idempotency_provider = ... }` — every
|
|
20
|
+
# instance that doesn't set its own store via `idempotency_store=`
|
|
21
|
+
# then shares that one process-wide store instead of each getting its
|
|
22
|
+
# own in-memory table. `FileStore` ships for the single-host case;
|
|
18
23
|
# Redis/SQLite-backed stores are a consumer's own implementation
|
|
19
24
|
# against the same two-method interface (`#fetch(key)` /
|
|
20
25
|
# `#store(key, value)`).
|
|
@@ -37,21 +42,27 @@ module Portage
|
|
|
37
42
|
|
|
38
43
|
private
|
|
39
44
|
|
|
40
|
-
def dedup(idempotency_key)
|
|
45
|
+
def dedup(idempotency_key, &block)
|
|
41
46
|
init_idempotency_locks!
|
|
42
47
|
|
|
43
|
-
key_lock =
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
key_lock = checkout_idempotency_lock(idempotency_key)
|
|
49
|
+
begin
|
|
50
|
+
# `key_lock` only serializes threads inside *this* process — it can't
|
|
51
|
+
# stop a second `portage` invocation (a second process, its own
|
|
52
|
+
# Mutex) from racing this one. That's the store's job: `fetch` then
|
|
53
|
+
# `store` as two separate calls (the old shape here) is two separate
|
|
54
|
+
# lock acquisitions on `FileStore`, so both processes can observe
|
|
55
|
+
# NOT_FOUND and both run `yield` — the double-charge §9a exists to
|
|
56
|
+
# prevent. `fetch_or_store` does the whole check-then-set under one
|
|
57
|
+
# lock acquisition instead.
|
|
58
|
+
key_lock.synchronize { idempotency_store.fetch_or_store(idempotency_key, &block) }
|
|
59
|
+
ensure
|
|
60
|
+
checkin_idempotency_lock(idempotency_key)
|
|
50
61
|
end
|
|
51
62
|
end
|
|
52
63
|
|
|
53
64
|
def idempotency_store
|
|
54
|
-
@idempotency_store ||= MemoryStore.new
|
|
65
|
+
@idempotency_store ||= Portage::Ucp.configuration.idempotency_provider || MemoryStore.new
|
|
55
66
|
end
|
|
56
67
|
|
|
57
68
|
def init_idempotency_locks!
|
|
@@ -62,6 +73,33 @@ module Portage
|
|
|
62
73
|
@idempotency_locks ||= {}
|
|
63
74
|
end
|
|
64
75
|
end
|
|
76
|
+
|
|
77
|
+
# `@idempotency_locks` is per-instance and never bounded otherwise —
|
|
78
|
+
# a long-lived process (server, not a CLI invoked fresh per command)
|
|
79
|
+
# would accumulate one Mutex per distinct idempotency key forever.
|
|
80
|
+
# Refcount each entry instead of leaving it in the table for the
|
|
81
|
+
# instance's lifetime: checkout bumps the count before handing out
|
|
82
|
+
# the Mutex, checkin drops it and reaps the entry once nothing still
|
|
83
|
+
# holds a reference. The refcount, not `Mutex#locked?`, is what makes
|
|
84
|
+
# this safe — a thread waiting on `key_lock.synchronize` still counts
|
|
85
|
+
# as holding a reference, so the entry can't be deleted (and a
|
|
86
|
+
# second, disconnected Mutex created for the same key) while it's
|
|
87
|
+
# still queued.
|
|
88
|
+
def checkout_idempotency_lock(idempotency_key)
|
|
89
|
+
@idempotency_mutex.synchronize do
|
|
90
|
+
entry = (@idempotency_locks[idempotency_key] ||= { mutex: Mutex.new, refcount: 0 })
|
|
91
|
+
entry[:refcount] += 1
|
|
92
|
+
entry[:mutex]
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def checkin_idempotency_lock(idempotency_key)
|
|
97
|
+
@idempotency_mutex.synchronize do
|
|
98
|
+
entry = @idempotency_locks[idempotency_key]
|
|
99
|
+
entry[:refcount] -= 1
|
|
100
|
+
@idempotency_locks.delete(idempotency_key) if entry[:refcount].zero?
|
|
101
|
+
end
|
|
102
|
+
end
|
|
65
103
|
end
|
|
66
104
|
end
|
|
67
105
|
end
|
|
@@ -32,6 +32,12 @@ module Portage
|
|
|
32
32
|
with_lock(File::LOCK_SH) { |data, _file| data[order_id] }
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
+
def each_record(&block)
|
|
36
|
+
return enum_for(:each_record) unless block
|
|
37
|
+
|
|
38
|
+
with_lock(File::LOCK_SH) { |data, _file| data.values }.each(&block)
|
|
39
|
+
end
|
|
40
|
+
|
|
35
41
|
private
|
|
36
42
|
|
|
37
43
|
def with_lock(lock_mode)
|
|
@@ -16,6 +16,13 @@ module Portage
|
|
|
16
16
|
def find(order_id)
|
|
17
17
|
raise NotImplementedError, "#{self.class} must implement #find"
|
|
18
18
|
end
|
|
19
|
+
|
|
20
|
+
# Yields every snapshot, in no guaranteed order, without a block
|
|
21
|
+
# returns an Enumerator — same §22 item 6 console read surface
|
|
22
|
+
# added to `TransactionLog::Store`. Additive; `#find` is unchanged.
|
|
23
|
+
def each_record(&)
|
|
24
|
+
raise NotImplementedError, "#{self.class} must implement #each_record"
|
|
25
|
+
end
|
|
19
26
|
end
|
|
20
27
|
end
|
|
21
28
|
end
|
|
@@ -21,9 +21,12 @@ module Portage
|
|
|
21
21
|
def synchronize(session_id, &)
|
|
22
22
|
init_session_locks!
|
|
23
23
|
|
|
24
|
-
key_lock =
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
key_lock = checkout_session_lock(session_id)
|
|
25
|
+
begin
|
|
26
|
+
key_lock.synchronize(&)
|
|
27
|
+
ensure
|
|
28
|
+
checkin_session_lock(session_id)
|
|
29
|
+
end
|
|
27
30
|
end
|
|
28
31
|
|
|
29
32
|
def init_session_locks!
|
|
@@ -34,6 +37,29 @@ module Portage
|
|
|
34
37
|
@session_locks ||= {}
|
|
35
38
|
end
|
|
36
39
|
end
|
|
40
|
+
|
|
41
|
+
# Same unbounded-growth problem as Idempotency's per-key locks: one
|
|
42
|
+
# Mutex per session/cart id, never removed, in a process that outlives
|
|
43
|
+
# any single session. Refcount instead of `Mutex#locked?` so an entry
|
|
44
|
+
# is only reaped once no thread — including one still queued on
|
|
45
|
+
# `key_lock.synchronize` — holds a reference to it; deleting on
|
|
46
|
+
# `locked?` alone would let a queued waiter end up serialized against
|
|
47
|
+
# a disconnected Mutex a new caller replaced it with.
|
|
48
|
+
def checkout_session_lock(session_id)
|
|
49
|
+
@session_lock_mutex.synchronize do
|
|
50
|
+
entry = (@session_locks[session_id] ||= { mutex: Mutex.new, refcount: 0 })
|
|
51
|
+
entry[:refcount] += 1
|
|
52
|
+
entry[:mutex]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def checkin_session_lock(session_id)
|
|
57
|
+
@session_lock_mutex.synchronize do
|
|
58
|
+
entry = @session_locks[session_id]
|
|
59
|
+
entry[:refcount] -= 1
|
|
60
|
+
@session_locks.delete(session_id) if entry[:refcount].zero?
|
|
61
|
+
end
|
|
62
|
+
end
|
|
37
63
|
end
|
|
38
64
|
end
|
|
39
65
|
end
|
|
@@ -44,6 +44,15 @@ module Portage
|
|
|
44
44
|
raise NotImplementedError, "#{self.class} must implement #find"
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
+
# Yields every record, in no guaranteed order, without a block
|
|
48
|
+
# returns an Enumerator — the read surface §22 item 6's console
|
|
49
|
+
# needed and this class's own comment flagged as the trigger to
|
|
50
|
+
# add it. `#find`/`#completed_since` stay as they were; this is
|
|
51
|
+
# additive, not a replacement for either.
|
|
52
|
+
def each_record(&)
|
|
53
|
+
raise NotImplementedError, "#{self.class} must implement #each_record"
|
|
54
|
+
end
|
|
55
|
+
|
|
47
56
|
def completed_since(since, shop:)
|
|
48
57
|
raise NotImplementedError, "#{self.class} must implement #completed_since"
|
|
49
58
|
end
|
|
@@ -102,6 +102,14 @@ module Portage
|
|
|
102
102
|
def completed_since(since, shop:)
|
|
103
103
|
@store.completed_since(since, shop: shop)
|
|
104
104
|
end
|
|
105
|
+
|
|
106
|
+
def each_record(&)
|
|
107
|
+
@store.each_record(&)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def all
|
|
111
|
+
enum_for(:each_record).to_a
|
|
112
|
+
end
|
|
105
113
|
end
|
|
106
114
|
end
|
|
107
115
|
end
|
data/lib/portage/ucp/version.rb
CHANGED
data/lib/portage/ucp.rb
CHANGED
|
@@ -32,6 +32,7 @@ require_relative "ucp/support/token_ref"
|
|
|
32
32
|
require_relative "ucp/payment_token_guard"
|
|
33
33
|
require_relative "ucp/payment_enrollment_guard"
|
|
34
34
|
require_relative "ucp/ap2/mandate"
|
|
35
|
+
require_relative "ucp/ap2/mandate_signature"
|
|
35
36
|
require_relative "ucp/ap2/mandate_guard"
|
|
36
37
|
require_relative "ucp/policy"
|
|
37
38
|
require_relative "ucp/policy_guard"
|
|
@@ -64,6 +65,10 @@ require_relative "ucp/security/signature"
|
|
|
64
65
|
require_relative "ucp/rack/signature_verification"
|
|
65
66
|
require_relative "ucp/resolver"
|
|
66
67
|
require_relative "ucp/check"
|
|
68
|
+
# Rails integration is opt-in and never a hard dependency (this stays the
|
|
69
|
+
# protocol-only core gem, per the gemspec) — only loaded when a host app
|
|
70
|
+
# already has Rails on the load path.
|
|
71
|
+
require_relative "ucp/railtie" if defined?(Rails::Railtie)
|
|
67
72
|
|
|
68
73
|
module Portage
|
|
69
74
|
module Ucp
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: portage-ucp
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tom Whitbread
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-16 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: base64
|
|
@@ -148,10 +148,13 @@ files:
|
|
|
148
148
|
- LICENSE
|
|
149
149
|
- README.md
|
|
150
150
|
- exe/portage-ucp-check
|
|
151
|
+
- lib/generators/portage/ucp/install/install_generator.rb
|
|
152
|
+
- lib/generators/portage/ucp/install/templates/portage_ucp.rb
|
|
151
153
|
- lib/portage/ucp.rb
|
|
152
154
|
- lib/portage/ucp/adapter.rb
|
|
153
155
|
- lib/portage/ucp/ap2/mandate.rb
|
|
154
156
|
- lib/portage/ucp/ap2/mandate_guard.rb
|
|
157
|
+
- lib/portage/ucp/ap2/mandate_signature.rb
|
|
155
158
|
- lib/portage/ucp/authenticator.rb
|
|
156
159
|
- lib/portage/ucp/capabilities/cart.rb
|
|
157
160
|
- lib/portage/ucp/capabilities/catalog.rb
|
|
@@ -182,6 +185,7 @@ files:
|
|
|
182
185
|
- lib/portage/ucp/rack/manifest_endpoint.rb
|
|
183
186
|
- lib/portage/ucp/rack/signature_verification.rb
|
|
184
187
|
- lib/portage/ucp/rack/webhook_endpoint.rb
|
|
188
|
+
- lib/portage/ucp/railtie.rb
|
|
185
189
|
- lib/portage/ucp/rate_limiter.rb
|
|
186
190
|
- lib/portage/ucp/reference_adapter.rb
|
|
187
191
|
- lib/portage/ucp/resolver.rb
|