jamm 2.3.0 → 2.5.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cb73f3a736756e42ca49cedb1fb255e011fb39e0c34dabc2f85bfd537836afe1
4
- data.tar.gz: d5ce67a32f315473bca8800024d19b939b9dafefb358a077e7f30fa551dd01ed
3
+ metadata.gz: fe888992acfb9739e5206ab7aaa3428f3e246998e633ff60e09999c2d96f3992
4
+ data.tar.gz: 6ee9932e4775b93a8b18e9dba2173871d6c36b0e727563111c8462801db481e3
5
5
  SHA512:
6
- metadata.gz: ff218dca883cf3515ce3d37a89147c6ce1a615fc53568f0916edb48211f6de1a5b4af7c5613066a74a3df017cd81ec76d734c9ecec259b3a78457af4eb3d4900
7
- data.tar.gz: 69cd60e93b565297f43fd08f7b6a768a6ae5955dddd47d4553b18756e5a3123cdb6548e596e1c9317928f613064c96c66630de9783fab57229d146e78ecfcbe2
6
+ metadata.gz: c33699a5edbb8cd8f52c5848bc5534b735f9ebca0990b45ceb2105408a2e5fbad7aca6205a0f788c5b6213e954d9a9c56e7d7f71f8f2cb47f491f7786afe658d
7
+ data.tar.gz: 73722cb39c3acbbd828c8011e4303f0ff1a8936b1dd390f34edbef5fe9dc26cf3375d6f025a8b20c8abd3e2a37fe80e3fbdc97b195dbbd899fcde93a099c3260
data/CHANGELOG.md CHANGED
@@ -5,21 +5,17 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [2.3.0] - 2026-06-24
8
+ ## [2.5.0] - 2026-07-03
9
9
 
10
10
  ### Added
11
11
 
12
- - Resolve numeric enum wire values (`status`, `api_source`, …) onto their string enum constants on every webhook model, matching REST API responses (the backend serializes webhooks with `json.Marshal`, so all enums arrive numeric)
13
- - Surface the refund `rfd-` id on the flat `refund_id` attribute in addition to the nested `refund.id`
14
- - Parse `EVENT_TYPE_USER_ACCOUNT_DELETED` webhooks (previously raised `Unknown event type`)
12
+ - Added `Jamm::Webhook.verify_and_parse(raw_body)` verifies the HMAC signature over the exact received `content` bytes and parses in one step. Unlike `Jamm::Webhook.verify`, it is not broken by JSON re-serialization (correctly handles `&`, `<`, `>` in content) and rejects bodies with duplicate top-level keys.
15
13
 
16
- ### Fixed
14
+ ## [2.4.0] - 2026-07-01
17
15
 
18
- - Webhook parsing no longer fails on new fields added by the backend (forward-compatible)
19
- - Refund webhooks now expose the nested transaction fields and the refund's `rfd-` id (`refund.id`)
20
- - `status` on refund/charge webhooks is no longer left as a raw integer
21
- - Nested webhook fields (e.g. `refund.error`) are now typed model instances instead of raw Hashes, so `error.code` / `error.message` work instead of raising `NoMethodError`
22
- - `Webhook.parse` now accepts payloads with either string or symbol keys
16
+ ### Added
17
+
18
+ - Send the `X-SDK-Version` request header (`ruby:<version>`) so the backend can track SDK version usage per merchant
23
19
 
24
20
  ## [2.2.0] - 2026-05-20
25
21
 
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- jamm (2.3.0)
4
+ jamm (2.5.0)
5
5
  base64 (~> 0.2)
6
6
  rest-client (~> 2.0)
7
7
  typhoeus (~> 1.0, >= 1.0.1)
@@ -24,7 +24,9 @@ GEM
24
24
  ffi (>= 1.15.0)
25
25
  faker (3.5.1)
26
26
  i18n (>= 1.8.11, < 2)
27
- ffi (1.17.0)
27
+ ffi (1.17.0-aarch64-linux-gnu)
28
+ ffi (1.17.0-arm64-darwin)
29
+ ffi (1.17.0-x86_64-linux-gnu)
28
30
  gimei (1.5.0)
