otto 2.0.0.pre1 → 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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +2 -3
  3. data/.github/workflows/claude-code-review.yml +30 -14
  4. data/.github/workflows/claude.yml +1 -1
  5. data/.rubocop.yml +4 -1
  6. data/CHANGELOG.rst +54 -6
  7. data/CLAUDE.md +537 -0
  8. data/Gemfile +3 -2
  9. data/Gemfile.lock +34 -26
  10. data/benchmark_middleware_wrap.rb +163 -0
  11. data/changelog.d/20251014_144317_delano_54_thats_a_wrapper.rst +36 -0
  12. data/changelog.d/20251014_161526_delano_54_thats_a_wrapper.rst +5 -0
  13. data/docs/.gitignore +2 -0
  14. data/docs/ipaddr-encoding-quirk.md +34 -0
  15. data/docs/migrating/v2.0.0-pre2.md +338 -0
  16. data/examples/authentication_strategies/config.ru +0 -1
  17. data/lib/otto/core/configuration.rb +91 -41
  18. data/lib/otto/core/freezable.rb +93 -0
  19. data/lib/otto/core/middleware_stack.rb +103 -16
  20. data/lib/otto/core/router.rb +8 -7
  21. data/lib/otto/core.rb +8 -0
  22. data/lib/otto/env_keys.rb +118 -0
  23. data/lib/otto/helpers/base.rb +2 -21
  24. data/lib/otto/helpers/request.rb +80 -2
  25. data/lib/otto/helpers/response.rb +25 -3
  26. data/lib/otto/helpers.rb +4 -0
  27. data/lib/otto/locale/config.rb +56 -0
  28. data/lib/otto/mcp/{validation.rb → schema_validation.rb} +3 -2
  29. data/lib/otto/mcp/server.rb +26 -13
  30. data/lib/otto/mcp.rb +3 -0
  31. data/lib/otto/privacy/config.rb +199 -0
  32. data/lib/otto/privacy/geo_resolver.rb +115 -0
  33. data/lib/otto/privacy/ip_privacy.rb +175 -0
  34. data/lib/otto/privacy/redacted_fingerprint.rb +136 -0
  35. data/lib/otto/privacy.rb +29 -0
  36. data/lib/otto/response_handlers/json.rb +6 -0
  37. data/lib/otto/route.rb +44 -48
  38. data/lib/otto/route_handlers/base.rb +1 -2
  39. data/lib/otto/route_handlers/factory.rb +24 -9
  40. data/lib/otto/route_handlers/logic_class.rb +2 -2
  41. data/lib/otto/security/authentication/auth_failure.rb +44 -0
  42. data/lib/otto/security/authentication/auth_strategy.rb +3 -3
  43. data/lib/otto/security/authentication/route_auth_wrapper.rb +260 -0
  44. data/lib/otto/security/authentication/strategies/{public_strategy.rb → noauth_strategy.rb} +6 -2
  45. data/lib/otto/security/authentication/strategy_result.rb +129 -15
  46. data/lib/otto/security/authentication.rb +5 -6
  47. data/lib/otto/security/config.rb +51 -18
  48. data/lib/otto/security/configurator.rb +2 -15
  49. data/lib/otto/security/middleware/ip_privacy_middleware.rb +211 -0
  50. data/lib/otto/security/middleware/rate_limit_middleware.rb +19 -3
  51. data/lib/otto/security.rb +9 -0
  52. data/lib/otto/version.rb +1 -1
  53. data/lib/otto.rb +183 -89
  54. data/otto.gemspec +5 -0
  55. metadata +83 -8
  56. data/changelog.d/20250911_235619_delano_next.rst +0 -28
  57. data/changelog.d/20250912_123055_delano_remove_ostruct.rst +0 -21
  58. data/changelog.d/20250912_175625_claude_delano_remove_ostruct.rst +0 -21
  59. data/lib/otto/security/authentication/authentication_middleware.rb +0 -123
  60. data/lib/otto/security/authentication/failure_result.rb +0 -36
