otto 2.9.0 → 2.11.0

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 (100) hide show
  1. checksums.yaml +4 -4
  2. data/.github/dependabot.yml +5 -0
  3. data/.github/workflows/ci.yml +11 -8
  4. data/.github/workflows/claude-code-review.yml +38 -13
  5. data/.github/workflows/claude.yml +10 -8
  6. data/.github/workflows/code-smells.yml +5 -5
  7. data/.github/workflows/release-gem.yml +2 -2
  8. data/.github/workflows/ruby-lint.yml +3 -3
  9. data/.github/workflows/yardoc.yml +5 -5
  10. data/.gitignore +1 -5
  11. data/.rubocop_todo.yml +8 -5
  12. data/AGENTS.md +40 -1
  13. data/CHANGELOG.rst +235 -0
  14. data/Gemfile +3 -3
  15. data/Gemfile.lock +11 -15
  16. data/README.md +75 -37
  17. data/docs/README.md +166 -0
  18. data/docs/adr/README.md +16 -0
  19. data/docs/adr/adr-001-route-authentication-at-handler-boundary.md +38 -0
  20. data/docs/adr/adr-002-multi-strategy-authentication-and-authorization.md +50 -0
  21. data/docs/adr/adr-003-caddy-tls-route-based-integration.md +48 -0
  22. data/docs/adr/adr-004-separate-compatibility-from-security-maintenance.md +58 -0
  23. data/docs/guides/authentication.md +377 -0
  24. data/docs/guides/caddy-tls.md +205 -0
  25. data/docs/guides/configuration_freezing.md +157 -0
  26. data/docs/guides/enrichment.md +161 -0
  27. data/docs/guides/forwarded-authority.md +249 -0
  28. data/docs/guides/geo-country.md +168 -0
  29. data/docs/guides/ip_privacy.md +39 -0
  30. data/docs/guides/ipaddr-encoding-quirk.md +56 -0
  31. data/docs/guides/mcp.md +282 -0
  32. data/docs/guides/privacy.md +193 -0
  33. data/docs/guides/routing.md +293 -0
  34. data/docs/guides/structured_logging.md +281 -0
  35. data/docs/guides/testing-guide.md +391 -0
  36. data/docs/maintainers/github-actions.md +41 -0
  37. data/docs/maintainers/investigations/.gitignore +2 -0
  38. data/docs/migrating/v2.0.0.md +337 -0
  39. data/docs/reference/authentication.md +290 -0
  40. data/docs/reference/route-syntax.md +181 -0
  41. data/docs/reference/runtime-and-dependency-security.md +86 -0
  42. data/examples/advanced_routes/README.md +43 -57
  43. data/examples/authentication_strategies/README.md +37 -196
  44. data/examples/basic/README.md +24 -39
  45. data/examples/basic/config.ru +0 -1
  46. data/examples/caddy_tls_demo/README.md +8 -2
  47. data/examples/lambda_handlers/README.md +11 -2
  48. data/examples/mcp_demo/README.md +75 -161
  49. data/examples/mcp_demo/config.ru +1 -0
  50. data/examples/security_features/README.md +36 -234
  51. data/lib/otto/caddy_tls/localhost_guard.rb +22 -24
  52. data/lib/otto/core/configuration.rb +40 -23
  53. data/lib/otto/core/error_handler.rb +40 -2
  54. data/lib/otto/core/file_safety.rb +105 -31
  55. data/lib/otto/core/middleware_stack.rb +36 -36
  56. data/lib/otto/core/router.rb +94 -18
  57. data/lib/otto/core/static_mounts.rb +172 -0
  58. data/lib/otto/core.rb +1 -0
  59. data/lib/otto/env_keys.rb +20 -2
  60. data/lib/otto/mcp/auth/token.rb +10 -4
  61. data/lib/otto/mcp/core.rb +23 -5
  62. data/lib/otto/mcp/endpoint.rb +41 -0
  63. data/lib/otto/mcp/errors.rb +15 -0
  64. data/lib/otto/mcp/options.rb +292 -0
  65. data/lib/otto/mcp/protocol.rb +52 -22
  66. data/lib/otto/mcp/rate_limiting.rb +175 -97
  67. data/lib/otto/mcp/registry.rb +14 -14
  68. data/lib/otto/mcp/schema_validation.rb +20 -12
  69. data/lib/otto/mcp/server.rb +131 -32
  70. data/lib/otto/optional_dependency.rb +57 -0
  71. data/lib/otto/privacy/config.rb +10 -8
  72. data/lib/otto/security/authentication/route_auth_wrapper/role_authorization.rb +22 -5
  73. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +181 -18
  74. data/lib/otto/security/authentication/strategies/permission_strategy.rb +1 -0
  75. data/lib/otto/security/authentication/strategies/role_strategy.rb +1 -0
  76. data/lib/otto/security/authentication/strategies/session_strategy.rb +1 -0
  77. data/lib/otto/security/authentication/strategy_result.rb +58 -28
  78. data/lib/otto/security/config.rb +328 -14
  79. data/lib/otto/security/configurator.rb +36 -6
  80. data/lib/otto/security/core.rb +19 -1
  81. data/lib/otto/security/middleware/ip_privacy_middleware.rb +105 -4
  82. data/lib/otto/security/middleware/rate_limit_middleware.rb +1 -6
  83. data/lib/otto/security/rate_limiter.rb +81 -46
  84. data/lib/otto/static.rb +38 -0
  85. data/lib/otto/utils.rb +50 -0
  86. data/lib/otto/version.rb +1 -1
  87. data/lib/otto.rb +130 -13
  88. data/otto.gemspec +0 -2
  89. metadata +32 -41
  90. data/docs/.gitignore +0 -10
  91. data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +0 -1105
  92. data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +0 -376
  93. data/docs/enrichment.md +0 -128
  94. data/docs/geo-country.md +0 -181
  95. data/docs/ipaddr-encoding-quirk.md +0 -34
  96. data/docs/migrating/v2.0.0-pre1.md +0 -276
  97. data/docs/migrating/v2.0.0-pre2.md +0 -338
  98. data/docs/modern-authentication-authorization-landscape.md +0 -558
  99. data/docs/multi-strategy-authentication-design.md +0 -1401
  100. data/docs/reverse-proxy-network-services.md +0 -371
