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
data/Gemfile.lock CHANGED
@@ -1,8 +1,10 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- otto (2.0.0.pre2)
4
+ otto (2.0.0.pre3)
5
+ concurrent-ruby (~> 1.3, < 2.0)
5
6
  facets (~> 3.1)
7
+ ipaddr (~> 1, < 2.0)
6
8
  logger (~> 1, < 2.0)
7
9
  loofah (~> 2.20)
8
10
  rack (~> 3.1, < 4.0)
@@ -13,6 +15,7 @@ GEM
13
15
  remote: https://rubygems.org/
14
16
  specs:
15
17
  ast (2.4.3)
18
+ benchmark (0.4.1)
16
19
  bigdecimal (3.2.3)
17
20
  concurrent-ruby (1.3.5)
18
21
  crass (1.0.6)
@@ -21,10 +24,11 @@ GEM
21
24
  irb (~> 1.10)
22
25
  reline (>= 0.3.8)
23
26
  diff-lcs (1.6.2)
24
- erb (5.0.2)
27
+ erb (5.1.1)
25
28
  facets (3.1.0)
26
29
  hana (1.3.7)
27
30
  io-console (0.8.1)
31
+ ipaddr (1.2.7)
28
32
  irb (1.15.2)
29
33
  pp (>= 0.6.0)
30
34
  rdoc (>= 4.0.0)
@@ -41,7 +45,7 @@ GEM
41
45
  loofah (2.24.1)
42
46
  crass (~> 1.0.2)
43
47
  nokogiri (>= 1.12.0)
44
- minitest (5.25.5)
48
+ minitest (5.26.0)
45
49
  nokogiri (1.18.10-aarch64-linux-gnu)
46
50
  racc (~> 1.4)
47
51
  nokogiri (1.18.10-aarch64-linux-musl)
@@ -64,7 +68,7 @@ GEM
64
68
  racc
65
69
  pastel (0.8.0)
66
70
  tty-color (~> 0.5)
67
- pp (0.6.2)
71
+ pp (0.6.3)
68
72
  prettyprint
69
73
  prettier_print (1.2.1)
70
74
  prettyprint (0.2.0)
@@ -73,7 +77,7 @@ GEM
73
77
  date
74
78
  stringio
75
79
  racc (1.8.1)
76
- rack (3.2.2)
80
+ rack (3.2.3)
77
81
  rack-attack (6.7.0)
78
82
  rack (>= 1.0, < 4)
79
83
  rack-parser (0.7.0)
@@ -85,9 +89,10 @@ GEM
85
89
  rainbow (3.1.1)
86
90
  rbs (3.9.5)
87
91
  logger
88
- rdoc (6.14.2)
92
+ rdoc (6.15.0)
89
93
  erb
90
94
  psych (>= 4.0.0)
95
+ tsort
91
96
  regexp_parser (2.11.3)
92
97
  reline (0.6.2)
93
98
  io-console (~> 0.5)
@@ -104,7 +109,7 @@ GEM
104
109
  rspec-mocks (3.13.5)
105
110
  diff-lcs (>= 1.2.0, < 2.0)
106
111
  rspec-support (~> 3.13.0)
107
- rspec-support (3.13.5)
112
+ rspec-support (3.13.6)
108
113
  rubocop (1.81.1)
109
114
  json (~> 2.3)
110
115
  language_server-protocol (~> 3.17.0.2)
@@ -140,15 +145,16 @@ GEM
140
145
  stringio (3.1.7)
141
146
  syntax_tree (6.3.0)
142
147
  prettier_print (>= 1.2.0)
143
- tryouts (3.6.0)
148
+ tryouts (3.6.1)
144
149
  concurrent-ruby (~> 1.0)
145
150
  irb
146
151
  minitest (~> 5.0)
147
152
  pastel (~> 0.8)
148
153
  prism (~> 1.0)
