otto 2.5.0 → 2.7.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +1 -1
  3. data/.github/workflows/claude-code-review.yml +1 -1
  4. data/.github/workflows/claude.yml +1 -1
  5. data/.github/workflows/code-smells.yml +2 -2
  6. data/.github/workflows/release-gem.yml +1 -1
  7. data/.github/workflows/ruby-lint.yml +1 -1
  8. data/.github/workflows/yardoc.yml +1 -1
  9. data/.pre-commit-config.yaml +22 -5
  10. data/CHANGELOG.rst +283 -0
  11. data/Gemfile +2 -1
  12. data/Gemfile.lock +14 -12
  13. data/README.md +13 -3
  14. data/docs/.gitignore +1 -0
  15. data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +1105 -0
  16. data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +376 -0
  17. data/docs/geo-country.md +172 -0
  18. data/docs/reverse-proxy-network-services.md +19 -6
  19. data/examples/advanced_routes/README.md +49 -0
  20. data/examples/advanced_routes/config.rb +15 -2
  21. data/examples/advanced_routes/routes +12 -0
  22. data/examples/lambda_handlers/README.md +128 -0
  23. data/examples/lambda_handlers/config.ru +26 -0
  24. data/examples/lambda_handlers/handlers.rb +75 -0
  25. data/examples/lambda_handlers/routes +28 -0
  26. data/examples/simple_geo_resolver.rb +38 -5
  27. data/lib/otto/caddy_tls/localhost_guard.rb +43 -25
  28. data/lib/otto/core/configuration.rb +103 -1
  29. data/lib/otto/core/middleware_stack.rb +72 -25
  30. data/lib/otto/core/router.rb +67 -10
  31. data/lib/otto/core/uri_generator.rb +36 -2
  32. data/lib/otto/env_keys.rb +43 -0
  33. data/lib/otto/errors.rb +7 -0
  34. data/lib/otto/logging_helpers.rb +50 -1
  35. data/lib/otto/mcp/rate_limiting.rb +5 -2
  36. data/lib/otto/mcp/route_parser.rb +15 -4
  37. data/lib/otto/privacy/config.rb +281 -3
  38. data/lib/otto/privacy/core.rb +104 -8
  39. data/lib/otto/privacy/geo_resolver.rb +228 -128
  40. data/lib/otto/privacy/ip_privacy.rb +24 -0
  41. data/lib/otto/privacy/redacted_fingerprint.rb +58 -22
  42. data/lib/otto/privacy/user_agent_privacy.rb +64 -0
  43. data/lib/otto/privacy.rb +4 -1
  44. data/lib/otto/request.rb +35 -1
  45. data/lib/otto/route.rb +103 -41
  46. data/lib/otto/route_definition.rb +56 -6
  47. data/lib/otto/route_handlers/base.rb +4 -0
  48. data/lib/otto/route_handlers/factory.rb +15 -0
  49. data/lib/otto/route_handlers/lambda.rb +47 -32
  50. data/lib/otto/security/authentication/auth_failure.rb +36 -2
  51. data/lib/otto/security/authentication/auth_strategy.rb +12 -2
  52. data/lib/otto/security/authentication/authorization_failure.rb +7 -0
  53. data/lib/otto/security/authentication/route_auth_wrapper.rb +138 -31
  54. data/lib/otto/security/config.rb +123 -6
  55. data/lib/otto/security/core.rb +4 -1
  56. data/lib/otto/security/csp/policy.rb +135 -3
  57. data/lib/otto/security/csp/report_middleware.rb +3 -1
  58. data/lib/otto/security/csrf_enforcement_wrapper.rb +68 -0
  59. data/lib/otto/security/csrf_validation.rb +75 -0
  60. data/lib/otto/security/middleware/csrf_middleware.rb +15 -71
  61. data/lib/otto/security/middleware/ip_privacy_middleware.rb +232 -15
  62. data/lib/otto/security/rate_limiter.rb +7 -1
  63. data/lib/otto/security.rb +1 -0
  64. data/lib/otto/utils.rb +100 -0
  65. data/lib/otto/version.rb +1 -1
  66. data/lib/otto.rb +37 -5
  67. metadata +13 -6