29
31
  hashdiff (1.1.0)
30
32
  http-accept (1.7.0)
data/jamm.gemspec CHANGED
@@ -21,6 +21,7 @@ Gem::Specification.new do |s|
21
21
 
22
22
  s.files = `git ls-files`.split("\n").reject do |file|
23
23
  file.start_with?(
24
+ 'compatibility',
24
25
  'examples',
25
26
  'images',
26
27
  'openapi',
@@ -62,8 +62,6 @@ module Api
62
62
 
63
63
  attr_accessor :refund
64
64
 
65
- attr_accessor :api_source
66
-
67
65
  class EnumAttributeValidator
68
66
  attr_reader :datatype
69
67
  attr_reader :allowable_values
@@ -107,8 +105,7 @@ module Api
107
105
  :'consumption_tax' => :'consumptionTax',
108
106
  :'error' => :'error',
109
107
  :'refund_id' => :'refundId',
110
- :'refund' => :'refund',
111
- :'api_source' => :'apiSource'
108
+ :'refund' => :'refund'
112
109
  }
113
110
  end
114
111
 
@@ -138,8 +135,7 @@ module Api
138
135
  :'consumption_tax' => :'Integer',
139
136
  :'error' => :'Apiv1Error',
140
137
  :'refund_id' => :'String',
141
- :'refund' => :'RefundInfo',
142
- :'api_source' => :'ChargeMessageApiSource'
138
+ :'refund' => :'RefundInfo'
143
139
  }
144
140
  end
145
141
 
@@ -241,12 +237,6 @@ module Api
241
237
  if attributes.key?(:'refund')
242
238
  self.refund = attributes[:'refund']
243
239
  end
244
-
245
- if attributes.key?(:'api_source')
246
- self.api_source = attributes[:'api_source']
247
- else
248
- self.api_source = 'API_SOURCE_UNSPECIFIED'
249
- end
250
240
  end
251
241
 
252
242
  # Show invalid properties with the reasons. Usually used together with valid?
@@ -287,8 +277,7 @@ module Api
287
277
  consumption_tax == o.consumption_tax &&
288
278
  error == o.error &&
289
279
  refund_id == o.refund_id &&
290
- refund == o.refund &&
291
- api_source == o.api_source
280
+ refund == o.refund
292
281
  end
293
282
 
294
283
  # @see the `==` method
@@ -300,7 +289,7 @@ module Api
300
289
  # Calculates hash code according to all attributes.
301
290
  # @return [Integer] Hash code
302
291
  def hash
303
- [id, customer, status, description, merchant_name, initial_amount, discount, final_amount, amount_refunded, currency, processed_at, jamm_fee, created_at, updated_at, original_transaction_jamm_fee, consumption_tax, error, refund_id, refund, api_source].hash
292
+ [id, customer, status, description, merchant_name, initial_amount, discount, final_amount, amount_refunded, currency, processed_at, jamm_fee, created_at, updated_at, original_transaction_jamm_fee, consumption_tax, error, refund_id, refund].hash
304
293
  end
305
294
 
306
295
  # Builds the object from hash
@@ -38,6 +38,7 @@ module Api
38
38
  PAYMENT_CUSTOMER_NOT_FOUND = "ERROR_TYPE_PAYMENT_CUSTOMER_NOT_FOUND".freeze
39
39
  PAYMENT_CUSTOMER_INACTIVE = "ERROR_TYPE_PAYMENT_CUSTOMER_INACTIVE".freeze
40
40
  PAYMENT_SERVICE_DISABLED = "ERROR_TYPE_PAYMENT_SERVICE_DISABLED".freeze
41
+ PAYMENT_BANK_UNAVAILABLE = "ERROR_TYPE_PAYMENT_BANK_UNAVAILABLE".freeze
41
42
  CSV_VALIDATION_FAILED = "ERROR_TYPE_CSV_VALIDATION_FAILED".freeze
42
43
  CSV_TOTP_REQUIRED = "ERROR_TYPE_CSV_TOTP_REQUIRED".freeze
