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>
76 lines
2.8 KiB
Go
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)
|
|
}
|
|
}
|