otto 2.10.0 → 2.12.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 (45) 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/.rubocop_todo.yml +3 -2
  10. data/AGENTS.md +18 -0
  11. data/CHANGELOG.rst +132 -0
  12. data/Gemfile +2 -2
  13. data/Gemfile.lock +8 -8
  14. data/README.md +6 -0
  15. data/docs/guides/configuration_freezing.md +17 -6
  16. data/docs/guides/forwarded-authority.md +5 -1
  17. data/docs/guides/privacy.md +5 -0
  18. data/docs/guides/routing.md +180 -5
  19. data/docs/guides/testing-guide.md +115 -2
  20. data/lib/otto/caddy_tls/localhost_guard.rb +6 -6
  21. data/lib/otto/core/configuration.rb +9 -4
  22. data/lib/otto/core/error_handler.rb +40 -2
  23. data/lib/otto/core/file_safety.rb +24 -8
  24. data/lib/otto/core/router.rb +53 -24
  25. data/lib/otto/core/static_mounts.rb +172 -0
  26. data/lib/otto/core.rb +1 -0
  27. data/lib/otto/env_keys.rb +2 -1
  28. data/lib/otto/privacy/config.rb +19 -13
  29. data/lib/otto/response.rb +5 -2
  30. data/lib/otto/route.rb +1 -1
  31. data/lib/otto/route_handlers/base.rb +1 -1
  32. data/lib/otto/route_handlers/logic_class.rb +86 -34
  33. data/lib/otto/security/config.rb +349 -303
  34. data/lib/otto/security/configurator.rb +82 -45
  35. data/lib/otto/security/core.rb +4 -2
  36. data/lib/otto/security/csp/report_middleware.rb +17 -1
  37. data/lib/otto/security/middleware/ip_privacy_middleware.rb +3 -5
  38. data/lib/otto/security/rate_limiter.rb +8 -5
  39. data/lib/otto/security/trusted_proxy_config.rb +396 -0
  40. data/lib/otto/static.rb +45 -6
  41. data/lib/otto/testing.rb +148 -0
  42. data/lib/otto/utils.rb +63 -14
  43. data/lib/otto/version.rb +1 -1
  44. data/lib/otto.rb +126 -2
  45. metadata +5 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ebb9d40d464e54cb0a25d0014dfa6868905e833c400701739d61463ae56b7265
4
- data.tar.gz: f111ef655d58256cece33215fe05a7711c5f1bd0e06925c41e58b0e492f6dfb5
3
+ metadata.gz: d60f9cd87b0c935f8c79ecc1aae11f4b9880794d57b0ee5309c53baafcb84dcf
4
+ data.tar.gz: d92225f2ab02c1fbbd9c6ab745c27373aa6f11608f27067ec4485ca4f81878b2
5
5
  SHA512:
6
- metadata.gz: c1381ef06000d127d7411f50e797dbf14720821873bc8f9b70e1e01a3a232fb7a8f2ebaee7e0ec4ed8a9d61dfc8cf8d67753de21702ef253dd42b4e1736949ea
7
- data.tar.gz: 7105c5561be5e5f3cfe771b41cc1d576c0ede262cecc92347cc518cbdd0c57deaefca50a923a535661a47ac6a977a1171a6dab9cb3f9ff59ca993c5a1a2d326a
6
+ metadata.gz: cf10b7fdf1245e1dd64140ef3a57fc69f2a42197ede66812f02a89f80f291ff3759a7b5245487fd0e7948e4da6534534dbe9d753e0d15cf0ea57366da2c928da
7
+ data.tar.gz: cba8ac893ef636d0e978061304704018907bba315160405561f41592613a250a762ac1a0ebad7de675984101f711f44aabec8693ac3445e4e1e6d9b8f7ffd788
@@ -74,7 +74,7 @@ jobs:
74
74
  steps:
75
75
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
76
76
  - name: Set up Ruby
77
- uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
77
+ uses: ruby/setup-ruby@984c0c890880bbf811283d6f09c4607c62d210a4 # v1.323.0
78
78
  continue-on-error: ${{ matrix.experimental }}
79
79
  with:
80
80
  ruby-version: ${{ matrix.ruby }}
@@ -61,7 +61,7 @@ jobs:
61
61
 
62
62
  - name: Run Claude Code Review
63
63
  id: claude-review