@@ -0,0 +1,128 @@
1
+ # Otto - Lambda / Inline Route Handlers
2
+
3
+ This example demonstrates Otto's fourth route-handler kind (issue #41):
4
+ **lambda handlers**. A lambda handler is a plain proc, pre-registered by name,
5
+ that a route can target with an `&` prefix.
6
+
7
+ ## What You'll Learn
8
+
9
+ - Registering lambda handlers at `Otto.new` construction time
10
+ - The `&handler_name` route syntax
11
+ - Returning strings, Hashes (JSON), and mutating the response directly
12
+ - Applying route options (`response=json`, `csrf=exempt`) to lambdas
13
+ - Why this is safe: no `eval`, no dynamic code from route files
14
+
15
+ ## The Four Handler Kinds
16
+
17
+ | Route target | Kind | Resolves to |
18
+ |-----------------|-------------|-------------------------|
19
+ | `App.index` | `:class` | class method |
20
+ | `App#index` | `:instance` | instance method |
21
+ | `BareClass` | `:logic` | Logic object |
22
+ | `&health_check` | `:lambda` | pre-registered proc |
23
+
24
+ ## Registration
25
+
26
+ Lambdas are supplied to Otto as a `name => callable` Hash. Otto validates each
27
+ entry (must respond to `#call`, must accept 3 arguments) and freezes the
28
+ registry:
29
+
30
+ ```ruby
31
+ require_relative '../../lib/otto'
32
+ require_relative 'handlers'
33
+
34
+ app = Otto.new('routes', {
35
+ lambda_handlers: LambdaHandlers::REGISTRY,
36
+ })
37
+ ```
38
+
39
+ Each handler receives `(req, res, extra_params)`:
40
+
41
+ ```ruby
42
+ HEALTH_CHECK = lambda do |req, res, extra_params|
43
+ res.headers['content-type'] = 'text/plain; charset=utf-8'
44
+ res.body = 'OK'
45
+ end
46
+ ```
47
+
48
+ ## Route Syntax
49
+
50
+ Prefix the target with `&` and give the registered name. Everything after the
51
+ `&` is the exact registry key. The target comes first; route options follow it:
52
+
53
+ ```
54
+ GET /ping &health_check
55
+ GET /status &status response=json
56
+ GET /greet/:name &greet response=json
57
+ POST /webhook &webhook csrf=exempt response=json
58
+ ```
59
+
60
+ ## Response Types
61
+
62
+ Lambdas participate in Otto's normal response-type dispatch, the same way a
63
+ class/instance/logic handler does:
64
+
65
+ - **Default** (no `response=`): the handler mutates `res` directly — set
66
+ `res.status` / `res.headers` / `res.body`. The return value is ignored, so
67
+ writing `res.body` is what produces output.
68
+ - **`response=json`**: the returned Hash is serialized as JSON and the JSON
69
+ content-type is set for you.
70
+
71
+ ## Route Options
72
+
73
+ Route options apply to lambdas just like any other handler:
74
+
75
+ - `response=json` — serialize the returned Hash as JSON.
76
+ - `csrf=exempt` — parsed and exposed on the route definition (intended to mark
77
+ the webhook, which external callers reach without a browser token). Note:
78
+ `CSRFMiddleware` does not yet consult per-route options, so this option is
79
+ currently recorded but not enforced — see issue #186.
80
+ - `auth=` / `role=` — authentication and authorization (when `auth_config`
81
+ is configured on the Otto instance).
82
+
83
+ ## Security
84
+
85
+ The `&` syntax performs an O(1) lookup of a **pre-registered** proc by name.
86
+ Route files never contain Ruby code and are never `eval`'d. A route naming a
87
+ handler that was not registered raises a clear error instead of executing
88
+ anything.
89
+
90
+ ## How to Run
91
+
92
+ ```sh
93
+ cd examples/lambda_handlers
94
+ rackup config.ru -p 10780
95
+ ```
96
+
97
+ Then, from another terminal:
98
+
99
+ ```sh
100
+ curl localhost:10780/ping
101
+ # OK
102
+
103
+ curl localhost:10780/status
104
+ # {"service":"otto-lambda-demo","status":"healthy","time":"..."}
105
+
106
+ curl localhost:10780/greet/otto
107
+ # {"greeting":"Hello, otto!"}
108
+
109
+ curl -X POST --data 'hello' localhost:10780/webhook
110
+ # {"received":true,"bytes":5}
111
+ ```
112
+
113
+ ## File Structure
114
+
115
+ - `README.md`: This file.
116
+ - `handlers.rb`: Defines the lambda procs and the `REGISTRY` Hash.
117
+ - `routes`: Maps URLs to lambdas using the `&` syntax.
118
+ - `config.ru`: Rack config that registers the lambdas with `Otto.new`.
119
+
120
+ ## Next Steps
121
+
122
+ - Explore [Advanced Routes](../advanced_routes/) for class/instance/logic
123
+ handlers and response-type negotiation.
124
+ - See [Security Features](../security_features/) for CSRF and input validation.
125
+
126
+ ## Further Reading
127
+
128
+ - [docs/ADVANCED_ROUTES.txt](../../docs/ADVANCED_ROUTES.txt)
@@ -0,0 +1,26 @@
1
+ # examples/lambda_handlers/config.ru
2
+ #
3
+ # Usage:
4
+ #
5
+ # $ rackup config.ru -p 10780
6
+ #
7
+ # then, in another terminal:
8
+ #
9
+ # $ curl localhost:10780/ping
10
+ # $ curl localhost:10780/status
11
+ # $ curl localhost:10780/greet/otto
12
+ # $ curl -X POST --data 'hello' localhost:10780/webhook
13
+
14
+ require_relative '../../lib/otto'
15
+ require_relative 'handlers'
16
+
17
+ # Register the lambda handlers at construction time. Only names present in
18
+ # this Hash can be referenced from the routes file's '&' syntax. Otto
19
+ # validates every entry here (must respond to #call, arity of 3) and freezes
20
+ # the registry — a route naming an unknown handler fails loudly rather than
21
+ # executing anything.
22
+ app = Otto.new('routes', {
23
+ lambda_handlers: LambdaHandlers::REGISTRY,
24
+ })
25
+
26
+ run app
@@ -0,0 +1,75 @@
1
+ # examples/lambda_handlers/handlers.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ # Pre-registered lambda route handlers (Otto issue #41).
6
+ #
7
+ # A lambda handler is any object responding to #call that accepts exactly
8
+ # three arguments: (req, res, extra_params).
9
+ #
10
+ # * req - the Rack::Request for this request
11
+ # * res - the Rack::Response to populate
12
+ # * extra_params - a Hash of route/path params merged by Otto
13
+ #
14
+ # These procs are handed to Otto at construction time via the
15
+ # `lambda_handlers:` option (see config.ru). Each is looked up O(1) by name
16
+ # from the route file's `&handler_name` syntax. Nothing in the route file is
17
+ # ever eval'd — only names present in this registry can be invoked, and an
18
+ # unknown name fails loudly instead of executing arbitrary code.
19
+ #
20
+ # How the response is produced depends on the route's `response=` option,
21
+ # exactly like the class/instance/logic handler kinds:
22
+ #
23
+ # * default (no `response=`) -> the handler mutates `res` directly
24
+ # (status/headers/body); the return value is ignored.
25
+ # * `response=json` -> the returned Hash is serialized to JSON.
26
+ module LambdaHandlers
27
+ # A minimal string responder. With no `response=` option the default response
28
+ # handler leaves the body alone, so (like a class handler) we write it here.
29
+ HEALTH_CHECK = lambda do |_req, res, _extra|
30
+ res.headers['content-type'] = 'text/plain; charset=utf-8'
31
+ res.body = 'OK'
32
+ end
33
+
34
+ # A Hash responder. Pair it with `response=json` on the route and Otto's
35
+ # JSON response handler serializes the Hash and sets the JSON content-type.
36
+ STATUS = lambda do |_req, _res, _extra|
37
+ {
38
+ service: 'otto-lambda-demo',
39
+ status: 'healthy',
40
+ time: Time.now.utc.iso8601,
41
+ }
42
+ end
43
+
44
+ # A handler that reads merged params. Path/query params arrive in
45
+ # `extra_params`; request params are available through `req`.
46
+ GREET = lambda do |req, _res, extra|
47
+ name = extra['name'] || req.params['name'] || 'world'
48
+ { greeting: "Hello, #{name}!" }
49
+ end
50
+
51
+ # A webhook-style POST handler. The matching route declares `csrf=exempt`,
52
+ # intended to let external callers (which cannot present a CSRF token) reach
53
+ # it. Note: CSRFMiddleware does not yet honor per-route csrf=exempt (issue
54
+ # #186), so the option is currently advisory. When enforcement lands, exempt
55
+ # CSRF only for endpoints authenticated another way (signature header, shared
56
+ # secret, etc.).
57
+ WEBHOOK = lambda do |req, _res, _extra|
58
+ # Rewind first: upstream middleware may already have read the input stream.
59
+ req.body.rewind if req.body.respond_to?(:rewind)
60
+ payload = req.body.read.to_s
61
+ # NOTE: this route uses `response=json`, so Otto's JSON response handler
62
+ # owns the status (200) and content-type. Setting them here would be
63
+ # overridden, so we only return the Hash to be serialized.
64
+ { received: true, bytes: payload.bytesize }
65
+ end
66
+
67
+ # The registry passed to Otto.new(lambda_handlers: ...). Keys are the names
68
+ # referenced after '&' in the routes file.
69
+ REGISTRY = {
70
+ 'health_check' => HEALTH_CHECK,
71
+ 'status' => STATUS,
72
+ 'greet' => GREET,
73
+ 'webhook' => WEBHOOK,
74
+ }.freeze
75
+ end
@@ -0,0 +1,28 @@
1
+ # examples/lambda_handlers/routes
2
+
3
+ # OTTO - LAMBDA / INLINE ROUTE HANDLERS (issue #41)
4
+ #
5
+ # A route target prefixed with '&' names a pre-registered lambda handler
6
+ # instead of a class/method. The name after '&' is looked up O(1) from the
7
+ # lambda_handlers registry supplied to Otto.new -- never eval'd.
8
+ #
9
+ # The target comes first; route options follow it.
10
+ #
11
+ # Compare the four handler kinds:
12
+ # App.index -> :class (class method)
13
+ # App#index -> :instance (instance method)
14
+ # BareClass -> :logic (Logic object)
15
+ # &health_check -> :lambda (pre-registered proc) <-- this file
16
+
17
+ # Plain-text string responder (default response handler).
18
+ GET /ping &health_check
19
+
20
+ # Hash responder serialized as JSON.
21
+ GET /status &status response=json
22
+
23
+ # Reads a path param, returned as JSON.
24
+ GET /greet/:name &greet response=json
25
+
26
+ # Webhook receiver: marked csrf=exempt (option is parsed/exposed but not yet
27
+ # enforced by CSRFMiddleware -- see issue #186).
28
+ POST /webhook &webhook csrf=exempt response=json
@@ -3,15 +3,46 @@
3
3
 
