otto 2.9.0 → 2.10.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 (96) 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 +7 -5
  12. data/AGENTS.md +22 -1
  13. data/CHANGELOG.rst +203 -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 +146 -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 +181 -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 +36 -22
  53. data/lib/otto/core/file_safety.rb +89 -31
  54. data/lib/otto/core/middleware_stack.rb +36 -36
  55. data/lib/otto/core/router.rb +62 -19
  56. data/lib/otto/env_keys.rb +20 -2
  57. data/lib/otto/mcp/auth/token.rb +10 -4
  58. data/lib/otto/mcp/core.rb +23 -5
  59. data/lib/otto/mcp/endpoint.rb +41 -0
  60. data/lib/otto/mcp/errors.rb +15 -0
  61. data/lib/otto/mcp/options.rb +292 -0
  62. data/lib/otto/mcp/protocol.rb +52 -22
  63. data/lib/otto/mcp/rate_limiting.rb +175 -97
  64. data/lib/otto/mcp/registry.rb +14 -14
  65. data/lib/otto/mcp/schema_validation.rb +20 -12
  66. data/lib/otto/mcp/server.rb +131 -32
  67. data/lib/otto/optional_dependency.rb +57 -0
  68. data/lib/otto/privacy/config.rb +10 -8
  69. data/lib/otto/security/authentication/route_auth_wrapper/role_authorization.rb +22 -5
  70. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +181 -18
  71. data/lib/otto/security/authentication/strategies/permission_strategy.rb +1 -0
  72. data/lib/otto/security/authentication/strategies/role_strategy.rb +1 -0
  73. data/lib/otto/security/authentication/strategies/session_strategy.rb +1 -0
  74. data/lib/otto/security/authentication/strategy_result.rb +58 -28
  75. data/lib/otto/security/config.rb +328 -14
  76. data/lib/otto/security/configurator.rb +36 -6
  77. data/lib/otto/security/core.rb +19 -1
  78. data/lib/otto/security/middleware/ip_privacy_middleware.rb +105 -4
  79. data/lib/otto/security/middleware/rate_limit_middleware.rb +1 -6
  80. data/lib/otto/security/rate_limiter.rb +81 -46
  81. data/lib/otto/utils.rb +50 -0
  82. data/lib/otto/version.rb +1 -1
  83. data/lib/otto.rb +9 -11
  84. data/otto.gemspec +0 -2
  85. metadata +31 -41
  86. data/docs/.gitignore +0 -10
  87. data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +0 -1105
  88. data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +0 -376
  89. data/docs/enrichment.md +0 -128
  90. data/docs/geo-country.md +0 -181
  91. data/docs/ipaddr-encoding-quirk.md +0 -34
  92. data/docs/migrating/v2.0.0-pre1.md +0 -276
  93. data/docs/migrating/v2.0.0-pre2.md +0 -338
  94. data/docs/modern-authentication-authorization-landscape.md +0 -558
  95. data/docs/multi-strategy-authentication-design.md +0 -1401
  96. data/docs/reverse-proxy-network-services.md +0 -371
