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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f8228c4a5a32d4ed322103ebc5061bf3179700fae68cc414fd0e53a8cb7f900e
4
- data.tar.gz: dfc1f273f7fc79a8af633d4341af409a1447550c56a2fb0e8c7d4301b85c65c4
3
+ metadata.gz: 826f590ea83ab80febf6e461f60d1eb580d7ebf460a419bafb81d0f713525e81
4
+ data.tar.gz: eb98a54978daf0904de0121101e98e597a67cd38dabd7440dd3813449b384635
5
5
  SHA512:
6
- metadata.gz: 4b7bb63032f231ed97eee56d0ed1d5d85a0fed3fae9c5590a22b153285ce13cc9c362903971bbb9d0a1f9fd0ecb5372e6412f6c38c76ed4f1e17927491006803
7
- data.tar.gz: 3046889323d2b87b8ec26523e93bd33ff4edad0345bb858e5f2a75f6c9e0d288e0918cb8b0ee0b3dee467fa47023d7469a254a3e0bfc0ada5aaa527907c1c6fc
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
- ## [Unreleased]
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); `signature` is carried opaquely
10
- # — MandateGuard checks the mandate's *shape*, never the signature
11
- # itself, since verifying it needs a trust anchor this gem doesn't
12
- # have (see MandateGuard's own comment).
13
- PaymentMandate = Data.define(:amount, :currency, :merchant, :expires_at, :signature) do
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-*shape* validation (design-log §33/Phase B) required
7
- # fields present and not expired. This is deliberately NOT
8
- # cryptographic AP2 verification: `signature` is carried opaquely
9
- # (see PaymentMandate) and this guard never inspects it. No key
10
- # infrastructure or trust anchor exists in this repo to verify a
11
- # signature against that's a real external dependency and an
12
- # explicit non-goal here, the same carve-out the existing plan draws
13
- # around crypto-signing the confirmation payload. A real PSP adapter
14
- # is expected to verify a mandate's signature against the issuing
15
- # agent's trust anchor before ever handing it to this gem.
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
- def self.validate!(mandate)
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
- return unless Time.now >= Time.parse(mandate.expires_at)
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
- raise Portage::Ucp::InvalidMandateError,
29
- "payment mandate expired at #{mandate.expires_at}"
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
- Portage::Ucp::Ap2::MandateGuard.validate!(arguments[:mandate]) if arguments[:mandate]
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
@@ -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 or missing a required field. Mandate-shape
19
- # validation only, not cryptographic AP2 verification: no key
20
- # infrastructure or trust anchor exists in this repo to verify a
21
- # mandate's signature against (see Ap2::MandateGuard's own comment).
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
- logger.info(JSON.generate({ event: event }.merge(redact(fields))))
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 single file under `flock`
10
- # stands in for that — every read/write acquires the lock so two
11
- # processes racing on the same key don't both run the mutation.
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, _file| data.key?(key) ? data[key] : NOT_FOUND }
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, file|
42
+ with_lock(File::LOCK_EX) do |data|
35
43
  data[key] = value
36
- persist(data, file)
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, _file| data.key?(key) }
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(@path, File::RDWR | File::CREAT, 0o600) do |file|
50
- file.flock(lock_mode)
51
- yield(read(file), file)
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(file)
56
- file.rewind
57
- raw = file.read
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, file)
68
- file.rewind
69
- file.truncate(0)
70
- file.write(Marshal.dump(data))
71
- file.flush
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, lost on restart — the same
13
- # durability the old bare Hash had). A consumer that needs dedup to
14
- # survive a process restart or be shared across processes (the actual
15
- # complaint behind this: a single-host CLI invoked fresh per command,
16
- # or a multi-worker server) injects a different store via
17
- # `idempotency_store=` `FileStore` ships for the single-host case;
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 = @idempotency_mutex.synchronize { @idempotency_locks[idempotency_key] ||= Mutex.new }
44
-
45
- key_lock.synchronize do
46
- cached = idempotency_store.fetch(idempotency_key)
47
- next cached unless cached.equal?(NOT_FOUND)
48
-
49
- idempotency_store.store(idempotency_key, yield)
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
@@ -41,6 +41,14 @@ module Portage
41
41
  def find(order_id)
42
42
  @store.find(order_id)
43
43
  end
44
+
45
+ def each_record(&)
46
+ @store.each_record(&)
47
+ end
48
+
49
+ def all
50
+ enum_for(:each_record).to_a
51
+ end
44
52
  end
45
53
  end
46
54
  end
@@ -21,9 +21,12 @@ module Portage
21
21
  def synchronize(session_id, &)
22
22
  init_session_locks!
23
23
 
24
- key_lock = @session_lock_mutex.synchronize { @session_locks[session_id] ||= Mutex.new }
25
-
26
- key_lock.synchronize(&)
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
@@ -75,6 +75,12 @@ module Portage
75
75
  end
76
76
  end
77
77
 
78
+ def each_record(&block)
79
+ return enum_for(:each_record) unless block
80
+
81
+ with_lock(File::LOCK_SH) { |data, _file| data.values }.each(&block)
82
+ end
83
+
78
84
  private
79
85
 
80
86
  def with_lock(lock_mode)
@@ -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
@@ -1,5 +1,5 @@
1
1
  module Portage
2
2
  module Ucp
3
- VERSION = "0.6.0".freeze
3
+ VERSION = "0.7.1".freeze
4
4
  end
5
5
  end
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.6.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-15 00:00:00.000000000 Z
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