64
- uses: anthropics/claude-code-action@d75b94d5ad426cb8546e6628b6f5f19b84e5cce1 # v1.0.216
64
+ uses: anthropics/claude-code-action@7b0b255830a1fab6e602658672acad11c12d841d # v1.0.226
65
65
  with:
66
66
  claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
67
67
 
@@ -32,7 +32,7 @@ jobs:
32
32
 
33
33
  - name: Run Claude Code
34
34
  id: claude
35
- uses: anthropics/claude-code-action@d75b94d5ad426cb8546e6628b6f5f19b84e5cce1 # v1.0.216
35
+ uses: anthropics/claude-code-action@7b0b255830a1fab6e602658672acad11c12d841d # v1.0.226
36
36
  with:
37
37
  claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
38
38
 
@@ -24,7 +24,7 @@ jobs:
24
24
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
25
25
 
26
26
  - name: Set up Ruby
27
- uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
27
+ uses: ruby/setup-ruby@984c0c890880bbf811283d6f09c4607c62d210a4 # v1.323.0
28
28
  with:
29
29
  ruby-version: 3.4
30
30
 
@@ -91,7 +91,7 @@ jobs:
91
91
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
92
92
 
93
93
  - name: Set up Ruby
94
- uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
94
+ uses: ruby/setup-ruby@984c0c890880bbf811283d6f09c4607c62d210a4 # v1.323.0
95
95
  with:
96
96
  ruby-version: 3.4
97
97
  bundler-cache: true
@@ -139,7 +139,7 @@ jobs:
139
139
  persist-credentials: false
140
140
 
141
141
  - name: Set up Ruby
142
- uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
142
+ uses: ruby/setup-ruby@984c0c890880bbf811283d6f09c4607c62d210a4 # v1.323.0
143
143
  with:
144
144
  bundler-cache: true
145
145
  # Pinned to 3.3 (oldest non-experimental Ruby in the CI matrix).
@@ -46,7 +46,7 @@ jobs:
46
46
  - name: Checkout code
47
47
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
48
48
 
49
- - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
49
+ - uses: ruby/setup-ruby@984c0c890880bbf811283d6f09c4607c62d210a4 # v1.323.0
50
50
  with:
51
51
  ruby-version: ${{ matrix.ruby }}
52
52
  bundler-cache: true
@@ -47,7 +47,7 @@ jobs:
47
47
  fetch-depth: 0
48
48
 
49
49
  - name: Set up Ruby environment
50
- uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
50
+ uses: ruby/setup-ruby@984c0c890880bbf811283d6f09c4607c62d210a4 # v1.323.0
51
51
  with:
52
52
  ruby-version: '3.4'
53
53
  bundler-cache: true
data/.rubocop_todo.yml CHANGED
@@ -412,10 +412,10 @@ Metrics/ModuleLength:
412
412
  Exclude:
413
413
  - 'lib/otto/design_system.rb'
414
414
 
415
- # Offense count: 4
415
+ # Offense count: 6
416
416
  # Configuration parameters: CountKeywordArgs, MaxOptionalParameters.
417
417
  Metrics/ParameterLists:
418
- Max: 11
418
+ Max: 7
419
419
 
420
420
  # Offense count: 46
421
421
  # Configuration parameters: AllowedMethods, AllowedPatterns.
@@ -859,6 +859,7 @@ RSpec/SpecFilePathFormat:
859
859
  - 'spec/otto/enhanced_routing_spec.rb'
860
860
  - 'spec/otto/error_handler_registration_spec.rb'
861
861
  - 'spec/otto/error_handling_spec.rb'
862
+ - 'spec/otto/fallback_response_isolation_spec.rb'
862
863
  - 'spec/otto/file_safety_spec.rb'
863
864
  - 'spec/otto/locale_config_spec.rb'
864
865
  - 'spec/otto/mcp/rate_limiting_spec.rb'
data/AGENTS.md CHANGED
@@ -69,6 +69,24 @@ Helper modules should avoid overriding these methods inherited from Rack::Reques
69
69
 
70
70
  No runtime validation is performed for performance reasons. Overriding these methods will cause undefined behavior.
71
71
 
72
+ ## Static File Registration
73
+
74
+ Files under the `public:` directory are served without registration. Use
75
+ `mount_static` to bind a URL prefix to a directory outside it, or to verify a
76
+ required asset directory at boot:
77
+
78
+ ```ruby
79
+ otto = Otto.new('routes.txt', public: 'public')
80
+ otto.mount_static('/assets', root: 'build/assets')
81
+ ```
82
+
83
+ - Roots are canonicalized at registration; a missing or unsafe root raises `ArgumentError`
84
+ - Precedence is fixed: literal routes, then mounts (longest prefix first), then `public:`, then dynamic routes
85
+ - Must be registered before first request (before configuration freezing)
86
+ - `add_static_path` was removed in v2.10.0 and has no shim
87
+
88
+ See `docs/guides/routing.md` for the full contract.
89
+
72
90
  ## Authentication Architecture
