molecule-core/workspace-server/internal/router/cp_proxy.go
Hongming Wang eb4f262d2a feat(router): /cp/* reverse-proxy to CP + same-origin canvas fetches
Canvas's browser bundle issues fetches to both CP endpoints
(/cp/auth/me, /cp/orgs, ...) AND tenant-platform endpoints
(/canvas/viewport, /approvals/pending, /org/templates). They
share ONE build-time base URL. Baking api.moleculesai.app
broke tenant calls with 404; baking the tenant subdomain broke
auth. Tried both today and saw exactly one failure mode per
attempt.

Real fix: same-origin fetches + tenant-side split. Adds:

  internal/router/cp_proxy.go      # /cp/* → CP_UPSTREAM_URL

mounted before NoRoute(canvasProxy). Now a tenant serves:

  /cp/*              → reverse-proxy to api.moleculesai.app
  /canvas/viewport,
  /approvals/pending,
  /workspaces/:id/*,
  /ws, /registry,    → tenant platform (existing handlers)
  /metrics
  everything else    → canvas UI (existing reverse-proxy)

Canvas middleware reverts to `connect-src 'self' wss:` for the
same-origin path (keeping explicit PLATFORM_URL whitelist as a
self-hosted escape hatch when the build-arg is non-empty).

CI build-arg flips to NEXT_PUBLIC_PLATFORM_URL="" so the bundle
issues relative fetches.

Security of cp_proxy:
  - Cookie + Authorization PRESERVED across the hop (opposite of
    canvas proxy) — they carry the WorkOS session, which is the
    whole point.
  - Host rewritten to upstream so CORS + cookie-domain on the CP
    side see their own hostname.
  - Upstream URL validated at construction: must parse, must be
    http(s), must have a host — misconfig fails closed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 13:01:40 -07:00

76 lines
2.8 KiB
Go

package router
import (
"log"
"net/http"
"net/http/httputil"
"net/url"
"github.com/gin-gonic/gin"
)
// newCPProxy returns a Gin handler that reverse-proxies /cp/* requests
// to the control plane. Lives beside newCanvasProxy because they solve
// the same problem — tenant browser fetches targeted at a single
// same-origin base — for the mirror-image endpoint set.
//
// Why this exists: canvas's browser bundle calls both CP endpoints
// (/cp/auth/me, /cp/orgs, /cp/billing/checkout) AND tenant-platform
// endpoints (/canvas/viewport, /approvals/pending). They share ONE
// build-time base URL (NEXT_PUBLIC_PLATFORM_URL). Baking the CP
// origin breaks tenant calls; baking the tenant origin breaks CP
// calls. The only sane fix is same-origin fetches + let the server
// split the traffic. This handler is the /cp/* leg of that split;
// newCanvasProxy is the UI leg.
//
// Security:
// - We do NOT strip Cookie/Authorization here: those carry the
// WorkOS session cookie and must reach the CP to resolve the
// user. That's the whole point of this proxy.
// - We DO rewrite the Host header to the CP upstream so CORS and
// cookie-domain logic upstream see themselves, not the tenant.
// - We do NOT strip X-Forwarded-For — upstream may want it for
// audit and rate-limit keying.
// - The proxy ONLY forwards /cp/* paths. The upstream URL is
// env-configured and its scheme is enforced https in prod via
// url.Parse (the caller passes the URL; we reject anything
// that isn't http/https at construction time).
//
// Rate / timeout note: we do NOT set a custom Transport with
// aggressive timeouts because CP endpoints are fast and any hang
// is already bounded by the caller's browser-level timeout. If a
// future slow endpoint warrants a bound, add here not at the
// gateway.
func newCPProxy(targetURL string) gin.HandlerFunc {
target, err := url.Parse(targetURL)
if err != nil {
log.Fatalf("cp_proxy: invalid CP_UPSTREAM_URL %q: %v", targetURL, err)
}
if target.Scheme != "http" && target.Scheme != "https" {
log.Fatalf("cp_proxy: CP_UPSTREAM_URL scheme must be http(s), got %q", target.Scheme)
}
if target.Host == "" {
log.Fatalf("cp_proxy: CP_UPSTREAM_URL missing host: %q", targetURL)
}
proxy := &httputil.ReverseProxy{
Director: func(req *http.Request) {
req.URL.Scheme = target.Scheme
req.URL.Host = target.Host
// Host header rewrite: CP middleware (CORS, cookie-domain)
// keys off Host; rewriting avoids "origin not allowed" on
// upstream OPTIONS preflight.
req.Host = target.Host
},
ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) {
log.Printf("cp_proxy: %v", err)
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte("control plane unavailable"))
},
}
return func(c *gin.Context) {
proxy.ServeHTTP(c.Writer, c.Request)
}
}