otto 2.0.0.pre1 → 2.0.0.pre2

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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +2 -1
  3. data/.github/workflows/claude-code-review.yml +1 -1
  4. data/.github/workflows/claude.yml +1 -1
  5. data/.rubocop.yml +4 -1
  6. data/CHANGELOG.rst +54 -6
  7. data/Gemfile +1 -1
  8. data/Gemfile.lock +19 -18
  9. data/docs/.gitignore +1 -0
  10. data/docs/migrating/v2.0.0-pre2.md +345 -0
  11. data/lib/otto/core/configuration.rb +2 -2
  12. data/lib/otto/core/middleware_stack.rb +80 -0
  13. data/lib/otto/core/router.rb +7 -6
  14. data/lib/otto/env_keys.rb +114 -0
  15. data/lib/otto/helpers/base.rb +2 -21
  16. data/lib/otto/helpers/response.rb +22 -0
  17. data/lib/otto/mcp/{validation.rb → schema_validation.rb} +3 -2
  18. data/lib/otto/mcp/server.rb +26 -13
  19. data/lib/otto/response_handlers/json.rb +6 -0
  20. data/lib/otto/route.rb +44 -48
  21. data/lib/otto/route_handlers/factory.rb +22 -9
  22. data/lib/otto/security/authentication/authentication_middleware.rb +29 -12
  23. data/lib/otto/security/authentication/failure_result.rb +15 -7
  24. data/lib/otto/security/authentication/route_auth_wrapper.rb +149 -0
  25. data/lib/otto/security/authentication/strategies/{public_strategy.rb → noauth_strategy.rb} +1 -1
  26. data/lib/otto/security/authentication/strategy_result.rb +129 -15
  27. data/lib/otto/security/authentication.rb +2 -2
  28. data/lib/otto/security/config.rb +0 -11
  29. data/lib/otto/security/configurator.rb +2 -2
  30. data/lib/otto/security/middleware/rate_limit_middleware.rb +19 -3
  31. data/lib/otto/version.rb +1 -1
  32. data/lib/otto.rb +2 -3
  33. data/otto.gemspec +2 -0
  34. metadata +26 -6
  35. data/changelog.d/20250911_235619_delano_next.rst +0 -28
  36. data/changelog.d/20250912_123055_delano_remove_ostruct.rst +0 -21
  37. data/changelog.d/20250912_175625_claude_delano_remove_ostruct.rst +0 -21
@@ -36,6 +36,86 @@ class Otto
36
36
  # Invalidate memoized middleware list
37
37
  @memoized_middleware_list = nil
38
38
  end