149
- rspec (~> 3.0)
154
+ rspec (>= 3.0, < 5.0)
150
155
  tty-cursor (~> 0.7)
151
156
  tty-screen (~> 0.8)
157
+ tsort (0.2.0)
152
158
  tty-color (0.6.0)
153
159
  tty-cursor (0.7.1)
154
160
  tty-screen (0.8.2)
@@ -167,6 +173,7 @@ PLATFORMS
167
173
  x86_64-linux-musl
168
174
 
169
175
  DEPENDENCIES
176
+ benchmark
170
177
  debug
171
178
  json_schemer
172
179
  otto!
@@ -181,7 +188,7 @@ DEPENDENCIES
181
188
  ruby-lsp
182
189
  stackprof
183
190
  syntax_tree
184
- tryouts (~> 3.6.0)
191
+ tryouts (~> 3.6.1)
185
192
 
186
193
  BUNDLED WITH
187
194
  2.7.1
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Benchmark to measure real-world Otto performance with actual routes and middleware
5
+ # Usage: ruby benchmark_middleware_wrap.rb
6
+
7
+ require 'bundler/setup'
8
+ require 'benchmark'
9
+ require 'stringio'
10
+ require 'tempfile'
11
+
12
+ require_relative 'lib/otto'
13
+
14
+ REQUEST_COUNT = 50_000
15
+ MIDDLEWARE_COUNT = 40
16
+
17
+ # Create a temporary routes file
18
+ routes_content = <<~ROUTES
19
+ GET / TestApp.index
20
+ GET /users/:id TestApp.show
21
+ POST /users TestApp.create
22
+ GET /health TestApp.health
23
+ ROUTES
24
+
25
+ routes_file = Tempfile.new(['routes', '.txt'])
26
+ routes_file.write(routes_content)
27
+ routes_file.close
28
+
29
+ # Define test application
30
+ class TestApp
31
+ def self.index(_env, _params = {})
32
+ [200, { 'Content-Type' => 'text/html' }, ['Welcome']]
33
+ end
34
+
35
+ def self.show(env, params = {})
36
+ user_id = params[:id] || env.dig('otto.params', :id) || '123'
37
+ [200, { 'Content-Type' => 'text/html' }, ["User #{user_id}"]]
38
+ end
39
+
40
+ def self.create(_env, _params = {})
41
+ [201, { 'Content-Type' => 'application/json' }, ['{"id": 123}']]
42
+ end
43
+
44
+ def self.health(_env, _params = {})
45
+ [200, { 'Content-Type' => 'text/plain' }, ['OK']]
46
+ end
47
+ end
48
+
49
+ # Create real Rack middleware
50
+ class BenchmarkMiddleware
51
+ def initialize(app, _config = nil)
52
+ @app = app
53
+ end
54
+
55
+ def call(env)
56
+ @app.call(env)
57
+ end
58
+ end
59
+
60
+ # Create Otto instance with real configuration
61
+ otto = Otto.new(routes_file.path)
62
+
63
+ # Add real Otto security middleware
64
+ otto.enable_csrf_protection!
65
+ otto.enable_request_validation!
66
+
67
+ # Add custom middleware to reach target count
68
+ current_count = otto.middleware.size
69
+ (MIDDLEWARE_COUNT - current_count).times do
70
+ otto.use(Class.new(BenchmarkMiddleware))
71
+ end
72
+
73
+ # Suppress error logging for benchmark
74
+ Otto.logger.level = Logger::FATAL
75
+
76
+ puts "\n" + ("=" * 70)
77
+ puts "Otto Performance Benchmark"
78
+ puts ("=" * 70)
79
+ puts "Configuration:"
80
+ puts " Routes: #{otto.instance_variable_get(:@route_definitions).size}"
81
+ actual_app = otto.instance_variable_get(:@app)
82
+ puts " Middleware: #{otto.middleware.size} (#{MIDDLEWARE_COUNT} total in stack, app built: #{!actual_app.nil?})"
83
+ puts " Requests: #{REQUEST_COUNT.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse}"
84
+ puts ("=" * 70)
85
+
86
+ # Create realistic Rack environments for different routes
87
+ def make_env(method, path)
88
+ {
89
+ 'REQUEST_METHOD' => method,
90
+ 'PATH_INFO' => path,
91
+ 'QUERY_STRING' => '',
92
+ 'SERVER_NAME' => 'example.com',
93
+ 'SERVER_PORT' => '80',
94
+ 'rack.version' => [1, 3],
95
+ 'rack.url_scheme' => 'http',
96
+ 'rack.input' => StringIO.new,
97
+ 'rack.errors' => StringIO.new,
98
+ 'rack.multithread' => false,
99
+ 'rack.multiprocess' => true,
100
+ 'rack.run_once' => false,
101
+ 'REMOTE_ADDR' => '192.168.1.100',
102
+ 'HTTP_USER_AGENT' => 'Benchmark/1.0',
103
+ 'rack.session' => {}
104
+ }
105
+ end
106
+
107
+ # Test different routes
108
+ routes = [
109
+ ['GET', '/'],
110
+ ['GET', '/users/123'],
111
+ ['POST', '/users'],
112
+ ['GET', '/health']
113
+ ]
114
+
115
+ # Warmup
116
+ puts "\nWarming up (1,000 requests)..."
117
+ 1_000.times do |i|
118
+ method, path = routes[i % routes.size]
119
+ env = make_env(method, path)
120
+ otto.call(env)
121
+ end
122
+
123
+ puts "\n" + ("=" * 70)
124
+ puts "Running benchmark..."
125
+ puts ("=" * 70)
126
+
127
+ # Benchmark
128
+ result = Benchmark.measure do
129
+ REQUEST_COUNT.times do |i|
130
+ method, path = routes[i % routes.size]
131
+ env = make_env(method, path)
132
+ otto.call(env)
133
+ end
134
+ end
135
+
136
+ total_time = result.real
137
+ per_request = (total_time / REQUEST_COUNT * 1_000_000).round(2)
138
+ requests_per_sec = (REQUEST_COUNT / total_time).round(0)
139
+
140
+ puts "\nResults:"
141
+ puts ("=" * 70)
142
+ puts " Total time: #{(total_time * 1000).round(2)}ms"
143
+ puts " Time per request: #{per_request}µs"
144
+ puts " Requests/sec: #{requests_per_sec.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse}"
145
+ puts ("=" * 70)
146
+
147
+ # Performance analysis
148
+ puts "\nPerformance Analysis:"
149
+ if per_request < 20
150
+ puts " ✓ Excellent performance (< 20µs per request)"
151
+ elsif per_request < 50
152
+ puts " ✓ Good performance (< 50µs per request)"
153
+ elsif per_request < 100
154
+ puts " ~ Acceptable performance (< 100µs per request)"
155
+ else
156
+ puts " ⚠ May need optimization (#{per_request}µs per request)"
157
+ end
158
+
159
+ puts "\nMiddleware overhead: ~#{((per_request - 2.5) / MIDDLEWARE_COUNT).round(3)}µs per middleware"
160
+ puts
161
+
162
+ # Cleanup
163
+ routes_file.unlink
@@ -0,0 +1,36 @@
1
+ Changed
2
+ -------
3
+
4
+ - Authentication now handled by RouteAuthWrapper at handler level instead of middleware
5
+ - RouteAuthWrapper enhanced with session persistence, security headers, strategy caching, and sophisticated pattern matching
6
+ - env['otto.strategy_result'] now GUARANTEED to be present on all routes (authenticated or anonymous)
7
+ - RouteAuthWrapper now wraps all route handlers, not just routes with auth requirements
8
+
9
+ Removed
10
+ -------
11
+
12
+ - Removed AuthenticationMiddleware (architecturally broken - executed before routing)
13
+ - Removed enable_authentication! (no longer needed - RouteAuthWrapper handles auth automatically)
14
+ - Removed defensive nil fallback from LogicClassHandler (no longer needed)
15
+
16
+ Fixed
17
+ -----
18
+
19
+ - Session persistence now works correctly (env['rack.session'] references same object as strategy_result.session)
20
+ - Security headers now included on all authentication failure responses (401/302)
21
+ - Strategy lookups now cached for performance
22
+ - env['otto.strategy_result'] is now guaranteed to be present (anonymous StrategyResult for public routes)
23
+ - Routes without auth requirements now get anonymous StrategyResult with IP metadata
24
+
25
+ Security
26
+ --------
27
+
28
+ - Authentication strategies now execute after routing when route_definition is available
29
+ - Supports exact match, prefix match (role:admin), and fallback patterns for strategies
30
+
31
+ Documentation
32
+ -------------
33
+
34
+ - Updated CLAUDE.md with RouteAuthWrapper architecture overview
35
+ - Updated env_keys.rb to document guaranteed presence of strategy_result
36
+ - Added comprehensive tests for anonymous route handling
@@ -0,0 +1,5 @@
1
+ Changed
2
+ -------
3
+
4
+ - Renamed MiddlewareStack#build_app to #wrap to better reflect per-request behavior
5
+ (wraps base app in middleware layers on each request, not a one-time initialization)
data/docs/.gitignore CHANGED
@@ -2,3 +2,4 @@
2
2
  !.gitignore
