legba-ruby 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7e16984aefaf11067ac2cda77557466008f4ca10b3c2e254b07e162eda13df26
4
+ data.tar.gz: 6eda037a9461abc89012d851287d177b5cc427ed5c8ac5b5cc4b527944890f8f
5
+ SHA512:
6
+ metadata.gz: d85ebdff690bc72a80cf5bf45acc5a358b99a0df6c3099b14b883c42811467aa37e285272972548ba4684630ddecc95c81910e031c2a6f051a188a29940b5cc5
7
+ data.tar.gz: 231e3e5342fd0d79a9fa1b6e48c8a6c99dcbda22e39216488fbd90b2cccb4b3bc997643354bc6055f4e06c955d7e2b552908c4714e857dfa702233b11959f265
@@ -0,0 +1,289 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'uri'
5
+ require 'json'
6
+ require 'openssl'
7
+ require 'base64'
8
+ require_relative 'encryption'
9
+
10
+ module Legba
11
+ class Client
12
+ def initialize(app_id:, key:, secret:, cluster: nil, host: nil, use_tls: true, timeout: 30,
13
+ encryption_master_key_base64: nil)
14
+ raise ArgumentError, 'app_id is required' if app_id.nil? || app_id.empty?
15
+ raise ArgumentError, 'key is required' if key.nil? || key.empty?
16
+ raise ArgumentError, 'secret is required' if secret.nil? || secret.empty?
17
+ raise ArgumentError, 'cluster or host is required' if cluster.nil? && host.nil?
18
+
19
+ @app_id = app_id
20
+ @key = key
21
+ @secret = secret
22
+ @timeout = timeout
23
+
24
+ if encryption_master_key_base64
25
+ begin
26
+ key_bytes = Base64.strict_decode64(encryption_master_key_base64)
27
+ rescue ArgumentError
28
+ raise ArgumentError, 'encryption_master_key_base64 must be valid base64'
29
+ end
30
+ raise ArgumentError, 'encryption_master_key_base64 must encode exactly 32 bytes' unless key_bytes.bytesize == 32
31
+
32
+ @encryption_master_key = key_bytes
33
+ end
34
+
35
+ scheme = use_tls == false ? 'http' : 'https'
36
+ if host
37
+ bare = host.sub(%r{^https?://}, '')
38
+ @base_url = "#{scheme}://#{bare}"
39
+ else
40
+ @base_url = "#{scheme}://api-#{cluster}.legba.dev"
41
+ end
42
+ end
43
+
44
+ def trigger(channels, event, data, socket_id: nil)
45
+ channel_array = Array(channels)
46
+
47
+ raise ArgumentError, 'channels must not be empty' if channel_array.empty?
48
+
49
+ channel_array.each do |ch|
50
+ raise ArgumentError, 'each channel must be a non-empty string' unless ch.is_a?(String) && !ch.empty?
51
+ end
52
+ raise ArgumentError, 'event must be a non-empty string' unless event.is_a?(String) && !event.empty?
53
+
54
+ has_encrypted = channel_array.any? { |ch| Legba::Encryption.encrypted_channel?(ch) }
55
+ if has_encrypted && channel_array.length > 1
56
+ raise ArgumentError,
57
+ 'encrypted channels do not support multi-channel triggers'
58
+ end
59
+
60
+ data_string = JSON.generate(data)
61
+ raise ArgumentError, 'data must not exceed 10KB' if data_string.bytesize > 10_240
62
+
63
+ if has_encrypted
64
+ unless @encryption_master_key
65
+ raise ArgumentError,
66
+ 'encryption_master_key_base64 is required for encrypted channels'
67
+ end
68
+
69
+ shared_secret = Legba::Encryption.derive_shared_secret(@encryption_master_key, channel_array[0])
70
+ data_string = Legba::Encryption.encrypt_data(data_string, shared_secret)
71
+ end
72
+
73
+ body = { 'name' => event, 'data' => data_string }
74
+ if channel_array.length == 1
75
+ body['channel'] = channel_array[0]
76
+ else
77
+ body['channels'] = channel_array
78
+ end
79
+ body['socket_id'] = socket_id if socket_id
80
+
81
+ post('/events', JSON.generate(body))
82
+ end
83
+
84
+ def trigger_batch(events)
85
+ raise ArgumentError, 'batch must be a non-empty array' if events.nil? || events.empty?
86
+ raise ArgumentError, 'batch must not exceed 10 events' if events.length > 10
87
+
88
+ batch = events.map do |e|
89
+ channel = e[:channel] || e['channel']
90
+ event_name = e[:event] || e['event']
91
+ data = e[:data] || e['data']
92
+ socket_id = e[:socket_id] || e['socket_id']
93
+
94
+ unless channel.is_a?(String) && !channel.empty?
95
+ raise ArgumentError,
96
+ 'each batch event channel must be a non-empty string'
97
+ end
98
+ unless event_name.is_a?(String) && !event_name.empty?
99
+ raise ArgumentError,
100
+ 'each batch event name must be a non-empty string'
101
+ end
102
+
103
+ data_string = JSON.generate(data)
104
+ raise ArgumentError, 'batch event data must not exceed 10KB' if data_string.bytesize > 10_240
105
+
106
+ if Legba::Encryption.encrypted_channel?(channel)
107
+ unless @encryption_master_key
108
+ raise ArgumentError,
109
+ 'encryption_master_key_base64 is required for encrypted channels'
110
+ end
111
+
112
+ shared_secret = Legba::Encryption.derive_shared_secret(@encryption_master_key, channel)
113
+ data_string = Legba::Encryption.encrypt_data(data_string, shared_secret)
114
+ end
115
+
116
+ item = { 'name' => event_name, 'channel' => channel, 'data' => data_string }
117
+ item['socket_id'] = socket_id if socket_id
118
+ item
119
+ end
120
+
121
+ post('/batch_events', JSON.generate({ 'batch' => batch }))
122
+ end
123
+
124
+ def channels(filter_by_prefix: nil)
125
+ extra = filter_by_prefix ? { 'filter_by_prefix' => filter_by_prefix } : {}
126
+ raw = get('/channels', extra)
127
+
128
+ mapped = raw['channels'].each_with_object({}) do |(name, info), acc|
129
+ ch = { subscription_count: info['subscription_count'] }
130
+ ch[:user_count] = info['user_count'] if info.key?('user_count')
131
+ acc[name] = ch
132
+ end
133
+
134
+ { channels: mapped }
135
+ end
136
+
137
+ def channel(channel_name)
138
+ raise ArgumentError, 'channel_name must be a non-empty string' if channel_name.nil? || channel_name.empty?
139
+
140
+ raw = get("/channels/#{channel_name}")
141
+ result = { occupied: raw['occupied'], subscription_count: raw['subscription_count'] }
142
+ result[:user_count] = raw['user_count'] if raw.key?('user_count')
143
+ result
144
+ end
145
+
146
+ def channel_users(channel_name)
147
+ raise ArgumentError, 'channel_name must be a non-empty string' if channel_name.nil? || channel_name.empty?
148
+
149
+ unless channel_name.start_with?('presence-')
150
+ raise ArgumentError,
151
+ "channel_users is only available for presence channels (must start with 'presence-')"
152
+ end
153
+
154
+ raw = get("/channels/#{channel_name}/users")
155
+ { users: raw['users'].map { |u| { id: u['id'] } } }
156
+ end
157
+
158
+ def authenticate_channel(socket_id, channel_name, channel_data: nil)
159
+ raise ArgumentError, 'socket_id is required' if socket_id.nil? || socket_id.empty?
160
+ raise ArgumentError, 'channel_name is required' if channel_name.nil? || channel_name.empty?
161
+
162
+ if Legba::Encryption.encrypted_channel?(channel_name)
163
+ unless @encryption_master_key
164
+ raise ArgumentError,
165
+ 'encryption_master_key_base64 is required for encrypted channels'
166
+ end
167
+
168
+ shared_secret = Legba::Encryption.derive_shared_secret(@encryption_master_key, channel_name)
169
+ string_to_sign = "#{socket_id}:#{channel_name}"
170
+ hmac = OpenSSL::HMAC.hexdigest('SHA256', @secret, string_to_sign)
171
+ { auth: "#{@key}:#{hmac}", shared_secret: Base64.strict_encode64(shared_secret) }
172
+ elsif channel_name.start_with?('presence-')
173
+ raise ArgumentError, 'channel_data is required for presence channels' if channel_data.nil?
174
+
175
+ channel_data_json = JSON.generate(channel_data)
176
+ string_to_sign = "#{socket_id}:#{channel_name}:#{channel_data_json}"
177
+ hmac = OpenSSL::HMAC.hexdigest('SHA256', @secret, string_to_sign)
178
+ { auth: "#{@key}:#{hmac}", channel_data: channel_data_json }
179
+ else
180
+ string_to_sign = "#{socket_id}:#{channel_name}"
181
+ hmac = OpenSSL::HMAC.hexdigest('SHA256', @secret, string_to_sign)
182
+ { auth: "#{@key}:#{hmac}" }
183
+ end
184
+ end
185
+
186
+ def authenticate_user(socket_id, user_data)
187
+ raise ArgumentError, 'socket_id is required' if socket_id.nil? || socket_id.empty?
188
+
189
+ user_id = user_data[:id] || user_data['id']
190
+ raise ArgumentError, 'user_data must include :id' if user_id.nil? || user_id.to_s.empty?
191
+
192
+ user_info = user_data[:user_info] || user_data['user_info']
193
+
194
+ user_hash = { 'id' => user_id.to_s }
195
+ user_hash['user_info'] = user_info if user_info
196
+
197
+ body = JSON.generate({ 'socket_id' => socket_id, 'user' => user_hash })
198
+ post_json('/users/auth', body)
199
+ end
200
+
201
+ private
202
+
203
+ def sign(method, path, body, extra_params = {})
204
+ body_md5 = OpenSSL::Digest::MD5.hexdigest(body)
205
+ auth_timestamp = Time.now.to_i.to_s
206
+
207
+ params = {
208
+ 'auth_key' => @key,
209
+ 'auth_timestamp' => auth_timestamp,
210
+ 'auth_version' => '1.0',
211
+ 'body_md5' => body_md5
212
+ }.merge(extra_params)
213
+
214
+ sorted_params = params.keys.sort.map { |k| "#{k}=#{params[k]}" }.join('&')
215
+ string_to_sign = "#{method}\n#{path}\n#{sorted_params}"
216
+ auth_signature = OpenSSL::HMAC.hexdigest('SHA256', @secret, string_to_sign)
217
+
218
+ params.merge('auth_signature' => auth_signature)
219
+ end
220
+
221
+ def post(path, body)
222
+ full_path = "/apps/#{@app_id}#{path}"
223
+ signed = sign('POST', full_path, body)
224
+
225
+ uri = URI("#{@base_url}#{full_path}")
226
+ uri.query = URI.encode_www_form(signed)
227
+
228
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
229
+ http.read_timeout = @timeout
230
+ http.open_timeout = @timeout
231
+
232
+ req = Net::HTTP::Post.new(uri)
233
+ req['Content-Type'] = 'application/json'
234
+ req.body = body
235
+
236
+ resp = http.request(req)
237
+ return true if resp.is_a?(Net::HTTPSuccess)
238
+
239
+ json = JSON.parse(resp.body)
240
+ raise Legba::Error.new(json['error'], status: resp.code.to_i, code: json['code'])
241
+ end
242
+ end
243
+
244
+ def get(path, extra_params = {})
245
+ full_path = "/apps/#{@app_id}#{path}"
246
+ signed = sign('GET', full_path, '', extra_params)
247
+
248
+ uri = URI("#{@base_url}#{full_path}")
249
+ uri.query = URI.encode_www_form(signed)
250
+
251
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
252
+ http.read_timeout = @timeout
253
+ http.open_timeout = @timeout
254
+
255
+ req = Net::HTTP::Get.new(uri)
256
+ resp = http.request(req)
257
+ return JSON.parse(resp.body) if resp.is_a?(Net::HTTPSuccess)
258
+
259
+ json = JSON.parse(resp.body)
260
+ raise Legba::Error.new(json['error'], status: resp.code.to_i, code: json['code'])
261
+ end
262
+ end
263
+
264
+ def post_json(path, body)
265
+ full_path = "/apps/#{@app_id}#{path}"
266
+ signed = sign('POST', full_path, body)
267
+
268
+ uri = URI("#{@base_url}#{full_path}")
269
+ uri.query = URI.encode_www_form(signed)
270
+
271
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
272
+ http.read_timeout = @timeout
273
+ http.open_timeout = @timeout
274
+
275
+ req = Net::HTTP::Post.new(uri)
276
+ req['Content-Type'] = 'application/json'
277
+ req.body = body
278
+
279
+ resp = http.request(req)
280
+ if resp.is_a?(Net::HTTPSuccess)
281
+ return JSON.parse(resp.body).each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
282
+ end
283
+
284
+ json = JSON.parse(resp.body)
285
+ raise Legba::Error.new(json['error'], status: resp.code.to_i, code: json['code'])
286
+ end
287
+ end
288
+ end
289
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rbnacl'
4
+ require 'base64'
5
+ require 'openssl'
6
+ require 'json'
7
+
8
+ module Legba
9
+ module Encryption
10
+ ENCRYPTED_PREFIX = 'private-encrypted-'
11
+
12
+ def self.encrypted_channel?(channel_name)
13
+ channel_name.start_with?(ENCRYPTED_PREFIX)
14
+ end
15
+
16
+ def self.derive_shared_secret(master_key_bytes, channel_name)
17
+ digest = OpenSSL::Digest.new('SHA256')
18
+ digest.update(master_key_bytes)
19
+ digest.update(channel_name)
20
+ digest.digest
21
+ end
22
+
23
+ def self.encrypt_data(data_string, shared_secret)
24
+ box = RbNaCl::SecretBox.new(shared_secret)
25
+ nonce = RbNaCl::Random.random_bytes(box.nonce_bytes)
26
+ ciphertext = box.encrypt(nonce, data_string)
27
+
28
+ JSON.generate({
29
+ 'nonce' => Base64.strict_encode64(nonce),
30
+ 'ciphertext' => Base64.strict_encode64(ciphertext)
31
+ })
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legba
4
+ class Error < StandardError
5
+ attr_reader :status, :code
6
+
7
+ def initialize(message, status:, code:)
8
+ super(message)
9
+ @status = status
10
+ @code = code
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legba
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'json'
5
+
6
+ module Legba
7
+ class Webhook
8
+ # Verifies that the x-legba-signature header matches HMAC-SHA256(secret, body).
9
+ # Uses timing-safe comparison to prevent timing attacks.
10
+ # Returns true if valid, false otherwise.
11
+ def self.verify(headers, body, secret)
12
+ normalized = headers.each_with_object({}) { |(k, v), h| h[k.downcase] = v }
13
+ signature = normalized['x-legba-signature']
14
+ return false if signature.nil? || signature.empty?
15
+
16
+ expected = OpenSSL::HMAC.hexdigest('SHA256', secret, body)
17
+ OpenSSL.secure_compare(expected, signature)
18
+ end
19
+
20
+ # Parses a webhook JSON body into a symbol-keyed hash.
21
+ # Returns { time_ms: N, events: [{ name:, channel:, ... }] }
22
+ def self.parse(body)
23
+ raw = JSON.parse(body)
24
+ events = raw['events'].map do |event|
25
+ event.each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
26
+ end
27
+ { time_ms: raw['time_ms'], events: events }
28
+ end
29
+ end
30
+ end
data/lib/legba.rb ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'legba/version'
4
+ require_relative 'legba/error'
5
+ require_relative 'legba/encryption'
6
+ require_relative 'legba/client'
7
+ require_relative 'legba/webhook'
8
+
9
+ module Legba
10
+ # Public API: Legba::Client, Legba::Error, Legba::Webhook, Legba::VERSION
11
+ end
metadata ADDED
@@ -0,0 +1,120 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: legba-ruby
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Legba
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-03-16 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rbnacl
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '7.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '7.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: minitest
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '5.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '5.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '13.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '13.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rubocop
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.75'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.75'
69
+ - !ruby/object:Gem::Dependency
70
+ name: websocket-client-simple
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '0.9'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '0.9'
83
+ description: Zero-dependency Ruby server SDK for triggering real-time events on Legba
84
+ channels.
85
+ email:
86
+ executables: []
87
+ extensions: []
88
+ extra_rdoc_files: []
89
+ files:
90
+ - lib/legba.rb
91
+ - lib/legba/client.rb
92
+ - lib/legba/encryption.rb
93
+ - lib/legba/error.rb
94
+ - lib/legba/version.rb
95
+ - lib/legba/webhook.rb
96
+ homepage:
97
+ licenses:
98
+ - MIT
99
+ metadata:
100
+ rubygems_mfa_required: 'true'
101
+ post_install_message:
102
+ rdoc_options: []
103
+ require_paths:
104
+ - lib
105
+ required_ruby_version: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: 3.1.0
110
+ required_rubygems_version: !ruby/object:Gem::Requirement
111
+ requirements:
112
+ - - ">="
113
+ - !ruby/object:Gem::Version
114
+ version: '0'
115
+ requirements: []
116
+ rubygems_version: 3.3.7
117
+ signing_key:
118
+ specification_version: 4
119
+ summary: Ruby server SDK for Legba real-time messaging
120
+ test_files: []