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
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
- 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
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
end
end
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)
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)
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:
# config/initializers/210_app_type.rb
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
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:
# config/initializers/twilio.rb
class ConditionalTwilioAuth
def call(env)
if Heatwave::AppType.run_for?(env, :api)
@twilio_auth.call(env) # Rack::TwilioWebhookAuthentication
else
@app.call(env)
end
end
end
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:80
fronts Puma on:3000. TLS is terminated upstream — Cloudflare → the
cloudflaredtunnel →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
detect_from_host is a pure function, so the detection contract is covered by
plain unit tests:
# test/lib/heatwave/app_type_test.rb
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 domain
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 |