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
@@ -0,0 +1,193 @@
1
+ # Privacy-preserving request data
2
+
3
+ Otto's default request posture is to reduce the precision of public client
4
+ information before application code, logging, authentication, rate limiting,
5
+ and other middleware see it. The privacy pipeline is a Rack concern, not only a
6
+ router feature: if an outer Rack middleware logs the request before Otto runs,
7
+ place the privacy middleware in the common stack first.
8
+
9
+ ## Default behavior
10
+
11
+ With the default `:masked` profile:
12
+
13
+ - public IP addresses are masked by the configured octet precision (one octet
14
+ by default: `203.0.113.9` becomes `203.0.113.0`);
15
+ - requests from private and loopback addresses are exempt from the privacy
16
+ fingerprint by default, so their IP, user agent, and referer remain unchanged;
17
+ - for requests that are masked, user-agent version details are anonymized and
18
+ referer query parameters are removed;
19
+ - original public values are not retained in the Rack environment; and
20
+ - country resolution is country-level only and returns `**` when enabled but no
21
+ configured source answers.
22
+
23
+ Downstream code should read `req.ip`, `req.masked_ip`, and the documented privacy
24
+ environment keys rather than re-resolving an address from forwarded headers.
25
+
26
+ These profiles are technical data-minimization controls, not a compliance
27
+ certification. Whether a deployment meets GDPR, CCPA, or another legal regime
28
+ also depends on its purposes, notices, retention, access controls, vendors, and
29
+ jurisdiction.
30
+
31
+ ## Profiles
32
+
33
+ Choose a named profile when the deployment posture should be obvious in review:
34
+
35
+ ```ruby
36
+ otto = Otto.new('routes')
37
+ otto.configure_ip_privacy(profile: :masked) # default
38
+ # or:
39
+ otto.configure_ip_privacy(profile: :anonymous) # mask private/localhost too
40
+ # or:
41
+ otto.configure_ip_privacy(profile: :audit) # disable IP privacy
42
+ ```
43
+
44
+ | Profile | Public IPs | Private/localhost IPs | Use when |
45
+ | --- | --- | --- | --- |
46
+ | `:masked` | Masked | Exempt by default | General privacy-by-default deployments and local development. |
47
+ | `:anonymous` | Masked | Masked | Internal addresses must also be treated as identifying data. |
48
+ | `:audit` | Not masked | Not masked | The operator has deliberately accepted raw-IP retention and controls logs and downstream systems. |
49
+
50
+ The `:audit` profile transfers retention responsibility to the operator. It is
51
+ not a way to obtain precise matching while keeping privacy enabled; use
52
+ `env['otto.ip_match']` for that narrower need.
53
+
54
+ Configuration is boot-time only. The first request freezes configuration in
55
+ normal operation, so set profiles, trusted proxies, database readers, and other
56
+ privacy settings before serving traffic.
57
+
58
+ ## Privacy-safe request values
59
+
60
+ Inside a handler:
61
+
62
+ ```ruby
63
+ class Analytics
64
+ def self.record(req, res)
65
+ event = {
66
+ ip: req.ip,
67
+ country: req.geo_country,
68
+ asn: req.asn,
69
+ anonymizer: req.anonymizer,
70
+ user_agent: req.user_agent,
71
+ }
72
+
73
+ AuditLog.write(event)
74
+ res.status = 204
75
+ res.body = []
76
+ end
77
+ end
78
+ ```
79
+
80
+ Common values are also available in the Rack environment:
81
+
82
+ | Value | Environment key | Contract |
83
+ | --- | --- | --- |
84
+ | Canonical client IP | `otto.client_ip` | Masked IP when masking applies; resolved full IP under `:audit` or for an exempt private/loopback request. |
85
+ | Precise CIDR verdict | `otto.ip_match` | Callable that checks the resolved full IP against CIDRs and returns only `true` or `false`. |
86
+ | Masked IP | `otto.privacy.masked_ip` | Set when the request runs through the privacy fingerprint; absent for exempt or `:audit` requests. |
87
+ | Rotating IP hash | `otto.privacy.hashed_ip` | Correlation value computed with Otto's rotating key; absent for exempt or `:audit` requests. |
88
+ | Stable correlation hash | `otto.privacy.correlation_hash` | HMAC value when `correlation_secret:` is configured; otherwise `nil`. |
89
+ | Country | `otto.privacy.geo_country` | ISO 3166-1 alpha-2 code, `**` when enabled but unresolved, or `nil` when geo/privacy is disabled. |
90
+ | ASN | `otto.privacy.asn` | `nil` when off, `**` when enabled but unresolved, or a value such as `AS15169`. |
91
+ | Anonymizer | `otto.privacy.anonymizer` | `nil` when off, `**` when no database answers, or a classification label. |
92
+
93
+ `req.hashed_ip` is designed for short-lived correlation using a rotating key.
94
+ For long-lived correlation, explicitly configure a stable secret and protect
95
+ that secret as sensitive configuration. Changing the secret changes every
96
+ correlation hash.
97
+
98
+ ## Country, ASN, and anonymizer data
99
+
100
+ These signals have different trust and precision models:
101
+
102
+ - [Geo-country resolution](geo-country.md) documents trusted provider headers,
103
+ CIDR trusted-proxy requirements, masked MMDB lookup, and the unknown sentinel.
104
+ - [ASN and anonymizer enrichment](enrichment.md) documents the opt-in database
105
+ contracts. ASN uses a masked address; anonymizer classification deliberately
106
+ uses the unmasked address internally and emits only a label.
107
+
108
+ Enable optional signals explicitly:
109
+
110
+ ```ruby
111
+ otto.configure_ip_privacy(
112
+ geo: true,
113
+ geo_db_path: 'data/country.mmdb',
114
+ asn: true,
115
+ asn_db_path: 'data/origin-asn.mmdb',
116
+ anonymizer: true,
117
+ anonymizer_db_path: 'data/anonymizer.mmdb'
118
+ )
119
+ ```
120
+
121
+ The `maxmind-db` gem is optional and is required only when a database path is
122
+ configured. A reader object responding to `#get(ip)` can be injected instead.
123
+ Database paths are opened at configuration time; invalid paths fail during boot
124
+ rather than on an arbitrary request.
125
+
126
+ ## Trusted proxy and matching boundaries
127
+
128
+ Configure trusted proxies before relying on forwarded client information:
129
+
130
+ ```ruby
131
+ otto = Otto.new(
132
+ 'routes',
133
+ trusted_proxies: ['10.0.0.0/8', '192.0.2.0/24']
134
+ )
135
+ ```
136
+
137
+ CIDR-based trust lets Otto verify the proxy peer. Count-based
138
+ `trusted_proxy_depth` is a separate mode and does not make geo headers
139
+ trustworthy. A configured `geo_header` combined with depth mode is rejected at
140
+ configuration time; use a local database in depth-mode deployments instead.
141
+
142
+ The same trust decision also gates the forwarded host, scheme, and port headers
143
+ that `Rack::Request#host` reads. See
144
+ [Forwarded host authority](forwarded-authority.md), including the explicit
145
+ `trusted_proxies: :none` assertion for directly exposed applications.
146
+
147
+ For precise access control without exposing the address to application code,
148
+ call the automatically installed `env['otto.ip_match']` capability. It matches
149
+ the resolved full client IP against application CIDRs and returns only
150
+ `true`/`false`; it fails closed when no client IP resolves. Do not log or persist
151
+ the closure.
152
+
153
+ ```ruby
154
+ allowed = req.env.fetch('otto.ip_match').call(['192.0.2.0/24', '2001:db8::/32'])
155
+ ```
156
+
157
+ ## Middleware placement
158
+
159
+ When building a larger Rack stack, put privacy before components that log or
160
+ inspect the request:
161
+
162
+ ```ruby
163
+ builder.use Otto::Security::Middleware::IPPrivacyMiddleware, otto.security_config
164
+ builder.use Rack::CommonLogger
165
+ builder.use Sentry::Rack::CaptureExceptions
166
+ # Then mount the Otto application.
167
+ ```
168
+
169
+ Otto also installs its privacy middleware internally. The outer common-stack
170
+ placement is needed when middleware outside the Otto app would otherwise see the
171
+ raw peer first.
172
+
173
+ Pass `otto.security_config` as shown. The outer instance resolves `otto.client_ip`
174
+ first and the inner one then short-circuits, so an outer instance constructed
175
+ without the configuration would silently apply defaults instead of the
176
+ application's profile, precision, correlation secret, and enrichment settings.
177
+ Proxy trust is the exception: the inner instance re-applies its own trust
178
+ posture even after an outer pass, and a CIDR configuration that can no longer
179
+ see the connecting peer fails closed. See
180
+ [Forwarded host authority](forwarded-authority.md#place-the-middleware-before-other-request-consumers).
181
+
182
+ ## Operational rules
183
+
184
+ - Treat `nil`, `'**'`, `'none'`, and a real label as different states. In
185
+ particular, anonymizer `'none'` means the database was consulted and did not
186
+ list the address; `'**'` means no database answered.
187
+ - Do not use provider geo headers without a verifiable CIDR trusted proxy. A
188
+ client can otherwise submit its own country.
189
+ - Do not confuse the masked client IP with the raw peer used to authenticate a
190
+ local network service. Integrations such as Caddy TLS have their own trust
191
+ boundary.
192
+ - Test logs and monitoring outside Otto's stack. Privacy is only effective if
193
+ those components receive the masked environment first.
@@ -0,0 +1,293 @@
1
+ # Routing applications with Otto
2
+
3
+ Otto keeps the application boundary small: a plain-text route file maps an HTTP
4
+ verb and path to a Ruby handler. Use this guide to choose a handler style and
5
+ response contract. The exact route grammar is in the [route syntax reference](../reference/route-syntax.md).
6
+
7
+ ## Choose a handler style
8
+
9
+ | Use this when | Route target | Invocation |
10
+ | --- | --- | --- |
11
+ | You need a class method with direct Rack access | `App.index` | `App.index(req, res)` |
12
+ | You need an object with direct Rack access | `App#show` | `App.new(req, res).show` |
13
+ | You want a constrained, testable application operation | `App::Operation` | `App::Operation.new(strategy_result, params, locale)` |
14
+ | You need a small pre-registered endpoint function | `&name` | `call(req, res, captured_path_params)` |
15
+
16
+ ## Controller-style handlers
17
+
18
+ Routes can call a class method or instantiate a class for an instance method:
19
+
20
+ ```text
21
+ GET / App.index
22
+ GET /products/:id App#show
23
+ ```
24
+
25
+ ```ruby
26
+ class App
27
+ def initialize(req, res)
28
+ @req = req
29
+ @res = res
30
+ end
31
+
32
+ def show
33
+ @res.body = "Product: #{@req.params[:id]}"
34
+ end
35
+
36
+ def self.index(req, res)
37
+ res['content-type'] = 'text/plain'
38
+ res.body = 'Hello Otto'
39
+ end
40
+ end
41
+ ```
42
+
43
+ Use this style when the handler needs cookies, request headers, the Rack
44
+ request object, or direct response helpers.
45
+
46
+ ## Logic classes
47
+
48
+ Use a bare class target for an operation with an explicit input context:
49
+
50
+ ```text
51
+ GET /products/:id Products::Show auth=session response=json
52
+ POST /products Products::Create auth=session response=json
53
+ ```
54
+
55
+ ```ruby
56
+ class Products::Show
57
+ def initialize(strategy_result, params, locale)
58
+ @context = strategy_result
59
+ @params = params
60
+ @locale = locale
61
+ end
62
+
63
+ def raise_concerns
64
+ @product = Product.find(@params[:id])
65
+ unless @product.public? || @product.owner_id == @context.user_id
66
+ raise Otto::Security::AuthorizationError, 'Product access denied'
67
+ end
68
+ end
69
+
70
+ def process
71
+ { id: @product.id, name: @product.name, locale: @locale }
72
+ end
73
+ end
74
+ ```
75
+
76
+ Otto runs `raise_concerns` before `process` when those methods exist. Put
77
+ resource loading and resource-level authorization in `raise_concerns`; route
78
+ authentication and broad role checks belong in the route definition.
79
+
80
+ Logic classes do not receive the Rack environment. This keeps their inputs
81
+ explicit and prevents application operations from depending on ambient request
82
+ state. Choose a controller-style handler when direct request access is part of
83
+ the operation.
84
+
85
+ ## Registered lambda handlers
86
+
87
+ Lambda routes are useful for small endpoints that do not need a Ruby constant or
88
+ handler class. Register the callable at boot:
89
+
90
+ ```ruby
91
+ otto = Otto.new('routes', lambda_handlers: {
92
+ health_check: lambda do |_req, res, _extra_params|
93
+ res['content-type'] = 'text/plain'
94
+ res.body = 'ok'
95
+ end,
96
+ })
97
+ ```
98
+
99
+ ```text
100
+ GET /health &health_check
101
+ ```
102
+
103
+ The registry is normalized and frozen during configuration. A lambda must
104
+ accept three positional arguments: request, response, and captured path
105
+ parameters. Query and form parameters remain available through `req.params`.
106
+ The route name is an exact registry key; it is not evaluated as Ruby code.
107
+
108
+ ## Response selection
109
+
110
+ Use `response=` when the handler returns a value that should pass through Otto's
111
+ response handling:
112
+
113
+ ```text
114
+ GET /api/products Products::Index response=json
115
+ GET /dashboard Dashboard#show response=view
116
+ POST /login Sessions#create response=redirect
117
+ GET /data Data#show response=auto
118
+ ```
119
+
120
+ `response=default` is the default. Keep response selection in the route file
121
+ so the HTTP contract is visible beside the endpoint.
122
+
123
+ | Response type | Handler contract |
124
+ | --- | --- |
125
+ | `default` | Mutate `res` directly. The handler's return value is ignored. |
126
+ | `json` | Return a Hash for direct JSON serialization. `nil` becomes `{ "success": true }`; another value is wrapped as `data`. A Logic class may instead provide `response_data`. |
127
+ | `view` | Return a value rendered with `to_s`, or provide `view.render` on a Logic object. |
128
+ | `redirect` | Return a path String, or provide `redirect_path` on a Logic object. The fallback path is `/`. |
129
+ | `auto` | A Hash becomes JSON, a path-like String becomes a redirect, and a Logic object with `view` uses the view handler; other results use default behavior. |
130
+
131
+ An unknown response name currently falls back to `default`. Treat response
132
+ names as a fixed set; a typo otherwise changes the route to direct-response
133
+ behavior.
134
+
135
+ ## Route parameters
136
+
137
+ Named path segments are available in request parameters:
138
+
139
+ ```text
140
+ GET /products/:id Products::Show
141
+ ```
142
+
143
+ A handler can read `req.params[:id]` or a Logic class can read `params[:id]`.
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.
150
+
151
+ ## Security options in routes
152
+
153
+ Authentication, roles, and CSRF exemptions are explicit route options:
154
+
155
+ ```text
156
+ GET /profile Profile#show auth=session
157
+ GET /admin Admin::Dashboard auth=session role=admin
158
+ POST /hook Hooks#receive csrf=exempt
159
+ ```
160
+
161
+ Malformed `auth`, `role`, and `csrf` tokens fail route parsing rather than
162
+ silently weakening the route. Do not use `csrf=exempt` as a general API switch;
163
+ choose an independent request-authentication and replay-protection model for
164
+ webhooks or other non-browser endpoints.
165
+
166
+ ## Static files
167
+
168
+ Otto serves static files in two ways. Both apply the same safety policy: the
169
+ requested path is joined to a canonical root, resolved with `File.realpath`
170
+ (which follows every `..`, `.`, and symlink component), and served only when
171
+ the result is still inside that root and is a regular, readable file owned by
172
+ the process user or group. Anything else, including a symlink that points
173
+ outside the root, is treated as not found.
174
+
175
+ ### Implicit public directory
176
+
177
+ Passing `public:` serves every file under that directory at its relative path.
178
+ Nothing needs registering; a file added after boot is served on the next
179
+ request, and a symlinked public directory that is repointed by a deploy is
180
+ re-resolved on every request.
181
+
182
+ ```ruby
183
+ otto = Otto.new('routes', public: File.expand_path('public', __dir__))
184
+ # public/css/site.css is served at GET /css/site.css
185
+ ```
186
+
187
+ ### Explicit static mounts
188
+
189
+ `mount_static` binds one URL prefix to one directory. Use it when the files do
190
+ not live under a single public directory, when a URL prefix should map to a
191
+ different directory name, or when a required asset directory must be verified
192
+ at boot.
193
+
194
+ ```ruby
195
+ otto = Otto.new('routes')
196
+ otto.mount_static('/assets', root: 'public/assets')
197
+ otto.mount_static('/vendor', root: File.join(Gem.loaded_specs['some-ui-kit'].full_gem_path, 'dist'))
198
+ otto.mount_static('/', root: 'public/root-files') # favicon.ico, robots.txt
199
+ ```
200
+
201
+ - The prefix must start with `/`. A trailing slash is ignored, and `/`
202
+ mounts the root at the top level. Empty, `.`, and `..` segments are
203
+ rejected.
204
+ - The root is expanded and canonicalized once, at registration. A root that
205
+ is missing, unreadable, not a directory, not owned by the process user or
206
+ group, or a symlink that cannot be resolved raises `ArgumentError`, so a
207
+ misconfigured application does not boot. Because the root is fixed at
208
+ registration, a deploy that repoints a symlinked root takes effect at the
209
+ next restart.
210
+ - A mount authorizes only files inside its own root. It never exposes the
211
+ root's parent or siblings, and it does not widen the implicit public
212
+ directory. Registering the same prefix twice on one instance raises
213
+ `ArgumentError`; different Otto instances are fully independent.
214
+ - Requests are matched on the decoded, trailing-slash-stripped path, the same
215
+ normalization every other dispatch stage uses. Only `GET` is served, the
216
+ prefix itself is not (mounts serve files, not directory listings), and a
217
+ request for a file the root does not contain falls through to the next
218
+ dispatch stage.
219
+ - `mount_static` must be called before the first request. After configuration
220
+ freezing it raises `FrozenError`, and `otto.static_mounts` is a frozen,
221
+ read-only table.
222
+
223
+ ### Dispatch precedence
224
+
225
+ Precedence is fixed and does not depend on request history:
226
+
227
+ 1. literal routes, such as `GET /assets/app.css Assets#show`;
228
+ 2. explicit static mounts, consulted longest prefix first; when the longest
229
+ matching mount does not contain the file, shorter matching mounts are tried
230
+ in turn;
231
+ 3. the implicit `public:` directory;
232
+ 4. dynamic routes, such as `GET /assets/:name Assets#show`.
233
+
234
+ So a literal route at a mounted path always wins, a mounted file always beats
235
+ a file at the same URL in the public directory, and a dynamic route only sees
236
+ requests that no static source could serve.
237
+
238
+ ### Migrating from `add_static_path`
239
+
240
+ `add_static_path` was removed in v2.10.0. It only populated a request-time
241
+ cache; it never registered or restricted anything. Callers that used it to
242
+ "register" files under the public directory can delete the call, because the
243
+ public directory is served without registration. Callers that used it to reach
244
+ files outside the public directory should replace it with `mount_static` and
245
+ an explicit root. There is no compatibility shim: calling the removed method
246
+ raises `NoMethodError` at boot.
247
+
248
+ ## Fallback 404 and 500 responses
249
+
250
+ A `GET /404` or `GET /500` route in the routes file handles misses and
251
+ unhandled errors like any other route. Without one, Otto uses `not_found=` and
252
+ `server_error=`, which accept either a Rack triple or a callable:
253
+
254
+ ```ruby
255
+ otto.not_found = [404, { 'content-type' => 'application/json' }, ['{"error":"Not Found"}']]
256
+
257
+ otto.server_error = lambda do |env, error|
258
+ [500, { 'content-type' => 'text/plain' }, ["Error #{env['otto.error_id']}"]]
259
+ end
260
+ ```
261
+
262
+ A callable is invoked on every request with `env` (`not_found`) or `env` and
263
+ the exception (`server_error`), trimmed to the positional parameters it
264
+ declares, so `->(env) { ... }` and `->(env = nil) { ... }` both work for
265
+ `server_error`. It must return a Rack triple: an Integer status, Hash-like
266
+ headers, and a body that responds to `each` (a bare String is rejected, at
267
+ assignment time for a static triple). A static triple is copied per request
268
+ before it is returned, so middleware that writes response headers in place
269
+ (rack-session, Otto's CSRF middleware, anything calling
270
+ `Rack::Utils.set_cookie_header!`) never mutates the configured object or
271
+ leaks one client's `Set-Cookie` into another's response. Do not rely on
272
+ mutating the configured triple after boot; assign a new value or use the
273
+ callable form instead.
274
+
275
+ For JSON clients, an unhandled error returns Otto's built-in JSON error body
276
+ regardless of `server_error`; a `/500` route applies to every client.
277
+
278
+ ## Configuration timing
279
+
280
+ Construct and configure the Otto instance before the first request:
281
+
282
+ ```ruby
283
+ otto = Otto.new('routes')
284
+ otto.add_auth_strategy(
285
+ 'session',
286
+ Otto::Security::Authentication::Strategies::SessionStrategy.new
287
+ )
288
+ otto.register_request_helpers(MyApp::RequestHelpers)
289
+ # Add middleware and other boot-time options here.
290
+ ```
291
+
292
+ In normal operation, the first request freezes configuration. Runtime route or
293
+ security changes are not part of the application contract.