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
@@ -2,6 +2,8 @@
2
2
 
3
3
  # lib/otto/core/middleware_stack.rb
4
4
 
5
+ require_relative 'freezable'
6
+
5
7
  class Otto
6
8
  module Core
7
9
  # Enhanced middleware stack management for Otto framework.
@@ -9,10 +11,18 @@ class Otto
9
11
  # and improved execution chain management.
10
12
  class MiddlewareStack
11
13
  include Enumerable
14
+ include Otto::Core::Freezable
12
15
 
13
16
  def initialize
14
17
  @stack = []
15
18
  @middleware_set = Set.new
19
+ @on_change_callback = nil
20
+ end
21
+
22
+ # Set a callback to be invoked when the middleware stack changes
23
+ # @param callback [Proc] A callable object (e.g., method or lambda)
24
+ def on_change(&callback)
25
+ @on_change_callback = callback
16
26
  end
17
27
 
18
28
  # Enhanced middleware registration with argument uniqueness and immutability check
@@ -33,8 +43,89 @@ class Otto
33
43
  entry = { middleware: middleware_class, args: args, options: options }
34
44
  @stack << entry
35
45
  @middleware_set.add(middleware_class)
36
- # Invalidate memoized middleware list
37
- @memoized_middleware_list = nil
46
+ # Notify of change
47
+ @on_change_callback&.call
48
+ end
49
+
50
+ # Add middleware with position hint for optimal ordering
51
+ #
52
+ # @param middleware_class [Class] Middleware class
53
+ # @param args [Array] Middleware arguments
54
+ # @param position [Symbol, nil] Position hint (:first, :last, or nil for append)
55
+ # @option options [Symbol] :position Position hint (:first or :last)
56
+ def add_with_position(middleware_class, *args, position: nil, **options)
57
+ raise FrozenError, 'Cannot modify frozen middleware stack' if frozen?
58
+
59
+ # Check for identical configuration
60
+ existing_entry = @stack.find do |entry|
61
+ entry[:middleware] == middleware_class &&
62
+ entry[:args] == args &&
63
+ entry[:options] == options
64
+ end
65
+
66
+ return if existing_entry
67
+
68
+ entry = { middleware: middleware_class, args: args, options: options }
69
+
70
+ case position
71
+ when :first
72
+ @stack.unshift(entry)
73
+ when :last
74
+ @stack << entry
75
+ else
76
+ @stack << entry # Default append
77
+ end
78
+
79
+ @middleware_set.add(middleware_class)
80
+ # Notify of change
81
+ @on_change_callback&.call
82
+ end
83
+
84
+ # Validate MCP middleware ordering
85
+ #
86
+ # MCP middleware must be in security-optimal order:
87
+ # 1. RateLimitMiddleware (reject excessive requests early)
88
+ # 2. Auth middleware (validate credentials before parsing)
89
+ # 3. SchemaValidationMiddleware (expensive JSON schema validation last)
90
+ #
91
+ # @return [Array<String>] Warning messages if order is suboptimal
92
+ def validate_mcp_middleware_order
93
+ warnings = []
94
+
95
+ # PERFORMANCE NOTE: This implementation intentionally uses select + find_index
96
+ # rather than a single-pass approach. The filtered mcp_middlewares array is
97
+ # typically 0-3 items, making the performance difference unmeasurable.
98
+ # The current approach prioritizes readability over micro-optimization.
99
+ # Single-pass alternatives were considered but rejected as premature optimization.
100
+ mcp_middlewares = @stack.select do |entry|
101
+ [
102
+ Otto::MCP::RateLimitMiddleware,
103
+ Otto::MCP::Auth::TokenMiddleware,
104
+ Otto::MCP::SchemaValidationMiddleware,
105
+ ].include?(entry[:middleware])
106
+ end
107
+
108
+ return warnings if mcp_middlewares.size < 2
109
+
110
+ # Find positions
111
+ rate_limit_pos = mcp_middlewares.find_index { |e| e[:middleware] == Otto::MCP::RateLimitMiddleware }
112
+ auth_pos = mcp_middlewares.find_index { |e| e[:middleware] == Otto::MCP::Auth::TokenMiddleware }
113
+ validation_pos = mcp_middlewares.find_index { |e| e[:middleware] == Otto::MCP::SchemaValidationMiddleware }
114
+
115
+ # Check optimal order: rate_limit < auth < validation
116
+ if rate_limit_pos && auth_pos && rate_limit_pos > auth_pos
117
+ warnings << '[MCP Middleware] RateLimitMiddleware should come before TokenMiddleware for optimal performance'
118
+ end
119
+
120
+ if auth_pos && validation_pos && auth_pos > validation_pos
121
+ warnings << '[MCP Middleware] TokenMiddleware should come before SchemaValidationMiddleware for optimal performance'
122
+ end
123
+
124
+ if rate_limit_pos && validation_pos && rate_limit_pos > validation_pos
125
+ warnings << '[MCP Middleware] RateLimitMiddleware should come before SchemaValidationMiddleware for optimal performance'
126
+ end
127
+
128
+ warnings
38
129
  end
