lunixi-sdk 0.1.2
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/.github/assets/lunixi-logo-dark.svg +38 -0
- data/.github/assets/lunixi-logo.svg +38 -0
- data/CHANGELOG.md +25 -0
- data/LICENSE +21 -0
- data/README.md +161 -0
- data/README.tr.md +134 -0
- data/SECURITY.md +20 -0
- data/docs/PUBLISHING.md +20 -0
- data/docs/rails.en.md +112 -0
- data/docs/rails.tr.md +112 -0
- data/docs/ruby.en.md +145 -0
- data/docs/ruby.tr.md +145 -0
- data/lib/lunixi/checkout.rb +20 -0
- data/lib/lunixi/client.rb +217 -0
- data/lib/lunixi/errors.rb +34 -0
- data/lib/lunixi/fraud.rb +34 -0
- data/lib/lunixi/json_body.rb +103 -0
- data/lib/lunixi/payments.rb +163 -0
- data/lib/lunixi/signer.rb +49 -0
- data/lib/lunixi/transport.rb +48 -0
- data/lib/lunixi/version.rb +5 -0
- data/lib/lunixi/webhook.rb +137 -0
- data/lib/lunixi.rb +12 -0
- metadata +148 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lunixi
|
|
4
|
+
# Errors deliberately omit response bodies, request bodies and credentials.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
class ConfigurationError < Error; end
|
|
7
|
+
class SignatureError < Error; end
|
|
8
|
+
class WebhookError < Error; end
|
|
9
|
+
class ReplayConflict < WebhookError; end
|
|
10
|
+
class TransportError < Error; end
|
|
11
|
+
class RedirectError < Error; end
|
|
12
|
+
|
|
13
|
+
class ApiError < Error
|
|
14
|
+
attr_reader :status, :code, :request_id, :retry_after
|
|
15
|
+
|
|
16
|
+
def initialize(status:, code: nil, request_id: nil, retry_after: nil)
|
|
17
|
+
@status = status
|
|
18
|
+
@code = safe_identifier(code)
|
|
19
|
+
@request_id = safe_identifier(request_id)
|
|
20
|
+
@retry_after = retry_after
|
|
21
|
+
super("Lunixi request failed (HTTP #{status})")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def safe_identifier(value)
|
|
27
|
+
value if value.is_a?(String) && value.match?(/\A[a-zA-Z0-9_.:-]{1,200}\z/)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class AuthenticationError < ApiError; end
|
|
32
|
+
class ValidationError < ApiError; end
|
|
33
|
+
class RateLimitError < ApiError; end
|
|
34
|
+
end
|
data/lib/lunixi/fraud.rb
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lunixi
|
|
4
|
+
class Fraud
|
|
5
|
+
FIELDS = %w[integrationMode productContext transactionRef channel surface stage operationKind authModeRequested
|
|
6
|
+
authModeApplied subMerchantRef subjectType subjectId paymentRail transferDirection merchantGroupIds
|
|
7
|
+
paymentId payment bankTransfer cryptoTransfer walletOperation billPayment loanApplication payout
|
|
8
|
+
customer device session merchant mobileApp telemetry metadata identitySnapshot identityAssertion].freeze
|
|
9
|
+
|
|
10
|
+
def initialize(client)
|
|
11
|
+
@client = client
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Evaluate the documented fraud DTO; tenant identity comes from credentials.
|
|
15
|
+
def evaluate(body)
|
|
16
|
+
raise ConfigurationError, 'Fraud request must be an object' unless body.is_a?(Hash)
|
|
17
|
+
|
|
18
|
+
data = JSON.parse(JsonBody.dump(body))
|
|
19
|
+
raise ConfigurationError, 'Fraud request contains an unknown root field' unless (data.keys - FIELDS).empty?
|
|
20
|
+
|
|
21
|
+
@client.request('POST', '/api/v1/fraud/decisions/evaluate', body: data, signed: false)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Record a signed transaction event with a persisted operation idempotency key.
|
|
25
|
+
def record_event(body, idempotency_key:)
|
|
26
|
+
unless body.is_a?(Hash) && %w[eventId subjectType eventType occurredAt].all? { |name| body[name] || body[name.to_sym] }
|
|
27
|
+
raise ConfigurationError, 'Fraud event requires eventId, subjectType, eventType and occurredAt'
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
@client.request('POST', '/api/v1/fraud/events/transactions', body: body,
|
|
31
|
+
idempotency_key: Client.idempotency_key!(idempotency_key))
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Lunixi
|
|
6
|
+
# JSON bytes compatible with JSON.parse followed by JSON.stringify at the gateway.
|
|
7
|
+
# Ruby hashes preserve insertion order; JavaScript moves array-index keys first.
|
|
8
|
+
module JsonBody
|
|
9
|
+
MAX_SAFE_INTEGER = 9_007_199_254_740_991
|
|
10
|
+
MAX_DEPTH = 64
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def dump(value)
|
|
15
|
+
encode(value, 0).freeze
|
|
16
|
+
rescue JSON::GeneratorError, EncodingError
|
|
17
|
+
raise ConfigurationError, 'Request contains invalid JSON text', cause: nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def encode(value, depth)
|
|
21
|
+
raise ConfigurationError, 'Request JSON nesting is too deep' if depth > MAX_DEPTH
|
|
22
|
+
|
|
23
|
+
case value
|
|
24
|
+
when Hash then encode_hash(value, depth)
|
|
25
|
+
when Array then "[#{value.map { |entry| encode(entry, depth + 1) }.join(',')}]"
|
|
26
|
+
when String
|
|
27
|
+
utf8 = value.encode(Encoding::UTF_8)
|
|
28
|
+
raise ConfigurationError, 'Request contains invalid UTF-8' unless utf8.valid_encoding?
|
|
29
|
+
|
|
30
|
+
JSON.generate(utf8)
|
|
31
|
+
when Integer
|
|
32
|
+
raise ConfigurationError, 'JSON integer exceeds the JavaScript safe range; use a documented digit-string field' if value.abs > MAX_SAFE_INTEGER
|
|
33
|
+
|
|
34
|
+
value.to_s
|
|
35
|
+
when Float then float_number(value)
|
|
36
|
+
when true then 'true'
|
|
37
|
+
when false then 'false'
|
|
38
|
+
when nil then 'null'
|
|
39
|
+
else raise ConfigurationError, 'Request contains an unsupported JSON value'
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def encode_hash(value, depth)
|
|
44
|
+
pairs = {}
|
|
45
|
+
value.each do |key, entry|
|
|
46
|
+
raise ConfigurationError, 'JSON object keys must be strings or symbols' unless key.is_a?(String) || key.is_a?(Symbol)
|
|
47
|
+
|
|
48
|
+
name = key.to_s
|
|
49
|
+
raise ConfigurationError, 'Duplicate normalized JSON key' if pairs.key?(name)
|
|
50
|
+
|
|
51
|
+
pairs[name] = entry
|
|
52
|
+
end
|
|
53
|
+
indexed, ordinary = pairs.keys.partition { |key| array_index?(key) }
|
|
54
|
+
ordered = indexed.sort_by(&:to_i) + ordinary
|
|
55
|
+
"{#{ordered.map { |key| "#{encode(key, depth + 1)}:#{encode(pairs.fetch(key), depth + 1)}" }.join(',')}}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def array_index?(key)
|
|
59
|
+
key.match?(/\A(?:0|[1-9][0-9]{0,9})\z/) && key.to_i < 4_294_967_295
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Both runtimes use the shortest round-trippable decimal. ECMAScript expands
|
|
63
|
+
# values in [1e-6, 1e21) and writes the exponent without leading zeroes.
|
|
64
|
+
def float_number(value)
|
|
65
|
+
raise ConfigurationError, 'JSON numbers must be finite' unless value.finite?
|
|
66
|
+
return '0' if value.zero?
|
|
67
|
+
|
|
68
|
+
sign = value.negative? ? '-' : ''
|
|
69
|
+
mantissa, exponent = value.abs.to_s.downcase.split('e')
|
|
70
|
+
exponent = exponent ? exponent.to_i : 0
|
|
71
|
+
integer, fraction = mantissa.split('.')
|
|
72
|
+
fraction = (fraction || '').sub(/0+\z/, '')
|
|
73
|
+
digits = integer + fraction
|
|
74
|
+
point = integer.length + exponent
|
|
75
|
+
number = if value.abs >= 1e-6 && value.abs < 1e21
|
|
76
|
+
fixed_number(digits, point)
|
|
77
|
+
else
|
|
78
|
+
scientific_number(digits, point, integer, exponent, mantissa)
|
|
79
|
+
end
|
|
80
|
+
sign + number
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def fixed_number(digits, point)
|
|
84
|
+
if point <= 0
|
|
85
|
+
"0.#{'0' * -point}#{digits}"
|
|
86
|
+
elsif point >= digits.length
|
|
87
|
+
digits + ('0' * (point - digits.length))
|
|
88
|
+
else
|
|
89
|
+
"#{digits[0...point]}.#{digits[point..]}"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def scientific_number(digits, point, integer, exponent, mantissa)
|
|
94
|
+
significant = digits.sub(/\A0+/, '')
|
|
95
|
+
decimal_exponent = point - integer.index(/[1-9]/).to_i - 1
|
|
96
|
+
# Scientific Ruby output has one digit before the decimal point.
|
|
97
|
+
decimal_exponent = exponent if mantissa.to_f >= 1 && mantissa.to_f < 10
|
|
98
|
+
coefficient = significant[0] + (significant.length > 1 ? ".#{significant[1..]}" : '')
|
|
99
|
+
exponent_text = decimal_exponent.negative? ? decimal_exponent.to_s : "+#{decimal_exponent}"
|
|
100
|
+
"#{coefficient}e#{exponent_text}"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lunixi
|
|
4
|
+
# Explicit payment routes. The advanced Client#request method covers other
|
|
5
|
+
# documented endpoints without pretending to validate their entire DTO schema.
|
|
6
|
+
class Payments
|
|
7
|
+
BASE = '/api/v1/payments'
|
|
8
|
+
attr_reader :client
|
|
9
|
+
|
|
10
|
+
def initialize(client)
|
|
11
|
+
@client = client
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Create hosted/embedded checkout using integer minor units and a durable attempt key.
|
|
15
|
+
# @return [Hash] Unmodified gateway response envelope; creation is not settlement.
|
|
16
|
+
def create_intent(body, idempotency_key:)
|
|
17
|
+
body = payment_body!(body)
|
|
18
|
+
raise ConfigurationError, 'Intent installments are selected in the checkout form' if body.key?('installment')
|
|
19
|
+
|
|
20
|
+
client.request('POST', "#{BASE}/intents", body: body, idempotency_key: Client.idempotency_key!(idempotency_key))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [Hash] Authenticated payment status envelope for reconciliation.
|
|
24
|
+
def get(payment_id)
|
|
25
|
+
client.request('GET', "#{BASE}/#{identifier!(payment_id)}", signed: false)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param filters [Hash] Documented gateway payment list query fields.
|
|
29
|
+
def list(**filters)
|
|
30
|
+
client.request('GET', BASE, query: filters, signed: false)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Return merchant-enabled methods; the shared Lunixi form owns the final available list.
|
|
34
|
+
def methods(payment_method: nil)
|
|
35
|
+
query = payment_method ? { paymentMethod: payment_method } : {}
|
|
36
|
+
client.request('GET', "#{BASE}/methods", query: query)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Capture an authorization; nil amount requests the gateway's remaining amount.
|
|
40
|
+
def capture(payment_id, idempotency_key:, amount: nil)
|
|
41
|
+
body = { 'paymentIntentId' => identifier!(payment_id) }
|
|
42
|
+
body['amount'] = minor!(amount) unless amount.nil?
|
|
43
|
+
client.request('POST', "#{BASE}/#{payment_id}/capture", body: body, idempotency_key: Client.idempotency_key!(idempotency_key))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Refund captured funds; persist a separate key for each refund operation.
|
|
47
|
+
def refund(payment_id, idempotency_key:, amount: nil, reason: nil)
|
|
48
|
+
body = { 'paymentIntentId' => identifier!(payment_id) }
|
|
49
|
+
body['amount'] = minor!(amount) unless amount.nil?
|
|
50
|
+
body['reason'] = reason!(reason) unless reason.nil?
|
|
51
|
+
client.request('POST', "#{BASE}/#{payment_id}/refund", body: body, idempotency_key: Client.idempotency_key!(idempotency_key))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Void an authorization without inventing a settlement success result.
|
|
55
|
+
def void(payment_id, idempotency_key:)
|
|
56
|
+
client.request('POST', "#{BASE}/#{identifier!(payment_id)}/void", idempotency_key: Client.idempotency_key!(idempotency_key))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Start a 1–5 leg split payment; running statuses are returned, never marked paid.
|
|
60
|
+
def split(body, idempotency_key:)
|
|
61
|
+
data = normalize!(body)
|
|
62
|
+
minor!(data['totalAmount'])
|
|
63
|
+
currency!(data['currency'])
|
|
64
|
+
text!(data['ownerRef'])
|
|
65
|
+
legs = data['legs']
|
|
66
|
+
raise ConfigurationError, 'Split payment requires 1–5 legs' unless legs.is_a?(Array) && legs.size.between?(1, 5)
|
|
67
|
+
|
|
68
|
+
total = legs.sum do |leg|
|
|
69
|
+
raise ConfigurationError, 'Split legs must be objects' unless leg.is_a?(Hash)
|
|
70
|
+
|
|
71
|
+
minor!(leg['amount'])
|
|
72
|
+
end
|
|
73
|
+
raise ConfigurationError, 'Split leg amounts must equal totalAmount' unless total == data['totalAmount']
|
|
74
|
+
|
|
75
|
+
client.request('POST', "#{BASE}/split", body: data, idempotency_key: Client.idempotency_key!(idempotency_key))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @return [Hash] Current split-session status envelope.
|
|
79
|
+
def get_split(split_id)
|
|
80
|
+
client.request('GET', "#{BASE}/split/#{identifier!(split_id)}", signed: false)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Refund a split session using the documented partial/full amount in minor units.
|
|
84
|
+
def refund_split(split_id, amount:, idempotency_key:, reason: nil)
|
|
85
|
+
body = { 'amount' => minor!(amount) }
|
|
86
|
+
body['reason'] = reason!(reason) unless reason.nil?
|
|
87
|
+
client.request('POST', "#{BASE}/split/#{identifier!(split_id)}/refund", body: body,
|
|
88
|
+
idempotency_key: Client.idempotency_key!(idempotency_key))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def identifier!(value)
|
|
94
|
+
raise ConfigurationError, 'Invalid resource identifier' unless value.is_a?(String) && value.match?(/\A[a-zA-Z0-9_-]{1,256}\z/)
|
|
95
|
+
|
|
96
|
+
value
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def minor!(value)
|
|
100
|
+
unless value.is_a?(Integer) && value.positive? && value <= JsonBody::MAX_SAFE_INTEGER
|
|
101
|
+
raise ConfigurationError, 'Amount must be a positive safe integer in minor units'
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
value
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def currency!(value)
|
|
108
|
+
raise ConfigurationError, 'Currency must be a three-letter uppercase code' unless value.is_a?(String) && value.match?(/\A[A-Z]{3}\z/)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def reason!(value)
|
|
112
|
+
raise ConfigurationError, 'Reason must be a string of at most 500 characters' unless value.is_a?(String) && value.size <= 500
|
|
113
|
+
|
|
114
|
+
value
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def text!(value)
|
|
118
|
+
unless value.is_a?(String) && !value.strip.empty? && value.bytesize <= 255 && !value.match?(/[\x00-\x1f\x7f]/)
|
|
119
|
+
raise ConfigurationError, 'Reference must be a bounded nonempty string'
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
value
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def normalize!(body)
|
|
126
|
+
raise ConfigurationError, 'Payment body must be an object' unless body.is_a?(Hash)
|
|
127
|
+
|
|
128
|
+
JSON.parse(JsonBody.dump(body))
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def payment_body!(body)
|
|
132
|
+
data = normalize!(body)
|
|
133
|
+
raise ConfigurationError, 'Removed amount field; use amount in integer minor units' if %w[price paidPrice].any? { |key| data.key?(key) }
|
|
134
|
+
|
|
135
|
+
minor!(data['amount'])
|
|
136
|
+
currency!(data['currency'])
|
|
137
|
+
text!(data['orderId'])
|
|
138
|
+
validate_callback!(data['callbackUrl']) if data.key?('callbackUrl')
|
|
139
|
+
raise ConfigurationError, 'preauth must be a JSON boolean' if data.key?('preauth') && ![true, false].include?(data['preauth'])
|
|
140
|
+
|
|
141
|
+
validate_basket!(data['basketItems'], data['amount']) if data.key?('basketItems')
|
|
142
|
+
data
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def validate_callback!(url)
|
|
146
|
+
uri = URI.parse(url)
|
|
147
|
+
raise ConfigurationError, 'callbackUrl must use HTTPS' unless uri.is_a?(URI::HTTPS) && uri.host && !uri.userinfo && !uri.fragment && url.bytesize <= 2048
|
|
148
|
+
rescue URI::InvalidURIError, TypeError
|
|
149
|
+
raise ConfigurationError, 'Invalid callbackUrl', cause: nil
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def validate_basket!(items, amount)
|
|
153
|
+
raise ConfigurationError, 'basketItems must be a nonempty array' unless items.is_a?(Array) && !items.empty?
|
|
154
|
+
|
|
155
|
+
total = items.sum do |item|
|
|
156
|
+
raise ConfigurationError, 'Basket items must be objects' unless item.is_a?(Hash)
|
|
157
|
+
|
|
158
|
+
minor!(item['price'])
|
|
159
|
+
end
|
|
160
|
+
raise ConfigurationError, 'Basket total must equal amount' unless total == amount
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
require 'base64'
|
|
5
|
+
require 'digest'
|
|
6
|
+
|
|
7
|
+
module Lunixi
|
|
8
|
+
# Uses OpenSSL's Ed25519 implementation; no curve arithmetic is implemented here.
|
|
9
|
+
class Signer
|
|
10
|
+
PREFIX = ['302e020100300506032b657004220420'].pack('H*').freeze
|
|
11
|
+
|
|
12
|
+
def initialize(private_key)
|
|
13
|
+
raise ConfigurationError, 'An Ed25519 private key is required' unless private_key.is_a?(String) && !private_key.strip.empty?
|
|
14
|
+
|
|
15
|
+
material = private_key.strip
|
|
16
|
+
unless material.include?('-----BEGIN')
|
|
17
|
+
raw = Base64.strict_decode64(material)
|
|
18
|
+
raw = PREFIX + raw[0, 32] if [32, 64].include?(raw.bytesize)
|
|
19
|
+
material = raw
|
|
20
|
+
end
|
|
21
|
+
@key = OpenSSL::PKey.read(material)
|
|
22
|
+
raise ConfigurationError, 'Only Ed25519 private keys are accepted' unless @key.oid == 'ED25519'
|
|
23
|
+
|
|
24
|
+
@key.sign(nil, 'key-check') # Reject public-only keys at construction time.
|
|
25
|
+
rescue OpenSSL::OpenSSLError, ArgumentError
|
|
26
|
+
raise ConfigurationError, 'Invalid Ed25519 private key', cause: nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def sign(message)
|
|
30
|
+
Base64.strict_encode64(@key.sign(nil, message))
|
|
31
|
+
rescue OpenSSL::OpenSSLError
|
|
32
|
+
raise SignatureError, 'Ed25519 signing failed', cause: nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.canonical(method, path, date, nonce, digest = nil)
|
|
36
|
+
lines = [method.upcase, path, "X-Date:#{date}", "X-Nonce:#{nonce}"]
|
|
37
|
+
lines << "Digest:#{digest}" if digest
|
|
38
|
+
lines.join("\n")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.digest(body)
|
|
42
|
+
"SHA-256=#{Base64.strict_encode64(Digest::SHA256.digest(body))}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def inspect
|
|
46
|
+
'#<Lunixi::Signer [REDACTED]>'
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'uri'
|
|
5
|
+
require 'openssl'
|
|
6
|
+
|
|
7
|
+
module Lunixi
|
|
8
|
+
Response = Struct.new(:status, :headers, :body, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
# Net::HTTP never follows redirects. Each call gets a separate connection,
|
|
11
|
+
# avoiding sharing mutable Net::HTTP state between application threads.
|
|
12
|
+
class NetHttpTransport
|
|
13
|
+
MAX_RESPONSE_BYTES = 8 * 1024 * 1024
|
|
14
|
+
|
|
15
|
+
def initialize(timeout: 30, ca_file: nil)
|
|
16
|
+
@timeout = timeout
|
|
17
|
+
@ca_file = ca_file
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call(method:, uri:, headers:, body:)
|
|
21
|
+
http = Net::HTTP.new(uri.host, uri.port, nil) # Do not inherit proxy credentials/environment.
|
|
22
|
+
http.use_ssl = true
|
|
23
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
24
|
+
http.min_version = OpenSSL::SSL::TLS1_2_VERSION
|
|
25
|
+
http.ca_file = @ca_file if @ca_file
|
|
26
|
+
http.open_timeout = @timeout
|
|
27
|
+
http.read_timeout = @timeout
|
|
28
|
+
http.write_timeout = @timeout
|
|
29
|
+
http.max_retries = 0 # Retry policy belongs to Client, never Net::HTTP.
|
|
30
|
+
request = Net::HTTPGenericRequest.new(method, !body.nil?, method != 'HEAD', uri.request_uri, headers)
|
|
31
|
+
request.body = body unless body.nil?
|
|
32
|
+
data = +''
|
|
33
|
+
result = nil
|
|
34
|
+
http.start do |session|
|
|
35
|
+
session.request(request) do |response|
|
|
36
|
+
response.read_body do |chunk|
|
|
37
|
+
data << chunk
|
|
38
|
+
raise TransportError, 'Gateway response exceeds the size limit' if data.bytesize > MAX_RESPONSE_BYTES
|
|
39
|
+
end
|
|
40
|
+
result = Response.new(status: response.code.to_i, headers: response.each_header.to_h, body: data)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
result
|
|
44
|
+
rescue Timeout::Error, IOError, SystemCallError, SocketError, OpenSSL::SSL::SSLError
|
|
45
|
+
raise TransportError, 'Gateway connection failed; reconcile an uncertain payment before retrying', cause: nil
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lunixi
|
|
4
|
+
VerifiedEvent = Struct.new(:id, :type, :data, :raw, :body_digest, keyword_init: true)
|
|
5
|
+
|
|
6
|
+
# Pure verification never consumes an event id. Use #process with a durable
|
|
7
|
+
# transactional store so a crashed handler can safely receive the event again.
|
|
8
|
+
class WebhookVerifier
|
|
9
|
+
def initialize(secret:, previous_secrets: [], tolerance: 300, clock: -> { Time.now.utc })
|
|
10
|
+
@secrets = [secret, *previous_secrets]
|
|
11
|
+
unless @secrets.all? { |value| value.is_a?(String) && !value.empty? } && @secrets.size <= 3
|
|
12
|
+
raise ConfigurationError, 'One webhook secret and at most two previous secrets are required'
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
@secrets = @secrets.map { |value| value.dup.freeze }.freeze
|
|
16
|
+
raise ConfigurationError, 'Webhook tolerance must be between 1 and 3600 seconds' unless tolerance.is_a?(Integer) && tolerance.between?(1, 3600)
|
|
17
|
+
|
|
18
|
+
@tolerance = tolerance
|
|
19
|
+
@clock = clock
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def verify(raw_body, headers)
|
|
23
|
+
unless raw_body.is_a?(String) && raw_body.bytesize <= 1024 * 1024 && raw_body.dup.force_encoding('UTF-8').valid_encoding?
|
|
24
|
+
raise WebhookError, 'Webhook body must be bounded UTF-8 bytes'
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
raw_body = raw_body.dup.freeze
|
|
28
|
+
normalized = normalize_headers(headers)
|
|
29
|
+
id, timestamp, signature = signature_fields(normalized)
|
|
30
|
+
verify_signature(raw_body, id, timestamp, signature)
|
|
31
|
+
envelope = JSON.parse(raw_body, max_nesting: 64, object_class: UniqueJsonObject, allow_duplicate_key: false)
|
|
32
|
+
event_from_envelope(envelope, normalized, id, raw_body)
|
|
33
|
+
rescue JSON::ParserError, ArgumentError
|
|
34
|
+
raise WebhookError, 'Webhook data or timestamp is malformed', cause: nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The store must commit its receipt and all handler changes atomically. Its
|
|
38
|
+
# process_once(id:, digest:) yields once, returns :processed or :duplicate,
|
|
39
|
+
# and raises ReplayConflict when the same id has a different body digest.
|
|
40
|
+
def process(raw_body, headers, store:)
|
|
41
|
+
event = verify(raw_body, headers)
|
|
42
|
+
store.process_once(id: event.id, digest: event.body_digest) { yield event }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def inspect
|
|
46
|
+
'#<Lunixi::WebhookVerifier secrets=[REDACTED]>'
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def signature_fields(headers)
|
|
52
|
+
id = headers['x-lunixi-event-id']
|
|
53
|
+
timestamp = headers['x-lunixi-signature-timestamp']
|
|
54
|
+
signature = headers['x-lunixi-signature']
|
|
55
|
+
valid_id = id&.match?(/\A[a-zA-Z0-9_.:-]{1,200}\z/)
|
|
56
|
+
valid_time = timestamp&.match?(/\A\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{1,9})?(?:Z|[+-]\d\d:\d\d)\z/)
|
|
57
|
+
raise WebhookError, 'Required webhook headers are missing or malformed' unless valid_id && valid_time && signature && signature.bytesize <= 2048
|
|
58
|
+
raise WebhookError, 'Webhook timestamp is outside the tolerance window' if (@clock.call - Time.iso8601(timestamp)).abs > @tolerance
|
|
59
|
+
|
|
60
|
+
[id, timestamp, signature]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def verify_signature(raw_body, id, timestamp, signature)
|
|
64
|
+
candidates = signature.split(',').filter_map do |item|
|
|
65
|
+
match = /\Av2=([a-fA-F0-9]{64})\z/.match(item.strip)
|
|
66
|
+
match && match[1].downcase
|
|
67
|
+
end
|
|
68
|
+
raise WebhookError, 'Webhook requires a v2 signature' if candidates.empty? || candidates.size > 8
|
|
69
|
+
|
|
70
|
+
signed = "#{id}.#{timestamp}.".b + raw_body.b
|
|
71
|
+
matches = @secrets.flat_map do |secret|
|
|
72
|
+
expected = OpenSSL::HMAC.hexdigest('SHA256', secret, signed)
|
|
73
|
+
candidates.map { |candidate| OpenSSL.fixed_length_secure_compare(expected, candidate) }
|
|
74
|
+
end
|
|
75
|
+
raise WebhookError, 'Webhook signature is invalid' unless matches.include?(true)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def event_from_envelope(envelope, normalized, id, raw_body)
|
|
79
|
+
raise WebhookError, 'Webhook envelope must be an object' unless envelope.is_a?(Hash)
|
|
80
|
+
|
|
81
|
+
%w[id eventId].each do |field|
|
|
82
|
+
raise WebhookError, 'Webhook event id does not match the signed header' if envelope.key?(field) && envelope[field] != id
|
|
83
|
+
end
|
|
84
|
+
type = event_type(envelope, normalized)
|
|
85
|
+
data = envelope['data'] || envelope['payload'] || {}
|
|
86
|
+
raise WebhookError, 'Webhook data must be an object' unless data.is_a?(Hash)
|
|
87
|
+
|
|
88
|
+
VerifiedEvent.new(id: id.freeze, type: type.freeze, data: deep_freeze(data),
|
|
89
|
+
raw: deep_freeze(envelope), body_digest: Digest::SHA256.hexdigest(raw_body)).freeze
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def event_type(envelope, normalized)
|
|
93
|
+
types = %w[type eventType].filter_map { |field| envelope[field] }
|
|
94
|
+
raise WebhookError, 'Webhook event type must be present in the signed body' if types.empty?
|
|
95
|
+
|
|
96
|
+
header_type = normalized['x-lunixi-event-type']
|
|
97
|
+
types << header_type if header_type
|
|
98
|
+
unless !types.empty? && types.all? { |type| type.is_a?(String) && type.match?(/\A[a-zA-Z0-9_.:-]{1,200}\z/) } && types.uniq.one?
|
|
99
|
+
raise WebhookError, 'Webhook event type is missing or inconsistent'
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
types.first
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def normalize_headers(headers)
|
|
106
|
+
raise WebhookError, 'Webhook headers must be a hash' unless headers.is_a?(Hash)
|
|
107
|
+
|
|
108
|
+
headers.each_with_object({}) do |(name, value), result|
|
|
109
|
+
key = name.to_s.downcase
|
|
110
|
+
raise WebhookError, 'Duplicate or invalid webhook header' if result.key?(key) || !value.is_a?(String)
|
|
111
|
+
|
|
112
|
+
result[key] = value.dup.freeze
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def deep_freeze(value)
|
|
117
|
+
case value
|
|
118
|
+
when Hash then value.each do |key, entry|
|
|
119
|
+
key.freeze
|
|
120
|
+
deep_freeze(entry)
|
|
121
|
+
end
|
|
122
|
+
when Array then value.each { |entry| deep_freeze(entry) }
|
|
123
|
+
end
|
|
124
|
+
value.freeze
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# JSON.parse otherwise silently overwrites duplicate keys, which can make two
|
|
129
|
+
# applications interpret a signed JSON envelope differently.
|
|
130
|
+
class UniqueJsonObject < Hash
|
|
131
|
+
def []=(key, value)
|
|
132
|
+
raise WebhookError, 'Webhook contains duplicate JSON keys' if key?(key)
|
|
133
|
+
|
|
134
|
+
super
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
data/lib/lunixi.rb
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lunixi/version'
|
|
4
|
+
require_relative 'lunixi/errors'
|
|
5
|
+
require_relative 'lunixi/json_body'
|
|
6
|
+
require_relative 'lunixi/signer'
|
|
7
|
+
require_relative 'lunixi/transport'
|
|
8
|
+
require_relative 'lunixi/payments'
|
|
9
|
+
require_relative 'lunixi/checkout'
|
|
10
|
+
require_relative 'lunixi/fraud'
|
|
11
|
+
require_relative 'lunixi/client'
|
|
12
|
+
require_relative 'lunixi/webhook'
|