4
4
  # Otto GeoResolver Extension Guide
5
5
  #
6
- # This guide shows two approaches to extend Otto's IP geolocation:
7
- # 1. Configuration-based (simple, inline)
8
- # 2. Subclass-based (full control)
6
+ # Otto resolves a country code in this order (first hit wins):
7
+ # 1. App-configured trusted header (configure_ip_privacy(geo_header:))
8
+ # 2. Built-in CDN/provider headers (Cloudflare, AWS, Vercel, ...)
9
+ # 3. Custom resolver hook (GeoResolver.custom_resolver = ...)
10
+ # 4. Local MMDB database (configure_ip_privacy(geo_db_path:/geo_db_reader:))
11
+ # 5. '**' (unknown) Otto does not guess from a hardcoded table
12
+ #
13
+ # This guide shows the extension points:
14
+ # A. Built-in configuration (trusted header + local database) — no code
15
+ # B. Custom resolver hook (inline or a callable object)
16
+ # C. Subclass-based (full control)
9
17
 
10
18
  require 'bundler/setup'
11
19
  require 'otto'
12
20
 
13
21
  # =============================================================================
14
- # Quick Start: Configuration-based Extension
22
+ # A. Built-in configuration: trusted header + local country database
23
+ # =============================================================================
24
+ #
25
+ # No custom code needed — just configure the Otto instance. The database is
26
+ # looked up on the already-MASKED IP, and a bad geo_db_path fails at boot.
27
+ #
28
+ # otto = Otto.new('routes.txt')
29
+ # otto.configure_ip_privacy(
30
+ # geo_header: 'X-Client-Country', # trusted header checked before CDN headers
31
+ # geo_db_path: 'data/country.mmdb' # offline fallback (needs the maxmind-db gem)
32
+ # )
33
+ #
34
+ # Prefer to bring your own reader (any object responding to #get)? Inject it —
35
+ # this keeps the reader/data-source choice independent of Otto:
36
+ #
37
+ # reader = MaxMind::DB.new('data/country.mmdb', mode: MaxMind::DB::MODE_MEMORY)
38
+ # otto.configure_ip_privacy(geo_db_reader: reader)
39
+ #
40
+ # Security note: geo headers are only trusted for requests that arrive via a
41
+ # configured trusted proxy (add_trusted_proxy), since they are client-spoofable
42
+ # otherwise. configure_ip_privacy(geo: false) disables geo entirely.
43
+
44
+ # =============================================================================
45
+ # B. Quick Start: Custom resolver hook
15
46
  # =============================================================================