43
44
  CSV_TOTP_INVALID = "ERROR_TYPE_CSV_TOTP_INVALID".freeze
@@ -55,7 +56,7 @@ module Api
55
56
  TOTP_DISABLE_FAILED = "ERROR_TYPE_TOTP_DISABLE_FAILED".freeze
56
57
 
57
58
  def self.all_vars
58
- @all_vars ||= [UNSPECIFIED, AUTH_FAILED, AUTH_REJECTED, ACCOUNT_CREATION_FAILED, ACCOUNT_MODIFICATION_FAILED, ACCOUNT_DELETION_FAILED, ACCOUNT_BANK_REGISTRATION_FAILED, KYC_REJECTED, NOTIFICATION_WEBHOOK_FAILED, NOTIFICATION_EMAIL_FAILED, NOTIFICATION_SMS_FAILED, PAYMENT_GATEWAY_UNAVAILABLE, PAYMENT_GATEWAY_FAILED, PAYMENT_VALIDATION_FAILED, PAYMENT_CHARGE_FAILED, PAYMENT_CHARGE_REJECTED, PAYMENT_CHARGE_OVER_LIMIT, PAYMENT_CHARGE_SUBSCRIPTION_EXPIRED, PAYMENT_LINK_EXPIRED, PAYMENT_CHARGE_INSUFFICIENT_FUNDS, PAYMENT_CUSTOMER_NOT_FOUND, PAYMENT_CUSTOMER_INACTIVE, PAYMENT_SERVICE_DISABLED, CSV_VALIDATION_FAILED, CSV_TOTP_REQUIRED, CSV_TOTP_INVALID, CSV_TOTP_EXPIRED, CSV_TOTP_LOCKED, CSV_BATCH_TOO_LARGE, CSV_CUSTOMER_NOT_FOUND, CSV_PROCESSING_FAILED, CSV_CHALLENGE_NOT_FOUND, CSV_DUPLICATE_USER, TOTP_SETUP_FAILED, TOTP_ALREADY_ENABLED, TOTP_NOT_ENABLED, TOTP_SETUP_INVALID, TOTP_DISABLE_FAILED].freeze
59
+ @all_vars ||= [UNSPECIFIED, AUTH_FAILED, AUTH_REJECTED, ACCOUNT_CREATION_FAILED, ACCOUNT_MODIFICATION_FAILED, ACCOUNT_DELETION_FAILED, ACCOUNT_BANK_REGISTRATION_FAILED, KYC_REJECTED, NOTIFICATION_WEBHOOK_FAILED, NOTIFICATION_EMAIL_FAILED, NOTIFICATION_SMS_FAILED, PAYMENT_GATEWAY_UNAVAILABLE, PAYMENT_GATEWAY_FAILED, PAYMENT_VALIDATION_FAILED, PAYMENT_CHARGE_FAILED, PAYMENT_CHARGE_REJECTED, PAYMENT_CHARGE_OVER_LIMIT, PAYMENT_CHARGE_SUBSCRIPTION_EXPIRED, PAYMENT_LINK_EXPIRED, PAYMENT_CHARGE_INSUFFICIENT_FUNDS, PAYMENT_CUSTOMER_NOT_FOUND, PAYMENT_CUSTOMER_INACTIVE, PAYMENT_SERVICE_DISABLED, PAYMENT_BANK_UNAVAILABLE, CSV_VALIDATION_FAILED, CSV_TOTP_REQUIRED, CSV_TOTP_INVALID, CSV_TOTP_EXPIRED, CSV_TOTP_LOCKED, CSV_BATCH_TOO_LARGE, CSV_CUSTOMER_NOT_FOUND, CSV_PROCESSING_FAILED, CSV_CHALLENGE_NOT_FOUND, CSV_DUPLICATE_USER, TOTP_SETUP_FAILED, TOTP_ALREADY_ENABLED, TOTP_NOT_ENABLED, TOTP_SETUP_INVALID, TOTP_DISABLE_FAILED].freeze
59
60
  end
