flagkit 1.0.1

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,528 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "base64"
5
+ require "json"
6
+
7
+ module FlagKit
8
+ module Utils
9
+ # Security utilities for FlagKit SDK.
10
+ #
11
+ # Provides methods for detecting potential PII in data,
12
+ # validating API key usage, request signing with HMAC-SHA256,
13
+ # and cache encryption with AES-256-GCM.
14
+ module Security
15
+ # Common PII field patterns (case-insensitive)
16
+ PII_PATTERNS = %w[
17
+ email
18
+ phone
19
+ telephone
20
+ mobile
21
+ ssn
22
+ social_security
23
+ socialSecurity
24
+ credit_card
25
+ creditCard
26
+ card_number
27
+ cardNumber
28
+ cvv
29
+ password
30
+ passwd
31
+ secret
32
+ token
33
+ api_key
34
+ apiKey
35
+ private_key
36
+ privateKey
37
+ access_token
38
+ accessToken
39
+ refresh_token
40
+ refreshToken
41
+ auth_token
42
+ authToken
43
+ address
44
+ street
45
+ zip_code
46
+ zipCode
47
+ postal_code
48
+ postalCode
49
+ date_of_birth
50
+ dateOfBirth
51
+ dob
52
+ birth_date
53
+ birthDate
54
+ passport
55
+ driver_license
56
+ driverLicense
57
+ national_id
58
+ nationalId
59
+ bank_account
60
+ bankAccount
61
+ routing_number
62
+ routingNumber
63
+ iban
64
+ swift
65
+ ].freeze
66
+
67
+ class << self
68
+ # Checks if a field name potentially contains PII.
69
+ #
70
+ # @param field_name [String] The field name to check
71
+ # @return [Boolean] true if the field name matches a PII pattern
72
+ #
73
+ # @example
74
+ # Security.potential_pii_field?("email") # => true
75
+ # Security.potential_pii_field?("userEmail") # => true
76
+ # Security.potential_pii_field?("plan") # => false
77
+ def potential_pii_field?(field_name)
78
+ return false if field_name.nil?
79
+
80
+ lower_name = field_name.to_s.downcase
81
+ patterns = config.additional_pii_patterns + PII_PATTERNS
82
+ patterns.any? { |pattern| lower_name.include?(pattern.downcase) }
83
+ end
84
+
85
+ # Detects potential PII in an object and returns the field paths.
86
+ #
87
+ # @param data [Hash] The data to scan for PII
88
+ # @param prefix [String] Optional prefix for nested field paths
89
+ # @return [Array<String>] Array of field paths that may contain PII
90
+ #
91
+ # @example
92
+ # data = { email: "user@example.com", plan: "premium" }
93
+ # Security.detect_potential_pii(data) # => ["email"]
94
+ #
95
+ # @example Nested data
96
+ # data = { user: { email: "test@example.com" } }
97
+ # Security.detect_potential_pii(data) # => ["user.email"]
98
+ def detect_potential_pii(data, prefix = "")
99
+ return [] unless data.is_a?(Hash)
100
+
101
+ pii_fields = []
102
+
103
+ data.each do |key, value|
104
+ key_str = key.to_s
105
+ full_path = prefix.empty? ? key_str : "#{prefix}.#{key_str}"
106
+
107
+ pii_fields << full_path if potential_pii_field?(key_str)
108
+
109
+ # Recursively check nested hashes
110
+ if value.is_a?(Hash)
111
+ nested_pii = detect_potential_pii(value, full_path)
112
+ pii_fields.concat(nested_pii)
113
+ end
114
+ end
115
+
116
+ pii_fields
117
+ end
118
+
119
+ # Warns about potential PII in data.
120
+ #
121
+ # @param data [Hash, nil] The data to check
122
+ # @param data_type [String, Symbol] The type of data ("context" or "event")
123
+ # @param logger [Object, nil] Optional logger instance
124
+ # @param strict_mode [Boolean] When true, raises SecurityError instead of warning
125
+ # @param has_private_attributes [Boolean] Whether private_attributes are configured
126
+ # @return [void]
127
+ # @raise [FlagKit::SecurityError] In strict mode when PII detected without private_attributes
128
+ #
129
+ # @example
130
+ # Security.warn_if_potential_pii({ email: "test@example.com" }, :context, logger)
131
+ def warn_if_potential_pii(data, data_type, logger = nil, strict_mode: false, has_private_attributes: false)
132
+ return if data.nil?
133
+
134
+ pii_fields = detect_potential_pii(data)
135
+ return if pii_fields.empty?
136
+
137
+ # In strict mode, raise error if PII detected without private_attributes
138
+ if strict_mode && !has_private_attributes
139
+ raise FlagKit::SecurityError.new(
140
+ FlagKit::ErrorCode::SECURITY_PII_DETECTED,
141
+ "Potential PII detected in #{data_type} data: #{pii_fields.join(', ')}. " \
142
+ "In strict_pii_mode, you must configure private_attributes for PII fields or remove the data."
143
+ )
144
+ end
145
+
146
+ return unless config.warn_on_potential_pii
147
+ return if logger.nil?
148
+
149
+ advice = case data_type.to_s
150
+ when "context"
151
+ "Consider adding these to privateAttributes."
152
+ else
153
+ "Consider removing sensitive data from events."
154
+ end
155
+
156
+ message = "[FlagKit Security] Potential PII detected in #{data_type} data: " \
157
+ "#{pii_fields.join(', ')}. #{advice}"
158
+
159
+ logger.warn(message) if logger.respond_to?(:warn)
160
+ end
161
+
162
+ # Checks if an API key is a server key.
163
+ #
164
+ # @param api_key [String] The API key to check
165
+ # @return [Boolean] true if the key starts with "srv_"
166
+ #
167
+ # @example
168
+ # Security.server_key?("srv_abc123") # => true
169
+ # Security.server_key?("sdk_abc123") # => false
170
+ def server_key?(api_key)
171
+ return false if api_key.nil?
172
+
173
+ api_key.to_s.start_with?("srv_")
174
+ end
175
+
176
+ # Checks if an API key is a client/SDK key.
177
+ #
178
+ # @param api_key [String] The API key to check
179
+ # @return [Boolean] true if the key starts with "sdk_" or "cli_"
180
+ #
181
+ # @example
182
+ # Security.client_key?("sdk_abc123") # => true
183
+ # Security.client_key?("cli_abc123") # => true
184
+ # Security.client_key?("srv_abc123") # => false
185
+ def client_key?(api_key)
186
+ return false if api_key.nil?
187
+
188
+ key_str = api_key.to_s
189
+ key_str.start_with?("sdk_") || key_str.start_with?("cli_")
190
+ end
191
+
192
+ # Warns if a server key is used in a browser-like environment.
193
+ #
194
+ # In Ruby, this primarily applies to environments where JavaScript
195
+ # is generated or where code might be exposed to clients (e.g., Rails
196
+ # views with JavaScript, Opal, or similar).
197
+ #
198
+ # @param api_key [String] The API key to check
199
+ # @param logger [Object, nil] Optional logger instance
200
+ # @return [void]
201
+ #
202
+ # @example
203
+ # Security.warn_if_server_key_in_browser("srv_abc123", logger)
204
+ def warn_if_server_key_in_browser(api_key, logger = nil)
205
+ return unless config.warn_on_server_key_in_browser
206
+ return unless browser_like_environment? && server_key?(api_key)
207
+
208
+ message = "[FlagKit Security] WARNING: Server keys (srv_) should not be used in browser environments. " \
209
+ "This exposes your server key in client-side code, which is a security risk. " \
210
+ "Use SDK keys (sdk_) for client-side applications instead. " \
211
+ "See: https://docs.flagkit.dev/sdk/security#api-keys"
212
+
213
+ # Always output to stderr for visibility
214
+ warn message
215
+
216
+ # Also log through the SDK logger if available
217
+ logger.warn(message) if logger.respond_to?(:warn)
218
+ end
219
+
220
+ # Returns the current security configuration.
221
+ #
222
+ # @return [SecurityConfig] The current configuration
223
+ def config
224
+ @config ||= SecurityConfig.new
225
+ end
226
+
227
+ # Configures security settings.
228
+ #
229
+ # @yield [SecurityConfig] The configuration object to modify
230
+ # @return [SecurityConfig] The updated configuration
231
+ #
232
+ # @example
233
+ # Security.configure do |config|
234
+ # config.warn_on_potential_pii = true
235
+ # config.additional_pii_patterns = ["custom_field"]
236
+ # end
237
+ def configure
238
+ yield config if block_given?
239
+ config
240
+ end
241
+
242
+ # Resets configuration to defaults.
243
+ #
244
+ # @return [SecurityConfig] A new default configuration
245
+ def reset_config!
246
+ @config = SecurityConfig.new
247
+ end
248
+
249
+ # Gets the first 8 characters of an API key for identification.
250
+ # This is safe to expose as it doesn't reveal the full key.
251
+ #
252
+ # @param api_key [String] The API key
253
+ # @return [String] The key identifier (first 8 characters)
254
+ #
255
+ # @example
256
+ # Security.get_key_id("sdk_abc123xyz") # => "sdk_abc1"
257
+ def get_key_id(api_key)
258
+ return "" if api_key.nil?
259
+
260
+ api_key.to_s[0, 8]
261
+ end
262
+
263
+ # Generates an HMAC-SHA256 signature.
264
+ #
265
+ # @param message [String] The message to sign
266
+ # @param key [String] The signing key
267
+ # @return [String] The hex-encoded signature
268
+ #
269
+ # @example
270
+ # signature = Security.generate_hmac_sha256("message", "secret")
271
+ def generate_hmac_sha256(message, key)
272
+ OpenSSL::HMAC.hexdigest("SHA256", key, message)
273
+ end
274
+
275
+ # Creates a request signature for POST request bodies.
276
+ # Format: timestamp.body
277
+ #
278
+ # @param body [String] The request body (JSON string)
279
+ # @param api_key [String] The API key for signing
280
+ # @param timestamp [Integer, nil] Optional timestamp in milliseconds
281
+ # @return [Hash] Hash containing :signature, :timestamp, and :key_id
282
+ #
283
+ # @example
284
+ # result = Security.create_request_signature('{"key":"value"}', "sdk_abc123")
285
+ # # => { signature: "abc123...", timestamp: 1234567890, key_id: "sdk_abc1" }
286
+ def create_request_signature(body, api_key, timestamp: nil)
287
+ ts = timestamp || (Time.now.to_f * 1000).to_i
288
+ message = "#{ts}.#{body}"
289
+ signature = generate_hmac_sha256(message, api_key)
290
+
291
+ {
292
+ signature: signature,
293
+ timestamp: ts,
294
+ key_id: get_key_id(api_key)
295
+ }
296
+ end
297
+
298
+ # Signs a payload with HMAC-SHA256.
299
+ #
300
+ # @param data [Object] The data to sign (will be JSON encoded)
301
+ # @param api_key [String] The API key for signing
302
+ # @param timestamp [Integer, nil] Optional timestamp in milliseconds
303
+ # @return [Hash] Signed payload with :data, :signature, :timestamp, :key_id
304
+ #
305
+ # @example
306
+ # signed = Security.sign_payload({ events: [] }, "sdk_abc123")
307
+ def sign_payload(data, api_key, timestamp: nil)
308
+ ts = timestamp || (Time.now.to_f * 1000).to_i
309
+ payload = JSON.generate(data)
310
+ message = "#{ts}.#{payload}"
311
+ signature = generate_hmac_sha256(message, api_key)
312
+
313
+ {
314
+ data: data,
315
+ signature: signature,
316
+ timestamp: ts,
317
+ key_id: get_key_id(api_key)
318
+ }
319
+ end
320
+
321
+ # Verifies a signed payload.
322
+ #
323
+ # @param signed_payload [Hash] The signed payload to verify
324
+ # @param api_key [String] The API key for verification
325
+ # @param max_age_ms [Integer] Maximum age in milliseconds (default: 5 minutes)
326
+ # @return [Boolean] true if the signature is valid
327
+ #
328
+ # @example
329
+ # Security.verify_signed_payload(signed, "sdk_abc123")
330
+ def verify_signed_payload(signed_payload, api_key, max_age_ms: 300_000)
331
+ # Check timestamp age
332
+ age = (Time.now.to_f * 1000).to_i - signed_payload[:timestamp]
333
+ return false if age > max_age_ms || age.negative?
334
+
335
+ # Verify key ID matches
336
+ return false if signed_payload[:key_id] != get_key_id(api_key)
337
+
338
+ # Verify signature
339
+ payload = JSON.generate(signed_payload[:data])
340
+ message = "#{signed_payload[:timestamp]}.#{payload}"
341
+ expected_signature = generate_hmac_sha256(message, api_key)
342
+
343
+ signed_payload[:signature] == expected_signature
344
+ end
345
+
346
+ # Canonicalizes an object by sorting keys recursively.
347
+ # This ensures consistent JSON output for signature verification.
348
+ #
349
+ # @param obj [Object] The object to canonicalize
350
+ # @return [String] Canonical JSON string representation
351
+ #
352
+ # @example
353
+ # Security.canonicalize_object({ b: 2, a: 1 }) # => '{"a":1,"b":2}'
354
+ def canonicalize_object(obj)
355
+ JSON.generate(deep_sort_keys(obj))
356
+ end
357
+
358
+ # Verifies an HMAC-SHA256 signature for bootstrap data.
359
+ #
360
+ # @param bootstrap [Hash] The bootstrap data with :flags, :signature, :timestamp
361
+ # @param api_key [String] The API key for verification
362
+ # @param max_age_ms [Integer] Maximum age in milliseconds (default: 24 hours)
363
+ # @return [Hash] Result hash with :valid (Boolean) and :error (String or nil)
364
+ def verify_bootstrap_signature(bootstrap, api_key, max_age_ms: 86_400_000)
365
+ bootstrap = normalize_keys(bootstrap)
366
+
367
+ error = validate_bootstrap_fields(bootstrap) || validate_bootstrap_timestamp(bootstrap, max_age_ms)
368
+ return { valid: false, error: error } if error
369
+
370
+ verify_bootstrap_hmac(bootstrap, api_key)
371
+ end
372
+
373
+ private
374
+
375
+ # Validates required bootstrap fields are present.
376
+ def validate_bootstrap_fields(bootstrap)
377
+ return "Missing signature" unless bootstrap[:signature]
378
+ return "Missing timestamp" unless bootstrap[:timestamp]
379
+ return "Missing flags" unless bootstrap[:flags]
380
+
381
+ nil
382
+ end
383
+
384
+ # Validates bootstrap timestamp is within acceptable age range.
385
+ def validate_bootstrap_timestamp(bootstrap, max_age_ms)
386
+ timestamp = bootstrap[:timestamp].to_i
387
+ age = (Time.now.to_f * 1000).to_i - timestamp
388
+
389
+ return "Bootstrap data expired (age: #{age}ms, max: #{max_age_ms}ms)" if age > max_age_ms
390
+ return "Bootstrap timestamp is in the future" if age.negative?
391
+
392
+ nil
393
+ end
394
+
395
+ # Computes and verifies the HMAC signature for bootstrap data.
396
+ def verify_bootstrap_hmac(bootstrap, api_key)
397
+ canonical_flags = canonicalize_object(bootstrap[:flags])
398
+ message = "#{bootstrap[:timestamp]}.#{canonical_flags}"
399
+ expected = generate_hmac_sha256(message, api_key)
400
+
401
+ if secure_compare(expected, bootstrap[:signature].to_s)
402
+ { valid: true, error: nil }
403
+ else
404
+ { valid: false, error: "Invalid signature" }
405
+ end
406
+ end
407
+
408
+ # Performs constant-time string comparison to prevent timing attacks.
409
+ #
410
+ # @param a [String] First string
411
+ # @param b [String] Second string
412
+ # @return [Boolean] true if strings are equal
413
+ def secure_compare(expected, actual)
414
+ return false unless expected.bytesize == actual.bytesize
415
+
416
+ # Use OpenSSL's fixed_length_secure_compare for constant-time comparison
417
+ OpenSSL.fixed_length_secure_compare(expected, actual)
418
+ rescue NoMethodError
419
+ # Fallback for older Ruby versions without fixed_length_secure_compare
420
+ # This is a constant-time comparison implementation
421
+ l = expected.unpack("C*")
422
+ r = actual.unpack("C*")
423
+ result = 0
424
+ l.zip(r) { |x, y| result |= x ^ y }
425
+ result.zero?
426
+ end
427
+
428
+ # Recursively sorts hash keys for canonical representation.
429
+ #
430
+ # @param obj [Object] The object to process
431
+ # @return [Object] Object with sorted keys
432
+ def deep_sort_keys(obj)
433
+ case obj
434
+ when Hash
435
+ obj.keys.sort_by(&:to_s).each_with_object({}) do |key, sorted|
436
+ sorted[key] = deep_sort_keys(obj[key])
437
+ end
438
+ when Array
439
+ obj.map { |item| deep_sort_keys(item) }
440
+ else
441
+ obj
442
+ end
443
+ end
444
+
445
+ # Normalizes hash keys to symbols.
446
+ #
447
+ # @param hash [Hash] The hash to normalize
448
+ # @return [Hash] Hash with symbol keys
449
+ def normalize_keys(hash)
450
+ return hash unless hash.is_a?(Hash)
451
+
452
+ hash.transform_keys { |key| key.to_sym rescue key }
453
+ end
454
+
455
+ # Checks if we're in a browser-like environment.
456
+ #
457
+ # For Ruby, this checks for common indicators that code might
458
+ # be running in or generating client-side JavaScript:
459
+ # - Opal (Ruby to JavaScript compiler)
460
+ # - Browser gem
461
+ # - Environment variables indicating client-side context
462
+ #
463
+ # @return [Boolean] true if in a browser-like environment
464
+ def browser_like_environment?
465
+ # Check for Opal (Ruby compiled to JavaScript running in browser)
466
+ return true if defined?(RUBY_ENGINE) && RUBY_ENGINE == "opal"
467
+
468
+ # Check for environment variable that indicates browser context
469
+ return true if ENV["FLAGKIT_BROWSER_CONTEXT"] == "true"
470
+
471
+ false
472
+ end
473
+ end
474
+ end
475
+
476
+ # Security configuration options.
477
+ #
478
+ # @example
479
+ # config = SecurityConfig.new
480
+ # config.warn_on_potential_pii = true
481
+ # config.additional_pii_patterns = ["employee_id"]
482
+ class SecurityConfig
483
+ # @return [Boolean] Whether to warn about potential PII in context/events.
484
+ # Default: true in development, false in production
485
+ attr_accessor :warn_on_potential_pii
486
+
487
+ # @return [Boolean] Whether to warn when server keys are used in browser.
488
+ # Default: true
489
+ attr_accessor :warn_on_server_key_in_browser
490
+
491
+ # @return [Array<String>] Custom PII patterns to detect in addition to defaults
492
+ attr_accessor :additional_pii_patterns
493
+
494
+ # Creates a new SecurityConfig with default values.
495
+ #
496
+ # @param warn_on_potential_pii [Boolean] Enable PII warnings
497
+ # @param warn_on_server_key_in_browser [Boolean] Enable server key warnings
498
+ # @param additional_pii_patterns [Array<String>] Additional PII patterns
499
+ def initialize(
500
+ warn_on_potential_pii: default_warn_on_pii,
501
+ warn_on_server_key_in_browser: true,
502
+ additional_pii_patterns: []
503
+ )
504
+ @warn_on_potential_pii = warn_on_potential_pii
505
+ @warn_on_server_key_in_browser = warn_on_server_key_in_browser
506
+ @additional_pii_patterns = additional_pii_patterns
507
+ end
508
+
509
+ # Returns a hash representation of the configuration.
510
+ #
511
+ # @return [Hash] The configuration as a hash
512
+ def to_h
513
+ {
514
+ warn_on_potential_pii: warn_on_potential_pii,
515
+ warn_on_server_key_in_browser: warn_on_server_key_in_browser,
516
+ additional_pii_patterns: additional_pii_patterns
517
+ }
518
+ end
519
+
520
+ private
521
+
522
+ def default_warn_on_pii
523
+ env = ENV.fetch("RUBY_ENV", ENV.fetch("RAILS_ENV", ENV.fetch("RACK_ENV", "development")))
524
+ env != "production"
525
+ end
526
+ end
527
+ end
528
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlagKit
4
+ module Utils
5
+ # Semantic version comparison utilities for SDK version metadata handling.
6
+ #
7
+ # These utilities are used to compare the current SDK version against
8
+ # server-provided version requirements (min, recommended, latest).
9
+ module Version
10
+ # Maximum allowed value for version components (defensive limit).
11
+ MAX_VERSION_COMPONENT = 999_999_999
12
+
13
+ class << self
14
+ # Parse a semantic version string into numeric components.
15
+ # Returns nil if the version is not a valid semver.
16
+ #
17
+ # @param version [String] The version string to parse
18
+ # @return [Hash, nil] Hash with :major, :minor, :patch keys, or nil if invalid
19
+ #
20
+ # @example
21
+ # Version.parse("1.2.3") # => { major: 1, minor: 2, patch: 3 }
22
+ # Version.parse("v1.2.3") # => { major: 1, minor: 2, patch: 3 }
23
+ # Version.parse("1.2.3-rc1") # => { major: 1, minor: 2, patch: 3 }
24
+ # Version.parse("invalid") # => nil
25
+ def parse(version)
26
+ return nil if version.nil? || !version.is_a?(String)
27
+
28
+ # Trim whitespace
29
+ trimmed = version.strip
30
+ return nil if trimmed.empty?
31
+
32
+ # Strip leading 'v' or 'V' if present
33
+ normalized = trimmed.start_with?("v", "V") ? trimmed[1..] : trimmed
34
+
35
+ # Match semver pattern (allows pre-release suffix but ignores it for comparison)
36
+ match = normalized.match(/^(\d+)\.(\d+)\.(\d+)/)
37
+ return nil unless match
38
+
39
+ major = match[1].to_i
40
+ minor = match[2].to_i
41
+ patch = match[3].to_i
42
+
43
+ # Validate components are within reasonable bounds
44
+ return nil if major.negative? || major > MAX_VERSION_COMPONENT
45
+ return nil if minor.negative? || minor > MAX_VERSION_COMPONENT
46
+ return nil if patch.negative? || patch > MAX_VERSION_COMPONENT
47
+
48
+ {
49
+ major: major,
50
+ minor: minor,
51
+ patch: patch
52
+ }
53
+ end
54
+
55
+ # Compare two semantic versions.
56
+ #
57
+ # @param version_a [String] First version
58
+ # @param version_b [String] Second version
59
+ # @return [Integer] Negative if a < b, 0 if a == b, positive if a > b.
60
+ # Returns 0 if either version is invalid.
61
+ #
62
+ # @example
63
+ # Version.compare("1.0.0", "2.0.0") # => -1
64
+ # Version.compare("2.0.0", "1.0.0") # => 1
65
+ # Version.compare("1.0.0", "1.0.0") # => 0
66
+ def compare(version_a, version_b)
67
+ parsed_a = parse(version_a)
68
+ parsed_b = parse(version_b)
69
+
70
+ return 0 if parsed_a.nil? || parsed_b.nil?
71
+
72
+ # Compare major
73
+ if parsed_a[:major] != parsed_b[:major]
74
+ return parsed_a[:major] - parsed_b[:major]
75
+ end
76
+
77
+ # Compare minor
78
+ if parsed_a[:minor] != parsed_b[:minor]
79
+ return parsed_a[:minor] - parsed_b[:minor]
80
+ end
81
+
82
+ # Compare patch
83
+ parsed_a[:patch] - parsed_b[:patch]
84
+ end
85
+
86
+ # Check if version a is less than version b.
87
+ #
88
+ # @param version_a [String] First version
89
+ # @param version_b [String] Second version
90
+ # @return [Boolean] true if a < b
91
+ #
92
+ # @example
93
+ # Version.less_than?("1.0.0", "2.0.0") # => true
94
+ # Version.less_than?("2.0.0", "1.0.0") # => false
95
+ # Version.less_than?("1.0.0", "1.0.0") # => false
96
+ def less_than?(version_a, version_b)
97
+ compare(version_a, version_b).negative?
98
+ end
99
+
100
+ # Check if version a is greater than or equal to version b.
101
+ #
102
+ # @param version_a [String] First version
103
+ # @param version_b [String] Second version
104
+ # @return [Boolean] true if a >= b
105
+ #
106
+ # @example
107
+ # Version.at_least?("2.0.0", "1.0.0") # => true
108
+ # Version.at_least?("1.0.0", "1.0.0") # => true
109
+ # Version.at_least?("1.0.0", "2.0.0") # => false
110
+ def at_least?(version_a, version_b)
111
+ compare(version_a, version_b) >= 0
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlagKit
4
+ VERSION = "1.0.1"
5
+ end