39
130
  alias use add
40
131
  alias << add
@@ -51,8 +142,8 @@ class Otto
51
142
 
52
143
  # Rebuild the set of unique middleware classes
53
144
  @middleware_set = Set.new(@stack.map { |entry| entry[:middleware] })
54
- # Invalidate memoized middleware list
55
- @memoized_middleware_list = nil
145
+ # Notify of change
146
+ @on_change_callback&.call
56
147
  end
57
148
 
58
149
  # Check if middleware is registered - now O(1) using Set
@@ -67,8 +158,8 @@ class Otto
67
158
 
68
159
  @stack.clear
69
160
  @middleware_set.clear
70
- # Invalidate memoized middleware list
71
- @memoized_middleware_list = nil
161
+ # Notify of change
162
+ @on_change_callback&.call
72
163
  end
73
164
 
74
165
  # Enumerable support
@@ -77,7 +168,7 @@ class Otto
77
168
  end
78
169
 
79
170
  # Build Rack application with middleware chain
80
- def build_app(base_app, security_config = nil)
171
+ def wrap(base_app, security_config = nil)
81
172
  @stack.reduce(base_app) do |app, entry|
82
173
  middleware = entry[:middleware]
83
174
  args = entry[:args]
@@ -97,10 +188,9 @@ class Otto
97
188
  end
98
189
  end
99
190
 
100
- # Cached middleware list to reduce array creation
191
+ # Returns list of middleware classes in order
101
192
  def middleware_list
102
- # Memoize the result to avoid repeated array creation
103
- @memoized_middleware_list ||= @stack.map { |entry| entry[:middleware] }
193
+ @stack.map { |entry| entry[:middleware] }
104
194
  end
105
195
 
106
196
  # Detailed introspection
@@ -135,6 +225,8 @@ class Otto
135
225
  @stack.reverse_each(&)
136
226
  end
137
227
 
228
+
229
+
138
230
  private
139
231
 
140
232
  def middleware_needs_config?(middleware_class)
@@ -144,12 +236,7 @@ class Otto
144
236
  Otto::Security::Middleware::CSRFMiddleware,
145
237
  Otto::Security::Middleware::ValidationMiddleware,
146
238
  Otto::Security::Middleware::RateLimitMiddleware,
147
- Otto::Security::Authentication::AuthenticationMiddleware,
148
- # Backward compatibility aliases
149
- Otto::Security::CSRFMiddleware,
150
- Otto::Security::ValidationMiddleware,
151
- Otto::Security::RateLimitMiddleware,
152
- Otto::Security::AuthenticationMiddleware,
239
+ Otto::Security::Middleware::IPPrivacyMiddleware,
153
240
  ].include?(middleware_class)
154
241
  end
155
242
  end
@@ -12,7 +12,7 @@ class Otto
12
12
  path = File.expand_path(path)
13
13
  raise ArgumentError, "Bad path: #{path}" unless File.exist?(path)
14
14
 
15
- raw = File.readlines(path).select { |line| line =~ /^\w/ }.collect { |line| line.strip }
15
+ raw = File.readlines(path).grep(/^\w/).collect(&:strip)
16
16
  raw.each do |entry|
17
17
  # Enhanced parsing: split only on first two whitespace boundaries
18
18
  # This preserves parameters in the definition part