60
61
 
61
62
  # Builds the enum from string
@@ -17,7 +17,7 @@ module Api
17
17
  # RefundInfo contains refund-specific details for refund and refund_failed webhook events.
18
18
  class RefundInfo
19
19
  # External refund identifier (rfd-*).
20
- attr_accessor :id
20
+ attr_accessor :refund_id
21
21
 
22
22
  # Amount refunded for this event.
23
23
  attr_accessor :amount_refunded
@@ -39,7 +39,7 @@ module Api
39
39
  # Attribute mapping from ruby-style variable name to JSON key.
40
40
  def self.attribute_map
41
41
  {
42
- :'id' => :'id',
42
+ :'refund_id' => :'refundId',
43
43
  :'amount_refunded' => :'amountRefunded',
44
44
  :'jamm_fee' => :'jammFee',
45
45
  :'consumption_tax' => :'consumptionTax',
@@ -57,7 +57,7 @@ module Api
57
57
  # Attribute type mapping.
58
58
  def self.openapi_types
59
59
  {
60
- :'id' => :'String',
60
+ :'refund_id' => :'String',
61
61
  :'amount_refunded' => :'Integer',
62
62
  :'jamm_fee' => :'Integer',
63
63
  :'consumption_tax' => :'Integer',
@@ -88,8 +88,8 @@ module Api
88
88
  h[k.to_sym] = v
89
89
  }
90
90
 
91
- if attributes.key?(:'id')
92
- self.id = attributes[:'id']
91
+ if attributes.key?(:'refund_id')
92
+ self.refund_id = attributes[:'refund_id']
93
93
  end
94
94
 
95
95
  if attributes.key?(:'amount_refunded')
@@ -137,7 +137,7 @@ module Api
137
137
  def ==(o)
138
138
  return true if self.equal?(o)
139
139
  self.class == o.class &&
140
- id == o.id &&
140
+ refund_id == o.refund_id &&
141
141
  amount_refunded == o.amount_refunded &&
142
142
  jamm_fee == o.jamm_fee &&
143
143
  consumption_tax == o.consumption_tax &&
@@ -155,7 +155,7 @@ module Api
155
155
  # Calculates hash code according to all attributes.
156
156
  # @return [Integer] Hash code
157
157
  def hash
158
- [id, amount_refunded, jamm_fee, consumption_tax, original_transaction_fee_waived, error, processed_at].hash
158
+ [refund_id, amount_refunded, jamm_fee, consumption_tax, original_transaction_fee_waived, error, processed_at].hash
159
159
  end
160
160
 
161
161
  # Builds the object from hash
data/lib/jamm/api.rb CHANGED
@@ -19,7 +19,6 @@ require 'jamm/api/configuration'
19
19
  # Models
20
20
  require 'jamm/api/models/apiv1_error'
21
21
  require 'jamm/api/models/apiv1_status'
22
- require 'jamm/api/models/charge_message_api_source'
23
22
  require 'jamm/api/models/customer_service_update_customer_body'
24
23
  require 'jamm/api/models/googlerpc_status'
25
24
  require 'jamm/api/models/protobuf_any'
data/lib/jamm/client.rb CHANGED
@@ -9,6 +9,7 @@ module Jamm
9
9
  base.config.host = Jamm.openapi.config.host
10
10
  base.config.scheme = Jamm.openapi.config.scheme
11
11
  base.default_headers['Authorization'] = "Bearer #{Jamm::OAuth.token}"
12
+ base.default_headers['X-SDK-Version'] = "ruby:#{Jamm::VERSION}"
12
13
 
13
14
  # Platform feature, optionally set merchant id to call Jamm API
14
15
  # on behalf of the merchant.
data/lib/jamm/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Jamm
4
- VERSION = '2.3.0'
4
+ VERSION = '2.5.0'
5
5
  end
data/lib/jamm/webhook.rb CHANGED
@@ -11,157 +11,39 @@ module Jamm
11
11
  # Parse command is for parsing the received webhook message.
12
12
  # It does not call anything remotely, instead returns the suitable object.
13
13
  def self.parse(json)
14
- # Webhook payloads may arrive with string or symbol keys depending on how
15
- # the caller decoded the JSON (e.g. JSON.parse with or without
16
- # symbolize_names: true). Normalize to symbols so event-type routing,
17
- # wrapper flattening, and field lookups are reliable either way.
18
- json = deep_symbolize_keys(json)
19
-
20
- out = build(Jamm::OpenAPI::MerchantWebhookMessage, json)
14
+ out = Jamm::OpenAPI::MerchantWebhookMessage.new(json)
21
15
 
22
16
  case json[:event_type]
23
- when Jamm::OpenAPI::EventType::CHARGE_CREATED,
24
- Jamm::OpenAPI::EventType::CHARGE_UPDATED,
25
- Jamm::OpenAPI::EventType::REFUND_SUCCEEDED,
26
- Jamm::OpenAPI::EventType::REFUND_FAILED,
27
- Jamm::OpenAPI::EventType::CHARGE_SUCCESS,
28
- Jamm::OpenAPI::EventType::CHARGE_FAIL
29
- out.content = build(Jamm::OpenAPI::ChargeMessage, flatten_charge_content(json[:content]))
17
+ when Jamm::OpenAPI::EventType::CHARGE_CREATED
18
+ out.content = Jamm::OpenAPI::ChargeMessage.new(json[:content])
30
19
  return out
31
20
 
32
- when Jamm::OpenAPI::EventType::CONTRACT_ACTIVATED
33
- out.content = build(Jamm::OpenAPI::ContractMessage, json[:content])
21
+ when Jamm::OpenAPI::EventType::CHARGE_UPDATED
22
+ out.content = Jamm::OpenAPI::ChargeMessage.new(json[:content])
34
23
  return out
35
24
 
36
- when Jamm::OpenAPI::EventType::USER_ACCOUNT_DELETED
37
- out.content = build(Jamm::OpenAPI::UserAccountMessage, json[:content])
25
+ when Jamm::OpenAPI::EventType::REFUND_SUCCEEDED
26
+ out.content = Jamm::OpenAPI::ChargeMessage.new(json[:content])
38
27
  return out
39
- end
40
-
41
- raise 'Unknown event type'
42
- end
43
28
 
44
- # Build a generated model from a webhook payload while normalizing the
45
- # quirks of the webhook wire format. Applied to every model, so charges,
46
- # contracts, user-account and refund messages all benefit:
47
- #
48
- # 1. Forward-compat: the Jamm backend can add new fields to webhook
49
- # payloads at any time. The generated model `initialize` raises
50
- # ArgumentError on any key outside `attribute_map`, so unknown keys are
51
- # dropped first. Known keys are snake_case, matching `attribute_map`.
52
- # 2. Numeric enums: the backend serializes webhook payloads with Go's
53
- # `json.Marshal` (not protojson), so every enum field (status,
54
- # api_source, ...) arrives as its integer value, while the generated
55
- # enums are string-based. Each integer is mapped back to the enum string
56
- # so it matches the values returned by the REST API.
57
- # 3. Nested models: the generated `initialize` assigns nested objects
58
- # verbatim (the `_deserialize` coercion only runs from `build_from_hash`,
59
- # which expects camelCase keys the webhook does not use). So a nested
60
- # field like `refund.error` would stay a raw Hash and `error.code` would
61
- # raise NoMethodError. We coerce nested model fields (and arrays of them)
62
- # recursively so the typed accessors work.
63
- def self.build(klass, attributes)
64
- return nil if attributes.nil?
65
-
66
- known = klass.attribute_map
67
- types = klass.openapi_types
68
- filtered = attributes.each_with_object({}) do |(key, value), acc|
69
- sym = key.to_sym
70
- next unless known.key?(sym)
71
-
72
- acc[sym] = coerce(types[sym], value)
73
- end
29
+ when Jamm::OpenAPI::EventType::REFUND_FAILED
30
+ out.content = Jamm::OpenAPI::ChargeMessage.new(json[:content])
31
+ return out
74
32
 
75
- klass.new(filtered)
76
- end
33
+ when Jamm::OpenAPI::EventType::CHARGE_SUCCESS
34
+ out.content = Jamm::OpenAPI::ChargeMessage.new(json[:content])
35
+ return out
77
36
 
78
- # Refund webhooks (REFUND_SUCCEEDED / REFUND_FAILED) deliver `content` as a
79
- # nested { transaction, refund } wrapper instead of a flat ChargeMessage.
80
- # Flatten it back into a ChargeMessage so callers always receive the same shape.
81
- def self.flatten_charge_content(content)
82
- return content unless content.is_a?(Hash) && content.key?(:transaction)
83
-
84
- refund = content[:refund]
85
- # Keep `refund` as the raw Hash: `build` coerces it into a typed RefundInfo
86
- # (and recursively types its nested `error`). Also surface the refund's
87
- # `rfd-` id on the flat `refund_id` attribute the model documents.
88
- charge = content[:transaction].merge(refund: refund)
89
- charge[:refund_id] = refund[:id] if refund.is_a?(Hash) && !refund[:id].nil?
90
- charge
91
- end
37
+ when Jamm::OpenAPI::EventType::CHARGE_FAIL
38
+ out.content = Jamm::OpenAPI::ChargeMessage.new(json[:content])
39
+ return out
92
40
 
93
- # Coerce a raw webhook value into the shape the generated model expects,
94
- # based on the field's openapi type: numeric enums become their string
95
- # constant, nested models become typed instances, and `Array<T>` elements are
96
- # coerced by `T`. Anything else passes through untouched.
97
- def self.coerce(type, value)
98
- return value if value.nil?
99
-
100
- inner = array_inner_type(type)
101
- return coerce_array(inner, value) unless inner.nil?
102
-
103
- klass = openapi_const(type)
104
- return value if klass.nil?
105
-
106
- if klass.respond_to?(:all_vars)
107
- resolve_enum(klass, value)
108
- elsif klass.respond_to?(:openapi_types) && value.is_a?(Hash)
109
- build(klass, value)
110
- else
111
- value
41
+ when Jamm::OpenAPI::EventType::CONTRACT_ACTIVATED
42
+ out.content = Jamm::OpenAPI::ContractMessage.new(json[:content])
43
+ return out
112
44
  end
113
- end
114
-
115
- # Coerce each element of an `Array<T>` field by its inner type `T`.
116
- def self.coerce_array(inner_type, value)
117
- return value unless value.is_a?(Array)
118
45
 
119
- value.map { |element| coerce(inner_type, element) }
120
- end
121
-
122
- # Extract `T` from an `Array<T>` openapi type, or nil when not an array type.
123
- def self.array_inner_type(type)
124
- match = type.to_s.match(/\AArray<(.+)>\z/)
125
- match && match[1]
126
- end
127
-
128
- # Map a numeric enum wire value onto its string enum constant. A value that
129
- # is already a string (REST-style) passes through untouched.
130
- def self.resolve_enum(enum, value)
131
- return value unless value.is_a?(Integer)
132
-
133
- vars = enum.all_vars
134
- # Guard the bounds explicitly: Ruby maps negative indices from the end of
135
- # the array, so any unexpected wire value must fall back to the *_UNSPECIFIED
136
- # member (index 0) rather than silently selecting the wrong constant.
137
- value.between?(0, vars.length - 1) ? vars[value] : vars[0]
138
- end
139
-
140
- # Resolve an openapi_types entry (e.g. :ChargeMessageApiSource, :RefundInfo)
141
- # to its generated class, or nil when the type is a primitive (String,
142
- # Integer, ...) or otherwise unresolvable.
143
- def self.openapi_const(type)
144
- return nil if type.nil?
145
-
146
- name = type.to_s
147
- return nil unless Jamm::OpenAPI.const_defined?(name)
148
-
149
- Jamm::OpenAPI.const_get(name)
150
- rescue NameError
151
- nil
152
- end
153
-
154
- # Recursively convert Hash keys to symbols so parsing is robust regardless of
155
- # how the caller decoded the webhook JSON.
156
- def self.deep_symbolize_keys(value)
157
- case value
158
- when Hash
159
- value.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = deep_symbolize_keys(v) }
160
- when Array
161
- value.map { |v| deep_symbolize_keys(v) }
162
- else
163
- value
164
- end
46
+ raise 'Unknown event type'
165
47
  end
