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.
@@ -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
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lunixi
4
+ VERSION = '0.1.2'
5
+ 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'