@@ -25,13 +25,9 @@ class Otto
25
25
 
26
26
  # Check for MCP routes
27
27
  if Otto::MCP::RouteParser.is_mcp_route?(definition)
28
- raise '[MCP] MCP server not enabled' unless @mcp_server
29
-
30
28
  handle_mcp_route(verb, path, definition)
31
29
  next
32
30
  elsif Otto::MCP::RouteParser.is_tool_route?(definition)
33
- raise '[MCP] MCP server not enabled' unless @mcp_server
34
-
35
31
  handle_tool_route(verb, path, definition)
36
32
  next
37
33
  end
@@ -46,7 +42,8 @@ class Otto
46
42
  @routes_literal[route.verb] ||= {}
47
43
  @routes_literal[route.verb][path_clean] = route
48
44
  rescue StandardError => e
49
- Otto.logger.error "Bad route in #{path}: #{entry} (Error: #{e.message})"
45
+ Otto.logger.error "Error for route #{path}: #{e.message}"
46
+ Otto.logger.debug e.backtrace.join("\n") if Otto.debug
50
47
  end
51
48
  self
52
49
  end
@@ -54,7 +51,7 @@ class Otto
54
51
  def handle_request(env)
55
52
  locale = determine_locale env
56
53
  env['rack.locale'] = locale
57
- env['otto.locale_config'] = @locale_config if @locale_config
54
+ env['otto.locale_config'] = @locale_config.to_h if @locale_config
58
55
  @static_route ||= Rack::Files.new(option[:public]) if option[:public] && safe_dir?(option[:public])
59
56
  path_info = Rack::Utils.unescape(env['PATH_INFO'])
60
57
  path_info = '/' if path_info.to_s.empty?
@@ -164,6 +161,8 @@ class Otto
164
161
  end
165
162
 
166
163
  def handle_mcp_route(verb, path, definition)
164
+ raise '[MCP] MCP server not enabled' unless @mcp_server
165
+
167
166
  route_info = Otto::MCP::RouteParser.parse_mcp_route(verb, path, definition)
168
167
  @mcp_server.register_mcp_route(route_info)
169
168
  Otto.logger.debug "[MCP] Registered resource route: #{definition}" if Otto.debug
@@ -172,6 +171,8 @@ class Otto
172
171
  end
173
172
 
174
173
  def handle_tool_route(verb, path, definition)
174
+ raise '[MCP] MCP server not enabled' unless @mcp_server
175
+
175
176
  route_info = Otto::MCP::RouteParser.parse_tool_route(verb, path, definition)
176
177
  @mcp_server.register_mcp_route(route_info)
177
178
  Otto.logger.debug "[MCP] Registered tool route: #{definition}" if Otto.debug
