App Type Routing Architecture
How Heatwave routes requests to its five “app types” (www, crm, api, mcp,
media) from a single Rails codebase, and how middleware is loaded conditionally
per subdomain.
Overview
Section titled “Overview”Heatwave is a monolithic Rails app that serves multiple subdomains from one deployment:
| Subdomain | Purpose |
|---|---|
www.warmlyyours.com | Public marketing site + store |
crm.warmlyyours.com | Internal CRM |
api.warmlyyours.com | REST API + inbound webhooks (Twilio, SendGrid) |
mcp.warmlyyours.com | Model Context Protocol server (AI tools) |
media.warmlyyours.com | Dragonfly on-the-fly image/media server |
Design goals
Section titled “Design goals”- One code path in dev and prod. The same
config.rurouter and middleware run under Puma everywhere — there is no environment-specific routing layer. - MCP isolation. MCP requests bypass the Rails middleware stack entirely, so error reporting and stack traces stay clean and MCP can’t inherit Rails request handling.
- Conditional middleware. Subdomain-specific middleware (Twilio auth, ActionMailbox multipart) runs only for the app type that needs it.
- Testable detection. App-type detection is a pure function of the host.
How it works
Section titled “How it works”1. Rack-level routing (config.ru)
Section titled “1. Rack-level routing (config.ru)”config.ru wraps the Rails app in a SubdomainRouter that runs before any
Rails middleware. On mcp.*, MCP protocol requests go straight to the MCP SDK
transport; OAuth / discovery paths and everything else fall through to Rails:
# config.ru (abridged)class SubdomainRouter def call(env) if mcp_subdomain?(env) if oauth_path?(env) @rails_app.call(env) # OAuth / .well-known / browser root → Rails (Doorkeeper) else mcp_app.call(env) # MCP protocol → official MCP SDK (stateless Streamable HTTP) end else @rails_app.call(env) # everything else → normal Rails stack end endend
run SubdomainRouter.new(Rails.application)OAuth / .well-known / /accounts paths and plain browser visits to the MCP
root still go through Rails (Doorkeeper, sessions, discovery metadata) — only the
MCP protocol traffic is diverted. The MCP Rack app builds its own minimal stack
(McpRobotsTxt → McpBearerAuth → transport), so a misbehaving MCP request
never enters Rails middleware.
2. App-type detection (lib/heatwave/app_type.rb)
Section titled “2. App-type detection (lib/heatwave/app_type.rb)”For everything that does reach Rails, the app type is derived from the host:
Heatwave::AppType.current # => :www, :crm, :api, :media (or :unknown)Heatwave::AppType.crm? # predicate per type (www?/api?/media?/mcp?)Heatwave::AppType.skip_for?(env, :api, :mcp) # guard: skip this middleware?Heatwave::AppType.run_for?(env, :crm, :www) # guard: run this middleware?Heatwave::AppType.detect_from_host("crm.warmlyyours.com") # => :crm (pure fn)detect_from_host is a pure function (no env, no globals), which is what makes
the behaviour trivially testable.
3. Detector middleware (config/initializers/210_app_type.rb)
Section titled “3. Detector middleware (config/initializers/210_app_type.rb)”A tiny middleware sets Heatwave::AppType.current early in the stack so all
later middleware and controllers can read it. The module itself is loaded even
earlier, in 001_app_type_loader.rb, so other initializers can reference it:
Rails.application.config.middleware.insert_after( Rack::Sanitizer, Middleware::AppTypeDetector)(MCP never reaches here — it was already diverted in config.ru — so
Heatwave::AppType.mcp? is always false inside Rails.)
Conditional middleware
Section titled “Conditional middleware”Middleware that only applies to one app type guards on run_for? / skip_for?
instead of running globally:
| Middleware | Runs for | How it’s scoped |
|---|---|---|
| MCP transport | mcp | Diverted in config.ru, never enters Rails |
ConditionalTwilioAuth | api | Wraps Rack::TwilioWebhookAuthentication; runs only when run_for?(env, :api) |
| ActionMailbox multipart limit | api | SendGrid inbound email posts to api.* |
OmniAuth::Strategies::* | www + crm | Not isolated — path-only overhead is negligible and the Devise coupling is deep |
Prosopite, Rack::MiniProfiler, CloudflareGeoSimulator | dev/staging | Already environment-gated |
Example — Twilio webhook auth only on the API subdomain:
class ConditionalTwilioAuth def call(env) if Heatwave::AppType.run_for?(env, :api) @twilio_auth.call(env) # Rack::TwilioWebhookAuthentication else @app.call(env) end endendDev vs prod — same router, different process host
Section titled “Dev vs prod — same router, different process host”The routing logic is identical in both; only the process model differs, and
that difference is invisible to config.ru:
-
Development — a single bare Puma process serves every subdomain. Map the dev hosts in
/etc/hosts:127.0.0.1 www.warmlyyours.me crm.warmlyyours.me api.warmlyyours.me mcp.warmlyyours.me -
Production — Puma runs inside the app Docker image (Kamal), launched by
./bin/thrust ./bin/rails server: Thruster (HTTP/2 +X-Sendfile) on:80fronts Puma on:3000. TLS is terminated upstream — Cloudflare → thecloudflaredtunnel →kamal-proxy→ the container. One Puma serves all subdomains; MCP isolation is theconfig.rurouter (the same code as dev), not a separate process pool. The pre-Kamal nginx + Passenger app-group model is gone.
Testing
Section titled “Testing”detect_from_host is a pure function, so the detection contract is covered by
plain unit tests:
assert_equal :www, Heatwave::AppType.detect_from_host("www.warmlyyours.com")assert_equal :crm, Heatwave::AppType.detect_from_host("crm.warmlyyours.me")assert_equal :www, Heatwave::AppType.detect_from_host("warmlyyours.com") # bare domainKey files
Section titled “Key files”| File | Role |
|---|---|
config.ru | SubdomainRouter — diverts mcp.* to the MCP SDK before Rails |
lib/heatwave/app_type.rb | Host → app-type detection + predicates / guards |
lib/middleware/app_type_detector.rb | Sets AppType.current per request |
config/initializers/001_app_type_loader.rb | Loads the AppType module early |
config/initializers/210_app_type.rb | Registers the detector middleware |