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
data/lib/otto.rb CHANGED
@@ -1,6 +1,6 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto.rb
2
+ #
3
+ # frozen_string_literal: true
4
4
 
5
5
  require 'json'
6
6
  require 'logger'
@@ -11,29 +11,20 @@ require 'rack/request'
11
11
  require 'rack/response'
12
12
  require 'rack/utils'
13
13
 
14
- require_relative 'otto/security/authentication/strategy_result'
15
14
  require_relative 'otto/route_definition'
16
15
  require_relative 'otto/route'
17
16
  require_relative 'otto/static'
18
- require_relative 'otto/helpers/request'
19
- require_relative 'otto/helpers/response'
17
+ require_relative 'otto/helpers'
20
18
  require_relative 'otto/response_handlers'
21
19
  require_relative 'otto/route_handlers'
22
- require_relative 'otto/version'
23
- require_relative 'otto/security/config'
24
- require_relative 'otto/security/middleware/csrf_middleware'
25
- require_relative 'otto/security/middleware/validation_middleware'
26
- require_relative 'otto/security/authentication/authentication_middleware'
27
- require_relative 'otto/security/middleware/rate_limit_middleware'
28
- require_relative 'otto/mcp/server'
29
- require_relative 'otto/core/router'
30
- require_relative 'otto/core/file_safety'
31
- require_relative 'otto/core/configuration'
32
- require_relative 'otto/core/error_handler'
33
- require_relative 'otto/core/uri_generator'
34
- require_relative 'otto/core/middleware_stack'
35
- require_relative 'otto/security/configurator'
20
+ require_relative 'otto/locale'
21
+ require_relative 'otto/mcp'
22
+ require_relative 'otto/core'
23
+ require_relative 'otto/privacy'
24
+ require_relative 'otto/security'
36
25
  require_relative 'otto/utils'
26
+ require_relative 'otto/version'
27
+ require_relative 'otto/logging_helpers'
37
28
 
38
29
  # Otto is a simple Rack router that allows you to define routes in a file
39
30
  # with built-in security features including CSRF protection, input validation,
@@ -56,37 +47,6 @@ require_relative 'otto/utils'
56
47
  # otto.enable_csp!
57
48
  # otto.enable_frame_protection!
58
49
  #
59
- # Configuration Data class to replace OpenStruct
60
- # Configuration class to replace OpenStruct
61
- class ConfigData
62
- def initialize(**kwargs)
63
- @data = kwargs
64
- end
65
-
66
- # Dynamic attribute accessors
67
- def method_missing(method_name, *args)
68
- if method_name.to_s.end_with?('=')
69
- # Setter
70
- attr_name = method_name.to_s.chomp('=').to_sym
71
- @data[attr_name] = args.first
72
- elsif @data.key?(method_name)
73
- # Getter
74
- @data[method_name]
75
- else
76
- super
77
- end
78
- end
79
-
80
- def respond_to_missing?(method_name, include_private = false)
81
- method_name.to_s.end_with?('=') || @data.key?(method_name) || super
82
- end
83
-
84
- # Convert to hash for compatibility
85
- def to_h
86
- @data.dup
87
- end
88
- end
89
-
90
50
  class Otto
91
51
  include Otto::Core::Router
92
52
  include Otto::Core::FileSafety
@@ -102,25 +62,12 @@ class Otto
102
62
  else
103
63
  defined?(Otto::Utils) ? Otto::Utils.yes?(ENV.fetch('OTTO_DEBUG', nil)) : false
104
64
  end
105
- @logger = Logger.new($stdout, Logger::INFO)
106
- @global_config = nil
107
-
108
- # Global configuration for all Otto instances (Ruby 3.2+ pattern matching)
109
- def self.configure
110
- config = case @global_config
111
- in Hash => h
112
- # Transform string keys to symbol keys for ConfigData compatibility
113
- symbol_hash = h.transform_keys(&:to_sym)
114
- ConfigData.new(**symbol_hash)
115
- else
116
- ConfigData.new
117
- end
118
- yield config
119
- @global_config = config.to_h
120
- end
65
+ @logger = Logger.new($stdout, Logger::INFO)
121
66
 
