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