data/lib/otto/core.rb ADDED
@@ -0,0 +1,8 @@
1
+ # lib/otto/core.rb
2
+
3
+ require_relative 'core/router'
4
+ require_relative 'core/file_safety'
5
+ require_relative 'core/configuration'
6
+ require_relative 'core/error_handler'
7
+ require_relative 'core/uri_generator'
8
+ require_relative 'core/middleware_stack'
@@ -0,0 +1,118 @@
1
+ # lib/otto/env_keys.rb
2
+ #
3
+ # Central registry of all env['otto.*'] keys used throughout Otto framework.
4
+ # This documentation helps prevent key conflicts and aids multi-app integration.
5
+ #
6
+ # DOCUMENTATION-ONLY MODULE: The constants defined here are intentionally NOT used
7
+ # in the codebase. Otto uses string literals (e.g., env['otto.strategy_result'])
8
+ # for readibility/simplicity. This module exists as reference documentation but
9
+ # may be considered for future use if needed.
10
+ #
11
+ class Otto
12
+ # Rack environment keys used by Otto framework
13
+ #
14
+ # All Otto-specific keys are namespaced under 'otto.*' to avoid conflicts
15
+ # with other Rack middleware or applications.
16
+ module EnvKeys
17
+ # =========================================================================
18
+ # ROUTING & REQUEST FLOW
19
+ # =========================================================================
20
+
21
+ # Route definition parsed from routes file
22
+ # Type: Otto::RouteDefinition
23
+ # Set by: Otto::Core::Router#parse_routes
24
+ # Used by: AuthenticationMiddleware, RouteHandlers, LogicClassHandler
25
+ ROUTE_DEFINITION = 'otto.route_definition'
26
+
27
+ # Route-specific options parsed from route string
28
+ # Type: Hash (e.g., { response: 'json', csrf: 'exempt', auth: 'authenticated' })
29
+ # Set by: Otto::RouteDefinition#initialize
30
+ # Used by: CSRFMiddleware, RouteHandlers
31
+ ROUTE_OPTIONS = 'otto.route_options'
32
+
33
+ # =========================================================================
34
+ # AUTHENTICATION & AUTHORIZATION
35
+ # =========================================================================
36
+
37
+ # Authentication strategy result containing session/user state
38
+ # Type: Otto::Security::Authentication::StrategyResult
39
+ # Set by: RouteAuthWrapper (wraps all route handlers)
40
+ # Used by: RouteHandlers, LogicClasses, Controllers
41
+ # Guarantee: ALWAYS present - either authenticated or anonymous
42
+ # - Routes WITH auth requirement: Authenticated StrategyResult or 401/302
43
+ # - Routes WITHOUT auth requirement: Anonymous StrategyResult
44
+ STRATEGY_RESULT = 'otto.strategy_result'
45
+
46
+ # Authenticated user object (convenience accessor)
47
+ # Type: Hash, Custom User Object, or nil
48
+ # Set by: RouteAuthWrapper (from strategy_result.user)
49
+ # Used by: Controllers, RouteHandlers
50
+ # Note: nil for anonymous/unauthenticated requests
51
+ USER = 'otto.user'
52
+
53
+ # User-specific context (session, roles, permissions, etc.)
54
+ # Type: Hash
55
+ # Set by: RouteAuthWrapper (from strategy_result.user_context)
56
+ # Used by: Controllers, Analytics
57
+ # Note: Empty hash {} for anonymous requests
58
+ USER_CONTEXT = 'otto.user_context'
59
+
60
+ # =========================================================================
61
+ # SECURITY & CONFIGURATION
62
+ # =========================================================================
63
+
64
+ # Security configuration object
65
+ # Type: Otto::Security::Config
66
+ # Set by: Otto#initialize, SecurityConfig
67
+ # Used by: All security middleware (CSRF, Headers, Validation)
68
+ SECURITY_CONFIG = 'otto.security_config'
69
+
70
+ # =========================================================================
71
+ # LOCALIZATION (I18N)
72
+ # =========================================================================
73
+
74
+ # Resolved locale for current request
75
+ # Type: String (e.g., 'en', 'es', 'fr')
76
+ # Set by: LocaleMiddleware
77
+ # Used by: RouteHandlers, LogicClasses, Views
78
+ LOCALE = 'otto.locale'
79
+
80
+ # Locale configuration object
81
+ # Type: Otto::LocaleConfig
82
+ # Set by: LocaleMiddleware
83
+ # Used by: Locale resolution logic
84
+ LOCALE_CONFIG = 'otto.locale_config'
85
+
86
+ # Available locales for the application
87
+ # Type: Array<String>
88
+ # Set by: LocaleConfig
89
+ # Used by: Locale middleware, language switchers
90
+ AVAILABLE_LOCALES = 'otto.available_locales'
91
+
92
+ # Default/fallback locale
93
+ # Type: String
94
+ # Set by: LocaleConfig
95
+ # Used by: Locale middleware when resolution fails
96
+ DEFAULT_LOCALE = 'otto.default_locale'
97
+
98
+ # =========================================================================
99
+ # ERROR HANDLING
100
+ # =========================================================================
101
+
102
+ # Unique error ID for tracking/logging
103
+ # Type: String (hex format, e.g., '4ac47cb3a6d177ef')
104
+ # Set by: ErrorHandler, RouteHandlers
105
+ # Used by: Error responses, logging, support
106
+ ERROR_ID = 'otto.error_id'
107
+
108
+ # =========================================================================
109
+ # MCP (MODEL CONTEXT PROTOCOL)
110
+ # =========================================================================
111
+
112
+ # MCP HTTP endpoint path
113
+ # Type: String (default: '/_mcp')
114
+ # Set by: Otto::MCP::Server#enable!
115
+ # Used by: MCP middleware, SchemaValidationMiddleware
116
+ MCP_HTTP_ENDPOINT = 'otto.mcp_http_endpoint'
117
+ end
118
+ end
@@ -5,26 +5,7 @@
5
5
  class Otto