39
+
40
+ # Add middleware with position hint for optimal ordering
41
+ #
42
+ # @param middleware_class [Class] Middleware class
43
+ # @param args [Array] Middleware arguments
44
+ # @param position [Symbol, nil] Position hint (:first, :last, or nil for append)
45
+ # @option options [Symbol] :position Position hint (:first or :last)
46
+ def add_with_position(middleware_class, *args, position: nil, **options)
47
+ raise FrozenError, 'Cannot modify frozen middleware stack' if frozen?
48
+
49
+ # Check for identical configuration
50
+ existing_entry = @stack.find do |entry|
51
+ entry[:middleware] == middleware_class &&
52
+ entry[:args] == args &&
53
+ entry[:options] == options
54
+ end
55
+
56
+ return if existing_entry
57
+
58
+ entry = { middleware: middleware_class, args: args, options: options }
59
+
60
+ case position
61
+ when :first
62
+ @stack.unshift(entry)
63
+ when :last
64
+ @stack << entry
65
+ else
66
+ @stack << entry # Default append
67
+ end
68
+
69
+ @middleware_set.add(middleware_class)
70
+ @memoized_middleware_list = nil
71
+ end
72
+
73
+ # Validate MCP middleware ordering
74
+ #
75
+ # MCP middleware must be in security-optimal order:
76
+ # 1. RateLimitMiddleware (reject excessive requests early)
77
+ # 2. Auth middleware (validate credentials before parsing)
78
+ # 3. SchemaValidationMiddleware (expensive JSON schema validation last)
79
+ #
80
+ # @return [Array<String>] Warning messages if order is suboptimal
81
+ def validate_mcp_middleware_order
82
+ warnings = []
83
+
84
+ # PERFORMANCE NOTE: This implementation intentionally uses select + find_index
85
+ # rather than a single-pass approach. The filtered mcp_middlewares array is
86
+ # typically 0-3 items, making the performance difference unmeasurable.
87
+ # The current approach prioritizes readability over micro-optimization.
88
+ # Single-pass alternatives were considered but rejected as premature optimization.
89
+ mcp_middlewares = @stack.select do |entry|
90
+ [
91
+ Otto::MCP::RateLimitMiddleware,
92
+ Otto::MCP::Auth::TokenMiddleware,
93
+ Otto::MCP::SchemaValidationMiddleware,
94
+ ].include?(entry[:middleware])
95
+ end
96
+
97
+ return warnings if mcp_middlewares.size < 2
98
+
99
+ # Find positions
100
+ rate_limit_pos = mcp_middlewares.find_index { |e| e[:middleware] == Otto::MCP::RateLimitMiddleware }
101
+ auth_pos = mcp_middlewares.find_index { |e| e[:middleware] == Otto::MCP::Auth::TokenMiddleware }
102
+ validation_pos = mcp_middlewares.find_index { |e| e[:middleware] == Otto::MCP::SchemaValidationMiddleware }
103
+
104
+ # Check optimal order: rate_limit < auth < validation
105
+ if rate_limit_pos && auth_pos && rate_limit_pos > auth_pos
106
+ warnings << '[MCP Middleware] RateLimitMiddleware should come before TokenMiddleware for optimal performance'
107
+ end
108
+
109
+ if auth_pos && validation_pos && auth_pos > validation_pos
110
+ warnings << '[MCP Middleware] TokenMiddleware should come before SchemaValidationMiddleware for optimal performance'
111
+ end
112
+
113
+ if rate_limit_pos && validation_pos && rate_limit_pos > validation_pos
114
+ warnings << '[MCP Middleware] RateLimitMiddleware should come before SchemaValidationMiddleware for optimal performance'
115
+ end
116
+
117
+ warnings
118
+ end
39
119
  alias use add
40
120
  alias << add
41
121
 
@@ -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
@@ -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
@@ -0,0 +1,114 @@
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: AuthenticationMiddleware
40
+ # Used by: RouteHandlers, LogicClasses, Controllers
41
+ # Note: Always present (anonymous or authenticated)
42
+ STRATEGY_RESULT = 'otto.strategy_result'
43
+
44
+ # Authenticated user object (convenience accessor)
45
+ # Type: Hash, Custom User Object, or nil
46
+ # Set by: AuthenticationMiddleware (from strategy_result.user)
47
+ # Used by: Controllers, RouteHandlers
48
+ USER = 'otto.user'
49
+
50
+ # User-specific context (session, roles, permissions, etc.)
51
+ # Type: Hash
52
+ # Set by: AuthenticationMiddleware (from strategy_result.user_context)
53
+ # Used by: Controllers, Analytics
54
+ USER_CONTEXT = 'otto.user_context'
55
+
56
+ # =========================================================================
57
+ # SECURITY & CONFIGURATION
58
+ # =========================================================================
59
+
60
+ # Security configuration object
61
+ # Type: Otto::Security::Config
62
+ # Set by: Otto#initialize, SecurityConfig
63
+ # Used by: All security middleware (CSRF, Headers, Validation)
64
+ SECURITY_CONFIG = 'otto.security_config'
65
+
66
+ # =========================================================================
67
+ # LOCALIZATION (I18N)
68
+ # =========================================================================
69
+
70
+ # Resolved locale for current request
71
+ # Type: String (e.g., 'en', 'es', 'fr')
72
+ # Set by: LocaleMiddleware
73
+ # Used by: RouteHandlers, LogicClasses, Views
74
+ LOCALE = 'otto.locale'
75
+
76
+ # Locale configuration object
77
+ # Type: Otto::LocaleConfig
78
+ # Set by: LocaleMiddleware
79
+ # Used by: Locale resolution logic
80
+ LOCALE_CONFIG = 'otto.locale_config'
81
+
82
+ # Available locales for the application
83
+ # Type: Array<String>
84
+ # Set by: LocaleConfig
85
+ # Used by: Locale middleware, language switchers
86
+ AVAILABLE_LOCALES = 'otto.available_locales'
87
+
88
+ # Default/fallback locale
89
+ # Type: String
90
+ # Set by: LocaleConfig
91
+ # Used by: Locale middleware when resolution fails
92
+ DEFAULT_LOCALE = 'otto.default_locale'
93
+
94
+ # =========================================================================
95
+ # ERROR HANDLING
96
+ # =========================================================================
97
+
98
+ # Unique error ID for tracking/logging
99
+ # Type: String (hex format, e.g., '4ac47cb3a6d177ef')
100
+ # Set by: ErrorHandler, RouteHandlers
101
+ # Used by: Error responses, logging, support
102
+ ERROR_ID = 'otto.error_id'
103
+
104
+ # =========================================================================
105
+ # MCP (MODEL CONTEXT PROTOCOL)
106
+ # =========================================================================
107
+
108
+ # MCP HTTP endpoint path
109
+ # Type: String (default: '/_mcp')
110
+ # Set by: Otto::MCP::Server#enable!
111
+ # Used by: MCP middleware, SchemaValidationMiddleware
112
+ MCP_HTTP_ENDPOINT = 'otto.mcp_http_endpoint'
113
+ end
114
+ 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
@@ -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
@@ -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
@@ -7,6 +7,12 @@ class Otto
7
7
  # Handler for JSON responses
