otto 1.6.0 → 2.0.0.pre1

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 (136) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +1 -1
  3. data/.github/workflows/claude-code-review.yml +53 -0
  4. data/.github/workflows/claude.yml +49 -0
  5. data/.gitignore +3 -0
  6. data/.rubocop.yml +24 -345
  7. data/CHANGELOG.rst +83 -0
  8. data/CLAUDE.md +56 -0
  9. data/Gemfile +10 -3
  10. data/Gemfile.lock +23 -28
  11. data/README.md +2 -0
  12. data/bin/rspec +4 -4
  13. data/changelog.d/20250911_235619_delano_next.rst +28 -0
  14. data/changelog.d/20250912_123055_delano_remove_ostruct.rst +21 -0
  15. data/changelog.d/20250912_175625_claude_delano_remove_ostruct.rst +21 -0
  16. data/changelog.d/README.md +120 -0
  17. data/changelog.d/scriv.ini +5 -0
  18. data/docs/.gitignore +1 -0
  19. data/docs/migrating/v2.0.0-pre1.md +276 -0
  20. data/examples/.gitignore +1 -0
  21. data/examples/advanced_routes/README.md +33 -0
  22. data/examples/advanced_routes/app/controllers/handlers/async.rb +9 -0
  23. data/examples/advanced_routes/app/controllers/handlers/dynamic.rb +9 -0
  24. data/examples/advanced_routes/app/controllers/handlers/static.rb +9 -0
  25. data/examples/advanced_routes/app/controllers/modules/auth.rb +9 -0
  26. data/examples/advanced_routes/app/controllers/modules/transformer.rb +9 -0
  27. data/examples/advanced_routes/app/controllers/modules/validator.rb +9 -0
  28. data/examples/advanced_routes/app/controllers/routes_app.rb +232 -0
  29. data/examples/advanced_routes/app/controllers/v2/admin.rb +9 -0
  30. data/examples/advanced_routes/app/controllers/v2/config.rb +9 -0
  31. data/examples/advanced_routes/app/controllers/v2/settings.rb +9 -0
  32. data/examples/advanced_routes/app/logic/admin/logic/manager.rb +27 -0
  33. data/examples/advanced_routes/app/logic/admin/panel.rb +27 -0
  34. data/examples/advanced_routes/app/logic/analytics_processor.rb +25 -0
  35. data/examples/advanced_routes/app/logic/complex/business/handler.rb +27 -0
  36. data/examples/advanced_routes/app/logic/data_logic.rb +23 -0
  37. data/examples/advanced_routes/app/logic/data_processor.rb +25 -0
  38. data/examples/advanced_routes/app/logic/input_validator.rb +24 -0
  39. data/examples/advanced_routes/app/logic/nested/feature/logic.rb +27 -0
  40. data/examples/advanced_routes/app/logic/reports_generator.rb +27 -0
  41. data/examples/advanced_routes/app/logic/simple_logic.rb +25 -0
  42. data/examples/advanced_routes/app/logic/system/config/manager.rb +27 -0
  43. data/examples/advanced_routes/app/logic/test_logic.rb +23 -0
  44. data/examples/advanced_routes/app/logic/transform_logic.rb +23 -0
  45. data/examples/advanced_routes/app/logic/upload_logic.rb +23 -0
  46. data/examples/advanced_routes/app/logic/v2/logic/dashboard.rb +27 -0
  47. data/examples/advanced_routes/app/logic/v2/logic/processor.rb +27 -0
  48. data/examples/advanced_routes/app.rb +33 -0
  49. data/examples/advanced_routes/config.rb +23 -0
  50. data/examples/advanced_routes/config.ru +7 -0
  51. data/examples/advanced_routes/puma.rb +20 -0
  52. data/examples/advanced_routes/routes +167 -0
  53. data/examples/advanced_routes/run.rb +39 -0
  54. data/examples/advanced_routes/test.rb +58 -0
  55. data/examples/authentication_strategies/README.md +32 -0
  56. data/examples/authentication_strategies/app/auth.rb +68 -0
  57. data/examples/authentication_strategies/app/controllers/auth_controller.rb +29 -0
  58. data/examples/authentication_strategies/app/controllers/main_controller.rb +28 -0
  59. data/examples/authentication_strategies/config.ru +24 -0
  60. data/examples/authentication_strategies/routes +37 -0
  61. data/examples/basic/README.md +29 -0
  62. data/examples/basic/app.rb +7 -35
  63. data/examples/basic/routes +0 -9
  64. data/examples/mcp_demo/README.md +87 -0
  65. data/examples/mcp_demo/app.rb +29 -34
  66. data/examples/mcp_demo/config.ru +9 -60
  67. data/examples/security_features/README.md +46 -0
  68. data/examples/security_features/app.rb +23 -24
  69. data/examples/security_features/config.ru +8 -10
  70. data/lib/otto/core/configuration.rb +167 -0
  71. data/lib/otto/core/error_handler.rb +86 -0
  72. data/lib/otto/core/file_safety.rb +61 -0
  73. data/lib/otto/core/middleware_stack.rb +157 -0
  74. data/lib/otto/core/router.rb +183 -0
  75. data/lib/otto/core/uri_generator.rb +44 -0
  76. data/lib/otto/design_system.rb +7 -5
  77. data/lib/otto/helpers/base.rb +3 -0
  78. data/lib/otto/helpers/request.rb +10 -8
  79. data/lib/otto/helpers/response.rb +5 -4
  80. data/lib/otto/helpers/validation.rb +9 -7
  81. data/lib/otto/mcp/auth/token.rb +10 -9
  82. data/lib/otto/mcp/protocol.rb +24 -27
  83. data/lib/otto/mcp/rate_limiting.rb +8 -3
  84. data/lib/otto/mcp/registry.rb +7 -2
  85. data/lib/otto/mcp/route_parser.rb +10 -15
  86. data/lib/otto/mcp/server.rb +21 -11
  87. data/lib/otto/mcp/validation.rb +14 -10
  88. data/lib/otto/response_handlers/auto.rb +39 -0
  89. data/lib/otto/response_handlers/base.rb +16 -0
  90. data/lib/otto/response_handlers/default.rb +16 -0
  91. data/lib/otto/response_handlers/factory.rb +39 -0
  92. data/lib/otto/response_handlers/json.rb +28 -0
  93. data/lib/otto/response_handlers/redirect.rb +25 -0
  94. data/lib/otto/response_handlers/view.rb +24 -0
  95. data/lib/otto/response_handlers.rb +9 -135
  96. data/lib/otto/route.rb +9 -9
  97. data/lib/otto/route_definition.rb +15 -18
  98. data/lib/otto/route_handlers/base.rb +121 -0
  99. data/lib/otto/route_handlers/class_method.rb +89 -0
  100. data/lib/otto/route_handlers/factory.rb +29 -0
  101. data/lib/otto/route_handlers/instance_method.rb +69 -0
  102. data/lib/otto/route_handlers/lambda.rb +59 -0
  103. data/lib/otto/route_handlers/logic_class.rb +93 -0
  104. data/lib/otto/route_handlers.rb +10 -405
  105. data/lib/otto/security/authentication/auth_strategy.rb +44 -0
  106. data/lib/otto/security/authentication/authentication_middleware.rb +123 -0
  107. data/lib/otto/security/authentication/failure_result.rb +36 -0
  108. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +40 -0
  109. data/lib/otto/security/authentication/strategies/permission_strategy.rb +47 -0
  110. data/lib/otto/security/authentication/strategies/public_strategy.rb +19 -0
  111. data/lib/otto/security/authentication/strategies/role_strategy.rb +57 -0
  112. data/lib/otto/security/authentication/strategies/session_strategy.rb +41 -0
  113. data/lib/otto/security/authentication/strategy_result.rb +223 -0
  114. data/lib/otto/security/authentication.rb +28 -282
  115. data/lib/otto/security/config.rb +14 -12
  116. data/lib/otto/security/configurator.rb +219 -0
  117. data/lib/otto/security/csrf.rb +8 -143
  118. data/lib/otto/security/middleware/csrf_middleware.rb +151 -0
  119. data/lib/otto/security/middleware/rate_limit_middleware.rb +38 -0
  120. data/lib/otto/security/middleware/validation_middleware.rb +252 -0
  121. data/lib/otto/security/rate_limiter.rb +86 -0
  122. data/lib/otto/security/rate_limiting.rb +10 -105
  123. data/lib/otto/security/validator.rb +8 -253
  124. data/lib/otto/static.rb +3 -0
  125. data/lib/otto/utils.rb +14 -0
  126. data/lib/otto/version.rb +3 -1
  127. data/lib/otto.rb +142 -498
  128. data/otto.gemspec +2 -2
  129. metadata +89 -28
  130. data/examples/dynamic_pages/app.rb +0 -115
  131. data/examples/dynamic_pages/config.ru +0 -30
  132. data/examples/dynamic_pages/routes +0 -21
  133. data/examples/helpers_demo/app.rb +0 -244
  134. data/examples/helpers_demo/config.ru +0 -26
  135. data/examples/helpers_demo/routes +0 -7
  136. data/lib/concurrent_cache_store.rb +0 -68
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../auth_strategy'
4
+
5
+ class Otto
6
+ module Security
7
+ module Authentication
8
+ module Strategies
9
+ # Permission-based authentication strategy
10
+ class PermissionStrategy < AuthStrategy
11
+ def initialize(required_permissions, session_key: 'user_permissions')
12
+ @required_permissions = Array(required_permissions)
13
+ @session_key = session_key
14
+ end
15
+
16
+ def authenticate(env, requirement)
17
+ session = env['rack.session']
18
+ return failure('No session available') unless session
19
+
20
+ user_permissions = session[@session_key] || []
21
+ user_permissions = Array(user_permissions)
22
+
23
+ # Create user data from session
24
+ user_data = { user_permissions: user_permissions, session: session }
25
+
26
+ # Extract permission from requirement (e.g., "permission:write" -> "write")
27
+ required_permission = requirement.split(':', 2).last
28
+
29
+ if user_permissions.include?(required_permission)
30
+ success(user: user_data, user_permissions: user_permissions, required_permission: required_permission)
31
+ else
32
+ failure("Insufficient privileges - requires permission: #{required_permission}")
33
+ end
34
+ end
35
+
36
+ def user_context(env)
37
+ session = env['rack.session']
38
+ return {} unless session
39
+
40
+ user_permissions = session[@session_key] || []
41
+ { user_permissions: Array(user_permissions) }
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../auth_strategy'
4
+ require_relative '../strategy_result'
5
+
6
+ class Otto
7
+ module Security
8
+ module Authentication
9
+ module Strategies
10
+ # Public access strategy - always allows access
11
+ class PublicStrategy < AuthStrategy
12
+ def authenticate(env, _requirement)
13
+ Otto::Security::Authentication::StrategyResult.anonymous(metadata: { ip: env['REMOTE_ADDR'] })
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../auth_strategy'
4
+
5
+ class Otto
6
+ module Security
7
+ module Authentication
8
+ module Strategies
9
+ # Role-based authentication strategy
10
+ class RoleStrategy < AuthStrategy
11
+ def initialize(allowed_roles, session_key: 'user_roles')
12
+ @allowed_roles = Array(allowed_roles)
13
+ @session_key = session_key
14
+ end
15
+
16
+ def authenticate(env, requirement)
17
+ session = env['rack.session']
18
+ return failure('No session available') unless session
19
+
20
+ user_roles = session[@session_key] || []
21
+ user_roles = Array(user_roles)
22
+
23
+ # Create user data from session
24
+ user_data = { user_roles: user_roles, session: session }
25
+
26
+ # For requirements like "role:admin", extract the role part
27
+ if requirement.include?(':')
28
+ required_role = requirement.split(':', 2).last
29
+ if user_roles.include?(required_role)
30
+ success(user: user_data, user_roles: user_roles, required_role: required_role)
31
+ else
32
+ failure("Insufficient privileges - requires role: #{required_role}")
33
+ end
34
+ else
35
+ # For direct strategy matches, check if user has any of the allowed roles
36
+ matching_roles = user_roles & @allowed_roles
37
+ if matching_roles.any?
38
+ success(user: user_data, user_roles: user_roles, allowed_roles: @allowed_roles,
39
+ matching_roles: matching_roles)
40
+ else
41
+ failure("Insufficient privileges - requires one of roles: #{@allowed_roles.join(', ')}")
42
+ end
43
+ end
44
+ end
45
+
46
+ def user_context(env)
47
+ session = env['rack.session']
48
+ return {} unless session
49
+
50
+ user_roles = session[@session_key] || []
51
+ { user_roles: Array(user_roles) }
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../auth_strategy'
4
+
5
+ class Otto
6
+ module Security
7
+ module Authentication
8
+ module Strategies
9
+ # Session-based authentication strategy
10
+ class SessionStrategy < AuthStrategy
11
+ def initialize(session_key: 'user_id', session_store: nil)
12
+ @session_key = session_key
13
+ @session_store = session_store
14
+ end
15
+
16
+ def authenticate(env, _requirement)
17
+ session = env['rack.session']
18
+ return failure('No session available') unless session
19
+
20
+ user_id = session[@session_key]
21
+ return failure('Not authenticated') unless user_id
22
+
23
+ # Create a simple user hash for the generic strategy
24
+ user_data = { id: user_id, user_id: user_id }
25
+ success(session: session, user: user_data, auth_method: 'session')
26
+ end
27
+
28
+ def user_context(env)
29
+ session = env['rack.session']
30
+ return {} unless session
31
+
32
+ user_id = session[@session_key]
33
+ return {} unless user_id
34
+
35
+ { user_id: user_id }
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/otto/security/authentication/strategy_result.rb
4
+
5
+ # StrategyResult is an immutable data structure that holds the result of an
6
+ # authentication strategy. It contains session, user, and metadata needed by
7
+ # Otto Logic classes.
8
+ #
9
+ # @example Basic usage
10
+ # result = StrategyResult.new(
11
+ # session: { id: 'abc123', user_id: 1 },
12
+ # user: user_model_instance, # Actual user model, not a hash
13
+ # auth_method: 'token',
14
+ # metadata: { ip: '127.0.0.1' }
15
+ # )
16
+ #
17
+ # result.authenticated? #=> true
18
+ # result.has_role?('admin') #=> true
19
+ # result.user.name #=> 'John' (assuming user model has name method)
20
+ #
21
+ class Otto
22
+ module Security
23
+ module Authentication
24
+ StrategyResult = Data.define(:session, :user, :auth_method, :metadata) do
25
+ # Create an anonymous (unauthenticated) result
26
+ # @return [StrategyResult] Anonymous result with empty session and nil user
27
+ def self.anonymous(metadata: {})
28
+ new(
29
+ session: {},
30
+ user: nil, # Changed from {} to nil - clearer semantics
31
+ auth_method: 'anonymous',
32
+ metadata: metadata
33
+ )
34
+ end
35
+
36
+ # Check if the request is authenticated (has a user)
37
+ # @return [Boolean] True if user is present, false otherwise
38
+ def authenticated?
39
+ !user.nil?
40
+ end
41
+
42
+ # Check if the request is anonymous (no user)
43
+ # @return [Boolean] True if not authenticated
44
+ def anonymous?
45
+ user.nil?
46
+ end
47
+
48
+ # Success/failure methods for compatibility
49
+ def success?
50
+ true # If we have a StrategyResult, authentication succeeded
51
+ end
52
+
53
+ def failure?
54
+ false # Failures return nil, not a StrategyResult
55
+ end
56
+
57
+
58
+ # Check if the user has a specific role
59
+ # @param role [String, Symbol] Role to check
60
+ # @return [Boolean] True if user has the role
61
+ def has_role?(role)
62
+ return false unless authenticated?
63
+
64
+ # Try user model methods first, fall back to hash access for backward compatibility
65
+ if user.respond_to?(:role)
66
+ user.role.to_s == role.to_s
67
+ elsif user.respond_to?(:has_role?)
68
+ user.has_role?(role)
69
+ elsif user.is_a?(Hash)
70
+ user_role = user[:role] || user['role']
71
+ user_role.to_s == role.to_s
72
+ else
73
+ false
74
+ end
75
+ end
76
+
77
+ # Check if the user has a specific permission
78
+ # @param permission [String, Symbol] Permission to check
79
+ # @return [Boolean] True if user has the permission
80
+ def has_permission?(permission)
81
+ return false unless authenticated?
82
+
83
+ # Try user model methods first, fall back to hash access for backward compatibility
84
+ if user.respond_to?(:has_permission?)
85
+ user.has_permission?(permission)
86
+ elsif user.respond_to?(:permissions)
87
+ permissions = user.permissions || []
88
+ permissions = [permissions] unless permissions.is_a?(Array)
89
+ permissions.map(&:to_s).include?(permission.to_s)
90
+ elsif user.is_a?(Hash)
91
+ permissions = user[:permissions] || user['permissions'] || []
92
+ permissions = [permissions] unless permissions.is_a?(Array)
93
+ permissions.map(&:to_s).include?(permission.to_s)
94
+ else
95
+ false
96
+ end
97
+ end
98
+
99
+ # Check if the user has any of the specified roles
100
+ # @param roles [Array<String, Symbol>] Roles to check
101
+ # @return [Boolean] True if user has any of the roles
102
+ def has_any_role?(*roles)
103
+ roles.flatten.any? { |role| has_role?(role) }
104
+ end
105
+
106
+ # Check if the user has any of the specified permissions
107
+ # @param permissions [Array<String, Symbol>] Permissions to check
108
+ # @return [Boolean] True if user has any of the permissions
109
+ def has_any_permission?(*permissions)
110
+ permissions.flatten.any? { |permission| has_permission?(permission) }
111
+ end
112
+
113
+ # Get user ID from various possible locations
114
+ # @return [String, Integer, nil] User ID or nil
115
+ def user_id
116
+ return nil unless authenticated?
117
+
118
+ # Try user model methods first, fall back to hash access and session
119
+ if user.respond_to?(:id)
120
+ user.id
121
+ elsif user.respond_to?(:user_id)
122
+ user.user_id
123
+ elsif user.is_a?(Hash)
124
+ user[:id] || user['id'] || user[:user_id] || user['user_id']
125
+ end || session[:user_id] || session['user_id']
126
+ end
127
+
128
+ # Get user name from various possible locations
129
+ # @return [String, nil] User name or nil
130
+ def user_name
131
+ return nil unless authenticated?
132
+
133
+ # Try user model methods first, fall back to hash access
134
+ if user.respond_to?(:name)
135
+ user.name
136
+ elsif user.respond_to?(:username)
137
+ user.username
138
+ elsif user.is_a?(Hash)
139
+ user[:name] || user['name'] || user[:username] || user['username']
140
+ end
141
+ end
142
+
143
+ # Get session ID from various possible locations
144
+ # @return [String, nil] Session ID or nil
145
+ def session_id
146
+ session[:id] || session['id'] || session[:session_id] || session['session_id']
147
+ end
148
+
149
+ # Get all user roles as an array
150
+ # @return [Array<String>] Array of roles (empty if none)
151
+ def roles
152
+ return [] unless authenticated?
153
+
154
+ roles_data = user[:roles] || user['roles']
155
+ if roles_data.is_a?(Array)
156
+ roles_data.map(&:to_s)
157
+ elsif roles_data
158
+ [roles_data.to_s]
159
+ else
160
+ role = user[:role] || user['role']
161
+ role ? [role.to_s] : []
162
+ end
163
+ end
164
+
165
+ # Get all user permissions as an array
166
+ # @return [Array<String>] Array of permissions (empty if none)
167
+ def permissions
168
+ return [] unless authenticated?
169
+
170
+ perms = user[:permissions] || user['permissions'] || []
171
+ perms = [perms] unless perms.is_a?(Array)
172
+ perms.map(&:to_s)
173
+ end
174
+
175
+ # Create a string representation for debugging
176
+ # @return [String] Debug representation
177
+ def inspect
178
+ if authenticated?
179
+ "#<StrategyResult authenticated user=#{user_name || user_id} roles=#{roles} method=#{auth_method}>"
180
+ else
181
+ "#<StrategyResult anonymous method=#{auth_method}>"
182
+ end
183
+ end
184
+
185
+ # Get user context - a hash containing user-specific information and metadata
186
+ # @return [Hash] User context hash
187
+ def user_context
188
+ if authenticated?
189
+ case auth_method
190
+ when 'session'
191
+ { user_id: user_id, session: session }
192
+ else
193
+ metadata
194
+ end
195
+ else
196
+ case auth_method
197
+ when 'anonymous'
198
+ {}
199
+ else
200
+ metadata
201
+ end
202
+ end
203
+ end
204
+
205
+ # Create a hash representation
206
+ # @return [Hash] Hash representation of the context
207
+ def to_h
208
+ {
209
+ session: session,
210
+ user: user,
211
+ auth_method: auth_method,
212
+ metadata: metadata,
213
+ authenticated: authenticated?,
214
+ user_id: user_id,
215
+ user_name: user_name,
216
+ roles: roles,
217
+ permissions: permissions
218
+ }
219
+ end
220
+ end
221
+ end
222
+ end
223
+ end