166
48
 
167
49
  # Verify message.
@@ -181,6 +63,118 @@ module Jamm
181
63
  raise Jamm::InvalidSignatureError, 'Digests do not match'
182
64
  end
183
65
 
66
+ # Verify the HMAC signature over the exact received bytes and parse, in one step.
67
+ #
68
+ # This is the recommended entry point. The backend signs the raw +content+ bytes it
69
+ # transmits, produced by Go's JSON encoder which HTML-escapes & < > as & <
70
+ # >. Re-serializing the parsed content (as +verify+ does via JSON.dump) un-escapes
71
+ # those characters, so its digest no longer matches. This slices the raw +content+
72
+ # substring out of +raw_body+ verbatim and HMACs that.
73
+ def self.verify_and_parse(raw_body)
74
+ raise ArgumentError, 'raw_body cannot be nil or empty' if raw_body.nil? || raw_body.empty?
75
+
76
+ parsed = JSON.parse(raw_body, symbolize_names: true)
77
+ signature = parsed[:signature]
78
+ raise ArgumentError, "Webhook body is missing the 'signature' field" if signature.nil? || signature.empty?
79
+
80
+ raw_content = extract_raw_content(raw_body)
81
+ digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), Jamm.client_secret, raw_content)
82
+ given = "sha256=#{digest}"
83
+ raise Jamm::InvalidSignatureError, 'Digests do not match' unless secure_compare(given, signature)
84
+
85
+ parse(parsed)
86
+ end
87
+
88
+ # Extracts the top-level +content+ value substring from a raw webhook body verbatim,
89
+ # without decoding or re-serializing it, so the exact signed bytes are recovered.
90
+ def self.extract_raw_content(raw_body)
91
+ i = 0
92
+ n = raw_body.length
93
+ i += 1 while i < n && raw_body[i].match?(/\s/)
94
+ raise ArgumentError, 'Webhook body must be a JSON object' unless raw_body[i] == '{'
95
+
96
+ i += 1
97
+ # Scan every top-level key. Duplicate keys are rejected: JSON.parse keeps the LAST
98
+ # occurrence while this returns the FIRST, so a duplicate `content` could otherwise
99
+ # verify one payload and parse another (signature bypass).
100
+ seen = {}
101
+ content = nil
102
+ loop do
103
+ i += 1 while i < n && (raw_body[i].match?(/\s/) || raw_body[i] == ',')
104
+ break if i >= n || raw_body[i] == '}'
105
+ raise ArgumentError, 'Malformed webhook JSON' unless raw_body[i] == '"'
106
+
107
+ key_start = i
108
+ i = skip_string(raw_body, i)
109
+ # Decode the key (not a raw slice): otherwise an escaped duplicate such as
110
+ # "content" would evade both the duplicate check and the 'content' match below,
111
+ # while JSON.parse collapses it to `content` and keeps the last value. Normalize a
112
+ # malformed key to ArgumentError to match the rest of this method.
113
+ key = begin
114
+ JSON.parse(raw_body[key_start...i])
115
+ rescue JSON::ParserError
116
+ raise ArgumentError, 'Malformed webhook JSON'
117
+ end
118
+ raise ArgumentError, "Duplicate top-level key in webhook body: #{key}" if seen.key?(key)
119
+
120
+ seen[key] = true
121
+ i += 1 while i < n && raw_body[i].match?(/\s/)
122
+ raise ArgumentError, 'Malformed webhook JSON' unless raw_body[i] == ':'
123
+
124
+ i += 1
125
+ i += 1 while i < n && raw_body[i].match?(/\s/)
126
+ value_start = i
127
+ i = skip_value(raw_body, i)
128
+ content = raw_body[value_start...i] if key == 'content'
129
+ end
130
+ raise ArgumentError, "Webhook body does not contain 'content' field" if content.nil?
131
+
132
+ content
133
+ end
134
+
135
+ # +i+ points at an opening '"'. Returns the index just past the closing '"'.
136
+ def self.skip_string(str, i)
137
+ i += 1
138
+ n = str.length
139
+ while i < n
140
+ c = str[i]
141
+ if c == '\\'
142
+ i += 2
143
+ next
144
+ end
145
+ return i + 1 if c == '"'
146
+
147
+ i += 1
148
+ end
149
+ raise ArgumentError, 'Unterminated string in webhook JSON'
150
+ end
151
+
152
+ # +i+ points at the first char of a JSON value. Returns the index just past it.
153
+ def self.skip_value(str, i)
154
+ return skip_string(str, i) if str[i] == '"'
155
+
156
+ if str[i] == '{' || str[i] == '['
157
+ depth = 0
158
+ n = str.length
159
+ while i < n
160
+ ch = str[i]
161
+ if ch == '"'
162
+ i = skip_string(str, i)
163
+ next
164
+ elsif ch == '{' || ch == '['
165
+ depth += 1
166
+ elsif ch == '}' || ch == ']'
167
+ depth -= 1
168
+ return i + 1 if depth.zero?
169
+ end
170
+ i += 1
171
+ end
172
+ raise ArgumentError, 'Unterminated object/array in webhook JSON'
173
+ end
174
+ i += 1 while i < str.length && !",}] \t\n\r".include?(str[i])
175
+ i
176
+ end
177
+
184
178
  # Securely compare two strings of equal length.
