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
d81d15537e
commit
3abcee11b3
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 =
|
export const PLATFORM_URL =
|
||||||
process.env.NEXT_PUBLIC_PLATFORM_URL || "http://localhost:8080";
|
process.env.NEXT_PUBLIC_PLATFORM_URL || "http://localhost:8080";
|
||||||
|
|
||||||
@ -6,10 +8,21 @@ async function request<T>(
|
|||||||
path: string,
|
path: string,
|
||||||
body?: unknown
|
body?: unknown
|
||||||
): Promise<T> {
|
): 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}`, {
|
const res = await fetch(`${PLATFORM_URL}${path}`, {
|
||||||
method,
|
method,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers,
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
credentials: "include",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { Secret } from '@/types/secrets';
|
import type { Secret } from '@/types/secrets';
|
||||||
|
import { getTenantSlug } from '../tenant';
|
||||||
|
|
||||||
const PLATFORM_URL = process.env.NEXT_PUBLIC_PLATFORM_URL ?? 'http://localhost:8080';
|
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> {
|
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, {
|
const res = await fetch(url, {
|
||||||
...init,
|
...init,
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
...saasHeaders,
|
||||||
...init?.headers,
|
...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