122
- attr_reader :routes, :routes_literal, :routes_static, :route_definitions, :option, :static_route,
123
- :security_config, :locale_config, :auth_config, :route_handler_factory, :mcp_server, :security, :middleware
67
+ attr_reader :routes, :routes_literal, :routes_static, :route_definitions, :option,
68
+ :static_route, :security_config, :locale_config, :auth_config,
69
+ :route_handler_factory, :mcp_server, :security, :middleware,
70
+ :error_handlers
124
71
  attr_accessor :not_found, :server_error
125
72
 
126
73
  def initialize(path = nil, opts = {})
@@ -131,27 +78,103 @@ class Otto
131
78
  Otto.logger.debug "new Otto: #{opts}" if Otto.debug
132
79
  load(path) unless path.nil?
133
80
  super()
81
+
82
+ # Auto-register AuthorizationError for 403 Forbidden responses
83
+ # This allows Logic classes to raise AuthorizationError for resource-level access control
84
+ register_error_handler(Otto::Security::AuthorizationError, status: 403, log_level: :warn)
85
+
86
+ # Build the middleware app once after all initialization is complete
87
+ build_app!
88
+
89
+ # Configuration freezing is deferred until first request to support
90
+ # multi-step initialization (e.g., multi-app architectures).
91
+ # This allows adding auth strategies, middleware, etc. after Otto.new
92
+ # but before processing requests.
93
+ @freeze_mutex = Mutex.new
94
+ @configuration_frozen = false
134
95
  end
135
96
  alias options option
136
97
 
137
98
  # Main Rack application interface
138
99
  def call(env)
139
- # Apply middleware stack
140
- base_app = ->(e) { handle_request(e) }
100
+ # Freeze configuration on first request (thread-safe)
101
+ # Skip in test environment to allow test flexibility
102
+ unless defined?(RSpec) || @configuration_frozen
103
+ Otto.logger.debug '[Otto] Lazy freezing check: configuration not yet frozen' if Otto.debug
104
+
105
+ @freeze_mutex.synchronize do
106
+ unless @configuration_frozen
107
+ Otto.logger.info '[Otto] Freezing configuration on first request (lazy freeze)'
108
+ freeze_configuration!
109
+ @configuration_frozen = true
110
+ Otto.logger.debug '[Otto] Configuration frozen successfully' if Otto.debug
111
+ end
112
+ end
113
+ end
141
114
 
142
- # Use the middleware stack as the source of truth
143
- app = @middleware.build_app(base_app, @security_config)
115
+ # Track request timing for lifecycle hooks
116
+ start_time = Otto::Utils.now_in_μs
117
+ request = Rack::Request.new(env)
118
+ response_raw = nil
144
119
 
145
120
  begin
146
- app.call(env)
121
+ # Use pre-built middleware app (built once at initialization)
122
+ response_raw = @app.call(env)
147
123
  rescue StandardError => e
148
- handle_error(e, env)
124
+ response_raw = handle_error(e, env)
125
+ ensure
126
+ # Execute request completion hooks if any are registered
127
+ unless @request_complete_callbacks.empty?
128
+ begin
129
+ duration = Otto::Utils.now_in_μs - start_time
130
+ # Wrap response tuple in Rack::Response for developer-friendly API
131
+ # Otto's hook API should provide nice abstractions like Rack::Request/Response
132
+ response = Rack::Response.new(response_raw[2], response_raw[0], response_raw[1])
133
+ @request_complete_callbacks.each do |callback|
134
+ callback.call(request, response, duration)
135
+ end
136
+ rescue StandardError => e
137
+ Otto.logger.error "[Otto] Request completion hook error: #{e.message}"
138
+ Otto.logger.debug "[Otto] Hook error backtrace: #{e.backtrace.join("\n")}" if Otto.debug
139
+ end
140
+ end
149
141
  end