185
179
  # This method is a port of ActiveSupport::SecurityUtils.secure_compare
186
180
  # which works on non-Rails platforms.
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jamm
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.3.0
4
+ version: 2.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jamm
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-24 00:00:00.000000000 Z
11
+ date: 2026-07-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -84,7 +84,6 @@ files:
84
84
  - lib/jamm/api/configuration.rb
85
85
  - lib/jamm/api/models/apiv1_error.rb
86
86
  - lib/jamm/api/models/apiv1_status.rb
87
- - lib/jamm/api/models/charge_message_api_source.rb
88
87
  - lib/jamm/api/models/customer_service_update_customer_body.rb
89
88
  - lib/jamm/api/models/googlerpc_status.rb
90
89
  - lib/jamm/api/models/protobuf_any.rb
@@ -1,42 +0,0 @@
1
- =begin
2
- #Jamm API
3
-
4
- #No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
5
-
6
- The version of the OpenAPI document: 1.0
7
-
8
- Generated by: https://openapi-generator.tech
9
- Generator version: 7.9.0
10
-
11
- =end
12
-
13
- require 'date'
14
- require 'time'
15
-
16
- module Api
17
- class ChargeMessageApiSource
18
- UNSPECIFIED = "API_SOURCE_UNSPECIFIED".freeze
19
- OFF_SESSION_SYNC = "API_SOURCE_OFF_SESSION_SYNC".freeze
20
- OFF_SESSION_ASYNC = "API_SOURCE_OFF_SESSION_ASYNC".freeze
21
- ON_SESSION = "API_SOURCE_ON_SESSION".freeze
22
-
23
- def self.all_vars
24
- @all_vars ||= [UNSPECIFIED, OFF_SESSION_SYNC, OFF_SESSION_ASYNC, ON_SESSION].freeze
25
- end
26
-
27
- # Builds the enum from string
28
- # @param [String] The enum value in the form of the string
29
- # @return [String] The enum value
30
- def self.build_from_hash(value)
31
- new.build_from_hash(value)
32
- end
33
-
34
- # Builds the enum from string
35
- # @param [String] The enum value in the form of the string
36
- # @return [String] The enum value
37
- def build_from_hash(value)
38
- return value if ChargeMessageApiSource.all_vars.include?(value)
39
- raise "Invalid ENUM value #{value} for class #ChargeMessageApiSource"
40
- end
41
- end
42
- end