otto 2.0.0.pre2 → 2.0.0.pre3

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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +0 -2
  3. data/.github/workflows/claude-code-review.yml +29 -13
  4. data/CLAUDE.md +537 -0
  5. data/Gemfile +2 -1
  6. data/Gemfile.lock +17 -10
  7. data/benchmark_middleware_wrap.rb +163 -0
  8. data/changelog.d/20251014_144317_delano_54_thats_a_wrapper.rst +36 -0
  9. data/changelog.d/20251014_161526_delano_54_thats_a_wrapper.rst +5 -0
  10. data/docs/.gitignore +1 -0
  11. data/docs/ipaddr-encoding-quirk.md +34 -0
  12. data/docs/migrating/v2.0.0-pre2.md +11 -18
  13. data/examples/authentication_strategies/config.ru +0 -1
  14. data/lib/otto/core/configuration.rb +89 -39
  15. data/lib/otto/core/freezable.rb +93 -0
  16. data/lib/otto/core/middleware_stack.rb +24 -17
  17. data/lib/otto/core/router.rb +1 -1
  18. data/lib/otto/core.rb +8 -0
  19. data/lib/otto/env_keys.rb +8 -4
  20. data/lib/otto/helpers/request.rb +80 -2
  21. data/lib/otto/helpers/response.rb +3 -3
  22. data/lib/otto/helpers.rb +4 -0
  23. data/lib/otto/locale/config.rb +56 -0
  24. data/lib/otto/mcp.rb +3 -0
  25. data/lib/otto/privacy/config.rb +199 -0
  26. data/lib/otto/privacy/geo_resolver.rb +115 -0
  27. data/lib/otto/privacy/ip_privacy.rb +175 -0
  28. data/lib/otto/privacy/redacted_fingerprint.rb +136 -0
  29. data/lib/otto/privacy.rb +29 -0
  30. data/lib/otto/route_handlers/base.rb +1 -2
  31. data/lib/otto/route_handlers/factory.rb +16 -14
  32. data/lib/otto/route_handlers/logic_class.rb +2 -2
  33. data/lib/otto/security/authentication/{failure_result.rb → auth_failure.rb} +3 -3
  34. data/lib/otto/security/authentication/auth_strategy.rb +3 -3
  35. data/lib/otto/security/authentication/route_auth_wrapper.rb +137 -26
  36. data/lib/otto/security/authentication/strategies/noauth_strategy.rb +5 -1
  37. data/lib/otto/security/authentication.rb +3 -4
  38. data/lib/otto/security/config.rb +51 -7
  39. data/lib/otto/security/configurator.rb +0 -13
  40. data/lib/otto/security/middleware/ip_privacy_middleware.rb +211 -0
  41. data/lib/otto/security.rb +9 -0
  42. data/lib/otto/version.rb +1 -1
  43. data/lib/otto.rb +181 -86
  44. data/otto.gemspec +3 -0
  45. metadata +58 -3
  46. data/lib/otto/security/authentication/authentication_middleware.rb +0 -140
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/otto/core/freezable.rb
4
+
5
+ require 'set'
6
+
7
+ class Otto
8
+ module Core
9
+ # Provides deep freezing capability for configuration objects
10
+ #
11
+ # This module enables objects to be deeply frozen, preventing any
12
+ # modifications to the object itself and all its nested structures.
13
+ # This is critical for security as it prevents runtime tampering with
14
+ # security configurations.
15
+ #
16
+ # @example
17
+ # class MyConfig
18
+ # include Otto::Core::Freezable
19
+ #
20
+ # def initialize
21
+ # @settings = { security: { enabled: true } }
22
+ # end
23
+ # end
24
+ #
25
+ # config = MyConfig.new
26
+ # config.deep_freeze!
27
+ # # Now config and all nested hashes/arrays are frozen
28
+ #
29
+ module Freezable
30
+ # Deeply freeze this object and all its instance variables
31
+ #
32
+ # This method recursively freezes all nested structures including:
33
+ # - Hashes (both keys and values)
34
+ # - Arrays (and all elements)
35
+ # - Sets
36
+ # - Other freezable objects
37
+ #
38
+ # NOTE: This method is idempotent and safe to call multiple times.
39
+ #
40
+ # @return [self] The frozen object
41
+ def deep_freeze!
42
+ return self if frozen?
43
+
44
+ freeze_instance_variables!
45
+ freeze
46
+ self
47
+ end
48
+
49
+ private
50
+
51
+ # Freeze all instance variables recursively
52
+ def freeze_instance_variables!
53
+ instance_variables.each do |var|
54
+ value = instance_variable_get(var)
55
+ deep_freeze_value(value)
56
+ end
57
+ end
58
+
59
+ # Recursively freeze a value based on its type
60
+ #
61
+ # @param value [Object] Value to freeze
62
+ # @return [void]
63
+ def deep_freeze_value(value)
64
+ case value
65
+ when Hash
66
+ # Freeze hash keys and values, then freeze the hash itself
67
+ value.each do |k, v|
68
+ k.freeze unless k.frozen?
69
+ deep_freeze_value(v)
70
+ end
71
+ value.freeze
72
+ when Array
73
+ # Freeze all array elements, then freeze the array
74
+ value.each { |item| deep_freeze_value(item) }
75
+ value.freeze
76
+ when Set
77
+ # Sets are immutable once frozen
78
+ value.freeze
79
+ when String, Symbol, Numeric, TrueClass, FalseClass, NilClass
80
+ # These types are either immutable or already frozen
81
+ value.freeze if value.respond_to?(:freeze) && !value.frozen?
82
+ else
83
+ # For other objects, recursively freeze if they support it, otherwise shallow freeze.
84
+ if value.respond_to?(:deep_freeze!)
85
+ value.deep_freeze!
86
+ elsif value.respond_to?(:freeze) && !value.frozen?
87
+ value.freeze
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -2,6 +2,8 @@
2
2
 
