Class: OpenReceive::Server::RequestHandler
- Inherits:
-
Object
- Object
- OpenReceive::Server::RequestHandler
- Defined in:
- lib/openreceive/server/request_handler.rb
Constant Summary collapse
- MAX_REFERENCE_LENGTH =
Spec-declared caps, mirroring the OpenAPI request schemas and the JS handler exactly (the second settlement engine must not drift).
200- MAX_MEMO_LENGTH =
500- MAX_BODY_BYTES =
64 * 1024
- ROUTE_BODY_FIELDS =
Declared fields per route (additionalProperties: false, snake_case only — camelCase aliases are rejected, matching JS).
{ "checkout.prepare" => %w[reference], "checkout.create" => %w[reference memo metadata], "payment.check" => %w[reference payment_hash], "swap.quote" => %w[reference pay_in_asset], "swap.create" => %w[reference pay_in_asset memo metadata], "swap.read" => %w[reference payment_hash], "swap.refund" => %w[reference payment_hash refund_address] }.freeze
Instance Method Summary collapse
-
#check_payment(raw_body:, request:, request_id:, reconcile_pass: nil, attempt_status: nil) ⇒ Object
Storage-aware callers (the Rails engine) pass
reconcile_pass— the request-level gated reconcile result ({ "reason" => "ran", "checks" } or a skip reason) — plusattempt_status, a proc mapping a payment hash to its persisted { "status", "paid_at"? }. - #create_checkout(raw_body:, request:, request_id:) ⇒ Object
- #create_swap(raw_body:, request:, request_id:) ⇒ Object
- #error_response(error, request_id) ⇒ Object
- #get_swap(raw_body:, request:, request_id:) ⇒ Object
-
#initialize(service:, authorize:, resolve_checkout:, on_checkout_created:, on_paid:, rate_limit: nil, client_ip: nil) ⇒ RequestHandler
constructor
client_ip(a proc receiving the framework request) attributes payer IPs for rate limiting and for stamping committed attempt rows; when it returns nil the limiter fails open for that request. -
#parse_rates_currencies(raw) ⇒ Object
The payer's ?currencies filter.
- #prepare_checkout(raw_body:, request:, request_id:) ⇒ Object
- #quote_swap(raw_body:, request:, request_id:) ⇒ Object
- #read_rates(query_string:, request:, request_id:) ⇒ Object
- #refund_swap(raw_body:, request:, request_id:) ⇒ Object
Constructor Details
#initialize(service:, authorize:, resolve_checkout:, on_checkout_created:, on_paid:, rate_limit: nil, client_ip: nil) ⇒ RequestHandler
client_ip (a proc receiving the framework request) attributes payer
IPs for rate limiting and for stamping committed attempt rows; when it
returns nil the limiter fails open for that request.
31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
# File 'lib/openreceive/server/request_handler.rb', line 31 def initialize(service:, authorize:, resolve_checkout:, on_checkout_created:, on_paid:, rate_limit: nil, client_ip: nil) raise ArgumentError, "authorize is required" if .nil? raise ArgumentError, "resolve_checkout is required" if resolve_checkout.nil? raise ArgumentError, "on_checkout_created is required" if on_checkout_created.nil? raise ArgumentError, "on_paid is required" if on_paid.nil? @service = service @authorize = @resolve_checkout = resolve_checkout @on_checkout_created = on_checkout_created @on_paid = on_paid @rate_limit = rate_limit @client_ip = client_ip end |
Instance Method Details
#check_payment(raw_body:, request:, request_id:, reconcile_pass: nil, attempt_status: nil) ⇒ Object
Storage-aware callers (the Rails engine) pass reconcile_pass — the
request-level gated reconcile result ({ "reason" => "ran", "checks" }
or a skip reason) — plus attempt_status, a proc mapping a payment
hash to its persisted { "status", "paid_at"? }. The requested hash is
then served from the pass (winner) or the host row (gate_busy / outside
the pending set / disabled) with details omitted — never a second
per-invoice wallet walk and never a second gate claim. Row attention
serves as pending on the wire (operator state, not payer
information); the row path never emits not_found. Storage-agnostic
callers (Server::RackApp) omit both and run a one-attempt
reconcile_payments walk.
104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 |
# File 'lib/openreceive/server/request_handler.rb', line 104 def check_payment(raw_body:, request:, request_id:, reconcile_pass: nil, attempt_status: nil) handle(request_id) do body = parse(raw_body, "payment.check", request: request) reference = required_reference(body) # Payer input is shape-validated BEFORE any host hook runs (mirrors # JS): guard/resolve never see an un-vetted selector. requested_hash = required_payment_hash(body["payment_hash"]) guard("payment.check", request, { reference: reference, payment_hash: requested_hash }) resolved = resolve_host("payment.check", request, reference, body) hash = selected_payment_hash(resolved, requested_hash) checkout = committed_checkout(reference, resolved) public_checked = if reconcile_pass.nil? checked_via_wallet(hash, checkout) else checked_from_pass(hash, reconcile_pass, attempt_status) end # Catalog warms on the first check; clients keep "Loading currencies…" # until payment_methods is present (even as an empty Lightning-only # list). Amount-aware, like the JS handler: limits are evaluated # against this attempt's committed invoice amount. payment_methods = @service.(amount_msats: checkout["amount_msats"]) success(200, public_checked.merge("payment_methods" => payment_methods), request_id) end end |
#create_checkout(raw_body:, request:, request_id:) ⇒ Object
60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 |
# File 'lib/openreceive/server/request_handler.rb', line 60 def create_checkout(raw_body:, request:, request_id:) handle(request_id) do body = parse(raw_body, "checkout.create", request: request) reference = required_reference(body) ("checkout.create", request, { reference: reference }) resolved = resolve_host("checkout.create", request, reference, body) # Rate limits meter minting only: re-serving an already-committed # attempt costs no wallet call, so a capped payer can still re-fetch # instructions they were already given (mirrors JS). enforce_rate_limit!("checkout.create", request, { reference: reference }) unless resolved["payment_hash"] checkout = if resolved["payment_hash"] committed_checkout(reference, resolved) else @service.create_checkout( "reference" => reference, "amount" => required_amount(resolved), "memo" => validated_memo(body), "metadata" => body["metadata"] ) end commit(checkout, nil, request) unless resolved["payment_hash"] # The catalog rides along with the mint for the same reason it rides # along with prepare and payments/check (mirrors the JS handler): a # client that just minted Lightning must not lose the pay-in options # it renders its picker from. Amount-aware, against this attempt's # committed invoice amount — and served on the re-fetch path too, so # a repeated create answers with the same shape it first did. payment_methods = @service.(amount_msats: checkout["amount_msats"]) body = { "checkout" => checkout, "payment_methods" => payment_methods } description = resolved_description(resolved) body["description"] = description if description success(201, body, request_id) end end |
#create_swap(raw_body:, request:, request_id:) ⇒ Object
141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 |
# File 'lib/openreceive/server/request_handler.rb', line 141 def create_swap(raw_body:, request:, request_id:) handle(request_id) do body = parse(raw_body, "swap.create", request: request) reference = required_reference(body) asset = required(body["pay_in_asset"], "pay_in_asset") ("swap.create", request, { reference: reference }) resolved = resolve_host("swap.create", request, reference, body, asset) enforce_rate_limit!("swap.create", request, { reference: reference }) unless resolved["payment_hash"] swap = if resolved["payment_hash"] data = required_swap_data(resolved["swap_data"]) status = @service.get_swap( reference: reference, payment_hash: resolved["payment_hash"], swap_data: data ) status.merge( "checkout" => committed_checkout(reference, resolved), "swap_data" => data ) else # Explicit, validated fields only: the raw payer body must # never reach the service (an "expiry_seconds" key would beat # the provider-mandated shadow-invoice expiry, and duplicate # order keys would split authorization from minting). @service.create_swap( "reference" => reference, "amount" => required_amount(resolved), "pay_in_asset" => asset, "memo" => validated_memo(body), "metadata" => body["metadata"] ) end commit(swap.fetch("checkout"), swap["swap_data"], request) unless resolved["payment_hash"] success(201, { "swap" => public_swap(swap) }, request_id) end end |
#error_response(error, request_id) ⇒ Object
221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 |
# File 'lib/openreceive/server/request_handler.rb', line 221 def error_response(error, request_id) # Only an error carrying a code from the canonical contract enum keeps # its status/code/message on the wire; anything else — a leaked library # exception with its own #code included — is redacted to an opaque 500, # exactly like the JS errorResponse fallthrough. code = error.respond_to?(:code) ? error.code : nil unless Nwc::ERROR_CODES.include?(code) report_unexpected_error(error, request_id) return [500, headers(request_id), { "code" => "INTERNAL", "message" => "Internal server error.", "request_id" => request_id }] end retryable = error.respond_to?(:retryable) ? error.retryable : nil if error.respond_to?(:status) && !error.status.nil? status = error.status else # A canonical code without a status is the wallet shape: a retryable # outage is a 503, an upstream refusal a 502 (mirrors the JS # isWalletErrorShape mapping). retryable = Nwc::RETRYABLE_ERROR_CODES.include?(code) if retryable.nil? status = retryable ? 503 : 502 end body = { "code" => code, "message" => error., "request_id" => request_id } body["retryable"] = retryable unless retryable.nil? body["details"] = error.details if error.respond_to?(:details) && error.details.is_a?(Hash) response_headers = headers(request_id) # Mirrors the JS handler: a Retry-After hint (whole seconds, minimum 1) # rides along with retryable throttling errors. if error.respond_to?(:retry_after_seconds) && !error.retry_after_seconds.nil? response_headers = response_headers.merge( "retry-after" => [1, error.retry_after_seconds.ceil].max.to_s ) end [status, response_headers, body.compact] end |
#get_swap(raw_body:, request:, request_id:) ⇒ Object
176 177 178 179 180 |
# File 'lib/openreceive/server/request_handler.rb', line 176 def get_swap(raw_body:, request:, request_id:) swap_action("swap.read", raw_body, request, request_id) do |reference, hash, data, _body| @service.get_swap(reference: reference, payment_hash: hash, swap_data: data) end end |
#parse_rates_currencies(raw) ⇒ Object
The payer's ?currencies filter. Shape is checked HERE, at the wire boundary and with the same message as the JS handler's ratesCurrencies, so a malformed entry is a 400 in both engines rather than falling through to the service's rates-unavailable path.
210 211 212 213 214 215 216 217 218 219 |
# File 'lib/openreceive/server/request_handler.rb', line 210 def parse_rates_currencies(raw) return nil if raw.nil? currencies = raw.split(",").map(&:strip).reject(&:empty?) if currencies.empty? || currencies.any? { |value| !/\A[A-Za-z]{3}\z/.match?(value) } raise ValidationError, "currencies must be a comma-separated list of three-letter currency codes." end currencies end |
#prepare_checkout(raw_body:, request:, request_id:) ⇒ Object
46 47 48 49 50 51 52 53 54 55 56 57 58 |
# File 'lib/openreceive/server/request_handler.rb', line 46 def prepare_checkout(raw_body:, request:, request_id:) handle(request_id) do body = parse(raw_body, "checkout.prepare", request: request) reference = required_reference(body) guard("checkout.prepare", request, { reference: reference }) resolved = resolve_host("checkout.prepare", request, reference, body) prepared = @service.prepare_checkout("amount" => required_amount(resolved)) body = prepared.merge("reference" => reference) description = resolved_description(resolved) body["description"] = description if description success(200, body, request_id) end end |
#quote_swap(raw_body:, request:, request_id:) ⇒ Object
130 131 132 133 134 135 136 137 138 139 |
# File 'lib/openreceive/server/request_handler.rb', line 130 def quote_swap(raw_body:, request:, request_id:) handle(request_id) do body = parse(raw_body, "swap.quote", request: request) reference = required_reference(body) asset = required(body["pay_in_asset"], "pay_in_asset") guard("swap.quote", request, { reference: reference }) resolved = resolve_host("swap.quote", request, reference, body, asset) success(200, @service.quote_swap("amount" => required_amount(resolved), "pay_in_asset" => asset), request_id) end end |
#read_rates(query_string:, request:, request_id:) ⇒ Object
193 194 195 196 197 198 199 200 201 202 203 204 |
# File 'lib/openreceive/server/request_handler.rb', line 193 def read_rates(query_string:, request:, request_id:) handle(request_id) do pairs = begin URI.decode_www_form(query_string.to_s) rescue ArgumentError [] end raw = pairs.filter_map { |key, value| value if key == "currencies" }.first currencies = parse_rates_currencies(raw) success(200, @service.list_rates(currencies.nil? ? {} : { "currencies" => currencies }), request_id) end end |
#refund_swap(raw_body:, request:, request_id:) ⇒ Object
182 183 184 185 186 187 188 189 190 191 |
# File 'lib/openreceive/server/request_handler.rb', line 182 def refund_swap(raw_body:, request:, request_id:) swap_action("swap.refund", raw_body, request, request_id) do |reference, hash, data, body| @service.refund_swap( reference: reference, payment_hash: hash, swap_data: data, refund_address: required(body["refund_address"], "refund_address") ) end end |