6
6
  # Base helper methods providing core functionality for Otto applications
7
7
  module BaseHelpers
8
- # Build application path by joining path segments
9
- #
10
- # This method safely joins multiple path segments, handling
11
- # duplicate slashes and ensuring proper path formatting.
12
- # Includes the script name (mount point) as the first segment.
13
- #
14
- # @param paths [Array<String>] Path segments to join
15
- # @return [String] Properly formatted path
16
- #
17
- # @example
18
- # app_path('api', 'v1', 'users')
19
- # # => "/myapp/api/v1/users"
20
- #
21
- # @example
22
- # app_path(['admin', 'settings'])
23
- # # => "/myapp/admin/settings"
24
- def app_path(*paths)
25
- paths = paths.flatten.compact
26
- paths.unshift(env['SCRIPT_NAME']) if env['SCRIPT_NAME']
27
- paths.join('/').gsub('//', '/')
28
- end
8
+ # Keep only truly context-independent shared functionality here
9
+ # Methods requiring env access should be implemented in the specific helper modules
29
10
  end
30
11
  end
@@ -1,5 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/helpers/request.rb
4
2
 
5
3
  require_relative 'base'
@@ -13,6 +11,86 @@ class Otto
13
11
  env['HTTP_USER_AGENT']
14
12
  end
15
13
 
14
+ # NOTE: We do NOT override Rack::Request#ip
15
+ #
16
+ # IPPrivacyMiddleware masks both REMOTE_ADDR and X-Forwarded-For headers,
17
+ # so Rack's native ip resolution logic works correctly with masked values.
18
+ # This allows Rack to handle proxy scenarios (trusted proxies, header parsing)
19
+ # while still returning privacy-safe masked IPs.
20
+ #
21
+ # If you need the masked IP explicitly, use:
22
+ # req.masked_ip # => '192.168.1.0' or nil if privacy disabled
23
+ #
24
+ # If you need the geo country:
25
+ # req.geo_country # => 'US' or nil
26
+ #
27
+ # If you need the full privacy fingerprint:
28
+ # req.redacted_fingerprint # => RedactedFingerprint object or nil
29
+
30
+ # Get the privacy-safe fingerprint for this request
31
+ #
32
+ # Returns nil if IP privacy is disabled. The fingerprint contains
33
+ # anonymized request information suitable for logging and analytics.
34
+ #
35
+ # @return [Otto::Privacy::RedactedFingerprint, nil] Privacy-safe fingerprint
36
+ # @example
37
+ # fingerprint = req.redacted_fingerprint
38
+ # fingerprint.masked_ip # => '192.168.1.0'
39
+ # fingerprint.country # => 'US'
40
+ def redacted_fingerprint
41
+ env['otto.redacted_fingerprint']
42
+ end
43
+
44
+ # Get the geo-location country code for the request
45
+ #
46
+ # Returns ISO 3166-1 alpha-2 country code or 'XX' for unknown.
47
+ # Only available when IP privacy is enabled (default).
48
+ #
49
+ # @return [String, nil] Country code or nil if privacy disabled
50
+ # @example
51
+ # req.geo_country # => 'US'
52
+ def geo_country
53
+ redacted_fingerprint&.country || env['otto.geo_country']
54
+ end
55
+
56
+ # Get anonymized user agent string
57
+ #
58
+ # Returns user agent with version numbers stripped for privacy.
59
+ # Only available when IP privacy is enabled (default).
60
+ #
61
+ # @return [String, nil] Anonymized user agent or nil
62
+ # @example
63
+ # req.anonymized_user_agent
64
+ # # => 'Mozilla/X.X (Windows NT X.X; Win64; x64) AppleWebKit/X.X'
65
+ def anonymized_user_agent
66
+ redacted_fingerprint&.anonymized_ua
67
+ end
68
+
69
+ # Get masked IP address
70
+ #
71
+ # Returns privacy-safe masked IP. When privacy is enabled (default),
72
+ # this returns the masked version. When disabled, returns original IP.
73
+ #
74
+ # @return [String, nil] Masked or original IP address
75
+ # @example
76
+ # req.masked_ip # => '192.168.1.0'
77
+ def masked_ip
78
+ env['otto.masked_ip'] || env['REMOTE_ADDR']
79
+ end
80
+
81
+ # Get hashed IP for session correlation
82
+ #
83
+ # Returns daily-rotating hash of the IP address, allowing session
84
+ # tracking without storing the original IP. Only available when
85
+ # IP privacy is enabled (default).
86
+ #
87
+ # @return [String, nil] Hexadecimal hash string or nil
88
+ # @example
89
+ # req.hashed_ip # => 'a3f8b2c4d5e6f7...'
90
+ def hashed_ip
91
+ redacted_fingerprint&.hashed_ip || env['otto.hashed_ip']
92
+ end
93
+
16
94
  def client_ipaddress
