diff --git a/.env.example b/.env.example index 3888db48..32fac03a 100644 --- a/.env.example +++ b/.env.example @@ -34,7 +34,7 @@ PLUGINS_DIR= # Path to plugins/ directory (default: /plugins i # MOLECULE_MCP_ALLOW_SEND_MESSAGE= # Set to "true" to include send_message_to_user in the MCP bridge tool list (issue #810). Excluded by default to prevent unintended WebSocket pushes from CLI sessions. # MOLECULE_MCP_URL=http://localhost:8080 # Platform URL for opencode MCP config (opencode.json). Same as PLATFORM_URL; separate var so opencode configs can reference it without ambiguity. # WORKSPACE_DIR= # Optional global host path bind-mounted to /workspace in every container. Per-workspace workspace_dir column overrides this; if neither is set each workspace gets an isolated Docker named volume. -# MOLECULE_ENV=development # Environment label (development/staging/production). Used for log tagging and conditional behaviour. +MOLECULE_ENV=development # Environment label (development/staging/production). Used for log tagging and for the AdminAuth dev-mode escape hatch (lets the Canvas dashboard keep working after the first workspace is created, when ADMIN_TOKEN is unset). SaaS deployments MUST set MOLECULE_ENV=production. # MOLECULE_ENABLE_TEST_TOKENS= # Set to 1 to expose GET /admin/workspaces/:id/test-token (mints a fresh bearer token for E2E scripts). The route is auto-enabled when MOLECULE_ENV != production; this flag is the explicit override. Leave unset/0 in prod — the route 404s unless enabled. # MOLECULE_ORG_ID= # SaaS only: org UUID set by control plane on tenant machines. When set, workspace provisioning auto-routes through the control plane API instead of Docker. # CP_PROVISION_URL= # Override control plane URL for workspace provisioning (default: https://api.moleculesai.app). Only needed for testing against a non-production control plane. diff --git a/canvas/src/components/CookieConsent.tsx b/canvas/src/components/CookieConsent.tsx index 5ea0dc57..2f04df39 100644 --- a/canvas/src/components/CookieConsent.tsx +++ b/canvas/src/components/CookieConsent.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState } from "react"; +import { isSaaSTenant } from "@/lib/tenant"; const STORAGE_KEY = "molecule_cookie_consent"; @@ -74,7 +75,18 @@ export function CookieConsent() { // Read persisted decision on mount. useState's initialState can't run // on first render because localStorage is SSR-unsafe — defer to // useEffect so the initial HTML is identical to the server snapshot. + // + // The banner is SaaS-only: it carries a link to the hosted + // privacy policy (moleculesai.app/legal/privacy) and presumes + // GDPR/ePrivacy obligations that only apply to the hosted offering. + // Self-hosted / local-dev / Vercel-preview hosts get no banner — + // matches the `isSaaSTenant()` convention used by AuthGate and + // the tier picker. useEffect(() => { + if (!isSaaSTenant()) { + setVisible(false); + return; + } setVisible(getStoredConsent() === null); }, []); diff --git a/canvas/src/components/__tests__/CookieConsent.test.tsx b/canvas/src/components/__tests__/CookieConsent.test.tsx index 36314858..188c6f9c 100644 --- a/canvas/src/components/__tests__/CookieConsent.test.tsx +++ b/canvas/src/components/__tests__/CookieConsent.test.tsx @@ -6,11 +6,30 @@ import { CookieConsent, hasConsent } from "../CookieConsent"; const STORAGE_KEY = "molecule_cookie_consent"; // These tests lock the privacy-preserving default: the banner appears on -// first visit, clicking either button records a decision, and subsequent -// renders skip the banner until the policy version changes. +// first visit (SaaS mode), clicking either button records a decision, and +// subsequent renders skip the banner until the policy version changes. +// +// The banner is SaaS-only — it references moleculesai.app's hosted privacy +// policy and presumes GDPR/ePrivacy obligations that only apply to the +// hosted offering. Self-hosted / local-dev hosts must not see it. Most +// tests below simulate SaaS by overriding window.location.hostname; the +// "local-dev" test omits that override. + +// setSaaSHostname rewrites window.location.hostname to look like a SaaS +// tenant subdomain so isSaaSTenant() returns true. Must run before +// CookieConsent mounts, otherwise its one-shot useEffect captures the +// localhost default. jsdom's location object is read-only via the normal +// setter but defineProperty lets us replace it for the scope of a test. +function setSaaSHostname(host = "acme.moleculesai.app") { + Object.defineProperty(window, "location", { + configurable: true, + value: { ...window.location, hostname: host }, + }); +} beforeEach(() => { window.localStorage.clear(); + setSaaSHostname(); }); afterEach(() => { @@ -86,6 +105,28 @@ describe("CookieConsent", () => { expect(dialog.getAttribute("aria-labelledby")).toBe("cookie-consent-title"); expect(dialog.getAttribute("aria-describedby")).toBe("cookie-consent-body"); }); + + it("does NOT render on local dev (non-SaaS hostname)", () => { + // Simulate `npm run dev` on localhost — isSaaSTenant() returns false + // and the banner must stay hidden. Regression test for PR #1871: + // a fresh-clone Canvas showing the hosted privacy banner on + // localhost:3000 was confusing for self-hosted users. + Object.defineProperty(window, "location", { + configurable: true, + value: { ...window.location, hostname: "localhost" }, + }); + render(); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("does NOT render on a LAN hostname (192.168.*, *.local)", () => { + Object.defineProperty(window, "location", { + configurable: true, + value: { ...window.location, hostname: "192.168.1.74" }, + }); + render(); + expect(screen.queryByRole("dialog")).toBeNull(); + }); }); describe("hasConsent", () => { diff --git a/workspace-server/internal/middleware/wsauth_middleware.go b/workspace-server/internal/middleware/wsauth_middleware.go index 9e330e99..50535bad 100644 --- a/workspace-server/internal/middleware/wsauth_middleware.go +++ b/workspace-server/internal/middleware/wsauth_middleware.go @@ -148,6 +148,26 @@ func AdminAuth(database *sql.DB) gin.HandlerFunc { } } + // Tier 1b: Local-dev escape hatch. On `go run ./cmd/server` the + // Canvas has no bearer token (there's no WorkOS session, no + // baked NEXT_PUBLIC_ADMIN_TOKEN), so the moment the first + // workspace token lands in the DB Tier 1 closes and Canvas → 401 + // on every GET /workspaces. This reopens fail-open *only* when + // - ADMIN_TOKEN is empty (i.e. the operator has not opted in + // to the Phase-30 closure), AND + // - MOLECULE_ENV is explicitly a dev mode. + // SaaS never hits this branch because tenant provisioning sets + // both ADMIN_TOKEN and MOLECULE_ENV=production. Matches the + // existing convention in handlers/admin_test_token.go which + // gates the test-token endpoint on MOLECULE_ENV != "production". + if adminSecret == "" { + env := strings.ToLower(strings.TrimSpace(os.Getenv("MOLECULE_ENV"))) + if env == "development" || env == "dev" { + c.Next() + return + } + } + // SaaS-canvas path: when the request carries a WorkOS session // cookie AND the CP confirms it's valid, accept without a // bearer. This is how the tenant's Next.js canvas UI diff --git a/workspace-server/internal/middleware/wsauth_middleware_test.go b/workspace-server/internal/middleware/wsauth_middleware_test.go index 020eabfd..b796dc75 100644 --- a/workspace-server/internal/middleware/wsauth_middleware_test.go +++ b/workspace-server/internal/middleware/wsauth_middleware_test.go @@ -735,6 +735,114 @@ func TestAdminAuth_Issue180_ApprovalsListing_FailOpen_NoTokens(t *testing.T) { } } +// TestAdminAuth_DevModeEscapeHatch_FailsOpenWithHasLiveTokens documents the +// Tier-1b dev-mode escape hatch. When the platform runs with MOLECULE_ENV=development +// and ADMIN_TOKEN is unset, AdminAuth must stay fail-open even after workspace +// tokens land in the DB. This keeps the Canvas dashboard usable in local dev +// after the first workspace is created (PR #1871 — quickstart bugless). +// +// SaaS never hits this path because tenant provisioning sets both +// ADMIN_TOKEN and MOLECULE_ENV=production. +func TestAdminAuth_DevModeEscapeHatch_FailsOpenWithHasLiveTokens(t *testing.T) { + t.Setenv("MOLECULE_ENV", "development") + t.Setenv("ADMIN_TOKEN", "") + + mockDB, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New: %v", err) + } + defer mockDB.Close() + + // HasAnyLiveTokenGlobal returns 1 — tokens exist (post first-workspace). + // The Tier-1 fail-open branch WOULD close here. Tier-1b must still open. + mock.ExpectQuery(hasAnyLiveTokenGlobalQuery). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + + r := gin.New() + r.GET("/workspaces", AdminAuth(mockDB), func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"workspaces": []interface{}{}}) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/workspaces", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("dev-mode escape hatch: expected 200, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet sqlmock expectations: %v", err) + } +} + +// TestAdminAuth_DevModeEscapeHatch_IgnoredWhenAdminTokenSet verifies that the +// dev-mode escape hatch does NOT override an operator who has set ADMIN_TOKEN. +// Setting ADMIN_TOKEN is the explicit opt-in to #684 closure; dev-mode must not +// silently reopen the gate. +func TestAdminAuth_DevModeEscapeHatch_IgnoredWhenAdminTokenSet(t *testing.T) { + t.Setenv("MOLECULE_ENV", "development") + t.Setenv("ADMIN_TOKEN", "operator-explicitly-set-this") + + mockDB, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New: %v", err) + } + defer mockDB.Close() + + // Tokens exist — Tier 1 closes. + mock.ExpectQuery(hasAnyLiveTokenGlobalQuery). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + + r := gin.New() + r.GET("/workspaces", AdminAuth(mockDB), func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"workspaces": []interface{}{}}) + }) + + w := httptest.NewRecorder() + // No bearer token — must 401 even in dev mode because ADMIN_TOKEN is set. + req, _ := http.NewRequest(http.MethodGet, "/workspaces", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("dev-mode + ADMIN_TOKEN set: expected 401, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet sqlmock expectations: %v", err) + } +} + +// TestAdminAuth_DevModeEscapeHatch_IgnoredInProduction verifies the hatch never +// fires when MOLECULE_ENV=production. This is the SaaS-safety guarantee. +func TestAdminAuth_DevModeEscapeHatch_IgnoredInProduction(t *testing.T) { + t.Setenv("MOLECULE_ENV", "production") + t.Setenv("ADMIN_TOKEN", "") + + mockDB, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New: %v", err) + } + defer mockDB.Close() + + mock.ExpectQuery(hasAnyLiveTokenGlobalQuery). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + + r := gin.New() + r.GET("/workspaces", AdminAuth(mockDB), func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"workspaces": []interface{}{}}) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/workspaces", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("production mode: expected 401, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet sqlmock expectations: %v", err) + } +} + // TestAdminAuth_Issue120_PatchWorkspace_NoBearer_Returns401 documents the #120 // attack vector and verifies that AdminAuth returns 401 for PATCH without a token. func TestAdminAuth_Issue120_PatchWorkspace_NoBearer_Returns401(t *testing.T) {