stream-chat-ruby 3.26.1 → 3.26.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f76091a8b305ba916ed2217c639f46deca24f997c148aaac21dd1cdeb7664482
4
- data.tar.gz: bd4e54bd949250094bb2d3ee3c350312f3faebe927c5521733e9ffd6bb08e90a
3
+ metadata.gz: '019bf5c538cd43fe7a5faef6b980bf00818756ec5cc470d0e61f8e5d610bbf4e'
4
+ data.tar.gz: 389642e627468500058a1aa007dae4a9b2bda9bcda2e73e5fd30218730e74ce3
5
5
  SHA512:
6
- metadata.gz: c4352825a7532841cb8667de383e977c6a8ae9908bd2e44dc8de5f714cb8ea6a186d90e7b820b795ce097f78492838bb453dba1e216a7d8284ff6f7347f0b4d4
7
- data.tar.gz: f9e18435119dda8be34f8e282004fe9597cee212e34d5a676428d562005c44cdbcee8af19c4c3e2c878268c86beca1120a9173e9fe632341ee46221e6b758d38
6
+ metadata.gz: c2cbc7e4c1f481fefea8d06ee3a7e4e8c3c0c8e26a068b0754574588c0d65ce262886ffe3ebcee86196085df7d520ad4b1f6234c8b87351ba57d61e8d29132e2
7
+ data.tar.gz: 92abc3b3c173377ac3d5bc19957221f055fdfb15eabc38d8a64ef302be883b3441db8bbf22da87b937dbbb88f726344430bc5e6a8b2376f9a0f1c90734ff7d0a
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --require spec_helper
2
+ --format documentation
3
+ --profile 25
data/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
+ ### [3.26.2](https://github.com/GetStream/stream-chat-ruby/compare/v3.26.1...v3.26.2) (2026-06-30)
6
+
7
+
8
+ ### Features
9
+
10
+ * **webhooks:** verify_and_parse_* API for compressed payloads (CHA-3071) ([#211](https://github.com/GetStream/stream-chat-ruby/issues/211)) ([dd76c8f](https://github.com/GetStream/stream-chat-ruby/commit/dd76c8f39d0fae98c3d2c331635e9369b40e3696))
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * guard StreamAPIException against empty and non-integer error bodies ([#217](https://github.com/GetStream/stream-chat-ruby/issues/217)) ([415f6c0](https://github.com/GetStream/stream-chat-ruby/commit/415f6c0bd7b51b87631c5a1e8f821e50a5def2ec))
16
+
17
+
18
+ ### Other
19
+
20
+ * before-message-send hook attempt timeout (CHA-3267) ([#216](https://github.com/GetStream/stream-chat-ruby/issues/216)) ([e721776](https://github.com/GetStream/stream-chat-ruby/commit/e721776376c064b434485bd4cb4188762f9f3c2b)), closes [GetStream/chat#13541](https://github.com/GetStream/chat/issues/13541)
21
+ * **spec:** clean fellowship sediment + retry commands eventually ([#212](https://github.com/GetStream/stream-chat-ruby/issues/212)) ([f3c8f5b](https://github.com/GetStream/stream-chat-ruby/commit/f3c8f5be841f95c2e0e399d3d180d7565d80fd6a))
22
+
5
23
  ### [3.26.1](https://github.com/GetStream/stream-chat-ruby/compare/v3.25.0...v3.26.1) (2026-04-29)
6
24
 
7
25
 
@@ -1,7 +1,11 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
+ require 'base64'
4
5
  require 'open-uri'
6
+ require 'openssl'
7
+ require 'stringio'
8
+ require 'zlib'
5
9
  require 'faraday'
6
10
  require 'faraday/multipart'
7
11
  require 'faraday/net_http_persistent'
@@ -18,6 +22,7 @@ require 'stream-chat/util'
18
22
  require 'stream-chat/types'
19
23
  require 'stream-chat/moderation'
20
24
  require 'stream-chat/channel_batch_updater'
25
+ require 'stream-chat/webhook'
21
26
 
22
27
  module StreamChat
23
28
  DEFAULT_BLOCKLIST = 'profanity'
@@ -698,11 +703,57 @@ module StreamChat
698
703
  get('rate_limits', params: params)
699
704
  end
700
705
 
701
- # Verify the signature added to a webhook event.
706
+ # Verify the signature on a webhook event using the client's API secret.
707
+ #
708
+ # Backward-compatible boolean helper. New integrations should call
709
+ # {#verify_and_parse_webhook} (or the SQS / SNS variants), which also handle
710
+ # gzip payload compression and return the parsed event.
702
711
  sig { params(request_body: String, x_signature: String).returns(T::Boolean) }
703
712
  def verify_webhook(request_body, x_signature)
704
- signature = OpenSSL::HMAC.hexdigest('SHA256', @api_secret, request_body)
705
- signature == x_signature
713
+ StreamChat::Webhook.verify_signature(request_body, x_signature, @api_secret)
714
+ end
715
+
716
+ # Verify and parse an HTTP webhook event.
717
+ #
718
+ # Decompresses `body` when gzipped (detected from the body bytes), verifies
719
+ # the `X-Signature` header against the client's API secret, and returns the
720
+ # parsed event as a `Hash`. Typed event classes are planned for a future
721
+ # release.
722
+ #
723
+ # @param body [String, Array<Integer>] Raw HTTP request body bytes Stream
724
+ # signed.
725
+ # @param signature [String] Value of the `X-Signature` header.
726
+ # @return [Hash] The parsed event.
727
+ # @raise [InvalidWebhookError] When the signature does not match or the
728
+ # gzip envelope is malformed.
729
+ sig do
730
+ params(
731
+ body: T.any(String, T::Array[Integer]),
732
+ signature: String
733
+ ).returns(T::Hash[String, T.untyped])
734
+ end
735
+ def verify_and_parse_webhook(body, signature)
736
+ StreamChat::Webhook.verify_and_parse_webhook(body, signature, @api_secret)
737
+ end
738
+
739
+ # Parse an SQS-delivered webhook event (decode only; Stream does not HMAC-sign SQS bodies).
740
+ #
741
+ # @param message_body [String] SQS message `Body` string.
742
+ # @return [Hash] The parsed event.
743
+ # @raise [InvalidWebhookError] on malformed base64 or gzip envelope
744
+ sig { params(message_body: String).returns(T::Hash[String, T.untyped]) }
745
+ def parse_sqs(message_body)
746
+ StreamChat::Webhook.parse_sqs(message_body)
747
+ end
748
+
749
+ # Parse an SNS-delivered webhook event (unwraps envelope JSON when needed).
750
+ #
751
+ # @param message [String] Raw SNS POST body or pre-extracted Message string.
752
+ # @return [Hash] The parsed event.
753
+ # @raise [InvalidWebhookError] on decode failures
754
+ sig { params(message: String).returns(T::Hash[String, T.untyped]) }
755
+ def parse_sns(message)
756
+ StreamChat::Webhook.parse_sns(message)
706
757
  end
707
758
 
708
759
  # Allows you to send custom events to a connected user.
@@ -21,14 +21,28 @@ module StreamChat
21
21
  def initialize(response)
22
22
  super()
23
23
  @response = response
24
+
25
+ # Seed defaults first so the typed readers never return nil. A 5xx can
26
+ # arrive with an empty or non-JSON body (e.g. a load balancer emitting a
27
+ # bare 503), and an error envelope may omit "code"/"message" or send a
28
+ # non-integer "code". Without these defaults, reading error_code on such
29
+ # an exception raised a Sorbet TypeError that masked the real HTTP error.
30
+ @json_response = T.let(false, T::Boolean)
31
+ @error_code = T.let(-1, Integer)
32
+ @error_message = T.let('unknown', String)
33
+
24
34
  begin
25
35
  parsed_response = JSON.parse(response.body)
26
- @json_response = T.let(true, T::Boolean)
27
- @error_code = T.let(parsed_response.fetch('code', 'unknown'), Integer)
28
- @error_message = T.let(parsed_response.fetch('message', 'unknown'), String)
29
36
  rescue JSON::ParserError
30
- @json_response = false
37
+ return
31
38
  end
39
+ return unless parsed_response.is_a?(Hash)
40
+
41
+ @json_response = true
42
+ code = parsed_response['code']
43
+ @error_code = code if code.is_a?(Integer)
44
+ msg = parsed_response['message']
45
+ @error_message = msg if msg.is_a?(String)
32
46
  end
33
47
 
34
48
  sig { returns(String) }
@@ -47,4 +61,15 @@ module StreamChat
47
61
  end
48
62
 
49
63
  class StreamChannelException < StandardError; end
64
+
65
+ # Raised by webhook verify/parse helpers when the HMAC does not match or a
66
+ # gzip/base64/JSON envelope cannot be decoded. The message identifies which
67
+ # failure mode fired; the class-level constants below are the canonical
68
+ # strings for callers that prefer exact-match filtering over substring.
69
+ class InvalidWebhookError < StandardError
70
+ SIGNATURE_MISMATCH = 'signature mismatch'
71
+ INVALID_BASE64 = 'invalid base64 encoding'
72
+ GZIP_FAILED = 'gzip decompression failed'
73
+ INVALID_JSON = 'invalid JSON payload'
74
+ end
50
75
  end
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module StreamChat
5
- VERSION = '3.26.1'
5
+ VERSION = '3.26.2'
6
6
  end
@@ -0,0 +1,206 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'base64'
5
+ require 'json'
6
+ require 'openssl'
7
+ require 'sorbet-runtime'
8
+ require 'stringio'
9
+ require 'zlib'
10
+
11
+ require 'stream-chat/errors'
12
+
13
+ module StreamChat
14
+ # Stateless helpers implementing the cross-SDK webhook contract documented at
15
+ # https://getstream.io/chat/docs/node/webhooks_overview/.
16
+ #
17
+ # The composite functions (`verify_and_parse_webhook`, `parse_sqs`,
18
+ # `parse_sns`) are the recommended entry points. The primitives
19
+ # they compose (`gunzip_payload`, `decode_sqs_payload`, `decode_sns_payload`,
20
+ # `verify_signature`, `parse_event`) are exposed so callers can build custom
21
+ # flows or run individual steps in isolation.
22
+ #
23
+ # The Ruby SDK currently returns the parsed JSON as a `Hash`; typed event
24
+ # classes will land in a future release.
25
+ module Webhook # rubocop:disable Metrics/ModuleLength
26
+ extend T::Sig
27
+
28
+ GZIP_MAGIC = T.let("\x1f\x8b".b.freeze, String)
29
+
30
+ # Coerces the webhook body into a binary `String` regardless of whether
31
+ # the caller hands us a `String` (HTTP `request.raw_post`) or an array of
32
+ # bytes (which is what some Ruby SQS clients yield when the message body
33
+ # is binary safe).
34
+ sig { params(body: T.any(String, T::Array[Integer])).returns(String) }
35
+ def self.normalize_body(body)
36
+ raw =
37
+ if body.is_a?(Array)
38
+ body.pack('C*')
39
+ else
40
+ String.new(body)
41
+ end
42
+ raw.force_encoding(Encoding::ASCII_8BIT)
43
+ end
44
+
45
+ # Returns `body` unchanged unless it starts with the gzip magic
46
+ # (`1f 8b`, per RFC 1952), in which case the gzip stream is inflated and
47
+ # the decompressed bytes are returned.
48
+ #
49
+ # Magic-byte detection (rather than relying on a header) keeps the same
50
+ # handler correct when middleware - Rack, Rails - auto-decompresses the
51
+ # request before your code sees it.
52
+ sig { params(body: T.any(String, T::Array[Integer])).returns(String) }
53
+ def self.gunzip_payload(body)
54
+ raw = normalize_body(body)
55
+ return raw unless raw.start_with?(GZIP_MAGIC)
56
+
57
+ begin
58
+ Zlib::GzipReader.new(StringIO.new(raw)).read.force_encoding(Encoding::ASCII_8BIT)
59
+ rescue Zlib::Error
60
+ raise InvalidWebhookError, InvalidWebhookError::GZIP_FAILED
61
+ end
62
+ end
63
+
64
+ # Reverses the SQS firehose envelope: the message `Body` is base64-decoded
65
+ # and, when the result begins with the gzip magic, gzip-decompressed. The
66
+ # same call works whether or not Stream is currently compressing payloads.
67
+ sig { params(body: String).returns(String) }
68
+ def self.decode_sqs_payload(body)
69
+ decoded =
70
+ begin
71
+ Base64.strict_decode64(body)
72
+ rescue ArgumentError
73
+ raise InvalidWebhookError, InvalidWebhookError::INVALID_BASE64
74
+ end
75
+ gunzip_payload(decoded)
76
+ end
77
+
78
+ # Reverses an SNS HTTP notification envelope. When `notification_body` is a
79
+ # JSON envelope (`{"Type":"Notification","Message":"..."}`), the inner
80
+ # `Message` field is extracted and run through the SQS pipeline
81
+ # (base64-decode, then gzip-if-magic). When the input is not a JSON
82
+ # envelope it is treated as the already-extracted `Message` string, so
83
+ # call sites that pre-unwrap continue to work.
84
+ sig { params(notification_body: String).returns(String) }
85
+ def self.decode_sns_payload(notification_body)
86
+ inner = extract_sns_message(notification_body)
87
+ decode_sqs_payload(inner.nil? ? notification_body : inner)
88
+ end
89
+
90
+ sig { params(notification_body: String).returns(T.nilable(String)) }
91
+ def self.extract_sns_message(notification_body)
92
+ trimmed = notification_body.to_s.lstrip
93
+ return nil unless trimmed.start_with?('{')
94
+
95
+ begin
96
+ parsed = JSON.parse(trimmed)
97
+ rescue JSON::ParserError
98
+ return nil
99
+ end
100
+ return nil unless parsed.is_a?(Hash)
101
+
102
+ message = parsed['Message']
103
+ message.is_a?(String) ? message : nil
104
+ end
105
+ private_class_method :extract_sns_message
106
+
107
+ # Constant-time HMAC-SHA256 verification of `signature` against the digest
108
+ # of `body` keyed by `secret`.
109
+ #
110
+ # The signature is always computed over the **uncompressed** JSON bytes,
111
+ # so callers that decoded a gzipped or base64-wrapped payload must pass
112
+ # the inflated bytes here.
113
+ sig do
114
+ params(
115
+ body: T.any(String, T::Array[Integer]),
116
+ signature: String,
117
+ secret: String
118
+ ).returns(T::Boolean)
119
+ end
120
+ def self.verify_signature(body, signature, secret)
121
+ raw = normalize_body(body)
122
+ expected = OpenSSL::HMAC.hexdigest('SHA256', secret, raw)
123
+ constant_time_equal?(expected, signature)
124
+ end
125
+
126
+ # Parse a JSON-encoded webhook event into a `Hash`.
127
+ #
128
+ # The Ruby SDK currently returns the parsed JSON as a `Hash`; typed event
129
+ # classes will land in a future release. The function name matches the
130
+ # documented primitive so callers can swap in a typed parser later without
131
+ # changing call sites.
132
+ sig { params(payload: String).returns(T::Hash[String, T.untyped]) }
133
+ def self.parse_event(payload)
134
+ result =
135
+ begin
136
+ JSON.parse(payload)
137
+ rescue JSON::ParserError
138
+ raise InvalidWebhookError, InvalidWebhookError::INVALID_JSON
139
+ end
140
+ raise InvalidWebhookError, InvalidWebhookError::INVALID_JSON unless result.is_a?(Hash)
141
+
142
+ result
143
+ end
144
+
145
+ sig do
146
+ params(
147
+ payload: String,
148
+ signature: String,
149
+ secret: String
150
+ ).returns(T::Hash[String, T.untyped])
151
+ end
152
+ def self.verify_and_parse_internal(payload, signature, secret)
153
+ raise InvalidWebhookError, InvalidWebhookError::SIGNATURE_MISMATCH unless verify_signature(payload, signature, secret)
154
+
155
+ parse_event(payload)
156
+ end
157
+ private_class_method :verify_and_parse_internal
158
+
159
+ # Decompress `body` when gzipped, verify the HMAC `signature`, and return
160
+ # the parsed event.
161
+ sig do
162
+ params(
163
+ body: T.any(String, T::Array[Integer]),
164
+ signature: String,
165
+ secret: String
166
+ ).returns(T::Hash[String, T.untyped])
167
+ end
168
+ def self.verify_and_parse_webhook(body, signature, secret)
169
+ verify_and_parse_internal(gunzip_payload(body), signature, secret)
170
+ end
171
+
172
+ # Decode the SQS `Body` (base64, then gzip-if-magic) and return the parsed
173
+ # event JSON. Stream does not ship an application-level signature on SQS payloads.
174
+ sig { params(message_body: String).returns(T::Hash[String, T.untyped]) }
175
+ def self.parse_sqs(message_body)
176
+ parse_event(decode_sqs_payload(message_body))
177
+ end
178
+
179
+ # Decode SNS (unwrap envelope when needed) and return the parsed event JSON.
180
+ sig { params(message: String).returns(T::Hash[String, T.untyped]) }
181
+ def self.parse_sns(message)
182
+ parse_event(decode_sns_payload(message))
183
+ end
184
+
185
+ # Timing-safe equality check used to compare the locally computed HMAC
186
+ # with the `X-Signature` header. Prefers the OpenSSL primitive when the
187
+ # Ruby build exposes it, otherwise falls back to a manual byte XOR loop
188
+ # that does not short-circuit on the first mismatch.
189
+ sig { params(left: String, right: String).returns(T::Boolean) }
190
+ def self.constant_time_equal?(left, right)
191
+ a = left.b
192
+ b = right.b
193
+ return false unless a.bytesize == b.bytesize
194
+
195
+ if OpenSSL.respond_to?(:fixed_length_secure_compare)
196
+ OpenSSL.fixed_length_secure_compare(a, b)
197
+ else
198
+ a_bytes = a.bytes
199
+ b_bytes = b.bytes
200
+ diff = 0
201
+ a_bytes.each_with_index { |byte, i| diff |= byte ^ b_bytes[i] }
202
+ diff.zero?
203
+ end
204
+ end
205
+ end
206
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: stream-chat-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.26.1
4
+ version: 3.26.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - getstream.io
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-29 00:00:00.000000000 Z
11
+ date: 2026-06-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -113,6 +113,7 @@ extensions: []
113
113
  extra_rdoc_files: []
114
114
  files:
115
115
  - ".gitignore"
116
+ - ".rspec"
116
117
  - ".rubocop.yml"
117
118
  - ".versionrc.js"
118
119
  - CHANGELOG.md
@@ -136,6 +137,7 @@ files:
136
137
  - lib/stream-chat/types.rb
137
138
  - lib/stream-chat/util.rb
138
139
  - lib/stream-chat/version.rb
140
+ - lib/stream-chat/webhook.rb
139
141
  - stream-chat.gemspec
140
142
  homepage: http://github.com/GetStream/stream-chat-ruby
141
143
  licenses: []