8
8
  class JSONHandler < BaseHandler
9
9
  def self.handle(result, response, context = {})
10
+ # If a redirect has already been set, don't override with JSON
11
+ # This allows controllers to conditionally redirect based on Accept header
12
+ if response.status&.between?(300, 399) && response['Location']
13
+ return
14
+ end
15
+
10
16
  response['Content-Type'] = 'application/json'
11
17
 
12
18
  # Determine the data to serialize
data/lib/otto/route.rb CHANGED
@@ -45,10 +45,10 @@ class Otto
45
45
  # "V2::Logic::AuthSession auth=authenticated response=redirect" (enhanced)
46
46
  # @raise [ArgumentError] if definition format is invalid or class name is unsafe
47
47
  def initialize(verb, path, definition)
48
- @pattern, @keys = *compile(path)
48
+ pattern, keys = *compile(path)
49
49
 
50
50
  # Create immutable route definition
51
- @route_definition = Otto::RouteDefinition.new(verb, path, definition, pattern: @pattern, keys: @keys)
51
+ @route_definition = Otto::RouteDefinition.new(verb, path, definition, pattern: pattern, keys: keys)
52
52
 
53
53
  # Resolve the class
54
54
  @klass = safe_const_get(@route_definition.klass_name)
@@ -87,52 +87,6 @@ class Otto
87
87
  @route_definition.options
88
88
  end
89
89
 
90
- private
91
-
92
- # Safely resolve a class name using Object.const_get with security validations
93
- # This replaces the previous eval() usage to prevent code injection attacks.
94
- #
95
- # Security features:
96
- # - Validates class name format (must start with capital letter)
97
- # - Prevents access to dangerous system classes
98
- # - Blocks relative class references (starting with ::)
99
- # - Provides clear error messages for debugging
100
- #
101
- # @param class_name [String] The class name to resolve
102
- # @return [Class] The resolved class
103
- # @raise [ArgumentError] if class name is invalid, forbidden, or not found
104
- def safe_const_get(class_name)
105
- # Validate class name format
106
- unless class_name.match?(/\A[A-Z][a-zA-Z0-9_]*(?:::[A-Z][a-zA-Z0-9_]*)*\z/)
107
- raise ArgumentError, "Invalid class name format: #{class_name}"
108
- end
109
-
110
- # Prevent dangerous class names
111
- forbidden_classes = %w[
112
- Kernel Module Class Object BasicObject
113
- File Dir IO Process System
114
- Binding Proc Method UnboundMethod
115
- Thread ThreadGroup Fiber
116
- ObjectSpace GC
117
- ]
118
-
119
- if forbidden_classes.include?(class_name) || class_name.start_with?('::')
120
- raise ArgumentError, "Forbidden class name: #{class_name}"
121
- end
122
-
123
- begin
124
- Object.const_get(class_name)
125
- rescue NameError => e
126
- raise ArgumentError, "Class not found: #{class_name} - #{e.message}"
127
- end
128
- end
129
-
130
- public
131
-
132
- def pattern_regexp
133
- Regexp.new(@path.gsub('/*', '/.+'))
134
- end
135
-
136
90
  # Execute the route by calling the associated class method