16
47
 
17
48
  puts 'Simple Custom Geo Resolution'
@@ -31,7 +62,9 @@ Otto::Privacy::GeoResolver.custom_resolver = custom_resolver
31
62
 
32
63
  # Step 3: Test it
33
64
  puts "1.2.3.4 -> #{Otto::Privacy::GeoResolver.resolve('1.2.3.4', {})}"
34
- puts "8.8.8.8 -> #{Otto::Privacy::GeoResolver.resolve('8.8.8.8', {})} (fallback)"
65
+ # Resolver returns nil for 8.8.8.8, and there is no header or database, so the
66
+ # honest answer is '**' (unknown) — Otto does not guess.
67
+ puts "8.8.8.8 -> #{Otto::Privacy::GeoResolver.resolve('8.8.8.8', {})} (unknown)"
35
68
 
36
69
  # Reset for next example
37
70
  Otto::Privacy::GeoResolver.custom_resolver = nil
@@ -2,8 +2,6 @@
2
2
  #
3
3
  # frozen_string_literal: true
4
4
 
5
- require 'ipaddr'
6
-
7
5
  require_relative '../utils'
8
6
 
9
7
  class Otto
@@ -22,11 +20,15 @@ class Otto
22
20
  #
23
21
  # == Security: authenticate the RAW peer, not the resolved client IP