142
+
143
+ response_raw
144
+ end
145
+
146
+ # Builds the middleware application chain
147
+ # Called once at initialization and whenever middleware stack changes
148
+ #
149
+ # IMPORTANT: If you have routes with auth requirements, you MUST add session
150
+ # middleware to your middleware stack BEFORE Otto processes requests.
151
+ #
152
+ # Session middleware is required for RouteAuthWrapper to correctly persist
153
+ # session changes during authentication. Common options include:
154
+ # - Rack::Session::Cookie (requires rack-session gem)
155
+ # - Rack::Session::Pool
156
+ # - Rack::Session::Memcache
157
+ # - Any Rack-compatible session middleware
158
+ #
159
+ # Example:
160
+ # use Rack::Session::Cookie, secret: ENV['SESSION_SECRET']
161
+ # otto = Otto.new('routes.txt')
162
+ #
163
+ def build_app!
164
+ base_app = method(:handle_request)
165
+ @app = @middleware.wrap(base_app, @security_config)
150
166
  end
151
167
 
152
168
  # Middleware Management
153
169
  def use(middleware, ...)
170
+ ensure_not_frozen!
154
171
  @middleware.add(middleware, ...)
172
+
173
+ # NOTE: If build_app! is triggered during a request (via use() or
174
+ # middleware_stack=), the @app instance variable could be swapped
175
+ # mid-request in a multi-threaded environment.
176
+
177
+ build_app! if @app # Rebuild app if already initialized
155
178
  end
156
179
 
157
180
  # Compatibility method for existing tests
@@ -163,6 +186,7 @@ class Otto
163
186
  def middleware_stack=(stack)
164
187
  @middleware.clear!
165
188
  Array(stack).each { |middleware| @middleware.add(middleware) }
189
+ build_app! if @app # Rebuild app if already initialized
166
190
  end
167
191
 
168
192
  # Compatibility method for middleware detection
@@ -179,6 +203,7 @@ class Otto
179
203
  # @example
180
204
  # otto.enable_csrf_protection!
181
205
  def enable_csrf_protection!
206
+ ensure_not_frozen!
182
207
  return if @middleware.includes?(Otto::Security::Middleware::CSRFMiddleware)
183
208
 
184
209
  @security_config.enable_csrf_protection!
@@ -191,6 +216,7 @@ class Otto
191
216
  # @example
192
217
  # otto.enable_request_validation!
193
218
  def enable_request_validation!
219
+ ensure_not_frozen!
194
220
  return if @middleware.includes?(Otto::Security::Middleware::ValidationMiddleware)
195
221
 
196
222
  @security_config.input_validation = true
@@ -206,6 +232,7 @@ class Otto
206
232
  # @example
207
233
  # otto.enable_rate_limiting!(requests_per_minute: 50)
208
234
  def enable_rate_limiting!(options = {})
235
+ ensure_not_frozen!
209
236
  return if @middleware.includes?(Otto::Security::Middleware::RateLimitMiddleware)
210
237
 
211
238
  @security.configure_rate_limiting(options)
@@ -222,7 +249,7 @@ class Otto
222
249
  # @example
223
250
  # otto.add_rate_limit_rule('uploads', limit: 5, period: 300, condition: ->(req) { req.post? && req.path.include?('upload') })
224
251
  def add_rate_limit_rule(name, options)
225
- @security_config.rate_limiting_config[:custom_rules] ||= {}
252
+ ensure_not_frozen!
226
253
  @security_config.rate_limiting_config[:custom_rules][name.to_s] = options
227
254
  end
228
255
 
@@ -234,6 +261,7 @@ class Otto
234
261
  # otto.add_trusted_proxy('10.0.0.0/8')
235
262
  # otto.add_trusted_proxy(/^172\.16\./)
236
263
  def add_trusted_proxy(proxy)
264
+ ensure_not_frozen!
237
265
  @security_config.add_trusted_proxy(proxy)
238
266
  end
239
267
 
@@ -247,6 +275,7 @@ class Otto
247
275
  # 'strict-transport-security' => 'max-age=31536000'
248
276
  # })
249
277
  def set_security_headers(headers)
278
+ ensure_not_frozen!
250
279
  @security_config.security_headers.merge!(headers)
251
280
  end
252
281
 
@@ -259,6 +288,7 @@ class Otto
259
288
  # @example
260
289
  # otto.enable_hsts!(max_age: 86400, include_subdomains: false)