17
95
  remote_addr = env['REMOTE_ADDR']
18
96
 
@@ -14,10 +14,10 @@ class Otto
14
14
  def send_secure_cookie(name, value, ttl, opts = {})
15
15
  # Default security options
16
16
  defaults = {
17
- secure: true,
18
- httponly: true,
17
+ secure: true,
18
+ httponly: true,
19
19
  same_site: :strict,
20
- path: '/',
20
+ path: '/',
21
21
  }
22
22
 
23
23
  # Merge with provided options
@@ -152,5 +152,27 @@ class Otto
152
152
  headers['expires'] = 'Mon, 7 Nov 2011 00:00:00 UTC'
153
153
  headers['pragma'] = 'no-cache'
154
154
  end
155
+
156
+ # Build application path by joining path segments
157
+ #
158
+ # This method safely joins multiple path segments, handling
159
+ # duplicate slashes and ensuring proper path formatting.
160
+ # Includes the script name (mount point) as the first segment.
161
+ #
162
+ # @param paths [Array<String>] Path segments to join
163
+ # @return [String] Properly formatted path
164
+ #
165
+ # @example
166
+ # app_path('api', 'v1', 'users')
167
+ # # => "/myapp/api/v1/users"
168
+ #
169
+ # @example
170
+ # app_path(['admin', 'settings'])
171
+ # # => "/myapp/admin/settings"
172
+ def app_path(*paths)
173
+ paths = paths.flatten.compact
174
+ paths.unshift(request.env['SCRIPT_NAME']) if request.env['SCRIPT_NAME']
175
+ paths.join('/').gsub('//', '/')
176
+ end
155
177
  end
156
178
  end
@@ -0,0 +1,4 @@
1
+ # lib/otto/helpers.rb
2
+
3
+ require_relative 'helpers/request'
4
+ require_relative 'helpers/response'
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/otto/locale/config.rb
4
+
5
+ require_relative '../core/freezable'
6
+
7
+ class Otto
8
+ module Locale
9
+ # Locale configuration for Otto applications
10
+ #
11
+ # This class manages locale-related settings including available locales
12
+ # and default locale selection.
13
+ #
14
+ # @example Basic usage
15
+ # config = Otto::Locale::Config.new
16
+ # config.available_locales = { 'en' => 'English', 'es' => 'Spanish' }
17
+ # config.default_locale = 'en'
18
+ #
19
+ # @example With initialization
20
+ # config = Otto::Locale::Config.new(
21
+ # available_locales: { 'en' => 'English', 'fr' => 'French' },
22
+ # default_locale: 'en'
23
+ # )
24
+ class Config
25
+ include Otto::Core::Freezable
26
+
27
+ attr_accessor :available_locales, :default_locale
28
+
29
+ # Initialize locale configuration
30
+ #
31
+ # @param available_locales [Hash, nil] Hash of locale codes to names
32
+ # @param default_locale [String, nil] Default locale code
33
+ def initialize(available_locales: nil, default_locale: nil)
34
+ @available_locales = available_locales
35
+ @default_locale = default_locale
36
+ end
37
+
38
+ # Convert to hash for compatibility with existing code
39
+ #
40
+ # @return [Hash] Hash representation of configuration
41
+ def to_h
42
+ {
43
+ available_locales: @available_locales,
44
+ default_locale: @default_locale,
45
+ }.compact
46
+ end
47
+
48
+ # Check if locale configuration is present
49
+ #
50
+ # @return [Boolean] true if either available_locales or default_locale is set
51
+ def configured?
52
+ !@available_locales.nil? || !@default_locale.nil?
53
+ end
54
+ end
55
+ end
56
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # lib/otto/mcp/validation.rb
3
+ # lib/otto/mcp/schema_validation.rb
4
4
 
