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.
- checksums.yaml +4 -4
- data/.github/dependabot.yml +5 -0
- data/.github/workflows/ci.yml +11 -8
- data/.github/workflows/claude-code-review.yml +38 -13
- data/.github/workflows/claude.yml +10 -8
- data/.github/workflows/code-smells.yml +5 -5
- data/.github/workflows/release-gem.yml +2 -2
- data/.github/workflows/ruby-lint.yml +3 -3
- data/.github/workflows/yardoc.yml +5 -5
- data/.gitignore +1 -5
- data/.rubocop_todo.yml +8 -5
- data/AGENTS.md +40 -1
- data/CHANGELOG.rst +235 -0
- data/Gemfile +3 -3
- data/Gemfile.lock +11 -15
- data/README.md +75 -37
- data/docs/README.md +166 -0
- data/docs/adr/README.md +16 -0
- data/docs/adr/adr-001-route-authentication-at-handler-boundary.md +38 -0
- data/docs/adr/adr-002-multi-strategy-authentication-and-authorization.md +50 -0
- data/docs/adr/adr-003-caddy-tls-route-based-integration.md +48 -0
- data/docs/adr/adr-004-separate-compatibility-from-security-maintenance.md +58 -0
- data/docs/guides/authentication.md +377 -0
- data/docs/guides/caddy-tls.md +205 -0
- data/docs/guides/configuration_freezing.md +157 -0
- data/docs/guides/enrichment.md +161 -0
- data/docs/guides/forwarded-authority.md +249 -0
- data/docs/guides/geo-country.md +168 -0
- data/docs/guides/ip_privacy.md +39 -0
- data/docs/guides/ipaddr-encoding-quirk.md +56 -0
- data/docs/guides/mcp.md +282 -0
- data/docs/guides/privacy.md +193 -0
- data/docs/guides/routing.md +293 -0
- data/docs/guides/structured_logging.md +281 -0
- data/docs/guides/testing-guide.md +391 -0
- data/docs/maintainers/github-actions.md +41 -0
- data/docs/maintainers/investigations/.gitignore +2 -0
- data/docs/migrating/v2.0.0.md +337 -0
- data/docs/reference/authentication.md +290 -0
- data/docs/reference/route-syntax.md +181 -0
- data/docs/reference/runtime-and-dependency-security.md +86 -0
- data/examples/advanced_routes/README.md +43 -57
- data/examples/authentication_strategies/README.md +37 -196
- data/examples/basic/README.md +24 -39
- data/examples/basic/config.ru +0 -1
- data/examples/caddy_tls_demo/README.md +8 -2
- data/examples/lambda_handlers/README.md +11 -2
- data/examples/mcp_demo/README.md +75 -161
- data/examples/mcp_demo/config.ru +1 -0
- data/examples/security_features/README.md +36 -234
- data/lib/otto/caddy_tls/localhost_guard.rb +22 -24
- data/lib/otto/core/configuration.rb +40 -23
- data/lib/otto/core/error_handler.rb +40 -2
- data/lib/otto/core/file_safety.rb +105 -31
- data/lib/otto/core/middleware_stack.rb +36 -36
- data/lib/otto/core/router.rb +94 -18
- data/lib/otto/core/static_mounts.rb +172 -0
- data/lib/otto/core.rb +1 -0
- data/lib/otto/env_keys.rb +20 -2
- data/lib/otto/mcp/auth/token.rb +10 -4
- data/lib/otto/mcp/core.rb +23 -5
- data/lib/otto/mcp/endpoint.rb +41 -0
- data/lib/otto/mcp/errors.rb +15 -0
- data/lib/otto/mcp/options.rb +292 -0
- data/lib/otto/mcp/protocol.rb +52 -22
- data/lib/otto/mcp/rate_limiting.rb +175 -97
- data/lib/otto/mcp/registry.rb +14 -14
- data/lib/otto/mcp/schema_validation.rb +20 -12
- data/lib/otto/mcp/server.rb +131 -32
- data/lib/otto/optional_dependency.rb +57 -0
- data/lib/otto/privacy/config.rb +10 -8
- data/lib/otto/security/authentication/route_auth_wrapper/role_authorization.rb +22 -5
- data/lib/otto/security/authentication/strategies/api_key_strategy.rb +181 -18
- data/lib/otto/security/authentication/strategies/permission_strategy.rb +1 -0
- data/lib/otto/security/authentication/strategies/role_strategy.rb +1 -0
- data/lib/otto/security/authentication/strategies/session_strategy.rb +1 -0
- data/lib/otto/security/authentication/strategy_result.rb +58 -28
- data/lib/otto/security/config.rb +328 -14
- data/lib/otto/security/configurator.rb +36 -6
- data/lib/otto/security/core.rb +19 -1
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +105 -4
- data/lib/otto/security/middleware/rate_limit_middleware.rb +1 -6
- data/lib/otto/security/rate_limiter.rb +81 -46
- data/lib/otto/static.rb +38 -0
- data/lib/otto/utils.rb +50 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +130 -13
- data/otto.gemspec +0 -2
- metadata +32 -41
- data/docs/.gitignore +0 -10
- data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +0 -1105
- data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +0 -376
- data/docs/enrichment.md +0 -128
- data/docs/geo-country.md +0 -181
- data/docs/ipaddr-encoding-quirk.md +0 -34
- data/docs/migrating/v2.0.0-pre1.md +0 -276
- data/docs/migrating/v2.0.0-pre2.md +0 -338
- data/docs/modern-authentication-authorization-landscape.md +0 -558
- data/docs/multi-strategy-authentication-design.md +0 -1401
- 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.
|