3
3
  # lib/otto/core/middleware_stack.rb
4
4
 
5
+ require_relative 'freezable'
6
+
5
7
  class Otto
6
8
  module Core
7
9
  # Enhanced middleware stack management for Otto framework.
@@ -9,10 +11,18 @@ class Otto
9
11
  # and improved execution chain management.
10
12
  class MiddlewareStack
11
13
  include Enumerable
14
+ include Otto::Core::Freezable
12
15
 
13
16
  def initialize
14
17
  @stack = []
15
18
  @middleware_set = Set.new
19
+ @on_change_callback = nil
20
+ end
21
+
22
+ # Set a callback to be invoked when the middleware stack changes
23
+ # @param callback [Proc] A callable object (e.g., method or lambda)
24
+ def on_change(&callback)
25
+ @on_change_callback = callback
16
26
  end
17
27
 
18
28
  # Enhanced middleware registration with argument uniqueness and immutability check
@@ -33,8 +43,8 @@ class Otto
33
43
  entry = { middleware: middleware_class, args: args, options: options }
34
44
  @stack << entry
35
45
  @middleware_set.add(middleware_class)
36
- # Invalidate memoized middleware list
37
- @memoized_middleware_list = nil
46
+ # Notify of change
47
+ @on_change_callback&.call
38
48
  end
39
49
 
40
50
  # Add middleware with position hint for optimal ordering
@@ -67,7 +77,8 @@ class Otto
67
77
  end
68
78
 
69
79
  @middleware_set.add(middleware_class)
70
- @memoized_middleware_list = nil
80
+ # Notify of change
81
+ @on_change_callback&.call
71
82
  end
72
83
 
73
84
  # Validate MCP middleware ordering
@@ -131,8 +142,8 @@ class Otto
131
142
 
132
143
  # Rebuild the set of unique middleware classes
133
144
  @middleware_set = Set.new(@stack.map { |entry| entry[:middleware] })