5
5
  require 'json'
6
6
 
@@ -67,7 +67,8 @@ class Otto
67
67
  end
68
68
 
69
69
  # Middleware for validating MCP protocol requests using JSON schema
70
- class ValidationMiddleware
70
+ # Validates JSON-RPC 2.0 structure and tool argument schemas
71
+ class SchemaValidationMiddleware
71
72
  def initialize(app, _security_config = nil)
72
73
  @app = app
73
74
  @validator = Validator.new
@@ -6,7 +6,7 @@ require_relative 'protocol'
6
6
  require_relative 'registry'
7
7
  require_relative 'route_parser'
8
8
  require_relative 'auth/token'
9
- require_relative 'validation'
9
+ require_relative 'schema_validation'
10
10
  require_relative 'rate_limiting'
11
11
 
12
12
  class Otto
@@ -53,30 +53,43 @@ class Otto
53
53
  private
54
54
 
55
55
  def configure_middleware(_options)
56
- # Configure middleware in security-optimal order:
57
- # 1. Rate limiting (reject excessive requests early)
58
- # 2. Authentication (validate credentials before parsing)
59
- # 3. Validation (expensive JSON schema validation last)
56
+ # Configure middleware in security-optimal order using explicit positioning:
57
+ # 1. Rate limiting (reject excessive requests early) - position: :first
58
+ # 2. Authentication (validate credentials before parsing) - default append
59
+ # 3. Validation (expensive JSON schema validation last) - position: :last
60
60
 
61
- # Configure rate limiting first
61
+ middleware = @otto_instance.instance_variable_get(:@middleware)
62
+
63
+ # Configure rate limiting first (explicit position for clarity)
62
64
  if @enable_rate_limiting
63
- @otto_instance.use Otto::MCP::RateLimitMiddleware, @otto_instance.security_config
64
- Otto.logger.debug '[MCP] Rate limiting enabled' if Otto.debug
65
+ middleware.add_with_position(
66
+ Otto::MCP::RateLimitMiddleware,
67
+ @otto_instance.security_config,
68
+ position: :first
69
+ )
70
+ Otto.logger.debug '[MCP] Rate limiting enabled (position: first)' if Otto.debug
65
71
  end
66
72
 
67
- # Configure authentication second
73
+ # Configure authentication second (default append order)
68
74
  if @auth_tokens.any?
69
- @auth = Otto::MCP::Auth::TokenAuth.new(@auth_tokens)
75
+ @auth = Otto::MCP::Auth::TokenAuth.new(@auth_tokens)
70
76
  @otto_instance.security_config.mcp_auth = @auth
71
77
  @otto_instance.use Otto::MCP::Auth::TokenMiddleware
72
78
  Otto.logger.debug '[MCP] Token authentication enabled' if Otto.debug
73
79
  end
74
80
 
75
- # Configure validation last (most expensive)
81
+ # Configure validation last (explicit position for clarity)
76
82
  return unless @enable_validation
77
83
 
78
- @otto_instance.use Otto::MCP::ValidationMiddleware
79
- Otto.logger.debug '[MCP] Request validation enabled' if Otto.debug
84
+ middleware.add_with_position(
85
+ Otto::MCP::SchemaValidationMiddleware,
86
+ position: :last
87
+ )
88
+ Otto.logger.debug '[MCP] Schema validation enabled (position: last)' if Otto.debug
89
+
90
+ # Validate middleware order (should pass with explicit positioning)
91
+ warnings = middleware.validate_mcp_middleware_order
92
+ warnings.each { |warning| Otto.logger.warn warning }
80
93
  end
81
94
 
82
95
  def add_mcp_endpoint_route