137
91
  #
138
92
  # This method handles the complete request/response cycle with built-in security:
@@ -218,6 +172,48 @@ class Otto
218
172
 
219
173
  private
220
174
 
175
+ # Safely resolve a class name using Object.const_get with security validations
176
+ # This replaces the previous eval() usage to prevent code injection attacks.
177
+ #
178
+ # Security features:
179
+ # - Validates class name format (must start with capital letter)
180
+ # - Prevents access to dangerous system classes
181
+ # - Blocks relative class references (starting with ::)
182
+ # - Provides clear error messages for debugging
183
+ #
184
+ # @param class_name [String] The class name to resolve
185
+ # @return [Class] The resolved class
186
+ # @raise [ArgumentError] if class name is invalid, forbidden, or not found
187
+ def safe_const_get(class_name)
188
+ # Validate class name format
189
+ unless class_name.match?(/\A[A-Z][a-zA-Z0-9_]*(?:::[A-Z][a-zA-Z0-9_]*)*\z/)
190
+ raise ArgumentError, "Invalid class name format: #{class_name}"
191
+ end
192
+
193
+ # Remove any leading :: then add exactly one
194
+ fq_class_name = "::#{class_name.sub(/^::+/, '')}"
195
+
196
+ # Prevent dangerous class names
197
+ forbidden_classes = %w[
198
+ Kernel Module Class Object BasicObject
199
+ File Dir IO Process System
200
+ Binding Proc Method UnboundMethod
201
+ Thread ThreadGroup Fiber
202
+ ObjectSpace GC
203
+ ]
204
+
205
+ if forbidden_classes.include?(class_name) || class_name.start_with?('::')
206
+ raise ArgumentError, "Forbidden class name: #{class_name}"
207
+ end
208
+
209
+ begin
210
+ # Always guarantee exactly two leading colons
211
+ Object.const_get(fq_class_name)
212
+ rescue NameError => e
213
+ raise ArgumentError, "Class not found: #{fq_class_name} - #{e.message}"
214
+ end
215
+ end
216
+
221
217
  def compile(path)
222
218
  keys = []
223
219
 
@@ -13,16 +13,29 @@ class Otto
13
13
  # @param otto_instance [Otto] The Otto instance for configuration access
14
14
  # @return [BaseHandler] Appropriate handler for the route
15
15
  def self.create_handler(route_definition, otto_instance = nil)
16
- case route_definition.kind
17
- when :logic
18
- LogicClassHandler.new(route_definition, otto_instance)
19
- when :instance
20
- InstanceMethodHandler.new(route_definition, otto_instance)
21
- when :class
22
- ClassMethodHandler.new(route_definition, otto_instance)
23
- else
24
- raise ArgumentError, "Unknown handler kind: #{route_definition.kind}"
16
+ # Create base handler based on route kind
17
+ handler = case route_definition.kind
18
+ when :logic
19
+ LogicClassHandler.new(route_definition, otto_instance)
20
+ when :instance
21
+ InstanceMethodHandler.new(route_definition, otto_instance)
22
+ when :class
23
+ ClassMethodHandler.new(route_definition, otto_instance)
24
+ else
25
+ raise ArgumentError, "Unknown handler kind: #{route_definition.kind}"
26
+ end
27
+
28
+ # Wrap with auth enforcement if route has auth requirement
29
+ if route_definition.auth_requirement && otto_instance&.auth_config
30
+ require_relative '../security/authentication/route_auth_wrapper'
31
+ handler = Otto::Security::Authentication::RouteAuthWrapper.new(
32
+ handler,
33
+ route_definition,
34
+ otto_instance.auth_config
35
+ )
25
36
  end
