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) {