134
- # Invalidate memoized middleware list
135
- @memoized_middleware_list = nil
145
+ # Notify of change
146
+ @on_change_callback&.call
136
147
  end
137
148
 
138
149
  # Check if middleware is registered - now O(1) using Set
@@ -147,8 +158,8 @@ class Otto
147
158
 
148
159
  @stack.clear
149
160
  @middleware_set.clear
150
- # Invalidate memoized middleware list
151
- @memoized_middleware_list = nil
161
+ # Notify of change
162
+ @on_change_callback&.call
152
163
  end
153
164
 
154
165
  # Enumerable support
@@ -157,7 +168,7 @@ class Otto
157
168
  end
158
169
 
159
170
  # Build Rack application with middleware chain
160
- def build_app(base_app, security_config = nil)
171
+ def wrap(base_app, security_config = nil)
161
172
  @stack.reduce(base_app) do |app, entry|
162
173
  middleware = entry[:middleware]
163
174
  args = entry[:args]
@@ -177,10 +188,9 @@ class Otto
177
188
  end
178
189
  end
179
190
 
180
- # Cached middleware list to reduce array creation
191
+ # Returns list of middleware classes in order
181
192
  def middleware_list
182
- # Memoize the result to avoid repeated array creation
183
- @memoized_middleware_list ||= @stack.map { |entry| entry[:middleware] }
193
+ @stack.map { |entry| entry[:middleware] }
184
194
  end
185
195
 
186
196
  # Detailed introspection
@@ -215,6 +225,8 @@ class Otto
215
225
  @stack.reverse_each(&)
216
226
  end
217
227
 
228
+
229
+
218
230
  private
219
231
 
220
232
  def middleware_needs_config?(middleware_class)
@@ -224,12 +236,7 @@ class Otto
224
236
  Otto::Security::Middleware::CSRFMiddleware,
225
237
  Otto::Security::Middleware::ValidationMiddleware,
226
238
  Otto::Security::Middleware::RateLimitMiddleware,
227
- Otto::Security::Authentication::AuthenticationMiddleware,
228
- # Backward compatibility aliases
229
- Otto::Security::CSRFMiddleware,
230
- Otto::Security::ValidationMiddleware,
231
- Otto::Security::RateLimitMiddleware,
232
- Otto::Security::AuthenticationMiddleware,
239
+ Otto::Security::Middleware::IPPrivacyMiddleware,
233
240
  ].include?(middleware_class)
234
241
  end
235
242
  end
@@ -51,7 +51,7 @@ class Otto
51
51
  def handle_request(env)
52
52
  locale = determine_locale env
53
53
  env['rack.locale'] = locale
54
- env['otto.locale_config'] = @locale_config if @locale_config
54
+ env['otto.locale_config'] = @locale_config.to_h if @locale_config
55
55
  @static_route ||= Rack::Files.new(option[:public]) if option[:public] && safe_dir?(option[:public])
56
56
  path_info = Rack::Utils.unescape(env['PATH_INFO'])
57
57
  path_info = '/' if path_info.to_s.empty?
data/lib/otto/core.rb ADDED
@@ -0,0 +1,8 @@
1
+ # lib/otto/core.rb
2
+
3
+ require_relative 'core/router'
4
+ require_relative 'core/file_safety'
5
+ require_relative 'core/configuration'
6
+ require_relative 'core/error_handler'
7
+ require_relative 'core/uri_generator'
8
+ require_relative 'core/middleware_stack'
data/lib/otto/env_keys.rb CHANGED
@@ -36,21 +36,25 @@ class Otto
36
36
 
37
37
  # Authentication strategy result containing session/user state
38
38
  # Type: Otto::Security::Authentication::StrategyResult
39
- # Set by: AuthenticationMiddleware
39
+ # Set by: RouteAuthWrapper (wraps all route handlers)
40
40
  # Used by: RouteHandlers, LogicClasses, Controllers