37
+
38
+ handler
26
39
  end
27
40
  end
28
41
  end
@@ -2,7 +2,8 @@
2
2
 
3
3
  require_relative 'strategy_result'
4
4
  require_relative 'failure_result'
5
- require_relative 'strategies/public_strategy'
5
+ require_relative 'route_auth_wrapper'
6
+ require_relative 'strategies/noauth_strategy'
6
7
  require_relative 'strategies/role_strategy'
7
8
  require_relative 'strategies/permission_strategy'
8
9
 
@@ -16,10 +17,10 @@ class Otto
16
17
  @security_config = security_config
17
18
  @config = config
18
19
  @strategies = config[:auth_strategies] || {}
19
- @default_strategy = config[:default_auth_strategy] || 'publicly'
20
+ @default_strategy = config[:default_auth_strategy] || 'noauth'
20
21
 
21
- # Add default public strategy if not provided
22
- @strategies['publicly'] ||= Strategies::PublicStrategy.new
22
+ # Add default noauth strategy if not provided
23
+ @strategies['noauth'] ||= Strategies::NoAuthStrategy.new
23
24
  end
24
25
 
25
26
  def call(env)
@@ -51,15 +52,10 @@ class Otto
51
52
  # Perform authentication
52
53
  strategy_result = strategy.authenticate(env, auth_requirement)
53
54
 
54
- if strategy_result&.success?
55
- # Success - store the strategy result directly
56
- env['otto.strategy_result'] = strategy_result
57
- env['otto.user'] = strategy_result.user # For convenience
58
- env['otto.user_context'] = strategy_result.user_context # For convenience
59
- @app.call(env)
60
- else
55
+ # Check result type: FailureResult indicates auth failure, StrategyResult indicates success
56
+ if strategy_result.is_a?(Otto::Security::Authentication::FailureResult)
61
57
  # Failure - create anonymous result with failure info
62
- failure_reason = strategy_result&.failure_reason || 'Authentication failed'
58
+ failure_reason = strategy_result.failure_reason || 'Authentication failed'
63
59
  env['otto.strategy_result'] = Otto::Security::Authentication::StrategyResult.anonymous(
64
60
  metadata: {
65
61
  ip: env['REMOTE_ADDR'],
@@ -68,6 +64,23 @@ class Otto
68
64
  }
69
65
  )
70
66
  auth_error_response(failure_reason)
67
+ else
68
+ # Success - store the strategy result directly
69
+ env['otto.strategy_result'] = strategy_result
70
+
71
+ # SESSION PERSISTENCE: This assignment is INTENTIONAL, not a merge operation.
72
+ # We must ensure env['rack.session'] and strategy_result.session reference
73
+ # the SAME object so that:
74
+ # 1. Logic classes write to strategy_result.session
75
+ # 2. Rack's session middleware persists env['rack.session']
76
+ # 3. Changes from (1) are included in (2)
77
+ #
78
+ # Using merge! instead would break this - the objects must be identical.
79
+ # See commit ed7fa0d for the bug this fixes.
80
+ env['rack.session'] = strategy_result.session if strategy_result.session
81
+ env['otto.user'] = strategy_result.user # For convenience
82
+ env['otto.user_context'] = strategy_result.user_context # For convenience
83
+ @app.call(env)
71
84
  end
72
85
  end
73
86
 
@@ -109,6 +122,10 @@ class Otto
109
122
  }
110
123
 
111
124
  # Add security headers if available from config hash or Otto instance
125
+ # NOTE: Extracting this to a method was considered but rejected.
126
+ # This logic appears only once and is clear in context. Extraction would
127
+ # add ~10 lines (method def + docs) for a 5-line single-use block without
128
+ # improving readability. Consider extracting if this pattern is duplicated.
112
129
  if @config.is_a?(Hash) && @config[:security_headers]
113
130
  headers.merge!(@config[:security_headers])
114
131
  elsif @config.respond_to?(:security_config) && @config.security_config