data/lib/otto.rb CHANGED
@@ -55,6 +55,7 @@ require_relative 'otto/logging_helpers'
55
55
  class Otto
56
56
  include Otto::Core::Router
57
57
  include Otto::Core::FileSafety
58
+ include Otto::Core::StaticMounts
58
59
  include Otto::Core::Configuration
59
60
  include Otto::Core::ErrorHandler
60
61
  include Otto::Core::UriGenerator
@@ -68,6 +69,10 @@ class Otto
68
69
 
69
70
  LIB_HOME = __dir__ unless defined?(Otto::LIB_HOME)
70
71
 
72
+ # Parameter types (from Proc#parameters / Method#parameters) that consume
73
+ # one positional argument each. See {#fallback_call_args}.
74
+ POSITIONAL_PARAMETER_TYPES = %i[req opt].freeze
75
+
71
76
  @debug = case ENV.fetch('OTTO_DEBUG', nil)
72
77
  in 'true' | '1' | 'yes' | 'on'
73
78
  true
@@ -76,14 +81,61 @@ class Otto
76
81
  end
77
82
  @logger = Logger.new($stdout, Logger::INFO)
78
83
 
79
- attr_reader :routes, :routes_literal, :routes_static, :route_definitions,
84
+ attr_reader :routes, :routes_literal, :route_definitions,
80
85
  :routes_by_definition, :option,
81
86
  :static_route, :security_config, :locale_config, :auth_config,
82
87
  :route_handler_factory, :mcp_server, :caddy_tls_server, :security, :middleware,