@@ -1,371 +0,0 @@
1
- # Reverse-proxy / network-service integrations
2
-
3
- **Status:** shipped pilot (issue [#175](https://github.com/delano/otto/issues/175))
4
- **Ships:** `Otto::CaddyTLS` — Caddy on-demand TLS permission endpoint
5
- **Date:** 2026-07
6
-
7
- This document captures the design exploration for how Otto absorbs
8
- *network-service integrations* — small, optional, turnkey endpoints where an
9
- external network component (a reverse proxy, a TLS layer, a monitoring probe)
10
- speaks a fixed HTTP contract to the app, and the app supplies a tiny decision or
11
- handler while Otto owns all the HTTP ceremony.
12
-
13
- The pilot absorbs OneTimeSecret's "Internal ACME" app — the endpoint Caddy calls
14
- to decide whether to obtain a certificate on demand — into a reusable Otto
15
- primitive, decoupled from any application's domain model.
16
-
17
- > **Namespace note (resolved during review — see §4 and §7).** This work first
18
- > shipped under an umbrella `Otto::Services` namespace, on the assumption that CSP
19
- > reporting ([#174](https://github.com/delano/otto/issues/174)) would be its
20
- > second tenant. When #174 actually landed it chose `Otto::Security::CSP` — it is
21
- > the *receiving half* of Otto's own CSP support and belongs beside the emitting
22
- > half (`Otto::Security::Config`), not in a generic bucket. With its presumed
23
- > second tenant placed elsewhere, the umbrella had exactly one occupant, so it was
24
- > collapsed to a feature-named `Otto::CaddyTLS`, matching the existing `Otto::MCP`
25
- > precedent. "Making space" turned out to mean *a documented pattern plus shared
26
- > primitives in `Otto::Core`* — not a shared namespace module.
27
-
28
- ---
29
-
30
- ## 1. Problem & goal
31
-
32
- Apps that sit behind a reverse proxy repeatedly hand-roll the same small
33
- endpoints:
34
-
35
- - **Caddy on-demand TLS** asks the backend "may I get a cert for this host?"
36
- before issuing. The backend must parse `?domain=`, return `200`/non-`2xx`,
37
- restrict the caller to localhost, and fail closed on errors.
38
- - **CSP violation reporting** ([#174](https://github.com/delano/otto/issues/174))
39
- receives browser-posted violation reports, parsing two formats, bypassing
40
- CSRF, capping the body, and never raising.
41
-
42
- Each app rebuilds this ceremony — routing, method/size limits, response
43
- semantics, security guard, fail-safe behavior — around one app-specific decision.
44
- That repetition is exactly the kind of thing Otto already removes for MCP.
45
-
46
- **Goal:** *make space* — establish the shared pattern for these integrations and
47
- prove it with one concrete, well-decoupled pilot (Caddy on-demand TLS).
48
- Explicitly **not** a goal: build a framework, a registry, or the CSP endpoint now.
49
- The pattern should make CSP #174 a straightforward later sibling without being
50
- designed around it.
51
-
52
- ## 2. The pattern, and its two instances
53
-
54
- | Aspect | Caddy on-demand TLS | CSP reporting (#174) |
55
- |---|---|---|
56
- | Direction | proxy → app (control plane) | browser → app (telemetry) |
57
- | Verb / surface | `GET ?domain=` | `POST` JSON body |
58
- | App's job | decide allow/deny for a domain | receive a normalized report |
59
- | Otto's job | route, guard, fail-closed, `200`/`403` | intercept, CSRF-bypass, size-cap, parse, `204` |
60
- | Caller trust | **loopback only** (co-located proxy) | **public** (any browser) |
61
- | Coupling point | one decision block | one callback |
62
- | Ties to an Otto subsystem | none (bridge to an external service) | **yes** — it's the receiving half of Otto's CSP header support |
63
- | Home | `Otto::CaddyTLS` (top-level, like `Otto::MCP`) | `Otto::Security::CSP` (beside CSP emission) |
64
-
65
- The common shape is *"a fixed external HTTP contract, an app-supplied
66
- decision/handler, and Otto owning the ceremony."* That shape is the pattern worth
67
- establishing. But the two instances differ on nearly everything else — trust
68
- model, verb, consumer-API shape, and, decisively, **their relationship to existing
69
- Otto subsystems**. Caddy TLS is a bridge to an external service (Caddy) and stands
70
- alone; CSP reporting is one half of a capability Otto already owns. That is why
71
- they get *separate feature-named homes* rather than a shared umbrella — and why the
72
- only thing genuinely worth extracting up front is the reusable *mechanism* each
73
- needs (the localhost guard here; the `:outermost` middleware position there),
74
- not a namespace to file them both under.
75
-
76
- ## 3. Options considered
77
-
78
- Four architectures were explored (a design panel generated and adversarially
79
- critiqued each):
80
-
81
- 1. **MCP-style route-based primitive.** `enable_caddy_tls! { |domain| … }` lazily
82
- builds a small `Server` that registers a `GET` route programmatically (like
83
- `/_mcp`) and installs a localhost guard. *Pro:* mirrors the proven MCP
84
- precedent exactly; lowest review risk; turnkey. *Con:* one new namespace.
85
- 2. **Callback + config, interceptor middleware (CSP #174 style).** Configuration
86
- is data (endpoint + callback); a Rack middleware intercepts the path and
87
- short-circuits with `200`/`403`, no route entry. *Pro:* symmetric with how
88
- #174 configures. *Con (fatal for this endpoint):* a short-circuiting
89
- interceptor that sits ahead of the guard in the stack answers **before** the
90
- guard runs — a fail-**open** exposure of the cert gate to remote clients.
91
- (This is why CSP, which is *meant* to be public, correctly uses the interceptor
92
- pattern, and Caddy TLS, which must be gated, correctly does not.)
93
- 3. **Minimalist provided handler class.** Otto ships a handler you reference in
94
- `routes.txt` plus a guard you `use` yourself. *Pro:* least magic. *Con:* the
95
- operator must remember to wire the guard separately — a "forgot the guard"
96
- footgun on a security-critical endpoint.
97
- 4. **General network-services registry/base.** A shared `Endpoint` base or
98
- registry that Caddy and CSP both plug into. *Con:* premature abstraction — only
99
- one consumer differs (the guard), which doesn't justify a base class (YAGNI).
100
- (#174 later confirmed this: it shared *no* base with Caddy TLS.)
101
-
102
- ## 4. Recommendation
103
-
104
- **Ship option 1 (MCP-style route-based primitive) as a top-level, feature-named
105
- module `Otto::CaddyTLS`, with the localhost guard authenticating the raw TCP
106
- peer.**
107
-
108
- ```
109
- Otto::CaddyTLS # opt-in integration, sibling to Otto::MCP
110
- ├── CaddyTLS::Core # mixin on Otto: enable_caddy_tls!, caddy_tls_enabled?
111
- ├── CaddyTLS::LocalhostGuard # opt-in, raw-peer loopback guard middleware
112
- ├── CaddyTLS::Server # enable!/enabled?/permit? (fail-closed), route+guard registration
113
- └── CaddyTLS::PermissionHandler # class-method handler: ?domain= → 400/200/403
114
- ```
115
-
116
- Rejected: the interceptor (fail-open risk here), routes.txt-first (guard footgun),
117
- and the registry/base (premature).
118
-
119
- **Why a feature-named module, not an `Otto::Services` umbrella.** The umbrella was
120
- the initial choice, justified by CSP #174 being its second tenant. That premise did
121
- not hold (§7): #174 shipped as `Otto::Security::CSP`. Three signals in the codebase
122
- all point to a feature-named home instead:
123
-
124
- - **`Otto::MCP`** — Otto's existing external-system integration is a *top-level,
125
- protocol-named* namespace with `enable_mcp!`. Caddy TLS is the same shape.
126
- - **`Otto::Security::CSP`** — even a security *sub-feature* is named for itself and
127
- nested under the subsystem it belongs to, not dropped in a generic bucket.
128
- - **Shared mechanism lives in `Otto::Core`** — the one primitive CSP genuinely
129
- reused (the `:outermost` middleware position) was added to
130
- `lib/otto/core/middleware_stack.rb`, not to any "services" module. Otto's
131
- instinct is: promote shared code to core, name features for themselves.
132
-
133
- None of these support a generic `Otto::Services` drawer, so it was collapsed to
134
- `Otto::CaddyTLS`. `LocalhostGuard` lives under `Otto::CaddyTLS` while it has a
135
- single consumer; if a second internal-only integration ever needs it, promote it to
136
- a shared home then, shaped by two real examples.
137
-
138
- ### Public API
139
-
140
- ```ruby
141
- otto = Otto.new('routes.txt')
142
-
143
- otto.enable_caddy_tls! do |domain|
144
- # The ONLY coupling point. Return truthy => 200 (issue cert), falsy => 403 (deny).
145
- # `domain` is the only input. Any exception here is caught and denies (fail-closed).
146
- MyApp::CustomDomain.verified?(domain)
147
- end
148
-
149
- otto.caddy_tls_enabled? # => true
150
- ```
151
-
152
- Defaults: `endpoint: '/_caddy/tls-permission'`, `localhost_only: true`. The route
153
- is registered programmatically (like `/_mcp`) — no `routes.txt` entry required.
154
- Enabling without a block raises `ArgumentError` (there is no allow-all default).
155
- All setup runs through `ensure_not_frozen!`, so it must happen before the first
156
- request, and re-enabling is idempotent (the route/guard are never duplicated).
157
-
158
- ### Caddyfile (config-only; both directives, identical contract)
159
-
160
- ```caddyfile
161
- on_demand_tls {
162
- permission http { endpoint http://127.0.0.1:PORT/_caddy/tls-permission }
163
- }
164
-
165
- # Legacy / deprecated — same endpoint, same HTTP contract:
166
- on_demand_tls { ask http://127.0.0.1:PORT/_caddy/tls-permission }
167
- ```
168
-
169
- ## 5. Security model
170
-
171
- The endpoint gates certificate issuance, so the **allow** path must be
172
- un-trickable; the **deny** path is naturally safe (Caddy treats any non-`2xx` as
173
- deny). Everything fails closed.
174
-
175
- ### 5.1 Authenticate the *raw* peer, not the resolved client IP
176
-
177
- This is the load-bearing decision, and it corrects the obvious-but-wrong first
178
- instinct (which every initial design in the panel made).
179
-
180
- `Otto::CaddyTLS::LocalhostGuard` authenticates the **original TCP socket peer**,
181
- never the address left in `REMOTE_ADDR` after `IPPrivacyMiddleware` rewrites it
182
- from forwarded headers.
183
-
184
- Otto originally guaranteed that by ordering: the guard is installed via `Otto#use`
185
- (appended, therefore *outermost* in Otto's `reduce`-built stack) and
186
- `IPPrivacyMiddleware` was pinned *innermost*, so the guard ran first and read a
187
- pristine `REMOTE_ADDR`. Issue #219 inverted that: masking innermost meant every
188
- *other* middleware saw raw IPs, so `IPPrivacyMiddleware` is now pinned **outermost**
189
- (the `:entrypoint` tier) and runs *ahead* of the guard.
190
-
191
- The guarantee is preserved by a record rather than by order.
192
- `IPPrivacyMiddleware` evaluates the untouched peer before masking and stores the
193
- verdict as `env['otto.peer_loopback']` — a boolean, never an address, so it leaks
194
- nothing. The guard reads that record when present and evaluates `REMOTE_ADDR`
195
- itself when it is not (guard mounted outside Otto, or no privacy middleware in the
196
- stack). Both paths share `Otto::Utils.loopback_address?`, so they cannot drift, and
197
- the decision is made on the raw peer either way — regardless of when
198
- `enable_caddy_tls!` is called.
199
-
200
- Reading Otto's resolved `otto.client_ip` (or the rewritten `REMOTE_ADDR`) would be
201
- **exploitable**: `Otto::Utils.resolve_client_ip` honors `X-Forwarded-For` when the
202
- peer is a *trusted proxy*, and a co-located Caddy on loopback is itself a natural
203
- trusted proxy. An attacker who could route to the endpoint through it and send
204
- `X-Forwarded-For: 127.0.0.1` would be resolved to loopback and let in.
205
- Authenticating the raw peer removes forwarded headers from the trust decision
206
- entirely. (See `spec/otto/caddy_tls/localhost_guard_spec.rb`, "spoofing
207
- resistance".)
208
-
209
- ### 5.2 Reject relayed requests (forwarding headers)
210
-
211
- A direct call is loopback peer **and** *no forwarding headers*. Caddy's on-demand
212
- permission request is a direct backend call and carries none; a request that was
213
- **relayed through a reverse proxy** carries `X-Forwarded-For` (or `X-Real-IP`,
214
- `X-Client-IP`, `Forwarded`). The guard denies any request to the endpoint that
215
- carries one, even if its socket peer is loopback.
216
-
217
- This is what makes the endpoint safe against the "accidentally bolted onto an
218
- existing app" mistake: if the endpoint is mounted inside a public app behind a
219
- proxy that connects to the backend over loopback, *every* proxied user request has
220
- a loopback peer — but it also carries a forwarding header, so it is denied. Only
221
- the proxy's direct control-plane call (loopback peer, no forwarding header) is
222
- allowed.
223
-
224
- ### 5.3 Correct loopback detection
225
-
226
- `IPAddr.new(remote_addr).native.loopback?`, wrapped `rescue … => false`:
227
-
228
- - `.native` folds IPv4-mapped IPv6 (`::ffff:127.0.0.1`, which dual-stack servers
229
- commonly present) — plain `#loopback?` returns **false** for the mapped form and
230
- would wrongly reject legitimate traffic.
231
- - Blank, malformed, or `:port`-suffixed values (a non-standard `REMOTE_ADDR`) fail
232
- closed to `401` rather than raising on Caddy's TLS-handshake hot path.
233
-
234
- ### 5.4 Path-scoped, bypass-resistant
235
-
236
- The guard only enforces loopback for its own endpoint; every other route passes
237
- through untouched. It normalizes `PATH_INFO` through the **same
238
- `Otto::Utils.normalize_path` the router uses for literal matching** (URL-unescape,
239
- scrub invalid UTF-8 bytes, strip a trailing slash), so a percent-encoded
240
- (`…permissio%6e`), invalid-byte, or trailing-slash variant that the router would
241
- still route cannot slip past by normalizing differently in the guard than at
242
- dispatch. Sharing one implementation makes that agreement structural rather than a
243
- duplicated invariant two files must remember to keep in sync — if the router's
244
- normalization ever changes, the guard changes with it.
245
-
246
- ### 5.5 Fail-closed everywhere
247
-
248
- - The app block is wrapped by `Server#permit?`: `nil`, `false`, or any
249
- `StandardError` denies (`403`) and logs.
250
- - Blank/missing `domain` returns `400` before the block is consulted.
251
- - Only `?domain=` reaches the decision — no other query parameter. This preserves
252
- the downstream removal of `check_verification` (local processes must not bypass
253
- DNS verification via the query string).
254
- - `enable_caddy_tls!` with no block raises rather than defaulting to allow-all.
255
-
256
- ### 5.6 Deployment: co-locate the endpoint with Caddy
257
-
258
- The guard is **loopback-only by design**, and it stays that way even when Caddy
259
- and the application run on **different hosts**. The recommended topology is to run
260
- the permission endpoint as a tiny Otto app **on the same host as Caddy** — see
261
- `examples/caddy_tls_demo/standalone.ru`:
262
-
263
- ```
264
- [ Caddy host ] [ App / data host(s) ]
265
- Caddy ──loopback──▶ tiny Otto app ──(your own authenticated channel)──▶ data
266
- (127.0.0.1) enable_caddy_tls! { |domain| ... }
267
- ```
268
-
269
- The Caddy → endpoint hop is always loopback (secure, unspoofable). The endpoint's
270
- decision block is app-supplied, so it reaches the real domain data over whatever
271
- channel the app already trusts (an internal API call, a shared database, a cache).
272
- This keeps the *authentication* boundary simple and strong (loopback) while the
273
- *data* lookup crosses hosts however the app likes.
274
-
275
- Rejected alternative: widening the guard to a configurable trusted-source IP
276
- allowlist so Caddy could call cross-host directly. It trades an unspoofable
277
- boundary (loopback) for a spoofable one (source IP on a shared network) and adds
278
- configuration surface, for no capability the co-location topology doesn't already
279
- provide. Loopback-only + co-location is the better overall design.
280
-
281
- Additional layers (defense in depth):
282
-
283
- - **Dedicated loopback port.** Bind the endpoint app on `127.0.0.1:PORT` serving
284
- *only* the permission route, so it is unreachable from off-host by construction.
285
- - **Proxy path block.** If you *do* mount the endpoint inside a proxied app, also
286
- block the path at the proxy. Caddy's `on_demand_tls` call bypasses Caddy's own
287
- route rules, so blocking the public path does not affect certificate validation:
288
-
289
- ```caddyfile
290
- @tls_permission path /_caddy/tls-permission
291
- respond @tls_permission 404
292
- ```
293
-
294
- ## 6. Absorbing the OneTimeSecret "Internal ACME" app
295
-
296
- The behavior maps 1:1, decoupled and hardened:
297
-
298
- | OneTimeSecret | Otto pilot |
299
- |---|---|
300
- | `AskHandler.call` (`?domain=` → 400/200/403, `text/plain` `OK`/`Forbidden`) | `CaddyTLS::PermissionHandler.handle` (identical) |
301
- | `LocalhostOnly` (trusts resolved `REMOTE_ADDR`, plain `#loopback?`) | `CaddyTLS::LocalhostGuard` (raw peer + reject forwarding headers, `.native.loopback?`, fail-closed, router-equivalent path scoping) |
302
- | `Application.domain_allowed?` → `CustomDomain…ready?` (the coupling) | app-supplied `enable_caddy_tls!` block |
303
- | `domain_allowed?` `rescue => false` | absorbed into `Server#permit?` so every consumer inherits it |
304
- | `check_verification` removed from HTTP surface | preserved: only `?domain=` is read |
305
-
306
- ## 7. How CSP reporting (#174) actually landed — and what it taught us
307
-
308
- This is the part that validated (and corrected) the design. CSP reporting shipped
309
- **not** as a second tenant of this namespace but as `Otto::Security::CSP`
310
- (`Parser`, `Report`, `ReportMiddleware`), enabled via
311
- `enable_csp_reporting!(report_uri) { |report| … }` on `Otto::Security::Core`. It is
312
- a public, unauthenticated Rack middleware pinned `:outermost`, always answers
313
- `204`, size-caps the body, and dispatches each parsed report through a
314
- fire-and-forget callback held on `Otto::Security::Config`.
315
-
316
- What that outcome confirms:
317
-
318
- - **The pattern is real.** CSP is the same *shape* — a fixed external HTTP
319
- contract, an app-supplied handler, Otto owning the ceremony — so the pilot did
320
- generalize a genuine recurring need.
321
- - **The shared-namespace hypothesis was wrong.** CSP belongs beside Otto's CSP
322
- *emission* (`report-uri` directive, nonce policy) in `Otto::Security`; it shares
323
- `csp_report_uri` and the violation callback with header generation. Filing it in
324
- a generic `Otto::Services` would have severed it from the code it is one half of.
325
- A network-service integration goes wherever its *domain* is — `Otto::Security` for
326
- CSP, its own module for the Caddy bridge — not into a bucket named after the
327
- mechanism.
328
- - **What's actually shared is mechanism, and it lives in core.** The one thing both
329
- features needed was a way to run a middleware ahead of CSRF/auth. That became the
330
- generic `:outermost` position in `Otto::Core`'s middleware stack — reusable by
331
- any integration, owned by none.
332
- - **The guard is correctly *not* shared.** CSP reports come from browsers, so CSP
333
- opts out of any localhost guard and instead leans on content-type gating, a size
334
- cap, and Otto's rate limiting. That opt-out is exactly why `LocalhostGuard` is a
335
- standalone building block rather than baked into a base class — and it is the
336
- post-hoc comparison #174 asked for: a modular CSP endpoint differs from a
337
- hand-built one only in that the enable/callback/route plumbing is conventionalized.
338
-
339
- ## 8. Decisions
340
-
341
- Resolved during review:
342
-
343
- - **Namespace:** `Otto::CaddyTLS` (top-level, feature-named, mirroring
344
- `Otto::MCP`). The earlier `Otto::Services` umbrella was collapsed once #174
345
- chose `Otto::Security::CSP`, leaving the umbrella with a single tenant (§4, §7).
346
- - **Loopback-only, even cross-host.** The endpoint stays loopback-only; when Caddy
347
- and the app run on different hosts, co-locate the tiny permission app with Caddy
348
- (§5.6). Chosen over a configurable trusted-source IP allowlist because loopback
349
- is an unspoofable boundary and co-location needs no extra config or trust.
350
- - **API shape:** `enable_caddy_tls!` (code-side) is the primary, secure-by-default
351
- path — it bundles route + guard + decision so the endpoint cannot exist without
352
- its guard. The handler class *is* resolvable from a routes file for advanced
353
- users, but that split (route declared without guard) is the exact footgun we
354
- avoid, so it is not the documented path.
355
- - **Multi-instance:** the handler resolves its `Server` per-request from the Otto
356
- instance the dispatcher binds to it (not a class-level global), so multiple Otto
357
- apps in one process each evaluate their own permission block.
358
-
359
- Still the maintainer's call:
360
-
361
- - **Default endpoint path:** `'/_caddy/tls-permission'` (`_`-prefixed like `/_mcp`).
362
- The absorbed app used `/api/internal/acme/ask`.
363
- - **Guard denial status:** `401` (parity with the absorbed `LocalhostOnly`) vs
364
- `403`. Both are non-`2xx`, so Caddy denies either way.
365
-
366
- ## References
367
-
368
- - Caddy on-demand TLS / permission module — https://caddyserver.com/docs/json/apps/tls/automation/on_demand/permission/http/
369
- - Issue #175 (this work) · Issue #174 (CSP reporting, shipped as `Otto::Security::CSP`)
370
- - Precedent: `lib/otto/mcp/` (modular protocol integration)
371
- - Code: `lib/otto/caddy_tls/` · Specs: `spec/otto/caddy_tls/` · Example: `examples/caddy_tls_demo/`