Skip to content

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.

Heatwave is a monolithic Rails app that serves multiple subdomains from one deployment:

SubdomainPurpose
www.warmlyyours.comPublic marketing site + store
crm.warmlyyours.comInternal CRM
api.warmlyyours.comREST API + inbound webhooks (Twilio, SendGrid)
mcp.warmlyyours.comModel Context Protocol server (AI tools)
media.warmlyyours.comDragonfly on-the-fly image/media server
  1. One code path in dev and prod. The same config.ru router and middleware run under Puma everywhere — there is no environment-specific routing layer.
  2. 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.
  3. Conditional middleware. Subdomain-specific middleware (Twilio auth, ActionMailbox multipart) runs only for the app type that needs it.
  4. Testable detection. App-type detection is a pure function of the host.

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 (McpRobotsTxtMcpBearerAuth → 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:

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.)

Middleware that only applies to one app type guards on run_for? / skip_for? instead of running globally:

MiddlewareRuns forHow it’s scoped
MCP transportmcpDiverted in config.ru, never enters Rails
ConditionalTwilioAuthapiWraps Rack::TwilioWebhookAuthentication; runs only when run_for?(env, :api)
ActionMailbox multipart limitapiSendGrid inbound email posts to api.*
OmniAuth::Strategies::*www + crmNot isolated — path-only overhead is negligible and the Devise coupling is deep
Prosopite, Rack::MiniProfiler, CloudflareGeoSimulatordev/stagingAlready 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

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 :80 fronts Puma on :3000. TLS is terminated upstream — Cloudflare → the cloudflared tunnel → kamal-proxy → the container. One Puma serves all subdomains; MCP isolation is the config.ru router (the same code as dev), not a separate process pool. The pre-Kamal nginx + Passenger app-group model is gone.

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
FileRole
config.ruSubdomainRouter — diverts mcp.* to the MCP SDK before Rails
lib/heatwave/app_type.rbHost → app-type detection + predicates / guards
lib/middleware/app_type_detector.rbSets AppType.current per request
config/initializers/001_app_type_loader.rbLoads the AppType module early
config/initializers/210_app_type.rbRegisters the detector middleware