otto 2.0.0.pre2 → 2.0.0.pre7

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 (105) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +1 -3
  3. data/.github/workflows/claude-code-review.yml +29 -13
  4. data/.github/workflows/code-smells.yml +146 -0
  5. data/.gitignore +4 -0
  6. data/.pre-commit-config.yaml +2 -2
  7. data/.reek.yml +99 -0
  8. data/CHANGELOG.rst +90 -0
  9. data/CLAUDE.md +116 -45
  10. data/Gemfile +5 -2
  11. data/Gemfile.lock +70 -24
  12. data/README.md +49 -1
  13. data/changelog.d/20251103_235431_delano_86_improve_error_logging.rst +15 -0
  14. data/changelog.d/20251109_025012_claude_fix_backtrace_sanitization.rst +37 -0
  15. data/docs/.gitignore +1 -0
  16. data/docs/ipaddr-encoding-quirk.md +34 -0
  17. data/docs/migrating/v2.0.0-pre2.md +11 -18
  18. data/examples/advanced_routes/README.md +137 -20
  19. data/examples/authentication_strategies/README.md +212 -19
  20. data/examples/authentication_strategies/config.ru +0 -1
  21. data/examples/backtrace_sanitization_demo.rb +86 -0
  22. data/examples/basic/README.md +61 -10
  23. data/examples/error_handler_registration.rb +136 -0
  24. data/examples/logging_improvements.rb +76 -0
  25. data/examples/mcp_demo/README.md +187 -27
  26. data/examples/security_features/README.md +249 -30
  27. data/examples/simple_geo_resolver.rb +107 -0
  28. data/lib/otto/core/configuration.rb +90 -45
  29. data/lib/otto/core/error_handler.rb +138 -8
  30. data/lib/otto/core/file_safety.rb +2 -2
  31. data/lib/otto/core/freezable.rb +93 -0
  32. data/lib/otto/core/middleware_stack.rb +25 -18
  33. data/lib/otto/core/router.rb +62 -9
  34. data/lib/otto/core/uri_generator.rb +2 -2
  35. data/lib/otto/core.rb +10 -0
  36. data/lib/otto/design_system.rb +2 -2
  37. data/lib/otto/env_keys.rb +65 -12
  38. data/lib/otto/helpers/base.rb +2 -2
  39. data/lib/otto/helpers/request.rb +85 -2
  40. data/lib/otto/helpers/response.rb +5 -5
  41. data/lib/otto/helpers/validation.rb +2 -2
  42. data/lib/otto/helpers.rb +6 -0
  43. data/lib/otto/locale/config.rb +56 -0
  44. data/lib/otto/locale/middleware.rb +160 -0
  45. data/lib/otto/locale.rb +10 -0
  46. data/lib/otto/logging_helpers.rb +273 -0
  47. data/lib/otto/mcp/auth/token.rb +2 -2
  48. data/lib/otto/mcp/protocol.rb +2 -2
  49. data/lib/otto/mcp/rate_limiting.rb +2 -2
  50. data/lib/otto/mcp/registry.rb +2 -2
  51. data/lib/otto/mcp/route_parser.rb +2 -2
  52. data/lib/otto/mcp/schema_validation.rb +2 -2
  53. data/lib/otto/mcp/server.rb +2 -2
  54. data/lib/otto/mcp.rb +5 -0
  55. data/lib/otto/privacy/config.rb +201 -0
  56. data/lib/otto/privacy/geo_resolver.rb +285 -0
  57. data/lib/otto/privacy/ip_privacy.rb +177 -0
  58. data/lib/otto/privacy/redacted_fingerprint.rb +146 -0
  59. data/lib/otto/privacy.rb +31 -0
  60. data/lib/otto/response_handlers/auto.rb +2 -0
  61. data/lib/otto/response_handlers/base.rb +2 -0
  62. data/lib/otto/response_handlers/default.rb +2 -0
  63. data/lib/otto/response_handlers/factory.rb +2 -0
  64. data/lib/otto/response_handlers/json.rb +2 -0
  65. data/lib/otto/response_handlers/redirect.rb +2 -0
  66. data/lib/otto/response_handlers/view.rb +2 -0
  67. data/lib/otto/response_handlers.rb +2 -2
  68. data/lib/otto/route.rb +4 -4
  69. data/lib/otto/route_definition.rb +42 -15
  70. data/lib/otto/route_handlers/base.rb +2 -1
  71. data/lib/otto/route_handlers/class_method.rb +18 -25
  72. data/lib/otto/route_handlers/factory.rb +18 -16
  73. data/lib/otto/route_handlers/instance_method.rb +8 -5
  74. data/lib/otto/route_handlers/lambda.rb +8 -20
  75. data/lib/otto/route_handlers/logic_class.rb +25 -8
  76. data/lib/otto/route_handlers.rb +2 -2
  77. data/lib/otto/security/authentication/{failure_result.rb → auth_failure.rb} +5 -5
  78. data/lib/otto/security/authentication/auth_strategy.rb +13 -6
  79. data/lib/otto/security/authentication/route_auth_wrapper.rb +304 -41
  80. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +2 -0
  81. data/lib/otto/security/authentication/strategies/noauth_strategy.rb +7 -1
  82. data/lib/otto/security/authentication/strategies/permission_strategy.rb +2 -0
  83. data/lib/otto/security/authentication/strategies/role_strategy.rb +2 -0
  84. data/lib/otto/security/authentication/strategies/session_strategy.rb +2 -0
  85. data/lib/otto/security/authentication/strategy_result.rb +6 -5
  86. data/lib/otto/security/authentication.rb +5 -6
  87. data/lib/otto/security/authorization_error.rb +73 -0
  88. data/lib/otto/security/config.rb +53 -9
  89. data/lib/otto/security/configurator.rb +17 -15
  90. data/lib/otto/security/csrf.rb +2 -2
  91. data/lib/otto/security/middleware/csrf_middleware.rb +11 -1
  92. data/lib/otto/security/middleware/ip_privacy_middleware.rb +231 -0
  93. data/lib/otto/security/middleware/rate_limit_middleware.rb +2 -0
  94. data/lib/otto/security/middleware/validation_middleware.rb +15 -0
  95. data/lib/otto/security/rate_limiter.rb +2 -2
  96. data/lib/otto/security/rate_limiting.rb +2 -2
  97. data/lib/otto/security/validator.rb +2 -2
  98. data/lib/otto/security.rb +12 -0
  99. data/lib/otto/static.rb +2 -2
  100. data/lib/otto/utils.rb +27 -2
  101. data/lib/otto/version.rb +3 -3
  102. data/lib/otto.rb +344 -89
  103. data/otto.gemspec +9 -2
  104. metadata +72 -8
  105. data/lib/otto/security/authentication/authentication_middleware.rb +0 -140
