Molecule AI
Guides

Same-Origin Canvas Fetches

How Molecule AI's SaaS Canvas makes browser API calls to two backends through one origin — and how the /cp/* reverse proxy enables multi-tenant deployments.

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:

ServiceWhat it doesExample endpoints
Tenant platformYour 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 prefixWhat Canvas uses it for
/cp/auth/Session verification: GET /cp/auth/me, GET /cp/auth/tenant-member
/cp/orgsOrg listing, provision status, export
/cp/billing/Checkout and billing portal
/cp/templatesTemplate 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.

# 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:

// NEXT_PUBLIC_PLATFORM_URL = https://<tenant-slug>.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=<tenant-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

FileWhat it does
workspace-server/internal/router/cp_proxy.go/cp/* reverse proxy + allowlist
workspace-server/internal/middleware/session_auth.goWorkOS session verification + 30s cache
workspace-server/internal/router/router.goMounts proxy when CP_UPSTREAM_URL set
canvas/src/middleware.tsSimplified 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.

On this page