83
- :error_handlers, :request_class, :response_class
84
- attr_accessor :not_found, :server_error
88
+ :error_handlers, :request_class, :response_class,
89
+ :not_found, :server_error
90
+
91
+ # Configure the response returned when no route (and no +/404+ route) matches.
92
+ #
93
+ # Accepts either a Rack triple +[status, headers, body]+ (the body must
94
+ # respond to +each+, or +call+ for a streaming body) or anything that
95
+ # responds to +call(env)+ and returns one. A static triple is never handed
96
+ # back to the Rack stack by reference: Otto returns a per-request copy whose
97
+ # headers (and Array-valued header entries, and Array body) are fresh
98
+ # containers, so cookie middleware such as rack-session or Otto's own CSRF
99
+ # middleware cannot accumulate +Set-Cookie+ values on the configured object
100
+ # and replay them to later clients. Prefer the callable form when the
101
+ # response should vary per request.
102
+ #
103
+ # @param response [Array, #call, nil] a Rack triple, a callable, or nil to
104
+ # restore the built-in {Otto::Static.not_found} response
105
+ # @raise [ArgumentError] when +response+ is neither a Rack triple nor callable
106
+ #
107
+ # @example Static triple (copied per request)
108
+ # otto.not_found = [404, { 'content-type' => 'application/json' }, ['{"error":"Not Found"}']]
109
+ #
110
+ # @example Callable, built fresh on every miss
111
+ # otto.not_found = ->(env) { [404, { 'content-type' => 'text/plain' }, ["No #{env['PATH_INFO']}"]] }
112
+ def not_found=(response)
113
+ @not_found = validate_fallback_response!(:not_found, response)
114
+ end
115
+
116
+ # Configure the response returned for an unhandled error when no +/500+
117
+ # route is configured and the client does not prefer JSON.
118
+ #
119
+ # Accepts either a Rack triple or anything that responds to +call(env, error)+
120
+ # and returns one; a callable declaring a single positional parameter (or
121
+ # none) receives only what it declares. +env['otto.error_id']+ carries the
122
+ # logged correlation id. As with {#not_found=}, a static triple is copied
123
+ # per request so header writes by cookie middleware never touch the
124
+ # configured object. A callable that raises is logged and replaced by the
125
+ # built-in secure error response.
126
+ #
127
+ # @param response [Array, #call, nil] a Rack triple, a callable, or nil to
128
+ # restore the built-in secure error response
129
+ # @raise [ArgumentError] when +response+ is neither a Rack triple nor callable
130
+ #
131
+ # @example Callable receiving the error
132
+ # otto.server_error = ->(env, error) { [500, { 'content-type' => 'text/plain' }, ['Oops']] }
133
+ def server_error=(response)
134
+ @server_error = validate_fallback_response!(:server_error, response)
135
+ end
85
136
 
86
137
  def initialize(path = nil, opts = {})
138
+ constructed = false
87
139
  initialize_core_state
88
140
  initialize_options(path, opts)
89
141
  initialize_configurations(opts)
@@ -106,6 +158,13 @@ class Otto
106
158
  # but before processing requests.
107
159
  @freeze_mutex = Mutex.new
108
160
  @configuration_frozen = false
161
+ constructed = true
162
+ ensure
163
+ # A config that committed to a forwarding family during configure_security
164
+ # is held process-wide; withdraw it if construction failed afterwards
165
+ # (e.g. a bad routes path), or the dead app would keep vetoing other
166
+ # families for the life of the process.
167
+ Otto::Security::Config.release_rack_forwarding_family!(@security_config) if @security_config && !constructed
109
168
  end
110
169
  alias options option
111
170
 
@@ -172,16 +231,6 @@ class Otto
172
231
  private
173
232
 
174
233
  def initialize_core_state
175
- # The GET cache is a Concurrent::Map, not a plain Hash: lazy static-file
176
- # discovery (Core::Router#handle_request, Core::FileSafety#add_static_path)
177
- # writes into it at request time, after freeze_configuration! has already
178
- # deep-frozen the rest of the routing state. Deep-freezing this cache too
179
- # would turn every as-yet-uncached static file request into a 500
180
- # (FrozenError) in production (issue #185), so it is intentionally excluded
181
- # from deep_freeze_value in Configuration#freeze_configuration! and kept as
182
- # a structure that is both mutable post-freeze and safe under concurrent
183
- # request threads.
184
- @routes_static = { GET: Concurrent::Map.new }
185
234
  @routes = { GET: [] }
186
235
  @routes_literal = { GET: {} }
187
236
  @route_definitions = {}
@@ -190,6 +239,10 @@ class Otto
190
239
  # so reverse lookups (Otto#uri) consult this index instead of the
191
240
  # single-route @route_definitions entry (issue #190).
192
241
  @routes_by_definition = {}
242
+ # Explicit static mounts (Core::StaticMounts#mount_static). Always a
243
+ # frozen snapshot, replaced wholesale on registration; dispatch reads it
244
+ # without locking.
245
+ @static_mounts = [].freeze
193
246
  @security_config = Otto::Security::Config.new
194
247
  @middleware = Otto::Core::MiddlewareStack.new
195
248
  # Initialize @auth_config first so it can be shared with the configurator
@@ -303,6 +356,70 @@ class Otto
303
356
  end
304
357
  end
305
358
 