261
290
  def enable_hsts!(max_age: 31_536_000, include_subdomains: true)
291
+ ensure_not_frozen!
262
292
  @security_config.enable_hsts!(max_age: max_age, include_subdomains: include_subdomains)
263
293
  end
264
294
 
@@ -269,6 +299,7 @@ class Otto
269
299
  # @example
270
300
  # otto.enable_csp!("default-src 'self'; script-src 'self' 'unsafe-inline'")
271
301
  def enable_csp!(policy = "default-src 'self'")
302
+ ensure_not_frozen!
272
303
  @security_config.enable_csp!(policy)
273
304
  end
274
305
 
@@ -278,6 +309,7 @@ class Otto
278
309
  # @example
279
310
  # otto.enable_frame_protection!('DENY')
280
311
  def enable_frame_protection!(option = 'SAMEORIGIN')
312
+ ensure_not_frozen!
281
313
  @security_config.enable_frame_protection!(option)
282
314
  end
283
315
 
@@ -288,33 +320,202 @@ class Otto
288
320
  # @example
289
321
  # otto.enable_csp_with_nonce!(debug: true)
290
322
  def enable_csp_with_nonce!(debug: false)
323
+ ensure_not_frozen!
291
324
  @security_config.enable_csp_with_nonce!(debug: debug)
292
325
  end
293
326
 
294
- # Enable authentication middleware for route-level access control.
295
- # This will automatically check route auth parameters and enforce authentication.
296
- #
297
- # @example
298
- # otto.enable_authentication!
299
- def enable_authentication!
300
- return if @middleware.includes?(Otto::Security::Authentication::AuthenticationMiddleware)
301
-
302
- use Otto::Security::Authentication::AuthenticationMiddleware, @auth_config
303
- end
304
-
305
327
  # Add a single authentication strategy
306
328
  #
307
329
  # @param name [String] Strategy name
308
330
  # @param strategy [Otto::Security::Authentication::AuthStrategy] Strategy instance
309
331
  # @example
310
332
  # otto.add_auth_strategy('custom', MyCustomStrategy.new)
333
+ # Add an authentication strategy with a registered name
334
+ #
335
+ # This is the primary public API for registering authentication strategies.
336
+ # The name you provide here will be available as `strategy_result.strategy_name`
337
+ # in your application code, making it easy to identify which strategy authenticated
338
+ # the current request.
339
+ #
340
+ # Also available via Otto::Security::Configurator for consolidated security config.
341
+ #
342
+ # @param name [String, Symbol] Strategy name (e.g., 'session', 'api_key', 'jwt')
343
+ # @param strategy [AuthStrategy] Strategy instance
344
+ # @example
345
+ # otto.add_auth_strategy('session', SessionStrategy.new(session_key: 'user_id'))
346
+ # otto.add_auth_strategy('api_key', APIKeyStrategy.new)
347
+ # @raise [ArgumentError] if strategy name already registered
311
348
  def add_auth_strategy(name, strategy)
349
+ ensure_not_frozen!
312
350
  # Ensure auth_config is initialized (handles edge case where it might be nil)
313
351
  @auth_config = { auth_strategies: {}, default_auth_strategy: 'noauth' } if @auth_config.nil?
314
352
 
353
+ # Strict mode: Detect strategy name collisions
354
+ if @auth_config[:auth_strategies].key?(name)
355
+ raise ArgumentError, "Authentication strategy '#{name}' is already registered"
356
+ end
357
+
315
358
  @auth_config[:auth_strategies][name] = strategy
359
+ end
316
360
 
