Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/mesh/src/api/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import proxyRoutes from "./routes/proxy";
import { createKVRoutes } from "./routes/kv";
import { createTriggerCallbackRoutes } from "./routes/trigger-callback";
import publicConfigRoutes from "./routes/public-config";
import projectRoutes from "./routes/project";
import filesRoutes from "./routes/files";
import selfRoutes from "./routes/self";
import { shouldSkipMeshContext, SYSTEM_PATHS } from "./utils/paths";
Expand Down Expand Up @@ -566,6 +567,7 @@ export async function createApp(options: CreateAppOptions = {}) {
// Public Configuration (no auth required)
// ============================================================================
app.route("/api/config", publicConfigRoutes);
app.route("/api/project", projectRoutes);
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: /api/project is mounted as a public route, exposing unauthenticated dev-server start/stop/restart and preview-proxy endpoints.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/mesh/src/api/app.ts, line 570:

<comment>`/api/project` is mounted as a public route, exposing unauthenticated dev-server start/stop/restart and preview-proxy endpoints.</comment>

<file context>
@@ -566,6 +567,7 @@ export async function createApp(options: CreateAppOptions = {}) {
   // Public Configuration (no auth required)
   // ============================================================================
   app.route("/api/config", publicConfigRoutes);
+  app.route("/api/project", projectRoutes);
 
   // ============================================================================
</file context>
Fix with Cubic


// ============================================================================
// Better Auth Routes
Expand Down
143 changes: 143 additions & 0 deletions apps/mesh/src/api/routes/project.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/**
* Project API Routes
*
* Exposes project scan results and dev server control endpoints.
* Only available in local mode when a project directory is detected.
*/

import { Hono } from "hono";
import { getScanResult } from "@/project/state";
import {
getDevServerState,
startProjectDevServer,
stopProjectDevServer,
restartProjectDevServer,
} from "@/project/dev-server";

const app = new Hono();

/**
* GET /api/project
* Returns project scan result and dev server state
*/
app.get("/", (c) => {
const scan = getScanResult();
if (!scan) {
return c.json({ success: false, error: "No project detected" }, 404);
}

return c.json({
success: true,
scan,
devServer: getDevServerState(),
});
});

/**
* GET /api/project/dev-server
* Returns just the dev server state (for lightweight polling)
*/
app.get("/dev-server", (c) => {
return c.json({
success: true,
devServer: getDevServerState(),
});
});

/**
* POST /api/project/dev-server/start
* Starts the project dev server
*/
app.post("/dev-server/start", async (c) => {
const scan = getScanResult();
if (!scan) {
return c.json({ success: false, error: "No project detected" }, 404);
}

await startProjectDevServer(scan);
return c.json({ success: true, devServer: getDevServerState() });
});

/**
* POST /api/project/dev-server/stop
* Stops the project dev server
*/
app.post("/dev-server/stop", async (c) => {
await stopProjectDevServer();
return c.json({ success: true, devServer: getDevServerState() });
});

/**
* POST /api/project/dev-server/restart
* Restarts the project dev server
*/
app.post("/dev-server/restart", async (c) => {
await restartProjectDevServer();
return c.json({ success: true, devServer: getDevServerState() });
});

/**
* GET /api/project/dev-server/logs
* Returns dev server logs
*/
app.get("/dev-server/logs", (c) => {
const state = getDevServerState();
return c.json({ success: true, logs: state.logs });
});

/**
* Reverse proxy to the project dev server, stripping frame-blocking headers.
* This allows the preview iframe to load the dev server content.
* Handles both /api/project/preview and /api/project/preview/* paths.
*/
app.all("/preview", (c) => handlePreviewProxy(c));
app.all("/preview/*", (c) => handlePreviewProxy(c));

async function handlePreviewProxy(c: {
req: { path: string; url: string; method: string; raw: Request };
text: (body: string, status: number) => Response;
}) {
const devServer = getDevServerState();
if (!devServer.url) {
return c.text("Dev server not running", 503);
}

// Reconstruct the target URL from the wildcard path
const path = c.req.path.replace(/^\/api\/project\/preview\/?/, "/");
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The preview proxy can be escaped to arbitrary hosts via //-prefixed paths, creating an open proxy/SSRF vector.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/mesh/src/api/routes/project.ts, line 106:

<comment>The preview proxy can be escaped to arbitrary hosts via `//`-prefixed paths, creating an open proxy/SSRF vector.</comment>

<file context>
@@ -0,0 +1,143 @@
+  }
+
+  // Reconstruct the target URL from the wildcard path
+  const path = c.req.path.replace(/^\/api\/project\/preview\/?/, "/");
+  const targetUrl = new URL(path || "/", devServer.url);
+
</file context>
Fix with Cubic

const targetUrl = new URL(path || "/", devServer.url);

// Forward query string
const reqUrl = new URL(c.req.url);
targetUrl.search = reqUrl.search;

try {
const headers = new Headers(c.req.raw.headers);
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Do not forward authentication/session headers to the proxied dev server; this leaks sensitive credentials across origins.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/mesh/src/api/routes/project.ts, line 114:

<comment>Do not forward authentication/session headers to the proxied dev server; this leaks sensitive credentials across origins.</comment>

<file context>
@@ -0,0 +1,143 @@
+  targetUrl.search = reqUrl.search;
+
+  try {
+    const headers = new Headers(c.req.raw.headers);
+    // Remove host header to avoid conflicts
+    headers.delete("host");
</file context>
Fix with Cubic

// Remove host header to avoid conflicts
headers.delete("host");

const response = await fetch(targetUrl.toString(), {
method: c.req.method,
headers,
body:
c.req.method !== "GET" && c.req.method !== "HEAD"
? c.req.raw.body
: undefined,
redirect: "manual",
});

// Clone response and strip frame-blocking headers
const responseHeaders = new Headers(response.headers);
responseHeaders.delete("x-frame-options");
responseHeaders.delete("content-security-policy");

return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
});
} catch {
return c.text("Failed to proxy to dev server", 502);
}
}