359
+ # Validate a value assigned to {#not_found=} or {#server_error=}.
360
+ #
361
+ # @param name [Symbol] the setting name, for the error message
362
+ # @param response [Object] the assigned value
363
+ # @return [Array, #call, nil] the value, when acceptable
364
+ # @raise [ArgumentError] otherwise
365
+ def validate_fallback_response!(name, response)
366
+ return response if response.nil? || response.respond_to?(:call)
367
+ return response if rack_triple?(response)
368
+
369
+ raise ArgumentError,
370
+ "#{name} must be a Rack triple [status, headers, body] or respond to #call, got #{response.inspect}"
371
+ end
372
+
373
+ # A Rack triple: an Integer-like status, Hash-like headers, and a body that
374
+ # responds to +each+ (or +call+, for a streaming body).
375
+ def rack_triple?(response)
376
+ return false unless response.is_a?(Array) && response.length == 3
377
+
378
+ status, headers, body = response
379
+ status.respond_to?(:to_int) && headers.respond_to?(:each_pair) &&
380
+ (body.respond_to?(:each) || body.respond_to?(:call))
381
+ end
382
+
383
+ # Resolve a configured fallback into a fresh Rack triple for one request.
384
+ #
385
+ # A callable is invoked with as many of +args+ as it accepts (see
386
+ # {#fallback_call_args}); a static triple is used as-is. Either way the
387
+ # result is copied (see {Otto::Static.copy_response}) so the Rack stack
388
+ # never receives a container shared with the configuration or with another
389
+ # request.
390
+ #
391
+ # @param name [Symbol] the setting name, for the error message
392
+ # @param fallback [Array, #call] the configured value
393
+ # @param args [Array] positional arguments offered to a callable fallback
394
+ # @return [Array] a new Rack triple
395
+ # @raise [TypeError] when a callable returns something other than a Rack triple
396
+ def resolve_fallback_response(name, fallback, *args)
397
+ response = fallback.respond_to?(:call) ? fallback.call(*fallback_call_args(fallback, args)) : fallback
398
+ unless rack_triple?(response)
399
+ raise TypeError,
400
+ "#{name} callable must return a Rack triple [status, headers, body], got #{response.inspect}"
401
+ end
402
+
403
+ Otto::Static.copy_response(response)
404
+ end
405
+
406
+ # Trim +args+ to the positional parameters +callable+ declares, so a lambda
407
+ # or Method that takes fewer (or optional) parameters is never handed an
408
+ # argument it would reject. A splat parameter receives everything. Arity
409
+ # alone cannot express this: +->(env = nil) {}+ has arity -1, the same as
410
+ # +proc { |*a| }+, yet accepts at most one argument.
411
+ #
412
+ # @param callable [#call]
413
+ # @param args [Array] the arguments on offer, in order
414
+ # @return [Array] the leading subset of +args+ the callable accepts
415
+ def fallback_call_args(callable, args)
416
+ params = callable.respond_to?(:parameters) ? callable.parameters : callable.method(:call).parameters
417
+ return args if params.any? { |type, _name| type == :rest }
418
+
419
+ args.first(params.count { |type, _name| POSITIONAL_PARAMETER_TYPES.include?(type) })
420
+ end
421
+ private :validate_fallback_response!, :rack_triple?, :resolve_fallback_response, :fallback_call_args
422
+
306
423
  # Class methods for Otto framework providing singleton access and configuration
307
424
  module ClassMethods
308
425
  def default
data/otto.gemspec CHANGED
@@ -34,8 +34,6 @@ Gem::Specification.new do |spec|
34
34
  spec.add_dependency 'logger', '~> 1', '< 2.0'
35
35
 
36
36
  spec.add_dependency 'rack', '~> 3.1', '< 4.0'
37
- spec.add_dependency 'rack-parser', '~> 0.7'
38
- spec.add_dependency 'rexml', '~> 3.4'
39
37
 
40
38
  # Security dependencies
41
39
  spec.add_dependency 'loofah', '~> 2.20'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: otto
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.9.0
4
+ version: 2.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Delano Mandelbaum
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-19 00:00:00.000000000 Z
11
+ date: 2026-09-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby
@@ -70,34 +70,6 @@ dependencies:
70
70
  - - "<"
71
71
  - !ruby/object:Gem::Version
72
72
  version: '4.0'
73
- - !ruby/object:Gem::Dependency
74
- name: rack-parser
75
- requirement: !ruby/object:Gem::Requirement
76
- requirements:
77
- - - "~>"
78
- - !ruby/object:Gem::Version
79
- version: '0.7'
80
- type: :runtime
81
- prerelease: false
82
- version_requirements: !ruby/object:Gem::Requirement
83
- requirements:
84
- - - "~>"
85
- - !ruby/object:Gem::Version
86
- version: '0.7'
87
- - !ruby/object:Gem::Dependency
88
- name: rexml
89
- requirement: !ruby/object:Gem::Requirement
90
- requirements:
91
- - - "~>"
92
- - !ruby/object:Gem::Version
93
- version: '3.4'
94
- type: :runtime
95
- prerelease: false
96
- version_requirements: !ruby/object:Gem::Requirement
97
- requirements:
98
- - - "~>"
99
- - !ruby/object:Gem::Version
100
- version: '3.4'
101
73
  - !ruby/object:Gem::Dependency
