diff --git a/canvas/src/app/layout.tsx b/canvas/src/app/layout.tsx
index e0e98f1d..15cd5646 100644
--- a/canvas/src/app/layout.tsx
+++ b/canvas/src/app/layout.tsx
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import "./globals.css";
+import { AuthGate } from "@/components/AuthGate";
export const metadata: Metadata = {
title: "Molecule AI",
@@ -13,7 +14,13 @@ export default function RootLayout({
}) {
return (
-
{children}
+
+ {/* AuthGate is a client component; it checks the session on mount
+ and bounces anonymous users to the control plane's login page
+ when running on a tenant subdomain. Non-SaaS hosts (localhost,
+ vercel preview URL, apex) pass through unchanged. */}
+ {children}
+
);
}
diff --git a/canvas/src/components/AuthGate.tsx b/canvas/src/components/AuthGate.tsx
new file mode 100644
index 00000000..b65eeeb6
--- /dev/null
+++ b/canvas/src/components/AuthGate.tsx
@@ -0,0 +1,68 @@
+"use client";
+
+/**
+ * AuthGate wraps the canvas root so every page is gated on a valid session.
+ * Anonymous users get bounced to app.moleculesai.app/cp/auth/login?return_to=.
+ *
+ * In non-SaaS mode (no tenant slug — local dev, apex, vercel preview URL),
+ * the gate is a pass-through: canvas works without auth for local dev.
+ * This mirrors the control plane's "disabled provider" fallback.
+ */
+import { useEffect, useState, type ReactNode } from "react";
+import { fetchSession, redirectToLogin, type Session } from "@/lib/auth";
+import { getTenantSlug } from "@/lib/tenant";
+
+export type AuthGateState =
+ | { kind: "loading" }
+ | { kind: "anonymous"; skipRedirect: boolean }
+ | { kind: "authenticated"; session: Session };
+
+export function AuthGate({ children }: { children: ReactNode }) {
+ const [state, setState] = useState({ kind: "loading" });
+
+ useEffect(() => {
+ // In non-SaaS mode (no tenant slug) we skip the gate entirely —
+ // local dev, vercel preview URLs, and the app.moleculesai.app apex
+ // should not force login for API-only interactions.
+ const slug = getTenantSlug();
+ if (!slug) {
+ setState({ kind: "anonymous", skipRedirect: true });
+ return;
+ }
+ let cancelled = false;
+ fetchSession()
+ .then((s) => {
+ if (cancelled) return;
+ if (s) {
+ setState({ kind: "authenticated", session: s });
+ } else {
+ setState({ kind: "anonymous", skipRedirect: false });
+ }
+ })
+ .catch(() => {
+ // Network error — fail closed (show signin) so a transient
+ // outage doesn't leak the canvas UI to an unauth'd user.
+ if (!cancelled) setState({ kind: "anonymous", skipRedirect: false });
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ useEffect(() => {
+ if (state.kind === "anonymous" && !state.skipRedirect) {
+ redirectToLogin("sign-in");
+ }
+ }, [state]);
+
+ if (state.kind === "loading") {
+ // Minimal placeholder; canvas has its own loading UI downstream.
+ return null;
+ }
+ if (state.kind === "anonymous" && !state.skipRedirect) {
+ // Redirect already firing from the effect above; render nothing in
+ // the interim to avoid a flash of unauthenticated content.
+ return null;
+ }
+ return <>{children}>;
+}
diff --git a/canvas/src/lib/__tests__/auth.test.ts b/canvas/src/lib/__tests__/auth.test.ts
new file mode 100644
index 00000000..f1cd3b52
--- /dev/null
+++ b/canvas/src/lib/__tests__/auth.test.ts
@@ -0,0 +1,69 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { fetchSession, redirectToLogin } from "../auth";
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+});
+
+describe("fetchSession", () => {
+ it("returns session on 200", async () => {
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({ user_id: "u1", org_id: "o1", email: "a@x.com" }),
+ }));
+ const s = await fetchSession();
+ expect(s).toEqual({ user_id: "u1", org_id: "o1", email: "a@x.com" });
+ });
+
+ it("returns null on 401 without throwing", async () => {
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 401 }));
+ const s = await fetchSession();
+ expect(s).toBeNull();
+ });
+
+ it("throws on 500 so transient outages aren't treated as 'anonymous'", async () => {
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 500, statusText: "oops" }));
+ await expect(fetchSession()).rejects.toThrow("500");
+ });
+
+ it("sends credentials:include for cross-origin cookies", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 401 });
+ vi.stubGlobal("fetch", fetchMock);
+ await fetchSession();
+ expect(fetchMock).toHaveBeenCalledWith(
+ expect.stringContaining("/cp/auth/me"),
+ expect.objectContaining({ credentials: "include" }),
+ );
+ });
+});
+
+describe("redirectToLogin", () => {
+ it("sets window.location to cp login URL with return_to", () => {
+ const href = "https://acme.moleculesai.app/dashboard";
+ Object.defineProperty(window, "location", {
+ writable: true,
+ value: { href },
+ });
+ redirectToLogin("sign-in");
+ // href now holds the redirect target. encodeURIComponent(href) must
+ // appear in the query.
+ expect((window.location as unknown as { href: string }).href).toContain("/cp/auth/login");
+ expect((window.location as unknown as { href: string }).href).toContain(
+ encodeURIComponent(href),
+ );
+ });
+
+ it("uses signup path for sign-up screenHint", () => {
+ Object.defineProperty(window, "location", {
+ writable: true,
+ value: { href: "https://acme.moleculesai.app/" },
+ });
+ redirectToLogin("sign-up");
+ expect((window.location as unknown as { href: string }).href).toContain("/cp/auth/signup");
+ });
+});
diff --git a/canvas/src/lib/auth.ts b/canvas/src/lib/auth.ts
new file mode 100644
index 00000000..d16006ac
--- /dev/null
+++ b/canvas/src/lib/auth.ts
@@ -0,0 +1,51 @@
+/**
+ * Canvas-side session detection. Calls /cp/auth/me on the control plane
+ * (via same-origin → PLATFORM_URL) and returns the session or null.
+ *
+ * 401 is the "anonymous" signal and does NOT throw — the caller decides
+ * whether to redirect. Network errors do throw so React error boundaries
+ * can surface them.
+ */
+import { PLATFORM_URL } from "./api";
+
+export interface Session {
+ user_id: string;
+ org_id: string;
+ email: string;
+}
+
+// Base path prefix for auth endpoints on the control plane.
+const AUTH_BASE = "/cp/auth";
+
+/**
+ * fetchSession probes /cp/auth/me with the session cookie (credentials:
+ * include mandatory cross-origin). Returns the Session on 200, null on
+ * 401 (anonymous), throws on anything else so callers don't silently
+ * treat a 5xx as "not logged in".
+ */
+export async function fetchSession(): Promise {
+ const res = await fetch(`${PLATFORM_URL}${AUTH_BASE}/me`, {
+ credentials: "include",
+ });
+ if (res.status === 401) return null;
+ if (!res.ok) {
+ throw new Error(`/cp/auth/me: ${res.status} ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * redirectToLogin bounces the browser to the control plane's login page
+ * with a `return_to` param so the user lands back on the current URL
+ * after signup/login completes. Same-origin safety is enforced on the
+ * CP side (isSafeReturnTo rejects cross-domain / http / protocol-
+ * relative URLs). Uses window.location.href so the full URL including
+ * query + hash survives the round trip.
+ */
+export function redirectToLogin(screenHint: "sign-up" | "sign-in" = "sign-in"): void {
+ if (typeof window === "undefined") return;
+ const returnTo = window.location.href;
+ const path = screenHint === "sign-up" ? "signup" : "login";
+ const dest = `${PLATFORM_URL}${AUTH_BASE}/${path}?return_to=${encodeURIComponent(returnTo)}`;
+ window.location.href = dest;
+}