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:
parent
15ad2a8dbe
commit
c7537436ff
1
canvas/.gitignore
vendored
Normal file
1
canvas/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
.vercel
|
||||
52
canvas/src/lib/__tests__/tenant.test.ts
Normal file
52
canvas/src/lib/__tests__/tenant.test.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
|
||||
@ -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
56
canvas/src/lib/tenant.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user