otto 2.5.0 → 2.6.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ruby-lint.yml +1 -1
  3. data/.github/workflows/yardoc.yml +1 -1
  4. data/CHANGELOG.rst +65 -0
  5. data/Gemfile +1 -1
  6. data/Gemfile.lock +5 -5
  7. data/examples/advanced_routes/README.md +49 -0
  8. data/examples/advanced_routes/config.rb +15 -2
  9. data/examples/advanced_routes/routes +12 -0
  10. data/examples/lambda_handlers/README.md +128 -0
  11. data/examples/lambda_handlers/config.ru +26 -0
  12. data/examples/lambda_handlers/handlers.rb +75 -0
  13. data/examples/lambda_handlers/routes +28 -0
  14. data/lib/otto/core/configuration.rb +103 -1
  15. data/lib/otto/core/router.rb +67 -10
  16. data/lib/otto/core/uri_generator.rb +36 -2
  17. data/lib/otto/env_keys.rb +11 -0
  18. data/lib/otto/errors.rb +7 -0
  19. data/lib/otto/mcp/route_parser.rb +15 -4
  20. data/lib/otto/privacy/config.rb +37 -1
  21. data/lib/otto/privacy/core.rb +18 -1
  22. data/lib/otto/privacy/redacted_fingerprint.rb +4 -20
  23. data/lib/otto/privacy/user_agent_privacy.rb +64 -0
  24. data/lib/otto/privacy.rb +1 -0
  25. data/lib/otto/request.rb +27 -0
  26. data/lib/otto/route.rb +103 -41
  27. data/lib/otto/route_definition.rb +56 -6
  28. data/lib/otto/route_handlers/base.rb +4 -0
  29. data/lib/otto/route_handlers/factory.rb +15 -0
  30. data/lib/otto/route_handlers/lambda.rb +47 -32
  31. data/lib/otto/security/config.rb +100 -5
  32. data/lib/otto/security/csp/policy.rb +135 -3
  33. data/lib/otto/security/csrf_enforcement_wrapper.rb +68 -0
  34. data/lib/otto/security/csrf_validation.rb +75 -0
  35. data/lib/otto/security/middleware/csrf_middleware.rb +15 -71
  36. data/lib/otto/security/middleware/ip_privacy_middleware.rb +34 -6
  37. data/lib/otto/security.rb +1 -0
  38. data/lib/otto/version.rb +1 -1
  39. data/lib/otto.rb +26 -2
  40. metadata +9 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 343eb2f324f142437e65c4d5659201363a13eb68d599891146a2aad426da817e
4
- data.tar.gz: 542f896395a10158c89025cc3a6685d837ad63fa4aeb93deca81744f3a7d7e92
3
+ metadata.gz: de513e2f41fa9d2400937ac2ed577878bd124d5f760558bd12635bf8ad6f296a
4
+ data.tar.gz: 4a69f3741d3a6dbbe5a7387e2504446ce690927be12ccf90fa814db1a893d13d
5
5
  SHA512:
6
- metadata.gz: 59073e6907b3f183b7bf81b376ec07ff6d30ddbadb1436bbc8b1711265c325026486d44645b8f1a39e2cc181d9fcba4e8661259321ce40eab006e1e837fb82de
7
- data.tar.gz: 4b2a0c746eb41f05971740593d04a45670869f35ce4b58d1aa74cc1a25052cbb32d0dec961ba60dfd31bc7fe0c6e990dbaad5e12396869240065bd461b7e8229
6
+ metadata.gz: 77ee1dda2b8b84e3848fb72fd737b88b6d6efca57cd0d4effe909451a310aee51ac207a3152848ebc838df52bd02c3153cb818e1bafdc1000282a374be965375
7
+ data.tar.gz: 8622e8974f115eee90940a157ea93180f2e4aaa05295f5362ca24f068b359b7d2a1396c42483e4d4fa37b9e8fbee7056460230eb68227033134eda09e5e4d8cc
@@ -41,7 +41,7 @@ jobs:
41
41
 
