docs: add same-origin-canvas-fetches.md guide (synced from molecule-core)
This commit is contained in:
parent
c0b5a92b1c
commit
6b9c18a1f7
149
content/docs/guides/same-origin-canvas-fetches.md
Normal file
149
content/docs/guides/same-origin-canvas-fetches.md
Normal file
@ -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://<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.
|
||||
Loading…
Reference in New Issue
Block a user