41
- # Note: Always present (anonymous or authenticated)
41
+ # Guarantee: ALWAYS present - either authenticated or anonymous
42
+ # - Routes WITH auth requirement: Authenticated StrategyResult or 401/302
43
+ # - Routes WITHOUT auth requirement: Anonymous StrategyResult
42
44
  STRATEGY_RESULT = 'otto.strategy_result'
43
45
 
44
46
  # Authenticated user object (convenience accessor)
45
47
  # Type: Hash, Custom User Object, or nil
46
- # Set by: AuthenticationMiddleware (from strategy_result.user)
48
+ # Set by: RouteAuthWrapper (from strategy_result.user)
47
49
  # Used by: Controllers, RouteHandlers
50
+ # Note: nil for anonymous/unauthenticated requests
48
51
  USER = 'otto.user'
49
52
 
50
53
  # User-specific context (session, roles, permissions, etc.)
51
54
  # Type: Hash
52
- # Set by: AuthenticationMiddleware (from strategy_result.user_context)
55
+ # Set by: RouteAuthWrapper (from strategy_result.user_context)
53
56
  # Used by: Controllers, Analytics
57
+ # Note: Empty hash {} for anonymous requests
54
58
  USER_CONTEXT = 'otto.user_context'
55
59
 
56
60
  # =========================================================================
@@ -1,5 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/helpers/request.rb
4
2
 
5
3
  require_relative 'base'
@@ -13,6 +11,86 @@ class Otto
13
11
  env['HTTP_USER_AGENT']
14
12
  end
15
13
 
14
+ # NOTE: We do NOT override Rack::Request#ip
15
+ #
16
+ # IPPrivacyMiddleware masks both REMOTE_ADDR and X-Forwarded-For headers,
17
+ # so Rack's native ip resolution logic works correctly with masked values.
18
+ # This allows Rack to handle proxy scenarios (trusted proxies, header parsing)
19
+ # while still returning privacy-safe masked IPs.
20
+ #
21
+ # If you need the masked IP explicitly, use:
22
+ # req.masked_ip # => '192.168.1.0' or nil if privacy disabled
23
+ #
24
+ # If you need the geo country:
25
+ # req.geo_country # => 'US' or nil
26
+ #
27
+ # If you need the full privacy fingerprint:
28
+ # req.redacted_fingerprint # => RedactedFingerprint object or nil
29
+
30
+ # Get the privacy-safe fingerprint for this request
31
+ #
32
+ # Returns nil if IP privacy is disabled. The fingerprint contains
33
+ # anonymized request information suitable for logging and analytics.
34
+ #
35
+ # @return [Otto::Privacy::RedactedFingerprint, nil] Privacy-safe fingerprint
36
+ # @example
37
+ # fingerprint = req.redacted_fingerprint
38
+ # fingerprint.masked_ip # => '192.168.1.0'
39
+ # fingerprint.country # => 'US'
40
+ def redacted_fingerprint
41
+ env['otto.redacted_fingerprint']
42
+ end
43
+
44
+ # Get the geo-location country code for the request
45
+ #
46
+ # Returns ISO 3166-1 alpha-2 country code or 'XX' for unknown.
47
+ # Only available when IP privacy is enabled (default).
48
+ #
49
+ # @return [String, nil] Country code or nil if privacy disabled
50
+ # @example
51
+ # req.geo_country # => 'US'
52
+ def geo_country
53
+ redacted_fingerprint&.country || env['otto.geo_country']
54
+ end
55
+
56
+ # Get anonymized user agent string
57
+ #
58
+ # Returns user agent with version numbers stripped for privacy.
59
+ # Only available when IP privacy is enabled (default).
60
+ #
61
+ # @return [String, nil] Anonymized user agent or nil
62
+ # @example
63
+ # req.anonymized_user_agent
64
+ # # => 'Mozilla/X.X (Windows NT X.X; Win64; x64) AppleWebKit/X.X'
65
+ def anonymized_user_agent
66
+ redacted_fingerprint&.anonymized_ua
67
+ end
68
+
69
+ # Get masked IP address
70
+ #
71
+ # Returns privacy-safe masked IP. When privacy is enabled (default),
72
+ # this returns the masked version. When disabled, returns original IP.
73
+ #
74
+ # @return [String, nil] Masked or original IP address
75
+ # @example
76
+ # req.masked_ip # => '192.168.1.0'
77
+ def masked_ip
78
+ env['otto.masked_ip'] || env['REMOTE_ADDR']
79
+ end
80
+
81
+ # Get hashed IP for session correlation
82
+ #
83
+ # Returns daily-rotating hash of the IP address, allowing session
84
+ # tracking without storing the original IP. Only available when
85
+ # IP privacy is enabled (default).
86
+ #
87
+ # @return [String, nil] Hexadecimal hash string or nil
88
+ # @example
89
+ # req.hashed_ip # => 'a3f8b2c4d5e6f7...'
90
+ def hashed_ip
91
+ redacted_fingerprint&.hashed_ip || env['otto.hashed_ip']
92
+ end
93
+
16
94
  def client_ipaddress