24
22
  #
25
- # The guard reads the ORIGINAL +env['REMOTE_ADDR']+ the TCP socket peer —
26
- # and MUST run before +IPPrivacyMiddleware+ rewrites +REMOTE_ADDR+ from
27
- # forwarded headers. Installed via +Otto#use+ (appended, hence outermost in
28
- # the reduce-built stack) it always executes ahead of +IPPrivacyMiddleware+
29
- # (which is pinned innermost), so it inspects the true socket peer.
23
+ # The guard authenticates the TCP socket peer as it arrived, before
24
+ # +IPPrivacyMiddleware+ rewrites +REMOTE_ADDR+ from forwarded headers.
25
+ # +IPPrivacyMiddleware+ is pinned OUTERMOST (issue #219), so it runs ahead
26
+ # of this guard and records its verdict on the untouched peer as
27
+ # +env['otto.peer_loopback']+ a boolean, never an address. The guard reads
28
+ # that record when present and falls back to evaluating +REMOTE_ADDR+
29
+ # itself when it is not (no Otto privacy middleware in the stack, or the
30
+ # guard mounted outside Otto). Either way the decision is made on the raw
31
+ # peer.
30
32
  #
31
33
  # Reading Otto's resolved +otto.client_ip+ (or the rewritten +REMOTE_ADDR+)
32
34
  # would be exploitable: a co-located reverse proxy on loopback is itself a
@@ -92,11 +94,28 @@ class Otto
92
94
  # @param env [Hash] Rack environment
93
95
  # @return [Boolean]
94
96
  def direct_local_call?(env)
95
- loopback_peer?(env['REMOTE_ADDR']) && !relayed?(env)
97
+ loopback_peer?(env) && !relayed?(env)
96
98
  end
97
99
 
98
100
  # Whether any forwarding header is present (request came via a proxy).
99
101
  #
