diff --git a/content/docs/guides/same-origin-canvas-fetches.md b/content/docs/guides/same-origin-canvas-fetches.md new file mode 100644 index 0000000..de73d85 --- /dev/null +++ b/content/docs/guides/same-origin-canvas-fetches.md @@ -0,0 +1,149 @@ +# 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.