@@ -0,0 +1,273 @@
1
+ # lib/otto/logging_helpers.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ class Otto
6
+ # LoggingHelpers provides utility methods for consistent structured logging
7
+ # across the Otto framework. Centralizes common request context extraction
8
+ # to eliminate duplication while keeping logging calls simple and explicit.
9
+ module LoggingHelpers
10
+ # Structured logging helpers for Otto framework.
11
+ #
12
+ # BASE CONTEXT PATTERN (recommended for downstream projects):
13
+ #
14
+ # Create base context once per error/event, then merge event-specific fields.
15
+ #
16
+ # THREAD SAFETY: This pattern is thread-safe for concurrent requests. Each
17
+ # request has its own `env` hash, so `request_context(env)` creates isolated
18
+ # context hashes per request. The pattern extracts immutable values (strings,
19
+ # symbols) from `env`, and `.merge()` creates new hashes rather than mutating
20
+ # shared state. Safe for use in multi-threaded Rack servers (Puma, Falcon).
21
+ #
22
+ # base_context = Otto::LoggingHelpers.request_context(env)
23
+ #
24
+ # Otto.structured_log(:error, "Handler failed",
25
+ # base_context.merge(
26
+ # error: error.message,
27
+ # error_class: error.class.name,
28
+ # error_id: error_id,
29
+ # duration: duration
30
+ # )
31
+ # )
32
+ #
33
+ # Otto::LoggingHelpers.log_backtrace(error,
34
+ # base_context.merge(error_id: error_id, handler: 'Controller#action')
35
+ # )
36
+ #
37
+ # DOWNSTREAM EXTENSIBILITY:
38
+ #
39
+ # Large projects can inject custom shared fields:
40
+ #
41
+ # custom_base = Otto::LoggingHelpers.request_context(env).merge(
42
+ # transaction_id: Thread.current[:transaction_id],
43
+ # user_id: env['otto.user']&.id,
44
+ # tenant_id: env['tenant_id']
45
+ # )
46
+ #
47
+ # Otto.structured_log(:error, "Business operation failed",
48
+ # custom_base.merge(
49
+ # error: error.message,
50
+ # error_class: error.class.name,
51
+ # account_id: account.id,
52
+ # operation: :withdrawal
53
+ # )
54
+ # )
55
+ #
56
+
57
+ # Extract common request context for structured logging
58
+ #
59
+ # Returns a hash containing privacy-aware request metadata suitable
60
+ # for merging with event-specific data in Otto.structured_log calls.
61
+ #
62
+ # @param env [Hash] Rack environment hash
63
+ # @return [Hash] Request context with method, path, ip, country, user_agent
64
+ #
65
+ # @example Basic usage
66
+ # Otto.structured_log(:info, "Route matched",
67
+ # Otto::LoggingHelpers.request_context(env).merge(
68
+ # type: 'literal',
69
+ # handler: 'App#index'
70
+ # )
71
+ # )
72
+ #
73
+ # @note IP addresses are already masked by IPPrivacyMiddleware (public IPs only)
74
+ # @note User agents are truncated to 100 chars to prevent log bloat
75
+ def self.request_context(env)
76
+ {
77
+ method: env['REQUEST_METHOD'],
78
+ path: env['PATH_INFO'],
79
+ ip: env['REMOTE_ADDR'], # Already masked by IPPrivacyMiddleware for public IPs
80
+ country: env['otto.geo_country'],
81
+ user_agent: env['HTTP_USER_AGENT']&.slice(0, 100), # Already anonymized by IPPrivacyMiddleware
82
+ }.compact
83
+ end
84
+
85
+ # Log a timed operation with consistent timing and error handling
86
+ #
87
+ # @param level [Symbol] The log level (:debug, :info, :warn, :error)
88
+ # @param message [String] The log message
89
+ # @param env [Hash] Rack environment for request context
90
+ # @param metadata [Hash] Additional metadata to include in the log
91
+ # @yield The block to execute and time
92
+ # @return The result of the block
93
+ #
94
+ # @example
95
+ # Otto::LoggingHelpers.log_timed_operation(:info, "Template compiled", env,
96
+ # template_type: 'handlebars', cached: false
97
+ # ) do
98
+ # compile_template(template)
99
+ # end
100
+ #
101
+ def self.log_timed_operation(level, message, env, **metadata)
102
+ start_time = Otto::Utils.now_in_μs
103
+ result = yield
104
+ duration = Otto::Utils.now_in_μs - start_time
105
+
106
+ Otto.structured_log(level, message,
107
+ request_context(env).merge(metadata).merge(duration: duration))
108
+
109
+ result
110
+ rescue StandardError => e
111
+ duration = Otto::Utils.now_in_μs - start_time
112
+ Otto.structured_log(:error, "#{message} failed",
113
+ request_context(env).merge(metadata).merge(
114
+ duration: duration,
115
+ error: e.message,
116
+ error_class: e.class.name
117
+ ))
118
+ raise
119
+ end
120
+
121
+ # Detect project root directory for path sanitization
122
+ # @return [String] Absolute path to project root
123
+ def self.detect_project_root
124
+ @project_root ||= begin
125
+ if defined?(Bundler)
126
+ Bundler.root.to_s
127
+ else
128
+ Dir.pwd
129
+ end
130
+ end
131
+ end
132
+
133
+ # Sanitize a single backtrace line to remove sensitive path information
134
+ #
135
+ # Transforms absolute paths into relative or categorized paths:
136
+ # - Project files: relative path from project root
137
+ # - Gem files: [GEM] gem-name-version/relative/path
138
+ # - Ruby stdlib: [RUBY] filename
139
+ # - Unknown: [EXTERNAL] filename
140
+ #
141
+ # @param line [String] Raw backtrace line
142
+ # @param project_root [String] Project root path (auto-detected if nil)
143
+ # @return [String] Sanitized backtrace line
144
+ #
145
+ # @example
146
+ # sanitize_backtrace_line("/Users/admin/app/lib/user.rb:42:in `save'")
147
+ # # => "lib/user.rb:42:in `save'"
148
+ #
149
+ # sanitize_backtrace_line("/usr/local/gems/rack-3.1.8/lib/rack.rb:10")
150
+ # # => "[GEM] rack-3.1.8/lib/rack.rb:10"
151
+ #
152
+ def self.sanitize_backtrace_line(line, project_root = nil)
153
+ return line if line.nil? || line.empty?
154
+
155
+ project_root ||= detect_project_root
156
+ expanded_root = File.expand_path(project_root)
157
+
158
+ # Extract file path from backtrace line (format: "path:line:in `method'" or "path:line")
159
+ if line =~ /^(.+?):\d+(?::in `.+')?$/
160
+ file_path = ::Regexp.last_match(1)
161
+ suffix = line[file_path.length..]
162
+
163
+ begin
164
+ expanded_path = File.expand_path(file_path)
165
+ rescue ArgumentError
166
+ # Handle malformed paths (e.g., containing null bytes)
167
+ # File.basename also raises ArgumentError for null bytes, so use simple string manipulation
168
+ basename = file_path.split('/').last || file_path
169
+ return "[EXTERNAL] #{basename}#{suffix}"
170
+ end
171
+
172
+ # Try project-relative path first
173
+ if expanded_path.start_with?(expanded_root + File::SEPARATOR)
174
+ relative_path = expanded_path.delete_prefix(expanded_root + File::SEPARATOR)
175
+ return relative_path + suffix
176
+ end
177
+
178
+ # Check for bundler gems specifically (e.g., /bundler/gems/otto-abc123/lib/...)
179
+ # Must check BEFORE regular gems pattern to handle /gems/3.4.0/bundler/gems/...
180
+ if expanded_path =~ %r{/bundler/gems/([^/]+)/(.+)$}
181
+ gem_name = ::Regexp.last_match(1)
182
+ gem_relative = ::Regexp.last_match(2)
183
+ # Strip git hash suffix for cleaner output (otto-abc123def456 → otto)
184
+ gem_name = gem_name.sub(/-[a-f0-9]{7,}$/, '') if gem_name =~ /-[a-f0-9]{7,}$/i
185
+ return "[GEM] #{gem_name}/#{gem_relative}#{suffix}"
186
+ end
187
+
188
+ # Check for regular gem path (e.g., /path/to/gems/rack-3.1.8/lib/rack.rb)
189
+ # Handle version directories: /gems/3.4.0/gems/rack-3.1.8/... by looking for last /gems/
190
+ if expanded_path =~ %r{/gems/([^/]+)/(.+)$}
191
+ gem_name = ::Regexp.last_match(1)
192
+ gem_relative = ::Regexp.last_match(2)
193
+
194
+ # Skip version-only directory names (e.g., 3.4.0)
195
+ # Look deeper if gem_name is just a version number
196
+ if gem_name =~ /^[\d.]+$/ && gem_relative =~ %r{^(?:bundler/)?gems/([^/]+)/(.+)$}
197
+ # Found nested gem path, use that instead
198
+ gem_name = ::Regexp.last_match(1)
199
+ gem_relative = ::Regexp.last_match(2)
200
+ end
201
+
202
+ # Strip version suffix for cleaner output (rack-3.1.8 → rack)
203
+ base_gem_name = gem_name.split('-')[0..-2].join('-')
204
+ base_gem_name = gem_name if base_gem_name.empty?
205
+
206
+ return "[GEM] #{base_gem_name}/#{gem_relative}#{suffix}"
207
+ end
208
+
209
+ # Check for Ruby stdlib (e.g., /path/to/ruby/3.4.0/logger.rb)
210
+ if expanded_path =~ %r{/ruby/[\d.]+/(.+)$}
211
+ stdlib_file = ::Regexp.last_match(1)
212
+ return "[RUBY] #{stdlib_file}#{suffix}"
213
+ end
214
+
215
+ # Unknown/external path - show filename only
216
+ filename = File.basename(file_path)
217
+ return "[EXTERNAL] #{filename}#{suffix}"
218
+ end
219
+
220
+ # Couldn't parse - return as-is (better than failing)
221
+ line
222
+ end
223
+
224
+ # Sanitize an array of backtrace lines
225
+ #
226
+ # @param backtrace [Array<String>] Raw backtrace lines
227
+ # @param project_root [String] Project root path (auto-detected if nil)
228
+ # @return [Array<String>] Sanitized backtrace lines
229
+ def self.sanitize_backtrace(backtrace, project_root: nil)
230
+ return [] if backtrace.nil? || backtrace.empty?
231
+
232
+ project_root ||= detect_project_root
233
+ backtrace.map { |line| sanitize_backtrace_line(line, project_root) }
234
+ end
235
+
236
+ # Log exception backtrace with correlation fields for debugging.
237
+ # Always logs for unhandled errors at ERROR level with sanitized paths.
238
+ # Limits to first 20 lines for critical errors.
239
+ #
240
+ # SECURITY: Paths are sanitized to prevent exposing sensitive system information:
241
+ # - Project files: Show relative paths only
242
+ # - Gem files: Show gem name and relative path within gem
243
+ # - Ruby stdlib: Show filename only
244
+ # - External files: Show filename only
245
+ #
246
+ # Expects caller to provide correlation context (error_id, handler, etc).
247
+ # Does NOT duplicate error/error_class fields - those belong in main error log.
248
+ #
249
+ # @param error [Exception] The exception to log backtrace for
250
+ # @param context [Hash] Correlation fields (error_id, method, path, ip, handler, etc)
251
+ #
252
+ # @example Basic usage
253
+ # Otto::LoggingHelpers.log_backtrace(error,
254
+ # base_context.merge(error_id: error_id, handler: 'UserController#create')
255
+ # )
256
+ #
257
+ # @example Downstream extensibility
258
+ # custom_context = Otto::LoggingHelpers.request_context(env).merge(
259
+ # error_id: error_id,
260
+ # transaction_id: Thread.current[:transaction_id],
261
+ # tenant_id: env['tenant_id']
262
+ # )
263
+ # Otto::LoggingHelpers.log_backtrace(error, custom_context)
264
+ #
265
+ def self.log_backtrace(error, context = {})
266
+ raw_backtrace = error.backtrace&.first(20) || []
267
+ sanitized = sanitize_backtrace(raw_backtrace)
268
+
269
+ Otto.structured_log(:error, 'Exception backtrace',
270
+ context.merge(backtrace: sanitized))
271
+ end
272
+ end
273
+ end
@@ -1,6 +1,6 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/mcp/auth/token.rb
2
+ #
3
+ # frozen_string_literal: true
4
4
 
5
5
  require 'json'
6
6
 
@@ -1,6 +1,6 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/mcp/protocol.rb
2
+ #
3
+ # frozen_string_literal: true
4
4
 
5
5
  require 'json'
6
6
  require_relative 'registry'
@@ -1,6 +1,6 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/mcp/rate_limiting.rb
2
+ #
3
+ # frozen_string_literal: true
4
4
 
5
5
  require 'json'
6
6
 
@@ -1,6 +1,6 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/mcp/registry.rb
2
+ #
3
+ # frozen_string_literal: true
4
4
 
5
5
  class Otto
6
6
  module MCP
@@ -1,6 +1,6 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/mcp/route_parser.rb
2
+ #
3
+ # frozen_string_literal: true
4
4
 
5
5
  class Otto
6
6
  module MCP
@@ -1,6 +1,6 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/mcp/schema_validation.rb
2
+ #
3
+ # frozen_string_literal: true
4
4
 
5
5
  require 'json'
6
6
 
@@ -1,6 +1,6 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/mcp/server.rb
2
+ #
3
+ # frozen_string_literal: true
4
4
 
5
5
  require_relative 'protocol'
6
6
  require_relative 'registry'
data/lib/otto/mcp.rb ADDED
@@ -0,0 +1,5 @@
1
+ # lib/otto/mcp.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require_relative 'mcp/server'
@@ -0,0 +1,201 @@
1
+ # lib/otto/privacy/config.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require 'ipaddr'
6
+ require 'securerandom'
7
+ require 'digest'
8
+
9
+ require 'concurrent'
10
+
11
+ require_relative '../core/freezable'
12
+
13
+ class Otto
14
+ module Privacy
15
+ # Configuration for IP privacy features
16
+ #
17
+ # Privacy is ENABLED by default for public IPs. Private/localhost IPs are not masked.
18
+ #
19
+ # @example Default configuration (privacy enabled)
20
+ # config = Otto::Privacy::Config.new
21
+ # config.enabled? # => true
22
+ #
23
+ # @example Configure masking level
24
+ # config = Otto::Privacy::Config.new
25
+ # config.octet_precision = 2 # Mask 2 octets instead of 1
26
+ #
27
+ class Config
28
+ include Otto::Core::Freezable
29
+
30
+ attr_accessor :octet_precision, :hash_rotation_period, :geo_enabled, :mask_private_ips
31
+ attr_reader :disabled
32
+
33
+ # Class-level rotation key storage (mutable, not frozen with instances)
34
+ # This is stored at the class level so it persists across frozen config instances
35
+ @rotation_keys_store = nil
36
+
37
+ class << self
38
+ # Get the class-level rotation keys store
39
+ # @return [Concurrent::Map] Thread-safe map for rotation keys
40
+ def rotation_keys_store
41
+ @rotation_keys_store = Concurrent::Map.new unless defined?(@rotation_keys_store) && @rotation_keys_store
42
+ @rotation_keys_store
43
+ end
44
+ end
45
+
46
+ # Initialize privacy configuration
47
+ #
48
+ # @param options [Hash] Configuration options
49
+ # @option options [Integer] :octet_precision Number of trailing octets to mask (1 or 2, default: 1)
50
+ # @option options [Integer] :hash_rotation_period Seconds between key rotation (default: 86400)
51
+ # @option options [Boolean] :geo_enabled Enable geo-location resolution (default: true)
52
+ # @option options [Boolean] :disabled Disable privacy entirely (default: false)
53
+ # @option options [Boolean] :mask_private_ips Mask private/localhost IPs (default: false)
54
+ # @option options [Redis] :redis Optional Redis connection for multi-server environments
55
+ def initialize(options = {})
56
+ @octet_precision = options.fetch(:octet_precision, 1)
57
+ @hash_rotation_period = options.fetch(:hash_rotation_period, 86_400) # 24 hours
58
+ @geo_enabled = options.fetch(:geo_enabled, true)
59
+ @disabled = options.fetch(:disabled, false) # Enabled by default (privacy-by-default)
60
+ @mask_private_ips = options.fetch(:mask_private_ips, false) # Don't mask private/localhost by default
61
+ @redis = options[:redis] # Optional Redis connection for multi-server environments
62
+ end
63
+
64
+ # Check if privacy is enabled
65
+ #
66
+ # @return [Boolean] true if privacy is enabled (default)
67
+ def enabled?
68
+ !@disabled
69
+ end
70
+
71
+ # Check if privacy is disabled
72
+ #
73
+ # @return [Boolean] true if privacy was explicitly disabled
74
+ def disabled?
75
+ @disabled
76
+ end
77
+
78
+ # Disable privacy (allows access to original IPs)
79
+ #
80
+ # IMPORTANT: This should only be used when you have a specific
81
+ # requirement to access original IP addresses. By default, Otto
82
+ # provides privacy-safe masked IPs.
83
+ #
84
+ # @return [self]
85
+ def disable!
86
+ @disabled = true
87
+ self
88
+ end
89
+
90
+ # Enable privacy (default state)
91
+ #
92
+ # @return [self]
93
+ def enable!
94
+ @disabled = false
95
+ self
96
+ end
97
+
98
+ # Get the current rotation key for IP hashing
99
+ #
100
+ # Keys rotate at fixed intervals based on hash_rotation_period (default: 24 hours).
101
+ # Each rotation period gets a unique key, ensuring IP addresses hash differently
102
+ # across periods while remaining consistent within.
103
+ #
104
+ # Multi-server support:
105
+ # - With Redis: Uses SET NX GET EX for atomic key generation across all servers
106
+ # - Without Redis: Falls back to in-memory Concurrent::Hash (single-server only)
107
+ #
108
+ # Redis keys:
109
+ # - rotation_key:{timestamp} - Stores the rotation key with TTL
110
+ #
111
+ # @return [String] Current rotation key for hashing
112
+ def rotation_key
113
+ if @redis
114
+ rotation_key_redis
115
+ else
116
+ rotation_key_memory
117
+ end
118
+ end
119
+
120
+ # Validate configuration settings
121
+ #
122
+ # @raise [ArgumentError] if configuration is invalid
123
+ def validate!
124
+ raise ArgumentError, "octet_precision must be 1 or 2, got: #{@octet_precision}" unless [1,
125
+ 2].include?(@octet_precision)
126
+
127
+ return unless @hash_rotation_period < 60
128
+
129
+ raise ArgumentError, 'hash_rotation_period must be at least 60 seconds'
130
+ end
131
+
132
+ private
133
+
134
+ # Redis-based rotation key (atomic across multiple servers)
135
+ #
136
+ # Uses SET NX GET EX to atomically:
137
+ # 1. Check if key exists
138
+ # 2. Set new key only if missing
139
+ # 3. Return existing or newly set key
140
+ # 4. Auto-expire with TTL
141
+ #
142
+ # @return [String] Current rotation key
143
+ # @api private
144
+ def rotation_key_redis
145
+ now_seconds = Time.now.utc.to_i
146
+
147
+ # Quantize to rotation period boundary
148
+ rotation_timestamp = (now_seconds / @hash_rotation_period) * @hash_rotation_period
149
+
150
+ redis_key = "rotation_key:#{rotation_timestamp}"
151
+ ttl = (@hash_rotation_period * 1.2).to_i # Auto-cleanup with 20% buffer
152
+
153
+ key = SecureRandom.hex(32)
154
+
155
+ # SET NX GET returns old value if key exists, nil if we set it
156
+ # @see https://valkey.io/commands/set/
157
+ existing_key = @redis.set(redis_key, key, nx: true, get: true, ex: ttl)
158
+
159
+ existing_key || key
160
+ end
161
+
162
+ # In-memory rotation key (single-server fallback)
163
+ #
164
+ # Uses class-level Concurrent::Hash for thread-safety within a single process.
165
+ # NOT atomic across multiple servers.
166
+ #
167
+ # The rotation keys are stored at the class level so they remain mutable
168
+ # even when config instances are frozen.
169
+ #
170
+ # @return [String] Current rotation key
171
+ # @api private
172
+ def rotation_key_memory
173
+ rotation_keys = self.class.rotation_keys_store
174
+
175
+ now_seconds = Time.now.utc.to_i
176
+
177
+ # Quantize to rotation period boundary (e.g., midnight UTC for 24-hour period)
178
+ seconds_since_epoch = now_seconds % @hash_rotation_period
179
+ rotation_timestamp = now_seconds - seconds_since_epoch
180
+
181
+ # Atomically get or create key for this rotation period
182
+ # Use compute_if_absent for thread-safe atomic operation
183
+ key = rotation_keys.compute_if_absent(rotation_timestamp) do
184
+ # Generate new key atomically
185
+ # IMPORTANT: Don't modify the map inside this block to avoid deadlock
186
+ SecureRandom.hex(32)
187
+ end
188
+
189
+ # Clean up old keys after atomic operation completes
190
+ # This runs outside compute_if_absent to avoid deadlock
191
+ if rotation_keys.size > 1
192
+ rotation_keys.each_key do |ts|
193
+ rotation_keys.delete(ts) if ts != rotation_timestamp
194
+ end
195
+ end
196
+
197
+ key
198
+ end
199
+ end
200
+ end
201
+ end