42
42
  steps:
43
43
  - name: Checkout code
44
- uses: actions/checkout@v6.0.3
44
+ uses: actions/checkout@v7.0.0
45
45
 
46
46
  - uses: ruby/setup-ruby@v1
47
47
  with:
@@ -42,7 +42,7 @@ jobs:
42
42
 
43
43
  steps:
44
44
  - name: Checkout repository
45
- uses: actions/checkout@v6.0.3
45
+ uses: actions/checkout@v7.0.0
46
46
  with:
47
47
  fetch-depth: 0
48
48
 
data/CHANGELOG.rst CHANGED
@@ -7,6 +7,71 @@ The format is based on `Keep a Changelog <https://keepachangelog.com/en/1.1.0/>`
7
7
 
8
8
  <!--scriv-insert-here-->
9
9
 
10
+ .. _changelog-2.6.0:
11
+
12
+ 2.6.0 — 2026-07-10
13
+ ==================
14
+
15
+ Added
16
+ -----
17
+
18
+ - ``Otto::Privacy::UserAgentPrivacy.anonymize(ua, max_length:)`` — public
19
+ User-Agent anonymization, mirroring ``Otto::Privacy::IPPrivacy``. Strips
20
+ build/version identifiers while preserving browser/OS family; idempotent.
21
+ (delano/otto#194)
22
+
23
+ - Stable-keyed IP correlation hash: ``req.ip_correlation_hash`` /
24
+ ``env['otto.privacy.correlation_hash']``, keyed via
25
+ ``configure_ip_privacy(correlation_secret:)`` so the same host correlates
26
+ across days without exposing the raw IP. (#192)
27
+
28
+ - CSP nonce policies can now be customized per-directive via
29
+ ``enable_csp_with_nonce!(directives:)`` and ``#csp_directive_overrides=`` /
30
+ ``#merge_csp_directives``, not just ``report-uri``/``report-to``. (#201)
31
+
32
+ Changed
33
+ -------
34
+
35
+ - ``RedactedFingerprint#anonymize_user_agent`` now delegates to
36
+ ``UserAgentPrivacy.anonymize``; behavior is unchanged. (delano/otto#194)
37
+
38
+ - Default CSP ``worker-src`` now emits ``'self' blob:`` instead of
39
+ ``'self' data:``. Restore the old value with
40
+ ``directives: { worker_src: "'self' data: blob:" }``. (#201)
41
+
42
+ Fixed
43
+ -----
44
+
45
+ - ``Otto#uri`` no longer corrupts when the same handler is mounted at
46
+ multiple paths — all routes per definition are kept, and ``uri()`` matches
47
+ on the params given. (#190)
48
+
49
+ - ``Route#call`` no longer builds a duplicate, discarded request/response
50
+ pair when a route handler factory is present. (#189)
51
+
52
+ - Dynamic static-file serving no longer raises ``FrozenError`` in production
53
+ on the first request for an uncached asset. (delano/otto#185)
54
+
55
+ Security
56
+ --------
57
+
58
+ - Route loading now fails closed on malformed input: unparseable options
59
+ warn, and malformed or mismatched-case ``auth``/``role``/``csrf`` tokens
60
+ raise ``Otto::RouteDefinitionError`` at load instead of silently serving
61
+ an unprotected route. (#191)
62
+
63
+ - ``klass.otto`` is no longer a shared mutable class accessor — it's now
64
+ fiber-local, preventing concurrent requests across ``Otto`` instances from
65
+ racing and observing the wrong security config. (#188)
66
+
67
+ - Dynamic-route and static-file dispatch now normalize request paths the
68
+ same way literal routing does, closing a trailing-slash and
69
+ invalid-UTF-8 divergence. (#187)
70
+
71
+ - ``csrf=exempt`` is now actually enforced at the handler layer; previously
72
+ it was a silent no-op. **Behavior change**: ``CSRFMiddleware`` used
73
+ standalone no longer blocks unsafe requests on its own. (#186)
74
+
10
75
  .. _changelog-2.5.0:
11
76
 
12
77
  2.5.0 — 2026-07-02
data/Gemfile CHANGED
@@ -28,7 +28,7 @@ group :development do
28
28
  gem 'debug'
29
29
  gem 'rackup' # Used to boot examples/ apps; not needed by specs
30
30
  gem 'rake', '~> 13.4', require: false # Provides `rake release` for release-gem.yml
31
- gem 'rubocop', '~> 1.88.0', require: false
31
+ gem 'rubocop', '~> 1.88.1', require: false
32
32
  gem 'rubocop-performance', require: false
33
33
  gem 'rubocop-rspec', require: false
34
34
  gem 'rubocop-thread_safety', require: false
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- otto (2.5.0)
4
+ otto (2.6.0)
5
5
  concurrent-ruby (~> 1.3, < 2.0)
6
6
  logger (~> 1, < 2.0)
7
7
  loofah (~> 2.20)
@@ -59,7 +59,7 @@ GEM
59
59
  prism (>= 1.3.0)
60
60
  rdoc (>= 4.0.0)
61
61
  reline (>= 0.4.2)
62
- json (2.19.9)
62
+ json (2.20.0)
63
63
  json_schemer (2.5.0)
64
64
  bigdecimal
65
65
  hana (~> 1.3)
@@ -67,7 +67,7 @@ GEM
67
67
  simpleidn (~> 0.2)
68
68
  kramdown (2.5.2)
69
69
  rexml (>= 3.4.4)
70
- language_server-protocol (3.17.0.5)
70
+ language_server-protocol (3.17.0.6)
71
71
  lint_roller (1.1.0)
72
72
  logger (1.7.0)
73
73
  loofah (2.25.1)
@@ -147,7 +147,7 @@ GEM
147
147
  diff-lcs (>= 1.2.0, < 2.0)
148
148
  rspec-support (~> 3.13.0)
149
149
  rspec-support (3.13.7)
150
- rubocop (1.88.0)
150
+ rubocop (1.88.1)
151
151
  json (~> 2.3)
152
152
  language_server-protocol (~> 3.17.0.2)
153
153
  lint_roller (~> 1.1.0)
@@ -225,7 +225,7 @@ DEPENDENCIES
225
225
  rake (~> 13.4)
226
226
  reek (~> 6.5)
227
227
  rspec (~> 3.13)
228
- rubocop (~> 1.88.0)
228
+ rubocop (~> 1.88.1)
229
229
  rubocop-performance
230
230
  rubocop-rspec
231
231
  rubocop-thread_safety
@@ -58,6 +58,55 @@ Add arbitrary key-value pairs for flexible routing:
58
58
  GET /admin AdminPanel#dashboard role=admin
59
59
  ```
60
60
 
61
+ ### Lambda / Inline Route Handlers (Issue #41)
62
+ Route to a proc that you **pre-register** by name, using the `&` prefix:
63
+ ```
64
+ GET /ping &health_check response=json
65
+ POST /webhook &receive_webhook response=json csrf=exempt
66
+ GET /go &to_dashboard response=redirect
67
+ ```
68
+
69
+ The `&name` token is a plain string key looked up (O(1)) in a registry you
70
+ supply at construction — the entire token after `&` is the key (dots, `#`, and
71
+ `::` are inert). Register the procs when you build Otto:
72
+ ```ruby
73
+ otto = Otto.new('routes', lambda_handlers: {
74
+ 'health_check' => ->(req, res, extra_params) {
75
+ { status: 'ok', at: Time.now.to_i } # response=json serializes this Hash
76
+ },
77
+ 'receive_webhook' => ->(req, res, extra_params) {
78
+ { received: true }
79
+ },
80
+ 'to_dashboard' => ->(req, res, extra_params) {
81
+ '/dashboard' # response=redirect uses this path
82
+ },
83
+ })
84
+ ```
85
+
86
+ The handler contract:
87
+
88
+ - Each proc is called with **`(req, res, extra_params)`** — `extra_params` is the
89
+ hash of path captures (e.g. `:id` from `/users/:id`).
90
+ - The proc must accept 3 arguments (fixed arity `3`, or a splat/optional form).
91
+ An invalid arity raises `ArgumentError` at construction.
92
+ - **All response types work** exactly as for controller routes:
93
+ `response=json` (serializes a returned Hash), `response=view` (`to_s` as HTML),
94
+ `response=redirect` (returned String is the `Location`), `response=auto`
95
+ (content negotiation). With the default response type the proc must write to
96
+ `res` directly, just like the other handler kinds.
97
+ - **Route options apply**: `csrf=exempt` (parse/expose parity with controllers),
98
+ `auth=`, `role=`, and custom path params all flow through unchanged.
99
+
100
+ Security guarantee (the point of this feature): route files never carry code.
101
+ `&name` is only ever a name; there is **no `eval` and no dynamic constant
102
+ loading**. A route naming an unregistered handler fails with a clear
103
+ `ArgumentError` ("Lambda handler '...' is not registered or not callable")
104
+ instead of executing anything. The registered procs are, of course, trusted
105
+ code that you wrote.
106
+
107
+ Note: `csrf=exempt` is parse-and-expose parity with controller routes — the CSRF
108
+ middleware does not enforce it for any handler kind.
109
+
61
110
  ## How to Run
62
111
 
63
112
  ### Using rackup (recommended)
@@ -4,8 +4,21 @@ require 'rack'
4
4
  require_relative '../../lib/otto'
5
5
  require_relative 'app'
6
6
 
7
- # Simple Otto configuration demonstrating advanced routes syntax
8
- otto = Otto.new('routes')
7
+ # Simple Otto configuration demonstrating advanced routes syntax.
8
+ #
9
+ # Lambda / inline route handlers (issue #41): procs are pre-registered by name
10
+ # and referenced from the routes file with the '&name' prefix. Lookup is O(1)
11
+ # by exact string — no eval, no dynamic code from the route file.
12
+ otto = Otto.new('routes', lambda_handlers: {
13
+ # GET /ping &health_check response=json
14
+ 'health_check' => ->(_req, _res, _extra_params) { { status: 'ok', at: Time.now.to_i } },
15
+
16
+ # POST /hooks/receive &receive_webhook response=json csrf=exempt
17
+ 'receive_webhook' => ->(req, _res, _extra_params) { { received: true, method: req.request_method } },
18
+
19
+ # GET /go/dashboard &to_dashboard response=redirect (returned String is the Location)
20
+ 'to_dashboard' => ->(_req, _res, _extra_params) { '/dashboard' },
21
+ })
9
22
 
10
23
  # Enable basic security features to demonstrate CSRF functionality
11
24
  otto.enable_csrf_protection!
@@ -93,6 +93,18 @@ GET /logic/nested/feature Nested::Feature::Logic
93
93
  POST /logic/complex/handler Complex::Business::Handler response=json
94
94
  PUT /logic/system/config System::Config::Manager response=json csrf=exempt
95
95
 
96
+ # ========================================
97
+ # LAMBDA / INLINE ROUTE HANDLERS (Issue #41)
98
+ # ========================================
99
+
100
+ # The '&name' prefix references a proc pre-registered via
101
+ # Otto.new('routes', lambda_handlers: { 'name' => ->(req,res,extra){ ... } })
102
+ # Lookup is O(1) by exact string name — no eval, no dynamic constants.
103
+ # An unregistered name fails with a clear error, never executes code.
104
+ GET /ping &health_check response=json
105
+ POST /hooks/receive &receive_webhook response=json csrf=exempt
106
+ GET /go/dashboard &to_dashboard response=redirect
107
+
96
108
  # ========================================
97
109
  # NAMESPACED CLASS ROUTES
98
110
  # ========================================
@@ -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
@@ -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