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,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlagKit
4
+ # Configuration options for the FlagKit SDK.
5
+ class Options
6
+ DEFAULT_POLLING_INTERVAL = 30
7
+ DEFAULT_CACHE_TTL = 300
8
+ DEFAULT_MAX_CACHE_SIZE = 1000
9
+ DEFAULT_EVENT_BATCH_SIZE = 10
10
+ DEFAULT_EVENT_FLUSH_INTERVAL = 30
11
+ DEFAULT_TIMEOUT = 10
12
+ DEFAULT_RETRY_ATTEMPTS = 3
13
+ DEFAULT_CIRCUIT_BREAKER_THRESHOLD = 5
14
+ DEFAULT_CIRCUIT_BREAKER_RESET_TIMEOUT = 30
15
+ DEFAULT_KEY_ROTATION_GRACE_PERIOD = 300
16
+ DEFAULT_MAX_PERSISTED_EVENTS = 10_000
17
+ DEFAULT_PERSISTENCE_FLUSH_INTERVAL = 1000
18
+ DEFAULT_EVALUATION_JITTER_ENABLED = false
19
+ DEFAULT_EVALUATION_JITTER_MIN_MS = 5
20
+ DEFAULT_EVALUATION_JITTER_MAX_MS = 15
21
+ DEFAULT_BOOTSTRAP_VERIFICATION_ENABLED = true
22
+ DEFAULT_BOOTSTRAP_VERIFICATION_MAX_AGE = 86_400_000 # 24 hours in milliseconds
23
+ DEFAULT_BOOTSTRAP_VERIFICATION_ON_FAILURE = "warn"
24
+ DEFAULT_ERROR_SANITIZATION_ENABLED = true
25
+ DEFAULT_ERROR_SANITIZATION_PRESERVE_ORIGINAL = false
26
+
27
+ attr_reader :api_key,
28
+ :polling_interval,
29
+ :cache_ttl,
30
+ :max_cache_size,
31
+ :cache_enabled,
32
+ :event_batch_size,
33
+ :event_flush_interval,
34
+ :events_enabled,
35
+ :timeout,
36
+ :retry_attempts,
37
+ :circuit_breaker_threshold,
38
+ :circuit_breaker_reset_timeout,
39
+ :bootstrap,
40
+ :logger,
41
+ :storage,
42
+ :local_port,
43
+ :secondary_api_key,
44
+ :key_rotation_grace_period,
45
+ :strict_pii_mode,
46
+ :enable_request_signing,
47
+ :encrypt_cache,
48
+ :persist_events,
49
+ :event_storage_path,
50
+ :max_persisted_events,
51
+ :persistence_flush_interval,
52
+ :evaluation_jitter_enabled,
53
+ :evaluation_jitter_min_ms,
54
+ :evaluation_jitter_max_ms,
55
+ :bootstrap_verification_enabled,
56
+ :bootstrap_verification_max_age,
57
+ :bootstrap_verification_on_failure,
58
+ :error_sanitization_enabled,
59
+ :error_sanitization_preserve_original,
60
+ :on_usage_update,
61
+ :on_subscription_error,
62
+ :on_connection_limit_error
63
+
64
+ # @param api_key [String] The API key
65
+ # @param polling_interval [Integer] Polling interval in seconds
66
+ # @param cache_ttl [Integer] Cache TTL in seconds
67
+ # @param max_cache_size [Integer] Maximum cache size
68
+ # @param cache_enabled [Boolean] Whether caching is enabled
69
+ # @param event_batch_size [Integer] Event batch size
70
+ # @param event_flush_interval [Integer] Event flush interval in seconds
71
+ # @param events_enabled [Boolean] Whether events are enabled
72
+ # @param timeout [Integer] Request timeout in seconds
73
+ # @param retry_attempts [Integer] Number of retry attempts
74
+ # @param circuit_breaker_threshold [Integer] Circuit breaker failure threshold
75
+ # @param circuit_breaker_reset_timeout [Integer] Circuit breaker reset timeout in seconds
76
+ # @param bootstrap [Hash, nil] Bootstrap data
77
+ # @param logger [Object, nil] Logger instance
78
+ # @param storage [Object, nil] Storage adapter
79
+ # @param local_port [Integer, nil] Local development server port (uses http://localhost:{port}/api/v1)
80
+ # @param secondary_api_key [String, nil] Secondary API key for key rotation
81
+ # @param key_rotation_grace_period [Integer] Grace period in seconds during key rotation
82
+ # @param strict_pii_mode [Boolean] Raise SecurityError instead of warning when PII detected
83
+ # @param enable_request_signing [Boolean] Enable HMAC-SHA256 request signing for POST requests
84
+ # @param encrypt_cache [Boolean] Enable AES-256-GCM encryption for cached data
85
+ # @param persist_events [Boolean] Enable crash-resilient event persistence
86
+ # @param event_storage_path [String, nil] Directory for event storage (defaults to OS temp dir)
87
+ # @param max_persisted_events [Integer] Maximum events to persist
88
+ # @param persistence_flush_interval [Integer] Milliseconds between disk writes
89
+ # @param evaluation_jitter_enabled [Boolean] Enable timing jitter for cache timing attack protection
90
+ # @param evaluation_jitter_min_ms [Integer] Minimum jitter delay in milliseconds
91
+ # @param evaluation_jitter_max_ms [Integer] Maximum jitter delay in milliseconds
92
+ # @param bootstrap_verification_enabled [Boolean] Enable HMAC-SHA256 signature verification for bootstrap data
93
+ # @param bootstrap_verification_max_age [Integer] Maximum age in milliseconds for bootstrap data
94
+ # @param bootstrap_verification_on_failure [String] Action on verification failure: 'warn', 'error', or 'ignore'
95
+ # @param error_sanitization_enabled [Boolean] Enable sanitization of sensitive info from error messages
96
+ # @param error_sanitization_preserve_original [Boolean] Preserve original message in addition to sanitized
97
+ # @param on_usage_update [Proc, nil] Callback for usage metrics updates (receives UsageMetrics)
98
+ # @param on_subscription_error [Proc, nil] Callback when subscription error occurs in streaming (receives message)
99
+ # @param on_connection_limit_error [Proc, nil] Callback when connection limit is reached in streaming
100
+ def initialize(
101
+ api_key:,
102
+ polling_interval: DEFAULT_POLLING_INTERVAL,
103
+ cache_ttl: DEFAULT_CACHE_TTL,
104
+ max_cache_size: DEFAULT_MAX_CACHE_SIZE,
105
+ cache_enabled: true,
106
+ event_batch_size: DEFAULT_EVENT_BATCH_SIZE,
107
+ event_flush_interval: DEFAULT_EVENT_FLUSH_INTERVAL,
108
+ events_enabled: true,
109
+ timeout: DEFAULT_TIMEOUT,
110
+ retry_attempts: DEFAULT_RETRY_ATTEMPTS,
111
+ circuit_breaker_threshold: DEFAULT_CIRCUIT_BREAKER_THRESHOLD,
112
+ circuit_breaker_reset_timeout: DEFAULT_CIRCUIT_BREAKER_RESET_TIMEOUT,
113
+ bootstrap: nil,
114
+ logger: nil,
115
+ storage: nil,
116
+ local_port: nil,
117
+ secondary_api_key: nil,
118
+ key_rotation_grace_period: DEFAULT_KEY_ROTATION_GRACE_PERIOD,
119
+ strict_pii_mode: false,
120
+ enable_request_signing: true,
121
+ encrypt_cache: false,
122
+ persist_events: false,
123
+ event_storage_path: nil,
124
+ max_persisted_events: DEFAULT_MAX_PERSISTED_EVENTS,
125
+ persistence_flush_interval: DEFAULT_PERSISTENCE_FLUSH_INTERVAL,
126
+ evaluation_jitter_enabled: DEFAULT_EVALUATION_JITTER_ENABLED,
127
+ evaluation_jitter_min_ms: DEFAULT_EVALUATION_JITTER_MIN_MS,
128
+ evaluation_jitter_max_ms: DEFAULT_EVALUATION_JITTER_MAX_MS,
129
+ bootstrap_verification_enabled: DEFAULT_BOOTSTRAP_VERIFICATION_ENABLED,
130
+ bootstrap_verification_max_age: DEFAULT_BOOTSTRAP_VERIFICATION_MAX_AGE,
131
+ bootstrap_verification_on_failure: DEFAULT_BOOTSTRAP_VERIFICATION_ON_FAILURE,
132
+ error_sanitization_enabled: DEFAULT_ERROR_SANITIZATION_ENABLED,
133
+ error_sanitization_preserve_original: DEFAULT_ERROR_SANITIZATION_PRESERVE_ORIGINAL,
134
+ on_usage_update: nil,
135
+ on_subscription_error: nil,
136
+ on_connection_limit_error: nil
137
+ )
138
+ @api_key = api_key
139
+ @polling_interval = polling_interval
140
+ @cache_ttl = cache_ttl
141
+ @max_cache_size = max_cache_size
142
+ @cache_enabled = cache_enabled
143
+ @event_batch_size = event_batch_size
144
+ @event_flush_interval = event_flush_interval
145
+ @events_enabled = events_enabled
146
+ @timeout = timeout
147
+ @retry_attempts = retry_attempts
148
+ @circuit_breaker_threshold = circuit_breaker_threshold
149
+ @circuit_breaker_reset_timeout = circuit_breaker_reset_timeout
150
+ @bootstrap = bootstrap
151
+ @logger = logger
152
+ @storage = storage
153
+ @local_port = local_port
154
+ @secondary_api_key = secondary_api_key
155
+ @key_rotation_grace_period = key_rotation_grace_period
156
+ @strict_pii_mode = strict_pii_mode
157
+ @enable_request_signing = enable_request_signing
158
+ @encrypt_cache = encrypt_cache
159
+ @persist_events = persist_events
160
+ @event_storage_path = event_storage_path || default_event_storage_path
161
+ @max_persisted_events = max_persisted_events
162
+ @persistence_flush_interval = persistence_flush_interval
163
+ @evaluation_jitter_enabled = evaluation_jitter_enabled
164
+ @evaluation_jitter_min_ms = evaluation_jitter_min_ms
165
+ @evaluation_jitter_max_ms = evaluation_jitter_max_ms
166
+ @bootstrap_verification_enabled = bootstrap_verification_enabled
167
+ @bootstrap_verification_max_age = bootstrap_verification_max_age
168
+ @bootstrap_verification_on_failure = bootstrap_verification_on_failure
169
+ @error_sanitization_enabled = error_sanitization_enabled
170
+ @error_sanitization_preserve_original = error_sanitization_preserve_original
171
+ @on_usage_update = on_usage_update
172
+ @on_subscription_error = on_subscription_error
173
+ @on_connection_limit_error = on_connection_limit_error
174
+ end
175
+
176
+ # Validates the options.
177
+ #
178
+ # @raise [Error] If validation fails
179
+ # @raise [SecurityError] If local_port is used in production
180
+ def validate!
181
+ validate_api_key!
182
+ validate_positive_integers!
183
+ validate_local_port_restriction!
184
+ end
185
+
186
+ private
187
+
188
+ def validate_local_port_restriction!
189
+ return unless local_port
190
+
191
+ env = ENV.fetch("RACK_ENV", ENV.fetch("RAILS_ENV", nil))
192
+ return unless env == "production"
193
+
194
+ raise SecurityError.new(
195
+ ErrorCode::SECURITY_LOCAL_PORT_IN_PRODUCTION,
196
+ "local_port cannot be used in production environment. " \
197
+ "This is a security risk as it bypasses HTTPS and may expose traffic to interception."
198
+ )
199
+ end
200
+
201
+ def validate_api_key!
202
+ raise Error.config_error(ErrorCode::CONFIG_INVALID_API_KEY, "API key is required") if api_key.nil? || api_key.empty?
203
+
204
+ unless api_key.start_with?("sdk_", "srv_", "cli_")
205
+ raise Error.config_error(ErrorCode::CONFIG_INVALID_API_KEY, "Invalid API key format")
206
+ end
207
+ end
208
+
209
+ def validate_positive_integers!
210
+ if polling_interval <= 0
211
+ raise Error.config_error(ErrorCode::CONFIG_INVALID_POLLING_INTERVAL, "Polling interval must be positive")
212
+ end
213
+
214
+ raise Error.config_error(ErrorCode::CONFIG_INVALID_CACHE_TTL, "Cache TTL must be positive") if cache_ttl <= 0
215
+ end
216
+
217
+ def default_event_storage_path
218
+ require "tmpdir"
219
+ File.join(Dir.tmpdir, "flagkit", "events")
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlagKit
4
+ module Types
5
+ # Context for flag evaluation containing user and custom attributes.
6
+ class EvaluationContext
7
+ PRIVATE_ATTRIBUTE_PREFIX = "_"
8
+
9
+ attr_reader :user_id, :attributes
10
+
11
+ # @param user_id [String, nil] The user identifier
12
+ # @param attributes [Hash] Custom attributes
13
+ def initialize(user_id: nil, **attributes)
14
+ @user_id = user_id
15
+ @attributes = attributes.transform_keys(&:to_s)
16
+ end
17
+
18
+ # Creates a new context with the given user ID.
19
+ #
20
+ # @param user_id [String] The user ID
21
+ # @return [EvaluationContext]
22
+ def with_user_id(user_id)
23
+ self.class.new(user_id: user_id, **attributes)
24
+ end
25
+
26
+ # Creates a new context with the given attribute added.
27
+ #
28
+ # @param key [String, Symbol] The attribute key
29
+ # @param value [Object] The attribute value
30
+ # @return [EvaluationContext]
31
+ def with_attribute(key, value)
32
+ new_attrs = attributes.merge(key.to_s => value)
33
+ self.class.new(user_id: user_id, **new_attrs)
34
+ end
35
+
36
+ # Creates a new context with multiple attributes added.
37
+ #
38
+ # @param attrs [Hash] The attributes to add
39
+ # @return [EvaluationContext]
40
+ def with_attributes(attrs)
41
+ new_attrs = attributes.merge(attrs.transform_keys(&:to_s))
42
+ self.class.new(user_id: user_id, **new_attrs)
43
+ end
44
+
45
+ # Merges another context into this one.
46
+ # The other context takes precedence.
47
+ #
48
+ # @param other [EvaluationContext, nil] The other context
49
+ # @return [EvaluationContext]
50
+ def merge(other)
51
+ return self unless other
52
+
53
+ new_user_id = other.user_id || user_id
54
+ new_attrs = attributes.merge(other.attributes)
55
+ self.class.new(user_id: new_user_id, **new_attrs)
56
+ end
57
+
58
+ # Creates a copy with private attributes stripped.
59
+ #
60
+ # @return [EvaluationContext]
61
+ def strip_private_attributes
62
+ public_attrs = attributes.reject { |key, _| key.start_with?(PRIVATE_ATTRIBUTE_PREFIX) }
63
+ self.class.new(user_id: user_id, **public_attrs)
64
+ end
65
+
66
+ # Checks if the context is empty.
67
+ #
68
+ # @return [Boolean]
69
+ def empty?
70
+ user_id.nil? && attributes.empty?
71
+ end
72
+
73
+ # Gets an attribute value.
74
+ #
75
+ # @param key [String, Symbol] The attribute key
76
+ # @return [Object, nil]
77
+ def [](key)
78
+ attributes[key.to_s]
79
+ end
80
+
81
+ # Converts the context to a hash for API requests.
82
+ #
83
+ # @return [Hash]
84
+ def to_h
85
+ result = {}
86
+ result["userId"] = user_id if user_id
87
+ result["attributes"] = attributes unless attributes.empty?
88
+ result
89
+ end
90
+
91
+ # Creates a context from a hash.
92
+ #
93
+ # @param data [Hash] The data hash
94
+ # @return [EvaluationContext]
95
+ def self.from_hash(data)
96
+ return new if data.nil?
97
+
98
+ user_id = data["userId"] || data["user_id"] || data[:user_id]
99
+ attrs = data["attributes"] || data[:attributes] || {}
100
+ new(user_id: user_id, **attrs)
101
+ end
102
+
103
+ def ==(other)
104
+ return false unless other.is_a?(EvaluationContext)
105
+
106
+ user_id == other.user_id && attributes == other.attributes
107
+ end
108
+
109
+ def eql?(other)
110
+ self == other
111
+ end
112
+
113
+ def hash
114
+ [user_id, attributes].hash
115
+ end
116
+ end
117
+ end
118
+
119
+ # Alias for backward compatibility
120
+ EvaluationContext = Types::EvaluationContext
121
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlagKit
4
+ module Types
5
+ # Reasons for flag evaluation results.
6
+ module EvaluationReason
7
+ CACHED = "CACHED"
8
+ DEFAULT = "DEFAULT"
9
+ FLAG_NOT_FOUND = "FLAG_NOT_FOUND"
10
+ BOOTSTRAP = "BOOTSTRAP"
11
+ SERVER = "SERVER"
12
+ STALE_CACHE = "STALE_CACHE"
13
+ ERROR = "ERROR"
14
+ DISABLED = "DISABLED"
15
+ TYPE_MISMATCH = "TYPE_MISMATCH"
16
+ OFFLINE = "OFFLINE"
17
+ end
18
+ end
19
+
20
+ # Alias for backward compatibility
21
+ EvaluationReason = Types::EvaluationReason
22
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlagKit
4
+ module Types
5
+ # Result of evaluating a feature flag.
6
+ class EvaluationResult
7
+ attr_reader :flag_key, :value, :enabled, :reason, :version, :timestamp
8
+
9
+ # @param flag_key [String] The flag key
10
+ # @param value [Object] The evaluated value
11
+ # @param enabled [Boolean] Whether the flag is enabled
12
+ # @param reason [String] The evaluation reason
13
+ # @param version [Integer] The flag version
14
+ # @param timestamp [Time] When the evaluation occurred
15
+ def initialize(flag_key:, value:, enabled: false, reason: EvaluationReason::DEFAULT, version: 0, timestamp: nil)
16
+ @flag_key = flag_key
17
+ @value = value
18
+ @enabled = enabled
19
+ @reason = reason
20
+ @version = version
21
+ @timestamp = timestamp || Time.now
22
+ end
23
+
24
+ # @return [Boolean] The value as a boolean
25
+ def boolean_value
26
+ value == true
27
+ end
28
+
29
+ # @return [String, nil] The value as a string
30
+ def string_value
31
+ value&.to_s
32
+ end
33
+
34
+ # @return [Float] The value as a float
35
+ def number_value
36
+ value.is_a?(Numeric) ? value.to_f : 0.0
37
+ end
38
+
39
+ # @return [Integer] The value as an integer
40
+ def int_value
41
+ value.is_a?(Numeric) ? value.to_i : 0
42
+ end
43
+
44
+ # @return [Hash, nil] The value as a hash
45
+ def json_value
46
+ value.is_a?(Hash) ? value : nil
47
+ end
48
+
49
+ # Creates a default result.
50
+ #
51
+ # @param key [String] The flag key
52
+ # @param default_value [Object] The default value
53
+ # @param reason [String] The reason
54
+ # @return [EvaluationResult]
55
+ def self.default_result(key, default_value, reason)
56
+ new(flag_key: key, value: default_value, enabled: false, reason: reason)
57
+ end
58
+
59
+ # Converts the result to a hash.
60
+ #
61
+ # @return [Hash]
62
+ def to_h
63
+ {
64
+ flag_key: flag_key,
65
+ value: value,
66
+ enabled: enabled,
67
+ reason: reason,
68
+ version: version,
69
+ timestamp: timestamp.iso8601
70
+ }
71
+ end
72
+ end
73
+ end
74
+
75
+ # Alias for backward compatibility
76
+ EvaluationResult = Types::EvaluationResult
77
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlagKit
4
+ module Types
5
+ # Represents the state of a feature flag.
6
+ class FlagState
7
+ attr_reader :key, :value, :enabled, :version, :flag_type, :last_modified, :metadata
8
+
9
+ # @param key [String] The flag key
10
+ # @param value [Object] The flag value
11
+ # @param enabled [Boolean] Whether the flag is enabled
12
+ # @param version [Integer] The flag version
13
+ # @param flag_type [String] The flag type
14
+ # @param last_modified [String, nil] ISO 8601 timestamp
15
+ # @param metadata [Hash, nil] Additional metadata
16
+ def initialize(key:, value:, enabled: true, version: 0, flag_type: nil, last_modified: nil, metadata: nil)
17
+ @key = key
18
+ @value = value
19
+ @enabled = enabled
20
+ @version = version
21
+ @flag_type = flag_type || FlagType.infer(value)
22
+ @last_modified = last_modified || Time.now.utc.iso8601
23
+ @metadata = metadata || {}
24
+ end
25
+
26
+ # @return [Boolean] The value as a boolean
27
+ def boolean_value
28
+ value == true
29
+ end
30
+
31
+ # @return [String, nil] The value as a string
32
+ def string_value
33
+ value&.to_s
34
+ end
35
+
36
+ # @return [Float] The value as a float
37
+ def number_value
38
+ value.is_a?(Numeric) ? value.to_f : 0.0
39
+ end
40
+
41
+ # @return [Integer] The value as an integer
42
+ def int_value
43
+ value.is_a?(Numeric) ? value.to_i : 0
44
+ end
45
+
46
+ # @return [Hash, nil] The value as a hash
47
+ def json_value
48
+ value.is_a?(Hash) ? value : nil
49
+ end
50
+
51
+ # Creates a FlagState from a hash.
52
+ #
53
+ # @param data [Hash] The data hash
54
+ # @return [FlagState]
55
+ def self.from_hash(data)
56
+ new(
57
+ key: data["key"] || data[:key],
58
+ value: data["value"] || data[:value],
59
+ enabled: data.fetch("enabled", data.fetch(:enabled, true)),
60
+ version: data["version"] || data[:version] || 0,
61
+ flag_type: data["flagType"] || data[:flag_type],
62
+ last_modified: data["lastModified"] || data[:last_modified],
63
+ metadata: data["metadata"] || data[:metadata]
64
+ )
65
+ end
66
+
67
+ # Converts the flag state to a hash.
68
+ #
69
+ # @return [Hash]
70
+ def to_h
71
+ {
72
+ key: key,
73
+ value: value,
74
+ enabled: enabled,
75
+ version: version,
76
+ flag_type: flag_type,
77
+ last_modified: last_modified,
78
+ metadata: metadata
79
+ }
80
+ end
81
+
82
+ def ==(other)
83
+ return false unless other.is_a?(FlagState)
84
+
85
+ key == other.key && value == other.value && enabled == other.enabled && version == other.version
86
+ end
87
+
88
+ def eql?(other)
89
+ self == other
90
+ end
91
+
92
+ def hash
93
+ [key, value, enabled, version].hash
94
+ end
95
+ end
96
+ end
97
+
98
+ # Alias for backward compatibility
99
+ FlagState = Types::FlagState
100
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlagKit
4
+ module Types
5
+ # Types of feature flags.
6
+ module FlagType
7
+ BOOLEAN = "boolean"
8
+ STRING = "string"
9
+ NUMBER = "number"
10
+ JSON = "json"
11
+
12
+ ALL = [BOOLEAN, STRING, NUMBER, JSON].freeze
13
+
14
+ # Infers the flag type from a value.
15
+ #
16
+ # @param value [Object] The value to infer type from
17
+ # @return [String] The inferred flag type
18
+ def self.infer(value)
19
+ case value
20
+ when TrueClass, FalseClass
21
+ BOOLEAN
22
+ when String
23
+ STRING
24
+ when Numeric
25
+ NUMBER
26
+ else
27
+ JSON
28
+ end
29
+ end
30
+
31
+ # Parses a flag type string.
32
+ #
33
+ # @param value [String] The type string
34
+ # @return [String] The normalized flag type
35
+ def self.parse(value)
36
+ return JSON unless value
37
+
38
+ normalized = value.to_s.downcase
39
+ ALL.include?(normalized) ? normalized : JSON
40
+ end
41
+ end
42
+ end
43
+
44
+ # Alias for backward compatibility
45
+ FlagType = Types::FlagType
46
+ end