# Same-Origin Canvas Fetches — the /cp/* Reverse Proxy > How Molecule AI's SaaS Canvas makes browser API calls to two backends > through one origin — and why the `/cp/*` proxy makes multi-tenant > deployment simpler and safer. **PRs:** #1095 (`feat/tenant-cp-proxy-same-origin`) | **Status:** ✅ Merged --- ## The problem: two backends, one browser origin Canvas (Molecule AI's browser UI) makes API calls to two distinct services: | Service | What it does | Example endpoints | |---|---|---| | **Tenant platform** | Your Molecule workspace management | `/workspaces`, `/approvals/pending` | | **Control Plane (CP)** | Org-level operations, billing, auth verification | `/cp/auth/me`, `/cp/orgs`, `/cp/billing/checkout` | Before this change, Canvas had to call both services directly from the browser. That meant: - Two separate base URLs in the browser bundle (`NEXT_PUBLIC_PLATFORM_URL` for tenant, another for CP) - CORS preflight complexity — cross-origin calls need explicit `Access-Control-Allow-*` headers on the CP - Cookie domain issues — WorkOS session cookies scoped to `.moleculesai.app` aren't sent to a custom tenant domain The result was a fragile configuration that complicated tenant provisioning. ## The fix: server-side split, same-origin fetches The tenant platform now runs a `/cp/*` reverse proxy. Canvas makes **all** calls to its single `NEXT_PUBLIC_PLATFORM_URL` (the tenant). The tenant splits the traffic: ``` Browser → tenant.moleculesai.app ├── /workspaces, /approvals/pending, /channels/* → handled locally └── /cp/* → reverse-proxied upstream to CP ``` The browser never knows there are two backends. No CORS, no cookie domain mismatches, no extra env vars for Canvas to configure. --- ## Architecture at a glance ``` Browser (Canvas) │ │ GET /cp/auth/me (or any /cp/* path) ▼ Tenant Platform (:8080) │ │ Reverse proxy: forward Cookie + Authorization headers ▼ Control Plane (api.moleculesai.app) │ │ WorkOS session cookie → verify membership ▼ Response flows back through tenant → browser ``` The proxy: - **Does NOT strip** `Cookie` or `Authorization` headers — they carry the WorkOS session cookie needed by the CP - **Does rewrite** the `Host` header so CP middleware (CORS checks, cookie-domain logic) sees the CP origin, not the tenant - **Does NOT strip** `X-Forwarded-For` — upstream uses it for audit and rate limiting --- ## Security: fail-closed allowlist The proxy does **not** forward arbitrary `/cp/*` paths. An explicit allowlist gates every upstream route **before** cookies leave the tenant: | Allowed prefix | What Canvas uses it for | |---|---| | `/cp/auth/` | Session verification: `GET /cp/auth/me`, `GET /cp/auth/tenant-member` | | `/cp/orgs` | Org listing, provision status, export | | `/cp/billing/` | Checkout and billing portal | | `/cp/templates` | Template registry reads | | `/cp/legal/` | Terms of service document (served from CP) | **Every other `/cp/*` path returns 404**, not 403. The 404 prevents leaking which CP routes exist to an attacker probing the proxy. ### Why an allowlist instead of a denylist `/cp/admin/*` endpoints accept WorkOS session cookies as a valid auth tier. A tenant-authed browser user could craft a request to `/cp/admin/tenants/other-slug/diagnostics` — without the allowlist, the tenant would happily forward their cookie upstream. The CP would see a legitimate admin session and honor the request, turning any tenant into a lateral-movement hop. The allowlist is the structural fix. --- ## Configuration **For SaaS tenants:** No configuration needed. The control plane provisioner sets `CP_UPSTREAM_URL` automatically at tenant launch. ```bash # What the provisioner sets: CP_UPSTREAM_URL=https://api.moleculesai.app ``` **For self-hosted / local dev:** `CP_UPSTREAM_URL` is unset. The `/cp/*` proxy is never mounted. Canvas connects directly to the local platform — behaviour is unchanged. **For operators investigating:** If Canvas admin pages (billing, org switcher) return 502, check that `CP_UPSTREAM_URL` is reachable from the tenant platform's network. --- ## What changed in the browser bundle Canvas's Next.js build sets one base URL: ```typescript // NEXT_PUBLIC_PLATFORM_URL = https://.moleculesai.app const res = await fetch(`${process.env.NEXT_PUBLIC_PLATFORM_URL}/cp/auth/me`, { credentials: 'include', // send WorkOS session cookie }); ``` Previously Canvas needed two separate env vars and conditional logic to choose the right base URL for each call. That conditional logic is gone — one URL, server-side routing. --- ## AdminAuth + WorkOS session verification The `/cp/*` proxy enables a related improvement: **browser-based admin authentication**. Canvas runs in the browser and authenticates users via a WorkOS session cookie (scoped to `.moleculesai.app`). It has no bearer token — the `ADMIN_TOKEN` scheme is for CLI and server-to-server callers, not browser users. AdminAuth now accepts a session-verification tier that runs **before** the bearer check: 1. If a `Cookie` header is present **and** `CP_UPSTREAM_URL` is configured → the tenant platform calls `GET /cp/auth/tenant-member?slug=` upstream with the same cookie. 200 + `member: true` → grant admin access. 2. If the upstream says no, or no cookie is present → fall through to the existing bearer-token path. Positive verifications are cached **30 seconds** (keyed by `sha256(slug + cookie)`), so a burst of Canvas admin-page renders doesn't hammer the CP. Negative results (invalid session) are cached **5 seconds** to absorb retry bursts without fan-out. Logout and role changes propagate within that window. For **self-hosted** and **local dev** deployments, `CP_UPSTREAM_URL` is unset → this feature is disabled, behaviour is unchanged. --- ## Code references | File | What it does | |---|---| | `workspace-server/internal/router/cp_proxy.go` | `/cp/*` reverse proxy + allowlist | | `workspace-server/internal/middleware/session_auth.go` | WorkOS session verification + 30s cache | | `workspace-server/internal/router/router.go` | Mounts proxy when `CP_UPSTREAM_URL` set | | `canvas/src/middleware.ts` | Simplified Canvas fetch base — one URL | --- ## What this means for you - **SaaS tenants**: Canvas Just Works after provisioning. No extra env vars for browser API calls. - **Self-hosted operators**: No change — your Canvas talks to your local platform as before. - **Platform contributors**: If a new Canvas UI fetch needs a `/cp/*` path, add it to `cpProxyAllowedPrefixes` in `cp_proxy.go`. The allowlist means you must opt in — no accidental exposure.