@@ -0,0 +1,338 @@
1
+ # Otto v2.0.0-pre2 Migration Guide
2
+
3
+ ## Overview
4
+
5
+ This release resolves critical architectural issues with `StrategyResult` semantics and removes deprecated methods. The changes clarify the distinction between "request state" (user in session) and "authentication outcomes" (auth attempt just succeeded).
6
+
7
+ ## Breaking Changes
8
+
9
+ ### 1. StrategyResult Methods Removed
10
+
11
+ **Removed Methods:**
12
+ - `StrategyResult#success?` - Always returned `true`, meaningless
13
+ - `StrategyResult#failure?` - Always returned `false`, meaningless
14
+ - `FailureResult#success?` - Always returned `false`
15
+ - `FailureResult#failure?` - Always returned `true`
16
+
17
+ **Migration:**
18
+
19
+ ```ruby
20
+ # Before - checking success/failure
21
+ if strategy_result&.success?
22
+ # handle success
23
+ end
24
+
25
+ # After - type checking
26
+ if strategy_result.is_a?(Otto::Security::Authentication::StrategyResult)
27
+ # handle success
28
+ end
29
+
30
+ ```
31
+
32
+ ### 2. New Semantic Distinction
33
+
34
+ **New Method:**
35
+ - `StrategyResult#auth_attempt_succeeded?` - Returns `true` only when auth strategy just executed successfully
36
+
37
+ **Key Semantic Difference:**
38
+
39
+ | Method | Meaning | Use Case |
40
+ |--------|---------|----------|
41
+ | `authenticated?` | User in session (request state) | Check if session has user |
42
+ | `auth_attempt_succeeded?` | Auth strategy just succeeded (auth outcome) | Post-login redirects, analytics |
43
+
44
+ **Migration Examples:**
45
+
46
+ #### Registration Flow (IMPORTANT)
47
+
48
+ ```ruby
49
+ # Before - BROKEN - blocks legitimate registration
50
+ class CreateAccount < Logic::Base
51
+ def raise_concerns
52
+ # This was always true if user in session, blocking registration
53
+ raise OT::FormError, "Already signed up" if @strategy_result.success?
54
+ end
55
+ end
56
+
57
+ # After - CORRECT
58
+ class CreateAccount < Logic::Base
59
+ def raise_concerns
60
+ # Check if user already in session
61
+ raise OT::FormError, "Already signed up" if @strategy_result.authenticated?
62
+ end
63
+ end
64
+ ```
65
+
66
+ #### Post-Login Redirect
67
+
68
+ ```ruby
69
+ # Before - unreliable
70
+ class AuthController
71
+ def authenticate
72
+ if @strategy_result.success? # Always true, not helpful
73
+ redirect_to dashboard_path
74
+ end
75
+ end
76
+ end
77
+
78
+ # After - correct semantic
79
+ class AuthController
80
+ def authenticate
81
+ if @strategy_result.auth_attempt_succeeded?
82
+ # Only redirect when auth route just succeeded
83
+ redirect_to dashboard_path
84
+ end
85
+ end
86
+ end
87
+ ```
88
+
89
+ ## Non-Breaking Enhancements
90
+
91
+ ### 1. Comprehensive Documentation
92
+
93
+ `StrategyResult` now includes extensive inline documentation:
94
+ - Usage patterns and creation guidelines
95
+ - Session contract for multi-app architectures
96
+ - Examples for common scenarios
97
+ - Clear distinction between request state and auth outcomes
98
+
99
+ ### 2. Session Contract (Multi-App Architectures)
100
+
101
+ For shared session architectures (Auth app + Core app):
102
+
103
+ **Required session keys for authenticated state:**
104
+ ```ruby
105
+ session['authenticated'] # Boolean flag
106
+ session['identity_id'] # User/customer ID
107
+ session['authenticated_at'] # Timestamp
108
+ ```
109
+
110
+ **Optional session keys:**
111
+ ```ruby
112
+ session['email'] # User email
113
+ session['ip_address'] # Client IP (masked by default via IPPrivacyMiddleware)
114
+ session['user_agent'] # Client UA
115
+ session['locale'] # User locale
116
+ ```
117
+
118
+ **Advanced mode adds:**
119
+ ```ruby
120
+ session['account_external_id'] # Rodauth external_id
121
+ session['advanced_account_id'] # Rodauth account ID
122
+ ```
123
+
124
+ ## Application Code Updates Required
125
+
126
+ ### 1. Remove Manual StrategyResult Creation
127
+
128
+ **Anti-pattern identified:**
129
+ ```ruby
130
+ # BAD - Bypasses Otto's auth_method tracking
131
+ class Controller::Base
132
+ def _strategy_result
133
+ Otto::Security::Authentication::StrategyResult.new(
134
+ session: session,
135
+ user: cust,
136
+ auth_method: 'session', # Hardcoded - loses semantic meaning
137
+ metadata: { ip: req.masked_ip } # Uses masked IP (privacy by default)
138
+ )
139
+ end
140
+ end
141
+ ```
142
+
143
+ **Correct approach:**
144
+ ```ruby
145
+ # GOOD - Use RouteAuthWrapper-provided result
146
+ class Controller::Base
147
+ def strategy_result
148
+ req.env['otto.strategy_result'] # Created by RouteAuthWrapper
149
+ end
150
+ end
151
+
152
+ # Or for non-auth checks, use session directly
153
+ class Controller::Base
154
+ def current_user
155
+ return nil unless session['authenticated']
156
+ Customer.find(session['identity_id'])
157
+ end
158
+ end
159
+ ```
160
+
161
+ ### 2. Update Logic Classes
162
+
163
+ **Pattern to check for:**
164
+ ```ruby
165
+ # Search your codebase for these patterns:
166
+ grep -r "@strategy_result.success?" apps/
167
+ grep -r "@context.success?" apps/
168
+ grep -r "strategy_result&.success?" apps/
169
+ ```
170
+
171
+ **Update to:**
172
+ - Use `authenticated?` for "user in session" checks (registration, profile access, etc.)
173
+ - Use `auth_attempt_succeeded?` for "just logged in" checks (redirects, welcome messages, etc.)
174
+
175
+ ### 3. Test Updates
176
+
177
+ **RSpec matchers:**
178
+ ```ruby
179
+ # Before
180
+ expect(result).to be_success
181
+ expect(result).to be_failure
182
+
183
+ # After
184
+ expect(result).to be_a(Otto::Security::Authentication::StrategyResult)
185
+ ```
186
+
187
+ ## Architecture Clarifications
188
+
189
+ ### When StrategyResult is Created
190
+
191
+ 1. **Routes WITH `auth=...` requirement:**
192
+ - RouteAuthWrapper executes strategy
193
+ - Always returns `StrategyResult` (success or failure)
194
+ - RouteAuthWrapper returns 401/302 response on `AuthFailure`
195
+
196
+ 2. **Routes WITHOUT `auth=...` requirement:**
197
+ - No RouteAuthWrapper wrapping
198
+ - No `StrategyResult` created (routes without auth don't need it)
199
+
200
+ 3. **Auth app (Roda) routes:**
201
+ - Manually creates `StrategyResult` for Logic class compatibility
202
+ - Same interface as Otto controllers
203
+
204
+ ### Integration Boundaries
205
+
206
+ **Multi-app setup (Auth + Core + API):**
207
+ - **Shared:** Session middleware, Redis session, Logic classes, Customer model
208
+ - **Auth app:** Creates StrategyResult manually, uses Roda routing
209
+ - **Core/API apps:** StrategyResult from RouteAuthWrapper
210
+ - **Integration:** Pure session-based, no direct code calls between apps
211
+
212
+ ## Testing Your Migration
213
+
214
+ ### 1. Registration Flow Test
215
+
216
+ ```ruby
217
+ describe "CreateAccount" do
218
+ it "blocks registration when user already authenticated" do
219
+ strategy_result = Otto::Security::Authentication::StrategyResult.new(
220
+ session: { user_id: 123 },
221
+ user: { id: 123 },
222
+ auth_method: 'anonymous', # No auth route, but user in session
223
+ metadata: {}
224
+ )
225
+
226
+ logic = CreateAccount.new(strategy_result, params, 'en')
227
+
228
+ expect { logic.raise_concerns }.to raise_error(OT::FormError, /Already signed up/)
229
+ end
230
+ end
231
+ ```
232
+
233
+ ### 2. Auth Attempt Test
234
+
235
+ ```ruby
236
+ describe "LoginHandler" do
237
+ it "redirects after successful authentication" do
238
+ strategy_result = Otto::Security::Authentication::StrategyResult.new(
239
+ session: { user_id: 123 },
240
+ user: { id: 123 },
241
+ auth_method: 'session', # Auth route succeeded
242
+ metadata: {}
243
+ )
244
+
245
+ expect(strategy_result.authenticated?).to be true
246
+ expect(strategy_result.auth_attempt_succeeded?).to be true
247
+ end
248
+ end
249
+ ```
250
+
251
+ ## Checklist
252
+
253
+ - [ ] Remove all usage of `success?` and `failure?` methods
254
+ - [ ] Update registration flows to use `authenticated?`
255
+ - [ ] Update post-login flows to use `auth_attempt_succeeded?` if needed
256
+ - [ ] Remove manual `StrategyResult` creation in controllers
257
+ - [ ] Update test matchers from `be_success`/`be_failure` to type checks
258
+ - [ ] Verify session contract keys match across apps
259
+ - [ ] Run full test suite: `bundle exec rspec`
260
+ - [ ] Test registration while logged in (should be blocked)
261
+ - [ ] Test login redirect flow (should work correctly)
262
+
263
+ ## Configuration Updates
264
+
265
+ ### Authentication Login Path Configuration
266
+
267
+ When authentication fails for HTML requests, Otto redirects to a login page. You can configure this path:
268
+
269
+ ```ruby
270
+ # Initialize with login_path configuration
271
+ otto = Otto.new do
272
+ auth_config[:login_path] = '/auth/login' # Default: '/signin'
273
+ end
274
+
275
+ # Or configure after initialization
276
+ otto.auth_config[:login_path] = '/custom/login'
277
+ ```
278
+
279
+ **Note:** If not configured, the default fallback is `/signin`. Ensure this route exists or configure your actual login path to avoid 404 errors on authentication failures.
280
+
281
+ ## Additional Improvements in v2.0.0-pre2
282
+
283
+ ### Middleware Architecture Enhancements
284
+
285
+ **1. Renamed MCP ValidationMiddleware → SchemaValidationMiddleware**
286
+ - Resolves naming collision with `Otto::Security::ValidationMiddleware`
287
+ - `Otto::MCP::SchemaValidationMiddleware` now clearly indicates JSON schema validation
288
+ - `Otto::Security::ValidationMiddleware` remains for input sanitization
289
+
290
+ **Migration:**
291
+ ```ruby
292
+ # File renamed: lib/otto/mcp/validation.rb → lib/otto/mcp/schema_validation.rb
293
+ # Class renamed automatically if using Otto's MCP server
294
+ # No action needed for most users
295
+ ```
296
+
297
+ **2. Centralized Env Keys Documentation**
298
+ - New file: `lib/otto/env_keys.rb`
299
+ - Documents all `env['otto.*']` keys with types, setters, and users
300
+ - Includes usage examples and multi-app integration patterns
301
+ - Essential reference for custom middleware development
302
+
303
+ **3. RateLimitMiddleware Clarity**
304
+ - Added documentation clarifying it's a CONFIGURATOR, not enforcer
305
+ - Actual rate limiting happens in Rack::Attack middleware
306
+ - `call` method is explicitly a pass-through
307
+
308
+ **4. Middleware Order Enforcement**
309
+ - New method: `MiddlewareStack#validate_mcp_middleware_order`
310
+ - New method: `MiddlewareStack#add_with_position` for explicit ordering
311
+ - MCP Server uses explicit positioning: `position: :first` and `position: :last`
312
+ - Validates middleware order and warns if suboptimal
313
+ - Optimal: RateLimitMiddleware → TokenMiddleware → SchemaValidationMiddleware
314
+ - Validation runs automatically when MCP is enabled
315
+
316
+ **Usage Example:**
317
+ ```ruby
318
+ # Explicit positioning for clarity
319
+ middleware.add_with_position(
320
+ Otto::MCP::RateLimitMiddleware,
321
+ security_config,
322
+ position: :first # Ensures rate limiting runs first
323
+ )
324
+
325
+ middleware.add_with_position(
326
+ Otto::MCP::SchemaValidationMiddleware,
327
+ position: :last # Ensures validation runs last
328
+ )
329
+ ```
330
+
331
+ ## Questions?
332
+
333
+ Review the comprehensive inline documentation in:
334
+ - `lib/otto/security/authentication/strategy_result.rb` (lines 1-90) - Auth semantics
335
+ - `lib/otto/security/authentication/route_auth_wrapper.rb` - Auth handler wrapper
336
+ - `lib/otto/env_keys.rb` - Complete env key registry
337
+
338
+ The documentation includes detailed usage patterns, session contracts, and examples for common scenarios.
@@ -12,7 +12,6 @@ otto = Otto.new('routes')
12
12
  # Enable security features to demonstrate advanced route parameters
13
13
  otto.enable_csrf_protection!
14
14
  otto.enable_request_validation!
15
- otto.enable_authentication!
16
15
 
17
16
  # Load and configure authentication strategies
18
17
  AuthenticationSetup.configure(otto)
@@ -7,52 +7,37 @@ require_relative '../security/validator'
7
7
  require_relative '../security/authentication'
8
8
  require_relative '../security/rate_limiting'
9
9
  require_relative '../mcp/server'
10
+ require_relative 'freezable'
10
11
 
11
12
  class Otto
12
13
  module Core
13
14
  # Configuration module providing locale and application configuration methods
14
15
  module Configuration
16
+ include Otto::Core::Freezable
15
17
  def configure_locale(opts)
16
- # Start with global configuration
17
- global_config = self.class.global_config
18
- @locale_config = nil
19
-
20
- # Check if we have any locale configuration from any source
21
- has_global_locale = global_config && (global_config[:available_locales] || global_config[:default_locale])
18
+ # Check if we have any locale configuration
22
19
  has_direct_options = opts[:available_locales] || opts[:default_locale]
23
20
  has_legacy_config = opts[:locale_config]
24
21
 
25
- # Only create locale_config if we have configuration from somewhere
26
- return unless has_global_locale || has_direct_options || has_legacy_config
22
+ # Only create locale_config if we have configuration
23
+ return unless has_direct_options || has_legacy_config
27
24
 
28
- @locale_config = {}
25
+ # Initialize with direct options
26
+ available_locales = opts[:available_locales]
27
+ default_locale = opts[:default_locale]
29
28
 
30
- # Apply global configuration first
31
- if global_config && global_config[:available_locales]
32
- @locale_config[:available_locales] =
33
- global_config[:available_locales]
34
- end
35
- if global_config && global_config[:default_locale]
36
- @locale_config[:default_locale] =
37
- global_config[:default_locale]
29
+ # Legacy support: Configure locale if provided via locale_config hash
30
+ if opts[:locale_config]
31
+ locale_opts = opts[:locale_config]
32
+ available_locales ||= locale_opts[:available_locales] || locale_opts[:available]
33
+ default_locale ||= locale_opts[:default_locale] || locale_opts[:default]
38
34
  end
39
35
 
40
- # Apply direct instance options (these override global config)
41
- @locale_config[:available_locales] = opts[:available_locales] if opts[:available_locales]
42
- @locale_config[:default_locale] = opts[:default_locale] if opts[:default_locale]
43
-
44
- # Legacy support: Configure locale if provided in initialization options via locale_config hash
45
- return unless opts[:locale_config]
46
-
47
- locale_opts = opts[:locale_config]
48
- if locale_opts[:available_locales] || locale_opts[:available]
49
- @locale_config[:available_locales] =
50
- locale_opts[:available_locales] || locale_opts[:available]
51
- end
52
- return unless locale_opts[:default_locale] || locale_opts[:default]
53
-
54
- @locale_config[:default_locale] =
55
- locale_opts[:default_locale] || locale_opts[:default]
36
+ # Create Otto::Locale::Config instance
37
+ @locale_config = Otto::Locale::Config.new(
38
+ available_locales: available_locales,
39
+ default_locale: default_locale
40
+ )
56
41
  end
57
42
 
58
43
  def configure_security(opts)
@@ -86,7 +71,6 @@ class Otto
86
71
  # Enable authentication middleware if strategies are configured
87
72
  return unless opts[:auth_strategies] && !opts[:auth_strategies].empty?
88
73
 
89
- enable_authentication!
90
74
  end
91
75
 
92
76
  def configure_mcp(opts)
@@ -115,9 +99,14 @@ class Otto
115
99
  # default_locale: 'en'
116
100
  # )
117
101
  def configure(available_locales: nil, default_locale: nil)
118
- @locale_config ||= {}
119
- @locale_config[:available_locales] = available_locales if available_locales
120
- @locale_config[:default_locale] = default_locale if default_locale
102
+ ensure_not_frozen!
103
+
104
+ # Initialize locale_config if not already set
105
+ @locale_config ||= Otto::Locale::Config.new
106
+
107
+ # Update configuration
108
+ @locale_config.available_locales = available_locales if available_locales
109
+ @locale_config.default_locale = default_locale if default_locale
121
110
  end
122
111
 
123
112
  # Configure rate limiting settings.
@@ -134,6 +123,7 @@ class Otto
134
123
  # }
135
124
  # })
136
125
  def configure_rate_limiting(config)
126
+ ensure_not_frozen!
137
127
  @security_config.rate_limiting_config.merge!(config)
138
128
  end
139
129
 
@@ -143,20 +133,80 @@ class Otto
143
133
  # @param default_strategy [String] Default strategy to use when none specified
144
134
  # @example
145
135
  # otto.configure_auth_strategies({
146
- # 'publicly' => Otto::Security::Authentication::Strategies::PublicStrategy.new,
136
+ # 'noauth' => Otto::Security::Authentication::Strategies::NoAuthStrategy.new,
147
137
  # 'authenticated' => Otto::Security::Authentication::Strategies::SessionStrategy.new(session_key: 'user_id'),
148
138
  # 'role:admin' => Otto::Security::Authentication::Strategies::RoleStrategy.new(['admin']),
149
139
  # 'api_key' => Otto::Security::Authentication::Strategies::APIKeyStrategy.new(api_keys: ['secret123'])
150
140
  # })
151
- def configure_auth_strategies(strategies, default_strategy: 'publicly')
141
+ def configure_auth_strategies(strategies, default_strategy: 'noauth')
142
+ ensure_not_frozen!
152
143
  # Update existing @auth_config rather than creating a new one
153
144
  @auth_config[:auth_strategies] = strategies
154
145
  @auth_config[:default_auth_strategy] = default_strategy
155
146
 
156
- enable_authentication! unless strategies.empty?
157
147
  end
158
148
 
159
- private
149
+ # Freeze the application configuration to prevent runtime modifications.
150
+ # Called automatically at the end of initialization to ensure immutability.
151
+ #
152
+ # This prevents security-critical configuration from being modified after
153
+ # the application begins handling requests. Uses deep freezing to prevent
154
+ # both direct modification and modification through nested structures.
155
+ #
156
+ # @raise [RuntimeError] if configuration is already frozen
157
+ # @return [self]
158
+ def freeze_configuration!
159
+ if frozen_configuration?
160
+ Otto.logger.debug '[Otto::Configuration] Configuration already frozen, skipping' if Otto.debug
161
+ return self
162
+ end
163
+
164
+ Otto.logger.debug '[Otto::Configuration] Starting configuration freeze process' if Otto.debug
165
+
166
+ # Deep freeze configuration objects with memoization support
167
+ Otto.logger.debug '[Otto::Configuration] Freezing security_config' if Otto.debug
168
+ @security_config.deep_freeze! if @security_config.respond_to?(:deep_freeze!)
169
+
170
+ Otto.logger.debug '[Otto::Configuration] Freezing locale_config' if Otto.debug
171
+ @locale_config.deep_freeze! if @locale_config.respond_to?(:deep_freeze!)
172
+
173
+ Otto.logger.debug '[Otto::Configuration] Freezing middleware stack' if Otto.debug
174
+ @middleware.deep_freeze! if @middleware.respond_to?(:deep_freeze!)
175
+
176
+ # Deep freeze configuration hashes (recursively freezes nested structures)
177
+ Otto.logger.debug '[Otto::Configuration] Freezing auth_config hash' if Otto.debug
178
+ deep_freeze_value(@auth_config) if @auth_config
179
+
180
+ Otto.logger.debug '[Otto::Configuration] Freezing option hash' if Otto.debug
181
+ deep_freeze_value(@option) if @option
182
+
183
+ # Deep freeze route structures (prevent modification of nested hashes/arrays)
184
+ Otto.logger.debug '[Otto::Configuration] Freezing route structures' if Otto.debug
185
+ deep_freeze_value(@routes) if @routes
186
+ deep_freeze_value(@routes_literal) if @routes_literal
187
+ deep_freeze_value(@routes_static) if @routes_static
188
+ deep_freeze_value(@route_definitions) if @route_definitions
189
+
190
+ @configuration_frozen = true
191
+ Otto.logger.info '[Otto::Configuration] Configuration freeze completed successfully'
192
+
193
+ self
194
+ end
195
+
196
+ # Check if configuration is frozen
197
+ #
198
+ # @return [Boolean] true if configuration is frozen
199
+ def frozen_configuration?
200
+ @configuration_frozen == true
201
+ end
202
+
203
+ # Ensure configuration is not frozen before allowing mutations
204
+ #
205
+ # @raise [FrozenError] if configuration is frozen
206
+ def ensure_not_frozen!
207
+ raise FrozenError, 'Cannot modify frozen configuration' if frozen_configuration?
208
+ end
209
+
160
210
 
161
211
  def middleware_enabled?(middleware_class)
162
212
  # Only check the new middleware stack as the single source of truth
@@ -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