17
95
  remote_addr = env['REMOTE_ADDR']
18
96
 
@@ -14,10 +14,10 @@ class Otto
14
14
  def send_secure_cookie(name, value, ttl, opts = {})
15
15
  # Default security options
16
16
  defaults = {
17
- secure: true,
18
- httponly: true,
17
+ secure: true,
18
+ httponly: true,
19
19
  same_site: :strict,
20
- path: '/',
20
+ path: '/',
21
21
  }
22
22
 
23
23
  # Merge with provided options
@@ -0,0 +1,4 @@
1
+ # lib/otto/helpers.rb
2
+
3
+ require_relative 'helpers/request'
4
+ require_relative 'helpers/response'
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/otto/locale/config.rb
4
+
5
+ require_relative '../core/freezable'
6
+
7
+ class Otto
8
+ module Locale
9
+ # Locale configuration for Otto applications
10
+ #
11
+ # This class manages locale-related settings including available locales
12
+ # and default locale selection.
13
+ #
14
+ # @example Basic usage
15
+ # config = Otto::Locale::Config.new
16
+ # config.available_locales = { 'en' => 'English', 'es' => 'Spanish' }
17
+ # config.default_locale = 'en'
18
+ #
19
+ # @example With initialization
20
+ # config = Otto::Locale::Config.new(
21
+ # available_locales: { 'en' => 'English', 'fr' => 'French' },
22
+ # default_locale: 'en'
23
+ # )
24
+ class Config
25
+ include Otto::Core::Freezable
26
+
27
+ attr_accessor :available_locales, :default_locale
28
+
29
+ # Initialize locale configuration
30
+ #
31
+ # @param available_locales [Hash, nil] Hash of locale codes to names
32
+ # @param default_locale [String, nil] Default locale code
33
+ def initialize(available_locales: nil, default_locale: nil)
34
+ @available_locales = available_locales
35
+ @default_locale = default_locale
36
+ end
37
+
38
+ # Convert to hash for compatibility with existing code
39
+ #
40
+ # @return [Hash] Hash representation of configuration
41
+ def to_h
42
+ {
43
+ available_locales: @available_locales,
44
+ default_locale: @default_locale,
45
+ }.compact
46
+ end
47
+
48
+ # Check if locale configuration is present
49
+ #
50
+ # @return [Boolean] true if either available_locales or default_locale is set
51
+ def configured?
52
+ !@available_locales.nil? || !@default_locale.nil?
53
+ end
54
+ end
55
+ end
56
+ end
data/lib/otto/mcp.rb ADDED
@@ -0,0 +1,3 @@
1
+ # lib/otto/mcp.rb
2
+
3
+ require_relative 'mcp/server'
@@ -0,0 +1,199 @@
1
+ # lib/otto/privacy/config.rb
2
+
3
+ require 'ipaddr'
4
+ require 'securerandom'
5
+ require 'digest'
6
+
7
+ require 'concurrent'
8
+
9
+ require_relative '../core/freezable'
10
+
11
+ class Otto
12
+ module Privacy
13
+ # Configuration for IP privacy features
14
+ #
15
+ # Privacy is ENABLED by default for public IPs. Private/localhost IPs are not masked.
16
+ #
17
+ # @example Default configuration (privacy enabled)
18
+ # config = Otto::Privacy::Config.new
19
+ # config.enabled? # => true
20
+ #
21
+ # @example Configure masking level
22
+ # config = Otto::Privacy::Config.new
23
+ # config.octet_precision = 2 # Mask 2 octets instead of 1
24
+ #
25
+ class Config
26
+ include Otto::Core::Freezable
27
+
28
+ attr_accessor :octet_precision, :hash_rotation_period, :geo_enabled, :mask_private_ips
29
+ attr_reader :disabled
30
+
31
+ # Class-level rotation key storage (mutable, not frozen with instances)
32
+ # This is stored at the class level so it persists across frozen config instances
33
+ @rotation_keys_store = nil
34
+
35
+ class << self
36
+ # Get the class-level rotation keys store
37
+ # @return [Concurrent::Map] Thread-safe map for rotation keys
38
+ def rotation_keys_store
39
+ @rotation_keys_store = Concurrent::Map.new unless defined?(@rotation_keys_store) && @rotation_keys_store
40
+ @rotation_keys_store
41
+ end
42
+ end
43
+
44
+ # Initialize privacy configuration
45
+ #
46
+ # @param options [Hash] Configuration options
47
+ # @option options [Integer] :octet_precision Number of trailing octets to mask (1 or 2, default: 1)
48
+ # @option options [Integer] :hash_rotation_period Seconds between key rotation (default: 86400)
49
+ # @option options [Boolean] :geo_enabled Enable geo-location resolution (default: true)
50
+ # @option options [Boolean] :disabled Disable privacy entirely (default: false)
51
+ # @option options [Boolean] :mask_private_ips Mask private/localhost IPs (default: false)
52
+ # @option options [Redis] :redis Optional Redis connection for multi-server environments
53
+ def initialize(options = {})
54
+ @octet_precision = options.fetch(:octet_precision, 1)
55
+ @hash_rotation_period = options.fetch(:hash_rotation_period, 86_400) # 24 hours
56
+ @geo_enabled = options.fetch(:geo_enabled, true)
57
+ @disabled = options.fetch(:disabled, false) # Enabled by default (privacy-by-default)
58
+ @mask_private_ips = options.fetch(:mask_private_ips, false) # Don't mask private/localhost by default
59
+ @redis = options[:redis] # Optional Redis connection for multi-server environments
60
+ end
61
+
62
+ # Check if privacy is enabled
63
+ #
64
+ # @return [Boolean] true if privacy is enabled (default)
65
+ def enabled?
66
+ !@disabled
67
+ end
68
+
69
+ # Check if privacy is disabled
70
+ #
71
+ # @return [Boolean] true if privacy was explicitly disabled
72
+ def disabled?
73
+ @disabled
74
+ end
75
+
76
+ # Disable privacy (allows access to original IPs)
77
+ #
78
+ # IMPORTANT: This should only be used when you have a specific
79
+ # requirement to access original IP addresses. By default, Otto
80
+ # provides privacy-safe masked IPs.
81
+ #
82
+ # @return [self]
83
+ def disable!
84
+ @disabled = true
85
+ self
86
+ end
87
+
88
+ # Enable privacy (default state)
89
+ #
90
+ # @return [self]
91
+ def enable!
92
+ @disabled = false
93
+ self
94
+ end
95
+
96
+ # Get the current rotation key for IP hashing
97
+ #
98
+ # Keys rotate at fixed intervals based on hash_rotation_period (default: 24 hours).
99
+ # Each rotation period gets a unique key, ensuring IP addresses hash differently
100
+ # across periods while remaining consistent within.
101
+ #
102
+ # Multi-server support:
103
+ # - With Redis: Uses SET NX GET EX for atomic key generation across all servers
104
+ # - Without Redis: Falls back to in-memory Concurrent::Hash (single-server only)
105
+ #
106
+ # Redis keys:
107
+ # - rotation_key:{timestamp} - Stores the rotation key with TTL
108
+ #
109
+ # @return [String] Current rotation key for hashing
110
+ def rotation_key
111
+ if @redis
112
+ rotation_key_redis
113
+ else
114
+ rotation_key_memory
115
+ end
116
+ end
117
+
118
+ # Validate configuration settings
119
+ #
120
+ # @raise [ArgumentError] if configuration is invalid
121
+ def validate!
122
+ raise ArgumentError, "octet_precision must be 1 or 2, got: #{@octet_precision}" unless [1,
123
+ 2].include?(@octet_precision)
124
+
125
+ return unless @hash_rotation_period < 60
126
+
127
+ raise ArgumentError, 'hash_rotation_period must be at least 60 seconds'
128
+ end
129
+
130
+ private
131
+
132
+ # Redis-based rotation key (atomic across multiple servers)
133
+ #
134
+ # Uses SET NX GET EX to atomically:
135
+ # 1. Check if key exists
136
+ # 2. Set new key only if missing
137
+ # 3. Return existing or newly set key
138
+ # 4. Auto-expire with TTL
139
+ #
140
+ # @return [String] Current rotation key
141
+ # @api private
142
+ def rotation_key_redis
143
+ now_seconds = Time.now.utc.to_i
144
+
145
+ # Quantize to rotation period boundary
146
+ rotation_timestamp = (now_seconds / @hash_rotation_period) * @hash_rotation_period
147
+
148
+ redis_key = "rotation_key:#{rotation_timestamp}"
149
+ ttl = (@hash_rotation_period * 1.2).to_i # Auto-cleanup with 20% buffer
150
+
151
+ key = SecureRandom.hex(32)
152
+
153
+ # SET NX GET returns old value if key exists, nil if we set it
154
+ # @see https://valkey.io/commands/set/
155
+ existing_key = @redis.set(redis_key, key, nx: true, get: true, ex: ttl)
156
+
157
+ existing_key || key
158
+ end
159
+
160
+ # In-memory rotation key (single-server fallback)
161
+ #
162
+ # Uses class-level Concurrent::Hash for thread-safety within a single process.
163
+ # NOT atomic across multiple servers.
164
+ #
165
+ # The rotation keys are stored at the class level so they remain mutable
166
+ # even when config instances are frozen.
167
+ #
168
+ # @return [String] Current rotation key
169
+ # @api private
170
+ def rotation_key_memory
171
+ rotation_keys = self.class.rotation_keys_store
172
+
173
+ now_seconds = Time.now.utc.to_i
174
+
175
+ # Quantize to rotation period boundary (e.g., midnight UTC for 24-hour period)
176
+ seconds_since_epoch = now_seconds % @hash_rotation_period
177
+ rotation_timestamp = now_seconds - seconds_since_epoch
178
+
179
+ # Atomically get or create key for this rotation period
180
+ # Use compute_if_absent for thread-safe atomic operation
181
+ key = rotation_keys.compute_if_absent(rotation_timestamp) do
182
+ # Generate new key atomically
183
+ # IMPORTANT: Don't modify the map inside this block to avoid deadlock
184
+ SecureRandom.hex(32)
185
+ end
186
+
187
+ # Clean up old keys after atomic operation completes
188
+ # This runs outside compute_if_absent to avoid deadlock
189
+ if rotation_keys.size > 1
190
+ rotation_keys.each_key do |ts|
191
+ rotation_keys.delete(ts) if ts != rotation_timestamp
192
+ end
193
+ end
194
+
195
+ key
196
+ end
197
+ end
198
+ end
199
+ end