diff --git a/canvas/.gitignore b/canvas/.gitignore new file mode 100644 index 00000000..e985853e --- /dev/null +++ b/canvas/.gitignore @@ -0,0 +1 @@ +.vercel diff --git a/canvas/src/lib/__tests__/tenant.test.ts b/canvas/src/lib/__tests__/tenant.test.ts new file mode 100644 index 00000000..818a40d5 --- /dev/null +++ b/canvas/src/lib/__tests__/tenant.test.ts @@ -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(''); + }); +}); diff --git a/canvas/src/lib/api.ts b/canvas/src/lib/api.ts index 64b5ed12..10e77e52 100644 --- a/canvas/src/lib/api.ts +++ b/canvas/src/lib/api.ts @@ -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( path: string, body?: unknown ): Promise { + // 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 = { "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(); diff --git a/canvas/src/lib/api/secrets.ts b/canvas/src/lib/api/secrets.ts index 572d61cd..8e41f49f 100644 --- a/canvas/src/lib/api/secrets.ts +++ b/canvas/src/lib/api/secrets.ts @@ -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(url: string, init?: RequestInit): Promise { + // 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 = { '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, }, }); diff --git a/canvas/src/lib/tenant.ts b/canvas/src/lib/tenant.ts new file mode 100644 index 00000000..af79776c --- /dev/null +++ b/canvas/src/lib/tenant.ts @@ -0,0 +1,56 @@ +/** + * Tenant slug derivation for SaaS-mode canvas. + * + * When canvas is served at .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; +}