export default app;
15 changes: 15 additions & 0 deletions apps/mesh/src/api/routes/public-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import { Hono } from "hono";
import { basename } from "path";
import { getConfig, getThemeConfig, type ThemeConfig } from "@/core/config";
import { isLocalMode } from "@/auth/local-mode";
import { getInternalUrl } from "@/core/server-constants";
Expand Down Expand Up @@ -43,6 +44,15 @@ export type PublicConfig = {
* Requires FIRECRAWL_API_KEY to be configured.
*/
brandExtractEnabled?: boolean;
/**
* Absolute path to the user's project directory.
* Only set in local mode when running inside a project.
*/
projectDir?: string;
/**
* Human-readable project name (basename of projectDir).
*/
projectName?: string;
};

/**
Expand All @@ -61,6 +71,11 @@ app.get("/", (c) => {
...(isLocalMode() && { internalUrl: getInternalUrl() }),
...(getSettings().enableDecoImport && { enableDecoImport: true }),
brandExtractEnabled: !!getSettings().firecrawlApiKey,
...(isLocalMode() &&
getSettings().projectDir != null && {
projectDir: getSettings().projectDir as string,
projectName: basename(getSettings().projectDir as string),
}),
};

return c.json({ success: true, config });
Expand Down
18 changes: 18 additions & 0 deletions apps/mesh/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,18 @@
import { parseArgs } from "util";
import { homedir } from "os";
import { join } from "path";
import { existsSync } from "fs";

/** Check if a directory contains a project marker (package.json, deno.json, etc.) */
function detectProjectDir(dir: string): string | undefined {
const markers = ["package.json", "deno.json", "deno.jsonc"];
for (const marker of markers) {
if (existsSync(join(dir, marker))) {
return dir;
}
}
return undefined;
}

