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 +7 -0
- data/lib/legba/client.rb +289 -0
- data/lib/legba/encryption.rb +34 -0
- data/lib/legba/error.rb +13 -0
- data/lib/legba/version.rb +5 -0
- data/lib/legba/webhook.rb +30 -0
- data/lib/legba.rb +11 -0
- metadata +120 -0
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
|
data/lib/legba/client.rb
ADDED
|
@@ -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
|
data/lib/legba/error.rb
ADDED
|
@@ -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: []
|