317
- enable_authentication!
361
+ # Register an error handler for expected business logic errors
362
+ #
363
+ # This allows you to handle known error conditions (like missing resources,
364
+ # expired data, rate limits) without logging them as unhandled 500 errors.
365
+ #
366
+ # @param error_class [Class, String] The exception class or class name to handle
367
+ # @param status [Integer] HTTP status code to return (default: 500)
368
+ # @param log_level [Symbol] Log level for expected errors (:info, :warn, :error)
369
+ # @param handler [Proc] Optional block to customize error response
370
+ #
371
+ # @example Basic usage with status code
372
+ # otto.register_error_handler(Onetime::MissingSecret, status: 404, log_level: :info)
373
+ # otto.register_error_handler(Onetime::SecretExpired, status: 410, log_level: :info)
374
+ #
375
+ # @example With custom response handler
376
+ # otto.register_error_handler(Onetime::RateLimited, status: 429, log_level: :warn) do |error, req|
377
+ # {
378
+ # error: 'Rate limit exceeded',
379
+ # retry_after: error.retry_after,
380
+ # message: error.message
381
+ # }
382
+ # end
383
+ #
384
+ # @example Using string class names (for lazy loading)
385
+ # otto.register_error_handler('Onetime::MissingSecret', status: 404, log_level: :info)
386
+ #
387
+ def register_error_handler(error_class, status: 500, log_level: :info, &handler)
388
+ ensure_not_frozen!
389
+
390
+ # Normalize error class to string for consistent lookup
391
+ error_class_name = error_class.is_a?(String) ? error_class : error_class.name
392
+
393
+ @error_handlers[error_class_name] = {
394
+ status: status,
395
+ log_level: log_level,
396
+ handler: handler
397
+ }
398
+ end
399
+
400
+ # Disable IP privacy to access original IP addresses
401
+ #
402
+ # IMPORTANT: By default, Otto masks public IP addresses for privacy.
403
+ # Private/localhost IPs (127.0.0.0/8, 10.0.0.0/8, etc.) are never masked.
404
+ # Only disable this if you need access to original public IPs.
405
+ #
406
+ # When disabled:
407
+ # - env['REMOTE_ADDR'] contains the real IP address
408
+ # - env['otto.original_ip'] also contains the real IP
409
+ # - No PrivateFingerprint is created
410
+ #
411
+ # @example
412
+ # otto.disable_ip_privacy!
413
+ def disable_ip_privacy!
414
+ ensure_not_frozen!
415
+ @security_config.ip_privacy_config.disable!
416
+ end
417
+
418
+ # Enable full IP privacy (mask ALL IPs including private/localhost)
419
+ #
420
+ # By default, Otto exempts private and localhost IPs from masking for
421
+ # better development experience. Call this method to mask ALL IPs
422
+ # regardless of type.
423
+ #
424
+ # @example Enable full privacy (mask all IPs)
425
+ # otto = Otto.new(routes_file)
426
+ # otto.enable_full_ip_privacy!
427
+ # # Now 127.0.0.1 → 127.0.0.0, 192.168.1.100 → 192.168.1.0
428
+ #
429
+ # @return [void]
430
+ # @raise [FrozenError] if called after configuration is frozen
431
+ def enable_full_ip_privacy!
432
+ ensure_not_frozen!
433
+ @security_config.ip_privacy_config.mask_private_ips = true
434
+ end
435
+
436
+ # Register a callback to be executed after each request completes
437
+ #
438
+ # Instance-level request completion callbacks allow each Otto instance
439
+ # to have its own isolated set of callbacks, preventing duplicate
440
+ # invocations in multi-app architectures (e.g., Rack::URLMap).
441
+ #
442
+ # The callback receives three arguments:
443
+ # - request: Rack::Request object
444
+ # - response: Rack::Response object (wrapping the response tuple)
445
+ # - duration: Request processing duration in microseconds
446
+ #
447
+ # @example Basic usage
448
+ # otto = Otto.new(routes_file)
449
+ # otto.on_request_complete do |req, res, duration|
450
+ # logger.info "Request completed", path: req.path, duration: duration
451
+ # end
452
+ #
453
+ # @example Multi-app architecture
454
+ # # App 1: Core Web Application
455
+ # core_router = Otto.new
456
+ # core_router.on_request_complete do |req, res, duration|
457
+ # logger.info "Core app request", path: req.path
458
+ # end
459
+ #
460
+ # # App 2: API Application
461
+ # api_router = Otto.new
462
+ # api_router.on_request_complete do |req, res, duration|
463
+ # logger.info "API request", path: req.path
464
+ # end
465
+ #
466
+ # # Each callback only fires for its respective Otto instance
467
+ #
468
+ # @yield [request, response, duration] Block to execute after each request
469
+ # @yieldparam request [Rack::Request] The request object
470
+ # @yieldparam response [Rack::Response] The response object
471
+ # @yieldparam duration [Integer] Duration in microseconds
472
+ # @return [self] Returns self for method chaining
473
+ # @raise [FrozenError] if called after configuration is frozen
474
+ def on_request_complete(&block)
475
+ ensure_not_frozen!
476
+ @request_complete_callbacks << block if block_given?
477
+ self
478
+ end
479
+
480
+ # Get registered request completion callbacks (for internal use)
481
+ #
482
+ # @api private
483
+ # @return [Array<Proc>] Array of registered callback blocks
484
+ attr_reader :request_complete_callbacks
485
+
486
+ # Configure IP privacy settings
487
+ #
488
+ # Privacy is enabled by default. Use this method to customize privacy
489
+ # behavior without disabling it entirely.
490
+ #
491
+ # @param octet_precision [Integer] Number of octets to mask (1 or 2, default: 1)
492
+ # @param hash_rotation [Integer] Seconds between key rotation (default: 86400)
493
+ # @param geo [Boolean] Enable geo-location resolution (default: true)
494
+ # @param redis [Redis] Redis connection for multi-server atomic key generation
495
+ #
496
+ # @example Mask 2 octets instead of 1
497
+ # otto.configure_ip_privacy(octet_precision: 2)
498
+ #
499
+ # @example Disable geo-location
500
+ # otto.configure_ip_privacy(geo: false)
501
+ #
502
+ # @example Custom hash rotation
503
+ # otto.configure_ip_privacy(hash_rotation: 24.hours)
504
+ #
505
+ # @example Multi-server with Redis
506
+ # redis = Redis.new(url: ENV['REDIS_URL'])
507
+ # otto.configure_ip_privacy(redis: redis)
508
+ def configure_ip_privacy(octet_precision: nil, hash_rotation: nil, geo: nil, redis: nil)
509
+ ensure_not_frozen!
510
+ config = @security_config.ip_privacy_config
511
+
512
+ config.octet_precision = octet_precision if octet_precision
513
+ config.hash_rotation_period = hash_rotation if hash_rotation
514
+ config.geo_enabled = geo unless geo.nil?
515
+ config.instance_variable_set(:@redis, redis) if redis
516
+
517
+ # Validate configuration
518
+ config.validate!
318
519
  end