102
74
  name: loofah
103
75
  requirement: !ruby/object:Gem::Requirement
@@ -144,18 +116,32 @@ files:
144
116
  - bin/rspec
145
117
  - changelog.d/README.md
146
118
  - changelog.d/scriv.ini
147
- - docs/.gitignore
148
- - docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md
149
- - docs/1108-STREAMING_SUPPORT_SUMMARY.md
150
- - docs/enrichment.md
151
- - docs/geo-country.md
152
- - docs/ipaddr-encoding-quirk.md
153
- - docs/migrating/v2.0.0-pre1.md
154
- - docs/migrating/v2.0.0-pre2.md
119
+ - docs/README.md
120
+ - docs/adr/README.md
121
+ - docs/adr/adr-001-route-authentication-at-handler-boundary.md
122
+ - docs/adr/adr-002-multi-strategy-authentication-and-authorization.md
123
+ - docs/adr/adr-003-caddy-tls-route-based-integration.md
124
+ - docs/adr/adr-004-separate-compatibility-from-security-maintenance.md
125
+ - docs/guides/authentication.md
126
+ - docs/guides/caddy-tls.md
127
+ - docs/guides/configuration_freezing.md
128
+ - docs/guides/enrichment.md
129
+ - docs/guides/forwarded-authority.md
130
+ - docs/guides/geo-country.md
131
+ - docs/guides/ip_privacy.md
132
+ - docs/guides/ipaddr-encoding-quirk.md
133
+ - docs/guides/mcp.md
134
+ - docs/guides/privacy.md
135
+ - docs/guides/routing.md
136
+ - docs/guides/structured_logging.md
137
+ - docs/guides/testing-guide.md
138
+ - docs/maintainers/github-actions.md
139
+ - docs/maintainers/investigations/.gitignore
140
+ - docs/migrating/v2.0.0.md
155
141
  - docs/migrating/v2.3.0.md
156
- - docs/modern-authentication-authorization-landscape.md
157
- - docs/multi-strategy-authentication-design.md
158
- - docs/reverse-proxy-network-services.md
142
+ - docs/reference/authentication.md
143
+ - docs/reference/route-syntax.md
144
+ - docs/reference/runtime-and-dependency-security.md
159
145
  - examples/.gitignore
160
146
  - examples/advanced_routes/README.md
161
147
  - examples/advanced_routes/app.rb
@@ -237,6 +223,7 @@ files:
237
223
  - lib/otto/core/middleware_management.rb
238
224
  - lib/otto/core/middleware_stack.rb
239
225
  - lib/otto/core/router.rb
226
+ - lib/otto/core/static_mounts.rb
240
227
  - lib/otto/core/uri_generator.rb
241
228
  - lib/otto/design_system.rb
242
229
  - lib/otto/env_keys.rb
@@ -251,12 +238,16 @@ files:
251
238
  - lib/otto/mcp.rb
252
239
  - lib/otto/mcp/auth/token.rb
253
240
  - lib/otto/mcp/core.rb
241
+ - lib/otto/mcp/endpoint.rb
242
+ - lib/otto/mcp/errors.rb
243
+ - lib/otto/mcp/options.rb
254
244
  - lib/otto/mcp/protocol.rb
255
245
  - lib/otto/mcp/rate_limiting.rb
256
246
  - lib/otto/mcp/registry.rb
257
247
  - lib/otto/mcp/route_parser.rb
258
248
  - lib/otto/mcp/schema_validation.rb
259
249
  - lib/otto/mcp/server.rb
250
+ - lib/otto/optional_dependency.rb
260
251
  - lib/otto/privacy.rb
261
252
  - lib/otto/privacy/anonymizer_resolver.rb
262
253
  - lib/otto/privacy/asn_resolver.rb
data/docs/.gitignore DELETED
@@ -1,10 +0,0 @@
1
- *
2
- !.gitignore
3
- !migrating/
4
- !migrating/*.md
5
- !ipaddr-encoding-quirk.md
6
- !enrichment.md
7
- !geo-country.md
8
- !modern-authentication-authorization-landscape.md
9
- !multi-strategy-authentication-design.md
10
- !reverse-proxy-network-services.md