const { values, positionals } = parseArgs({
args: process.argv.slice(2),
Expand Down Expand Up @@ -60,6 +72,9 @@ const { values, positionals } = parseArgs({
type: "string",
default: "1",
},
project: {
type: "string",
},
vibe: {
type: "boolean",
default: false,
Expand Down Expand Up @@ -94,6 +109,7 @@ Server Options:
Dev Options:
--vite-port <port> Vite dev server port (default: 4000)
--base-url <url> Base URL for the server
--project <path> Project directory to manage (default: auto-detect CWD)

Environment Variables:
PORT Port to listen on (default: 3000)
Expand Down Expand Up @@ -203,6 +219,7 @@ if (command === "dev") {
skipMigrations: values["skip-migrations"] === true,
noTui,
localMode: values["no-local-mode"] !== true,
projectDir: values.project || detectProjectDir(process.cwd()),
};

if (noTui) {
Expand Down Expand Up @@ -270,6 +287,7 @@ const serveOptions = {
const n = Number(values["num-threads"]);
return Number.isInteger(n) && n > 0 ? n : 1;
})(),
projectDir: values.project || detectProjectDir(process.cwd()),
};

const noTui = values["no-tui"] === true || !process.stdout.isTTY;
Expand Down
5 changes: 5 additions & 0 deletions apps/mesh/src/cli/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface DevOptions {
skipMigrations: boolean;
noTui?: boolean;
localMode: boolean;
projectDir?: string;
}

// Strip ANSI escape codes from a string
Expand Down Expand Up @@ -102,6 +103,7 @@ export async function startDevServer(
skipMigrations: options.skipMigrations,
noTui: options.noTui,
vitePort: options.vitePort,
projectDir: options.projectDir,
});

for (const s of services) {
Expand Down Expand Up @@ -130,6 +132,9 @@ export async function startDevServer(
DECOCMS_HOME: settings.dataDir,
DATA_DIR: settings.dataDir,
DECO_CLI: "1",
...(settings.projectDir
? { DECOCMS_PROJECT_DIR: settings.projectDir }
: {}),
...(settings.baseUrl ? { BASE_URL: settings.baseUrl } : {}),
},
stdio: [
Expand Down
2 changes: 2 additions & 0 deletions apps/mesh/src/cli/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface ServeOptions {
localMode: boolean;
noTui?: boolean;
numThreads?: number;
projectDir?: string;
}

// Strip ANSI escape codes from a string
Expand Down Expand Up @@ -143,6 +144,7 @@ export async function startServer(options: ServeOptions): Promise<void> {
skipMigrations: options.skipMigrations,
noTui: options.noTui,
nodeEnv: "production",
projectDir: options.projectDir,
});

for (const s of services) {
Expand Down
37 changes: 37 additions & 0 deletions apps/mesh/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,43 @@ if (settings.localMode && !isWorker) {
try {
const seeded = await seedLocalMode();
void seeded;

// Bootstrap project agents if running in a project directory
if (settings.projectDir) {
try {
const { getDb } = await import("./database");
const { getLocalAdminUser } = await import("./auth/local-mode");
const database = getDb();
const user = await getLocalAdminUser();
if (user) {
// Get the user's organization
const membership = await database.db
.selectFrom("member")
.select("organizationId")
.where("userId", "=", user.id)
.executeTakeFirst();

if (membership) {
const { bootstrapProjectAgents } = await import(
"./project/bootstrap"
);
const { scan } = await bootstrapProjectAgents(
settings.projectDir,
membership.organizationId,
user.id,
);

// Auto-start the project dev server
const { startProjectDevServer } = await import(
"./project/dev-server"
);
await startProjectDevServer(scan);
}
}
} catch (error) {
console.error("[project] Failed to bootstrap project:", error);
}
}
} catch (error) {
console.error("Failed to seed local mode:", error);
} finally {
Expand Down
Loading
Loading