73
91
 
74
92
  Authentication is handled by `RouteAuthWrapper` at the handler level, NOT by middleware.
data/CHANGELOG.rst CHANGED
@@ -7,6 +7,138 @@ 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.12.0:
11
+
12
+ 2.12.0 — 2026-09-26
13
+ ===================
14
+
15
+ Added
16
+ -----
17
+
18
+ - ``referrer_policy`` sets the Referrer-Policy header on routed responses,
19
+ static files, built-in 404 and 500 responses, and authentication failures.
20
+ Pass one W3C policy token to ``Otto.new`` or assign it before the first
21
+ request. A value a route handler sets on its own response is kept. See
22
+ ``docs/guides/testing-guide.md``. (#281)
23
+
24
+ - Logic classes can declare a ``route_params:`` keyword on ``initialize`` to
25
+ receive the router's path captures separately from caller-supplied
26
+ parameters. (#285)
27
+
28
+ - ``Otto::Utils.routing_path(env)`` returns the normalized path the router
29
+ matches, so middleware can judge a request by the same path the router
30
+ dispatches. ``include_mount: true`` includes ``SCRIPT_NAME``. See
31
+ ``docs/guides/routing.md``. (#288)
32
+
33
+ - ``require 'otto/testing'`` adds test support for any test framework.
34
+ ``Otto::Testing.reset!`` clears Otto's process-global forwarding state
35
+ between tests. ``Otto::Testing.env_for`` and ``resolve_client_ip!`` build a
36
+ Rack env whose ``otto.client_ip`` and ``otto.ip_match`` are resolved by
37
+ ``IPPrivacyMiddleware``. See ``docs/guides/testing-guide.md``. (#287)
38
+
39
+ - ``Otto::Security::Config#trusted_proxy_mode`` returns ``:filter``,
40
+ ``:depth``, ``:none``, or nil. (#148)
41
+
42
+ Changed
43
+ -------
44
+
45
+ - A ``referrer-policy`` value set through ``security_headers`` is now
46
+ validated like ``referrer_policy``. An unknown token or a comma-separated
47
+ fallback list raises ``ArgumentError`` at configuration time, and the header
48
+ can no longer be removed from ``security_headers``. (#281)
49
+
50
+ - Trusted-proxy state moved into a private
51
+ ``Otto::Security::TrustedProxyConfig``, and
52
+ ``Security::Configurator#configure`` now takes ``**options``. Public
53
+ methods, accepted keys, and behavior are unchanged. (#148)
54
+
55
+ - The forwarding-family conflict error and the warning for a hand-set
56
+ ``otto.client_ip`` now name the ``Otto::Testing`` helper that addresses
57
+ each. ``Otto::Security::Config.reset_rack_forwarding_family_for_testing!``
58
+ remains for existing RSpec callers. (#287)
59
+
60
+ Fixed
61
+ -----
62
+
63
+ - Requests whose ``PATH_INFO`` contains a malformed percent-escape (``%zz``)
64
+ or a raw invalid byte are now routed instead of answering 500. (#288)
65
+
66
+ - The general rate-limit throttle now skips internal paths by the path the
67
+ router dispatches, so ``/%5Fmcp`` is skipped like ``/_mcp``. (#288)
68
+
69
+ - ``Otto::Privacy::Config#profile=`` now sets both ``disabled`` and
70
+ ``mask_private_ips`` for every profile. Switching to ``:audit`` previously
71
+ left ``mask_private_ips`` unchanged, so a later ``enable!`` could restore
72
+ ``:anonymous`` instead of ``:masked``. (#286)
73
+
74
+ - CSP violation reports now reach the receiver when Otto is mounted under a
75
+ sub-path. The receiver matches ``csp_report_uri`` against ``SCRIPT_NAME`` +
76
+ ``PATH_INFO``, so configure the site-absolute path including the mount
77
+ prefix, such as ``/api/csp-report`` under ``map '/api'``. A mount-relative
78
+ value no longer matches. Trailing-slash and percent-encoded spellings of
79
+ the report path are also matched. (#289)
80
+
81
+ Security
82
+ --------
83
+
84
+ - JSON request bodies can no longer override path captures, query
85
+ parameters, or form fields in Logic class parameters; JSON keys now merge
86
+ below them. JSON bodies are no longer parsed for ``GET`` or ``HEAD``
87
+ requests. Before upgrading, check Logic classes that read a JSON body on
88
+ ``GET`` or expect a JSON key to replace a query or path value. (#285)
89
+
90
+ Documentation
91
+ -------------
92
+
93
+ - ``docs/guides/routing.md`` covers matching request paths in middleware,
94
+ including apps mounted under a sub-path. ``docs/guides/testing-guide.md``
95
+ covers ``Otto::Testing`` and Referrer-Policy configuration. (#281, #287,
96
+ #288)
97
+
98
+ AI Assistance
99
+ -------------
100
+
101
+ - The ``TrustedProxyConfig`` refactor was implemented with AI assistance and
102
+ checked against the previous release by a differential run of 1,620
103
+ trusted-proxy operation sequences and 29 ``configure`` calls, with no
104
+ difference in outcome. (#148)
105
+
106
+ - ``Otto::Testing`` helpers, specs, and guide updates were written with AI
107
+ assistance. The reset was also exercised without RSpec, under Tryouts and
108
+ Minitest. (#287)
109
+
110
+ .. _changelog-2.11.0:
111
+
112
+ 2.11.0 — 2026-09-12
113
+ ===================
114
+
115
+ Added
116
+ -----
117
+
118
+ - ``Otto#mount_static(prefix, root:)`` serves an explicit directory at a URL
119
+ prefix. See ``docs/guides/routing.md`` for configuration, dispatch
120
+ precedence, and migration from ``add_static_path``. (#267)
121
+
122
+ - ``Otto#not_found=`` and ``Otto#server_error=`` now accept callables for
123
+ per-request fallback responses. A server-error callable can receive the
124
+ exception; see ``docs/guides/routing.md`` for the callback contract. (#272)
125
+
126
+ Security
127
+ --------
128
+
129
+ - Static Rack triples configured with ``Otto#not_found=`` or
130
+ ``Otto#server_error=`` are now copied for each request, preventing in-place
131
+ header changes, including ``Set-Cookie``, from being shared between fallback
132
+ responses. (#272)
133
+
134
+ Documentation
135
+ -------------
136
+
137
+ - Documented static-file dispatch precedence and migration from the removed
138
+ ``add_static_path`` API in ``docs/guides/routing.md``. Corrected stale
139
+ ``routes_static`` cache guidance in ``docs/guides/configuration_freezing.md``.
140
+ (#267)
141
+
10
142
  .. _changelog-2.10.0:
11
143
 
12
144
  2.10.0 — 2026-09-04
data/Gemfile CHANGED
@@ -19,7 +19,7 @@ end
19
19
  group :development, :test, optional: true do
20
20
  # Keep gems that need to be in both environments
21
21
  gem 'json_schemer', '~> 2.0'
22
- gem 'maxmind-db', '~> 1.2' # Optional geo DB reader; exercised by geo specs
22
+ gem 'maxmind-db', '~> 1.5' # Optional geo DB reader; exercised by geo specs
23
23
  gem 'rack-attack', '~> 6.7'
24
24
  gem 'reek', '~> 6.5'
25
25
  end
@@ -29,7 +29,7 @@ group :development do
29
29
  gem 'debug'
30
30
  gem 'rackup' # Used to boot examples/ apps; not needed by specs
31
31
  gem 'rake', '~> 13.4', require: false # Provides `rake release` for release-gem.yml
32
- gem 'rubocop', '~> 1.90.0', require: false
32
+ gem 'rubocop', '~> 1.91.0', require: false
33
33
  gem 'rubocop-performance', require: false
34
34
  gem 'rubocop-rspec', require: false
35
35
  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.10.0)
4
+ otto (2.12.0)
5
5
  concurrent-ruby (~> 1.3, < 2.0)
6
6
  logger (~> 1, < 2.0)
7
7
  loofah (~> 2.20)
@@ -57,7 +57,7 @@ GEM
57
57
  prism (>= 1.3.0)
58
58
  rdoc (>= 4.0.0)
59
59
  reline (>= 0.4.2)
60
- json (2.21.2)
60
+ json (3.0.2)
61
61
  json_schemer (2.5.0)
62
62
  bigdecimal
63
63
  hana (~> 1.3)
@@ -71,7 +71,7 @@ GEM
71
71
  loofah (2.25.2)
72
72
  crass (~> 1.0.2)
73
73
  nokogiri (>= 1.12.0)
74
- maxmind-db (1.4.0)
74
+ maxmind-db (1.5.0)
75
75
  minitest (5.26.0)
76
76
  nokogiri (1.19.4-aarch64-linux-gnu)
77
77
  racc (~> 1.4)
@@ -144,7 +144,7 @@ GEM
144
144
  diff-lcs (>= 1.2.0, < 2.0)
145
145
  rspec-support (~> 3.13.0)
146
146
  rspec-support (3.13.7)
147
- rubocop (1.90.0)
147
+ rubocop (1.91.0)
148
148
  json (>= 2.3)
149
149
  language_server-protocol (~> 3.17.0.2)
150
150
  lint_roller (~> 1.1.0)
@@ -166,9 +166,9 @@ GEM
166
166
  lint_roller (~> 1.1)
167
167
  regexp_parser (>= 2.0)
168
168
  rubocop (~> 1.86, >= 1.86.2)
169
- rubocop-thread_safety (0.7.3)
169
+ rubocop-thread_safety (0.8.0)
170
170
  lint_roller (~> 1.1)
171
- rubocop (~> 1.72, >= 1.72.1)
171
+ rubocop (~> 1.89)
172
172
  rubocop-ast (>= 1.44.0, < 2.0)
173
173
  ruby-lsp (0.26.11)
174
174
  language_server-protocol (~> 3.17.0)
@@ -215,7 +215,7 @@ DEPENDENCIES
215
215
  debug
216
216
  json_schemer (~> 2.0)
217
217
  kramdown
218
- maxmind-db (~> 1.2)
218
+ maxmind-db (~> 1.5)
219
219
  otto!
220
220
  rack-attack (~> 6.7)
221
221
  rack-test
@@ -223,7 +223,7 @@ DEPENDENCIES
223
223
  rake (~> 13.4)
224
224
  reek (~> 6.5)
225
225
  rspec (~> 3.13)
226
- rubocop (~> 1.90.0)
226
+ rubocop (~> 1.91.0)
227
227
  rubocop-performance
228
228
  rubocop-rspec
229
229
  rubocop-thread_safety
data/README.md CHANGED
@@ -278,6 +278,12 @@ middleware, so browsers can POST reports without a CSRF token — regardless of
278
278
  order you enable security features in. A throwing callback can never break the
279
279
  receiver; it still answers `204`.
280
280
 
281
+ The report path is the site-absolute path the browser POSTs to. When Otto is
282
+ mounted under a sub-path (`map '/api' { run otto }`), include the prefix:
283
+ `enable_csp_reporting!("/api/_/csp-report")`. The receiver matches it against
284
+ `SCRIPT_NAME` + `PATH_INFO`, so the same value works in the header and in the
285
+ receiver.
286
+
281
287
  Modern browsers (Chrome) have deprecated `report-uri` in favour of the Reporting
282
288
  API. Pass `endpoint_url:` — an **absolute** URL whose path is the report path —
283
289
  to also emit a `report-to` directive and a `Reporting-Endpoints` response header,
@@ -51,15 +51,17 @@ otto.add_auth_strategy('other', MyApp::OtherStrategy.new)
51
51
  - the middleware stack;
52
52
  - authentication configuration and instance options;
53
53
  - dynamic and literal route tables;
54
- - route definitions and reverse-route indexes.
54
+ - route definitions and reverse-route indexes;
55
+ - the explicit static mount table (`mount_static`).
55
56
 
56
57
  Hashes and arrays inside those structures are recursively frozen. Configuration
57
58
  objects that implement `deep_freeze!` can prepare memoized values before they
58
59
  are frozen.
59
60
 
60
- The static-file route structure is an intentional exception. Its outer hash is
61
- frozen, but the `routes_static[:GET]` `Concurrent::Map` remains writable because
62
- Otto caches newly discovered static paths during requests.
61
+ Static-file dispatch keeps no mutable routing state. Files under the implicit
62
+ `public:` directory are resolved on each request against the current canonical
63
+ root, and explicit mounts are an immutable snapshot from the moment they are
64
+ registered; `mount_static` raises `FrozenError` after the freeze boundary.
63
65
 
64
66
  ## Scope and current limitations
65
67
 
@@ -69,8 +71,12 @@ some state that is not included in `freeze_configuration!`:
69
71
 
70
72
  - `error_handlers` remains a mutable Hash, although
71
73
  `register_error_handler` rejects calls after freezing;
72
- - the `not_found` and `server_error` fallback response writers remain available;
73
- - the inner static-file cache remains mutable by design.
74
+ - the `not_found` and `server_error` fallback response writers remain
75
+ available. A configured static triple is never returned by reference:
76
+ Otto copies it per request, so header writes by cookie middleware do not
77
+ reach the configured object even though it is not frozen;
78
+ - the `Rack::Files` instance for the implicit `public:` directory is rebuilt
79
+ when that directory is repointed between requests.
74
80
 
75
81
  Application code should not mutate those objects directly after boot. Do not
76
82
  state or depend on a guarantee that every object reachable from an Otto instance
@@ -86,6 +92,9 @@ otto.security_config.disable_csrf_protection!
86
92
  otto.add_trusted_proxy('192.0.2.10')
87
93
  otto.add_rate_limit_rule('uploads', limit: 5, period: 60)
88
94
 
95
+ # Static mounts
96
+ otto.mount_static('/assets', root: 'public/assets')
97
+
89
98
  # Middleware and authentication
90
99
  otto.use MyApp::OtherMiddleware
91
100
  otto.add_auth_strategy('other', MyApp::OtherStrategy.new)
@@ -144,3 +153,5 @@ first requests therefore do not run the freeze operation concurrently.
144
153
  freezing behavior.
145
154
  - [Configuration-freezing specs](../../spec/otto/configuration_freezing_spec.rb).
146
155
  - [Static-file freezing specs](../../spec/otto/static_file_freezing_spec.rb).
156
+ - [Static mount specs](../../spec/otto/core/static_mounts_spec.rb) — registration
157
+ after the freeze boundary and serving from a frozen mount table.
@@ -190,10 +190,14 @@ process that both resolve proxied requests must agree. The later one raises:
190
190
  Cannot use forwarding family %s (trusted_proxy_header) because another Otto
191
191
  application in this process already uses %s. Rack's forwarded host, port,
192
192
  scheme, and IP policy is process-global, so every Otto application in one
193
- process that resolves proxied requests must use the same forwarding family.
193
+ process that resolves proxied requests must use the same forwarding family. A
194
+ test suite that builds applications with different families must clear this
195
+ between tests: require 'otto/testing' and call Otto::Testing.reset!.
194
196
  ```
195
197
 
196
198
  The two placeholders are the requested family and the already committed one.
199
+ See [Testing Otto applications](testing-guide.md#reset-ottos-process-global-state-between-tests)
200
+ for the reset.
197
201
 
198
202
  An application that configures no proxy trust, and one that asserts
199
203
  `trusted_proxies: :none`, read no forwarded chain and therefore stake no claim
@@ -154,6 +154,11 @@ the closure.
154
154
  allowed = req.env.fetch('otto.ip_match').call(['192.0.2.0/24', '2001:db8::/32'])
155
155
  ```
156
156
 
157
+ In tests, build the env with `Otto::Testing.env_for` rather than writing
158
+ `otto.client_ip` by hand; a hand-written value leaves `otto.ip_match` denying
159
+ every range. See
160
+ [Testing Otto applications](testing-guide.md#hand-a-harness-a-resolved-client-ip).
161
+
157
162
  ## Middleware placement
158
163
 
159
164
  When building a larger Rack stack, put privacy before components that log or
@@ -142,11 +142,31 @@ GET /products/:id Products::Show
142
142
 
143
143
  A handler can read `req.params[:id]` or a Logic class can read `params[:id]`.
144
144
  Request query and body parameters are merged according to the handler's request
145
- contract. JSON bodies are parsed for Logic-class parameters when the content
146
- type is JSON and the body is a JSON object. A valid non-object JSON body is
147
- ignored. Malformed JSON is logged and the Logic class still runs with its other
148
- parameters; perform application validation when malformed JSON must return a
149
- client error.
145
+ contract. Path captures are merged on top, so a caller cannot replace the value
146
+ the router matched by repeating the key in the query string or body. Between
147
+ the query string and a form body, Rack's own order applies (the form body
148
+ wins). For Logic classes a JSON body sits below all of those.
149
+
150
+ A Logic class that must not confuse a path value with a caller-supplied one
151
+ can take the captures separately by declaring a `route_params:` keyword:
152
+
153
+ ```ruby
154
+ class Receipts::Show
155
+ def initialize(strategy_result, params, locale, route_params: {})
156
+ @identifier = route_params[:identifier] # only ever the path value
157
+ @params = params
158
+ end
159
+ end
160
+ ```
161
+
162
+ The keyword is optional; the three-argument constructor keeps working. The same
163
+ values remain in `params` for classes that do not declare it.
164
+
165
+ JSON bodies are parsed for Logic-class parameters when the request method
166
+ carries a body (never `GET` or `HEAD`), the content type is JSON, and the body
167
+ is a JSON object. A valid non-object JSON body is ignored. Malformed JSON is
168
+ logged and the Logic class still runs with its other parameters; perform
169
+ application validation when malformed JSON must return a client error.
150
170
 
151
171
  ## Security options in routes
152
172
 
@@ -163,6 +183,161 @@ silently weakening the route. Do not use `csrf=exempt` as a general API switch;
163
183
  choose an independent request-authentication and replay-protection model for
164
184
  webhooks or other non-browser endpoints.
165
185
 
186
+ ## Static files
187
+
188
+ Otto serves static files in two ways. Both apply the same safety policy: the
189
+ requested path is joined to a canonical root, resolved with `File.realpath`
190
+ (which follows every `..`, `.`, and symlink component), and served only when
191
+ the result is still inside that root and is a regular, readable file owned by
192
+ the process user or group. Anything else, including a symlink that points
193
+ outside the root, is treated as not found.
194
+
195
+ ### Implicit public directory
196
+
197
+ Passing `public:` serves every file under that directory at its relative path.
198
+ Nothing needs registering; a file added after boot is served on the next
199
+ request, and a symlinked public directory that is repointed by a deploy is
200
+ re-resolved on every request.
201
+
202
+ ```ruby
203
+ otto = Otto.new('routes', public: File.expand_path('public', __dir__))
204
+ # public/css/site.css is served at GET /css/site.css
205
+ ```
206
+
207
+ ### Explicit static mounts
208
+
209
+ `mount_static` binds one URL prefix to one directory. Use it when the files do
210
+ not live under a single public directory, when a URL prefix should map to a
211
+ different directory name, or when a required asset directory must be verified
212
+ at boot.
213
+
214
+ ```ruby
215
+ otto = Otto.new('routes')
216
+ otto.mount_static('/assets', root: 'public/assets')
217
+ otto.mount_static('/vendor', root: File.join(Gem.loaded_specs['some-ui-kit'].full_gem_path, 'dist'))
218
+ otto.mount_static('/', root: 'public/root-files') # favicon.ico, robots.txt
219
+ ```
220
+
221
+ - The prefix must start with `/`. A trailing slash is ignored, and `/`
222
+ mounts the root at the top level. Empty, `.`, and `..` segments are
223
+ rejected.
224
+ - The root is expanded and canonicalized once, at registration. A root that
225
+ is missing, unreadable, not a directory, not owned by the process user or
226
+ group, or a symlink that cannot be resolved raises `ArgumentError`, so a
227
+ misconfigured application does not boot. Because the root is fixed at
228
+ registration, a deploy that repoints a symlinked root takes effect at the
229
+ next restart.
230
+ - A mount authorizes only files inside its own root. It never exposes the
231
+ root's parent or siblings, and it does not widen the implicit public
232
+ directory. Registering the same prefix twice on one instance raises
233
+ `ArgumentError`; different Otto instances are fully independent.
234
+ - Requests are matched on the decoded, trailing-slash-stripped path, the same
235
+ normalization every other dispatch stage uses. Only `GET` is served, the
236
+ prefix itself is not (mounts serve files, not directory listings), and a
237
+ request for a file the root does not contain falls through to the next
238
+ dispatch stage.
239
+ - `mount_static` must be called before the first request. After configuration
240
+ freezing it raises `FrozenError`, and `otto.static_mounts` is a frozen,
241
+ read-only table.
242
+
243
+ ### Dispatch precedence
244
+
245
+ Precedence is fixed and does not depend on request history:
246
+
247
+ 1. literal routes, such as `GET /assets/app.css Assets#show`;
248
+ 2. explicit static mounts, consulted longest prefix first; when the longest
249
+ matching mount does not contain the file, shorter matching mounts are tried
250
+ in turn;
251
+ 3. the implicit `public:` directory;
252
+ 4. dynamic routes, such as `GET /assets/:name Assets#show`.
253
+
254
+ So a literal route at a mounted path always wins, a mounted file always beats
255
+ a file at the same URL in the public directory, and a dynamic route only sees
256
+ requests that no static source could serve.
257
+
258
+ ### Migrating from `add_static_path`
259
+
260
+ `add_static_path` was removed in v2.10.0. It only populated a request-time
261
+ cache; it never registered or restricted anything. Callers that used it to
262
+ "register" files under the public directory can delete the call, because the
263
+ public directory is served without registration. Callers that used it to reach
264
+ files outside the public directory should replace it with `mount_static` and
265
+ an explicit root. There is no compatibility shim: calling the removed method
266
+ raises `NoMethodError` at boot.
267
+
268
+ ## Matching request paths before the router
269
+
270
+ The router does not match raw `PATH_INFO`. It percent-decodes it, scrubs
271
+ invalid UTF-8, and strips one trailing slash, so `GET /%63olonel/` reaches the
272
+ `/colonel` route. Middleware that decides on a request by its path (an access
273
+ guard, a throttle, a session skip) must compare the same value. Otherwise a
274
+ request can match a route without matching the guard in front of it, which
275
+ on an access guard is a bypass.
276
+
277
+ `Otto::Utils.routing_path(env)` returns that value. The router gets its path
278
+ from the same method, so the two cannot drift:
279
+
280
+ ```ruby
281
+ class AdminGuard
282
+ ADMIN = Otto::Utils.normalize_path('/colonel')
283
+
284
+ def initialize(app)
285
+ @app = app
286
+ end
287
+
288
+ def call(env)
289
+ path = Otto::Utils.routing_path(env)
290
+ admin_path = path == ADMIN || path.start_with?("#{ADMIN}/")
291
+ return [404, {}, []] if admin_path && !allowed?(env) # allowed? is yours
292
+
293
+ @app.call(env)
294
+ end
295
+ end
296
+ ```
297
+
298
+ The result is mount-relative. When the app is mounted under a sub-path
299
+ (`map '/api' { run otto }`), Rack moves the prefix into `SCRIPT_NAME` and the
300
+ router sees only the rest. Compare that form against paths as written in the
301
+ routes file. For middleware shared by several mounted apps and configured
302
+ with external URLs, pass `include_mount: true` to get `SCRIPT_NAME` and
303
+ `PATH_INFO` joined and normalized as one path: inside an app mounted at
304
+ `/api/v2`, the router's `/status` is `/api/v2/status`, and matching the
305
+ mount-relative form would also match every other app's `/status`.
306
+
307
+ The value uses `normalize_path` conventions: root is `''`, and a configured
308
+ path must go through `Otto::Utils.normalize_path` before an exact comparison.
309
+ A malformed escape such as `%zz` is kept as written; the method never raises.
310
+
311
+ ## Fallback 404 and 500 responses
312
+
313
+ A `GET /404` or `GET /500` route in the routes file handles misses and
314
+ unhandled errors like any other route. Without one, Otto uses `not_found=` and
315
+ `server_error=`, which accept either a Rack triple or a callable:
316
+
317
+ ```ruby
318
+ otto.not_found = [404, { 'content-type' => 'application/json' }, ['{"error":"Not Found"}']]
319
+
320
+ otto.server_error = lambda do |env, error|
321
+ [500, { 'content-type' => 'text/plain' }, ["Error #{env['otto.error_id']}"]]
322
+ end
323
+ ```
324
+
325
+ A callable is invoked on every request with `env` (`not_found`) or `env` and
326
+ the exception (`server_error`), trimmed to the positional parameters it
327
+ declares, so `->(env) { ... }` and `->(env = nil) { ... }` both work for
328
+ `server_error`. It must return a Rack triple: an Integer status, Hash-like
329
+ headers, and a body that responds to `each` (a bare String is rejected, at
330
+ assignment time for a static triple). A static triple is copied per request
331
+ before it is returned, so middleware that writes response headers in place
332
+ (rack-session, Otto's CSRF middleware, anything calling
333
+ `Rack::Utils.set_cookie_header!`) never mutates the configured object or
334
+ leaks one client's `Set-Cookie` into another's response. Do not rely on
335
+ mutating the configured triple after boot; assign a new value or use the
336
+ callable form instead.
337
+
338
+ For JSON clients, an unhandled error returns Otto's built-in JSON error body
339
+ regardless of `server_error`; a `/500` route applies to every client.
340
+
166
341
  ## Configuration timing
167
342
 
168
343
  Construct and configure the Otto instance before the first request: