openreceive-rails 0.2.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3709b7ea572da6d1a0a259c84f95ea3f5e41ec1fc7dc9dd067a8a77951bd0691
4
+ data.tar.gz: 0c42db53e3d8dff9c5796ebc53b15c87b02286c627250be6a2741327d3082403
5
+ SHA512:
6
+ metadata.gz: '0930dafa248e3f22fbc2297fac22a8d14b09ed407c49c08f01cd5de48cb7213f49370644233a74efd45782432a1629a12439a312c4c074f1a9401dcc8ccda51d'
7
+ data.tar.gz: 2990f0db5d68c3b8a0fa4c1bace12eb7708f1961894d6bd444cdd593f259aead0249b28ba127e2cc246903ec4517c6f8944ae3a7cde14d4bf58ea6dd7fa65e1f
data/CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ ## 0.2.1 - 2026-08-24
4
+
5
+ The Ruby gems release in lockstep with the npm workspace version. The full
6
+ release narrative lives in the repository-root
7
+ [CHANGELOG](https://github.com/openreceive/openreceive/blob/master/CHANGELOG.md);
8
+ entries here are scoped to this gem.
9
+
10
+ - Packaging only: `npm run release:gem:build` now names the built `.gem` with
11
+ the version RubyGems actually mints, so prerelease versions build and push.
12
+
13
+ ## 0.1.1 - 2026-08-18
14
+
15
+ - First packaged release of `openreceive-rails`: the mountable engine, the
16
+ engine-owned `OpenReceivePayment` model and reconciliation, and the
17
+ `openreceive:install` generator.
18
+ - `config.price_provider` defaults to the built-in live price feed, and swap
19
+ providers auto-build from `LSC_URI_PRIMARY`/`LSC_URI_BACKUP` — matching the
20
+ Node engine's `createOpenReceive` defaults.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 OpenReceive contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # openreceive-rails
2
+
3
+ Mountable receive-only OpenReceive engine. The engine owns the
4
+ `OpenReceivePayment` attempt model (statuses `pending`, `settled`, `expired`,
5
+ `failed`, `attention`), its per-reference commit locking, settlement write-once, and
6
+ the reconciliation state machine. The install generator mounts the routes and
7
+ emits the initializer plus one migration creating both engine tables
8
+ (`openreceive_payments` and the `openreceive_meta` reconcile gate):
9
+
10
+ ```sh
11
+ bin/rails generate openreceive:install
12
+ bin/rails db:migrate
13
+ ```
14
+
15
+ The generated migration supports PostgreSQL, SQLite, and MySQL, and seeds the
16
+ shared `schema_version`; on its first database touch the engine refuses to
17
+ operate a database whose stored schema version is newer than the gem.
18
+
19
+ The quickstart host contract is `config.authorize`, `config.amount_for` (the
20
+ trusted price for a reference, or `nil` for a 404), and `config.on_paid` (run
21
+ inside the settlement transaction, only for the first settled attempt for a reference). The generated
22
+ initializer starts with the `OpenReceive::LOGGING_ON_PAID` placeholder, which
23
+ only logs settlements — the engine warns every time your application boots
24
+ until it is replaced.
25
+ Hosts with a custom repository may instead configure `resolve_checkout` and
26
+ `on_checkout_created` together as the advanced escape hatch. In production the
27
+ engine builds the service (and its wallet preflight) eagerly when your
28
+ application boots, so a missing `NWC_URI` or a spend-capable wallet stops the
29
+ deploy instead of surfacing as checkout-time 500s.
30
+
31
+ Settlement runs on the request path by default: every engine route runs one
32
+ opportunistic reconcile pass, serialized across all Puma workers by that
33
+ durable `openreceive_meta` gate (`config.opportunistic_reconcile` disables or
34
+ tunes it). The optional
35
+ `bin/rails openreceive:notifications` worker listens for wallet notifications
36
+ and reconciles periodically; `OpenReceive::ReconcileJob` and
37
+ `bin/rails openreceive:reconcile` remain one-shot primitives. Closure of an
38
+ unpaid attempt requires a successful wallet scan at or after expiry plus the
39
+ shared grace window — a local clock alone never closes a row.
40
+
41
+ The engine inherits the host's `protect_from_forgery`: render `csrf_meta_tags`
42
+ and the checkout client sends `X-CSRF-Token` from it. Independently of that,
43
+ the shared handler refuses non-JSON bodies (415) and `Sec-Fetch-Site:
44
+ cross-site` requests (403) before `authorize` runs.
45
+
46
+ Because the engine cannot see fulfillment that happens outside it,
47
+ `config.on_paid` must be idempotent if any other path can also fulfill an
48
+ order — the generated initializer shows the guarded transition. The receive-only wallet URI loads from `ENV["NWC_URI"]`; your
49
+ application refuses to start when the connection advertises spend methods unless
50
+ `config.allow_spend_capable_wallet` or `OPENRECEIVE_ALLOW_SPEND_CAPABLE_NWC`
51
+ overrides it. Keep ordinary settings such as `config.price_currencies` in
52
+ `config/initializers/openreceive.rb`.
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenReceive
4
+ # Base controller for the engine. It inherits DYNAMICALLY from the configured
5
+ # `config.parent_controller` (default "ActionController::Base"; a host sets "ApplicationController")
6
+ # so the engine automatically gets the host's authentication and current_user.
7
+ #
8
+ # Set `parent_controller` in a normal initializer — not `after_initialize`. Production
9
+ # eager-loads this class before after_initialize runs, so a late parent_controller change
10
+ # is ignored and the default ActionController::Base CSRF `:exception` strategy remains.
11
+ #
12
+ # Every action is a thin adapter: it converts the Rails request into the inputs the shared
13
+ # OpenReceive::Server::RequestHandler needs, delegates, and renders the returned
14
+ # [status, headers, body] triple. Controllers and the Rack app therefore cannot drift — the
15
+ # routing/authorize/error semantics live in one place (the server gem's RequestHandler).
16
+ class ApplicationController < OpenReceive.config.parent_controller.constantize
17
+ # The host's forgery protection is inherited, not skipped: whatever
18
+ # `protect_from_forgery` the parent controller configures applies to the
19
+ # engine's routes exactly as it applies to the host's own. The shipped
20
+ # browser client sends `X-CSRF-Token` from `<meta name="csrf-token">`
21
+ # whenever the page renders `csrf_meta_tags`, so a Rails host needs no
22
+ # extra wiring; API-only parents (ActionController::API) have no forgery
23
+ # protection, and the shared handler's JSON-only + same-site gates cover
24
+ # them. A failed check answers with the shared 403 error contract instead
25
+ # of the opaque 500 the StandardError rescue below would produce.
26
+
27
+ # Any PAYMENT call is a settlement trigger (mirrors the JS handler's
28
+ # dispatch): after the route matched and before its own work, run one
29
+ # durably gated reconcile pass. maybe_reconcile! never raises; a failed
30
+ # scan must not fail this request. payments/check consumes this pass
31
+ # result — exactly one gate claim per request. Host-only routes never
32
+ # auto-run this; hosts call OpenReceive.maybe_reconcile! from their own
33
+ # code instead.
34
+ #
35
+ # Unauthenticated GET /rates is deliberately EXEMPT (RatesController skips
36
+ # this filter, matching the JS handler's early return for route.kind
37
+ # "rates"): crawlers and health checks must not consume the wallet-scan
38
+ # budget.
39
+ around_action :openreceive_opportunistic_reconcile
40
+
41
+ # Any exception the thin adapter layer itself raises (body cap, render)
42
+ # still answers with the shared error contract instead of the host's HTML
43
+ # error page — the same last-resort rescue as Server::RackApp#call. The
44
+ # handler's error_response redacts unexpected exceptions and reports them
45
+ # through Rails.error before the opaque 500 goes on the wire.
46
+ rescue_from StandardError do |error|
47
+ openreceive_respond(openreceive_handler.error_response(error, openreceive_request_id))
48
+ end
49
+
50
+ rescue_from ActionController::InvalidAuthenticityToken do
51
+ forbidden = Server::ForbiddenError.new("Invalid or missing CSRF token.")
52
+ openreceive_respond(openreceive_handler.error_response(forbidden, openreceive_request_id))
53
+ end
54
+
55
+ private
56
+
57
+ def openreceive_opportunistic_reconcile
58
+ # The body cap runs FIRST, matching the JS handler's dispatch order: an
59
+ # anonymous oversized POST is refused without a database read, without
60
+ # claiming the reconcile gate, and without triggering a wallet scan. The
61
+ # read is memoized, so the action reuses it.
62
+ openreceive_raw_body
63
+ @openreceive_reconcile_pass = OpenReceive.maybe_reconcile!
64
+ yield
65
+ end
66
+
67
+ # The memoized shared request handler (Service + configured host callbacks).
68
+ def openreceive_handler
69
+ OpenReceive.config.request_handler
70
+ end
71
+
72
+ # Always server-generated (matches RackApp and JS): client-supplied
73
+ # X-Request-Id values are unvalidated content and are never reflected.
74
+ def openreceive_request_id
75
+ "req_#{SecureRandom.uuid}"
76
+ end
77
+
78
+ # The raw JSON request body string (Server::RequestHandler parses it so the parse/error semantics
79
+ # match the Rack app exactly rather than relying on Rails' params coercion). Capped pre-auth,
80
+ # mirroring RackApp#read_body and the JS readJsonBody: an over-declared Content-Length is
81
+ # rejected before any read, and the read itself stops one byte past the cap so a chunked body
82
+ # can never stream unbounded input into memory.
83
+ def openreceive_raw_body
84
+ return @openreceive_raw_body if defined?(@openreceive_raw_body)
85
+
86
+ @openreceive_raw_body = openreceive_read_raw_body
87
+ end
88
+
89
+ def openreceive_read_raw_body
90
+ max_bytes = Server::RequestHandler::MAX_BODY_BYTES
91
+ raise Server::PayloadTooLargeError if request.get_header("CONTENT_LENGTH").to_i > max_bytes
92
+
93
+ body = request.body
94
+ return "" if body.nil?
95
+
96
+ raw = body.respond_to?(:read) ? body.read(max_bytes + 1).to_s : body.to_s
97
+ body.rewind if body.respond_to?(:rewind)
98
+ raise Server::PayloadTooLargeError if raw.bytesize > max_bytes
99
+
100
+ raw
101
+ end
102
+
103
+ # Render a [status, headers, body] triple with a byte-equal JSON body. `render body:` with an
104
+ # explicit content_type avoids Rails appending a charset, keeping the wire body identical to the
105
+ # Rack app; the JSON is generated exactly as RackApp generates it. Every non-Content-Type header
106
+ # from the shared handler is copied onto the Rails response verbatim.
107
+ def openreceive_respond(result)
108
+ status, headers, body = result
109
+ headers.each do |key, value|
110
+ next if key.casecmp("content-type").zero?
111
+
112
+ response.set_header(key, value)
113
+ end
114
+ render body: JSON.generate(body), content_type: "application/json", status: status
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenReceive
4
+ class CheckoutsController < ApplicationController
5
+ def prepare
6
+ openreceive_respond(openreceive_handler.prepare_checkout(
7
+ raw_body: openreceive_raw_body,
8
+ request: request,
9
+ request_id: openreceive_request_id
10
+ ))
11
+ end
12
+
13
+ def create
14
+ openreceive_respond(openreceive_handler.create_checkout(
15
+ raw_body: openreceive_raw_body,
16
+ request: request,
17
+ request_id: openreceive_request_id
18
+ ))
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenReceive
4
+ class PaymentsController < ApplicationController
5
+ def check
6
+ openreceive_respond(openreceive_handler.check_payment(
7
+ raw_body: openreceive_raw_body,
8
+ request: request,
9
+ request_id: openreceive_request_id,
10
+ **openreceive_check_pass_arguments
11
+ ))
12
+ end
13
+
14
+ private
15
+
16
+ # Engine mode serves payments/check from the around_action's gated pass
17
+ # (winner) or the engine-owned row (gate_busy / disabled) — never a second
18
+ # per-invoice wallet walk. Advanced mode (custom repository) has no
19
+ # engine-owned rows: it keeps the handler's legacy per-invoice behavior.
20
+ def openreceive_check_pass_arguments
21
+ return {} if OpenReceive.config.advanced_hooks?
22
+
23
+ {
24
+ reconcile_pass: @openreceive_reconcile_pass || { "reason" => "disabled" },
25
+ attempt_status: lambda do |payment_hash|
26
+ payment = OpenReceivePayment.find_by(payment_hash: payment_hash.to_s.downcase)
27
+ next nil if payment.nil?
28
+
29
+ {
30
+ "status" => payment.status,
31
+ "paid_at" => payment.paid_at&.to_i
32
+ }.compact
33
+ end
34
+ }
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenReceive
4
+ class RatesController < ApplicationController
5
+ # Unauthenticated and payment-free: a crawler or health check hitting
6
+ # /rates must not consume the NWC scan budget. The JS handler returns
7
+ # before its opportunistic pass for the same route.
8
+ skip_around_action :openreceive_opportunistic_reconcile
9
+
10
+ def index
11
+ openreceive_respond(openreceive_handler.read_rates(
12
+ query_string: request.query_string,
13
+ request: request,
14
+ request_id: openreceive_request_id
15
+ ))
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenReceive
4
+ class SwapsController < ApplicationController
5
+ def quote
6
+ respond_with(:quote_swap)
7
+ end
8
+
9
+ def create
10
+ respond_with(:create_swap)
11
+ end
12
+
13
+ def status
14
+ respond_with(:get_swap)
15
+ end
16
+
17
+ def refund
18
+ respond_with(:refund_swap)
19
+ end
20
+
21
+ private
22
+
23
+ def respond_with(method)
24
+ openreceive_respond(openreceive_handler.public_send(
25
+ method,
26
+ raw_body: openreceive_raw_body,
27
+ request: request,
28
+ request_id: openreceive_request_id
29
+ ))
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenReceive
4
+ # One reconciliation pass, wrapped for the host's ActiveJob backend. Schedule
5
+ # it with the host's recurring-job system (for example a solid_queue
6
+ # config/recurring.yml entry, sidekiq-cron, or clockwork); OpenReceive ships
7
+ # no scheduler of its own.
8
+ class ReconcileJob < ActiveJob::Base
9
+ queue_as :default
10
+
11
+ def perform
12
+ OpenReceive.reconcile!
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ # Engine-owned key/value/rev rows in the host database (the install generator
6
+ # emits the openreceive_meta table next to openreceive_payments). It holds the
7
+ # durable reconcile gate every Puma worker and process on this database shares,
8
+ # so rapid OpenReceive calls collapse to one real wallet scan per interval, and
9
+ # the installed schema-version marker the engine refuses to run past. Mirrors
10
+ # the JS SQL repository's claimReconcileGate and assertSupportedSchema.
11
+ class OpenReceiveMeta < ActiveRecord::Base
12
+ self.table_name = "openreceive_meta"
13
+ self.primary_key = "key"
14
+
15
+ RECONCILE_GATE_KEY = "transaction_scan_gate"
16
+ SCHEMA_VERSION_KEY = "schema_version"
17
+ CAS_RETRIES = 6
18
+ # Tolerance when reading a timestamp another worker wrote. Beyond it a claim
19
+ # stamped in the future is a backwards clock step, not a fresh claim: without
20
+ # this clamp the gate would read as busy until wall-clock time caught up.
21
+ META_CLOCK_SKEW_SECONDS = 60
22
+
23
+ # One probe per process, on the engine's first database touch: a database
24
+ # written by a NEWER library must not be operated by this one (columns or
25
+ # state transitions it does not know about). An unreadable or absent marker
26
+ # means "not versioned" — the pre-versioned migrations could not seed a row —
27
+ # and is not a refusal. Mirrors the JS repository's assertSupportedSchema.
28
+ def self.assert_supported_schema!
29
+ @schema_version_checked ||= begin
30
+ stored = stored_schema_version
31
+ if !stored.nil? && stored > OpenReceive::Server::PAYMENTS_SCHEMA_VERSION
32
+ raise OpenReceive::ConfigurationError,
33
+ "openreceive_meta reports openreceive schema version #{stored}, newer than this " \
34
+ "library's #{OpenReceive::Server::PAYMENTS_SCHEMA_VERSION}. Upgrade openreceive-rails " \
35
+ "before serving this database."
36
+ end
37
+ true
38
+ end
39
+ end
40
+
41
+ # Optimistic compare-and-set: INSERT-if-absent at rev 0, or
42
+ # UPDATE ... WHERE rev = expected. Returns true when this caller's write won.
43
+ def self.cas(key, value, expected_rev)
44
+ if expected_rev.nil?
45
+ begin
46
+ create!(key: key, value: value, rev: 0)
47
+ true
48
+ rescue ActiveRecord::RecordNotUnique
49
+ false
50
+ end
51
+ else
52
+ where(key: key, rev: expected_rev).update_all(value: value, rev: expected_rev + 1) == 1
53
+ end
54
+ end
55
+
56
+ # Claim the durable global reconcile gate. Returns true when this caller may
57
+ # run a wallet scan now; false (gate_busy) when another worker scanned within
58
+ # interval_seconds. The winner is identified by reading back its own token —
59
+ # the portable equivalent of an affected-row count, matching the JS
60
+ # claimReconcileGate. A failed scan leaves claimed_at in place on purpose —
61
+ # the next interval retries without a stampede.
62
+ def self.claim_reconcile_gate(now:, interval_seconds:)
63
+ assert_supported_schema!
64
+ claim = JSON.generate("claimed_at" => Integer(now), "token" => SecureRandom.uuid)
65
+ CAS_RETRIES.times do
66
+ row = find_by(key: RECONCILE_GATE_KEY)
67
+ if row.nil?
68
+ cas(RECONCILE_GATE_KEY, claim, nil)
69
+ else
70
+ claimed_at = parse_claimed_at(row.value)
71
+ if claimed_at && fresh_timestamp?(Integer(now), claimed_at, Integer(interval_seconds))
72
+ return false
73
+ end
74
+ cas(RECONCILE_GATE_KEY, claim, row.rev)
75
+ end
76
+ return true if where(key: RECONCILE_GATE_KEY).pick(:value) == claim
77
+ end
78
+ false
79
+ end
80
+
81
+ def self.stored_schema_version
82
+ value = where(key: SCHEMA_VERSION_KEY).pick(:value)
83
+ return nil if value.nil?
84
+
85
+ Integer(value.to_s, 10, exception: false)
86
+ rescue ActiveRecord::ActiveRecordError
87
+ nil
88
+ end
89
+
90
+ # True when `timestamp` is within `window_seconds` of `now`. A stamp far in
91
+ # the future is a clock that stepped backwards, not a fresh claim: clamping
92
+ # it to stale keeps a rewound clock from parking the gate busy until
93
+ # wall-clock time catches up. Mirrors the JS isFreshTimestamp.
94
+ def self.fresh_timestamp?(now, timestamp, window_seconds)
95
+ age = now - timestamp
96
+ return false if age < -META_CLOCK_SKEW_SECONDS
97
+
98
+ age < window_seconds
99
+ end
100
+
101
+ def self.parse_claimed_at(value)
102
+ parsed = JSON.parse(value.to_s)
103
+ claimed_at = parsed["claimed_at"]
104
+ claimed_at.is_a?(Numeric) ? Integer(claimed_at) : nil
105
+ rescue JSON::ParserError
106
+ nil
107
+ end
108
+
109
+ private_class_method :stored_schema_version, :fresh_timestamp?, :parse_claimed_at
110
+ end