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.
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenReceive
4
+ module Server
5
+ # Terminal-transition decisions for one non-settled reconciliation result.
6
+ # Mirrors spec/test-vectors/attempt-reconciliation.json exactly: closure of
7
+ # an unpaid attempt requires a successful wallet scan observed at or after
8
+ # expiry plus the grace window — a local clock alone never closes a row.
9
+ module Reconciliation
10
+ # Seconds past an attempt's expiry during which reconciliation still scans
11
+ # for a settlement before closing the attempt. Covers clock skew and
12
+ # wallets that accept a payment moments after nominal invoice expiry.
13
+ # The value 900 is pinned by spec/test-vectors/attempt-reconciliation.json
14
+ # ("expiry_grace_seconds", asserted by tools/conformance/ruby-crosslang.rb)
15
+ # and mirrored by JS OPENRECEIVE_ATTEMPT_EXPIRY_GRACE_SECONDS.
16
+ EXPIRY_GRACE_SECONDS = 900
17
+
18
+ module_function
19
+
20
+ # Returns { "status" =>, "reason" => } to persist, or nil to keep the
21
+ # attempt pending. Settled results never reach this decision; they deliver
22
+ # settlement instead. transaction_state is the explicit state field on the
23
+ # wallet's transaction record, when the scan found one; it decides whether
24
+ # a pending result past expiry plus grace is an operator-attention case or
25
+ # just an abandoned invoice.
26
+ def transition(expires_at:, status:, observed_at:, transaction_state: nil)
27
+ case status.to_s
28
+ when "failed"
29
+ { "status" => "failed", "reason" => "wallet_reported_failed" }
30
+ when "expired"
31
+ { "status" => "expired", "reason" => "wallet_reported_expired" }
32
+ when "not_found", "pending"
33
+ # The invoice may outlive the requested expiry, so closure waits for a
34
+ # scan past expiry plus grace instead of trusting the local clock alone.
35
+ return nil if Integer(observed_at) < Integer(expires_at) + EXPIRY_GRACE_SECONDS
36
+
37
+ if status.to_s == "not_found"
38
+ { "status" => "expired", "reason" => "not_found_after_expiry" }
39
+ elsif %w[pending accepted].include?(transaction_state.to_s)
40
+ # `attention` requires the wallet's EXPLICIT claim that the
41
+ # transaction is still in flight long after expiry.
42
+ { "status" => "attention", "reason" => "unsettled_after_expiry" }
43
+ else
44
+ # NIP-47 state fields are optional and the unpaid scan lists unpaid
45
+ # invoices, so a state-less record is indistinguishable from an
46
+ # ordinary abandoned invoice — close it as expired.
47
+ { "status" => "expired", "reason" => "no_finality_after_expiry" }
48
+ end
49
+ else
50
+ raise ArgumentError, "unexpected reconciliation status: #{status}"
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,598 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "uri"
6
+ require "openreceive"
7
+
8
+ module OpenReceive
9
+ module Server
10
+ class RequestHandler
11
+ # Spec-declared caps, mirroring the OpenAPI request schemas and the JS
12
+ # handler exactly (the second settlement engine must not drift).
13
+ MAX_REFERENCE_LENGTH = 200
14
+ MAX_MEMO_LENGTH = 500
15
+ MAX_BODY_BYTES = 64 * 1024
16
+ # Declared fields per route (additionalProperties: false, snake_case
17
+ # only — camelCase aliases are rejected, matching JS).
18
+ ROUTE_BODY_FIELDS = {
19
+ "checkout.prepare" => %w[reference],
20
+ "checkout.create" => %w[reference memo metadata],
21
+ "payment.check" => %w[reference payment_hash],
22
+ "swap.quote" => %w[reference pay_in_asset],
23
+ "swap.create" => %w[reference pay_in_asset memo metadata],
24
+ "swap.read" => %w[reference payment_hash],
25
+ "swap.refund" => %w[reference payment_hash refund_address]
26
+ }.freeze
27
+
28
+ # `client_ip` (a proc receiving the framework request) attributes payer
29
+ # IPs for rate limiting and for stamping committed attempt rows; when it
30
+ # returns nil the limiter fails open for that request.
31
+ def initialize(service:, authorize:, resolve_checkout:, on_checkout_created:, on_paid:,
32
+ rate_limit: nil, client_ip: nil)
33
+ raise ArgumentError, "authorize is required" if authorize.nil?
34
+ raise ArgumentError, "resolve_checkout is required" if resolve_checkout.nil?
35
+ raise ArgumentError, "on_checkout_created is required" if on_checkout_created.nil?
36
+ raise ArgumentError, "on_paid is required" if on_paid.nil?
37
+ @service = service
38
+ @authorize = authorize
39
+ @resolve_checkout = resolve_checkout
40
+ @on_checkout_created = on_checkout_created
41
+ @on_paid = on_paid
42
+ @rate_limit = rate_limit
43
+ @client_ip = client_ip
44
+ end
45
+
46
+ def prepare_checkout(raw_body:, request:, request_id:)
47
+ handle(request_id) do
48
+ body = parse(raw_body, "checkout.prepare", request: request)
49
+ reference = required_reference(body)
50
+ guard("checkout.prepare", request, { reference: reference })
51
+ resolved = resolve_host("checkout.prepare", request, reference, body)
52
+ prepared = @service.prepare_checkout("amount" => required_amount(resolved))
53
+ success(200, prepared.merge("reference" => reference), request_id)
54
+ end
55
+ end
56
+
57
+ def create_checkout(raw_body:, request:, request_id:)
58
+ handle(request_id) do
59
+ body = parse(raw_body, "checkout.create", request: request)
60
+ reference = required_reference(body)
61
+ authorize!("checkout.create", request, { reference: reference })
62
+ resolved = resolve_host("checkout.create", request, reference, body)
63
+ # Rate limits meter minting only: re-serving an already-committed
64
+ # attempt costs no wallet call, so a capped payer can still re-fetch
65
+ # instructions they were already given (mirrors JS).
66
+ enforce_rate_limit!("checkout.create", request, { reference: reference }) unless resolved["payment_hash"]
67
+ checkout = if resolved["payment_hash"]
68
+ committed_checkout(reference, resolved)
69
+ else
70
+ @service.create_checkout(
71
+ "reference" => reference, "amount" => required_amount(resolved),
72
+ "memo" => validated_memo(body), "metadata" => body["metadata"]
73
+ )
74
+ end
75
+ commit(checkout, nil, request) unless resolved["payment_hash"]
76
+ success(201, { "checkout" => checkout }, request_id)
77
+ end
78
+ end
79
+
80
+ # Storage-aware callers (the Rails engine) pass `reconcile_pass` — the
81
+ # request-level gated reconcile result ({ "reason" => "ran", "checks" }
82
+ # or a skip reason) — plus `attempt_status`, a proc mapping a payment
83
+ # hash to its persisted { "status", "paid_at"? }. The requested hash is
84
+ # then served from the pass (winner) or the host row (gate_busy / outside
85
+ # the pending set / disabled) with `details` omitted — never a second
86
+ # per-invoice wallet walk and never a second gate claim. Row `attention`
87
+ # serves as `pending` on the wire (operator state, not payer
88
+ # information); the row path never emits `not_found`. Storage-agnostic
89
+ # callers (Server::RackApp) omit both and run a one-attempt
90
+ # reconcile_payments walk.
91
+ def check_payment(raw_body:, request:, request_id:, reconcile_pass: nil, attempt_status: nil)
92
+ handle(request_id) do
93
+ body = parse(raw_body, "payment.check", request: request)
94
+ reference = required_reference(body)
95
+ # Payer input is shape-validated BEFORE any host hook runs (mirrors
96
+ # JS): guard/resolve never see an un-vetted selector.
97
+ requested_hash = required_payment_hash(body["payment_hash"])
98
+ guard("payment.check", request, { reference: reference, payment_hash: requested_hash })
99
+ resolved = resolve_host("payment.check", request, reference, body)
100
+ hash = selected_payment_hash(resolved, requested_hash)
101
+ checkout = committed_checkout(reference, resolved)
102
+ public_checked =
103
+ if reconcile_pass.nil?
104
+ checked_via_wallet(hash, checkout)
105
+ else
106
+ checked_from_pass(hash, reconcile_pass, attempt_status)
107
+ end
108
+ # Catalog warms on the first check; clients keep "Loading currencies…"
109
+ # until payment_methods is present (even as an empty Lightning-only
110
+ # list). Amount-aware, like the JS handler: limits are evaluated
111
+ # against this attempt's committed invoice amount.
112
+ payment_methods = @service.list_swap_options(amount_msats: checkout["amount_msats"])
113
+ success(200, public_checked.merge("payment_methods" => payment_methods), request_id)
114
+ end
115
+ end
116
+
117
+ def quote_swap(raw_body:, request:, request_id:)
118
+ handle(request_id) do
119
+ body = parse(raw_body, "swap.quote", request: request)
120
+ reference = required_reference(body)
121
+ asset = required(body["pay_in_asset"], "pay_in_asset")
122
+ guard("swap.quote", request, { reference: reference })
123
+ resolved = resolve_host("swap.quote", request, reference, body, asset)
124
+ success(200, @service.quote_swap("amount" => required_amount(resolved), "pay_in_asset" => asset), request_id)
125
+ end
126
+ end
127
+
128
+ def create_swap(raw_body:, request:, request_id:)
129
+ handle(request_id) do
130
+ body = parse(raw_body, "swap.create", request: request)
131
+ reference = required_reference(body)
132
+ asset = required(body["pay_in_asset"], "pay_in_asset")
133
+ authorize!("swap.create", request, { reference: reference })
134
+ resolved = resolve_host("swap.create", request, reference, body, asset)
135
+ enforce_rate_limit!("swap.create", request, { reference: reference }) unless resolved["payment_hash"]
136
+ swap = if resolved["payment_hash"]
137
+ data = required_swap_data(resolved["swap_data"])
138
+ status = @service.get_swap(
139
+ reference: reference, payment_hash: resolved["payment_hash"], swap_data: data
140
+ )
141
+ status.merge(
142
+ "checkout" => committed_checkout(reference, resolved),
143
+ "swap_data" => data
144
+ )
145
+ else
146
+ # Explicit, validated fields only: the raw payer body must
147
+ # never reach the service (an "expiry_seconds" key would beat
148
+ # the provider-mandated shadow-invoice expiry, and duplicate
149
+ # order keys would split authorization from minting).
150
+ @service.create_swap(
151
+ "reference" => reference,
152
+ "amount" => required_amount(resolved),
153
+ "pay_in_asset" => asset,
154
+ "memo" => validated_memo(body),
155
+ "metadata" => body["metadata"]
156
+ )
157
+ end
158
+ commit(swap.fetch("checkout"), swap["swap_data"], request) unless resolved["payment_hash"]
159
+ success(201, { "swap" => public_swap(swap) }, request_id)
160
+ end
161
+ end
162
+
163
+ def get_swap(raw_body:, request:, request_id:)
164
+ swap_action("swap.read", raw_body, request, request_id) do |reference, hash, data, _body|
165
+ @service.get_swap(reference: reference, payment_hash: hash, swap_data: data)
166
+ end
167
+ end
168
+
169
+ def refund_swap(raw_body:, request:, request_id:)
170
+ swap_action("swap.refund", raw_body, request, request_id) do |reference, hash, data, body|
171
+ @service.refund_swap(
172
+ reference: reference,
173
+ payment_hash: hash,
174
+ swap_data: data,
175
+ refund_address: required(body["refund_address"], "refund_address")
176
+ )
177
+ end
178
+ end
179
+
180
+ def read_rates(query_string:, request:, request_id:)
181
+ handle(request_id) do
182
+ pairs = begin
183
+ URI.decode_www_form(query_string.to_s)
184
+ rescue ArgumentError
185
+ []
186
+ end
187
+ raw = pairs.filter_map { |key, value| value if key == "currencies" }.first
188
+ currencies = parse_rates_currencies(raw)
189
+ success(200, @service.list_rates(currencies.nil? ? {} : { "currencies" => currencies }), request_id)
190
+ end
191
+ end
192
+
193
+ # The payer's ?currencies filter. Shape is checked HERE, at the wire
194
+ # boundary and with the same message as the JS handler's ratesCurrencies,
195
+ # so a malformed entry is a 400 in both engines rather than falling
196
+ # through to the service's rates-unavailable path.
197
+ def parse_rates_currencies(raw)
198
+ return nil if raw.nil?
199
+
200
+ currencies = raw.split(",").map(&:strip).reject(&:empty?)
201
+ if currencies.empty? || currencies.any? { |value| !/\A[A-Za-z]{3}\z/.match?(value) }
202
+ raise ValidationError,
203
+ "currencies must be a comma-separated list of three-letter currency codes."
204
+ end
205
+ currencies
206
+ end
207
+
208
+ def error_response(error, request_id)
209
+ # Only an error carrying a code from the canonical contract enum keeps
210
+ # its status/code/message on the wire; anything else — a leaked library
211
+ # exception with its own #code included — is redacted to an opaque 500,
212
+ # exactly like the JS errorResponse fallthrough.
213
+ code = error.respond_to?(:code) ? error.code : nil
214
+ unless Nwc::ERROR_CODES.include?(code)
215
+ report_unexpected_error(error, request_id)
216
+ return [500, headers(request_id),
217
+ { "code" => "INTERNAL", "message" => "Internal server error.", "request_id" => request_id }]
218
+ end
219
+ retryable = error.respond_to?(:retryable) ? error.retryable : nil
220
+ if error.respond_to?(:status) && !error.status.nil?
221
+ status = error.status
222
+ else
223
+ # A canonical code without a status is the wallet shape: a retryable
224
+ # outage is a 503, an upstream refusal a 502 (mirrors the JS
225
+ # isWalletErrorShape mapping).
226
+ retryable = Nwc::RETRYABLE_ERROR_CODES.include?(code) if retryable.nil?
227
+ status = retryable ? 503 : 502
228
+ end
229
+ body = { "code" => code, "message" => error.message, "request_id" => request_id }
230
+ body["retryable"] = retryable unless retryable.nil?
231
+ body["details"] = error.details if error.respond_to?(:details) && error.details.is_a?(Hash)
232
+ response_headers = headers(request_id)
233
+ # Mirrors the JS handler: a Retry-After hint (whole seconds, minimum 1)
234
+ # rides along with retryable throttling errors.
235
+ if error.respond_to?(:retry_after_seconds) && !error.retry_after_seconds.nil?
236
+ response_headers = response_headers.merge(
237
+ "retry-after" => [1, error.retry_after_seconds.ceil].max.to_s
238
+ )
239
+ end
240
+ [status, response_headers, body.compact]
241
+ end
242
+
243
+ private
244
+
245
+ # Status refresh with no request-level pass: one-attempt reconcile_payments,
246
+ # delivering settlement inline. A truncated walk raises WalletUnavailableError
247
+ # (retryable 503) rather than reporting not_found.
248
+ def checked_via_wallet(hash, checkout)
249
+ checked = @service.reconcile_payments(
250
+ "attempts" => [{ "payment_hash" => hash, "created_at" => checkout.fetch("created_at") }]
251
+ ).first
252
+ if checked.nil?
253
+ raise OpenReceive::WalletUnavailableError,
254
+ "payment reconciliation did not complete: the wallet history " \
255
+ "walk ended before this invoice could be confirmed"
256
+ end
257
+ if checked["status"] == "settled" && checked["paid_at"]
258
+ @on_paid.call(
259
+ "payment_hash" => checked.fetch("payment_hash"),
260
+ "paid_at" => checked.fetch("paid_at"),
261
+ "details" => checked["details"]
262
+ )
263
+ end
264
+ details = checked["details"]
265
+ public_checked = checked.reject { |key, _| key == "details" }
266
+ public_checked["details"] = public_payment_details(details) unless details.nil?
267
+ public_checked
268
+ end
269
+
270
+ # Status refresh from the request-level gated pass. The winner serves the
271
+ # requested hash straight from the pass results (settlement was already
272
+ # delivered inside the pass); every other outcome serves the persisted
273
+ # row via attempt_status with `details` omitted — `details` stays
274
+ # contract-optional and no wallet snapshot is persisted just to make the
275
+ # two paths uniform.
276
+ def checked_from_pass(hash, reconcile_pass, attempt_status)
277
+ if reconcile_pass["reason"] == "ran"
278
+ checked = Array(reconcile_pass["checks"]).find do |check|
279
+ check["payment_hash"].to_s.downcase == hash.to_s.downcase
280
+ end
281
+ # A `not_found` pass result falls through to the row. A wallet that
282
+ # ignores `unpaid: true` omits a live invoice from the scan, and
283
+ # serving not_found here would flap a pending attempt between
284
+ # gate-winning and gate-busy requests.
285
+ unless checked.nil? || checked["status"].to_s == "not_found"
286
+ details = checked["details"]
287
+ public_checked = checked.reject { |key, _| key == "details" }
288
+ public_checked["details"] = public_payment_details(details) unless details.nil?
289
+ return public_checked
290
+ end
291
+ end
292
+ row = attempt_status&.call(hash)
293
+ # resolve_host selected this hash from the same repository moments ago.
294
+ raise NotFoundError, "Payment attempt not found for this reference." if row.nil?
295
+
296
+ # Row `attention` serves as `pending` on the wire (operator state, not
297
+ # payer information); the row path never emits `not_found`.
298
+ status = row["status"].to_s == "attention" ? "pending" : row["status"].to_s
299
+ public_checked = { "payment_hash" => hash.to_s.downcase, "status" => status }
300
+ public_checked["paid_at"] = Integer(row["paid_at"]) if row["paid_at"]
301
+ public_checked
302
+ end
303
+
304
+ def swap_action(action, raw_body, request, request_id)
305
+ handle(request_id) do
306
+ body = parse(raw_body, action, request: request)
307
+ reference = required_reference(body)
308
+ # Shape-validated before guard/resolve, matching JS: host hooks
309
+ # never receive an un-vetted payer selector.
310
+ requested_hash = required_payment_hash(body["payment_hash"])
311
+ guard(action, request, { reference: reference, payment_hash: requested_hash })
312
+ resolved = resolve_host(action, request, reference, body)
313
+ hash = selected_payment_hash(resolved, requested_hash)
314
+ success(200, yield(reference, hash, required_swap_data(resolved["swap_data"]), body), request_id)
315
+ end
316
+ end
317
+
318
+ def resolve_host(action, request, reference, body, pay_in_asset = nil)
319
+ args = { action: action, request: request, reference: reference, input: body }
320
+ args[:pay_in_asset] = pay_in_asset unless pay_in_asset.nil?
321
+ OpenReceive.stringify(@resolve_checkout.call(**args))
322
+ end
323
+
324
+ def guard(action, request, resource)
325
+ enforce_rate_limit!(action, request, resource)
326
+ authorize!(action, request, resource)
327
+ end
328
+
329
+ # Create actions call these separately: authorize first, then the rate
330
+ # limit only once the host has resolved that a new attempt must be
331
+ # minted (reuse is exempt) — the same split as the JS handler.
332
+ def enforce_rate_limit!(action, request, resource)
333
+ return if @rate_limit.nil?
334
+ context = { action: action, request: request, resource: resource }
335
+ raise RateLimitedError unless @rate_limit.call(context)
336
+ end
337
+
338
+ def authorize!(action, request, resource)
339
+ context = { action: action, request: request, resource: resource }
340
+ raise ForbiddenError, "Not authorized for this action." unless @authorize.call(context)
341
+ end
342
+
343
+ def commit(checkout, swap_data = nil, request = nil)
344
+ client_ip = @client_ip&.call(request)
345
+ @on_checkout_created.call(
346
+ reference: checkout.fetch("reference"),
347
+ payment_hash: checkout.fetch("payment_hash"),
348
+ checkout: checkout,
349
+ swap_data: swap_data,
350
+ client_ip: client_ip
351
+ )
352
+ rescue StandardError => e
353
+ # Meaningful repository refusals ("already paid", "live attempt for
354
+ # the same method") carry their own status/code and pass through
355
+ # untouched. Anything else is infrastructure failing to persist
356
+ # (database down, bug): retryable 503, never a payer-blaming conflict
357
+ # — mirrors the JS handler's commit().
358
+ raise e if e.respond_to?(:status) && e.respond_to?(:code)
359
+ raise HostPersistenceError
360
+ end
361
+
362
+ def public_swap(swap)
363
+ swap.reject { |key, _| key == "swap_data" }
364
+ end
365
+
366
+ # Payer-facing subset of a settlement's wallet details, whitelisted
367
+ # field-for-field from the JS handler's publicPaymentDetails: the raw
368
+ # wallet transaction carries the preimage, full invoice, and wallet
369
+ # metadata — none of which belong in a browser-polled response.
370
+ # The keys a normalized NwcTransaction actually carries. `state`,
371
+ # `amount` and `fees_paid` were never on it (they normalize to
372
+ # transaction_state / amount_msats / fees_paid_msats), so whitelisting
373
+ # them silently omitted the very fields this list exists to expose. The
374
+ # JS pick list is now the same, field for field.
375
+ # Never widen this to preimage or invoice: those stay server-side.
376
+ PUBLIC_TRANSACTION_FIELDS = %w[
377
+ payment_hash transaction_state amount_msats fees_paid_msats
378
+ created_at settled_at expires_at
379
+ ].freeze
380
+
381
+ def public_payment_details(details)
382
+ data = OpenReceive.stringify(details)
383
+ result = {}
384
+ transaction = data["transaction"]
385
+ if transaction.respond_to?(:each_pair)
386
+ rows = OpenReceive.as_string_keys(transaction)
387
+ result["transaction"] = PUBLIC_TRANSACTION_FIELDS.each_with_object({}) do |field, picked|
388
+ picked[field] = rows[field] unless rows[field].nil?
389
+ end
390
+ end
391
+ result["observed_at"] = data["observed_at"]
392
+ result["paid_at_source"] = data["paid_at_source"] unless data["paid_at_source"].nil?
393
+ result
394
+ end
395
+
396
+ def handle(request_id)
397
+ yield
398
+ rescue StandardError, NotImplementedError => e
399
+ error_response(e, request_id)
400
+ end
401
+
402
+ # Redacting an unexpected exception must not also swallow it: the host's
403
+ # error reporter (Rails.error, which feeds Sentry/Honeybadger/the Rails
404
+ # error subscribers) or logger receives it before the opaque 500 goes on
405
+ # the wire. The fallback log line carries class and origin only — never
406
+ # the message, which could quote request bodies, NWC URIs, invoices, or
407
+ # preimages.
408
+ def report_unexpected_error(error, request_id)
409
+ if defined?(::Rails) && ::Rails.respond_to?(:error) && ::Rails.error
410
+ ::Rails.error.report(error, handled: true, source: "openreceive")
411
+ else
412
+ origin = Array(error.backtrace).first
413
+ line = "[openreceive] unexpected #{error.class} (request_id=#{request_id})" \
414
+ "#{origin.nil? ? '' : " at #{origin}"}"
415
+ logger = defined?(::Rails) && ::Rails.respond_to?(:logger) ? ::Rails.logger : nil
416
+ logger.nil? ? warn(line) : logger.error(line)
417
+ end
418
+ rescue StandardError
419
+ nil
420
+ end
421
+
422
+ def parse(raw, route = nil, request: nil)
423
+ assert_not_cross_site!(request)
424
+ assert_json_content_type!(request)
425
+ text = raw.to_s
426
+ raise PayloadTooLargeError if text.bytesize > MAX_BODY_BYTES
427
+ value = text.strip.empty? ? {} : JSON.parse(text)
428
+ raise ValidationError, "Request body must be a JSON object." unless value.is_a?(Hash)
429
+ assert_declared_fields!(value, route)
430
+ value
431
+ rescue JSON::ParserError
432
+ raise ValidationError, "Request body must be a JSON object."
433
+ end
434
+
435
+ # The body-bearing routes accept `application/json` only, checked before
436
+ # authorize or any host hook. This is the CSRF-equivalent on
437
+ # cookie-authenticated mounts: a cross-site HTML form cannot set a JSON
438
+ # content type (only urlencoded, multipart, or text/plain), and a
439
+ # cross-origin fetch that does is non-simple and CORS-preflighted — which
440
+ # the library never answers — so a forged request with the victim's
441
+ # session can never carry a JSON body here. Reads the content type from a
442
+ # Rack env hash (CONTENT_TYPE) or a framework request object (Rails
443
+ # #content_type). Parameters and charset are ignored.
444
+ def assert_json_content_type!(request)
445
+ content_type =
446
+ if request.is_a?(Hash)
447
+ request["CONTENT_TYPE"]
448
+ elsif request.respond_to?(:content_type)
449
+ request.content_type
450
+ elsif request.respond_to?(:get_header)
451
+ request.get_header("CONTENT_TYPE")
452
+ end
453
+ return if content_type.to_s.split(";").first.to_s.strip.downcase == "application/json"
454
+
455
+ raise UnsupportedMediaTypeError
456
+ end
457
+
458
+ # Browsers label every request with its initiator's relation to the
459
+ # target (Sec-Fetch-Site), and a forged request from another site is
460
+ # always "cross-site" — including a no-cors fetch, which the content-type
461
+ # gate alone cannot see. The mounted routes serve the host's own pages,
462
+ # so a cross-site POST is refused before the body is read. "same-site"
463
+ # (a sibling subdomain) and an absent header (non-browser clients, old
464
+ # browsers — the content-type gate covers those) pass. Mirrors the JS
465
+ # handler's assertNotCrossSite.
466
+ def assert_not_cross_site!(request)
467
+ site =
468
+ if request.is_a?(Hash)
469
+ request["HTTP_SEC_FETCH_SITE"]
470
+ elsif request.respond_to?(:get_header)
471
+ request.get_header("HTTP_SEC_FETCH_SITE")
472
+ elsif request.respond_to?(:headers)
473
+ request.headers["Sec-Fetch-Site"]
474
+ end
475
+ return unless site.to_s.strip.downcase == "cross-site"
476
+
477
+ raise ForbiddenError, "Cross-site requests are not accepted."
478
+ end
479
+
480
+ def assert_declared_fields!(body, route)
481
+ allowed = ROUTE_BODY_FIELDS[route]
482
+ return if allowed.nil?
483
+ # A payer-supplied amount is the one undeclared field worth naming: the
484
+ # generic "unexpected field" message reads like a typo when the caller
485
+ # is actually reaching for the price authority. Every route in
486
+ # ROUTE_BODY_FIELDS passes through here, so this is THE gate — routes
487
+ # do not repeat it.
488
+ reject_payer_amount(body)
489
+ body.each_key do |key|
490
+ unless allowed.include?(key)
491
+ raise ValidationError, "Unexpected request field for this route: #{key}."
492
+ end
493
+ end
494
+ end
495
+
496
+ def required(value, field)
497
+ text = value.to_s.strip
498
+ raise ValidationError, "#{field} is required." if text.empty?
499
+ text
500
+ end
501
+
502
+ def required_reference(body)
503
+ reference = required(body["reference"], "reference")
504
+ if reference.length > MAX_REFERENCE_LENGTH
505
+ raise ValidationError, "reference must be #{MAX_REFERENCE_LENGTH} characters or fewer."
506
+ end
507
+ reference
508
+ end
509
+
510
+ def validated_memo(body)
511
+ memo = body["memo"]
512
+ if memo.is_a?(String) && memo.length > MAX_MEMO_LENGTH
513
+ raise ValidationError, "memo must be #{MAX_MEMO_LENGTH} characters or fewer."
514
+ end
515
+ memo
516
+ end
517
+
518
+ def required_amount(resolved)
519
+ amount = resolved["amount"]
520
+ if amount.nil?
521
+ # A host order without an amount is a host-integration bug, not a
522
+ # payer mistake: 500 INTERNAL with the JS handler's exact message.
523
+ raise InternalHostError, "The host resolved this order without an amount."
524
+ end
525
+ amount
526
+ end
527
+
528
+ def required_payment_hash(value)
529
+ hash = required(value, "payment_hash").downcase
530
+ unless /\A[0-9a-f]{64}\z/.match?(hash)
531
+ raise ValidationError, "payment_hash must be 64 hexadecimal characters."
532
+ end
533
+ hash
534
+ end
535
+
536
+ def required_swap_data(value)
537
+ raise NotFoundError, "The host order has no swap data." if value.nil?
538
+ unless value.is_a?(Hash)
539
+ raise ValidationError, "The host order's swap data is not a valid swap_data object."
540
+ end
541
+ value
542
+ end
543
+
544
+ def selected_payment_hash(resolved, requested_hash)
545
+ selected = host_payment_hash(resolved["payment_hash"])
546
+ return selected if selected == requested_hash
547
+
548
+ raise NotFoundError, "The selected payment attempt does not belong to this order."
549
+ end
550
+
551
+ # A payment hash the HOST resolver returned. A missing or malformed value
552
+ # here is a host integration bug, never payer input: it surfaces as a 500
553
+ # naming the host, not as a payer-blaming 400. Same ruling and the same
554
+ # wire message as the JS handler's hostPaymentHash.
555
+ def host_payment_hash(value)
556
+ hash = value.is_a?(String) ? value.strip.downcase : nil
557
+ return hash if !hash.nil? && /\A[0-9a-f]{64}\z/.match?(hash)
558
+
559
+ raise InternalHostError,
560
+ "The host resolver returned a missing or malformed payment hash for this reference."
561
+ end
562
+
563
+ def reject_payer_amount(body)
564
+ return unless body.key?("amount") || body.key?("amount_msats")
565
+ raise ValidationError, "This route does not accept a payer-supplied amount; the host resolves its order price."
566
+ end
567
+
568
+ def committed_checkout(reference, resolved)
569
+ checkout = resolved["checkout"]
570
+ unless checkout.is_a?(Hash)
571
+ raise ConflictError, "The host payment attempt has no checkout snapshot."
572
+ end
573
+
574
+ data = OpenReceive.as_string_keys(checkout)
575
+ # Both hashes come from the host snapshot and the host resolver, so a
576
+ # missing or malformed one is host data, not payer input.
577
+ hash = host_payment_hash(data["payment_hash"])
578
+ selected = host_payment_hash(resolved["payment_hash"])
579
+ checkout_order = required(data["reference"], "reference")
580
+ if hash != selected || checkout_order != reference
581
+ raise ConflictError, "The selected payment attempt is not a reusable pending checkout."
582
+ end
583
+ data
584
+ rescue ArgumentError, TypeError
585
+ raise ConflictError, "The selected payment attempt is not a reusable pending checkout."
586
+ end
587
+
588
+ def success(status, body, request_id)
589
+ [status, headers(request_id), body]
590
+ end
591
+
592
+ def headers(request_id)
593
+ # Lowercase keys: the Rack 3 SPEC requires them; Rails normalizes.
594
+ { "content-type" => "application/json; charset=utf-8", "x-request-id" => request_id }.compact
595
+ end
596
+ end
597
+ end
598
+ end