102
+ # Unlike the peer check, this reads header STATE, which IPPrivacyMiddleware
103
+ # has already touched by the time the guard runs. That is safe in both of
104
+ # its paths, but only for a reason worth writing down:
105
+ #
106
+ # - Masking REWRITES a forwarded header to the masked IP rather than
107
+ # removing it, so a relayed request still looks relayed. Correct — it was.
108
+ # - The no-resolvable-client-IP path DELETES them, which would make a
109
+ # relayed request look direct. That path is reached only when REMOTE_ADDR
110
+ # is absent or blank, which forces otto.peer_loopback to false, so
111
+ # #direct_local_call? denies on the peer check before this one matters.
112
+ #
113
+ # So header deletion upstream cannot turn a deny into an allow — but that
114
+ # rests on the peer check failing closed for a blank address. Anything that
115
+ # makes an unresolvable-IP request keep a loopback peer verdict would need
116
+ # to record the relay state pre-scrub too (an otto.peer_relayed sibling to
117
+ # otto.peer_loopback).
118
+ #
100
119
  # @param env [Hash] Rack environment
101
120
  # @return [Boolean]
102
121
  def relayed?(env)
@@ -125,28 +144,27 @@ class Otto
125
144
  Otto::Utils.normalize_path(path)
126
145
  end
127
146
 
128
- # Whether the connecting peer is a loopback address. Fails closed: a
129
- # blank or otherwise unparseable value is treated as non-loopback
130
- # (denied) rather than raising on the hot path.
147
+ # Whether the connecting peer is a loopback address.
131
148
  #
132
- # +.native+ folds IPv4-mapped IPv6 (+::ffff:127.0.0.1+, which dual-stack
133
- # servers commonly present) so it is correctly recognized as loopback;
134
- # plain +IPAddr#loopback?+ returns false for the mapped form.
149
+ # Prefers +env['otto.peer_loopback']+ IPPrivacyMiddleware's verdict on
150
+ # the ORIGINAL peer, recorded before it rewrites +REMOTE_ADDR+ (it runs
151
+ # outermost, so by the time this guard sees the env the address may
152
+ # already be the resolved-and-masked client IP). Only a real Boolean is
153
+ # honored; anything else falls through to evaluating +REMOTE_ADDR+, which
154
+ # is the correct source when no privacy middleware ran.
135
155
  #
136
- # A conforming Rack server sets +REMOTE_ADDR+ to a bare IP (the peer's
137
- # port lives in +REMOTE_PORT+). We deliberately do NOT strip a +:port+
138
- # suffix here: an unexpected format is a signal something upstream is
139
- # non-standard, so denying (fail-closed) is safer than coercing it.
156
+ # Both paths share +Otto::Utils.loopback_address?+, so the recorded
157
+ # verdict and the fallback cannot disagree. It fails closed: a blank,
158
+ # ported, or unparseable value is treated as non-loopback (denied) rather
159
+ # than raising on the hot path.
140
160
  #
141
- # @param remote_addr [String, nil] the raw socket peer address
161
+ # @param env [Hash] Rack environment
142
162
  # @return [Boolean]
143
- def loopback_peer?(remote_addr)
144
- addr = remote_addr.to_s.strip
145
- return false if addr.empty?
163
+ def loopback_peer?(env)
164
+ recorded = env['otto.peer_loopback']
165
+ return recorded if [true, false].include?(recorded)
146
166
 
147
- IPAddr.new(addr).native.loopback?
148
- rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
149
- false
167
+ Otto::Utils.loopback_address?(env['REMOTE_ADDR'])
150
168
  end
151
169
 
152
170
  # @return [Array] 401 Rack response tuple
@@ -100,6 +100,98 @@ class Otto
100
100
  @mcp_server.enable!(mcp_options)
101
101
  end
102
102
 