3
3
  !migrating/
4
4
  !migrating/*.md
5
+ !ipaddr-encoding-quirk.md
@@ -0,0 +1,34 @@
1
+ # IPAddr#to_s Encoding Quirk in Ruby 3
2
+
3
+ Ruby's `IPAddr#to_s` returns inconsistent encodings: IPv4 addresses use US-ASCII, IPv6 addresses use UTF-8.
4
+
5
+ ## Behavior
6
+
7
+ ```ruby
8
+ IPAddr.new('192.168.1.1').to_s.encoding # => #<Encoding:US-ASCII>
9
+ IPAddr.new('::1').to_s.encoding # => #<Encoding:UTF-8>
10
+ ```
11
+
12
+ ## Cause
13
+
14
+ Different string construction in IPAddr's `_to_string` method:
15
+
16
+ - **IPv4**: `Array#join('.')` → US-ASCII optimization
17
+ - **IPv6**: `String#%` → UTF-8 default
18
+
19
+ ## Impact
20
+
21
+ - Rack expects UTF-8 strings
22
+ - Mixed encodings cause `Encoding::CompatibilityError`
23
+ - String operations fail on encoding mismatches
24
+
25
+ ## Solution
26
+
27
+ Use `force_encoding('UTF-8')` instead of `encode('UTF-8')`:
28
+
29
+ - IP addresses contain only ASCII characters
30
+ - ASCII bytes are identical in US-ASCII and UTF-8
31
+ - `force_encoding` changes label only (O(1))
32
+ - `encode` creates new string (O(n))
33
+
34
+ This ensures consistent UTF-8 encoding across all IP strings.
@@ -27,12 +27,6 @@ if strategy_result.is_a?(Otto::Security::Authentication::StrategyResult)
27
27
  # handle success
28
28
  end
29
29
 
30
- # Or for middleware - FailureResult indicates failure
31
- if strategy_result.is_a?(Otto::Security::Authentication::FailureResult)
32
- # handle failure
33
- else
34
- # handle success
35
- end
36
30
  ```
37
31
 
38
32
  ### 2. New Semantic Distinction
@@ -116,7 +110,7 @@ session['authenticated_at'] # Timestamp
116
110
  **Optional session keys:**
117
111
  ```ruby
118
112
  session['email'] # User email
119
- session['ip_address'] # Client IP
113
+ session['ip_address'] # Client IP (masked by default via IPPrivacyMiddleware)
120
114
  session['user_agent'] # Client UA
121
115
  session['locale'] # User locale
122
116
  ```
@@ -140,7 +134,7 @@ class Controller::Base
140
134
  session: session,
141
135
  user: cust,
142
136
  auth_method: 'session', # Hardcoded - loses semantic meaning
143
- metadata: { ip: req.client_ipaddress }
137
+ metadata: { ip: req.masked_ip } # Uses masked IP (privacy by default)
144
138
  )
145
139
  end
146
140
  end
@@ -148,10 +142,10 @@ end
148
142
 
149
143
  **Correct approach:**
150
144
  ```ruby
151
- # GOOD - Use middleware-provided result
145
+ # GOOD - Use RouteAuthWrapper-provided result
152
146
  class Controller::Base
153
147
  def strategy_result
154
- req.env['otto.strategy_result'] # Created by AuthenticationMiddleware
148
+ req.env['otto.strategy_result'] # Created by RouteAuthWrapper
155
149
  end
156
150
  end
157
151
 
@@ -188,7 +182,6 @@ expect(result).to be_failure
188
182
 
189
183
  # After
190
184
  expect(result).to be_a(Otto::Security::Authentication::StrategyResult)
191
- expect(result).to be_a(Otto::Security::Authentication::FailureResult)
192
185
  ```
193
186
 
194
187
  ## Architecture Clarifications
@@ -196,13 +189,13 @@ expect(result).to be_a(Otto::Security::Authentication::FailureResult)
196
189
  ### When StrategyResult is Created
197
190
 
198
191
  1. **Routes WITH `auth=...` requirement:**
199
- - Strategy executes
200
- - Returns `StrategyResult` (success) or `FailureResult` (failure)
201
- - Middleware converts `FailureResult` to anonymous `StrategyResult` + 401 response
192
+ - RouteAuthWrapper executes strategy
193
+ - Always returns `StrategyResult` (success or failure)
194
+ - RouteAuthWrapper returns 401/302 response on `AuthFailure`
202
195
 
203
196
  2. **Routes WITHOUT `auth=...` requirement:**
204
- - Middleware creates anonymous `StrategyResult`
205
- - Sets `auth_method: 'anonymous'`
197
+ - No RouteAuthWrapper wrapping
198
+ - No `StrategyResult` created (routes without auth don't need it)
206
199
 
207
200
  3. **Auth app (Roda) routes:**
208
201
  - Manually creates `StrategyResult` for Logic class compatibility
@@ -213,7 +206,7 @@ expect(result).to be_a(Otto::Security::Authentication::FailureResult)
213
206
  **Multi-app setup (Auth + Core + API):**
214
207
  - **Shared:** Session middleware, Redis session, Logic classes, Customer model
215
208
  - **Auth app:** Creates StrategyResult manually, uses Roda routing
216
- - **Core/API apps:** StrategyResult from AuthenticationMiddleware
209
+ - **Core/API apps:** StrategyResult from RouteAuthWrapper
217
210
  - **Integration:** Pure session-based, no direct code calls between apps
218
211
 
219
212
  ## Testing Your Migration
@@ -339,7 +332,7 @@ middleware.add_with_position(
339
332
 
340
333
  Review the comprehensive inline documentation in:
341
334
  - `lib/otto/security/authentication/strategy_result.rb` (lines 1-90) - Auth semantics
342
- - `lib/otto/security/authentication/authentication_middleware.rb` - Auth middleware
335
+ - `lib/otto/security/authentication/route_auth_wrapper.rb` - Auth handler wrapper
343
336
  - `lib/otto/env_keys.rb` - Complete env key registry
344
337
 
345
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
 
@@ -149,14 +139,74 @@ class Otto
149
139
  # 'api_key' => Otto::Security::Authentication::Strategies::APIKeyStrategy.new(api_keys: ['secret123'])
150
140
  # })
151
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