openreceive-server 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: 815016906827bfc710461c1d68490612651e561647e844490366a835efd4ec5b
4
+ data.tar.gz: 35be8788e0f8bbcc83b2fdf6771e0bbce272267ef6c76151b739e23639ff56fe
5
+ SHA512:
6
+ metadata.gz: 96c1bbbea13c33b366f6f09a30104fa3340b49500ee6ecfd97475155c0b280981a534ba20df800b362d193cdc6cd11c4bbe6c16a6c8bd20e9e8083b6f0a5d9af
7
+ data.tar.gz: cfb1f18622c9e424e0f10754da453c7be4300137a204f8e6742cdd38f37587b37afa782a4b6bbc4bf6e55cc5e51727f2d6d9f459806fb4a5db5a8741efd00bdd
data/CHANGELOG.md ADDED
@@ -0,0 +1,24 @@
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-server`: the storage-free Service
16
+ mirroring the Node engine and the framework-agnostic Rack app implementing
17
+ the shipped HTTP routes.
18
+ - FixedFloat swap provider with LSC auto-wiring
19
+ (`LSC_URI_PRIMARY`/`LSC_URI_BACKUP`), amount-aware swap options, and
20
+ primary/backup failover; the Service defaults to the built-in live price
21
+ feed when the host injects no provider.
22
+ - `payments/check` whitelists public transaction fields (preimage/invoice
23
+ never reach the payer); 405/503/500 wire parity with the Node engine; the
24
+ rate limiter buckets IPv6 clients by /64.
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,46 @@
1
+ # openreceive-server
2
+
3
+ Storage-free Ruby service and Rack handler. Configure a receive-only NWC client. The host
4
+ authorizes requests, resolves order amounts, commits payment hashes before responding, and
5
+ consumes at-least-once verified payment events by hash. The service refuses to start when the
6
+ NWC connection advertises spend methods (`pay_invoice`, `multi_pay_invoice`,
7
+ `pay_keysend`, `multi_pay_keysend`) unless
8
+ `allow_spend_capable_wallet` or `OPENRECEIVE_ALLOW_SPEND_CAPABLE_NWC` overrides it; the
9
+ shared reconciliation decision table lives in `OpenReceive::Server::Reconciliation`.
10
+
11
+ ## Minimal Rack example
12
+
13
+ ```ruby
14
+ # config.ru — run with: rackup
15
+ require "openreceive/server"
16
+
17
+ service = OpenReceive::Server::Service.new(nwc_client: MyNwcClient.new)
18
+
19
+ run OpenReceive::Server::RackApp.new(
20
+ service: service,
21
+ # context is a Hash: context[:action] is the route name ("checkout.create",
22
+ # "payment.check", …), context[:request] is the Rack env, and
23
+ # context[:resource] is { reference:, payment_hash: } copied from the
24
+ # payer's body — it names an order, it does not prove ownership.
25
+ # Return true to allow, false for a 403.
26
+ authorize: ->(context) { my_policy_allows?(context) },
27
+ resolve_checkout: lambda do |action:, request:, reference:, input:, pay_in_asset: nil|
28
+ order = MyOrders.find(reference) or raise OpenReceive::Server::NotFoundError, "Unknown reference."
29
+ { "amount" => { "currency" => "USD", "value" => order.total } }
30
+ # Return payment_hash/checkout/swap_data for committed attempts on
31
+ # non-create actions; see the Rails engine for a full repository.
32
+ end,
33
+ on_checkout_created: ->(reference:, payment_hash:, checkout:, swap_data: nil, client_ip: nil) {
34
+ MyPayments.commit!(reference:, payment_hash:, checkout:, swap_data:, client_ip:)
35
+ },
36
+ on_paid: ->(event) { MyPayments.settle_once!(event) }
37
+ )
38
+ ```
39
+
40
+ Rack hosts own attempt persistence and replay-safe settlement (the Rails
41
+ engine ships both; see `openreceive-rails`). Rack hosts that want opportunistic
42
+ settlement run their own gated pass from middleware: feed the pending attempts
43
+ they store to `service.reconcile_payments({ attempts:, max_pages:, deadline: })`
44
+ and apply the per-hash results through `on_paid` — `RackApp` deliberately has
45
+ no built-in hook, and the durable-gate convenience (`OpenReceive.maybe_reconcile!`)
46
+ ships only with the Rails engine.
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenReceive
4
+ module Server
5
+ # Client-IP bucketing shared by rate limiting and attempt-row stamping.
6
+ # Ports the JS clientIpBucket exactly: the same input string
7
+ # must produce the same bucket string in both engines (mirrored tests in
8
+ # tests/rate-limit.test.mjs and the Ruby server tests).
9
+ #
10
+ # - IPv4-mapped IPv6 (`::ffff:a.b.c.d`) collapses to the plain IPv4, so
11
+ # the same client never gets two independent budgets.
12
+ # - IPv6 buckets to its /64 (`2001:db8:1:2::/64`): privacy extensions
13
+ # rotate the low 64 bits freely, so per-address budgets would hand every
14
+ # IPv6 payer an unlimited stream of fresh budgets.
15
+ # - IPv4 and already-bucketed values pass through unchanged (idempotent).
16
+ # - Unparsable input passes through as-is — an odd value still gets SOME
17
+ # consistent bucket rather than disabling the limit.
18
+ module ClientIp
19
+ module_function
20
+
21
+ # nil/empty-safe wrapper mirroring the JS handler's extractClientIp:
22
+ # no attributable IP stays nil (the limiter fails open); anything else
23
+ # is normalized into the bucket that is both stored and counted.
24
+ def attributed(raw)
25
+ value = raw.to_s
26
+ return nil if value.strip.empty?
27
+ bucket(value)
28
+ end
29
+
30
+ def bucket(ip)
31
+ value = ip.to_s.strip.downcase
32
+ value = value.delete_prefix("::ffff:") if value.start_with?("::ffff:") && value.include?(".")
33
+ return value unless value.include?(":")
34
+ return value if value.end_with?("/64")
35
+ address = value.split("%", -1).first || ""
36
+ hextets = expand_ipv6(address)
37
+ return value if hextets.nil?
38
+ "#{hextets.first(4).join(':')}::/64"
39
+ end
40
+
41
+ def expand_ipv6(value)
42
+ parts = value.split("::", -1)
43
+ return nil if parts.length > 2 || value.empty?
44
+ head = hextets_of(parts[0] || "")
45
+ tail = parts.length == 2 ? hextets_of(parts[1] || "") : []
46
+ return nil if head.nil? || tail.nil?
47
+ return head.length == 8 ? head : nil if parts.length == 1
48
+ missing = 8 - head.length - tail.length
49
+ return nil if missing < 1
50
+ head + Array.new(missing, "0") + tail
51
+ end
52
+
53
+ def hextets_of(segment)
54
+ return [] if segment == ""
55
+ groups = []
56
+ segment.split(":", -1).each do |group|
57
+ if /\A[0-9a-f]{1,4}\z/.match?(group)
58
+ groups << group.sub(/\A0+(?=.)/, "")
59
+ elsif /\A\d{1,3}(\.\d{1,3}){3}\z/.match?(group)
60
+ octets = group.split(".").map { |octet| Integer(octet, 10) }
61
+ return nil if octets.any? { |octet| octet > 255 }
62
+ # Embedded IPv4 tail expands to two hextets.
63
+ groups << (((octets[0] << 8) | octets[1]).to_s(16))
64
+ groups << (((octets[2] << 8) | octets[3]).to_s(16))
65
+ else
66
+ return nil
67
+ end
68
+ end
69
+ groups
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openreceive/server/swap"
4
+
5
+ module OpenReceive
6
+ module Server
7
+ class Config
8
+ attr_reader :nwc, :lsc_connections
9
+
10
+ def self.load(env: ENV)
11
+ new(env: env)
12
+ end
13
+
14
+ def initialize(env: ENV)
15
+ @nwc = clean(env["NWC_URI"])
16
+ @lsc_connections = OpenReceive::Server::LscUri.read_environment(env)
17
+ freeze
18
+ end
19
+
20
+ # FixedFloat-compatible providers for the parsed LSC connections, in
21
+ # env order (primary first, backup second) — the same providers the
22
+ # Service auto-builds when constructed without swap_providers.
23
+ def swap_providers(http: nil, now: nil)
24
+ OpenReceive::Server::Swap.providers_from_connections(@lsc_connections, http: http, now: now)
25
+ end
26
+
27
+ def to_h
28
+ {
29
+ "NWC_URI" => @nwc.nil? ? nil : "[REDACTED]",
30
+ "LSC_URI_connections" => @lsc_connections.length
31
+ }
32
+ end
33
+
34
+ def inspect
35
+ "#<OpenReceive::Server::Config storage=none nwc=#{@nwc.nil? ? 'missing' : '[REDACTED]'}>"
36
+ end
37
+
38
+ private
39
+
40
+ def clean(value)
41
+ text = value.to_s.strip
42
+ text.empty? ? nil : text
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenReceive
4
+ module Server
5
+ # Server-layer errors. Every error carries
6
+ # #status and #code so the Rack layer can map to an error.schema.json body directly.
7
+
8
+ # Generic service error carrying an explicit HTTP status + canonical code,
9
+ # the Ruby analogue of the JS ServiceError. Used where the JS
10
+ # service raises serviceError(status, code, message) — e.g. the swap flows
11
+ # — so both engines put the same status/code/message on the wire.
12
+ class ServiceError < StandardError
13
+ attr_reader :status, :code, :retryable, :details
14
+
15
+ def initialize(status, code, message, retryable: nil, details: nil)
16
+ super(message)
17
+ @status = status
18
+ @code = code
19
+ @retryable = retryable
20
+ @details = details
21
+ end
22
+ end
23
+
24
+ # 400 — the request body/params were malformed or violated a contract rule.
25
+ class ValidationError < StandardError
26
+ attr_reader :status, :code
27
+
28
+ def initialize(message = "Invalid request.")
29
+ super(message)
30
+ @status = 400
31
+ @code = "INVALID_REQUEST"
32
+ end
33
+ end
34
+
35
+ # 403 — the host application did not authorize this request. FORBIDDEN,
36
+ # not UNAUTHORIZED: that name belongs to the NIP-47 wallet layer.
37
+ class ForbiddenError < StandardError
38
+ attr_reader :status, :code
39
+
40
+ def initialize(message = "Forbidden.")
41
+ super(message)
42
+ @status = 403
43
+ @code = "FORBIDDEN"
44
+ end
45
+ end
46
+
47
+ # 404 — the order or checkout was not found.
48
+ class NotFoundError < StandardError
49
+ attr_reader :status, :code
50
+
51
+ def initialize(message = "Not found.")
52
+ super(message)
53
+ @status = 404
54
+ @code = "NOT_FOUND"
55
+ end
56
+ end
57
+
58
+ class ConflictError < StandardError
59
+ attr_reader :status, :code
60
+
61
+ def initialize(message = "Conflict.")
62
+ super(message)
63
+ @status = 409
64
+ @code = "CONFLICT"
65
+ end
66
+ end
67
+
68
+ # 405 — a known OpenReceive path called with the wrong HTTP method.
69
+ # Mirrors the JS router exactly: INVALID_REQUEST, no Allow header.
70
+ class MethodNotAllowedError < StandardError
71
+ attr_reader :status, :code
72
+
73
+ def initialize(message = "This OpenReceive route does not support that HTTP method.")
74
+ super(message)
75
+ @status = 405
76
+ @code = "INVALID_REQUEST"
77
+ end
78
+ end
79
+
80
+ # 500 INTERNAL raised deliberately (for example: the host resolved an
81
+ # order without an amount). Unlike an unexpected exception, the message is
82
+ # payer-safe by construction and stays on the wire — mirroring the JS
83
+ # handler's HttpError(500, "INTERNAL", ...).
84
+ class InternalHostError < StandardError
85
+ attr_reader :status, :code
86
+
87
+ def initialize(message = "Internal server error.")
88
+ super(message)
89
+ @status = 500
90
+ @code = "INTERNAL"
91
+ end
92
+ end
93
+
94
+ # 503 — infrastructure failed to persist the payment attempt (database
95
+ # down, host hook bug). Mirrors the JS handler's commit(): a retryable
96
+ # INTERNAL, never a payer-blaming conflict; the invoice is still withheld.
97
+ class HostPersistenceError < StandardError
98
+ attr_reader :status, :code, :retryable
99
+
100
+ def initialize(message = "The host could not persist this payment attempt; " \
101
+ "payer instructions were withheld. Please retry.")
102
+ super(message)
103
+ @status = 503
104
+ @code = "INTERNAL"
105
+ @retryable = true
106
+ end
107
+ end
108
+
109
+ # 413 — contract bodies are tiny; anything larger is rejected before any
110
+ # host callback runs (mirrors the JS handler's pre-auth body cap).
111
+ class PayloadTooLargeError < StandardError
112
+ attr_reader :status, :code
113
+
114
+ def initialize(message = "Request body is too large.")
115
+ super(message)
116
+ @status = 413
117
+ @code = "INVALID_REQUEST"
118
+ end
119
+ end
120
+
121
+ # 415 — the body-bearing routes accept `application/json` only. This is the
122
+ # CSRF-equivalent on cookie-authenticated mounts: a cross-site HTML form
123
+ # cannot set a JSON content type, and a cross-origin fetch that does is
124
+ # non-simple (CORS-preflighted, which the library never grants), so a
125
+ # forgery can never reach `authorize` with the victim's session. Rejected
126
+ # before authorize, like the body cap. Mirrors the JS handler.
127
+ class UnsupportedMediaTypeError < StandardError
128
+ attr_reader :status, :code
129
+
130
+ def initialize(message = "Request content type must be application/json.")
131
+ super(message)
132
+ @status = 415
133
+ @code = "INVALID_REQUEST"
134
+ end
135
+ end
136
+
137
+ # 429 — the payer exceeded the configured invoice-creation budget. Mirrors
138
+ # the JS handler: RATE_LIMITED with retryable: true so clients back off
139
+ # and retry instead of treating it as a permanent failure, plus a
140
+ # `Retry-After` hint (seconds) emitted as a response header.
141
+ class RateLimitedError < StandardError
142
+ attr_reader :status, :code, :retryable, :retry_after_seconds
143
+
144
+ def initialize(message = "Too many requests.")
145
+ super(message)
146
+ @status = 429
147
+ @code = "RATE_LIMITED"
148
+ @retryable = true
149
+ @retry_after_seconds = 60
150
+ end
151
+ end
152
+
153
+ # 501 — the host has not configured the capability this route needs
154
+ # (for example GET /rates without a price provider).
155
+ class NotImplementedHttpError < StandardError
156
+ attr_reader :status, :code
157
+
158
+ def initialize(message = "Not implemented.")
159
+ super(message)
160
+ @status = 501
161
+ @code = "NOT_IMPLEMENTED"
162
+ end
163
+ end
164
+
165
+ # Wallet/relay failure normalized per the shared error-normalization
166
+ # vectors. Carries the canonical code and retryable flag so browsers can
167
+ # distinguish "retry" from "bug" against Rails exactly as against Node.
168
+ class WalletFailureError < StandardError
169
+ attr_reader :status, :code, :retryable, :details
170
+
171
+ def initialize(normalized)
172
+ super(normalized.fetch("message"))
173
+ @code = normalized.fetch("code")
174
+ @retryable = normalized.fetch("retryable", false)
175
+ @details = normalized["details"]
176
+ @status = @retryable ? 503 : 502
177
+ end
178
+ end
179
+
180
+ # 502 — the wallet responded but violated the receive-checkout contract
181
+ # (for example minting an invoice that ignores the requested expiry).
182
+ class WalletContractError < StandardError
183
+ attr_reader :status, :code
184
+
185
+ def initialize(message = "Wallet violated the receive-checkout contract.")
186
+ super(message)
187
+ @status = 502
188
+ @code = "UNSUPPORTED_METHOD"
189
+ end
190
+ end
191
+
192
+ # Boot-time refusal: the connection offered an info method but preflight
193
+ # could not clear it — the read failed, the wallet cannot receive, or it
194
+ # speaks no encryption mode we support. Booting blind would defer the
195
+ # failure to the first customer checkout, so preflight fails closed
196
+ # (mirrors the JS WALLET_PREFLIGHT_FAILED config error).
197
+ class WalletPreflightError < StandardError
198
+ def initialize(reason)
199
+ super(
200
+ "OpenReceive wallet preflight failed: #{reason} Use a receive-only " \
201
+ "NWC connection advertising make_invoice and list_transactions."
202
+ )
203
+ end
204
+ end
205
+
206
+ # Boot-time refusal: the configured NWC connection advertises spend methods.
207
+ # OpenReceive is receive-only; a spend-capable code in a receive deployment
208
+ # is a live theft risk, so preflight fails closed instead of booting.
209
+ class SpendCapableWalletError < StandardError
210
+ def initialize(methods)
211
+ super(
212
+ "The configured NWC connection advertises spend methods " \
213
+ "(#{Array(methods).join(', ')}). OpenReceive is receive-only; use a " \
214
+ "receive-only NWC code, or override explicitly with " \
215
+ "config.allow_spend_capable_wallet = true or " \
216
+ "OPENRECEIVE_ALLOW_SPEND_CAPABLE_NWC=1."
217
+ )
218
+ end
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module OpenReceive
6
+ module Server
7
+ module LscUri
8
+ SCHEME = "lightning+swapconnect"
9
+ ENV_NAMES = %w[LSC_URI_PRIMARY LSC_URI_BACKUP].freeze
10
+ QUERY_PARAMETERS = %w[key secret].freeze
11
+ MAX_URI_LENGTH = 8192
12
+ MAX_CREDENTIAL_LENGTH = 2048
13
+
14
+ module_function
15
+
16
+ def parse(value)
17
+ input = credential(value, "LSC URI", MAX_URI_LENGTH)
18
+ uri = URI.parse(input)
19
+ raise ArgumentError, "LSC URI must use #{SCHEME}://." unless uri.scheme == SCHEME
20
+ raise ArgumentError, "LSC URI must not use URI userinfo." unless uri.userinfo.nil?
21
+ raise ArgumentError, "LSC URI requires a provider hostname." if uri.host.to_s.empty?
22
+ raise ArgumentError, "LSC URI must not contain a fragment." unless uri.fragment.nil?
23
+ raise ArgumentError, "LSC URI query encoding is invalid." if uri.query.to_s.match?(/%(?![0-9a-f]{2})/i)
24
+
25
+ begin
26
+ pairs = URI.decode_www_form(uri.query.to_s)
27
+ rescue ArgumentError
28
+ raise ArgumentError, "LSC URI query encoding is invalid."
29
+ end
30
+ unsupported = pairs.map(&:first).find { |name| !QUERY_PARAMETERS.include?(name) }
31
+ raise ArgumentError, "LSC URI contains an unsupported query parameter." unless unsupported.nil?
32
+
33
+ key = credential(single_parameter(pairs, "key"), "LSC URI key", MAX_CREDENTIAL_LENGTH)
34
+ secret = credential(single_parameter(pairs, "secret"), "LSC URI secret", MAX_CREDENTIAL_LENGTH)
35
+ path = normalize_path(uri.path)
36
+ port = uri.port.nil? ? "" : ":#{uri.port}"
37
+
38
+ {
39
+ "uri_protocol" => "#{SCHEME}:",
40
+ "base_url" => "https://#{uri.host}#{port}#{path}",
41
+ "provider_id" => provider_id(uri),
42
+ "key" => key,
43
+ "secret" => secret
44
+ }.freeze
45
+ rescue URI::InvalidURIError
46
+ raise ArgumentError, "LSC URI is not a valid absolute URI."
47
+ end
48
+
49
+ def read_environment(env = ENV)
50
+ provider_ids = {}
51
+ ENV_NAMES.each_with_object([]) do |name, connections|
52
+ value = env[name].to_s.strip
53
+ next if value.empty?
54
+
55
+ connection = parse(value)
56
+ id = connection.fetch("provider_id")
57
+ raise ArgumentError, "#{name} duplicates another LSC provider id." if provider_ids[id]
58
+
59
+ provider_ids[id] = true
60
+ connections << connection
61
+ rescue ArgumentError => e
62
+ raise ArgumentError, "#{name} is invalid: #{e.message}"
63
+ end.freeze
64
+ end
65
+
66
+ def single_parameter(pairs, name)
67
+ values = pairs.each_with_object([]) do |(key, value), matches|
68
+ matches << value if key == name
69
+ end
70
+ raise ArgumentError, "LSC URI requires exactly one #{name} parameter." unless values.length == 1
71
+
72
+ values.first
73
+ end
74
+ private_class_method :single_parameter
75
+
76
+ def credential(value, label, maximum_length)
77
+ normalized = value.to_s.strip
78
+ raise ArgumentError, "#{label} must not be empty." if normalized.empty?
79
+ raise ArgumentError, "#{label} is too long." if normalized.length > maximum_length
80
+
81
+ normalized
82
+ end
83
+ private_class_method :credential
84
+
85
+ def normalize_path(path)
86
+ return "/" if path.to_s.empty? || path == "/"
87
+
88
+ path.end_with?("/") ? path : "#{path}/"
89
+ end
90
+ private_class_method :normalize_path
91
+
92
+ def provider_id(uri)
93
+ path = uri.path.to_s.split("/").reject(&:empty?).join("-")
94
+ port = uri.port.nil? ? "" : "-#{uri.port}"
95
+ raw = "#{uri.host}#{port}#{path.empty? ? '' : "-#{path}"}"
96
+ .downcase.gsub(/[^a-z0-9_-]+/, "-").gsub(/\A-+|-+\z/, "")[0, 64]
97
+ raise ArgumentError, "LSC URI could not derive a provider id." if raw.to_s.empty?
98
+
99
+ raw
100
+ end
101
+ private_class_method :provider_id
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openreceive/server/client_ip"
5
+ require "openreceive/server/request_handler"
6
+
7
+ module OpenReceive
8
+ module Server
9
+ class RackApp
10
+ # Route paths (method-independent), used to answer a known path called
11
+ # with the wrong method with 405 instead of 404 — mirrors the JS router.
12
+ KNOWN_PATHS = [
13
+ "/checkouts/prepare", "/checkouts", "/payments/check", "/swaps/quote",
14
+ "/swaps", "/swaps/status", "/swaps/refunds", "/rates"
15
+ ].freeze
16
+
17
+ def initialize(service:, authorize:, resolve_checkout:, on_checkout_created:, on_paid:,
18
+ rate_limit: nil, client_ip: nil, prefix: "/openreceive")
19
+ @prefix = prefix.to_s.chomp("/")
20
+ raw_client_ip = client_ip || ->(request) { request.is_a?(Hash) ? request["REMOTE_ADDR"] : nil }
21
+ @handler = RequestHandler.new(
22
+ service: service,
23
+ authorize: authorize,
24
+ resolve_checkout: resolve_checkout,
25
+ on_checkout_created: on_checkout_created,
26
+ on_paid: on_paid,
27
+ rate_limit: rate_limit,
28
+ # Stamped IPs are normalized into the same bucket the limiter counts
29
+ # with (IPv6 /64, v4-mapped collapsed) — mirrors the JS handler.
30
+ client_ip: ->(request) { ClientIp.attributed(raw_client_ip.call(request)) }
31
+ )
32
+ end
33
+
34
+ def call(env)
35
+ # Always server-generated (matches JS): a client-supplied X-Request-Id
36
+ # is unvalidated content and must not be reflected into headers/logs.
37
+ request_id = "req_#{SecureRandom.uuid}"
38
+ path = env["PATH_INFO"].to_s
39
+ return response(@handler.error_response(NotFoundError.new("No OpenReceive route matched this method and path."), request_id)) unless path.start_with?(@prefix)
40
+ relative = path.delete_prefix(@prefix).sub(%r{/\z}, "")
41
+ # The body is read only after a route matches (an unknown path stays a
42
+ # 404 no matter how large the body is), mirroring the JS dispatch order.
43
+ triple = case [env["REQUEST_METHOD"], relative]
44
+ when ["POST", "/checkouts/prepare"] then @handler.prepare_checkout(raw_body: read_body(env), request: env, request_id: request_id)
45
+ when ["POST", "/checkouts"] then @handler.create_checkout(raw_body: read_body(env), request: env, request_id: request_id)
46
+ when ["POST", "/payments/check"] then @handler.check_payment(raw_body: read_body(env), request: env, request_id: request_id)
47
+ when ["POST", "/swaps/quote"] then @handler.quote_swap(raw_body: read_body(env), request: env, request_id: request_id)
48
+ when ["POST", "/swaps"] then @handler.create_swap(raw_body: read_body(env), request: env, request_id: request_id)
49
+ when ["POST", "/swaps/status"] then @handler.get_swap(raw_body: read_body(env), request: env, request_id: request_id)
50
+ when ["POST", "/swaps/refunds"] then @handler.refund_swap(raw_body: read_body(env), request: env, request_id: request_id)
51
+ when ["GET", "/rates"] then @handler.read_rates(query_string: env["QUERY_STRING"], request: env, request_id: request_id)
52
+ else @handler.error_response(unmatched_route_error(relative), request_id)
53
+ end
54
+ response(triple)
55
+ rescue StandardError => e
56
+ response(@handler.error_response(e, request_id))
57
+ end
58
+
59
+ private
60
+
61
+ # 405 INVALID_REQUEST for a known path with the wrong method, 404
62
+ # NOT_FOUND otherwise — status, code, and messages match the JS router
63
+ # (which sets no Allow header).
64
+ def unmatched_route_error(relative)
65
+ if KNOWN_PATHS.include?(relative)
66
+ MethodNotAllowedError.new
67
+ else
68
+ NotFoundError.new("No OpenReceive route matched this method and path.")
69
+ end
70
+ end
71
+
72
+ # Pre-auth body cap, mirroring the JS readJsonBody: an over-declared
73
+ # Content-Length is rejected before any read, and the read itself stops
74
+ # one byte past the cap — an unauthenticated payer can never stream an
75
+ # unbounded (chunked) body into memory.
76
+ def read_body(env)
77
+ input = env["rack.input"]
78
+ return "" if input.nil?
79
+ raise PayloadTooLargeError if env["CONTENT_LENGTH"].to_i > RequestHandler::MAX_BODY_BYTES
80
+ value = input.read(RequestHandler::MAX_BODY_BYTES + 1) || ""
81
+ input.rewind if input.respond_to?(:rewind)
82
+ raise PayloadTooLargeError if value.bytesize > RequestHandler::MAX_BODY_BYTES
83
+ value
84
+ end
85
+
86
+ def response(triple)
87
+ status, headers, body = triple
88
+ [status, headers, [JSON.generate(body)]]
89
+ end
90
+ end
91
+ end
92
+ end