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 +7 -0
- data/CHANGELOG.md +24 -0
- data/LICENSE +21 -0
- data/README.md +46 -0
- data/lib/openreceive/server/client_ip.rb +73 -0
- data/lib/openreceive/server/config.rb +46 -0
- data/lib/openreceive/server/errors.rb +221 -0
- data/lib/openreceive/server/lsc_uri.rb +104 -0
- data/lib/openreceive/server/rack_app.rb +92 -0
- data/lib/openreceive/server/reconciliation.rb +55 -0
- data/lib/openreceive/server/request_handler.rb +598 -0
- data/lib/openreceive/server/service.rb +775 -0
- data/lib/openreceive/server/swap/assets.rb +88 -0
- data/lib/openreceive/server/swap/fixedfloat.rb +831 -0
- data/lib/openreceive/server/swap/rates_feed.rb +311 -0
- data/lib/openreceive/server/swap/transient_cache.rb +106 -0
- data/lib/openreceive/server/swap/weight_budget.rb +115 -0
- data/lib/openreceive/server/swap.rb +141 -0
- data/lib/openreceive/server/version.rb +7 -0
- data/lib/openreceive/server/wallet_info.rb +69 -0
- data/lib/openreceive/server.rb +39 -0
- metadata +83 -0
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
|