molecule-core/docs/guides/same-origin-canvas-fetches.md
molecule-ai[bot] c40b237c32 docs(marketing): Phase 30 launch — content blog posts, DevRel assets, and execution suite
Rebuilt from original PR #1276. All Phase 30 launch content restored:
- 5 blog posts (Remote Workspaces, Chrome DevTools MCP, Container vs Remote, Secure by Design)
- 4 guides (Remote Workspaces, FAQ, same-origin canvas, quickstart audio)
- Community copy: Discord/Slack announcements, HN launch guide
- Social copy: Phase 30 (4 X versions + LinkedIn), Chrome DevTools MCP
- Sales: landing copy, battlecards, one-pager, objection handlers
- Press release draft
- Demos: AGENTS.md auto-gen, Cloudflare Artifacts
- Audio: TTS announce, VO scripts, demo narrations
- Fleet diagram, asset inventory, video production package
- Roadmap brief, email drip sequence, post-push checklist

Closes GH#1126
2026-04-21 06:22:27 +00:00

6.6 KiB

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.

# 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

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.