feat(canvas): SaaS cross-origin — slug header + cookie credentials (Phase F)

Canvas will be served at <slug>.moleculesai.app (Vercel). API calls go
cross-origin to https://app.moleculesai.app. This commit wires the
client side:

- canvas/src/lib/tenant.ts: getTenantSlug() derives the slug from
  window.location.hostname, case-insensitive, matching the control
  plane's reservedSubdomains list (app/www/api/admin/…). Server-side
  + localhost + vercel preview URLs + apex all return "" so local dev
  keeps working.

- canvas/src/lib/api.ts: adds X-Molecule-Org-Slug header + sets
  credentials:"include" on every fetch. The control plane's CORS
  middleware allows the origin + credentials; the session cookie has
  Domain=.moleculesai.app so the browser ships it.

- canvas/src/lib/api/secrets.ts: same treatment (secrets API uses its
  own fetch helper — shared slug+credentials logic applied).

Tests: +6 (tenant.test.ts covers slug / reserved / case / non-SaaS /
preview URL / apex). Full canvas suite 447/447 green.

Not in this PR:
- WS URL derivation for terminal/socket.ts (separate follow-up; WS
  needs its own slug-aware URL and the canvas terminal isn't used in
  SaaS launch day-one).
- Next.js rewrites (decided against; cross-origin with credentials
  is cleaner than path-level rewrites for session cookies).

Deploys to Vercel once merged — no manual config needed (env already set).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-14 20:08:39 -07:00
parent 15ad2a8dbe
commit c7537436ff
5 changed files with 131 additions and 2 deletions

1
canvas/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
.vercel

View File

@ -0,0 +1,52 @@
/**
* @vitest-environment jsdom
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { getTenantSlug } from '../tenant';
afterEach(() => {
vi.unstubAllGlobals();
});
// Shim window.location.hostname for each case.
function setHost(host: string) {
Object.defineProperty(window, 'location', {
value: { hostname: host },
writable: true,
});
}
describe('getTenantSlug', () => {
it('returns slug for tenant subdomain', () => {
setHost('acme.moleculesai.app');
expect(getTenantSlug()).toBe('acme');
});
it('is case-insensitive', () => {
setHost('ACME.MoleculesAI.app');
expect(getTenantSlug()).toBe('acme');
});
it('returns empty for reserved subdomains', () => {
for (const s of ['app', 'www', 'api', 'admin']) {
setHost(`${s}.moleculesai.app`);
expect(getTenantSlug()).toBe('');
}
});
it('returns empty for non-SaaS hosts', () => {
setHost('localhost');
expect(getTenantSlug()).toBe('');
});
it('returns empty for vercel preview URL', () => {
setHost('molecule-canvas-abc123.vercel.app');
expect(getTenantSlug()).toBe('');
});
it('returns empty for apex', () => {
setHost('moleculesai.app');
// doesn't end with "." + suffix
expect(getTenantSlug()).toBe('');
});
});

View File

@ -1,3 +1,5 @@
import { getTenantSlug } from "./tenant";
export const PLATFORM_URL =
process.env.NEXT_PUBLIC_PLATFORM_URL || "http://localhost:8080";
@ -6,10 +8,21 @@ async function request<T>(
path: string,
body?: unknown
): Promise<T> {
// SaaS cross-origin shape:
// - X-Molecule-Org-Slug: derived from window.location.hostname by
// getTenantSlug(). Control plane uses it for fly-replay routing.
// Empty on localhost / non-tenant hosts — safe to omit.
// - credentials:"include": sends the session cookie cross-origin.
// Cookie's Domain=.moleculesai.app attribute + cp's CORS allow this.
const headers: Record<string, string> = { "Content-Type": "application/json" };
const slug = getTenantSlug();
if (slug) headers["X-Molecule-Org-Slug"] = slug;
const res = await fetch(`${PLATFORM_URL}${path}`, {
method,
headers: { "Content-Type": "application/json" },
headers,
body: body ? JSON.stringify(body) : undefined,
credentials: "include",
});
if (!res.ok) {
const text = await res.text();

View File

@ -1,4 +1,5 @@
import type { Secret } from '@/types/secrets';
import { getTenantSlug } from '../tenant';
const PLATFORM_URL = process.env.NEXT_PUBLIC_PLATFORM_URL ?? 'http://localhost:8080';
@ -12,10 +13,16 @@ function apiUrl(workspaceId: string, path = ''): string {
}
async function request<T>(url: string, init?: RequestInit): Promise<T> {
// Match api.ts shape — slug header + cross-origin credentials so SaaS
// cross-subdomain fetches work. See lib/api.ts for the rationale.
const slug = getTenantSlug();
const saasHeaders: Record<string, string> = { 'Content-Type': 'application/json' };
if (slug) saasHeaders['X-Molecule-Org-Slug'] = slug;
const res = await fetch(url, {
...init,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...saasHeaders,
...init?.headers,
},
});

56
canvas/src/lib/tenant.ts Normal file
View File

@ -0,0 +1,56 @@
/**
* Tenant slug derivation for SaaS-mode canvas.
*
* When canvas is served at <slug>.moleculesai.app the org slug comes from
* the browser's hostname. When served anywhere else (localhost, Vercel
* preview URL, direct vercel.app) we fall back to a configured slug
* (NEXT_PUBLIC_DEFAULT_ORG_SLUG) or an empty string API calls without
* a slug hit the control plane's non-tenant routes.
*/
// SaaSHostSuffix is the domain this canvas is the tenant UI for. Parent
// domain with a leading dot; the hostname must end with this to be
// recognized as a tenant subdomain. Defaults to `.moleculesai.app` but
// is overridable via NEXT_PUBLIC_SAAS_HOST_SUFFIX for multi-brand or
// staging environments.
export const SaaSHostSuffix =
process.env.NEXT_PUBLIC_SAAS_HOST_SUFFIX ?? ".moleculesai.app";
// reservedSubdomains mirrors the control plane's list so we don't
// accidentally treat canvas-itself subdomains as tenant slugs when the
// user lands on e.g. app.moleculesai.app directly.
const reservedSubdomains = new Set([
"app",
"www",
"api",
"admin",
"cp",
"dashboard",
"billing",
"status",
"docs",
]);
/**
* getTenantSlug returns the tenant slug for the current request.
*
* Client-side: reads window.location.hostname.
* Server-side (SSR / build): reads NEXT_PUBLIC_DEFAULT_ORG_SLUG, which is
* unset in production SaaS (we never SSR tenant pages without a host)
* but useful for local dev when the app is served at localhost:3000.
*
* Returns "" if no slug can be derived callers must handle that case
* (usually by redirecting to app.moleculesai.app for signup/org picker).
*/
export function getTenantSlug(): string {
if (typeof window === "undefined") {
return process.env.NEXT_PUBLIC_DEFAULT_ORG_SLUG ?? "";
}
const host = window.location.hostname.toLowerCase();
if (!host.endsWith(SaaSHostSuffix)) {
return process.env.NEXT_PUBLIC_DEFAULT_ORG_SLUG ?? "";
}
const slug = host.slice(0, host.length - SaaSHostSuffix.length);
if (reservedSubdomains.has(slug)) return "";
return slug;
}