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

  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.

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

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