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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +2 -3
- data/.github/workflows/claude-code-review.yml +30 -14
- data/.github/workflows/claude.yml +1 -1
- data/.rubocop.yml +4 -1
- data/CHANGELOG.rst +54 -6
- data/CLAUDE.md +537 -0
- data/Gemfile +3 -2
- data/Gemfile.lock +34 -26
- data/benchmark_middleware_wrap.rb +163 -0
- data/changelog.d/20251014_144317_delano_54_thats_a_wrapper.rst +36 -0
- data/changelog.d/20251014_161526_delano_54_thats_a_wrapper.rst +5 -0
- data/docs/.gitignore +2 -0
- data/docs/ipaddr-encoding-quirk.md +34 -0
- data/docs/migrating/v2.0.0-pre2.md +338 -0
- data/examples/authentication_strategies/config.ru +0 -1
- data/lib/otto/core/configuration.rb +91 -41
- data/lib/otto/core/freezable.rb +93 -0
- data/lib/otto/core/middleware_stack.rb +103 -16
- data/lib/otto/core/router.rb +8 -7
- data/lib/otto/core.rb +8 -0
- data/lib/otto/env_keys.rb +118 -0
- data/lib/otto/helpers/base.rb +2 -21
- data/lib/otto/helpers/request.rb +80 -2
- data/lib/otto/helpers/response.rb +25 -3
- data/lib/otto/helpers.rb +4 -0
- data/lib/otto/locale/config.rb +56 -0
- data/lib/otto/mcp/{validation.rb → schema_validation.rb} +3 -2
- data/lib/otto/mcp/server.rb +26 -13
- data/lib/otto/mcp.rb +3 -0
- data/lib/otto/privacy/config.rb +199 -0
- data/lib/otto/privacy/geo_resolver.rb +115 -0
- data/lib/otto/privacy/ip_privacy.rb +175 -0
- data/lib/otto/privacy/redacted_fingerprint.rb +136 -0
- data/lib/otto/privacy.rb +29 -0
- data/lib/otto/response_handlers/json.rb +6 -0
- data/lib/otto/route.rb +44 -48
- data/lib/otto/route_handlers/base.rb +1 -2
- data/lib/otto/route_handlers/factory.rb +24 -9
- data/lib/otto/route_handlers/logic_class.rb +2 -2
- data/lib/otto/security/authentication/auth_failure.rb +44 -0
- data/lib/otto/security/authentication/auth_strategy.rb +3 -3
- data/lib/otto/security/authentication/route_auth_wrapper.rb +260 -0
- data/lib/otto/security/authentication/strategies/{public_strategy.rb → noauth_strategy.rb} +6 -2
- data/lib/otto/security/authentication/strategy_result.rb +129 -15
- data/lib/otto/security/authentication.rb +5 -6
- data/lib/otto/security/config.rb +51 -18
- data/lib/otto/security/configurator.rb +2 -15
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +211 -0
- data/lib/otto/security/middleware/rate_limit_middleware.rb +19 -3
- data/lib/otto/security.rb +9 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +183 -89
- data/otto.gemspec +5 -0
- metadata +83 -8
- data/changelog.d/20250911_235619_delano_next.rst +0 -28
- data/changelog.d/20250912_123055_delano_remove_ostruct.rst +0 -21
- data/changelog.d/20250912_175625_claude_delano_remove_ostruct.rst +0 -21
- data/lib/otto/security/authentication/authentication_middleware.rb +0 -123
- 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
|
-
#
|
|
37
|
-
@
|
|
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
|
-
#
|
|
55
|
-
@
|
|
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
|
-
#
|
|
71
|
-
@
|
|
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
|
|
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
|
-
#
|
|
191
|
+
# Returns list of middleware classes in order
|
|
101
192
|
def middleware_list
|
|
102
|
-
|
|
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::
|
|
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
|
data/lib/otto/core/router.rb
CHANGED
|
@@ -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).
|
|
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 "
|
|
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,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
|
data/lib/otto/helpers/base.rb
CHANGED
|
@@ -5,26 +5,7 @@
|
|
|
5
5
|
class Otto
|
|
6
6
|
# Base helper methods providing core functionality for Otto applications
|
|
7
7
|
module BaseHelpers
|
|
8
|
-
#
|
|
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
|
data/lib/otto/helpers/request.rb
CHANGED
|
@@ -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
|
-
|
|
18
|
-
|
|
17
|
+
secure: true,
|
|
18
|
+
httponly: true,
|
|
19
19
|
same_site: :strict,
|
|
20
|
-
|
|
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
|
data/lib/otto/helpers.rb
ADDED
|
@@ -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/
|
|
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
|
-
|
|
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
|
data/lib/otto/mcp/server.rb
CHANGED
|
@@ -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 '
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
|
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 (
|
|
81
|
+
# Configure validation last (explicit position for clarity)
|
|
76
82
|
return unless @enable_validation
|
|
77
83
|
|
|
78
|
-
|
|
79
|
-
|
|
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
|