319
520
 
320
521
  # Enable MCP (Model Context Protocol) server support
@@ -326,6 +527,7 @@ class Otto
326
527
  # @example
327
528
  # otto.enable_mcp!(http: true, endpoint: '/api/mcp')
328
529
  def enable_mcp!(options = {})
530
+ ensure_not_frozen!
329
531
  @mcp_server ||= Otto::MCP::Server.new(self)
330
532
 
331
533
  @mcp_server.enable!(options)
@@ -350,6 +552,16 @@ class Otto
350
552
  # Initialize @auth_config first so it can be shared with the configurator
351
553
  @auth_config = { auth_strategies: {}, default_auth_strategy: 'noauth' }
352
554
  @security = Otto::Security::Configurator.new(@security_config, @middleware, @auth_config)
555
+ @app = nil # Pre-built middleware app (built after initialization)
556
+ @request_complete_callbacks = [] # Instance-level request completion callbacks
557
+ @error_handlers = {} # Registered error handlers for expected errors
558
+
559
+ # Add IP Privacy middleware first in stack (privacy by default for public IPs)
560
+ # Private/localhost IPs are automatically exempted from masking
561
+ @middleware.add_with_position(
562
+ Otto::Security::Middleware::IPPrivacyMiddleware,
563
+ position: :first
564
+ )
353
565
  end
354
566
 
355
567
  def initialize_options(_path, opts)
@@ -375,7 +587,30 @@ class Otto
375
587
  end
376
588
 
377
589
  class << self