103
+ # Validate and freeze the lambda handler registry supplied at construction
104
+ # (issue #41, AC#3). Security: only pre-registered callables are accepted;
105
+ # nothing from route files reaches here, so no eval / dynamic code (AC#8).
106
+ def configure_lambda_handlers(opts)
107
+ @option[:lambda_handlers] = validate_lambda_handlers!(opts[:lambda_handlers])
108
+ end
109
+
110
+ # @raise [ArgumentError] naming the offending handler on any invalid entry
111
+ # @return [Hash] frozen registry ({}.freeze when none supplied)
112
+ #
113
+ # Keys are normalized to Strings so lookups from +&name+ routes (whose
114
+ # target is always a String parsed from the route file) resolve regardless
115
+ # of whether the caller registered handlers under Symbol or String keys.
116
+ # A fresh Hash is built and frozen so the caller's input object is never
117
+ # mutated in place.
118
+ def validate_lambda_handlers!(handlers)
119
+ return {}.freeze if handlers.nil?
120
+
121
+ unless handlers.is_a?(Hash)
122
+ raise ArgumentError,
123
+ "Otto :lambda_handlers must be a Hash of name => callable, got #{handlers.class}"
124
+ end
125
+
126
+ registry = {}
127
+
128
+ handlers.each do |name, handler|
129
+ key = name.to_s
130
+ if key.strip.empty?
131
+ raise ArgumentError,
132
+ "Lambda handler name #{name.inspect} is blank " \
133
+ '(expected a non-empty name matching the &handler_name route target)'
134
+ end
135
+
136
+ if registry.key?(key)
137
+ raise ArgumentError,
138
+ "Lambda handler name #{key.inspect} is registered more than once " \
139
+ '(String and Symbol keys collide once normalized to a String)'
140
+ end
141
+
142
+ unless handler.respond_to?(:call)
143
+ raise ArgumentError,
144
+ "Lambda handler '#{key}' is not callable (expected an object " \
145
+ "responding to #call, got #{handler.class})"
146
+ end
147
+
148
+ unless lambda_handler_accepts_three?(handler)
149
+ raise ArgumentError,
150
+ "Lambda handler '#{key}' has invalid arity " \
151
+ '(must accept 3 arguments: req, res, extra_params)'
152
+ end
153
+
154
+ registry[key] = handler
155
+ end
156
+
157
+ registry.freeze
158
+ end
159
+
160
+ # True if +handler+ can be invoked with exactly three positional arguments.
161
+ #
162
+ # Reflects on the callable's #parameters rather than #arity so that:
163
+ # * non-Proc/Method callables (a plain object with #call) are supported
164
+ # without ever calling #arity, which they need not define (BUG A); and
165
+ # * optional-arg forms that cannot actually take 3 positional args are
166
+ # rejected instead of blanket-accepted by a negative arity (BUG B) --
167
+ # e.g. ->(a=1){} (accepts 0..1) and ->(a,b=1){} (accepts 1..2).
168
+ #
169
+ # Accepts req/opt/rest combinations that admit 3 positionals; rejects
170
+ # anything requiring more than 3 or unable to reach 3.
171
+ #
172
+ # @api private
173
+ # @return [Boolean]
174
+ def lambda_handler_accepts_three?(handler)
175
+ callable =
176
+ if handler.is_a?(Proc) || handler.is_a?(Method)
177
+ handler
178
+ else
179
+ handler.method(:call)
180
+ end
181
+ params = callable.parameters
182
+ required = params.count { |(type, _)| type == :req }
183
+ optional = params.count { |(type, _)| type == :opt }
184
+ has_rest = params.any? { |(type, _)| type == :rest }
185
+
186
+ return false if required > 3
187
+
188
+ has_rest || (required + optional) >= 3
189
+ rescue NameError, NoMethodError
190
+ # A pathological callable whose #method(:call) reflection blows up still
191
+ # yields a clean, handler-named ArgumentError from the caller.
192
+ false
193
+ end
194
+
103
195
  # Configure locale settings for the application
104
196
  #
105
197
  # @param available_locales [Hash] Hash of available locales (e.g., { 'en' => 'English', 'es' => 'Spanish' })
@@ -190,8 +282,18 @@ class Otto
190
282
  # Deep freeze route structures (prevent modification of nested hashes/arrays)
191
283
  deep_freeze_value(@routes) if @routes
192
284
  deep_freeze_value(@routes_literal) if @routes_literal
193
- deep_freeze_value(@routes_static) if @routes_static
285
+ # @routes_static is intentionally NOT deep-frozen: its :GET entry is a
286
+ # Concurrent::Map that lazy static-file discovery writes into at
287
+ # request time (Core::Router#handle_request, Core::FileSafety#add_static_path),
288
+ # after this method has already run. Deep-freezing it would turn the
289
+ # first request for any as-yet-uncached static file into a
290
+ # FrozenError / 500 in production (issue #185). The outer hash is
291
+ # still shallow-frozen so its verb-key structure (currently just
292
+ # :GET) can't be altered post-freeze, while the Concurrent::Map value
293
+ # stays writable.
294
+ @routes_static.freeze if @routes_static && !@routes_static.frozen?
194
295
  deep_freeze_value(@route_definitions) if @route_definitions
296
+ deep_freeze_value(@routes_by_definition) if @routes_by_definition
195
297
 
196
298
  @configuration_frozen = true
197
299