diff --git a/apps/dashboard/next.config.mjs b/apps/dashboard/next.config.mjs index c8222a7dd..390d112a8 100644 --- a/apps/dashboard/next.config.mjs +++ b/apps/dashboard/next.config.mjs @@ -120,6 +120,16 @@ const nextConfig = { }, async headers() { + // The development-environment (RDE) dashboard is embedded as an iframe by the + // dev tool overlay, which runs inside the customer's own app on a *different* + // localhost origin (e.g. http://localhost:3000). A plain X-Frame-Options: + // SAMEORIGIN would block that framing, so RDE builds instead scope framing to + // localhost origins via CSP frame-ancestors. This only applies to the RDE + // build target (build:rde-standalone / dev:rde-production); the hosted and + // self-host Docker builds keep X-Frame-Options: SAMEORIGIN. + const isRdeBuild = process.env.HEXCLAVE_DASHBOARD_BUILD_FOR_RDE === "true"; + const allowsFraming = isRdeBuild || resolveHexclaveStackEnvVar("NEXT_PUBLIC_HEXCLAVE_IS_PREVIEW", "NEXT_PUBLIC_STACK_IS_PREVIEW") === "true"; + const rdeFrameAncestors = "frame-ancestors 'self' http://localhost:* https://localhost:* http://*.localhost:* https://*.localhost:* http://127.0.0.1:* https://127.0.0.1:* http://[::1]:* https://[::1]:*"; return [ { source: "/(.*)", @@ -141,7 +151,7 @@ const nextConfig = { key: "X-Content-Type-Options", value: "nosniff", }, - ...resolveHexclaveStackEnvVar("NEXT_PUBLIC_HEXCLAVE_IS_PREVIEW", "NEXT_PUBLIC_STACK_IS_PREVIEW") === "true" ? [] : [{ + ...allowsFraming ? [] : [{ key: "X-Frame-Options", value: "SAMEORIGIN", }], @@ -149,7 +159,7 @@ const nextConfig = { key: "Content-Security-Policy", // Note: *.localhost requires Chrome 117+ and may not work in Firefox // without network.dns.localDomains configuration. Fine for dev tool purposes. - value: "", + value: isRdeBuild ? rdeFrameAncestors : "", }, ], }, diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index b6d1c14df..a243d47c2 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -13,7 +13,7 @@ "bundle-type-definitions": "tsx scripts/bundle-type-definitions.ts", "bundle-type-definitions:watch": "tsx watch --clear-screen=false scripts/bundle-type-definitions.ts", "build": "rimraf .next/types .next/dev/types .next-development-environment/types .next-development-environment/dev/types && pnpm run bundle-type-definitions && next build", - "build:rde-standalone": "NEXT_CONFIG_OUTPUT=standalone STACK_NEXT_CONFIG_DISABLE_TYPESCRIPT=true pnpm run build", + "build:rde-standalone": "NEXT_CONFIG_OUTPUT=standalone STACK_NEXT_CONFIG_DISABLE_TYPESCRIPT=true HEXCLAVE_DASHBOARD_BUILD_FOR_RDE=true pnpm run build", "docker-build": "pnpm run bundle-type-definitions && next build --experimental-build-mode compile", "analyze-bundle": "next experimental-analyze", "start": "next start --port ${NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81}01", diff --git a/apps/dashboard/scripts/dev-rde-production.mjs b/apps/dashboard/scripts/dev-rde-production.mjs index 6a51dc23f..182c59e71 100644 --- a/apps/dashboard/scripts/dev-rde-production.mjs +++ b/apps/dashboard/scripts/dev-rde-production.mjs @@ -41,6 +41,7 @@ runOrExit("pnpm", ["exec", "next", "build"], { NEXT_CONFIG_OUTPUT: "standalone", NODE_ENV: "production", STACK_NEXT_CONFIG_DISABLE_TYPESCRIPT: "true", + HEXCLAVE_DASHBOARD_BUILD_FOR_RDE: "true", }, }); @@ -56,6 +57,12 @@ const server = spawn(process.execPath, [standaloneServerPath], { env: { ...process.env, NODE_ENV: "production", + // `next.config`'s `headers()` is evaluated at build time and baked into the + // routes manifest (the build above already sets this flag), so the RDE + // frame-ancestors headers are correct regardless of the server's runtime + // env. We still propagate the flag here so the running process self-reports + // as an RDE build and stays correct if any header logic ever moves to runtime. + HEXCLAVE_DASHBOARD_BUILD_FOR_RDE: "true", }, stdio: "inherit", }); diff --git a/apps/dashboard/src/app/api/development-environment/project-availability/route.test.ts b/apps/dashboard/src/app/api/development-environment/project-availability/route.test.ts new file mode 100644 index 000000000..fe83dd4bd --- /dev/null +++ b/apps/dashboard/src/app/api/development-environment/project-availability/route.test.ts @@ -0,0 +1,129 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { NextRequest } from "next/server"; + +vi.mock("server-only", () => ({})); + +let tempDir: string | undefined; +const remoteDevelopmentEnvironmentEnabledEnv = "NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT"; +const knownProjectId = "known-project-id"; +const knownConfigPath = "/home/dev/app/hexclave.config.ts"; + +function useTempStateFile() { + tempDir = mkdtempSync(join(tmpdir(), "stack-rde-availability-")); + process.env[remoteDevelopmentEnvironmentEnabledEnv] = "true"; + process.env.STACK_DEV_ENVS_PATH = join(tempDir, "dev-envs.json"); + writeFileSync(process.env.STACK_DEV_ENVS_PATH, JSON.stringify({ + version: 1, + localDashboardsByPort: { + "26700": { + port: 26700, + secret: "secret", + pid: 123, + startedAtMillis: Date.now(), + }, + }, + projectsByConfigPath: { + [knownConfigPath]: { + projectId: knownProjectId, + teamId: "team-id", + publishableClientKey: "pck", + secretServerKey: "ssk", + apiBaseUrl: "http://localhost:8102", + updatedAtMillis: Date.now(), + }, + }, + })); + chmodSync(process.env.STACK_DEV_ENVS_PATH, 0o600); +} + +function request(url: string, headers: Record) { + return new NextRequest(url, { headers }); +} + +async function getResponse(req: NextRequest) { + const { GET } = await import("./route"); + return await GET(req); +} + +afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + delete process.env[remoteDevelopmentEnvironmentEnabledEnv]; + delete process.env.STACK_DEV_ENVS_PATH; + if (tempDir != null) { + rmSync(tempDir, { recursive: true, force: true }); + tempDir = undefined; + } +}); + +describe("development environment project-availability route", () => { + it("returns 404 when the development environment is disabled", async () => { + tempDir = mkdtempSync(join(tmpdir(), "stack-rde-availability-")); + process.env.STACK_DEV_ENVS_PATH = join(tempDir, "dev-envs.json"); + const response = await getResponse(request( + `http://127.0.0.1:26700/api/development-environment/project-availability?project_id=${knownProjectId}`, + { host: "127.0.0.1:26700" }, + )); + expect(response.status).toBe(404); + }); + + it("returns 403 for non-loopback hosts", async () => { + useTempStateFile(); + const response = await getResponse(request( + `http://preview.example.test/api/development-environment/project-availability?project_id=${knownProjectId}`, + { host: "preview.example.test" }, + )); + expect(response.status).toBe(403); + }); + + it("returns 403 for non-localhost origins", async () => { + useTempStateFile(); + const response = await getResponse(request( + `http://127.0.0.1:26700/api/development-environment/project-availability?project_id=${knownProjectId}`, + { host: "127.0.0.1:26700", origin: "https://evil.example.com" }, + )); + expect(response.status).toBe(403); + }); + + it("allows requests from a localhost origin (the customer's dev tool)", async () => { + useTempStateFile(); + const response = await getResponse(request( + `http://127.0.0.1:26700/api/development-environment/project-availability?project_id=${knownProjectId}`, + { host: "127.0.0.1:26700", origin: "http://localhost:3000" }, + )); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ running: true, project_available: true }); + }); + + it("returns 400 when project_id is missing", async () => { + useTempStateFile(); + const response = await getResponse(request( + "http://127.0.0.1:26700/api/development-environment/project-availability", + { host: "127.0.0.1:26700" }, + )); + expect(response.status).toBe(400); + }); + + it("reports project_available: true for a project the development environment owns", async () => { + useTempStateFile(); + const response = await getResponse(request( + `http://127.0.0.1:26700/api/development-environment/project-availability?project_id=${knownProjectId}`, + { host: "127.0.0.1:26700" }, + )); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ running: true, project_available: true }); + }); + + it("reports project_available: false for an unknown project", async () => { + useTempStateFile(); + const response = await getResponse(request( + "http://127.0.0.1:26700/api/development-environment/project-availability?project_id=some-other-project", + { host: "127.0.0.1:26700" }, + )); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ running: true, project_available: false }); + }); +}); diff --git a/apps/dashboard/src/app/api/development-environment/project-availability/route.ts b/apps/dashboard/src/app/api/development-environment/project-availability/route.ts new file mode 100644 index 000000000..fc54a230d --- /dev/null +++ b/apps/dashboard/src/app/api/development-environment/project-availability/route.ts @@ -0,0 +1,58 @@ +import { getPublicEnvVar } from "@/lib/env"; +import { isLocalhost } from "@hexclave/shared/dist/utils/urls"; +import { NextRequest, NextResponse } from "next/server"; + +export const runtime = "nodejs"; + +// The dev tool overlay runs inside the customer's own app, which lives on a +// different localhost origin than the development-environment dashboard on +// :26700. It therefore has no browser secret and must reach this endpoint +// cross-origin. All this endpoint ever reveals is whether the locally-running +// development environment already owns a given project id — never secrets, +// config, or any other project data. Even so, the dashboard's global CORS +// middleware sets `Access-Control-Allow-Origin: *` on every /api/ route, which +// would otherwise let any website a developer visits read that ownership +// boolean off their machine. We therefore gate on both: +// - the request Host being loopback (defense-in-depth against proxied hosts; +// the development environment only ever binds to loopback anyway), and +// - the request Origin being absent or itself a localhost origin, so a +// non-localhost page cannot read the response cross-origin. + +function requestHostIsLoopback(req: NextRequest): boolean { + const host = req.headers.get("host"); + if (host == null) return false; + return isLocalhost(`http://${host}`); +} + +// A browser attaches an `Origin` header to every cross-origin (and most +// same-origin) fetches. A missing Origin means the caller is not a browser +// cross-origin request (e.g. a same-origin/non-browser client), which is fine. +// If it is present, it must be a localhost origin — otherwise some remote site +// is trying to read our loopback-only ownership boolean. +function requestOriginIsAllowed(req: NextRequest): boolean { + const origin = req.headers.get("origin"); + if (origin == null) return true; + return isLocalhost(origin); +} + +export async function GET(req: NextRequest): Promise { + const isRemoteDevelopmentEnvironment = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT") === "true"; + if (!isRemoteDevelopmentEnvironment) { + return NextResponse.json({ error: "This endpoint is only available in development environments." }, { status: 404 }); + } + if (!requestHostIsLoopback(req)) { + return NextResponse.json({ error: "This endpoint is only available on loopback addresses." }, { status: 403 }); + } + if (!requestOriginIsAllowed(req)) { + return NextResponse.json({ error: "This endpoint is only available to localhost origins." }, { status: 403 }); + } + + const projectId = req.nextUrl.searchParams.get("project_id"); + if (projectId == null || projectId.length === 0) { + return NextResponse.json({ error: "Missing project_id query parameter." }, { status: 400 }); + } + + const { getRemoteDevelopmentEnvironmentProjectConfigPaths } = await import("@/lib/remote-development-environment/manager"); + const projectAvailable = getRemoteDevelopmentEnvironmentProjectConfigPaths().has(projectId); + return NextResponse.json({ running: true, project_available: projectAvailable }); +} diff --git a/packages/template/src/dev-tool/dev-tool-core.ts b/packages/template/src/dev-tool/dev-tool-core.ts index 6191623b5..e39815fdb 100644 --- a/packages/template/src/dev-tool/dev-tool-core.ts +++ b/packages/template/src/dev-tool/dev-tool-core.ts @@ -5,7 +5,6 @@ import { DEV_TOOL_ROOT_ID } from "@hexclave/shared/dist/utils/dev-tool"; import { runAsynchronously, runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises"; import { isLocalhost } from "@hexclave/shared/dist/utils/urls"; import type { StackClientApp } from "../lib/hexclave-app"; -import { envVars } from "../generated/env"; import { getGlobalUiInstance, h, hasAppendChild, setGlobalUiInstance, setHtml, type UiGlobalInstance } from "../in-page-ui/dom"; import { getBaseUrl } from "../lib/hexclave-app/apps/implementations/common"; import type { HandlerUrlOptions, HandlerUrls, HandlerUrlTarget } from "../lib/hexclave-app/common"; @@ -60,6 +59,11 @@ const MAX_LOG_ENTRIES = 500; const CONSOLE_LOG_BATCH_SIZE = 100; const DRAG_THRESHOLD = 5; const DOCS_URL = 'https://docs.hexclave.com'; +// The local development-environment dashboard always listens on this loopback +// port (see the CLI's DEFAULT_DASHBOARD_PORT). The dev tool runs inside the +// customer's own app, so it can't read the CLI's env vars — the port is fixed. +const LOCAL_DASHBOARD_ORIGIN = 'http://localhost:26700'; +const HOSTED_DASHBOARD_URL = 'https://app.hexclave.com'; const TABS: { id: TabId; label: string; icon: string }[] = [ { id: 'overview', label: 'Overview', icon: '' }, @@ -190,42 +194,60 @@ function resolveApiBaseUrl(app: StackClientApp): string { return getBaseUrl(opts.baseUrl); } -function shouldShowDashboardTab(app: StackClientApp): boolean { - return envVars.HEXCLAVE_IS_LOCAL_EMULATOR === "true" && isLocalhost(resolveApiBaseUrl(app)); +function localDashboardProjectUrl(app: StackClientApp): string { + return `${LOCAL_DASHBOARD_ORIGIN}/projects/${encodeURIComponent(app.projectId)}`; } -function getTabsForApp(app: StackClientApp): { id: TabId; label: string; icon: string }[] { - if (shouldShowDashboardTab(app)) { - return TABS; - } - return TABS.filter((tab) => tab.id !== 'dashboard'); +function parseProjectAvailabilityResponse(body: unknown): boolean { + if (typeof body !== 'object' || body === null) return false; + return 'project_available' in body && body.project_available === true; } -function deriveDashboardBaseUrl(apiBaseUrl: string): string { +// How long to wait for the availability probe before giving up. A refused +// connection rejects almost immediately, but a host that accepts the socket yet +// never responds would otherwise leave the tab stuck on "Checking…" forever. +const LOCAL_DASHBOARD_PROBE_TIMEOUT_MS = 3000; + +// Whether the current page can even talk to the HTTP-only local dashboard. +// LOCAL_DASHBOARD_ORIGIN is `http://localhost:26700`, so the page itself must be +// an http: localhost origin: an https: page (e.g. an HTTPS dev server) would +// have both the probe fetch and the iframe embed blocked as mixed content, so +// we treat that as "not available" and fall back to the hosted dashboard. +function canReachLocalDashboard(): boolean { + return isLocalhost(window.location.href) && window.location.protocol === 'http:'; +} + +// Decides whether the Dashboard tab can embed the locally-running +// development-environment dashboard. Rather than inferring from build-time env +// vars (which don't reflect whether the dev environment is actually up right +// now), we ask the running development environment directly: +// 1. We must be viewing the app from an http: localhost origin — the HTTP-only +// dashboard on :26700 is only reachable from, and only relevant to, a local +// browser (and an https: page can't reach it without mixed-content errors). +// 2. The development environment must be running AND already own this exact +// project (see the project-availability route it answers). +// Any failure — dev environment not running (connection refused), probe timeout, +// project unknown, or a malformed response — resolves to `false`, which shows +// the hosted-dashboard hint instead. +async function isLocalDashboardProjectAvailable(app: StackClientApp): Promise { + if (!canReachLocalDashboard()) return false; + const url = `${LOCAL_DASHBOARD_ORIGIN}/api/development-environment/project-availability?project_id=${encodeURIComponent(app.projectId)}`; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), LOCAL_DASHBOARD_PROBE_TIMEOUT_MS); try { - const url = new URL(apiBaseUrl); - if (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]') { - const port = url.port; - if (port && port.endsWith('02')) { - url.port = port.slice(0, -2) + '01'; - } - return url.origin; - } - if (url.hostname.startsWith('api.')) { - url.hostname = 'app.' + url.hostname.slice(4); - return url.origin; - } - return url.origin; + const response = await fetch(url, { method: 'GET', signal: controller.signal }); + if (!response.ok) return false; + return parseProjectAvailabilityResponse(await response.json()); } catch { - return 'https://app.hexclave.com'; + // Reachability probe: when the local development environment isn't running, + // the fetch rejects (connection refused); when it hangs, the AbortController + // aborts it. Either way that simply means "not available". + return false; + } finally { + clearTimeout(timeout); } } -function resolveDashboardUrl(app: StackClientApp): string { - const base = deriveDashboardBaseUrl(resolveApiBaseUrl(app)); - return `${base}/projects/${encodeURIComponent(app.projectId)}`; -} - function formatTimestamp(ts: number): string { return new Date(ts).toLocaleTimeString([], { hour: '2-digit', @@ -560,8 +582,8 @@ function createTabBar( // Iframe helper // --------------------------------------------------------------------------- -function createIframeTab(src: string, title: string, loadingMsg = 'Loading\u2026', errorMsg = 'Unable to load content', errorDetail?: string, openExternallyLabel?: string): HTMLElement { - const container = h('div', { className: 'sdt-iframe-container' }); +function populateIframeTab(container: HTMLElement, src: string, title: string, loadingMsg = 'Loading\u2026', errorMsg = 'Unable to load content', errorDetail?: string, openExternallyLabel?: string): void { + container.innerHTML = ''; if (openExternallyLabel != null) { container.appendChild(h('div', { className: 'sdt-iframe-toolbar' }, h('a', { href: src, target: '_blank', rel: 'noopener noreferrer', className: 'sdt-iframe-open-link' }, openExternallyLabel), @@ -591,7 +613,7 @@ function createIframeTab(src: string, title: string, loadingMsg = 'Loading\u2026 } const retryBtn = h('button', { className: 'sdt-iframe-error-btn' }, 'Retry'); retryBtn.addEventListener('click', () => { - container.replaceWith(createIframeTab(src, title, loadingMsg, errorMsg, errorDetail, openExternallyLabel)); + populateIframeTab(container, src, title, loadingMsg, errorMsg, errorDetail, openExternallyLabel); }); errDiv.appendChild(retryBtn); const link = h('a', { href: src, target: '_blank', rel: 'noopener noreferrer', style: { color: 'var(--sdt-accent)', fontSize: '12px', textDecoration: 'none' } }, 'Open in new tab'); @@ -600,7 +622,6 @@ function createIframeTab(src: string, title: string, loadingMsg = 'Loading\u2026 }); container.appendChild(iframe); - return container; } // =========================================================================================== @@ -1678,8 +1699,52 @@ function createAITab(app: StackClientApp): HTMLElement { // --------------------------------------------------------------------------- function createDashboardTab(app: StackClientApp): HTMLElement { - const dashboardUrl = resolveDashboardUrl(app); - return createIframeTab(dashboardUrl, 'Hexclave Dashboard', 'Loading dashboard\u2026', 'Unable to load dashboard', 'The dashboard may require authentication or block framing', 'Open in New Tab'); + const container = h('div', { className: 'sdt-iframe-container' }); + + function showChecking() { + container.innerHTML = ''; + container.appendChild(h('div', { className: 'sdt-iframe-loading' }, 'Checking development environment\u2026')); + } + + function showUnavailable() { + container.innerHTML = ''; + const message = h('div', { className: 'sdt-dashboard-unavailable' }); + const text = h('div', { className: 'sdt-dashboard-unavailable-text' }); + text.appendChild(document.createTextNode('Navigate to ')); + text.appendChild(h('a', { + className: 'sdt-dashboard-unavailable-link', + href: HOSTED_DASHBOARD_URL, + target: '_blank', + rel: 'noopener noreferrer', + }, HOSTED_DASHBOARD_URL)); + text.appendChild(document.createTextNode(" to view this project's dashboard")); + message.appendChild(text); + container.appendChild(message); + } + + async function check() { + showChecking(); + const available = await isLocalDashboardProjectAvailable(app); + // The dev tool may have been torn down while we were probing; only mutate + // the DOM if this container is still mounted. + if (!container.isConnected) return; + if (available) { + populateIframeTab( + container, + localDashboardProjectUrl(app), + 'Hexclave Dashboard', + 'Loading dashboard\u2026', + 'Unable to load dashboard', + 'The dashboard may require authentication or block framing', + 'Open in New Tab', + ); + } else { + showUnavailable(); + } + } + + runAsynchronously(check); + return container; } // --------------------------------------------------------------------------- @@ -2109,7 +2174,7 @@ function createPanel( panel.style.height = state.get().panelHeight + 'px'; } - const tabs = getTabsForApp(app); + const tabs = TABS; const storedActiveTab = state.get().activeTab; const activeTab = tabs.some((tab) => tab.id === storedActiveTab) ? storedActiveTab : DEFAULT_STATE.activeTab; diff --git a/packages/template/src/dev-tool/dev-tool-styles.ts b/packages/template/src/dev-tool/dev-tool-styles.ts index 69bf23a89..fdce8a987 100644 --- a/packages/template/src/dev-tool/dev-tool-styles.ts +++ b/packages/template/src/dev-tool/dev-tool-styles.ts @@ -324,6 +324,14 @@ export const devToolCSS = getInPageUiBaseCSS('.hexclave-devtool') + ` z-index: 1; } + /* Iframe panes lay their content out as a flex column so the embedded + dashboard (or the fallback message) fills the pane height. Uses two + classes to win over the plain .sdt-tab-pane-active { display: block }. */ + .hexclave-devtool .sdt-tab-pane-iframe.sdt-tab-pane-active { + display: flex; + flex-direction: column; + } + /* ===== Overview tab — single column ===== */ .hexclave-devtool .sdt-ov { @@ -1231,6 +1239,33 @@ export const devToolCSS = getInPageUiBaseCSS('.hexclave-devtool') + ` background: var(--sdt-accent-hover); } + .hexclave-devtool .sdt-dashboard-unavailable { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 24px; + text-align: center; + color: var(--sdt-text-secondary); + font-size: 14px; + } + + .hexclave-devtool .sdt-dashboard-unavailable-text { + max-width: 420px; + line-height: 1.5; + } + + .hexclave-devtool .sdt-dashboard-unavailable-link { + color: var(--sdt-accent); + text-decoration: none; + } + + .hexclave-devtool .sdt-dashboard-unavailable-link:hover { + text-decoration: underline; + } + /* Shared content fade animation */ .hexclave-devtool .sdt-tab-content-fade { animation: sdt-tab-fade-in 0.15s ease-out;