378
- attr_accessor :debug, :logger, :global_config # rubocop:disable ThreadSafety/ClassAndModuleAttributes
590
+ attr_accessor :debug, :logger # rubocop:disable ThreadSafety/ClassAndModuleAttributes
591
+
592
+ # Helper method for structured logging that works with both standard Logger and structured loggers
593
+ def structured_log(level, message, data = {})
594
+ return unless logger
595
+
596
+ # Skip debug logging when Otto.debug is false
597
+ return if level == :debug && !debug
598
+
599
+ # Sanitize backtrace if present
600
+ if data.is_a?(Hash) && data[:backtrace].is_a?(Array)
601
+ data = data.dup
602
+ data[:backtrace] = Otto::LoggingHelpers.sanitize_backtrace(data[:backtrace])
603
+ end
604
+
605
+ # Try structured logging first (SemanticLogger, etc.)
606
+ if logger.respond_to?(level) && logger.method(level).arity > 1
607
+ logger.send(level, message, data)
608
+ else
609
+ # Fallback to standard logger with formatted string
610
+ formatted_data = data.empty? ? '' : " -- #{data.inspect}"
611
+ logger.send(level, "[Otto] #{message}#{formatted_data}")
612
+ end
613
+ end
379
614
  end
380
615
 
381
616
  # Class methods for Otto framework providing singleton access and configuration
@@ -398,7 +633,27 @@ class Otto
398
633
  end
399
634
 
400
635
  def env? *guesses
401
- !guesses.flatten.select { |n| ENV['RACK_ENV'].to_s == n.to_s }.empty?
636
+ guesses.flatten.any? { |n| ENV['RACK_ENV'].to_s == n.to_s }
637
+ end
638
+
639
+ # Test-only method to unfreeze Otto configuration
640
+ #
641
+ # This method resets the @configuration_frozen flag, allowing tests
642
+ # to bypass the ensure_not_frozen! check. It does NOT actually unfreeze
643
+ # Ruby objects (which is impossible once frozen).
644
+ #
645
+ # IMPORTANT: Only works when RSpec is defined. Raises an error otherwise
646
+ # to prevent accidental use in production.
647
+ #
648
+ # @param otto [Otto] The Otto instance to unfreeze
649
+ # @return [Otto] The unfrozen Otto instance
650
+ # @raise [RuntimeError] if RSpec is not defined (not in test environment)
651
+ # @api private
652
+ def unfreeze_for_testing(otto)
653
+ raise 'Otto.unfreeze_for_testing is only available in RSpec test environment' unless defined?(RSpec)
654
+
655
+ otto.instance_variable_set(:@configuration_frozen, false)
656
+ otto
402
657
  end
403
658
  end
404
659
  extend ClassMethods
data/otto.gemspec CHANGED
@@ -10,18 +10,25 @@ Gem::Specification.new do |spec|
10
10
  spec.email = 'gems@solutious.com'
11
11
  spec.authors = ['Delano Mandelbaum']
12
12
  spec.license = 'MIT'
13
- spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
13
+ spec.files = if File.directory?('.git') && system('git --version > /dev/null 2>&1')
14
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
15
+ else
16
+ Dir['**/*'].select { |f| File.file?(f) }.reject { |f| f.match(%r{^(test|spec|features)/}) }
17
+ end
14
18
  spec.homepage = 'https://github.com/delano/otto'
15
19
  spec.require_paths = ['lib']
16
20
 
17
21
  spec.required_ruby_version = ['>= 3.2', '< 4.0']
18
22
 
23
+ spec.add_dependency 'concurrent-ruby', '~> 1.3', '< 2.0'
24
+ spec.add_dependency 'ipaddr', '~> 1', '< 2.0'
25
+
19
26
  # Logger is not part of the default gems as of Ruby 3.5.0
20
27
  spec.add_dependency 'logger', '~> 1', '< 2.0'
21
28
 
22
29
  spec.add_dependency 'rack', '~> 3.1', '< 4.0'
23
30
  spec.add_dependency 'rack-parser', '~> 0.7'
24
- spec.add_dependency 'rexml', '>= 3.3.6'
31
+ spec.add_dependency 'rexml', '~> 3.4'
25
32
 
26
33
  # Security dependencies
27
34
  spec.add_dependency 'facets', '~> 3.1'