From 91a23321590aa4e0dd179e8afb42c1ea574683ff Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Mon, 2 Mar 2026 09:15:25 -0300 Subject: [PATCH 01/30] feat(local-mode): local-first developer experience with zero-ceremony setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running `bunx @decocms/mesh` (or `bun run dev`) now works with no config: - Prompts for a data directory on first run (defaults to ~/deco/) - Auto-generates and persists BETTER_AUTH_SECRET + ENCRYPTION_KEY - Seeds an admin user from the OS username (e.g. viktor@localhost.mesh) and a default "Viktor Local" org on fresh databases - Auto-logs in the browser without a login form (MESH_LOCAL_MODE=true) - Skips org selection and redirects straight to the first org - Enables local filesystem object storage (/mcp/dev-assets) for assets - Adds --home and --no-local-mode CLI flags; renames old dev to dev:saas New files: - apps/mesh/scripts/dev.ts — mirrors CLI setup for `bun run dev` - apps/mesh/src/auth/local-mode.ts — seedLocalMode, getLocalAdminUser - POST /api/auth/custom/local-session — auto-sign-in endpoint (local only) Co-Authored-By: Claude Sonnet 4.6 (1M context) --- apps/mesh/.gitignore | 3 + apps/mesh/package.json | 3 +- apps/mesh/scripts/dev.ts | 183 ++++++++++++++++++ apps/mesh/src/api/app.ts | 8 +- apps/mesh/src/api/routes/auth.ts | 52 +++++ apps/mesh/src/api/routes/dev-assets-mcp.ts | 7 +- apps/mesh/src/auth/local-mode.ts | 118 ++++++++++++ apps/mesh/src/auth/org.ts | 34 +++- apps/mesh/src/cli.ts | 212 +++++++++++++++++---- apps/mesh/src/core/config.ts | 14 ++ apps/mesh/src/core/server-constants.ts | 21 +- apps/mesh/src/database/index.ts | 17 +- apps/mesh/src/index.ts | 26 ++- apps/mesh/src/web/routes/home.tsx | 17 ++ apps/mesh/src/web/routes/login.tsx | 69 ++++++- 15 files changed, 730 insertions(+), 54 deletions(-) create mode 100644 apps/mesh/scripts/dev.ts create mode 100644 apps/mesh/src/auth/local-mode.ts diff --git a/apps/mesh/.gitignore b/apps/mesh/.gitignore index cd3c8f0fa7..2945106eb8 100644 --- a/apps/mesh/.gitignore +++ b/apps/mesh/.gitignore @@ -39,6 +39,9 @@ coverage/ # Temporary files /tmp/ +# Dev/test local data directory +.mesh-dev/ + # Authentication tokens diff --git a/apps/mesh/package.json b/apps/mesh/package.json index d81f791e98..218ae12909 100644 --- a/apps/mesh/package.json +++ b/apps/mesh/package.json @@ -21,7 +21,8 @@ "dist/**/*" ], "scripts": { - "dev": "bun run migrate && concurrently \"bun run dev:client\" \"bun run dev:server\"", + "dev": "bun run scripts/dev.ts", + "dev:saas": "bun run migrate && concurrently \"bun run dev:client\" \"bun run dev:server\"", "dev:client": "bun --bun vite dev", "dev:server": "NODE_ENV=development bun --env-file=.env --hot run src/index.ts", "build:client": "bun --bun vite build", diff --git a/apps/mesh/scripts/dev.ts b/apps/mesh/scripts/dev.ts new file mode 100644 index 0000000000..03653fdac4 --- /dev/null +++ b/apps/mesh/scripts/dev.ts @@ -0,0 +1,183 @@ +#!/usr/bin/env bun +/** + * Development environment setup script. + * + * Mirrors the CLI (src/cli.ts) behaviour so that `bun dev` and + * `bunx @decocms/mesh` share the same ~/deco data directory, secrets, + * and local-mode defaults. + * + * After setting up the environment it spawns the regular dev pipeline: + * bun run migrate && concurrently "bun run dev:client" "bun run dev:server" + */ + +import { existsSync } from "fs"; +import { mkdir } from "fs/promises"; +import { createInterface } from "readline"; +import { homedir } from "os"; +import { join } from "path"; +import { randomBytes } from "crypto"; +import { spawn } from "child_process"; + +// ============================================================================ +// Resolve MESH_HOME +// ============================================================================ + +// When MESH_HOME is explicitly set, respect it (CI, tests, custom setups). +// Otherwise default to ~/deco for interactive dev. +const meshAppDir = import.meta.dir.replace("/scripts", ""); +const explicitHome = process.env.MESH_HOME; +const userHome = join(homedir(), "deco"); +// In CI / non-TTY without explicit MESH_HOME, use a repo-local directory +// so tests never touch the developer's real ~/deco data. +const ciHome = join(meshAppDir, ".mesh-dev"); + +const dim = "\x1b[2m"; +const reset = "\x1b[0m"; +const bold = "\x1b[1m"; +const cyan = "\x1b[36m"; +const yellow = "\x1b[33m"; +const green = "\x1b[32m"; + +function prompt(question: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +let meshHome: string; + +if (explicitHome) { + // Explicit MESH_HOME takes priority (CI, tests, custom setups) + meshHome = explicitHome; +} else if (!process.stdin.isTTY) { + // Non-interactive (CI) — use repo-local directory to avoid touching ~/deco + meshHome = ciHome; +} else if (existsSync(userHome)) { + // Interactive with existing ~/deco — use it + meshHome = userHome; +} else { + // Interactive, first run — prompt for location + const displayDefault = userHome.replace(homedir(), "~"); + console.log(""); + console.log(`${bold}${cyan}MCP Mesh${reset} ${dim}(dev)${reset}`); + console.log(""); + const answer = await prompt( + ` Where should Mesh store its data? ${dim}(${displayDefault})${reset} `, + ); + if (answer === "") { + meshHome = userHome; + } else { + meshHome = answer.startsWith("~") + ? join(homedir(), answer.slice(1)) + : answer; + } +} + +// ============================================================================ +// Secrets management (same logic as src/cli.ts) +// ============================================================================ + +await mkdir(meshHome, { recursive: true, mode: 0o700 }); + +const secretsFilePath = join(meshHome, "secrets.json"); + +interface SecretsFile { + BETTER_AUTH_SECRET?: string; + ENCRYPTION_KEY?: string; +} + +let savedSecrets: SecretsFile = {}; +try { + const file = Bun.file(secretsFilePath); + if (await file.exists()) { + savedSecrets = await file.json(); + } +} catch { + // File doesn't exist or is invalid — will create new secrets +} + +let secretsModified = false; + +if (!process.env.BETTER_AUTH_SECRET) { + if (savedSecrets.BETTER_AUTH_SECRET) { + process.env.BETTER_AUTH_SECRET = savedSecrets.BETTER_AUTH_SECRET; + } else { + savedSecrets.BETTER_AUTH_SECRET = randomBytes(32).toString("base64"); + process.env.BETTER_AUTH_SECRET = savedSecrets.BETTER_AUTH_SECRET; + secretsModified = true; + } +} + +if (!process.env.ENCRYPTION_KEY) { + if (savedSecrets.ENCRYPTION_KEY) { + process.env.ENCRYPTION_KEY = savedSecrets.ENCRYPTION_KEY; + } else { + savedSecrets.ENCRYPTION_KEY = ""; + process.env.ENCRYPTION_KEY = savedSecrets.ENCRYPTION_KEY; + secretsModified = true; + } +} + +if (secretsModified) { + try { + await Bun.write(secretsFilePath, JSON.stringify(savedSecrets, null, 2)); + } catch (error) { + console.warn( + `${yellow}Warning: Could not save secrets file: ${error}${reset}`, + ); + } +} + +// ============================================================================ +// Set environment variables +// ============================================================================ + +process.env.MESH_HOME = meshHome; +process.env.DATABASE_URL = `file:${join(meshHome, "mesh.db")}`; +process.env.MESH_LOCAL_MODE = "true"; + +// ============================================================================ +// Banner +// ============================================================================ + +const displayHome = meshHome.replace(homedir(), "~"); + +console.log(""); +console.log(`${bold}${cyan}MCP Mesh${reset} ${dim}(dev)${reset}`); +console.log(""); +console.log( + `${bold} Mode: ${green}Local${reset}${bold} (auto-login enabled)${reset}`, +); +console.log(`${bold} Home: ${dim}${displayHome}/${reset}`); +console.log(`${bold} Database: ${dim}${displayHome}/mesh.db${reset}`); +console.log(""); + +// ============================================================================ +// Spawn the dev pipeline +// ============================================================================ + +const child = spawn( + "bun", + [ + "run", + "migrate", + "&&", + "concurrently", + '"bun run dev:client"', + '"bun run dev:server"', + ], + { + stdio: "inherit", + shell: true, + env: process.env, + cwd: meshAppDir, + }, +); + +child.on("exit", (code) => { + process.exit(code ?? 0); +}); diff --git a/apps/mesh/src/api/app.ts b/apps/mesh/src/api/app.ts index 4a2affe8fd..89a3e5fe38 100644 --- a/apps/mesh/src/api/app.ts +++ b/apps/mesh/src/api/app.ts @@ -673,8 +673,12 @@ export async function createApp(options: CreateAppOptions = {}) { app.use("/mcp/virtual-mcp/:virtualMcpId?", mcpAuth); app.use("/mcp/self", mcpAuth); - // Dev-only routes (local file storage MCP for testing object-storage plugin) - if (process.env.NODE_ENV !== "production") { + // Local file storage MCP routes + // Available in dev mode (NODE_ENV !== production) OR when local mode is active + if ( + process.env.NODE_ENV !== "production" || + process.env.MESH_LOCAL_MODE === "true" + ) { // Using require() for synchronous loading to ensure routes are registered // before any requests come in. Static imports in dev-only.ts allow knip tracking. // eslint-disable-next-line @typescript-eslint/no-require-imports diff --git a/apps/mesh/src/api/routes/auth.ts b/apps/mesh/src/api/routes/auth.ts index 123603fc89..e85c809677 100644 --- a/apps/mesh/src/api/routes/auth.ts +++ b/apps/mesh/src/api/routes/auth.ts @@ -8,6 +8,11 @@ import { Hono } from "hono"; import { authConfig, resetPasswordEnabled } from "../../auth"; import { KNOWN_OAUTH_PROVIDERS, OAuthProvider } from "@/auth/oauth-providers"; +import { + getLocalAdminUser, + isLocalMode, + LOCAL_ADMIN_PASSWORD, +} from "@/auth/local-mode"; const app = new Hono(); @@ -41,6 +46,11 @@ export type AuthConfig = { * Disabled by default in production unless UNSAFE_ALLOW_STDIO_TRANSPORT=true */ stdioEnabled: boolean; + /** + * Whether local mode is active (zero-ceremony developer experience). + * When true, the frontend should auto-login and skip org selection. + */ + localMode: boolean; }; /** @@ -87,6 +97,7 @@ app.get("/config", async (c) => { enabled: false, }, stdioEnabled, + localMode: isLocalMode(), }; return c.json({ success: true, config }); @@ -104,4 +115,45 @@ app.get("/config", async (c) => { } }); +/** + * Local Mode Auto-Session Endpoint + * + * When local mode is active, this endpoint signs in the admin user + * and returns the session. The frontend calls this to skip the login form. + * + * Route: POST /api/auth/custom/local-session + */ +app.post("/local-session", async (c) => { + if (!isLocalMode()) { + return c.json({ success: false, error: "Local mode is not active" }, 403); + } + + try { + const { auth } = await import("../../auth"); + const adminUser = await getLocalAdminUser(); + if (!adminUser) { + return c.json( + { success: false, error: "Local admin user not found" }, + 500, + ); + } + + // Sign in as the local admin user + const result = await auth.api.signInEmail({ + body: { + email: adminUser.email, + password: LOCAL_ADMIN_PASSWORD, + }, + asResponse: true, + }); + + // Forward the response (includes Set-Cookie headers) + return result; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Failed to create local session"; + return c.json({ success: false, error: errorMessage }, 500); + } +}); + export default app; diff --git a/apps/mesh/src/api/routes/dev-assets-mcp.ts b/apps/mesh/src/api/routes/dev-assets-mcp.ts index b584b2102f..f7a8d2bec9 100644 --- a/apps/mesh/src/api/routes/dev-assets-mcp.ts +++ b/apps/mesh/src/api/routes/dev-assets-mcp.ts @@ -48,8 +48,11 @@ interface ToolDefinition { _meta?: Record; } -// Base directory for dev assets (relative to cwd) -const DEV_ASSETS_BASE_DIR = "./data/assets"; +// Base directory for assets. +// Uses MESH_HOME/assets when available (local mode), falls back to ./data/assets +const DEV_ASSETS_BASE_DIR = process.env.MESH_HOME + ? `${process.env.MESH_HOME}/assets` + : "./data/assets"; // Default URL expiration time in seconds (1 hour) const DEFAULT_EXPIRES_IN = 3600; diff --git a/apps/mesh/src/auth/local-mode.ts b/apps/mesh/src/auth/local-mode.ts new file mode 100644 index 0000000000..70d1d25d66 --- /dev/null +++ b/apps/mesh/src/auth/local-mode.ts @@ -0,0 +1,118 @@ +/** + * Local Mode Setup + * + * Handles auto-seeding an admin user and "Local" organization + * for the zero-ceremony local developer experience. + * + * Only runs when MESH_LOCAL_MODE=true (set by CLI). + */ + +import { getDb } from "@/database"; +import { userInfo } from "os"; +import { auth } from "./index"; + +export const LOCAL_ADMIN_PASSWORD = "admin@mesh"; + +function getLocalUserName(): string { + try { + return userInfo().username || "local"; + } catch { + return "local"; + } +} + +function capitalize(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); +} + +/** + * Check if the database already has users. + * Returns true if the database is fresh (no users). + */ +async function isDatabaseFresh(): Promise { + const database = getDb(); + const result = await database.db + .selectFrom("user") + .select(database.db.fn.countAll().as("count")) + .executeTakeFirst(); + return Number(result?.count ?? 0) === 0; +} + +/** + * Seed the local mode environment. + * Creates an admin user and a default organization if the database is fresh. + * + * The signup triggers Better Auth's databaseHooks.user.create.after hook + * which automatically creates a default organization with seeded connections. + * + * Returns true if seeding was performed, false if skipped (already set up). + */ +export async function seedLocalMode(): Promise { + const fresh = await isDatabaseFresh(); + if (!fresh) { + return false; + } + + const username = getLocalUserName(); + const email = `${username}@localhost.mesh`; + const displayName = capitalize(username); + + // Create admin user via Better Auth signup. + // The databaseHooks.user.create.after hook in auth/index.ts will + // automatically create a default organization for this user. + const signUpResult = await auth.api.signUpEmail({ + body: { + email, + password: LOCAL_ADMIN_PASSWORD, + name: displayName, + }, + }); + + if (!signUpResult?.user?.id) { + throw new Error("Failed to create local admin user"); + } + + const userId = signUpResult.user.id; + const database = getDb(); + + // Set user as admin directly in the database (avoids needing auth headers) + await database.db + .updateTable("user") + .set({ role: "admin" }) + .where("id", "=", userId) + .execute(); + + // Rename the auto-created org to {username}-local + const orgSlug = `${username}-local`; + const orgName = `${displayName} Local`; + await database.db + .updateTable("organization") + .set({ name: orgName, slug: orgSlug }) + .where("id", "in", (qb) => + qb + .selectFrom("member") + .select("organizationId") + .where("userId", "=", userId), + ) + .execute(); + + return true; +} + +/** + * Get the local admin user, if it exists. + * Used by the auto-login middleware. + */ +export async function getLocalAdminUser() { + const database = getDb(); + const email = `${getLocalUserName()}@localhost.mesh`; + return database.db + .selectFrom("user") + .where("email", "=", email) + .selectAll() + .executeTakeFirst(); +} + +export function isLocalMode(): boolean { + return process.env.MESH_LOCAL_MODE === "true"; +} diff --git a/apps/mesh/src/auth/org.ts b/apps/mesh/src/auth/org.ts index 466487068d..2d3b74b511 100644 --- a/apps/mesh/src/auth/org.ts +++ b/apps/mesh/src/auth/org.ts @@ -6,6 +6,7 @@ import { ORG_ADMIN_PROJECT_SLUG, } from "@decocms/mesh-sdk"; import { getBaseUrl } from "@/core/server-constants"; +import { isLocalMode } from "@/auth/local-mode"; import { getDb } from "@/database"; import { CredentialVault } from "@/encryption/credential-vault"; import { ConnectionStorage } from "@/storage/connection"; @@ -77,6 +78,34 @@ function getDefaultOrgMcps(organizationId: string): MCPCreationSpec[] { { data: getWellKnownRegistryConnection(organizationId), }, + // Local Files - filesystem object storage (only in local mode) + ...(isLocalMode() + ? [ + { + data: { + id: "local-files", + title: "Local Files", + description: "Local filesystem storage for files and assets", + connection_type: "HTTP" as const, + connection_url: `${getBaseUrl()}/mcp/dev-assets`, + icon: null, + app_name: "@deco/local-files", + connection_token: null, + connection_headers: null, + oauth_config: null, + configuration_state: null, + configuration_scopes: null, + metadata: { + isDefault: true, + type: "local-files", + }, + } satisfies ConnectionCreateData, + permissions: { + self: ["*"], + }, + }, + ] + : []), ]; } @@ -130,12 +159,15 @@ export async function seedOrgDb(organizationId: string, createdBy: string) { connectionToken = key?.key; } // Get tools either from the lazy getter or by fetching from MCP + // Use the newly created API key token if available (for auth-protected endpoints) + const effectiveToken = + mcpConfig.data.connection_token ?? connectionToken; const fetchResult = await fetchToolsFromMCP({ id: "pending", title: mcpConfig.data.title, connection_type: mcpConfig.data.connection_type, connection_url: mcpConfig.data.connection_url, - connection_token: mcpConfig.data.connection_token, + connection_token: effectiveToken, connection_headers: mcpConfig.data.connection_headers, }).catch(() => null); const tools = diff --git a/apps/mesh/src/cli.ts b/apps/mesh/src/cli.ts index 0736463580..bb02b31db9 100644 --- a/apps/mesh/src/cli.ts +++ b/apps/mesh/src/cli.ts @@ -3,15 +3,23 @@ * MCP Mesh CLI Entry Point * * This script serves as the bin entry point for bunx @decocms/mesh - * It runs database migrations and starts the production server. + * It runs database migrations, seeds the local environment, and starts the server. * * Usage: * bunx @decocms/mesh * bunx @decocms/mesh --port 8080 + * bunx @decocms/mesh --home ~/my-mesh + * bunx @decocms/mesh --no-local-mode * bunx @decocms/mesh --help */ import { parseArgs } from "util"; +import { homedir } from "os"; +import { join } from "path"; +import { existsSync } from "fs"; +import { createInterface } from "readline"; + +const defaultHome = process.env.MESH_HOME || join(homedir(), "deco"); const { values } = parseArgs({ args: process.argv.slice(2), @@ -21,6 +29,9 @@ const { values } = parseArgs({ short: "p", default: process.env.PORT || "3000", }, + home: { + type: "string", + }, help: { type: "boolean", short: "h", @@ -35,6 +46,10 @@ const { values } = parseArgs({ type: "boolean", default: false, }, + "no-local-mode": { + type: "boolean", + default: false, + }, }, allowPositionals: true, }); @@ -48,13 +63,16 @@ Usage: Options: -p, --port Port to listen on (default: 3000, or PORT env var) + --home Data directory (default: ~/deco/, or MESH_HOME env var) + --no-local-mode Disable local mode (require login, no auto-setup) -h, --help Show this help message -v, --version Show version --skip-migrations Skip database migrations on startup Environment Variables: PORT Port to listen on (default: 3000) - DATABASE_URL Database connection URL (default: file:./data/mesh.db) + MESH_HOME Data directory (default: ~/deco/) + DATABASE_URL Database connection URL (default: MESH_HOME/mesh.db) NODE_ENV Set to 'production' for production mode BETTER_AUTH_SECRET Secret for authentication (auto-generated if not set) ENCRYPTION_KEY Key for encrypting secrets (auto-generated if not set) @@ -62,9 +80,10 @@ Environment Variables: CONFIG_PATH Path to full config file (default: ./config.json) Examples: - bunx @decocms/mesh # Start on port 3000 - bunx @decocms/mesh -p 8080 # Start on port 8080 - PORT=9000 bunx @decocms/mesh # Start on port 9000 + bunx @decocms/mesh # Start with defaults (~/deco/) + bunx @decocms/mesh -p 8080 # Start on port 8080 + bunx @decocms/mesh --home ~/my-project # Custom data directory + bunx @decocms/mesh --no-local-mode # Require login (SaaS mode) Documentation: https://github.com/decocms/mesh @@ -73,10 +92,6 @@ Documentation: } if (values.version) { - // Try to read version from package.json - // When bundled, the path changes depending on context: - // - During development: ../package.json (relative to src/) - // - When published: ../../package.json (relative to dist/server/) const possiblePaths = [ new URL("../package.json", import.meta.url), new URL("../../package.json", import.meta.url), @@ -100,27 +115,95 @@ if (values.version) { process.exit(0); } +// ============================================================================ +// Setup environment +// ============================================================================ + // Set PORT environment variable for the server process.env.PORT = values.port; -// Ensure NODE_ENV defaults to production when running via CLI -if (!process.env.NODE_ENV) { - process.env.NODE_ENV = "production"; -} - -// ANSI color codes +// ANSI color codes (needed early for the prompt) const dim = "\x1b[2m"; const reset = "\x1b[0m"; const bold = "\x1b[1m"; const cyan = "\x1b[36m"; const yellow = "\x1b[33m"; +const green = "\x1b[32m"; + +// ============================================================================ +// Resolve MESH_HOME — prompt on first run if using default +// ============================================================================ + +/** + * Prompt the user for input via readline. + */ +function prompt(question: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +let meshHome: string; + +if (values.home) { + // Explicitly passed via --home flag — expand ~ to home directory + meshHome = values.home.startsWith("~") + ? join(homedir(), values.home.slice(1)) + : values.home; +} else if (existsSync(defaultHome)) { + // Default directory already exists (not first run) + meshHome = defaultHome; +} else { + // First run with default path — ask the user + const displayDefault = defaultHome.replace(homedir(), "~"); + console.log(""); + console.log(`${bold}${cyan}MCP Mesh${reset}`); + console.log(""); + const answer = await prompt( + ` Where should Mesh store its data? ${dim}(${displayDefault})${reset} `, + ); + if (answer === "") { + meshHome = defaultHome; + } else { + // Expand ~ to home directory + meshHome = answer.startsWith("~") + ? join(homedir(), answer.slice(1)) + : answer; + } +} + +process.env.MESH_HOME = meshHome; + +// Set DATABASE_URL to MESH_HOME/mesh.db. The CLI owns the data directory, +// so we always use the SQLite database inside it. This prevents accidentally +// connecting to a PostgreSQL database from the shell environment. +process.env.DATABASE_URL = `file:${join(meshHome, "mesh.db")}`; + +// Ensure NODE_ENV defaults to production when running via CLI +if (!process.env.NODE_ENV) { + process.env.NODE_ENV = "production"; +} -// Path for storing auto-generated secrets (relative to cwd, alongside database) -const secretsFilePath = "./data/mesh-dev-only-secrets.json"; +// Determine if local mode should be active +// Local mode is on by default unless: +// - --no-local-mode flag is passed +// - A custom auth config with social providers / SSO is detected +const hasCustomAuthConfig = + process.env.AUTH_CONFIG_PATH && + process.env.AUTH_CONFIG_PATH !== "./auth-config.json"; +const localMode = !values["no-local-mode"] && !hasCustomAuthConfig; +process.env.MESH_LOCAL_MODE = localMode ? "true" : "false"; + +// ============================================================================ +// Secrets management +// ============================================================================ + +const secretsFilePath = join(meshHome, "secrets.json"); -// Generate or load secrets if not provided via environment variables -// This allows users to try the app without setting up environment variables -// while still persisting sessions across restarts const crypto = await import("crypto"); const { mkdir } = await import("fs/promises"); @@ -129,6 +212,9 @@ interface SecretsFile { ENCRYPTION_KEY?: string; } +// Ensure MESH_HOME directory exists +await mkdir(meshHome, { recursive: true, mode: 0o700 }); + // Try to load existing secrets from file let savedSecrets: SecretsFile = {}; try { @@ -170,54 +256,102 @@ if (!process.env.ENCRYPTION_KEY) { // Save secrets to file if we generated new ones if (secretsModified) { try { - // Ensure data directory exists - await mkdir("./data", { recursive: true }); await Bun.write(secretsFilePath, JSON.stringify(savedSecrets, null, 2)); } catch (error) { - console.warn(`${yellow}⚠️ Could not save secrets file: ${error}${reset}`); + console.warn( + `${yellow}Warning: Could not save secrets file: ${error}${reset}`, + ); } } +// ============================================================================ +// Startup banner +// ============================================================================ + +const displayHome = meshHome.replace(homedir(), "~"); + console.log(""); console.log(`${bold}${cyan}MCP Mesh${reset}`); console.log(`${dim}Self-hostable MCP Server${reset}`); +console.log(""); -// Only show warning for secrets that are actually from file if (betterAuthFromFile || encryptionKeyFromFile) { - console.log(""); console.log( - `${yellow}⚠️ Using generated dev-only secrets from: ${secretsFilePath}${reset}`, + `${dim}Using generated secrets from: ${displayHome}/secrets.json${reset}`, ); console.log( - `${dim} For production, set these environment variables:${reset}`, + `${dim}For production, set BETTER_AUTH_SECRET and ENCRYPTION_KEY env vars.${reset}`, ); - if (betterAuthFromFile) { - console.log( - `${dim} BETTER_AUTH_SECRET=$(openssl rand -base64 32)${reset}`, - ); - } - if (encryptionKeyFromFile) { - console.log(`${dim} ENCRYPTION_KEY=$(openssl rand -hex 32)${reset}`); + console.log(""); +} + +// ============================================================================ +// Build frontend if needed (when running from source) +// ============================================================================ + +{ + const scriptDir = new URL(".", import.meta.url).pathname; + const clientDistDir = join(scriptDir, "../dist/client"); + const clientIndexPath = join(clientDistDir, "index.html"); + + if (!existsSync(clientIndexPath)) { + console.log(`${dim}Building frontend (first run)...${reset}`); + const { execSync } = await import("child_process"); + // Resolve apps/mesh directory — works whether running from src/ or dist/server/ + const meshAppDir = existsSync(join(scriptDir, "../vite.config.ts")) + ? join(scriptDir, "..") + : existsSync(join(scriptDir, "../../vite.config.ts")) + ? join(scriptDir, "../..") + : null; + + if (meshAppDir) { + try { + execSync("bun --bun vite build", { + cwd: meshAppDir, + stdio: ["ignore", "pipe", "pipe"], + }); + console.log(`${dim}Frontend build complete.${reset}`); + } catch (error) { + console.warn( + `${yellow}Warning: Could not build frontend. UI may not be available.${reset}`, + ); + } + } } } -console.log(""); +// ============================================================================ +// Database migrations +// ============================================================================ -// Run migrations unless skipped if (!values["skip-migrations"]) { console.log(`${dim}Running database migrations...${reset}`); try { const { migrateToLatest } = await import("./database/migrate"); - // Keep database connection open since server will use it await migrateToLatest({ keepOpen: true }); console.log(`${dim}Migrations complete.${reset}`); - console.log(""); } catch (error) { console.error("Failed to run migrations:", error); process.exit(1); } } +// ============================================================================ +// Print final status and start server +// ============================================================================ + +const port = values.port; +console.log(""); +console.log( + `${bold} Mode: ${localMode ? `${green}Local${reset}${bold} (auto-login enabled)` : "Standard (login required)"}${reset}`, +); +console.log(`${bold} Home: ${dim}${displayHome}/${reset}`); +console.log(`${bold} Database: ${dim}${displayHome}/mesh.db${reset}`); +if (localMode) { + console.log(`${bold} Assets: ${dim}${displayHome}/assets/${reset}`); +} +console.log(`${bold} URL: ${dim}http://localhost:${port}${reset}`); +console.log(""); + // Import and start the server -// We import dynamically to ensure migrations run first await import("./index"); diff --git a/apps/mesh/src/core/config.ts b/apps/mesh/src/core/config.ts index 0f91ecb6cf..4fbbaddcc1 100644 --- a/apps/mesh/src/core/config.ts +++ b/apps/mesh/src/core/config.ts @@ -60,6 +60,20 @@ export interface Config { * @default true */ autoCreateOrganizationOnSignup?: boolean; + /** + * Whether to run in local mode (zero-ceremony developer experience). + * When true: + * - Auto-creates admin@localhost user and "Local" organization on first run + * - Auto-logs in without requiring sign-up or login + * - Enables local filesystem object storage + * - Skips organization selection screen + * + * Automatically enabled when running via CLI unless --no-local-mode is passed + * or a custom auth config is detected. + * + * @default undefined (determined at runtime by CLI) + */ + localMode?: boolean; } // Config paths can be overridden via environment variables for k8s flexibility diff --git a/apps/mesh/src/core/server-constants.ts b/apps/mesh/src/core/server-constants.ts index 182d7fb876..d4d134dd57 100644 --- a/apps/mesh/src/core/server-constants.ts +++ b/apps/mesh/src/core/server-constants.ts @@ -2,9 +2,12 @@ * Server Constants * * Centralized configuration for server-related constants. - * Respects BASE_URL and PORT environment variables. + * Respects BASE_URL, PORT, and MESH_HOME environment variables. */ +import { homedir } from "os"; +import { join } from "path"; + /** * Get the base URL for the server. * @@ -19,3 +22,19 @@ export function getBaseUrl(): string { const port = process.env.PORT || "3000"; return `http://localhost:${port}`; } + +/** + * Get the Mesh home directory for data storage. + * + * Priority: + * 1. MESH_HOME environment variable (if set) + * 2. ~/deco/ + * + * This is the default location for: + * - Database (mesh.db) + * - Secrets (secrets.json) + * - Local assets (assets/) + */ +export function getMeshHome(): string { + return process.env.MESH_HOME || join(homedir(), "deco"); +} diff --git a/apps/mesh/src/database/index.ts b/apps/mesh/src/database/index.ts index 05cbaaf42b..fdb9015bde 100644 --- a/apps/mesh/src/database/index.ts +++ b/apps/mesh/src/database/index.ts @@ -276,13 +276,20 @@ function parseDatabaseUrl(databaseUrl?: string): DatabaseConfig { // ============================================================================ /** - * Get database URL from environment or default + * Get database URL from environment or default. + * + * When MESH_HOME is set (via env or CLI --home flag), defaults to MESH_HOME/mesh.db. + * Otherwise falls back to ./data/mesh.db relative to CWD for backward compatibility. */ export function getDatabaseUrl(): string { - const databaseUrl = - process.env.DATABASE_URL || - `file:${path.join(process.cwd(), "data/mesh.db")}`; - return databaseUrl; + if (process.env.DATABASE_URL) { + return process.env.DATABASE_URL; + } + // If MESH_HOME is set, use it as the base for the database + if (process.env.MESH_HOME) { + return `file:${path.join(process.env.MESH_HOME, "mesh.db")}`; + } + return `file:${path.join(process.cwd(), "data/mesh.db")}`; } /** diff --git a/apps/mesh/src/index.ts b/apps/mesh/src/index.ts index f39781b9f8..620c4aaa1f 100644 --- a/apps/mesh/src/index.ts +++ b/apps/mesh/src/index.ts @@ -32,8 +32,15 @@ const underline = "\x1b[4m"; const url = `http://localhost:${port}`; // Create asset handler - handles both dev proxy and production static files +// When running from source (src/index.ts), the "../client" relative path +// doesn't resolve to dist/client/. Fall back to dist/client/ relative to CWD. +import { existsSync } from "fs"; +const resolvedClientDir = resolveClientDir(import.meta.url, "../client"); +const clientDir = existsSync(resolvedClientDir) + ? resolvedClientDir + : resolveClientDir(import.meta.url, "../dist/client"); const handleAssets = createAssetHandler({ - clientDir: resolveClientDir(import.meta.url, "../client"), + clientDir, isServerPath, }); @@ -60,6 +67,23 @@ Bun.serve({ development: process.env.NODE_ENV !== "production", }); +// Local mode: seed admin user + organization after server is listening +// This must run after Bun.serve() so that the org seed can fetch tools +// from the self MCP endpoint (http://localhost:PORT/mcp/self) +if (process.env.MESH_LOCAL_MODE === "true") { + (async () => { + try { + const { seedLocalMode } = await import("./auth/local-mode"); + const seeded = await seedLocalMode(); + if (seeded) { + console.log(`\n${green}Local environment initialized.${reset}`); + } + } catch (error) { + console.error("Failed to seed local mode:", error); + } + })(); +} + // Internal debug server (only enabled via ENABLE_DEBUG_SERVER=true) if (enableDebugServer) { startDebugServer({ port: debugPort }); diff --git a/apps/mesh/src/web/routes/home.tsx b/apps/mesh/src/web/routes/home.tsx index 7603e3eff4..75c583ad81 100644 --- a/apps/mesh/src/web/routes/home.tsx +++ b/apps/mesh/src/web/routes/home.tsx @@ -1,6 +1,23 @@ import { OrganizationsHome } from "@/web/components/organizations-home"; +import { useAuthConfig } from "@/web/providers/auth-config-provider"; +import { authClient } from "@/web/lib/auth-client"; +import { Navigate } from "@tanstack/react-router"; +import { SplashScreen } from "@/web/components/splash-screen"; export default function App() { + const authConfig = useAuthConfig(); + const { data: organizations, isPending } = authClient.useListOrganizations(); + + // In local mode, skip org selection — go straight to the first (only) org + if (authConfig.localMode) { + if (isPending) return ; + + const firstOrg = organizations?.[0]; + if (firstOrg?.slug) { + return ; + } + } + return (
diff --git a/apps/mesh/src/web/routes/login.tsx b/apps/mesh/src/web/routes/login.tsx index 26d6208f3e..8a9c598ad4 100644 --- a/apps/mesh/src/web/routes/login.tsx +++ b/apps/mesh/src/web/routes/login.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useAuthConfig } from "@/web/providers/auth-config-provider"; import { SplashScreen } from "@/web/components/splash-screen"; import { authClient } from "@/web/lib/auth-client"; @@ -59,6 +59,65 @@ function RunSSO({ return ; } +/** + * Auto-login for local mode. + * Calls the local-session endpoint and refreshes the page. + */ +function AutoLogin({ redirectTo }: { redirectTo: string }) { + const [error, setError] = useState(null); + + // oxlint-disable-next-line ban-use-effect/ban-use-effect + useEffect(() => { + let cancelled = false; + + (async () => { + try { + const res = await fetch("/api/auth/custom/local-session", { + method: "POST", + credentials: "include", + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || "Auto-login failed"); + } + if (!cancelled) { + // Validate redirectTo to prevent open redirects. + // Only allow relative paths that start with "/" but not "//". + const safeRedirect = + redirectTo.startsWith("/") && !redirectTo.startsWith("//") + ? redirectTo + : "/"; + // Reload to pick up the new session cookie + window.location.href = safeRedirect; + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : "Auto-login failed"); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [redirectTo]); + + if (error) { + return ( +
+
+

Auto-login failed: {error}

+

+ Try restarting the mesh server. +

+
+
+ ); + } + + return ; +} + export default function LoginRoute() { const session = authClient.useSession(); const searchParams = useSearch({ from: "/login" }); @@ -72,7 +131,8 @@ export default function LoginRoute() { code_challenge, code_challenge_method, } = searchParams; - const { sso, emailAndPassword, magicLink, socialProviders } = useAuthConfig(); + const authConfig = useAuthConfig(); + const { sso, emailAndPassword, magicLink, socialProviders } = authConfig; // Build OAuth authorize URL if this is an OAuth flow const oauthAuthorizeUrl = buildOAuthAuthorizeUrl({ @@ -98,6 +158,11 @@ export default function LoginRoute() { return ; } + // In local mode, auto-login without showing any form + if (authConfig.localMode) { + return ; + } + if (sso.enabled) { return ( From c1d7d56bffd65ea5c9eb1ce1b662e553b02a0b06 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Wed, 4 Mar 2026 17:33:33 -0300 Subject: [PATCH 02/30] fix(local-mode): security hardening, UX improvements, and reliability fixes - Generate random ENCRYPTION_KEY instead of empty string (cli.ts, dev.ts) - Restrict secrets.json file permissions to 0o600 (cli.ts, dev.ts) - Add non-TTY guard to prevent prompt() hang in Docker/CI (cli.ts) - Normalize org slug from OS username to valid [a-z0-9-] (local-mode.ts) - Retry auto-login on 5xx with exponential backoff (login.tsx) - Always start with fresh chat on page load (use-task-manager.ts) - Autofocus chat input on load (tiptap/input.tsx) Co-Authored-By: Claude Opus 4.6 --- apps/mesh/scripts/dev.ts | 5 +++-- apps/mesh/src/auth/local-mode.ts | 7 +++++- apps/mesh/src/cli.ts | 8 +++++-- .../components/chat/task/use-task-manager.ts | 4 ++-- .../src/web/components/chat/tiptap/input.tsx | 1 + apps/mesh/src/web/routes/login.tsx | 22 +++++++++++++------ 6 files changed, 33 insertions(+), 14 deletions(-) diff --git a/apps/mesh/scripts/dev.ts b/apps/mesh/scripts/dev.ts index 03653fdac4..2236cf943f 100644 --- a/apps/mesh/scripts/dev.ts +++ b/apps/mesh/scripts/dev.ts @@ -11,7 +11,7 @@ */ import { existsSync } from "fs"; -import { mkdir } from "fs/promises"; +import { chmod, mkdir } from "fs/promises"; import { createInterface } from "readline"; import { homedir } from "os"; import { join } from "path"; @@ -116,7 +116,7 @@ if (!process.env.ENCRYPTION_KEY) { if (savedSecrets.ENCRYPTION_KEY) { process.env.ENCRYPTION_KEY = savedSecrets.ENCRYPTION_KEY; } else { - savedSecrets.ENCRYPTION_KEY = ""; + savedSecrets.ENCRYPTION_KEY = randomBytes(32).toString("base64"); process.env.ENCRYPTION_KEY = savedSecrets.ENCRYPTION_KEY; secretsModified = true; } @@ -125,6 +125,7 @@ if (!process.env.ENCRYPTION_KEY) { if (secretsModified) { try { await Bun.write(secretsFilePath, JSON.stringify(savedSecrets, null, 2)); + await chmod(secretsFilePath, 0o600); } catch (error) { console.warn( `${yellow}Warning: Could not save secrets file: ${error}${reset}`, diff --git a/apps/mesh/src/auth/local-mode.ts b/apps/mesh/src/auth/local-mode.ts index 70d1d25d66..34052212e2 100644 --- a/apps/mesh/src/auth/local-mode.ts +++ b/apps/mesh/src/auth/local-mode.ts @@ -83,7 +83,12 @@ export async function seedLocalMode(): Promise { .execute(); // Rename the auto-created org to {username}-local - const orgSlug = `${username}-local`; + // Normalize slug: lowercase, replace non-alphanumeric with hyphens, collapse/trim + const orgSlug = `${username}-local` + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); const orgName = `${displayName} Local`; await database.db .updateTable("organization") diff --git a/apps/mesh/src/cli.ts b/apps/mesh/src/cli.ts index bb02b31db9..f6cf4c0ea3 100644 --- a/apps/mesh/src/cli.ts +++ b/apps/mesh/src/cli.ts @@ -157,6 +157,9 @@ if (values.home) { } else if (existsSync(defaultHome)) { // Default directory already exists (not first run) meshHome = defaultHome; +} else if (!process.stdin.isTTY) { + // Non-interactive (Docker, CI, systemd) — use default without prompting + meshHome = defaultHome; } else { // First run with default path — ask the user const displayDefault = defaultHome.replace(homedir(), "~"); @@ -205,7 +208,7 @@ process.env.MESH_LOCAL_MODE = localMode ? "true" : "false"; const secretsFilePath = join(meshHome, "secrets.json"); const crypto = await import("crypto"); -const { mkdir } = await import("fs/promises"); +const { mkdir, chmod } = await import("fs/promises"); interface SecretsFile { BETTER_AUTH_SECRET?: string; @@ -246,7 +249,7 @@ if (!process.env.ENCRYPTION_KEY) { if (savedSecrets.ENCRYPTION_KEY) { process.env.ENCRYPTION_KEY = savedSecrets.ENCRYPTION_KEY; } else { - savedSecrets.ENCRYPTION_KEY = ""; + savedSecrets.ENCRYPTION_KEY = crypto.randomBytes(32).toString("base64"); process.env.ENCRYPTION_KEY = savedSecrets.ENCRYPTION_KEY; secretsModified = true; } @@ -257,6 +260,7 @@ if (!process.env.ENCRYPTION_KEY) { if (secretsModified) { try { await Bun.write(secretsFilePath, JSON.stringify(savedSecrets, null, 2)); + await chmod(secretsFilePath, 0o600); } catch (error) { console.warn( `${yellow}Warning: Could not save secrets file: ${error}${reset}`, diff --git a/apps/mesh/src/web/components/chat/task/use-task-manager.ts b/apps/mesh/src/web/components/chat/task/use-task-manager.ts index 748f5543f6..a87d0f67ec 100644 --- a/apps/mesh/src/web/components/chat/task/use-task-manager.ts +++ b/apps/mesh/src/web/components/chat/task/use-task-manager.ts @@ -146,10 +146,10 @@ export function useTaskManager() { orgId: org.id, }); - // Manage active task ID with localStorage persistence + // Always start with a fresh chat on page load — ignore any previously stored task const [activeTaskId, setActiveTaskId] = useLocalStorage( LOCALSTORAGE_KEYS.assistantChatActiveTask(locator), - tasks[0]?.id ?? crypto.randomUUID(), + () => crypto.randomUUID(), ); // Fetch messages for the active task diff --git a/apps/mesh/src/web/components/chat/tiptap/input.tsx b/apps/mesh/src/web/components/chat/tiptap/input.tsx index 7ad946f34c..d33818862c 100644 --- a/apps/mesh/src/web/components/chat/tiptap/input.tsx +++ b/apps/mesh/src/web/components/chat/tiptap/input.tsx @@ -70,6 +70,7 @@ export function TiptapProvider({ { extensions: GLOBAL_EXTENSIONS, content: tiptapDoc || "", + autofocus: true, editable: !isDisabled, editorProps: { attributes: { diff --git a/apps/mesh/src/web/routes/login.tsx b/apps/mesh/src/web/routes/login.tsx index 8a9c598ad4..465a9e0c4f 100644 --- a/apps/mesh/src/web/routes/login.tsx +++ b/apps/mesh/src/web/routes/login.tsx @@ -72,13 +72,21 @@ function AutoLogin({ redirectTo }: { redirectTo: string }) { (async () => { try { - const res = await fetch("/api/auth/custom/local-session", { - method: "POST", - credentials: "include", - }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error(data.error || "Auto-login failed"); + let res: Response | undefined; + for (let attempt = 0; attempt < 5; attempt++) { + res = await fetch("/api/auth/custom/local-session", { + method: "POST", + credentials: "include", + }); + if (res.ok || res.status < 500) break; + // Retry on 5xx with exponential backoff + await new Promise((r) => + setTimeout(r, Math.min(1000 * 2 ** attempt, 10000)), + ); + } + if (!res?.ok) { + const data = await res?.json().catch(() => ({})); + throw new Error(data?.error || "Auto-login failed"); } if (!cancelled) { // Validate redirectTo to prevent open redirects. From 6166f862ec3ee0da1a0f33ee0ffc6d6273c9074f Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Wed, 4 Mar 2026 17:44:54 -0300 Subject: [PATCH 03/30] fix(chat): stabilize fresh-chat initializer across remounts Use module-level UUID constant so the fresh-chat behavior is stable within a session while still generating a new chat on each page load. Co-Authored-By: Claude Opus 4.6 --- .../mesh/src/web/components/chat/task/use-task-manager.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/mesh/src/web/components/chat/task/use-task-manager.ts b/apps/mesh/src/web/components/chat/task/use-task-manager.ts index a87d0f67ec..b34d3d0af0 100644 --- a/apps/mesh/src/web/components/chat/task/use-task-manager.ts +++ b/apps/mesh/src/web/components/chat/task/use-task-manager.ts @@ -39,6 +39,9 @@ import { import type { ChatMessage, Task } from "./types.ts"; import { TASK_CONSTANTS } from "./types.ts"; +// Fresh UUID per page load, stable across remounts within the same session +const FRESH_SESSION_TASK_ID = crypto.randomUUID(); + /** * Hook to get all tasks with infinite scroll pagination * @@ -146,10 +149,11 @@ export function useTaskManager() { orgId: org.id, }); - // Always start with a fresh chat on page load — ignore any previously stored task + // Always start with a fresh chat on page load — ignore any previously stored task. + // Uses module-level constant so remounts within the same session stay stable. const [activeTaskId, setActiveTaskId] = useLocalStorage( LOCALSTORAGE_KEYS.assistantChatActiveTask(locator), - () => crypto.randomUUID(), + () => FRESH_SESSION_TASK_ID, ); // Fetch messages for the active task From 546b73e33a17ae0496fead43faaf50165234c9b2 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Wed, 4 Mar 2026 17:49:10 -0300 Subject: [PATCH 04/30] fix: resolve CI test and e2e failures - Fix TS2345 errors: add non-null assertions for Hono route params (threadId, toolName) that are always present in route patterns - Respect MESH_LOCAL_MODE env var in dev.ts instead of hardcoding true - Set MESH_LOCAL_MODE=false in e2e workflow so signup form is shown Co-Authored-By: Claude Opus 4.6 --- .github/workflows/e2e.yml | 2 ++ apps/mesh/scripts/dev.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index bffdd613f0..784db9fb84 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -36,3 +36,5 @@ jobs: - name: Run e2e tests run: bun run test:e2e + env: + MESH_LOCAL_MODE: "false" diff --git a/apps/mesh/scripts/dev.ts b/apps/mesh/scripts/dev.ts index 2236cf943f..a9df83d934 100644 --- a/apps/mesh/scripts/dev.ts +++ b/apps/mesh/scripts/dev.ts @@ -139,7 +139,7 @@ if (secretsModified) { process.env.MESH_HOME = meshHome; process.env.DATABASE_URL = `file:${join(meshHome, "mesh.db")}`; -process.env.MESH_LOCAL_MODE = "true"; +process.env.MESH_LOCAL_MODE = process.env.MESH_LOCAL_MODE ?? "true"; // ============================================================================ // Banner From 46ddee0106e5ee16baf3a8644d660438d9fba58e Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Wed, 4 Mar 2026 17:52:37 -0300 Subject: [PATCH 05/30] fix: remove unused getMeshHome export flagged by knip Co-Authored-By: Claude Opus 4.6 --- apps/mesh/src/core/server-constants.ts | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/apps/mesh/src/core/server-constants.ts b/apps/mesh/src/core/server-constants.ts index d4d134dd57..182d7fb876 100644 --- a/apps/mesh/src/core/server-constants.ts +++ b/apps/mesh/src/core/server-constants.ts @@ -2,12 +2,9 @@ * Server Constants * * Centralized configuration for server-related constants. - * Respects BASE_URL, PORT, and MESH_HOME environment variables. + * Respects BASE_URL and PORT environment variables. */ -import { homedir } from "os"; -import { join } from "path"; - /** * Get the base URL for the server. * @@ -22,19 +19,3 @@ export function getBaseUrl(): string { const port = process.env.PORT || "3000"; return `http://localhost:${port}`; } - -/** - * Get the Mesh home directory for data storage. - * - * Priority: - * 1. MESH_HOME environment variable (if set) - * 2. ~/deco/ - * - * This is the default location for: - * - Database (mesh.db) - * - Secrets (secrets.json) - * - Local assets (assets/) - */ -export function getMeshHome(): string { - return process.env.MESH_HOME || join(homedir(), "deco"); -} From ca6303a0c94782bf1e81fffac98fe078526a5056 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 5 Mar 2026 15:29:40 -0300 Subject: [PATCH 06/30] fix(local-mode): trusted origins, internal URLs, empty state, and title - Add localhost:PORT to Better Auth trusted origins for proxy hostnames - Add getInternalUrl() for server-to-server connections (mcp/self, dev-assets) - Use BASE_URL in startup logs and CLI banner - Simplify no-LLM empty state (remove tasks panel when no model connected) - Change HTML title to "Deco Studio" Co-Authored-By: Claude Opus 4.6 --- apps/mesh/index.html | 2 +- apps/mesh/scripts/dev.ts | 3 ++ apps/mesh/src/auth/index.ts | 6 ++++ apps/mesh/src/auth/org.ts | 6 ++-- apps/mesh/src/cli.ts | 4 ++- apps/mesh/src/core/server-constants.ts | 10 ++++++ apps/mesh/src/index.ts | 2 +- apps/mesh/src/web/routes/orgs/home/page.tsx | 34 +++------------------ 8 files changed, 31 insertions(+), 36 deletions(-) diff --git a/apps/mesh/index.html b/apps/mesh/index.html index 30c69201c2..f0113d11d0 100644 --- a/apps/mesh/index.html +++ b/apps/mesh/index.html @@ -1,7 +1,7 @@ - MCP Mesh + Deco Studio diff --git a/apps/mesh/scripts/dev.ts b/apps/mesh/scripts/dev.ts index a9df83d934..0f261c254f 100644 --- a/apps/mesh/scripts/dev.ts +++ b/apps/mesh/scripts/dev.ts @@ -155,6 +155,9 @@ console.log( ); console.log(`${bold} Home: ${dim}${displayHome}/${reset}`); console.log(`${bold} Database: ${dim}${displayHome}/mesh.db${reset}`); +if (process.env.BASE_URL) { + console.log(`${bold} URL: ${dim}${process.env.BASE_URL}${reset}`); +} console.log(""); // ============================================================================ diff --git a/apps/mesh/src/auth/index.ts b/apps/mesh/src/auth/index.ts index 4a695f831a..5e470b47b9 100644 --- a/apps/mesh/src/auth/index.ts +++ b/apps/mesh/src/auth/index.ts @@ -323,6 +323,12 @@ function getTrustedOrigins(): string[] { } else if (url.hostname === "127.0.0.1") { origins.push(baseUrl.replace("127.0.0.1", "localhost")); } + // In dev, also trust localhost:PORT when BASE_URL uses a custom hostname (e.g. worktree proxies) + const port = process.env.PORT || "3000"; + const localhostOrigin = `http://localhost:${port}`; + if (!origins.includes(localhostOrigin)) { + origins.push(localhostOrigin); + } return origins; } diff --git a/apps/mesh/src/auth/org.ts b/apps/mesh/src/auth/org.ts index 2d3b74b511..c8ed18a892 100644 --- a/apps/mesh/src/auth/org.ts +++ b/apps/mesh/src/auth/org.ts @@ -5,7 +5,7 @@ import { ORG_ADMIN_PROJECT_NAME, ORG_ADMIN_PROJECT_SLUG, } from "@decocms/mesh-sdk"; -import { getBaseUrl } from "@/core/server-constants"; +import { getBaseUrl, getInternalUrl } from "@/core/server-constants"; import { isLocalMode } from "@/auth/local-mode"; import { getDb } from "@/database"; import { CredentialVault } from "@/encryption/credential-vault"; @@ -68,7 +68,7 @@ function getDefaultOrgMcps(organizationId: string): MCPCreationSpec[] { }, ); }, - data: getWellKnownSelfConnection(getBaseUrl(), organizationId), + data: getWellKnownSelfConnection(getInternalUrl(), organizationId), }, // MCP Registry (Community Registry) - public registry, no permissions required { @@ -87,7 +87,7 @@ function getDefaultOrgMcps(organizationId: string): MCPCreationSpec[] { title: "Local Files", description: "Local filesystem storage for files and assets", connection_type: "HTTP" as const, - connection_url: `${getBaseUrl()}/mcp/dev-assets`, + connection_url: `${getInternalUrl()}/mcp/dev-assets`, icon: null, app_name: "@deco/local-files", connection_token: null, diff --git a/apps/mesh/src/cli.ts b/apps/mesh/src/cli.ts index f6cf4c0ea3..fb8c0b7fa6 100644 --- a/apps/mesh/src/cli.ts +++ b/apps/mesh/src/cli.ts @@ -354,7 +354,9 @@ console.log(`${bold} Database: ${dim}${displayHome}/mesh.db${reset}`); if (localMode) { console.log(`${bold} Assets: ${dim}${displayHome}/assets/${reset}`); } -console.log(`${bold} URL: ${dim}http://localhost:${port}${reset}`); +console.log( + `${bold} URL: ${dim}${process.env.BASE_URL || `http://localhost:${port}`}${reset}`, +); console.log(""); // Import and start the server diff --git a/apps/mesh/src/core/server-constants.ts b/apps/mesh/src/core/server-constants.ts index 182d7fb876..6f4ffb48e4 100644 --- a/apps/mesh/src/core/server-constants.ts +++ b/apps/mesh/src/core/server-constants.ts @@ -19,3 +19,13 @@ export function getBaseUrl(): string { const port = process.env.PORT || "3000"; return `http://localhost:${port}`; } + +/** + * Get the internal loopback URL for server-to-server connections. + * Always uses localhost:PORT so the server can reach itself + * even when BASE_URL is a proxy hostname (e.g. tokyo.localhost). + */ +export function getInternalUrl(): string { + const port = process.env.PORT || "3000"; + return `http://localhost:${port}`; +} diff --git a/apps/mesh/src/index.ts b/apps/mesh/src/index.ts index 620c4aaa1f..3f38f09f62 100644 --- a/apps/mesh/src/index.ts +++ b/apps/mesh/src/index.ts @@ -29,7 +29,7 @@ const green = "\x1b[32m"; const cyan = "\x1b[36m"; const underline = "\x1b[4m"; -const url = `http://localhost:${port}`; +const url = process.env.BASE_URL || `http://localhost:${port}`; // Create asset handler - handles both dev proxy and production static files // When running from source (src/index.ts), the "../client" relative path diff --git a/apps/mesh/src/web/routes/orgs/home/page.tsx b/apps/mesh/src/web/routes/orgs/home/page.tsx index 98876fa026..ae7e2c2589 100644 --- a/apps/mesh/src/web/routes/orgs/home/page.tsx +++ b/apps/mesh/src/web/routes/orgs/home/page.tsx @@ -49,38 +49,12 @@ function HomeContent() { const defaultAgent = getWellKnownDecopilotVirtualMCP(org.id); const displayAgent = selectedVirtualMcp ?? defaultAgent; - // Show empty state when no LLM binding is found + // Show empty state when no LLM binding is found — no tasks panel needed if (modelsConnections.length === 0) { return ( - - - - - - -
- -
-
- {showContext && ( - <> - - - setShowContext(false)} /> - - - )} -
+
+ +
); } From f13042ef58bc867b2b4bc64d2b032e6c54f7f819 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 5 Mar 2026 15:29:45 -0300 Subject: [PATCH 07/30] feat(studio): add @decocms/studio wrapper package for npm alias Transitional package so `bunx @decocms/studio` works while the canonical package remains @decocms/mesh. Includes local publish script and CI workflow that auto-publishes with the same version as mesh. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/publish-studio-npm.yaml | 74 ++++++++++++++++++ packages/studio/README.md | 26 +++++++ packages/studio/bin/studio.js | 22 ++++++ packages/studio/package.json | 38 ++++++++++ packages/studio/scripts/publish.sh | 91 +++++++++++++++++++++++ 5 files changed, 251 insertions(+) create mode 100644 .github/workflows/publish-studio-npm.yaml create mode 100644 packages/studio/README.md create mode 100755 packages/studio/bin/studio.js create mode 100644 packages/studio/package.json create mode 100755 packages/studio/scripts/publish.sh diff --git a/.github/workflows/publish-studio-npm.yaml b/.github/workflows/publish-studio-npm.yaml new file mode 100644 index 0000000000..dee0b6b85d --- /dev/null +++ b/.github/workflows/publish-studio-npm.yaml @@ -0,0 +1,74 @@ +name: Publish @decocms/studio + +on: + push: + branches: [main] + paths: + - "apps/mesh/package.json" + - "packages/studio/**" + workflow_dispatch: + +jobs: + publish: + name: Publish to npm + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + + - name: Read mesh version + id: mesh-version + run: | + VERSION=$(node -e "console.log(require('./apps/mesh/package.json').version)") + echo "version=$VERSION" >> $GITHUB_OUTPUT + + if [[ "$VERSION" == *-* ]]; then + echo "npm-tag=next" >> $GITHUB_OUTPUT + else + echo "npm-tag=latest" >> $GITHUB_OUTPUT + fi + + echo "📦 @decocms/mesh version: $VERSION" + + - name: Check if already published + id: check + run: | + VERSION=${{ steps.mesh-version.outputs.version }} + if npm view "@decocms/studio@$VERSION" version >/dev/null 2>&1; then + echo "skip=true" >> $GITHUB_OUTPUT + echo "⏭️ @decocms/studio@$VERSION already published" + else + echo "skip=false" >> $GITHUB_OUTPUT + echo "✅ Will publish @decocms/studio@$VERSION" + fi + + - name: Patch studio package.json + if: steps.check.outputs.skip == 'false' + run: | + VERSION=${{ steps.mesh-version.outputs.version }} + cd packages/studio + node -e " + const pkg = require('./package.json'); + pkg.version = '$VERSION'; + pkg.dependencies['@decocms/mesh'] = '$VERSION'; + require('fs').writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); + " + echo "✅ Patched to v$VERSION" + cat package.json + + - name: Publish to npm + if: steps.check.outputs.skip == 'false' + run: npm publish --access public --tag ${{ steps.mesh-version.outputs.npm-tag }} --provenance + working-directory: packages/studio + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/packages/studio/README.md b/packages/studio/README.md new file mode 100644 index 0000000000..4f7947ee3f --- /dev/null +++ b/packages/studio/README.md @@ -0,0 +1,26 @@ +# @decocms/studio + +Transitional wrapper package for `@decocms/mesh`. + +## Why this exists + +The product is being rebranded from "MCP Mesh" to "Deco Studio". During the +transition both npm package names need to work: + +- `bunx @decocms/mesh` — canonical package (all source code lives here) +- `bunx @decocms/studio` — alias that depends on `@decocms/mesh` and + re-exports its CLI bin + +This lets marketing materials and docs reference `@decocms/studio` immediately +without renaming the main package or breaking existing installs. + +## How it works + +- `package.json` declares `@decocms/mesh` as a dependency (pinned to the same version) +- `bin/studio.js` simply imports the mesh CLI entry point +- Both packages are published with the same version number + +## Eventually + +Once the rename is complete, `@decocms/mesh` will become the wrapper (pointing +to `@decocms/studio`) and all source code will move under the studio name. diff --git a/packages/studio/bin/studio.js b/packages/studio/bin/studio.js new file mode 100755 index 0000000000..c1cd8587e6 --- /dev/null +++ b/packages/studio/bin/studio.js @@ -0,0 +1,22 @@ +#!/usr/bin/env node + +// Re-export the @decocms/mesh CLI entry point. +// This wrapper package exists so that `bunx @decocms/studio` works +// while the canonical package remains @decocms/mesh. + +const { execFileSync } = require("child_process"); +const { createRequire } = require("module"); +const { dirname, join } = require("path"); + +const require_ = createRequire(__filename); +const meshPkgJson = require_.resolve("@decocms/mesh/package.json"); +const meshDir = dirname(meshPkgJson); +const meshBin = join(meshDir, "dist", "server", "cli.js"); + +try { + execFileSync(process.execPath, [meshBin, ...process.argv.slice(2)], { + stdio: "inherit", + }); +} catch (e) { + process.exit(e.status || 1); +} diff --git a/packages/studio/package.json b/packages/studio/package.json new file mode 100644 index 0000000000..14eb4aa2ff --- /dev/null +++ b/packages/studio/package.json @@ -0,0 +1,38 @@ +{ + "name": "@decocms/studio", + "version": "2.135.1", + "description": "Deco Studio - Self-hostable MCP Gateway (wrapper for @decocms/mesh)", + "author": "Deco team", + "license": "MIT", + "homepage": "https://github.com/decocms/mesh", + "repository": { + "type": "git", + "url": "git+https://github.com/decocms/mesh.git", + "directory": "packages/studio" + }, + "bugs": { + "url": "https://github.com/decocms/mesh/issues" + }, + "bin": { + "studio": "./bin/studio.js" + }, + "files": [ + "bin/**/*" + ], + "dependencies": { + "@decocms/mesh": "workspace:*" + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "mcp", + "model-context-protocol", + "ai", + "gateway", + "self-hosted", + "mesh", + "studio", + "deco" + ] +} diff --git a/packages/studio/scripts/publish.sh b/packages/studio/scripts/publish.sh new file mode 100755 index 0000000000..4db7f7c893 --- /dev/null +++ b/packages/studio/scripts/publish.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Publish @decocms/studio as a wrapper for @decocms/mesh. +# +# This script: +# 1. Reads the current @decocms/mesh version +# 2. Patches packages/studio/package.json to use that exact version +# 3. Publishes @decocms/studio to npm with the same version & tag +# 4. Restores the workspace:* dependency afterward +# +# Usage: +# ./packages/studio/scripts/publish.sh # publish (dry-run) +# ./packages/studio/scripts/publish.sh --run # publish for real +# ./packages/studio/scripts/publish.sh --run --tag next # publish as prerelease + +STUDIO_DIR="$(cd "$(dirname "$0")/.." && pwd)" +MESH_PKG="$(cd "$STUDIO_DIR/../../apps/mesh" && pwd)/package.json" + +if [ ! -f "$MESH_PKG" ]; then + echo "❌ Could not find apps/mesh/package.json" + exit 1 +fi + +MESH_VERSION=$(node -e "console.log(require('$MESH_PKG').version)") +echo "📦 @decocms/mesh version: $MESH_VERSION" + +# Parse args +DRY_RUN=true +NPM_TAG="" +for arg in "$@"; do + case "$arg" in + --run) DRY_RUN=false ;; + --tag) shift_next=true ;; + *) + if [ "${shift_next:-}" = "true" ]; then + NPM_TAG="$arg" + shift_next=false + fi + ;; + esac +done + +# Auto-detect tag from version if not specified +if [ -z "$NPM_TAG" ]; then + if [[ "$MESH_VERSION" == *-* ]]; then + NPM_TAG="next" + else + NPM_TAG="latest" + fi +fi + +echo "🏷️ npm tag: $NPM_TAG" + +# Check if already published +if npm view "@decocms/studio@$MESH_VERSION" version >/dev/null 2>&1; then + echo "⏭️ @decocms/studio@$MESH_VERSION already published, skipping." + exit 0 +fi + +# Patch package.json: set version and pin dependency +cd "$STUDIO_DIR" +cp package.json package.json.bak + +node -e " +const pkg = require('./package.json'); +pkg.version = '$MESH_VERSION'; +pkg.dependencies['@decocms/mesh'] = '$MESH_VERSION'; +require('fs').writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); +" + +echo "✅ Patched studio package.json → v$MESH_VERSION" + +# Publish +if [ "$DRY_RUN" = true ]; then + echo "" + echo "🧪 DRY RUN — would publish:" + echo " npm publish --access public --tag $NPM_TAG" + echo "" + echo " Run with --run to publish for real." + npm publish --dry-run --access public --tag "$NPM_TAG" 2>&1 || true +else + echo "" + echo "🚀 Publishing @decocms/studio@$MESH_VERSION..." + npm publish --access public --tag "$NPM_TAG" + echo "✅ Published @decocms/studio@$MESH_VERSION" +fi + +# Restore workspace dependency +mv package.json.bak package.json +echo "🔄 Restored workspace:* dependency in package.json" From 58d034911d1d39cc214324a9975209415c499a23 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 5 Mar 2026 15:40:09 -0300 Subject: [PATCH 08/30] fix(oauth): use localhost origin for redirect URIs behind proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External OAuth servers (e.g. OpenRouter MCP) reject .localhost subdomains as redirect URIs. When running behind a proxy (tokyo.localhost → localhost:3000), the OAuth flow now uses localhost:PORT for the redirect URI. - Add setOAuthRedirectOrigin() to mesh-sdk for configuring redirect origin - Expose internalUrl in /api/config public endpoint - Set redirect origin from config on app init in ThemeProvider - Accept cross-origin postMessage between .localhost variants in local dev Co-Authored-By: Claude Opus 4.6 --- apps/mesh/src/api/routes/public-config.ts | 8 +++ .../mesh/src/web/providers/theme-provider.tsx | 6 ++ packages/mesh-sdk/src/index.ts | 1 + packages/mesh-sdk/src/lib/mcp-oauth.ts | 65 ++++++++++++++++++- 4 files changed, 77 insertions(+), 3 deletions(-) diff --git a/apps/mesh/src/api/routes/public-config.ts b/apps/mesh/src/api/routes/public-config.ts index 5b07ad4e45..4d0eca2871 100644 --- a/apps/mesh/src/api/routes/public-config.ts +++ b/apps/mesh/src/api/routes/public-config.ts @@ -7,6 +7,7 @@ import { Hono } from "hono"; import { getThemeConfig, type ThemeConfig } from "@/core/config"; +import { getInternalUrl } from "@/core/server-constants"; const app = new Hono(); @@ -19,6 +20,12 @@ export type PublicConfig = { * Contains CSS variable overrides that will be injected into the document. */ theme?: ThemeConfig; + /** + * The server's internal URL (localhost:PORT). + * Used as the OAuth redirect origin when the browser is behind a proxy + * (e.g. tokyo.localhost) that external OAuth servers may not accept. + */ + internalUrl?: string; }; /** @@ -32,6 +39,7 @@ export type PublicConfig = { app.get("/", (c) => { const config: PublicConfig = { theme: getThemeConfig(), + internalUrl: getInternalUrl(), }; return c.json({ success: true, config }); diff --git a/apps/mesh/src/web/providers/theme-provider.tsx b/apps/mesh/src/web/providers/theme-provider.tsx index ffe6278607..095055e036 100644 --- a/apps/mesh/src/web/providers/theme-provider.tsx +++ b/apps/mesh/src/web/providers/theme-provider.tsx @@ -9,6 +9,7 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { useLayoutEffect, type ReactNode } from "react"; import type { PublicConfig } from "@/api/routes/public-config"; import { KEYS } from "@/web/lib/query-keys"; +import { setOAuthRedirectOrigin } from "@decocms/mesh-sdk"; async function fetchPublicConfig(): Promise { const response = await fetch("/api/config"); @@ -69,6 +70,11 @@ export function ThemeProvider({ children }: { children: ReactNode }) { staleTime: Infinity, }); + // Set OAuth redirect origin for proxy environments (e.g. tokyo.localhost → localhost:3000) + if (publicConfig.internalUrl) { + setOAuthRedirectOrigin(publicConfig.internalUrl); + } + // Inject theme variables synchronously before paint to avoid FOUC // useLayoutEffect is correct here (not useEffect) for DOM mutations that affect visual appearance useLayoutEffect(() => { diff --git a/packages/mesh-sdk/src/index.ts b/packages/mesh-sdk/src/index.ts index 23fc507a7b..f01e49bbb4 100644 --- a/packages/mesh-sdk/src/index.ts +++ b/packages/mesh-sdk/src/index.ts @@ -147,6 +147,7 @@ export { authenticateMcp, handleOAuthCallback, isConnectionAuthenticated, + setOAuthRedirectOrigin, type McpOAuthProviderOptions, type OAuthTokenInfo, type AuthenticateMcpResult, diff --git a/packages/mesh-sdk/src/lib/mcp-oauth.ts b/packages/mesh-sdk/src/lib/mcp-oauth.ts index a4bbc06795..21b6e7d5ed 100644 --- a/packages/mesh-sdk/src/lib/mcp-oauth.ts +++ b/packages/mesh-sdk/src/lib/mcp-oauth.ts @@ -35,6 +35,61 @@ function hashServerUrl(url: string): string { return Math.abs(hash).toString(16); } +/** + * Override origin for OAuth redirect URIs. + * Set via `setOAuthRedirectOrigin()` when the browser runs behind a proxy + * (e.g. tokyo.localhost) that external OAuth servers may not accept. + */ +let _oauthRedirectOrigin: string | null = null; + +/** + * Set a custom origin for OAuth redirect URIs. + * Call this at app init with the server's internal URL (e.g. http://localhost:3000) + * so that external OAuth servers accept the redirect URI. + */ +export function setOAuthRedirectOrigin(origin: string): void { + _oauthRedirectOrigin = origin; +} + +/** + * Get the origin to use for OAuth redirect URIs. + * Returns the override if set, otherwise falls back to window.location.origin. + */ +function getOAuthRedirectOrigin(): string { + return _oauthRedirectOrigin ?? window.location.origin; +} + +/** + * Check if we're in a local dev environment (localhost or .localhost subdomain). + */ +function isLocalDev(): boolean { + const { hostname } = window.location; + return ( + hostname === "localhost" || + hostname === "127.0.0.1" || + hostname.endsWith(".localhost") + ); +} + +/** + * Check if a postMessage origin is allowed. + * In local dev, accept both the current origin and localhost variants. + */ +function isAllowedOrigin(origin: string): boolean { + if (origin === window.location.origin) return true; + if (!isLocalDev()) return false; + try { + const url = new URL(origin); + return ( + url.hostname === "localhost" || + url.hostname === "127.0.0.1" || + url.hostname.endsWith(".localhost") + ); + } catch { + return false; + } +} + /** * Global in-memory store for active OAuth sessions. */ @@ -89,7 +144,7 @@ class McpOAuthProvider implements OAuthClientProvider { constructor(options: McpOAuthProviderOptions) { this.serverUrl = options.serverUrl; this._redirectUrl = - options.callbackUrl ?? `${window.location.origin}/oauth/callback`; + options.callbackUrl ?? `${getOAuthRedirectOrigin()}/oauth/callback`; this._windowMode = options.windowMode ?? "popup"; // Build scope string if provided @@ -392,7 +447,7 @@ export async function authenticateMcp(params: { // Primary: Listen for postMessage from popup const handleMessage = async (event: MessageEvent) => { - if (event.origin !== window.location.origin) return; + if (!isAllowedOrigin(event.origin)) return; if (event.data?.type === "mcp:oauth:callback") { await processCallback(event.data); } @@ -499,7 +554,11 @@ function sendCallbackData( ): boolean { // Try postMessage first (primary method) if (window.opener && !window.opener.closed) { - window.opener.postMessage(data, window.location.origin); + // Use "*" for local dev where redirect URI (localhost:PORT) may differ + // from the opener origin (e.g. proxy.localhost). This is safe because + // the data is just an OAuth code already visible in the URL. + const target = isLocalDev() ? "*" : window.location.origin; + window.opener.postMessage(data, target); return true; } From 144b7acb2ee5bafe6e3fa38faeec9aac944856f5 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 5 Mar 2026 15:42:05 -0300 Subject: [PATCH 09/30] fix: gate localhost trusted origin to local mode, restore package.json on failure - Only add localhost:PORT to Better Auth trusted origins in local mode - Add EXIT trap to studio publish script to restore package.json on failure Co-Authored-By: Claude Opus 4.6 --- apps/mesh/src/auth/index.ts | 13 ++++++++----- packages/studio/scripts/publish.sh | 5 ++--- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/apps/mesh/src/auth/index.ts b/apps/mesh/src/auth/index.ts index 5e470b47b9..a96abac22a 100644 --- a/apps/mesh/src/auth/index.ts +++ b/apps/mesh/src/auth/index.ts @@ -27,6 +27,7 @@ import { defaultStatements, } from "better-auth/plugins/organization/access"; +import { isLocalMode } from "@/auth/local-mode"; import { config } from "@/core/config"; import { getBaseUrl } from "@/core/server-constants"; import { createAccessControl, Role } from "@decocms/better-auth/plugins/access"; @@ -323,11 +324,13 @@ function getTrustedOrigins(): string[] { } else if (url.hostname === "127.0.0.1") { origins.push(baseUrl.replace("127.0.0.1", "localhost")); } - // In dev, also trust localhost:PORT when BASE_URL uses a custom hostname (e.g. worktree proxies) - const port = process.env.PORT || "3000"; - const localhostOrigin = `http://localhost:${port}`; - if (!origins.includes(localhostOrigin)) { - origins.push(localhostOrigin); + // In local mode, also trust localhost:PORT when BASE_URL uses a custom hostname (e.g. worktree proxies) + if (isLocalMode()) { + const port = process.env.PORT || "3000"; + const localhostOrigin = `http://localhost:${port}`; + if (!origins.includes(localhostOrigin)) { + origins.push(localhostOrigin); + } } return origins; } diff --git a/packages/studio/scripts/publish.sh b/packages/studio/scripts/publish.sh index 4db7f7c893..6549312297 100755 --- a/packages/studio/scripts/publish.sh +++ b/packages/studio/scripts/publish.sh @@ -61,6 +61,7 @@ fi # Patch package.json: set version and pin dependency cd "$STUDIO_DIR" cp package.json package.json.bak +trap 'mv -f "$STUDIO_DIR/package.json.bak" "$STUDIO_DIR/package.json" 2>/dev/null; echo "🔄 Restored workspace:* dependency in package.json"' EXIT node -e " const pkg = require('./package.json'); @@ -86,6 +87,4 @@ else echo "✅ Published @decocms/studio@$MESH_VERSION" fi -# Restore workspace dependency -mv package.json.bak package.json -echo "🔄 Restored workspace:* dependency in package.json" +# Trap handler restores package.json on exit From aaea0c4900a5972214009d1f7bb7ad0057526aa6 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 5 Mar 2026 15:46:39 -0300 Subject: [PATCH 10/30] fix(local-mode): security hardening and reliability improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restrict /local-session endpoint to loopback requests only (127.0.0.1/::1) to prevent LAN access when server is bound to 0.0.0.0 - Fix tilde expansion in dev.ts and cli.ts: ~/dir was resolving to /dir instead of $HOME/dir (slice(1) → slice(2) to skip the ~/) - Gate local-session behind seed completion to prevent 500s on race - Respect user-provided DATABASE_URL in CLI instead of always overwriting - Add security comment documenting that loopback check (not password) is the security boundary for local-mode auto-login Co-Authored-By: Claude Opus 4.6 --- apps/mesh/scripts/dev.ts | 2 +- apps/mesh/src/api/routes/auth.ts | 18 ++++++++++++++++++ apps/mesh/src/auth/local-mode.ts | 20 ++++++++++++++++++++ apps/mesh/src/cli.ts | 11 ++++++----- apps/mesh/src/index.ts | 23 +++++++++++++---------- 5 files changed, 58 insertions(+), 16 deletions(-) diff --git a/apps/mesh/scripts/dev.ts b/apps/mesh/scripts/dev.ts index 0f261c254f..674ae0cf32 100644 --- a/apps/mesh/scripts/dev.ts +++ b/apps/mesh/scripts/dev.ts @@ -72,7 +72,7 @@ if (explicitHome) { meshHome = userHome; } else { meshHome = answer.startsWith("~") - ? join(homedir(), answer.slice(1)) + ? join(homedir(), answer.slice(2)) : answer; } } diff --git a/apps/mesh/src/api/routes/auth.ts b/apps/mesh/src/api/routes/auth.ts index e85c809677..c2e1abe400 100644 --- a/apps/mesh/src/api/routes/auth.ts +++ b/apps/mesh/src/api/routes/auth.ts @@ -128,7 +128,25 @@ app.post("/local-session", async (c) => { return c.json({ success: false, error: "Local mode is not active" }, 403); } + // Only allow from loopback to prevent LAN access when bound to 0.0.0.0 + const forwarded = c.req.header("x-forwarded-for"); + const remoteAddr = + forwarded?.split(",")[0]?.trim() ?? c.req.header("x-real-ip") ?? ""; + const isLoopback = + remoteAddr === "127.0.0.1" || + remoteAddr === "::1" || + remoteAddr === "localhost" || + remoteAddr === "" || // same-machine requests with no proxy + remoteAddr === "::ffff:127.0.0.1"; + if (!isLoopback) { + return c.json({ success: false, error: "Forbidden" }, 403); + } + try { + // Wait for local-mode seeding to complete before attempting login + const { waitForSeed } = await import("@/auth/local-mode"); + await waitForSeed(); + const { auth } = await import("../../auth"); const adminUser = await getLocalAdminUser(); if (!adminUser) { diff --git a/apps/mesh/src/auth/local-mode.ts b/apps/mesh/src/auth/local-mode.ts index 34052212e2..547a35abe8 100644 --- a/apps/mesh/src/auth/local-mode.ts +++ b/apps/mesh/src/auth/local-mode.ts @@ -11,6 +11,10 @@ import { getDb } from "@/database"; import { userInfo } from "os"; import { auth } from "./index"; +// Internal password for the auto-seeded local admin user. +// Security note: this value is NOT the security boundary — the /local-session +// endpoint is protected by a loopback-only check (see auth routes), so only +// requests from localhost/127.0.0.1 can use it. export const LOCAL_ADMIN_PASSWORD = "admin@mesh"; function getLocalUserName(): string { @@ -121,3 +125,19 @@ export async function getLocalAdminUser() { export function isLocalMode(): boolean { return process.env.MESH_LOCAL_MODE === "true"; } + +// Seed readiness gate — local-session waits for this before granting access +let _seedResolve: () => void; +const _seedReady = new Promise((resolve) => { + _seedResolve = resolve; +}); + +/** Mark local-mode seeding as complete. Called from index.ts after seedLocalMode(). */ +export function markSeedComplete(): void { + _seedResolve(); +} + +/** Wait for local-mode seeding to finish. No-op if already complete. */ +export function waitForSeed(): Promise { + return _seedReady; +} diff --git a/apps/mesh/src/cli.ts b/apps/mesh/src/cli.ts index fb8c0b7fa6..5c45d0bc35 100644 --- a/apps/mesh/src/cli.ts +++ b/apps/mesh/src/cli.ts @@ -174,17 +174,18 @@ if (values.home) { } else { // Expand ~ to home directory meshHome = answer.startsWith("~") - ? join(homedir(), answer.slice(1)) + ? join(homedir(), answer.slice(2)) : answer; } } process.env.MESH_HOME = meshHome; -// Set DATABASE_URL to MESH_HOME/mesh.db. The CLI owns the data directory, -// so we always use the SQLite database inside it. This prevents accidentally -// connecting to a PostgreSQL database from the shell environment. -process.env.DATABASE_URL = `file:${join(meshHome, "mesh.db")}`; +// Default DATABASE_URL to MESH_HOME/mesh.db if not explicitly set. +// Respects user-provided DATABASE_URL (e.g. PostgreSQL connection strings). +if (!process.env.DATABASE_URL) { + process.env.DATABASE_URL = `file:${join(meshHome, "mesh.db")}`; +} // Ensure NODE_ENV defaults to production when running via CLI if (!process.env.NODE_ENV) { diff --git a/apps/mesh/src/index.ts b/apps/mesh/src/index.ts index 3f38f09f62..e37b184e3e 100644 --- a/apps/mesh/src/index.ts +++ b/apps/mesh/src/index.ts @@ -71,17 +71,20 @@ Bun.serve({ // This must run after Bun.serve() so that the org seed can fetch tools // from the self MCP endpoint (http://localhost:PORT/mcp/self) if (process.env.MESH_LOCAL_MODE === "true") { - (async () => { - try { - const { seedLocalMode } = await import("./auth/local-mode"); - const seeded = await seedLocalMode(); - if (seeded) { - console.log(`\n${green}Local environment initialized.${reset}`); + import("./auth/local-mode").then( + async ({ seedLocalMode, markSeedComplete }) => { + try { + const seeded = await seedLocalMode(); + if (seeded) { + console.log(`\n${green}Local environment initialized.${reset}`); + } + } catch (error) { + console.error("Failed to seed local mode:", error); + } finally { + markSeedComplete(); } - } catch (error) { - console.error("Failed to seed local mode:", error); - } - })(); + }, + ); } // Internal debug server (only enabled via ENABLE_DEBUG_SERVER=true) From a42b778191e012f7b9c45afb259c5b0e7f08c6fb Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 5 Mar 2026 15:59:26 -0300 Subject: [PATCH 11/30] fix(tests): update OAuth callback tests for cross-origin postMessage Update test mocks to include hostname/port/protocol on window.location and adjust assertion for postMessage target origin ("*" in local dev). Also add DEBT.md tracking technical debt for local-first DX work. Co-Authored-By: Claude Opus 4.6 --- DEBT.md | 48 +++++++++++++++++++++++++ apps/mesh/src/web/lib/mcp-oauth.test.ts | 9 ++++- 2 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 DEBT.md diff --git a/DEBT.md b/DEBT.md new file mode 100644 index 0000000000..5512587801 --- /dev/null +++ b/DEBT.md @@ -0,0 +1,48 @@ +# Technical Debt — Local-First DX + +Items to address after the core local-first DX lands. + +## NATS onboarding in local mode + +The agentic onboarding flow should detect whether NATS is available and, if not, +offer to install and start it (via `brew install nats-server` or Docker). NATS +enables stream recovery (switch tabs / refresh → restore in-progress AI +responses). Without it, `NoOpStreamBuffer` is used and late-join replay is +disabled. + +- Relevant code: `apps/mesh/src/api/app.ts` (NATS_URL detection), + `apps/mesh/src/api/routes/decopilot/stream-buffer.ts` (NoOpStreamBuffer fallback) + +## OpenRouter auto-auth flow + +The auto-auth flow (install OpenRouter → OAuth popup → chat ready) lives on +`feat/local-first-dx` branch (commit `bacda6781`) and is not yet in +`feat/local-first-dx-core`. Needs to be cherry-picked or merged. + +- Key files: `apps/mesh/src/web/lib/authenticate-connection.ts`, + `apps/mesh/src/web/components/chat/no-llm-binding-empty-state.tsx` + +## @decocms/studio package rename + +`packages/studio/` is a thin wrapper that depends on `@decocms/mesh`. Eventually +the source should move to `@decocms/studio` as the canonical package and +`@decocms/mesh` becomes the wrapper (or is deprecated). + +## Password migration for local admin + +The local admin password (`admin@mesh`) is hardcoded. The loopback-only check on +`/local-session` is the real security boundary, so this is low-risk. If we ever +want per-install passwords, derive from `BETTER_AUTH_SECRET` and add a migration +path that updates the stored hash on boot. + +## Playwright e2e excluded from `bun test` + +The Playwright e2e test (`apps/mesh/src/**/*.e2e.test.ts`) is picked up by +`bun test` and fails because `@playwright/test` conflicts with bun's test +runner. Should be excluded via bun test config or file naming convention. + +## internalUrl in public config + +`/api/config` exposes `internalUrl` (e.g. `http://localhost:3000`) to support +OAuth redirect URIs behind proxies. In production, this should either be omitted +or set to the actual public URL. Currently always returns `localhost:PORT`. diff --git a/apps/mesh/src/web/lib/mcp-oauth.test.ts b/apps/mesh/src/web/lib/mcp-oauth.test.ts index 62191c3e00..e73729fb82 100644 --- a/apps/mesh/src/web/lib/mcp-oauth.test.ts +++ b/apps/mesh/src/web/lib/mcp-oauth.test.ts @@ -224,6 +224,9 @@ describe("handleOAuthCallback", () => { location: { search: "", origin: "http://localhost:3000", + hostname: "localhost", + port: "3000", + protocol: "http:", }, opener: null as Window | null, localStorage: { @@ -274,6 +277,9 @@ describe("handleOAuthCallback", () => { windowMock.location = { search, origin: "http://localhost:3000", + hostname: "localhost", + port: "3000", + protocol: "http:", }; if (!isBrowser) { (globalThis as unknown as { window: typeof windowMock }).window = @@ -316,7 +322,8 @@ describe("handleOAuthCallback", () => { code: "auth_code_123", state: "state_abc", }); - expect(origin).toBe("http://localhost:3000"); + // In local dev, postMessage uses "*" to support cross-origin proxy setups + expect(origin).toBe("*"); }); test("handles error parameter from OAuth provider", async () => { From a587bfab7e89afc2208c0bf836f873206255782b Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 5 Mar 2026 16:01:31 -0300 Subject: [PATCH 12/30] fix(local-mode): use socket-level IP check and fix tilde expansion - Replace spoofable X-Forwarded-For header check with Bun's getConnInfo() (socket-level requestIP) for the local-session loopback guard - Pass Bun server as env to Hono so getConnInfo can access requestIP - Fix tilde expansion: only expand bare ~ and ~/path, not ~user inputs Co-Authored-By: Claude Opus 4.6 --- apps/mesh/scripts/dev.ts | 9 ++++++--- apps/mesh/src/api/routes/auth.ts | 14 +++++++++----- apps/mesh/src/cli.ts | 11 +++++++---- apps/mesh/src/index.ts | 5 +++-- 4 files changed, 25 insertions(+), 14 deletions(-) diff --git a/apps/mesh/scripts/dev.ts b/apps/mesh/scripts/dev.ts index 674ae0cf32..7ee911de04 100644 --- a/apps/mesh/scripts/dev.ts +++ b/apps/mesh/scripts/dev.ts @@ -71,9 +71,12 @@ if (explicitHome) { if (answer === "") { meshHome = userHome; } else { - meshHome = answer.startsWith("~") - ? join(homedir(), answer.slice(2)) - : answer; + meshHome = + answer === "~" + ? homedir() + : answer.startsWith("~/") + ? join(homedir(), answer.slice(2)) + : answer; } } diff --git a/apps/mesh/src/api/routes/auth.ts b/apps/mesh/src/api/routes/auth.ts index c2e1abe400..2fd1c2d005 100644 --- a/apps/mesh/src/api/routes/auth.ts +++ b/apps/mesh/src/api/routes/auth.ts @@ -6,6 +6,7 @@ */ import { Hono } from "hono"; +import { getConnInfo } from "hono/bun"; import { authConfig, resetPasswordEnabled } from "../../auth"; import { KNOWN_OAUTH_PROVIDERS, OAuthProvider } from "@/auth/oauth-providers"; import { @@ -129,14 +130,17 @@ app.post("/local-session", async (c) => { } // Only allow from loopback to prevent LAN access when bound to 0.0.0.0 - const forwarded = c.req.header("x-forwarded-for"); - const remoteAddr = - forwarded?.split(",")[0]?.trim() ?? c.req.header("x-real-ip") ?? ""; + // Uses Bun's socket-level requestIP — not spoofable via headers + let remoteAddr: string | undefined; + try { + const info = getConnInfo(c); + remoteAddr = info.remote.address; + } catch { + // getConnInfo may fail in test environments without a real server + } const isLoopback = remoteAddr === "127.0.0.1" || remoteAddr === "::1" || - remoteAddr === "localhost" || - remoteAddr === "" || // same-machine requests with no proxy remoteAddr === "::ffff:127.0.0.1"; if (!isLoopback) { return c.json({ success: false, error: "Forbidden" }, 403); diff --git a/apps/mesh/src/cli.ts b/apps/mesh/src/cli.ts index 5c45d0bc35..e5f21fd8ce 100644 --- a/apps/mesh/src/cli.ts +++ b/apps/mesh/src/cli.ts @@ -172,10 +172,13 @@ if (values.home) { if (answer === "") { meshHome = defaultHome; } else { - // Expand ~ to home directory - meshHome = answer.startsWith("~") - ? join(homedir(), answer.slice(2)) - : answer; + // Expand ~ to home directory (only bare ~ or ~/path, not ~user) + meshHome = + answer === "~" + ? homedir() + : answer.startsWith("~/") + ? join(homedir(), answer.slice(2)) + : answer; } } diff --git a/apps/mesh/src/index.ts b/apps/mesh/src/index.ts index e37b184e3e..beeaab3834 100644 --- a/apps/mesh/src/index.ts +++ b/apps/mesh/src/index.ts @@ -60,9 +60,10 @@ Bun.serve({ idleTimeout: 0, port, hostname: "0.0.0.0", // Listen on all network interfaces (required for K8s) - fetch: async (request) => { + fetch: async (request, server) => { // Try assets first (static files or dev proxy), then API - return (await handleAssets(request)) ?? app.fetch(request); + // Pass server as env so Hono's getConnInfo can access requestIP + return (await handleAssets(request)) ?? app.fetch(request, { server }); }, development: process.env.NODE_ENV !== "production", }); From 29471e5911a0888f65337bae58a9580a8301cc0b Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 5 Mar 2026 16:05:26 -0300 Subject: [PATCH 13/30] feat(studio): rename package to decocms with deco CLI command Publish as `decocms` on npm so users can run `npx decocms` or `npm i -g decocms && deco`. Renamed bin from studio.js to deco.js and updated CI workflow, publish script, README, and DEBT.md. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/publish-studio-npm.yaml | 8 +++---- DEBT.md | 9 ++++---- packages/studio/README.md | 26 +++++++++++++++------- packages/studio/bin/{studio.js => deco.js} | 2 +- packages/studio/package.json | 9 ++++---- packages/studio/scripts/publish.sh | 14 ++++++------ 6 files changed, 40 insertions(+), 28 deletions(-) rename packages/studio/bin/{studio.js => deco.js} (90%) diff --git a/.github/workflows/publish-studio-npm.yaml b/.github/workflows/publish-studio-npm.yaml index dee0b6b85d..5685cf664f 100644 --- a/.github/workflows/publish-studio-npm.yaml +++ b/.github/workflows/publish-studio-npm.yaml @@ -1,4 +1,4 @@ -name: Publish @decocms/studio +name: Publish decocms on: push: @@ -44,12 +44,12 @@ jobs: id: check run: | VERSION=${{ steps.mesh-version.outputs.version }} - if npm view "@decocms/studio@$VERSION" version >/dev/null 2>&1; then + if npm view "decocms@$VERSION" version >/dev/null 2>&1; then echo "skip=true" >> $GITHUB_OUTPUT - echo "⏭️ @decocms/studio@$VERSION already published" + echo "⏭️ decocms@$VERSION already published" else echo "skip=false" >> $GITHUB_OUTPUT - echo "✅ Will publish @decocms/studio@$VERSION" + echo "✅ Will publish decocms@$VERSION" fi - name: Patch studio package.json diff --git a/DEBT.md b/DEBT.md index 5512587801..9c3e0f4ba8 100644 --- a/DEBT.md +++ b/DEBT.md @@ -22,11 +22,12 @@ The auto-auth flow (install OpenRouter → OAuth popup → chat ready) lives on - Key files: `apps/mesh/src/web/lib/authenticate-connection.ts`, `apps/mesh/src/web/components/chat/no-llm-binding-empty-state.tsx` -## @decocms/studio package rename +## decocms package rename -`packages/studio/` is a thin wrapper that depends on `@decocms/mesh`. Eventually -the source should move to `@decocms/studio` as the canonical package and -`@decocms/mesh` becomes the wrapper (or is deprecated). +`packages/studio/` publishes as `decocms` on npm (`npx decocms` / `deco` CLI). +It's a thin wrapper that depends on `@decocms/mesh`. Eventually the source +should move to `decocms` as the canonical package and `@decocms/mesh` becomes +the wrapper (or is deprecated). ## Password migration for local admin diff --git a/packages/studio/README.md b/packages/studio/README.md index 4f7947ee3f..88892669e1 100644 --- a/packages/studio/README.md +++ b/packages/studio/README.md @@ -1,26 +1,36 @@ -# @decocms/studio +# decocms -Transitional wrapper package for `@decocms/mesh`. +CLI wrapper for `@decocms/mesh` — the Deco Studio MCP Gateway. + +## Usage + +```bash +# Run directly +npx decocms + +# Or install globally +npm i -g decocms +deco +``` ## Why this exists The product is being rebranded from "MCP Mesh" to "Deco Studio". During the -transition both npm package names need to work: +transition both npm packages need to work: - `bunx @decocms/mesh` — canonical package (all source code lives here) -- `bunx @decocms/studio` — alias that depends on `@decocms/mesh` and - re-exports its CLI bin +- `npx decocms` / `deco` — user-facing CLI that depends on `@decocms/mesh` -This lets marketing materials and docs reference `@decocms/studio` immediately +This lets marketing materials and docs reference `npx decocms` immediately without renaming the main package or breaking existing installs. ## How it works - `package.json` declares `@decocms/mesh` as a dependency (pinned to the same version) -- `bin/studio.js` simply imports the mesh CLI entry point +- `bin/deco.js` simply imports the mesh CLI entry point - Both packages are published with the same version number ## Eventually Once the rename is complete, `@decocms/mesh` will become the wrapper (pointing -to `@decocms/studio`) and all source code will move under the studio name. +to `decocms`) and all source code will move under the decocms name. diff --git a/packages/studio/bin/studio.js b/packages/studio/bin/deco.js similarity index 90% rename from packages/studio/bin/studio.js rename to packages/studio/bin/deco.js index c1cd8587e6..7b7fde7fa0 100755 --- a/packages/studio/bin/studio.js +++ b/packages/studio/bin/deco.js @@ -1,7 +1,7 @@ #!/usr/bin/env node // Re-export the @decocms/mesh CLI entry point. -// This wrapper package exists so that `bunx @decocms/studio` works +// This wrapper package exists so that `npx decocms` / `deco` works // while the canonical package remains @decocms/mesh. const { execFileSync } = require("child_process"); diff --git a/packages/studio/package.json b/packages/studio/package.json index 14eb4aa2ff..cca499ddd8 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -1,7 +1,7 @@ { - "name": "@decocms/studio", + "name": "decocms", "version": "2.135.1", - "description": "Deco Studio - Self-hostable MCP Gateway (wrapper for @decocms/mesh)", + "description": "Deco Studio - Self-hostable MCP Gateway", "author": "Deco team", "license": "MIT", "homepage": "https://github.com/decocms/mesh", @@ -14,7 +14,7 @@ "url": "https://github.com/decocms/mesh/issues" }, "bin": { - "studio": "./bin/studio.js" + "deco": "./bin/deco.js" }, "files": [ "bin/**/*" @@ -33,6 +33,7 @@ "self-hosted", "mesh", "studio", - "deco" + "deco", + "decocms" ] } diff --git a/packages/studio/scripts/publish.sh b/packages/studio/scripts/publish.sh index 6549312297..09600b97db 100755 --- a/packages/studio/scripts/publish.sh +++ b/packages/studio/scripts/publish.sh @@ -1,12 +1,12 @@ #!/usr/bin/env bash set -euo pipefail -# Publish @decocms/studio as a wrapper for @decocms/mesh. +# Publish `decocms` as a wrapper for @decocms/mesh. # # This script: # 1. Reads the current @decocms/mesh version # 2. Patches packages/studio/package.json to use that exact version -# 3. Publishes @decocms/studio to npm with the same version & tag +# 3. Publishes `decocms` to npm with the same version & tag # 4. Restores the workspace:* dependency afterward # # Usage: @@ -53,8 +53,8 @@ fi echo "🏷️ npm tag: $NPM_TAG" # Check if already published -if npm view "@decocms/studio@$MESH_VERSION" version >/dev/null 2>&1; then - echo "⏭️ @decocms/studio@$MESH_VERSION already published, skipping." +if npm view "decocms@$MESH_VERSION" version >/dev/null 2>&1; then + echo "⏭️ decocms@$MESH_VERSION already published, skipping." exit 0 fi @@ -70,7 +70,7 @@ pkg.dependencies['@decocms/mesh'] = '$MESH_VERSION'; require('fs').writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); " -echo "✅ Patched studio package.json → v$MESH_VERSION" +echo "✅ Patched decocms package.json → v$MESH_VERSION" # Publish if [ "$DRY_RUN" = true ]; then @@ -82,9 +82,9 @@ if [ "$DRY_RUN" = true ]; then npm publish --dry-run --access public --tag "$NPM_TAG" 2>&1 || true else echo "" - echo "🚀 Publishing @decocms/studio@$MESH_VERSION..." + echo "🚀 Publishing decocms@$MESH_VERSION..." npm publish --access public --tag "$NPM_TAG" - echo "✅ Published @decocms/studio@$MESH_VERSION" + echo "✅ Published decocms@$MESH_VERSION" fi # Trap handler restores package.json on exit From 7e0cf035359ab25cc7aaec815d0b18e05f689ab9 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 5 Mar 2026 16:06:39 -0300 Subject: [PATCH 14/30] docs(studio): update decocms README and package metadata for npm Replace internal wrapper docs with a proper product README showing features, quick start, and MCP client config. Add descriptive keywords and update main README to reference `npx decocms`. Co-Authored-By: Claude Opus 4.6 --- README.md | 2 +- packages/studio/README.md | 94 ++++++++++++++++++++++++++++-------- packages/studio/package.json | 12 ++++- 3 files changed, 85 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index f1df9cc65b..478e3d5d2a 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ bun run dev → runs at [http://localhost:3000](http://localhost:3000) (client) + API server -Or use `npx @decocms/mesh` to instantly get a mesh running. +Or use `npx decocms` to instantly get a mesh running. --- diff --git a/packages/studio/README.md b/packages/studio/README.md index 88892669e1..b8f603d15c 100644 --- a/packages/studio/README.md +++ b/packages/studio/README.md @@ -1,36 +1,90 @@ -# decocms +# Deco Studio -CLI wrapper for `@decocms/mesh` — the Deco Studio MCP Gateway. - -## Usage +One secure endpoint for every MCP server — run locally in seconds. ```bash -# Run directly npx decocms +``` + +Or install globally: -# Or install globally +```bash npm i -g decocms deco ``` -## Why this exists +## What is this? + +Deco Studio (powered by [MCP Mesh](https://github.com/decocms/mesh)) is an +open-source control plane for Model Context Protocol (MCP) traffic. It sits +between your MCP clients (Cursor, Claude, Windsurf, VS Code, custom agents) and +your MCP servers, providing a unified layer for auth, routing, and observability. + +``` +MCP Clients (Cursor, Claude, VS Code, agents) + │ + ▼ + Deco Studio + Virtual MCPs · Policies · Traces · Vault + │ + ▼ +MCP Servers (Slack, GitHub, Postgres, your APIs) +``` + +## Features + +- **Single endpoint** — replace M x N client/server configs with one governed URL +- **RBAC & policies** — workspace and project-level access control with audit trails +- **Full observability** — OpenTelemetry tracing, cost tracking, error monitoring +- **Virtual MCPs** — runtime strategies for optimal tool selection (full-context, smart selection, code execution) +- **Token vault** — secure credential storage for MCP server connections +- **Local-first** — runs on your machine with SQLite, no cloud required +- **OAuth built-in** — connect to OAuth-protected MCP servers with one click +- **AI chat** — built-in chat UI with multi-provider support (OpenRouter, OpenAI, Anthropic, etc.) + +## Quick start -The product is being rebranded from "MCP Mesh" to "Deco Studio". During the -transition both npm packages need to work: +```bash +# Start Deco Studio locally +npx decocms + +# Opens at http://localhost:3000 +# Connect your MCP clients to http://localhost:3000/mcp +``` + +Point any MCP client at `http://localhost:3000/mcp` and all your MCP servers +are available through a single endpoint. + +## Connect MCP clients + +### Cursor / Claude Desktop / VS Code + +Add to your MCP config: + +```json +{ + "mcpServers": { + "deco": { + "url": "http://localhost:3000/mcp" + } + } +} +``` -- `bunx @decocms/mesh` — canonical package (all source code lives here) -- `npx decocms` / `deco` — user-facing CLI that depends on `@decocms/mesh` +## Self-host in production -This lets marketing materials and docs reference `npx decocms` immediately -without renaming the main package or breaking existing installs. +For team and production deployments, see the +[full documentation](https://docs.deco.page/) — supports Docker, Bun/Node, +Kubernetes, and PostgreSQL. -## How it works +## Links -- `package.json` declares `@decocms/mesh` as a dependency (pinned to the same version) -- `bin/deco.js` simply imports the mesh CLI entry point -- Both packages are published with the same version number +- [Documentation](https://docs.deco.page/) +- [GitHub](https://github.com/decocms/mesh) +- [Discord](https://decocms.com/discord) +- [Website](https://decocms.com/mesh) -## Eventually +## License -Once the rename is complete, `@decocms/mesh` will become the wrapper (pointing -to `decocms`) and all source code will move under the decocms name. +Sustainable Use License (SUL) — free to self-host for internal use and client +projects. See [LICENSE.md](https://github.com/decocms/mesh/blob/main/LICENSE.md). diff --git a/packages/studio/package.json b/packages/studio/package.json index cca499ddd8..54387c6f01 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -1,7 +1,7 @@ { "name": "decocms", "version": "2.135.1", - "description": "Deco Studio - Self-hostable MCP Gateway", + "description": "One secure endpoint for every MCP server. Open-source MCP control plane with auth, routing, and observability.", "author": "Deco team", "license": "MIT", "homepage": "https://github.com/decocms/mesh", @@ -34,6 +34,14 @@ "mesh", "studio", "deco", - "decocms" + "decocms", + "cli", + "cursor", + "claude", + "opentelemetry", + "observability", + "rbac", + "oauth", + "proxy" ] } From 1fc227e57444ae38c2c3c7b871037cde4aeda8f8 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 5 Mar 2026 16:09:31 -0300 Subject: [PATCH 15/30] fix(config): only expose internalUrl in local mode Production deployments don't need the localhost URL in /api/config. Gate it behind isLocalMode() so it's only sent when running locally behind a proxy (e.g. tokyo.localhost). Co-Authored-By: Claude Opus 4.6 --- DEBT.md | 6 ------ apps/mesh/src/api/routes/public-config.ts | 4 +++- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/DEBT.md b/DEBT.md index 9c3e0f4ba8..fa4752ba9d 100644 --- a/DEBT.md +++ b/DEBT.md @@ -41,9 +41,3 @@ path that updates the stored hash on boot. The Playwright e2e test (`apps/mesh/src/**/*.e2e.test.ts`) is picked up by `bun test` and fails because `@playwright/test` conflicts with bun's test runner. Should be excluded via bun test config or file naming convention. - -## internalUrl in public config - -`/api/config` exposes `internalUrl` (e.g. `http://localhost:3000`) to support -OAuth redirect URIs behind proxies. In production, this should either be omitted -or set to the actual public URL. Currently always returns `localhost:PORT`. diff --git a/apps/mesh/src/api/routes/public-config.ts b/apps/mesh/src/api/routes/public-config.ts index 4d0eca2871..20ad4f614d 100644 --- a/apps/mesh/src/api/routes/public-config.ts +++ b/apps/mesh/src/api/routes/public-config.ts @@ -7,6 +7,7 @@ import { Hono } from "hono"; import { getThemeConfig, type ThemeConfig } from "@/core/config"; +import { isLocalMode } from "@/auth/local-mode"; import { getInternalUrl } from "@/core/server-constants"; const app = new Hono(); @@ -39,7 +40,8 @@ export type PublicConfig = { app.get("/", (c) => { const config: PublicConfig = { theme: getThemeConfig(), - internalUrl: getInternalUrl(), + // Only expose internalUrl in local mode — production uses the public URL directly + ...(isLocalMode() && { internalUrl: getInternalUrl() }), }; return c.json({ success: true, config }); From 2de9069f2a98162c9ce761f12917767849545ad9 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 5 Mar 2026 16:12:14 -0300 Subject: [PATCH 16/30] fix: remove unused getBaseUrl import in org.ts Co-Authored-By: Claude Opus 4.6 --- apps/mesh/src/auth/org.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mesh/src/auth/org.ts b/apps/mesh/src/auth/org.ts index c8ed18a892..a03bc6b85e 100644 --- a/apps/mesh/src/auth/org.ts +++ b/apps/mesh/src/auth/org.ts @@ -5,7 +5,7 @@ import { ORG_ADMIN_PROJECT_NAME, ORG_ADMIN_PROJECT_SLUG, } from "@decocms/mesh-sdk"; -import { getBaseUrl, getInternalUrl } from "@/core/server-constants"; +import { getInternalUrl } from "@/core/server-constants"; import { isLocalMode } from "@/auth/local-mode"; import { getDb } from "@/database"; import { CredentialVault } from "@/encryption/credential-vault"; From d67c7dce40faa93eb4b6a8460dfacc25bade3a94 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 5 Mar 2026 16:16:24 -0300 Subject: [PATCH 17/30] docs(studio): align README with decocms.com/studio positioning Rewrite to lead with agents and tools, not just MCP plumbing. Matches the landing page: hire agents, connect 50+ integrations, track costs, run locally or scale to teams. Co-Authored-By: Claude Opus 4.6 --- packages/studio/README.md | 65 ++++++++++++++---------------------- packages/studio/package.json | 9 ++--- 2 files changed, 30 insertions(+), 44 deletions(-) diff --git a/packages/studio/README.md b/packages/studio/README.md index b8f603d15c..8d60823979 100644 --- a/packages/studio/README.md +++ b/packages/studio/README.md @@ -1,6 +1,6 @@ # Deco Studio -One secure endpoint for every MCP server — run locally in seconds. +Open-source control plane for your AI agents. Install in 30 seconds. ```bash npx decocms @@ -13,35 +13,29 @@ npm i -g decocms deco ``` -## What is this? +## What is Deco Studio? -Deco Studio (powered by [MCP Mesh](https://github.com/decocms/mesh)) is an -open-source control plane for Model Context Protocol (MCP) traffic. It sits -between your MCP clients (Cursor, Claude, Windsurf, VS Code, custom agents) and -your MCP servers, providing a unified layer for auth, routing, and observability. +Deco Studio is where you hire AI agents, connect tools, and manage projects — +all from a single dashboard. Browse specialized agents, wire up 50+ integrations +via MCP, and track every token, cost, and action in real time. + +- **Hire agents** — browse specialized AI agents or create your own from custom prompts. Agents can compose and call each other. +- **Connect tools** — 50+ integrations (GitHub, Slack, Postgres, OpenAI, and more) with one-click OAuth and granular RBAC. +- **Track everything** — real-time cost attribution, latency monitoring, and error tracking per agent and connection. +- **Run locally** — private, SQLite-based setup on your machine. No cloud required. +- **Scale to teams** — optional cloud sync via [studio.decocms.com](https://studio.decocms.com), or self-host for your org. ``` -MCP Clients (Cursor, Claude, VS Code, agents) - │ - ▼ - Deco Studio - Virtual MCPs · Policies · Traces · Vault - │ - ▼ -MCP Servers (Slack, GitHub, Postgres, your APIs) +Your AI Agents & MCP Clients + │ + ▼ + Deco Studio + Agents · Tools · Observability + │ + ▼ + Integrations (Slack, GitHub, APIs, DBs...) ``` -## Features - -- **Single endpoint** — replace M x N client/server configs with one governed URL -- **RBAC & policies** — workspace and project-level access control with audit trails -- **Full observability** — OpenTelemetry tracing, cost tracking, error monitoring -- **Virtual MCPs** — runtime strategies for optimal tool selection (full-context, smart selection, code execution) -- **Token vault** — secure credential storage for MCP server connections -- **Local-first** — runs on your machine with SQLite, no cloud required -- **OAuth built-in** — connect to OAuth-protected MCP servers with one click -- **AI chat** — built-in chat UI with multi-provider support (OpenRouter, OpenAI, Anthropic, etc.) - ## Quick start ```bash @@ -49,17 +43,9 @@ MCP Servers (Slack, GitHub, Postgres, your APIs) npx decocms # Opens at http://localhost:3000 -# Connect your MCP clients to http://localhost:3000/mcp ``` -Point any MCP client at `http://localhost:3000/mcp` and all your MCP servers -are available through a single endpoint. - -## Connect MCP clients - -### Cursor / Claude Desktop / VS Code - -Add to your MCP config: +Connect any MCP client to `http://localhost:3000/mcp`: ```json { @@ -71,20 +57,19 @@ Add to your MCP config: } ``` -## Self-host in production +## Deploy -For team and production deployments, see the -[full documentation](https://docs.deco.page/) — supports Docker, Bun/Node, -Kubernetes, and PostgreSQL. +Run locally with SQLite, or deploy for your team with Docker, Bun/Node, +Kubernetes, and PostgreSQL. See the [docs](https://docs.deco.page/). ## Links +- [Website](https://decocms.com/studio) - [Documentation](https://docs.deco.page/) - [GitHub](https://github.com/decocms/mesh) - [Discord](https://decocms.com/discord) -- [Website](https://decocms.com/mesh) ## License -Sustainable Use License (SUL) — free to self-host for internal use and client +Sustainable Use License — free to self-host for internal use and client projects. See [LICENSE.md](https://github.com/decocms/mesh/blob/main/LICENSE.md). diff --git a/packages/studio/package.json b/packages/studio/package.json index 54387c6f01..f3c05b83b8 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -1,7 +1,7 @@ { "name": "decocms", "version": "2.135.1", - "description": "One secure endpoint for every MCP server. Open-source MCP control plane with auth, routing, and observability.", + "description": "Open-source control plane for your AI agents. Hire agents, connect tools, track costs.", "author": "Deco team", "license": "MIT", "homepage": "https://github.com/decocms/mesh", @@ -26,6 +26,7 @@ "access": "public" }, "keywords": [ + "ai-agents", "mcp", "model-context-protocol", "ai", @@ -38,10 +39,10 @@ "cli", "cursor", "claude", - "opentelemetry", "observability", - "rbac", + "cost-tracking", + "integrations", "oauth", - "proxy" + "rbac" ] } From 3ae22abcbdbf17315b97ffe38ce6dd948a3ad50d Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 5 Mar 2026 16:20:05 -0300 Subject: [PATCH 18/30] fix: ignore decocms runtime dependency in knip config @decocms/mesh is resolved via require() in bin/deco.js at runtime, which knip can't trace. Add ignoreDependencies for packages/studio. Co-Authored-By: Claude Opus 4.6 --- knip.jsonc | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/knip.jsonc b/knip.jsonc index 7195f40c06..bc3c64cdbe 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -21,6 +21,10 @@ ], "ignore": ["src/api/index.ts"] }, + "packages/studio": { + // Runtime dependency resolved via require() in bin/deco.js + "ignoreDependencies": ["@decocms/mesh"] + }, "packages/mesh-sdk": { "ignoreDependencies": ["sonner"] }, From 7a63ed706737df12e0fdedd62e445d607d59988f Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 5 Mar 2026 16:24:00 -0300 Subject: [PATCH 19/30] chore: rebrand CLI and docs from @decocms/mesh to npx decocms / deco Update all user-facing references: CLI help text, release workflow, docs quickstart pages (en + pt-br), dev script comment, and OAuth client name to use "Deco Studio" / "npx decocms" / "deco". Co-Authored-By: Claude Opus 4.6 --- .github/workflows/release.yaml | 12 +++---- .../en/mcp-mesh/self-hosting/quickstart.mdx | 2 +- .../src/content/draft/pt-br/introduction.mdx | 2 +- .../draft/pt-br/mcp-mesh/quickstart.mdx | 2 +- .../src/content/latest/en/introduction.mdx | 2 +- .../content/latest/en/mcp-mesh/quickstart.mdx | 2 +- .../src/content/latest/pt-br/introduction.mdx | 2 +- .../latest/pt-br/mcp-mesh/quickstart.mdx | 2 +- apps/mesh/scripts/dev.ts | 2 +- apps/mesh/src/cli.ts | 31 ++++++++++--------- packages/mesh-sdk/src/lib/mcp-oauth.ts | 4 +-- 11 files changed, 33 insertions(+), 30 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 89c4fb2786..4d2758f159 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -143,15 +143,15 @@ jobs: uses: softprops/action-gh-release@v2 with: tag_name: v${{ steps.version-check.outputs.current-version }} - name: "@decocms/mesh v${{ steps.version-check.outputs.current-version }}" + name: "Deco Studio v${{ steps.version-check.outputs.current-version }}" body: | - ## MCP Mesh v${{ steps.version-check.outputs.current-version }} + ## Deco Studio v${{ steps.version-check.outputs.current-version }} - Self-hostable Virtual MCP for managing AI connections and tools. + Open-source control plane for your AI agents. ### Install via npm ```bash - bunx @decocms/mesh@${{ steps.version-check.outputs.current-version }} + npx decocms@${{ steps.version-check.outputs.current-version }} ``` ### Install via Docker @@ -175,11 +175,11 @@ jobs: run: | echo "## Release Summary" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "🎉 **@decocms/mesh v${{ steps.version-check.outputs.current-version }} Released**" >> $GITHUB_STEP_SUMMARY + echo "🎉 **Deco Studio v${{ steps.version-check.outputs.current-version }} Released**" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "### npm" >> $GITHUB_STEP_SUMMARY echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY - echo "bunx @decocms/mesh@${{ steps.version-check.outputs.current-version }}" >> $GITHUB_STEP_SUMMARY + echo "npx decocms@${{ steps.version-check.outputs.current-version }}" >> $GITHUB_STEP_SUMMARY echo "\`\`\`" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "### Docker" >> $GITHUB_STEP_SUMMARY diff --git a/apps/docs/client/src/content/draft/en/mcp-mesh/self-hosting/quickstart.mdx b/apps/docs/client/src/content/draft/en/mcp-mesh/self-hosting/quickstart.mdx index 09166bd7dd..737b44b78d 100644 --- a/apps/docs/client/src/content/draft/en/mcp-mesh/self-hosting/quickstart.mdx +++ b/apps/docs/client/src/content/draft/en/mcp-mesh/self-hosting/quickstart.mdx @@ -13,7 +13,7 @@ Pick one: ### Option A: one-command local setup (npx) ```bash -npx @decocms/mesh +npx decocms ``` ### Option B: local setup with Docker Compose diff --git a/apps/docs/client/src/content/draft/pt-br/introduction.mdx b/apps/docs/client/src/content/draft/pt-br/introduction.mdx index c2eb11ad56..15e4b06732 100644 --- a/apps/docs/client/src/content/draft/pt-br/introduction.mdx +++ b/apps/docs/client/src/content/draft/pt-br/introduction.mdx @@ -28,7 +28,7 @@ Escolha uma opção: ### Opção A: setup local com um comando (npx) ```bash -npx @decocms/mesh +npx decocms ``` ### Opção B: setup local com Docker Compose diff --git a/apps/docs/client/src/content/draft/pt-br/mcp-mesh/quickstart.mdx b/apps/docs/client/src/content/draft/pt-br/mcp-mesh/quickstart.mdx index cf8b791a50..0556a51ab1 100644 --- a/apps/docs/client/src/content/draft/pt-br/mcp-mesh/quickstart.mdx +++ b/apps/docs/client/src/content/draft/pt-br/mcp-mesh/quickstart.mdx @@ -13,7 +13,7 @@ Escolha uma opção: ### Opção A: setup local com um comando (npx) ```bash -npx @decocms/mesh +npx decocms ``` ### Opção B: setup local com Docker Compose diff --git a/apps/docs/client/src/content/latest/en/introduction.mdx b/apps/docs/client/src/content/latest/en/introduction.mdx index d11e851e64..2ae11a8053 100644 --- a/apps/docs/client/src/content/latest/en/introduction.mdx +++ b/apps/docs/client/src/content/latest/en/introduction.mdx @@ -29,7 +29,7 @@ Choose one: ### Option A: one-command local setup (npx) ```bash -npx @decocms/mesh +npx decocms ``` ### Option B: local setup with Docker Compose diff --git a/apps/docs/client/src/content/latest/en/mcp-mesh/quickstart.mdx b/apps/docs/client/src/content/latest/en/mcp-mesh/quickstart.mdx index 9100b155e3..b8e6a93cc4 100644 --- a/apps/docs/client/src/content/latest/en/mcp-mesh/quickstart.mdx +++ b/apps/docs/client/src/content/latest/en/mcp-mesh/quickstart.mdx @@ -13,7 +13,7 @@ Pick one: ### Option A: one-command local setup (npx) ```bash -npx @decocms/mesh +npx decocms ``` ### Option B: local setup with Docker Compose diff --git a/apps/docs/client/src/content/latest/pt-br/introduction.mdx b/apps/docs/client/src/content/latest/pt-br/introduction.mdx index a3970cafe2..6e0cb9e906 100644 --- a/apps/docs/client/src/content/latest/pt-br/introduction.mdx +++ b/apps/docs/client/src/content/latest/pt-br/introduction.mdx @@ -29,7 +29,7 @@ Escolha uma opção: ### Opção A: setup local com um comando (npx) ```bash -npx @decocms/mesh +npx decocms ``` ### Opção B: setup local com Docker Compose diff --git a/apps/docs/client/src/content/latest/pt-br/mcp-mesh/quickstart.mdx b/apps/docs/client/src/content/latest/pt-br/mcp-mesh/quickstart.mdx index cf8b791a50..0556a51ab1 100644 --- a/apps/docs/client/src/content/latest/pt-br/mcp-mesh/quickstart.mdx +++ b/apps/docs/client/src/content/latest/pt-br/mcp-mesh/quickstart.mdx @@ -13,7 +13,7 @@ Escolha uma opção: ### Opção A: setup local com um comando (npx) ```bash -npx @decocms/mesh +npx decocms ``` ### Opção B: setup local com Docker Compose diff --git a/apps/mesh/scripts/dev.ts b/apps/mesh/scripts/dev.ts index 7ee911de04..db63329fa6 100644 --- a/apps/mesh/scripts/dev.ts +++ b/apps/mesh/scripts/dev.ts @@ -3,7 +3,7 @@ * Development environment setup script. * * Mirrors the CLI (src/cli.ts) behaviour so that `bun dev` and - * `bunx @decocms/mesh` share the same ~/deco data directory, secrets, + * `npx decocms` / `deco` share the same ~/deco data directory, secrets, * and local-mode defaults. * * After setting up the environment it spawns the regular dev pipeline: diff --git a/apps/mesh/src/cli.ts b/apps/mesh/src/cli.ts index e5f21fd8ce..ee97aca8ff 100644 --- a/apps/mesh/src/cli.ts +++ b/apps/mesh/src/cli.ts @@ -2,15 +2,17 @@ /** * MCP Mesh CLI Entry Point * - * This script serves as the bin entry point for bunx @decocms/mesh + * Deco Studio CLI Entry Point + * + * This script serves as the bin entry point for `npx decocms` / `deco`. * It runs database migrations, seeds the local environment, and starts the server. * * Usage: - * bunx @decocms/mesh - * bunx @decocms/mesh --port 8080 - * bunx @decocms/mesh --home ~/my-mesh - * bunx @decocms/mesh --no-local-mode - * bunx @decocms/mesh --help + * npx decocms + * deco --port 8080 + * deco --home ~/my-mesh + * deco --no-local-mode + * deco --help */ import { parseArgs } from "util"; @@ -56,10 +58,11 @@ const { values } = parseArgs({ if (values.help) { console.log(` -MCP Mesh - Self-hostable MCP Server +Deco Studio - Open-source control plane for your AI agents Usage: - bunx @decocms/mesh [options] + npx decocms [options] + deco [options] Options: -p, --port Port to listen on (default: 3000, or PORT env var) @@ -80,13 +83,13 @@ Environment Variables: CONFIG_PATH Path to full config file (default: ./config.json) Examples: - bunx @decocms/mesh # Start with defaults (~/deco/) - bunx @decocms/mesh -p 8080 # Start on port 8080 - bunx @decocms/mesh --home ~/my-project # Custom data directory - bunx @decocms/mesh --no-local-mode # Require login (SaaS mode) + npx decocms # Start with defaults (~/deco/) + deco -p 8080 # Start on port 8080 + deco --home ~/my-project # Custom data directory + deco --no-local-mode # Require login (SaaS mode) Documentation: - https://github.com/decocms/mesh + https://decocms.com/studio `); process.exit(0); } @@ -111,7 +114,7 @@ if (values.version) { } } - console.log(`@decocms/mesh v${version}`); + console.log(`Deco Studio v${version}`); process.exit(0); } diff --git a/packages/mesh-sdk/src/lib/mcp-oauth.ts b/packages/mesh-sdk/src/lib/mcp-oauth.ts index 21b6e7d5ed..ded2e2ecea 100644 --- a/packages/mesh-sdk/src/lib/mcp-oauth.ts +++ b/packages/mesh-sdk/src/lib/mcp-oauth.ts @@ -159,7 +159,7 @@ class McpOAuthProvider implements OAuthClientProvider { token_endpoint_auth_method: "none", grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], - client_name: options.clientName ?? "@decocms/mesh MCP client", + client_name: options.clientName ?? "Deco Studio MCP client", // Only include scope if explicitly provided - some servers have their own scope requirements ...(scopeStr && { scope: scopeStr }), }; @@ -770,7 +770,7 @@ export async function isConnectionAuthenticated({ protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { - name: "@decocms/mesh MCP client", + name: "Deco Studio MCP client", version: "1.0.0", }, }, From cbfe8a4d5d47a73920b14225745e0b225ce6259f Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 5 Mar 2026 16:29:08 -0300 Subject: [PATCH 20/30] fix(studio): use bun runtime in deco CLI wrapper The built CLI uses Bun APIs (Bun.file, Bun.serve), so the wrapper must run it with bun, not node. Auto-detects bun availability. Co-Authored-By: Claude Opus 4.6 --- packages/studio/bin/deco.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/studio/bin/deco.js b/packages/studio/bin/deco.js index 7b7fde7fa0..2f9e9c52f8 100755 --- a/packages/studio/bin/deco.js +++ b/packages/studio/bin/deco.js @@ -13,8 +13,19 @@ const meshPkgJson = require_.resolve("@decocms/mesh/package.json"); const meshDir = dirname(meshPkgJson); const meshBin = join(meshDir, "dist", "server", "cli.js"); +// The built CLI uses Bun APIs (Bun.file, Bun.serve), so we must run it with bun. +// Fall back to node if bun is not available (will fail on Bun-specific APIs). +const runtime = (() => { + try { + execFileSync("bun", ["--version"], { stdio: "ignore" }); + return "bun"; + } catch { + return process.execPath; + } +})(); + try { - execFileSync(process.execPath, [meshBin, ...process.argv.slice(2)], { + execFileSync(runtime, [meshBin, ...process.argv.slice(2)], { stdio: "inherit", }); } catch (e) { From 435c4eb9974251c8db9177d14c7d11c8e80c3afb Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 5 Mar 2026 16:31:02 -0300 Subject: [PATCH 21/30] chore: rebrand CLI banners from MCP Mesh to Deco Studio Update startup banners, prompts, and subtitle in cli.ts and dev.ts to show "Deco Studio" instead of "MCP Mesh". Co-Authored-By: Claude Opus 4.6 --- apps/mesh/scripts/dev.ts | 6 +++--- apps/mesh/src/cli.ts | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/mesh/scripts/dev.ts b/apps/mesh/scripts/dev.ts index db63329fa6..203e4f36dd 100644 --- a/apps/mesh/scripts/dev.ts +++ b/apps/mesh/scripts/dev.ts @@ -63,10 +63,10 @@ if (explicitHome) { // Interactive, first run — prompt for location const displayDefault = userHome.replace(homedir(), "~"); console.log(""); - console.log(`${bold}${cyan}MCP Mesh${reset} ${dim}(dev)${reset}`); + console.log(`${bold}${cyan}Deco Studio${reset} ${dim}(dev)${reset}`); console.log(""); const answer = await prompt( - ` Where should Mesh store its data? ${dim}(${displayDefault})${reset} `, + ` Where should Deco store its data? ${dim}(${displayDefault})${reset} `, ); if (answer === "") { meshHome = userHome; @@ -151,7 +151,7 @@ process.env.MESH_LOCAL_MODE = process.env.MESH_LOCAL_MODE ?? "true"; const displayHome = meshHome.replace(homedir(), "~"); console.log(""); -console.log(`${bold}${cyan}MCP Mesh${reset} ${dim}(dev)${reset}`); +console.log(`${bold}${cyan}Deco Studio${reset} ${dim}(dev)${reset}`); console.log(""); console.log( `${bold} Mode: ${green}Local${reset}${bold} (auto-login enabled)${reset}`, diff --git a/apps/mesh/src/cli.ts b/apps/mesh/src/cli.ts index ee97aca8ff..704965350e 100644 --- a/apps/mesh/src/cli.ts +++ b/apps/mesh/src/cli.ts @@ -1,6 +1,6 @@ #!/usr/bin/env bun /** - * MCP Mesh CLI Entry Point + * Deco Studio CLI Entry Point * * Deco Studio CLI Entry Point * @@ -167,10 +167,10 @@ if (values.home) { // First run with default path — ask the user const displayDefault = defaultHome.replace(homedir(), "~"); console.log(""); - console.log(`${bold}${cyan}MCP Mesh${reset}`); + console.log(`${bold}${cyan}Deco Studio${reset}`); console.log(""); const answer = await prompt( - ` Where should Mesh store its data? ${dim}(${displayDefault})${reset} `, + ` Where should Deco store its data? ${dim}(${displayDefault})${reset} `, ); if (answer === "") { meshHome = defaultHome; @@ -282,8 +282,8 @@ if (secretsModified) { const displayHome = meshHome.replace(homedir(), "~"); console.log(""); -console.log(`${bold}${cyan}MCP Mesh${reset}`); -console.log(`${dim}Self-hostable MCP Server${reset}`); +console.log(`${bold}${cyan}Deco Studio${reset}`); +console.log(`${dim}Open-source control plane for your AI agents${reset}`); console.log(""); if (betterAuthFromFile || encryptionKeyFromFile) { From 66b6c131c502870c687e3522f6c193d75174698f Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 5 Mar 2026 16:35:09 -0300 Subject: [PATCH 22/30] fix(studio): require bun runtime and update instructions to bunx The built CLI uses Bun APIs so bun is a hard requirement. Show a clear error with install instructions if bun is missing. Update all docs, CLI help, README, and release workflow from npx to bunx. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/release.yaml | 4 ++-- DEBT.md | 2 +- README.md | 2 +- .../en/mcp-mesh/self-hosting/quickstart.mdx | 2 +- .../src/content/draft/pt-br/introduction.mdx | 2 +- .../draft/pt-br/mcp-mesh/quickstart.mdx | 2 +- .../src/content/latest/en/introduction.mdx | 2 +- .../content/latest/en/mcp-mesh/quickstart.mdx | 2 +- .../src/content/latest/pt-br/introduction.mdx | 2 +- .../latest/pt-br/mcp-mesh/quickstart.mdx | 2 +- apps/mesh/scripts/dev.ts | 2 +- apps/mesh/src/cli.ts | 4 ++-- packages/studio/README.md | 11 +++------- packages/studio/bin/deco.js | 22 +++++++++---------- 14 files changed, 28 insertions(+), 33 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 4d2758f159..421c6017c0 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -151,7 +151,7 @@ jobs: ### Install via npm ```bash - npx decocms@${{ steps.version-check.outputs.current-version }} + bunx decocms@${{ steps.version-check.outputs.current-version }} ``` ### Install via Docker @@ -179,7 +179,7 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY echo "### npm" >> $GITHUB_STEP_SUMMARY echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY - echo "npx decocms@${{ steps.version-check.outputs.current-version }}" >> $GITHUB_STEP_SUMMARY + echo "bunx decocms@${{ steps.version-check.outputs.current-version }}" >> $GITHUB_STEP_SUMMARY echo "\`\`\`" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "### Docker" >> $GITHUB_STEP_SUMMARY diff --git a/DEBT.md b/DEBT.md index fa4752ba9d..b552d43b5b 100644 --- a/DEBT.md +++ b/DEBT.md @@ -24,7 +24,7 @@ The auto-auth flow (install OpenRouter → OAuth popup → chat ready) lives on ## decocms package rename -`packages/studio/` publishes as `decocms` on npm (`npx decocms` / `deco` CLI). +`packages/studio/` publishes as `decocms` on npm (`bunx decocms` / `deco` CLI). It's a thin wrapper that depends on `@decocms/mesh`. Eventually the source should move to `decocms` as the canonical package and `@decocms/mesh` becomes the wrapper (or is deprecated). diff --git a/README.md b/README.md index 478e3d5d2a..753365dd11 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ bun run dev → runs at [http://localhost:3000](http://localhost:3000) (client) + API server -Or use `npx decocms` to instantly get a mesh running. +Or use `bunx decocms` to instantly get a mesh running. --- diff --git a/apps/docs/client/src/content/draft/en/mcp-mesh/self-hosting/quickstart.mdx b/apps/docs/client/src/content/draft/en/mcp-mesh/self-hosting/quickstart.mdx index 737b44b78d..00c51a7219 100644 --- a/apps/docs/client/src/content/draft/en/mcp-mesh/self-hosting/quickstart.mdx +++ b/apps/docs/client/src/content/draft/en/mcp-mesh/self-hosting/quickstart.mdx @@ -13,7 +13,7 @@ Pick one: ### Option A: one-command local setup (npx) ```bash -npx decocms +bunx decocms ``` ### Option B: local setup with Docker Compose diff --git a/apps/docs/client/src/content/draft/pt-br/introduction.mdx b/apps/docs/client/src/content/draft/pt-br/introduction.mdx index 15e4b06732..badfc30061 100644 --- a/apps/docs/client/src/content/draft/pt-br/introduction.mdx +++ b/apps/docs/client/src/content/draft/pt-br/introduction.mdx @@ -28,7 +28,7 @@ Escolha uma opção: ### Opção A: setup local com um comando (npx) ```bash -npx decocms +bunx decocms ``` ### Opção B: setup local com Docker Compose diff --git a/apps/docs/client/src/content/draft/pt-br/mcp-mesh/quickstart.mdx b/apps/docs/client/src/content/draft/pt-br/mcp-mesh/quickstart.mdx index 0556a51ab1..6329d72960 100644 --- a/apps/docs/client/src/content/draft/pt-br/mcp-mesh/quickstart.mdx +++ b/apps/docs/client/src/content/draft/pt-br/mcp-mesh/quickstart.mdx @@ -13,7 +13,7 @@ Escolha uma opção: ### Opção A: setup local com um comando (npx) ```bash -npx decocms +bunx decocms ``` ### Opção B: setup local com Docker Compose diff --git a/apps/docs/client/src/content/latest/en/introduction.mdx b/apps/docs/client/src/content/latest/en/introduction.mdx index 2ae11a8053..690a71237c 100644 --- a/apps/docs/client/src/content/latest/en/introduction.mdx +++ b/apps/docs/client/src/content/latest/en/introduction.mdx @@ -29,7 +29,7 @@ Choose one: ### Option A: one-command local setup (npx) ```bash -npx decocms +bunx decocms ``` ### Option B: local setup with Docker Compose diff --git a/apps/docs/client/src/content/latest/en/mcp-mesh/quickstart.mdx b/apps/docs/client/src/content/latest/en/mcp-mesh/quickstart.mdx index b8e6a93cc4..9c5f57d199 100644 --- a/apps/docs/client/src/content/latest/en/mcp-mesh/quickstart.mdx +++ b/apps/docs/client/src/content/latest/en/mcp-mesh/quickstart.mdx @@ -13,7 +13,7 @@ Pick one: ### Option A: one-command local setup (npx) ```bash -npx decocms +bunx decocms ``` ### Option B: local setup with Docker Compose diff --git a/apps/docs/client/src/content/latest/pt-br/introduction.mdx b/apps/docs/client/src/content/latest/pt-br/introduction.mdx index 6e0cb9e906..9cc692f888 100644 --- a/apps/docs/client/src/content/latest/pt-br/introduction.mdx +++ b/apps/docs/client/src/content/latest/pt-br/introduction.mdx @@ -29,7 +29,7 @@ Escolha uma opção: ### Opção A: setup local com um comando (npx) ```bash -npx decocms +bunx decocms ``` ### Opção B: setup local com Docker Compose diff --git a/apps/docs/client/src/content/latest/pt-br/mcp-mesh/quickstart.mdx b/apps/docs/client/src/content/latest/pt-br/mcp-mesh/quickstart.mdx index 0556a51ab1..6329d72960 100644 --- a/apps/docs/client/src/content/latest/pt-br/mcp-mesh/quickstart.mdx +++ b/apps/docs/client/src/content/latest/pt-br/mcp-mesh/quickstart.mdx @@ -13,7 +13,7 @@ Escolha uma opção: ### Opção A: setup local com um comando (npx) ```bash -npx decocms +bunx decocms ``` ### Opção B: setup local com Docker Compose diff --git a/apps/mesh/scripts/dev.ts b/apps/mesh/scripts/dev.ts index 203e4f36dd..2778475c49 100644 --- a/apps/mesh/scripts/dev.ts +++ b/apps/mesh/scripts/dev.ts @@ -3,7 +3,7 @@ * Development environment setup script. * * Mirrors the CLI (src/cli.ts) behaviour so that `bun dev` and - * `npx decocms` / `deco` share the same ~/deco data directory, secrets, + * `bunx decocms` / `deco` share the same ~/deco data directory, secrets, * and local-mode defaults. * * After setting up the environment it spawns the regular dev pipeline: diff --git a/apps/mesh/src/cli.ts b/apps/mesh/src/cli.ts index 704965350e..5ec7ba7f1b 100644 --- a/apps/mesh/src/cli.ts +++ b/apps/mesh/src/cli.ts @@ -61,7 +61,7 @@ if (values.help) { Deco Studio - Open-source control plane for your AI agents Usage: - npx decocms [options] + bunx decocms [options] deco [options] Options: @@ -83,7 +83,7 @@ Environment Variables: CONFIG_PATH Path to full config file (default: ./config.json) Examples: - npx decocms # Start with defaults (~/deco/) + bunx decocms # Start with defaults (~/deco/) deco -p 8080 # Start on port 8080 deco --home ~/my-project # Custom data directory deco --no-local-mode # Require login (SaaS mode) diff --git a/packages/studio/README.md b/packages/studio/README.md index 8d60823979..8509065c0d 100644 --- a/packages/studio/README.md +++ b/packages/studio/README.md @@ -3,15 +3,10 @@ Open-source control plane for your AI agents. Install in 30 seconds. ```bash -npx decocms +bunx decocms ``` -Or install globally: - -```bash -npm i -g decocms -deco -``` +> Requires [Bun](https://bun.sh). Install with: `curl -fsSL https://bun.sh/install | bash` ## What is Deco Studio? @@ -40,7 +35,7 @@ Your AI Agents & MCP Clients ```bash # Start Deco Studio locally -npx decocms +bunx decocms # Opens at http://localhost:3000 ``` diff --git a/packages/studio/bin/deco.js b/packages/studio/bin/deco.js index 2f9e9c52f8..c864dde164 100755 --- a/packages/studio/bin/deco.js +++ b/packages/studio/bin/deco.js @@ -13,19 +13,19 @@ const meshPkgJson = require_.resolve("@decocms/mesh/package.json"); const meshDir = dirname(meshPkgJson); const meshBin = join(meshDir, "dist", "server", "cli.js"); -// The built CLI uses Bun APIs (Bun.file, Bun.serve), so we must run it with bun. -// Fall back to node if bun is not available (will fail on Bun-specific APIs). -const runtime = (() => { - try { - execFileSync("bun", ["--version"], { stdio: "ignore" }); - return "bun"; - } catch { - return process.execPath; - } -})(); +// The built CLI uses Bun APIs (Bun.file, Bun.serve), so bun is required. +try { + execFileSync("bun", ["--version"], { stdio: "ignore" }); +} catch { + console.error("Deco Studio requires Bun to run."); + console.error("Install it with: curl -fsSL https://bun.sh/install | bash"); + console.error(""); + console.error("Then run: bunx decocms"); + process.exit(1); +} try { - execFileSync(runtime, [meshBin, ...process.argv.slice(2)], { + execFileSync("bun", [meshBin, ...process.argv.slice(2)], { stdio: "inherit", }); } catch (e) { From 982eebfcfa8ef86163176feafc5963efd2b58594 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 5 Mar 2026 16:41:24 -0300 Subject: [PATCH 23/30] fix(docs): align section headings with bunx commands Update "(npx)" headings to "(bunx)" in quickstart docs and change "Install via npm" to "Install via Bun" in release workflow to match the actual bunx decocms commands. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/release.yaml | 2 +- .../src/content/draft/en/mcp-mesh/self-hosting/quickstart.mdx | 2 +- apps/docs/client/src/content/draft/pt-br/introduction.mdx | 2 +- .../docs/client/src/content/draft/pt-br/mcp-mesh/quickstart.mdx | 2 +- apps/docs/client/src/content/latest/en/introduction.mdx | 2 +- apps/docs/client/src/content/latest/en/mcp-mesh/quickstart.mdx | 2 +- apps/docs/client/src/content/latest/pt-br/introduction.mdx | 2 +- .../client/src/content/latest/pt-br/mcp-mesh/quickstart.mdx | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 421c6017c0..ce508bbf43 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -149,7 +149,7 @@ jobs: Open-source control plane for your AI agents. - ### Install via npm + ### Install via Bun ```bash bunx decocms@${{ steps.version-check.outputs.current-version }} ``` diff --git a/apps/docs/client/src/content/draft/en/mcp-mesh/self-hosting/quickstart.mdx b/apps/docs/client/src/content/draft/en/mcp-mesh/self-hosting/quickstart.mdx index 00c51a7219..31cae06261 100644 --- a/apps/docs/client/src/content/draft/en/mcp-mesh/self-hosting/quickstart.mdx +++ b/apps/docs/client/src/content/draft/en/mcp-mesh/self-hosting/quickstart.mdx @@ -10,7 +10,7 @@ import Callout from "../../../../../components/ui/Callout.astro"; Pick one: -### Option A: one-command local setup (npx) +### Option A: one-command local setup (bunx) ```bash bunx decocms diff --git a/apps/docs/client/src/content/draft/pt-br/introduction.mdx b/apps/docs/client/src/content/draft/pt-br/introduction.mdx index badfc30061..ec1585152f 100644 --- a/apps/docs/client/src/content/draft/pt-br/introduction.mdx +++ b/apps/docs/client/src/content/draft/pt-br/introduction.mdx @@ -25,7 +25,7 @@ Se você conheceu a gente antes, como **deco.cx**, e está buscando **headless C Escolha uma opção: -### Opção A: setup local com um comando (npx) +### Opção A: setup local com um comando (bunx) ```bash bunx decocms diff --git a/apps/docs/client/src/content/draft/pt-br/mcp-mesh/quickstart.mdx b/apps/docs/client/src/content/draft/pt-br/mcp-mesh/quickstart.mdx index 6329d72960..05a5cdf227 100644 --- a/apps/docs/client/src/content/draft/pt-br/mcp-mesh/quickstart.mdx +++ b/apps/docs/client/src/content/draft/pt-br/mcp-mesh/quickstart.mdx @@ -10,7 +10,7 @@ import Callout from "../../../../components/ui/Callout.astro"; Escolha uma opção: -### Opção A: setup local com um comando (npx) +### Opção A: setup local com um comando (bunx) ```bash bunx decocms diff --git a/apps/docs/client/src/content/latest/en/introduction.mdx b/apps/docs/client/src/content/latest/en/introduction.mdx index 690a71237c..626baf96a5 100644 --- a/apps/docs/client/src/content/latest/en/introduction.mdx +++ b/apps/docs/client/src/content/latest/en/introduction.mdx @@ -26,7 +26,7 @@ If you know us from before (as **deco.cx**) and you’re looking for **headless Choose one: -### Option A: one-command local setup (npx) +### Option A: one-command local setup (bunx) ```bash bunx decocms diff --git a/apps/docs/client/src/content/latest/en/mcp-mesh/quickstart.mdx b/apps/docs/client/src/content/latest/en/mcp-mesh/quickstart.mdx index 9c5f57d199..3c8e49ea0a 100644 --- a/apps/docs/client/src/content/latest/en/mcp-mesh/quickstart.mdx +++ b/apps/docs/client/src/content/latest/en/mcp-mesh/quickstart.mdx @@ -10,7 +10,7 @@ import Callout from "../../../../components/ui/Callout.astro"; Pick one: -### Option A: one-command local setup (npx) +### Option A: one-command local setup (bunx) ```bash bunx decocms diff --git a/apps/docs/client/src/content/latest/pt-br/introduction.mdx b/apps/docs/client/src/content/latest/pt-br/introduction.mdx index 9cc692f888..2cbdcbe3d5 100644 --- a/apps/docs/client/src/content/latest/pt-br/introduction.mdx +++ b/apps/docs/client/src/content/latest/pt-br/introduction.mdx @@ -26,7 +26,7 @@ Se você conheceu a gente antes, como **deco.cx**, e está buscando **headless C Escolha uma opção: -### Opção A: setup local com um comando (npx) +### Opção A: setup local com um comando (bunx) ```bash bunx decocms diff --git a/apps/docs/client/src/content/latest/pt-br/mcp-mesh/quickstart.mdx b/apps/docs/client/src/content/latest/pt-br/mcp-mesh/quickstart.mdx index 6329d72960..05a5cdf227 100644 --- a/apps/docs/client/src/content/latest/pt-br/mcp-mesh/quickstart.mdx +++ b/apps/docs/client/src/content/latest/pt-br/mcp-mesh/quickstart.mdx @@ -10,7 +10,7 @@ import Callout from "../../../../components/ui/Callout.astro"; Escolha uma opção: -### Opção A: setup local com um comando (npx) +### Opção A: setup local com um comando (bunx) ```bash bunx decocms From 89fb8ede0feaa9f5764632bf5b833bbbd174fe1e Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 5 Mar 2026 16:49:23 -0300 Subject: [PATCH 24/30] fix(dev): respect existing DATABASE_URL and fix path resolution - Use nullish coalescing for DATABASE_URL to not override user-provided values (e.g., PostgreSQL connection strings), matching cli.ts behavior - Replace brittle string replace with join(import.meta.dir, "..") for cross-platform path handling Co-Authored-By: Claude Opus 4.6 --- apps/mesh/scripts/dev.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/mesh/scripts/dev.ts b/apps/mesh/scripts/dev.ts index 2778475c49..5faee128a3 100644 --- a/apps/mesh/scripts/dev.ts +++ b/apps/mesh/scripts/dev.ts @@ -24,7 +24,7 @@ import { spawn } from "child_process"; // When MESH_HOME is explicitly set, respect it (CI, tests, custom setups). // Otherwise default to ~/deco for interactive dev. -const meshAppDir = import.meta.dir.replace("/scripts", ""); +const meshAppDir = join(import.meta.dir, ".."); const explicitHome = process.env.MESH_HOME; const userHome = join(homedir(), "deco"); // In CI / non-TTY without explicit MESH_HOME, use a repo-local directory @@ -141,7 +141,8 @@ if (secretsModified) { // ============================================================================ process.env.MESH_HOME = meshHome; -process.env.DATABASE_URL = `file:${join(meshHome, "mesh.db")}`; +process.env.DATABASE_URL = + process.env.DATABASE_URL ?? `file:${join(meshHome, "mesh.db")}`; process.env.MESH_LOCAL_MODE = process.env.MESH_LOCAL_MODE ?? "true"; // ============================================================================ From 7cecc736ae214515a6664a592332cc30475637b8 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 5 Mar 2026 17:02:27 -0300 Subject: [PATCH 25/30] =?UTF-8?q?fix:=20harden=20local-first=20DX=20?= =?UTF-8?q?=E2=80=94=20random=20password,=20seed=20gate,=20postMessage,=20?= =?UTF-8?q?prod=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract shared bootstrap module (scripts/bootstrap.ts) from cli.ts and dev.ts - Generate per-install LOCAL_ADMIN_PASSWORD in secrets.json instead of hardcoded value - Fix seed gate: resolve immediately when not in local mode (prevents hang) - Add .catch() to dynamic import chain in index.ts - Tighten postMessage target from "*" to getOAuthRedirectOrigin() - Refuse local mode in production (NODE_ENV=production) unless explicitly overridden Co-Authored-By: Claude Opus 4.6 --- apps/mesh/scripts/bootstrap.ts | 243 +++++++++++++++++++++++++ apps/mesh/scripts/dev.ts | 149 +++------------ apps/mesh/src/api/routes/auth.ts | 5 +- apps/mesh/src/auth/local-mode.ts | 40 +++- apps/mesh/src/cli.ts | 200 ++++---------------- apps/mesh/src/index.ts | 25 ++- packages/mesh-sdk/src/lib/mcp-oauth.ts | 10 +- 7 files changed, 372 insertions(+), 300 deletions(-) create mode 100644 apps/mesh/scripts/bootstrap.ts diff --git a/apps/mesh/scripts/bootstrap.ts b/apps/mesh/scripts/bootstrap.ts new file mode 100644 index 0000000000..2b3e4843a0 --- /dev/null +++ b/apps/mesh/scripts/bootstrap.ts @@ -0,0 +1,243 @@ +/** + * Shared bootstrap utilities for CLI and dev scripts. + * + * Extracted to avoid duplication between src/cli.ts and scripts/dev.ts. + * Both entry points resolve MESH_HOME, manage secrets, and print banners + * using these shared functions. + */ + +import { existsSync } from "fs"; +import { chmod, mkdir } from "fs/promises"; +import { createInterface } from "readline"; +import { homedir } from "os"; +import { join } from "path"; +import { randomBytes } from "crypto"; + +// ============================================================================ +// ANSI color codes +// ============================================================================ + +export const ansi = { + dim: "\x1b[2m", + reset: "\x1b[0m", + bold: "\x1b[1m", + cyan: "\x1b[36m", + yellow: "\x1b[33m", + green: "\x1b[32m", +} as const; + +// ============================================================================ +// Interactive prompt +// ============================================================================ + +export function prompt(question: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +// ============================================================================ +// Tilde expansion +// ============================================================================ + +/** + * Expand ~ and ~/path to the user's home directory. + * Does NOT expand ~user (returns as-is). + */ +export function expandTilde(path: string): string { + if (path === "~") return homedir(); + if (path.startsWith("~/")) return join(homedir(), path.slice(2)); + return path; +} + +// ============================================================================ +// Secrets management +// ============================================================================ + +export interface SecretsFile { + BETTER_AUTH_SECRET?: string; + ENCRYPTION_KEY?: string; + LOCAL_ADMIN_PASSWORD?: string; +} + +export interface SecretsResult { + secrets: SecretsFile; + betterAuthFromFile: boolean; + encryptionKeyFromFile: boolean; +} + +/** + * Load or generate secrets (BETTER_AUTH_SECRET, ENCRYPTION_KEY). + * Persists new secrets to MESH_HOME/secrets.json with 0600 permissions. + * Sets the corresponding environment variables. + */ +export async function loadOrCreateSecrets( + meshHome: string, +): Promise { + const secretsFilePath = join(meshHome, "secrets.json"); + + // Ensure MESH_HOME directory exists + await mkdir(meshHome, { recursive: true, mode: 0o700 }); + + // Try to load existing secrets from file + let savedSecrets: SecretsFile = {}; + try { + const file = Bun.file(secretsFilePath); + if (await file.exists()) { + savedSecrets = await file.json(); + } + } catch { + // File doesn't exist or is invalid, will create new secrets + } + + let betterAuthFromFile = false; + let encryptionKeyFromFile = false; + let secretsModified = false; + + if (!process.env.BETTER_AUTH_SECRET) { + if (savedSecrets.BETTER_AUTH_SECRET) { + process.env.BETTER_AUTH_SECRET = savedSecrets.BETTER_AUTH_SECRET; + } else { + savedSecrets.BETTER_AUTH_SECRET = randomBytes(32).toString("base64"); + process.env.BETTER_AUTH_SECRET = savedSecrets.BETTER_AUTH_SECRET; + secretsModified = true; + } + betterAuthFromFile = true; + } + + if (!process.env.ENCRYPTION_KEY) { + if (savedSecrets.ENCRYPTION_KEY) { + process.env.ENCRYPTION_KEY = savedSecrets.ENCRYPTION_KEY; + } else { + savedSecrets.ENCRYPTION_KEY = randomBytes(32).toString("base64"); + process.env.ENCRYPTION_KEY = savedSecrets.ENCRYPTION_KEY; + secretsModified = true; + } + encryptionKeyFromFile = true; + } + + // Generate a per-install local admin password if not already in secrets + if (!savedSecrets.LOCAL_ADMIN_PASSWORD) { + savedSecrets.LOCAL_ADMIN_PASSWORD = randomBytes(24).toString("base64"); + secretsModified = true; + } + + // Save secrets to file if we generated new ones + if (secretsModified) { + try { + await Bun.write(secretsFilePath, JSON.stringify(savedSecrets, null, 2)); + await chmod(secretsFilePath, 0o600); + } catch (error) { + console.warn( + `${ansi.yellow}Warning: Could not save secrets file: ${error}${ansi.reset}`, + ); + } + } + + return { secrets: savedSecrets, betterAuthFromFile, encryptionKeyFromFile }; +} + +// ============================================================================ +// MESH_HOME resolution +// ============================================================================ + +export interface ResolveMeshHomeOptions { + /** Explicitly provided path (e.g. --home flag or MESH_HOME env var) */ + explicit?: string; + /** Default path (usually ~/deco) */ + defaultPath: string; + /** Fallback for non-interactive environments (CI) — if not provided, uses defaultPath */ + ciFallback?: string; + /** Banner text for the first-run prompt */ + banner: string; +} + +/** + * Resolve MESH_HOME: uses explicit path, prompts on first run, or uses default. + */ +export async function resolveMeshHome( + opts: ResolveMeshHomeOptions, +): Promise { + if (opts.explicit) { + return expandTilde(opts.explicit); + } + + if (existsSync(opts.defaultPath)) { + return opts.defaultPath; + } + + if (!process.stdin.isTTY) { + return opts.ciFallback ?? opts.defaultPath; + } + + // First run — prompt the user + const displayDefault = opts.defaultPath.replace(homedir(), "~"); + console.log(""); + console.log(opts.banner); + console.log(""); + const answer = await prompt( + ` Where should Deco store its data? ${ansi.dim}(${displayDefault})${ansi.reset} `, + ); + + if (answer === "") return opts.defaultPath; + return expandTilde(answer); +} + +// ============================================================================ +// Startup banner +// ============================================================================ + +export interface BannerOptions { + meshHome: string; + localMode: boolean; + port?: string; + baseUrl?: string; + showSecretHint?: boolean; + showAssets?: boolean; + label?: string; +} + +export function printBanner(opts: BannerOptions): void { + const { dim, reset, bold, cyan } = ansi; + const displayHome = opts.meshHome.replace(homedir(), "~"); + const label = opts.label ?? "Deco Studio"; + + console.log(""); + console.log(`${bold}${cyan}${label}${reset}`); + console.log(`${dim}Open-source control plane for your AI agents${reset}`); + console.log(""); + + if (opts.showSecretHint) { + console.log( + `${dim}Using generated secrets from: ${displayHome}/secrets.json${reset}`, + ); + console.log( + `${dim}For production, set BETTER_AUTH_SECRET and ENCRYPTION_KEY env vars.${reset}`, + ); + console.log(""); + } +} + +export function printStatus(opts: BannerOptions): void { + const { dim, reset, bold, green } = ansi; + const displayHome = opts.meshHome.replace(homedir(), "~"); + + console.log(""); + console.log( + `${bold} Mode: ${opts.localMode ? `${green}Local${reset}${bold} (auto-login enabled)` : "Standard (login required)"}${reset}`, + ); + console.log(`${bold} Home: ${dim}${displayHome}/${reset}`); + console.log(`${bold} Database: ${dim}${displayHome}/mesh.db${reset}`); + if (opts.showAssets && opts.localMode) { + console.log(`${bold} Assets: ${dim}${displayHome}/assets/${reset}`); + } + const url = + opts.baseUrl || + `http://localhost:${opts.port || process.env.PORT || "3000"}`; + console.log(`${bold} URL: ${dim}${url}${reset}`); + console.log(""); +} diff --git a/apps/mesh/scripts/dev.ts b/apps/mesh/scripts/dev.ts index 5faee128a3..a7a3b4937d 100644 --- a/apps/mesh/scripts/dev.ts +++ b/apps/mesh/scripts/dev.ts @@ -10,131 +10,39 @@ * bun run migrate && concurrently "bun run dev:client" "bun run dev:server" */ -import { existsSync } from "fs"; -import { chmod, mkdir } from "fs/promises"; -import { createInterface } from "readline"; -import { homedir } from "os"; import { join } from "path"; -import { randomBytes } from "crypto"; import { spawn } from "child_process"; +import { + ansi, + loadOrCreateSecrets, + resolveMeshHome, + printBanner, + printStatus, +} from "./bootstrap"; + // ============================================================================ // Resolve MESH_HOME // ============================================================================ -// When MESH_HOME is explicitly set, respect it (CI, tests, custom setups). -// Otherwise default to ~/deco for interactive dev. const meshAppDir = join(import.meta.dir, ".."); -const explicitHome = process.env.MESH_HOME; -const userHome = join(homedir(), "deco"); +const userHome = join((await import("os")).homedir(), "deco"); // In CI / non-TTY without explicit MESH_HOME, use a repo-local directory // so tests never touch the developer's real ~/deco data. const ciHome = join(meshAppDir, ".mesh-dev"); -const dim = "\x1b[2m"; -const reset = "\x1b[0m"; -const bold = "\x1b[1m"; -const cyan = "\x1b[36m"; -const yellow = "\x1b[33m"; -const green = "\x1b[32m"; - -function prompt(question: string): Promise { - const rl = createInterface({ input: process.stdin, output: process.stdout }); - return new Promise((resolve) => { - rl.question(question, (answer) => { - rl.close(); - resolve(answer.trim()); - }); - }); -} - -let meshHome: string; - -if (explicitHome) { - // Explicit MESH_HOME takes priority (CI, tests, custom setups) - meshHome = explicitHome; -} else if (!process.stdin.isTTY) { - // Non-interactive (CI) — use repo-local directory to avoid touching ~/deco - meshHome = ciHome; -} else if (existsSync(userHome)) { - // Interactive with existing ~/deco — use it - meshHome = userHome; -} else { - // Interactive, first run — prompt for location - const displayDefault = userHome.replace(homedir(), "~"); - console.log(""); - console.log(`${bold}${cyan}Deco Studio${reset} ${dim}(dev)${reset}`); - console.log(""); - const answer = await prompt( - ` Where should Deco store its data? ${dim}(${displayDefault})${reset} `, - ); - if (answer === "") { - meshHome = userHome; - } else { - meshHome = - answer === "~" - ? homedir() - : answer.startsWith("~/") - ? join(homedir(), answer.slice(2)) - : answer; - } -} +const meshHome = await resolveMeshHome({ + explicit: process.env.MESH_HOME, + defaultPath: userHome, + ciFallback: ciHome, + banner: `${ansi.bold}${ansi.cyan}Deco Studio${ansi.reset} ${ansi.dim}(dev)${ansi.reset}`, +}); // ============================================================================ -// Secrets management (same logic as src/cli.ts) +// Secrets management // ============================================================================ -await mkdir(meshHome, { recursive: true, mode: 0o700 }); - -const secretsFilePath = join(meshHome, "secrets.json"); - -interface SecretsFile { - BETTER_AUTH_SECRET?: string; - ENCRYPTION_KEY?: string; -} - -let savedSecrets: SecretsFile = {}; -try { - const file = Bun.file(secretsFilePath); - if (await file.exists()) { - savedSecrets = await file.json(); - } -} catch { - // File doesn't exist or is invalid — will create new secrets -} - -let secretsModified = false; - -if (!process.env.BETTER_AUTH_SECRET) { - if (savedSecrets.BETTER_AUTH_SECRET) { - process.env.BETTER_AUTH_SECRET = savedSecrets.BETTER_AUTH_SECRET; - } else { - savedSecrets.BETTER_AUTH_SECRET = randomBytes(32).toString("base64"); - process.env.BETTER_AUTH_SECRET = savedSecrets.BETTER_AUTH_SECRET; - secretsModified = true; - } -} - -if (!process.env.ENCRYPTION_KEY) { - if (savedSecrets.ENCRYPTION_KEY) { - process.env.ENCRYPTION_KEY = savedSecrets.ENCRYPTION_KEY; - } else { - savedSecrets.ENCRYPTION_KEY = randomBytes(32).toString("base64"); - process.env.ENCRYPTION_KEY = savedSecrets.ENCRYPTION_KEY; - secretsModified = true; - } -} - -if (secretsModified) { - try { - await Bun.write(secretsFilePath, JSON.stringify(savedSecrets, null, 2)); - await chmod(secretsFilePath, 0o600); - } catch (error) { - console.warn( - `${yellow}Warning: Could not save secrets file: ${error}${reset}`, - ); - } -} +await loadOrCreateSecrets(meshHome); // ============================================================================ // Set environment variables @@ -149,20 +57,17 @@ process.env.MESH_LOCAL_MODE = process.env.MESH_LOCAL_MODE ?? "true"; // Banner // ============================================================================ -const displayHome = meshHome.replace(homedir(), "~"); +printBanner({ + meshHome, + localMode: true, + label: `Deco Studio ${ansi.dim}(dev)${ansi.reset}`, +}); -console.log(""); -console.log(`${bold}${cyan}Deco Studio${reset} ${dim}(dev)${reset}`); -console.log(""); -console.log( - `${bold} Mode: ${green}Local${reset}${bold} (auto-login enabled)${reset}`, -); -console.log(`${bold} Home: ${dim}${displayHome}/${reset}`); -console.log(`${bold} Database: ${dim}${displayHome}/mesh.db${reset}`); -if (process.env.BASE_URL) { - console.log(`${bold} URL: ${dim}${process.env.BASE_URL}${reset}`); -} -console.log(""); +printStatus({ + meshHome, + localMode: true, + baseUrl: process.env.BASE_URL, +}); // ============================================================================ // Spawn the dev pipeline diff --git a/apps/mesh/src/api/routes/auth.ts b/apps/mesh/src/api/routes/auth.ts index 2fd1c2d005..e0bbb37180 100644 --- a/apps/mesh/src/api/routes/auth.ts +++ b/apps/mesh/src/api/routes/auth.ts @@ -11,8 +11,8 @@ import { authConfig, resetPasswordEnabled } from "../../auth"; import { KNOWN_OAUTH_PROVIDERS, OAuthProvider } from "@/auth/oauth-providers"; import { getLocalAdminUser, + getLocalAdminPassword, isLocalMode, - LOCAL_ADMIN_PASSWORD, } from "@/auth/local-mode"; const app = new Hono(); @@ -161,10 +161,11 @@ app.post("/local-session", async (c) => { } // Sign in as the local admin user + const password = await getLocalAdminPassword(); const result = await auth.api.signInEmail({ body: { email: adminUser.email, - password: LOCAL_ADMIN_PASSWORD, + password, }, asResponse: true, }); diff --git a/apps/mesh/src/auth/local-mode.ts b/apps/mesh/src/auth/local-mode.ts index 547a35abe8..55d166e3d4 100644 --- a/apps/mesh/src/auth/local-mode.ts +++ b/apps/mesh/src/auth/local-mode.ts @@ -9,13 +9,32 @@ import { getDb } from "@/database"; import { userInfo } from "os"; +import { join } from "path"; import { auth } from "./index"; -// Internal password for the auto-seeded local admin user. -// Security note: this value is NOT the security boundary — the /local-session -// endpoint is protected by a loopback-only check (see auth routes), so only -// requests from localhost/127.0.0.1 can use it. -export const LOCAL_ADMIN_PASSWORD = "admin@mesh"; +/** + * Get the per-install local admin password from secrets.json. + * Falls back to a default if secrets.json is unavailable (should not happen + * in normal CLI flow since loadOrCreateSecrets() runs first). + */ +export async function getLocalAdminPassword(): Promise { + const meshHome = process.env.MESH_HOME; + if (meshHome) { + try { + const file = Bun.file(join(meshHome, "secrets.json")); + if (await file.exists()) { + const secrets = await file.json(); + if (secrets.LOCAL_ADMIN_PASSWORD) { + return secrets.LOCAL_ADMIN_PASSWORD; + } + } + } catch { + // Fall through to default + } + } + // Fallback — only reachable if secrets.json wasn't written yet + return "admin@mesh"; +} function getLocalUserName(): string { try { @@ -60,6 +79,7 @@ export async function seedLocalMode(): Promise { const username = getLocalUserName(); const email = `${username}@localhost.mesh`; const displayName = capitalize(username); + const password = await getLocalAdminPassword(); // Create admin user via Better Auth signup. // The databaseHooks.user.create.after hook in auth/index.ts will @@ -67,7 +87,7 @@ export async function seedLocalMode(): Promise { const signUpResult = await auth.api.signUpEmail({ body: { email, - password: LOCAL_ADMIN_PASSWORD, + password, name: displayName, }, }); @@ -126,10 +146,14 @@ export function isLocalMode(): boolean { return process.env.MESH_LOCAL_MODE === "true"; } -// Seed readiness gate — local-session waits for this before granting access +// Seed readiness gate — local-session waits for this before granting access. +// Resolves immediately if not in local mode (no seeding to wait for). let _seedResolve: () => void; const _seedReady = new Promise((resolve) => { _seedResolve = resolve; + if (!isLocalMode()) { + resolve(); + } }); /** Mark local-mode seeding as complete. Called from index.ts after seedLocalMode(). */ @@ -137,7 +161,7 @@ export function markSeedComplete(): void { _seedResolve(); } -/** Wait for local-mode seeding to finish. No-op if already complete. */ +/** Wait for local-mode seeding to finish. No-op if already complete or not in local mode. */ export function waitForSeed(): Promise { return _seedReady; } diff --git a/apps/mesh/src/cli.ts b/apps/mesh/src/cli.ts index 5ec7ba7f1b..de843dc76d 100644 --- a/apps/mesh/src/cli.ts +++ b/apps/mesh/src/cli.ts @@ -2,13 +2,11 @@ /** * Deco Studio CLI Entry Point * - * Deco Studio CLI Entry Point - * - * This script serves as the bin entry point for `npx decocms` / `deco`. + * This script serves as the bin entry point for `bunx decocms` / `deco`. * It runs database migrations, seeds the local environment, and starts the server. * * Usage: - * npx decocms + * bunx decocms * deco --port 8080 * deco --home ~/my-mesh * deco --no-local-mode @@ -19,7 +17,14 @@ import { parseArgs } from "util"; import { homedir } from "os"; import { join } from "path"; import { existsSync } from "fs"; -import { createInterface } from "readline"; + +import { + ansi, + loadOrCreateSecrets, + resolveMeshHome, + printBanner, + printStatus, +} from "../scripts/bootstrap"; const defaultHome = process.env.MESH_HOME || join(homedir(), "deco"); @@ -122,68 +127,17 @@ if (values.version) { // Setup environment // ============================================================================ -// Set PORT environment variable for the server process.env.PORT = values.port; -// ANSI color codes (needed early for the prompt) -const dim = "\x1b[2m"; -const reset = "\x1b[0m"; -const bold = "\x1b[1m"; -const cyan = "\x1b[36m"; -const yellow = "\x1b[33m"; -const green = "\x1b[32m"; - // ============================================================================ -// Resolve MESH_HOME — prompt on first run if using default +// Resolve MESH_HOME // ============================================================================ -/** - * Prompt the user for input via readline. - */ -function prompt(question: string): Promise { - const rl = createInterface({ input: process.stdin, output: process.stdout }); - return new Promise((resolve) => { - rl.question(question, (answer) => { - rl.close(); - resolve(answer.trim()); - }); - }); -} - -let meshHome: string; - -if (values.home) { - // Explicitly passed via --home flag — expand ~ to home directory - meshHome = values.home.startsWith("~") - ? join(homedir(), values.home.slice(1)) - : values.home; -} else if (existsSync(defaultHome)) { - // Default directory already exists (not first run) - meshHome = defaultHome; -} else if (!process.stdin.isTTY) { - // Non-interactive (Docker, CI, systemd) — use default without prompting - meshHome = defaultHome; -} else { - // First run with default path — ask the user - const displayDefault = defaultHome.replace(homedir(), "~"); - console.log(""); - console.log(`${bold}${cyan}Deco Studio${reset}`); - console.log(""); - const answer = await prompt( - ` Where should Deco store its data? ${dim}(${displayDefault})${reset} `, - ); - if (answer === "") { - meshHome = defaultHome; - } else { - // Expand ~ to home directory (only bare ~ or ~/path, not ~user) - meshHome = - answer === "~" - ? homedir() - : answer.startsWith("~/") - ? join(homedir(), answer.slice(2)) - : answer; - } -} +const meshHome = await resolveMeshHome({ + explicit: values.home || process.env.MESH_HOME, + defaultPath: defaultHome, + banner: `${ansi.bold}${ansi.cyan}Deco Studio${ansi.reset}`, +}); process.env.MESH_HOME = meshHome; @@ -208,93 +162,27 @@ const hasCustomAuthConfig = const localMode = !values["no-local-mode"] && !hasCustomAuthConfig; process.env.MESH_LOCAL_MODE = localMode ? "true" : "false"; +// CLI is the intended local runner — allow local mode even when NODE_ENV=production +if (localMode) { + process.env.MESH_ALLOW_LOCAL_PROD = "true"; +} + // ============================================================================ // Secrets management // ============================================================================ -const secretsFilePath = join(meshHome, "secrets.json"); - -const crypto = await import("crypto"); -const { mkdir, chmod } = await import("fs/promises"); - -interface SecretsFile { - BETTER_AUTH_SECRET?: string; - ENCRYPTION_KEY?: string; -} - -// Ensure MESH_HOME directory exists -await mkdir(meshHome, { recursive: true, mode: 0o700 }); - -// Try to load existing secrets from file -let savedSecrets: SecretsFile = {}; -try { - const file = Bun.file(secretsFilePath); - if (await file.exists()) { - savedSecrets = await file.json(); - } -} catch { - // File doesn't exist or is invalid, will create new secrets -} - -// Track which secrets are from file vs env (independently) -let betterAuthFromFile = false; -let encryptionKeyFromFile = false; -let secretsModified = false; - -if (!process.env.BETTER_AUTH_SECRET) { - if (savedSecrets.BETTER_AUTH_SECRET) { - process.env.BETTER_AUTH_SECRET = savedSecrets.BETTER_AUTH_SECRET; - } else { - savedSecrets.BETTER_AUTH_SECRET = crypto.randomBytes(32).toString("base64"); - process.env.BETTER_AUTH_SECRET = savedSecrets.BETTER_AUTH_SECRET; - secretsModified = true; - } - betterAuthFromFile = true; -} - -if (!process.env.ENCRYPTION_KEY) { - if (savedSecrets.ENCRYPTION_KEY) { - process.env.ENCRYPTION_KEY = savedSecrets.ENCRYPTION_KEY; - } else { - savedSecrets.ENCRYPTION_KEY = crypto.randomBytes(32).toString("base64"); - process.env.ENCRYPTION_KEY = savedSecrets.ENCRYPTION_KEY; - secretsModified = true; - } - encryptionKeyFromFile = true; -} - -// Save secrets to file if we generated new ones -if (secretsModified) { - try { - await Bun.write(secretsFilePath, JSON.stringify(savedSecrets, null, 2)); - await chmod(secretsFilePath, 0o600); - } catch (error) { - console.warn( - `${yellow}Warning: Could not save secrets file: ${error}${reset}`, - ); - } -} +const { betterAuthFromFile, encryptionKeyFromFile } = + await loadOrCreateSecrets(meshHome); // ============================================================================ // Startup banner // ============================================================================ -const displayHome = meshHome.replace(homedir(), "~"); - -console.log(""); -console.log(`${bold}${cyan}Deco Studio${reset}`); -console.log(`${dim}Open-source control plane for your AI agents${reset}`); -console.log(""); - -if (betterAuthFromFile || encryptionKeyFromFile) { - console.log( - `${dim}Using generated secrets from: ${displayHome}/secrets.json${reset}`, - ); - console.log( - `${dim}For production, set BETTER_AUTH_SECRET and ENCRYPTION_KEY env vars.${reset}`, - ); - console.log(""); -} +printBanner({ + meshHome, + localMode, + showSecretHint: betterAuthFromFile || encryptionKeyFromFile, +}); // ============================================================================ // Build frontend if needed (when running from source) @@ -306,7 +194,7 @@ if (betterAuthFromFile || encryptionKeyFromFile) { const clientIndexPath = join(clientDistDir, "index.html"); if (!existsSync(clientIndexPath)) { - console.log(`${dim}Building frontend (first run)...${reset}`); + console.log(`${ansi.dim}Building frontend (first run)...${ansi.reset}`); const { execSync } = await import("child_process"); // Resolve apps/mesh directory — works whether running from src/ or dist/server/ const meshAppDir = existsSync(join(scriptDir, "../vite.config.ts")) @@ -321,10 +209,10 @@ if (betterAuthFromFile || encryptionKeyFromFile) { cwd: meshAppDir, stdio: ["ignore", "pipe", "pipe"], }); - console.log(`${dim}Frontend build complete.${reset}`); - } catch (error) { + console.log(`${ansi.dim}Frontend build complete.${ansi.reset}`); + } catch { console.warn( - `${yellow}Warning: Could not build frontend. UI may not be available.${reset}`, + `${ansi.yellow}Warning: Could not build frontend. UI may not be available.${ansi.reset}`, ); } } @@ -336,11 +224,11 @@ if (betterAuthFromFile || encryptionKeyFromFile) { // ============================================================================ if (!values["skip-migrations"]) { - console.log(`${dim}Running database migrations...${reset}`); + console.log(`${ansi.dim}Running database migrations...${ansi.reset}`); try { const { migrateToLatest } = await import("./database/migrate"); await migrateToLatest({ keepOpen: true }); - console.log(`${dim}Migrations complete.${reset}`); + console.log(`${ansi.dim}Migrations complete.${ansi.reset}`); } catch (error) { console.error("Failed to run migrations:", error); process.exit(1); @@ -351,20 +239,12 @@ if (!values["skip-migrations"]) { // Print final status and start server // ============================================================================ -const port = values.port; -console.log(""); -console.log( - `${bold} Mode: ${localMode ? `${green}Local${reset}${bold} (auto-login enabled)` : "Standard (login required)"}${reset}`, -); -console.log(`${bold} Home: ${dim}${displayHome}/${reset}`); -console.log(`${bold} Database: ${dim}${displayHome}/mesh.db${reset}`); -if (localMode) { - console.log(`${bold} Assets: ${dim}${displayHome}/assets/${reset}`); -} -console.log( - `${bold} URL: ${dim}${process.env.BASE_URL || `http://localhost:${port}`}${reset}`, -); -console.log(""); +printStatus({ + meshHome, + localMode, + port: values.port, + showAssets: true, +}); // Import and start the server await import("./index"); diff --git a/apps/mesh/src/index.ts b/apps/mesh/src/index.ts index beeaab3834..2f6b930a97 100644 --- a/apps/mesh/src/index.ts +++ b/apps/mesh/src/index.ts @@ -31,6 +31,21 @@ const underline = "\x1b[4m"; const url = process.env.BASE_URL || `http://localhost:${port}`; +// Refuse local mode in production — it disables authentication +if ( + process.env.MESH_LOCAL_MODE === "true" && + process.env.NODE_ENV === "production" && + !process.env.MESH_ALLOW_LOCAL_PROD +) { + console.error( + "\x1b[31mError: Local mode is not allowed in production (NODE_ENV=production).\x1b[0m", + ); + console.error( + "Set MESH_ALLOW_LOCAL_PROD=true to override (not recommended).", + ); + process.exit(1); +} + // Create asset handler - handles both dev proxy and production static files // When running from source (src/index.ts), the "../client" relative path // doesn't resolve to dist/client/. Fall back to dist/client/ relative to CWD. @@ -72,8 +87,8 @@ Bun.serve({ // This must run after Bun.serve() so that the org seed can fetch tools // from the self MCP endpoint (http://localhost:PORT/mcp/self) if (process.env.MESH_LOCAL_MODE === "true") { - import("./auth/local-mode").then( - async ({ seedLocalMode, markSeedComplete }) => { + import("./auth/local-mode") + .then(async ({ seedLocalMode, markSeedComplete }) => { try { const seeded = await seedLocalMode(); if (seeded) { @@ -84,8 +99,10 @@ if (process.env.MESH_LOCAL_MODE === "true") { } finally { markSeedComplete(); } - }, - ); + }) + .catch((error) => { + console.error("Failed to load local-mode module:", error); + }); } // Internal debug server (only enabled via ENABLE_DEBUG_SERVER=true) diff --git a/packages/mesh-sdk/src/lib/mcp-oauth.ts b/packages/mesh-sdk/src/lib/mcp-oauth.ts index ded2e2ecea..e09276a304 100644 --- a/packages/mesh-sdk/src/lib/mcp-oauth.ts +++ b/packages/mesh-sdk/src/lib/mcp-oauth.ts @@ -554,10 +554,12 @@ function sendCallbackData( ): boolean { // Try postMessage first (primary method) if (window.opener && !window.opener.closed) { - // Use "*" for local dev where redirect URI (localhost:PORT) may differ - // from the opener origin (e.g. proxy.localhost). This is safe because - // the data is just an OAuth code already visible in the URL. - const target = isLocalDev() ? "*" : window.location.origin; + // In local dev the redirect URI (localhost:PORT) may differ from the + // opener origin (e.g. proxy.localhost). Use the configured redirect origin + // so the message is scoped to a known origin rather than "*". + const target = isLocalDev() + ? getOAuthRedirectOrigin() + : window.location.origin; window.opener.postMessage(data, target); return true; } From fd0ce938dafae022f7fa1271200d7b526199779a Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 5 Mar 2026 17:28:27 -0300 Subject: [PATCH 26/30] fix: security hardening, OAuth flow extraction, and deferred debt tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove hardcoded "admin@mesh" fallback password — throw on missing secrets.json - Fix seed gate hang when local-mode module import fails (markSeedComplete in catch) - Fix directory traversal in sanitizeKey() — use path.resolve() + containment check - Unify DEV_ASSETS_BASE_DIR to use MESH_HOME in both dev-assets files - Use crypto.timingSafeEqual() for HMAC signature verification - Remove production console.log leaking taskId on every stream resume - Cherry-pick OAuth flow extraction from feat/oauth-flow-extraction - Update DEBT.md with deferred review issues from merged PRs Co-Authored-By: Claude Opus 4.6 --- DEBT.md | 87 +++++++++++++++---- apps/mesh/src/api/routes/dev-assets-mcp.ts | 19 ++-- apps/mesh/src/api/routes/dev-assets.ts | 32 ++++--- apps/mesh/src/auth/local-mode.ts | 8 +- apps/mesh/src/index.ts | 10 ++- apps/mesh/src/web/components/chat/context.tsx | 3 +- .../chat/no-llm-binding-empty-state.tsx | 58 +++++++------ .../components/details/connection/index.tsx | 77 ++-------------- .../src/web/lib/authenticate-connection.ts | 82 +++++++++++++++++ 9 files changed, 240 insertions(+), 136 deletions(-) create mode 100644 apps/mesh/src/web/lib/authenticate-connection.ts diff --git a/DEBT.md b/DEBT.md index b552d43b5b..9d04bec468 100644 --- a/DEBT.md +++ b/DEBT.md @@ -13,15 +13,6 @@ disabled. - Relevant code: `apps/mesh/src/api/app.ts` (NATS_URL detection), `apps/mesh/src/api/routes/decopilot/stream-buffer.ts` (NoOpStreamBuffer fallback) -## OpenRouter auto-auth flow - -The auto-auth flow (install OpenRouter → OAuth popup → chat ready) lives on -`feat/local-first-dx` branch (commit `bacda6781`) and is not yet in -`feat/local-first-dx-core`. Needs to be cherry-picked or merged. - -- Key files: `apps/mesh/src/web/lib/authenticate-connection.ts`, - `apps/mesh/src/web/components/chat/no-llm-binding-empty-state.tsx` - ## decocms package rename `packages/studio/` publishes as `decocms` on npm (`bunx decocms` / `deco` CLI). @@ -29,15 +20,79 @@ It's a thin wrapper that depends on `@decocms/mesh`. Eventually the source should move to `decocms` as the canonical package and `@decocms/mesh` becomes the wrapper (or is deprecated). -## Password migration for local admin - -The local admin password (`admin@mesh`) is hardcoded. The loopback-only check on -`/local-session` is the real security boundary, so this is low-risk. If we ever -want per-install passwords, derive from `BETTER_AUTH_SECRET` and add a migration -path that updates the stored hash on boot. - ## Playwright e2e excluded from `bun test` The Playwright e2e test (`apps/mesh/src/**/*.e2e.test.ts`) is picked up by `bun test` and fails because `@playwright/test` conflicts with bun's test runner. Should be excluded via bun test config or file naming convention. + +--- + +## Items from PR review (deferred — belong to already-merged PRs) + +### PROJECT_PINNED_VIEWS_UPDATE missing org ownership check (PR #2567) + +The tool calls `requireAuth(ctx)` + `ctx.access.check()` but does not call +`requireOrganization(ctx)` or validate `project.organizationId !== organization.id`. +An authenticated user from org A could update pinned views on org B's project. + +- File: `apps/mesh/src/tools/projects/pinned-views-update.ts` + +### N+1 query in PROJECT_CONNECTION_LIST (PR #2567) + +Each connection is fetched individually via `findById` inside `Promise.all(map(...))`. +No `findByIds` batch method exists on `ConnectionStorage`. + +- Fix: Add `findByIds(ids: string[])` using `WHERE id IN (...)` +- File: `apps/mesh/src/tools/projects/connection-list.ts:52-56` + +### COLLECTION_CONNECTIONS_GET write side-effect in readOnly tool (PR #2567) + +The GET handler (annotated `readOnlyHint: true`) backfills missing tools via +`fetchToolsFromMCP` with a 2s timeout and calls `ctx.storage.connections.update()`. +A read operation should not have write side-effects. + +- Fix: Move backfill to an explicit tool or post-OAuth event trigger +- File: `apps/mesh/src/tools/connection/get.ts` + +### TaskStreamManager useSyncExternalStore misuse (PR #2563) + +`subscribe` is a new function reference every render, causing interval leaks. +`useSyncExternalStore` is used as a lifecycle hook rather than for external store +subscription, which is semantically incorrect under React 19. + +- Fix: Stabilize `subscribe` via `useRef` or restructure +- File: `apps/mesh/src/web/components/chat/context.tsx` + +### ResizeObserver memory leak in monitoring dashboard (PR #2554) + +Inline ref callback creates a new `ResizeObserver` on every render without +disconnecting the old one. Use React 19 ref callback cleanup: +`return () => observer.disconnect();` + +- File: monitoring dashboard component (TBD exact location) + +### Monitoring fetches 2000 raw rows to browser (PR #2554) + +Dashboard fetches 2000 full `MonitoringLog` rows (including `input`/`output` JSON) +to the browser for client-side bucketing. Only 5 scalar fields are needed. + +- Fix: Push aggregation server-side or add field projection to `MONITORING_LOGS_LIST` +- File: `apps/mesh/src/web/components/monitoring/hooks.ts:37` + +### ondownloadfile handler should validate URI scheme (PR #2571) + +`window.open(item.uri, "_blank")` accepts arbitrary URIs including `javascript:`. +Validate scheme is `http:` or `https:` before calling `window.open`. + +- File: `apps/mesh/src/mcp-apps/use-app-bridge.ts` + +### Thread/Task naming asymmetry + +UI uses "task" but backend protocol uses "thread" everywhere (`COLLECTION_THREADS_LIST`, +`thread_id`, etc.). Either rename fully or document the mapping explicitly. + +### Large component files should be split + +- `apps/mesh/src/web/components/settings-modal/pages/org-billing.tsx` (1,732 lines) +- `apps/mesh/src/web/routes/orgs/monitoring.tsx` (1,510 lines) diff --git a/apps/mesh/src/api/routes/dev-assets-mcp.ts b/apps/mesh/src/api/routes/dev-assets-mcp.ts index f7a8d2bec9..8f4301ac54 100644 --- a/apps/mesh/src/api/routes/dev-assets-mcp.ts +++ b/apps/mesh/src/api/routes/dev-assets-mcp.ts @@ -27,7 +27,7 @@ import { import { Hono } from "hono"; import { createHmac } from "node:crypto"; import { mkdir, readdir, rm, stat } from "node:fs/promises"; -import { join, relative } from "node:path"; +import { join, relative, resolve, sep } from "node:path"; import { z } from "zod"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; @@ -86,21 +86,26 @@ function getOrgAssetsDir(orgId: string): string { } /** - * Sanitize a file key to prevent directory traversal + * Sanitize a file key — strips leading slashes only. + * Actual traversal prevention is enforced by getFilePath's containment check. */ function sanitizeKey(key: string): string { - // Remove leading slashes and normalize path - const normalized = key.replace(/^\/+/, "").replace(/\.\./g, ""); - return normalized; + return key.replace(/^\/+/, ""); } /** - * Get the full file path for a key within an org's assets + * Get the full file path for a key within an org's assets. + * Throws if the resolved path escapes the org's base directory. */ function getFilePath(orgId: string, key: string): string { const baseDir = getOrgAssetsDir(orgId); const sanitizedKey = sanitizeKey(key); - return join(baseDir, sanitizedKey); + const resolved = resolve(join(baseDir, sanitizedKey)); + const realBase = resolve(baseDir); + if (resolved !== realBase && !resolved.startsWith(realBase + sep)) { + throw new Error("Path traversal detected"); + } + return resolved; } /** diff --git a/apps/mesh/src/api/routes/dev-assets.ts b/apps/mesh/src/api/routes/dev-assets.ts index 17592d7e40..4c0a5ac94f 100644 --- a/apps/mesh/src/api/routes/dev-assets.ts +++ b/apps/mesh/src/api/routes/dev-assets.ts @@ -12,12 +12,15 @@ */ import { Hono } from "hono"; -import { createHmac } from "node:crypto"; +import { createHmac, timingSafeEqual } from "node:crypto"; import { mkdir, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; +import { dirname, join, resolve, sep } from "node:path"; -// Base directory for dev assets (relative to cwd) -const DEV_ASSETS_BASE_DIR = "./data/assets"; +// Base directory for assets. +// Uses MESH_HOME/assets when available (local mode), falls back to ./data/assets +const DEV_ASSETS_BASE_DIR = process.env.MESH_HOME + ? `${process.env.MESH_HOME}/assets` + : "./data/assets"; const app = new Hono(); @@ -35,21 +38,26 @@ function getOrgAssetsDir(orgId: string): string { } /** - * Sanitize a file key to prevent directory traversal + * Sanitize a file key — strips leading slashes only. + * Actual traversal prevention is enforced by getFilePath's containment check. */ function sanitizeKey(key: string): string { - // Remove leading slashes and normalize path - const normalized = key.replace(/^\/+/, "").replace(/\.\./g, ""); - return normalized; + return key.replace(/^\/+/, ""); } /** - * Get the full file path for a key within an org's assets + * Get the full file path for a key within an org's assets. + * Throws if the resolved path escapes the org's base directory. */ function getFilePath(orgId: string, key: string): string { const baseDir = getOrgAssetsDir(orgId); const sanitizedKey = sanitizeKey(key); - return join(baseDir, sanitizedKey); + const resolved = resolve(join(baseDir, sanitizedKey)); + const realBase = resolve(baseDir); + if (resolved !== realBase && !resolved.startsWith(realBase + sep)) { + throw new Error("Path traversal detected"); + } + return resolved; } /** @@ -67,7 +75,9 @@ function verifySignature( const expectedSignature = createHmac("sha256", secret) .update(data) .digest("hex"); - return signature === expectedSignature; + const expected = Buffer.from(expectedSignature, "hex"); + const actual = Buffer.from(signature, "hex"); + return expected.length === actual.length && timingSafeEqual(expected, actual); } /** diff --git a/apps/mesh/src/auth/local-mode.ts b/apps/mesh/src/auth/local-mode.ts index 55d166e3d4..0f490d9c78 100644 --- a/apps/mesh/src/auth/local-mode.ts +++ b/apps/mesh/src/auth/local-mode.ts @@ -32,8 +32,12 @@ export async function getLocalAdminPassword(): Promise { // Fall through to default } } - // Fallback — only reachable if secrets.json wasn't written yet - return "admin@mesh"; + // No fallback — if secrets.json is unavailable, fail loudly rather than + // using a well-known credential that is published in the source repo. + throw new Error( + "Local admin password unavailable — secrets.json was not initialized. " + + "Ensure loadOrCreateSecrets() runs before the server starts (the CLI does this automatically).", + ); } function getLocalUserName(): string { diff --git a/apps/mesh/src/index.ts b/apps/mesh/src/index.ts index 2f6b930a97..b0d1a43c9e 100644 --- a/apps/mesh/src/index.ts +++ b/apps/mesh/src/index.ts @@ -100,8 +100,16 @@ if (process.env.MESH_LOCAL_MODE === "true") { markSeedComplete(); } }) - .catch((error) => { + .catch(async (error) => { console.error("Failed to load local-mode module:", error); + // Still release the seed gate so /local-session doesn't hang forever + try { + const { markSeedComplete } = await import("./auth/local-mode"); + markSeedComplete(); + } catch { + // Module itself failed to load — gate was never armed (isLocalMode() + // would have resolved it immediately in the Promise constructor) + } }); } diff --git a/apps/mesh/src/web/components/chat/context.tsx b/apps/mesh/src/web/components/chat/context.tsx index 8e5497413d..f7606e3e93 100644 --- a/apps/mesh/src/web/components/chat/context.tsx +++ b/apps/mesh/src/web/components/chat/context.tsx @@ -603,12 +603,11 @@ function TaskStreamManager({ } }; - const tryResumeStream = (reason: string) => { + const tryResumeStream = (_reason: string) => { if (!taskId || hasResumedRef.current === taskId) return; if (resumeFailCountRef.current >= MAX_RESUME_RETRIES) return; hasResumedRef.current = taskId; - console.log(`[chat] resumeStream (${reason})`, taskId); chatRef.current.resumeStream().catch((err: unknown) => { console.error("[chat] resumeStream error", err); resumeFailCountRef.current++; diff --git a/apps/mesh/src/web/components/chat/no-llm-binding-empty-state.tsx b/apps/mesh/src/web/components/chat/no-llm-binding-empty-state.tsx index 8b2a8ab31d..46b6ac08f1 100644 --- a/apps/mesh/src/web/components/chat/no-llm-binding-empty-state.tsx +++ b/apps/mesh/src/web/components/chat/no-llm-binding-empty-state.tsx @@ -1,4 +1,5 @@ import { useNavigate } from "@tanstack/react-router"; +import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { Button } from "@deco/ui/components/button.tsx"; import { EmptyState } from "../empty-state"; @@ -11,7 +12,7 @@ import { } from "@decocms/mesh-sdk"; import { generatePrefixedId } from "@/shared/utils/generate-id"; import { authClient } from "@/web/lib/auth-client"; -import { useDecoChatOpen } from "@/web/hooks/use-deco-chat-open"; +import { authenticateConnection } from "@/web/lib/authenticate-connection"; interface NoLlmBindingEmptyStateProps { title?: string; @@ -29,8 +30,8 @@ export function NoLlmBindingEmptyState({ org, }: NoLlmBindingEmptyStateProps) { const actions = useConnectionActions(); - const [, setDecoChatOpen] = useDecoChatOpen(); const navigate = useNavigate(); + const queryClient = useQueryClient(); const { data: session } = authClient.useSession(); const allConnections = useConnections(); @@ -56,35 +57,36 @@ export function NoLlmBindingEmptyState({ (conn) => conn.connection_url === OPENROUTER_MCP_URL, ); - if (existingConnection) { - setDecoChatOpen(false); - navigate({ - to: "/$org/$project/mcps/$connectionId", - params: { - org: org.slug, - project: ORG_ADMIN_PROJECT_SLUG, - connectionId: existingConnection.id, - }, - }); - return; - } + const connectionId = existingConnection?.id ?? null; - // Create new OpenRouter connection - const connectionData = getWellKnownOpenRouterConnection({ - id: generatePrefixedId("conn"), - }); + if (!connectionId) { + // Create new OpenRouter connection + const connectionData = getWellKnownOpenRouterConnection({ + id: generatePrefixedId("conn"), + }); - const result = await actions.create.mutateAsync(connectionData); + const result = await actions.create.mutateAsync(connectionData); - setDecoChatOpen(false); - navigate({ - to: "/$org/$project/mcps/$connectionId", - params: { - org: org.slug, - project: ORG_ADMIN_PROJECT_SLUG, - connectionId: result.id, - }, - }); + // Immediately trigger OAuth auth — opens popup, stays on Home + const success = await authenticateConnection( + result.id, + actions, + queryClient, + ); + if (success) { + toast.success("OpenRouter connected successfully"); + } + } else { + // Connection exists but may not be authenticated — trigger auth + const success = await authenticateConnection( + connectionId, + actions, + queryClient, + ); + if (success) { + toast.success("OpenRouter authenticated successfully"); + } + } } catch (error) { toast.error( `Failed to connect OpenRouter: ${error instanceof Error ? error.message : String(error)}`, diff --git a/apps/mesh/src/web/components/details/connection/index.tsx b/apps/mesh/src/web/components/details/connection/index.tsx index 4ead3b1d07..da0bd43f24 100644 --- a/apps/mesh/src/web/components/details/connection/index.tsx +++ b/apps/mesh/src/web/components/details/connection/index.tsx @@ -12,7 +12,7 @@ import { useCollectionBindings, } from "@/web/hooks/use-binding"; import { useMCPAuthStatus } from "@/web/hooks/use-mcp-auth-status"; -import { authenticateMcp } from "@/web/lib/mcp-oauth"; +import { authenticateConnection } from "@/web/lib/authenticate-connection"; import { KEYS } from "@/web/lib/query-keys"; import { Breadcrumb, @@ -339,75 +339,14 @@ function ConnectionInspectorViewWithConnection({ }; const handleAuthenticate = async () => { - const { token, tokenInfo, error } = await authenticateMcp({ - connectionId: connection.id, - }); - if (error || !token) { - toast.error(`Authentication failed: ${error}`); - return; - } - - if (tokenInfo) { - try { - const response = await fetch( - `/api/connections/${connection.id}/oauth-token`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify({ - accessToken: tokenInfo.accessToken, - refreshToken: tokenInfo.refreshToken, - expiresIn: tokenInfo.expiresIn, - scope: tokenInfo.scope, - clientId: tokenInfo.clientId, - clientSecret: tokenInfo.clientSecret, - tokenEndpoint: tokenInfo.tokenEndpoint, - }), - }, - ); - if (!response.ok) { - console.error("Failed to save OAuth token:", await response.text()); - await connectionActions.update.mutateAsync({ - id: connection.id, - data: { connection_token: token }, - }); - } else { - try { - await connectionActions.update.mutateAsync({ - id: connection.id, - data: {}, - }); - } catch (err) { - console.warn( - "Failed to refresh connection tools after OAuth:", - err, - ); - } - } - } catch (err) { - console.error("Error saving OAuth token:", err); - await connectionActions.update.mutateAsync({ - id: connection.id, - data: { connection_token: token }, - }); - } - } else { - await connectionActions.update.mutateAsync({ - id: connection.id, - data: { connection_token: token }, - }); - } - - const mcpProxyUrl = new URL( - `/mcp/${connection.id}`, - window.location.origin, + const success = await authenticateConnection( + connection.id, + connectionActions, + queryClient, ); - await queryClient.invalidateQueries({ - queryKey: KEYS.isMCPAuthenticated(mcpProxyUrl.href, null), - }); - - toast.success("Authentication successful"); + if (success) { + toast.success("Authentication successful"); + } }; const handleRemoveOAuth = async () => { diff --git a/apps/mesh/src/web/lib/authenticate-connection.ts b/apps/mesh/src/web/lib/authenticate-connection.ts new file mode 100644 index 0000000000..5e9dae19bf --- /dev/null +++ b/apps/mesh/src/web/lib/authenticate-connection.ts @@ -0,0 +1,82 @@ +import { authenticateMcp } from "@/web/lib/mcp-oauth"; +import { KEYS } from "@/web/lib/query-keys"; +import type { QueryClient } from "@tanstack/react-query"; +import type { useConnectionActions } from "@decocms/mesh-sdk"; +import { toast } from "sonner"; + +/** + * Runs the full OAuth authentication flow for an MCP connection: + * 1. Opens the OAuth popup via authenticateMcp() + * 2. Saves the token via the OAuth endpoint (or falls back to connection_token) + * 3. Invalidates auth-related queries so the UI refreshes + * + * Returns true on success, false on failure. + */ +export async function authenticateConnection( + connectionId: string, + connectionActions: ReturnType, + queryClient: QueryClient, +): Promise { + const { token, tokenInfo, error } = await authenticateMcp({ connectionId }); + + if (error || !token) { + toast.error(`Authentication failed: ${error}`); + return false; + } + + if (tokenInfo) { + try { + const response = await fetch( + `/api/connections/${connectionId}/oauth-token`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + accessToken: tokenInfo.accessToken, + refreshToken: tokenInfo.refreshToken, + expiresIn: tokenInfo.expiresIn, + scope: tokenInfo.scope, + clientId: tokenInfo.clientId, + clientSecret: tokenInfo.clientSecret, + tokenEndpoint: tokenInfo.tokenEndpoint, + }), + }, + ); + if (!response.ok) { + console.error("Failed to save OAuth token:", await response.text()); + await connectionActions.update.mutateAsync({ + id: connectionId, + data: { connection_token: token }, + }); + } else { + try { + await connectionActions.update.mutateAsync({ + id: connectionId, + data: {}, + }); + } catch (err) { + console.warn("Failed to refresh connection tools after OAuth:", err); + } + } + } catch (err) { + console.error("Error saving OAuth token:", err); + await connectionActions.update.mutateAsync({ + id: connectionId, + data: { connection_token: token }, + }); + } + } else { + await connectionActions.update.mutateAsync({ + id: connectionId, + data: { connection_token: token }, + }); + } + + const mcpProxyUrl = new URL(`/mcp/${connectionId}`, window.location.origin); + await queryClient.invalidateQueries({ + queryKey: KEYS.isMCPAuthenticated(mcpProxyUrl.href, null), + }); + + return true; +} From 3f19c450a12ca892fc4dd6ca8dea14a2d6ad2760 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 5 Mar 2026 17:30:02 -0300 Subject: [PATCH 27/30] fix: explicit MESH_ALLOW_LOCAL_PROD check and postMessage target for proxy setups - Use `!== "true"` instead of truthiness check for MESH_ALLOW_LOCAL_PROD to prevent unintended bypass with values like "false" - Use "*" as postMessage target in local dev so OAuth callbacks work behind proxies where opener origin differs from redirect origin Co-Authored-By: Claude Opus 4.6 --- apps/mesh/src/index.ts | 2 +- packages/mesh-sdk/src/lib/mcp-oauth.ts | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/mesh/src/index.ts b/apps/mesh/src/index.ts index b0d1a43c9e..ab783fd02d 100644 --- a/apps/mesh/src/index.ts +++ b/apps/mesh/src/index.ts @@ -35,7 +35,7 @@ const url = process.env.BASE_URL || `http://localhost:${port}`; if ( process.env.MESH_LOCAL_MODE === "true" && process.env.NODE_ENV === "production" && - !process.env.MESH_ALLOW_LOCAL_PROD + process.env.MESH_ALLOW_LOCAL_PROD !== "true" ) { console.error( "\x1b[31mError: Local mode is not allowed in production (NODE_ENV=production).\x1b[0m", diff --git a/packages/mesh-sdk/src/lib/mcp-oauth.ts b/packages/mesh-sdk/src/lib/mcp-oauth.ts index e09276a304..3fc6214e26 100644 --- a/packages/mesh-sdk/src/lib/mcp-oauth.ts +++ b/packages/mesh-sdk/src/lib/mcp-oauth.ts @@ -555,11 +555,11 @@ function sendCallbackData( // Try postMessage first (primary method) if (window.opener && !window.opener.closed) { // In local dev the redirect URI (localhost:PORT) may differ from the - // opener origin (e.g. proxy.localhost). Use the configured redirect origin - // so the message is scoped to a known origin rather than "*". - const target = isLocalDev() - ? getOAuthRedirectOrigin() - : window.location.origin; + // opener origin (e.g. proxy.localhost:4000 vs localhost:3000). Using a + // specific targetOrigin would cause the message to be silently dropped + // when origins don't match. Use "*" in local dev since both ends are + // on localhost; in production use the exact origin for security. + const target = isLocalDev() ? "*" : window.location.origin; window.opener.postMessage(data, target); return true; } From 8dd9079a48a6d478c3aafa28e552435030878fe8 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 5 Mar 2026 17:33:21 -0300 Subject: [PATCH 28/30] fix(local-mode): probe ~/deco for secrets.json when MESH_HOME is unset getLocalAdminPassword() now checks the default ~/deco directory as a fallback when MESH_HOME is not set. This fixes auto-login when running via `bun run dev:server` directly (which doesn't set MESH_HOME), while still refusing to use a hardcoded credential. Co-Authored-By: Claude Opus 4.6 --- apps/mesh/src/auth/local-mode.ts | 51 ++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/apps/mesh/src/auth/local-mode.ts b/apps/mesh/src/auth/local-mode.ts index 0f490d9c78..f21df486dc 100644 --- a/apps/mesh/src/auth/local-mode.ts +++ b/apps/mesh/src/auth/local-mode.ts @@ -8,32 +8,51 @@ */ import { getDb } from "@/database"; -import { userInfo } from "os"; +import { homedir, userInfo } from "os"; import { join } from "path"; import { auth } from "./index"; +/** + * Try to read LOCAL_ADMIN_PASSWORD from a secrets.json in the given directory. + */ +async function readPasswordFromDir(dir: string): Promise { + try { + const file = Bun.file(join(dir, "secrets.json")); + if (await file.exists()) { + const secrets = await file.json(); + if (secrets.LOCAL_ADMIN_PASSWORD) { + return secrets.LOCAL_ADMIN_PASSWORD; + } + } + } catch { + // Not available in this directory + } + return null; +} + /** * Get the per-install local admin password from secrets.json. - * Falls back to a default if secrets.json is unavailable (should not happen - * in normal CLI flow since loadOrCreateSecrets() runs first). + * + * Checks MESH_HOME first, then the default ~/deco directory (which the CLI + * and dev.ts both use). Throws if neither location has a password — never + * falls back to a hardcoded credential. */ export async function getLocalAdminPassword(): Promise { + // 1. Try MESH_HOME (set by CLI / dev.ts) const meshHome = process.env.MESH_HOME; if (meshHome) { - try { - const file = Bun.file(join(meshHome, "secrets.json")); - if (await file.exists()) { - const secrets = await file.json(); - if (secrets.LOCAL_ADMIN_PASSWORD) { - return secrets.LOCAL_ADMIN_PASSWORD; - } - } - } catch { - // Fall through to default - } + const pw = await readPasswordFromDir(meshHome); + if (pw) return pw; } - // No fallback — if secrets.json is unavailable, fail loudly rather than - // using a well-known credential that is published in the source repo. + + // 2. Try default ~/deco (covers `bun run dev:server` without MESH_HOME) + const defaultHome = join(homedir(), "deco"); + if (!meshHome || meshHome !== defaultHome) { + const pw = await readPasswordFromDir(defaultHome); + if (pw) return pw; + } + + // No password found — fail loudly rather than using a known credential throw new Error( "Local admin password unavailable — secrets.json was not initialized. " + "Ensure loadOrCreateSecrets() runs before the server starts (the CLI does this automatically).", From 61d02a24b2bbaa58c405e7dfa1738b55ddc452de Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 5 Mar 2026 17:41:12 -0300 Subject: [PATCH 29/30] fix(dev): check non-interactive before defaultPath existence in resolveMeshHome Swap the order so CI/non-TTY environments always use the CI-safe .mesh-dev path, even when ~/deco exists from a prior local run. Previously, existsSync(~/deco) was checked first, causing CI on shared runners to use the developer's real data directory. Co-Authored-By: Claude Opus 4.6 --- apps/mesh/scripts/bootstrap.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/mesh/scripts/bootstrap.ts b/apps/mesh/scripts/bootstrap.ts index 2b3e4843a0..6ed846f8f4 100644 --- a/apps/mesh/scripts/bootstrap.ts +++ b/apps/mesh/scripts/bootstrap.ts @@ -166,14 +166,16 @@ export async function resolveMeshHome( return expandTilde(opts.explicit); } - if (existsSync(opts.defaultPath)) { - return opts.defaultPath; - } - + // Non-interactive (CI) — always use the CI-safe path so we never + // accidentally touch the developer's real ~/deco data on shared runners. if (!process.stdin.isTTY) { return opts.ciFallback ?? opts.defaultPath; } + if (existsSync(opts.defaultPath)) { + return opts.defaultPath; + } + // First run — prompt the user const displayDefault = opts.defaultPath.replace(homedir(), "~"); console.log(""); From 327e1b43f010420ecf4b09498769c4bcaf1ff89c Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 5 Mar 2026 17:52:12 -0300 Subject: [PATCH 30/30] fix: rename system prompt identity from "decopilot" to "Deco Pilot" The LLM was self-identifying as "decoCopilot" which reads as "decoco" in Portuguese. Use properly spaced "Deco Pilot" and "Deco Studio". Co-Authored-By: Claude Opus 4.6 --- apps/mesh/src/api/routes/decopilot/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mesh/src/api/routes/decopilot/constants.ts b/apps/mesh/src/api/routes/decopilot/constants.ts index 8c4cb598ea..7a6e0724ec 100644 --- a/apps/mesh/src/api/routes/decopilot/constants.ts +++ b/apps/mesh/src/api/routes/decopilot/constants.ts @@ -19,7 +19,7 @@ export const SUBAGENT_EXCLUDED_TOOLS = ["user_ask", "subtask"]; * @returns ChatMessage with the base system prompt */ export function DECOPILOT_BASE_PROMPT(agentInstructions?: string): ChatMessage { - const platformPrompt = `You are decopilot, an AI assistant running inside decocms (deco context management system).`; + const platformPrompt = `You are Deco Pilot, an AI assistant running inside Deco Studio (deco context management system).`; let text = platformPrompt; if (agentInstructions?.trim()) {