import express, { Request, Response, NextFunction } from 'express'; import cors from 'cors'; import { Piece } from '@activepieces/pieces-framework'; import { spawn } from 'child_process'; import fs from 'fs'; import path from 'path'; const app = express(); const PORT = process.env.PORT || 3003; // ============================================================================ // SECURITY: Authentication Middleware // ============================================================================ const PIECE_ENGINE_API_KEY = process.env.PIECE_ENGINE_API_KEY || ''; const REQUIRE_AUTH = process.env.REQUIRE_AUTH === 'true' || PIECE_ENGINE_API_KEY.length > 0; /** * Authentication middleware for management endpoints. * Validates API key via X-API-Key header or Authorization: Bearer */ function authenticateRequest(req: Request, res: Response, next: NextFunction): void { // Skip authentication if not explicitly enabled if (!REQUIRE_AUTH) { next(); return; } // Try X-API-Key header first const apiKey = req.headers['x-api-key'] as string; // Try Authorization header (Bearer token) const authHeader = req.headers['authorization'] as string; const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : null; const token = apiKey || bearerToken; if (!token) { res.status(401).json({ success: false, error: 'Authentication required. Provide X-API-Key header or Authorization: Bearer .' }); return; } if (token !== PIECE_ENGINE_API_KEY) { res.status(403).json({ success: false, error: 'Invalid API key' }); return; } next(); } // NPM package name validation (RFC compliance) // Based on: https://github.com/npm/validate-npm-package-name const VALID_PACKAGE_NAME_REGEX = /^(?:@([a-z0-9-~][a-z0-9-._~]*)\/)?([a-z0-9-~][a-z0-9-._~]*)$/; /** * Validates an npm package name against RFC standards to prevent command injection. * @param packageName - The package name to validate * @returns True if the package name is valid, false otherwise */ function isValidPackageName(packageName: string): boolean { if (!packageName || typeof packageName !== 'string') { return false; } // Check length (npm limits to 214 chars) if (packageName.length > 214) { return false; } // Validate against RFC-compliant regex return VALID_PACKAGE_NAME_REGEX.test(packageName); } /** * Safely installs an npm package using spawn (no shell interpretation). * @param packageName - The validated package name to install * @returns Promise that resolves when installation completes */ function safeNpmInstall(packageName: string): Promise { return new Promise((resolve, reject) => { // Use spawn with argument array to prevent shell injection const process = spawn('npm', ['install', packageName, '--save'], { stdio: 'inherit', shell: false // Critical: Disable shell to prevent command injection }); process.on('close', (code) => { if (code === 0) { resolve(); } else { reject(new Error(`npm install exited with code ${code}`)); } }); process.on('error', (err) => { reject(new Error(`Failed to spawn npm process: ${err.message}`)); }); }); } app.use(express.json()); app.use(cors()); // In-memory piece registry const pieces: Record = {}; // Helper to load a piece safely const loadPiece = async (pieceName: string): Promise => { try { console.log(`Attempting to load piece: ${pieceName}`); // SECURITY: Validate package name before any operations if (!isValidPackageName(pieceName)) { console.error(`Invalid package name: ${pieceName}`); return null; } // Try to import directly let module; try { module = await import(pieceName); } catch (importErr) { console.log(`Module ${pieceName} not found. Attempting dynamic install...`); // Use safe installation method with spawn instead of exec await safeNpmInstall(pieceName); module = await import(pieceName); } const piece = module.piece || module.default?.piece || module.default; if (piece && typeof piece === 'object' && piece.displayName) { pieces[pieceName] = piece; return piece; } console.warn(`Module ${pieceName} loaded but no Piece export found.`); return null; } catch (e: any) { console.error(`Failed to load/install piece ${pieceName}:`, e.message); return null; } }; // Bootstrap: Load all pieces defined in package.json const bootstrap = async () => { try { const packageJsonPath = path.join(process.cwd(), 'package.json'); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); const deps = packageJson.dependencies || {}; const pieceNames = Object.keys(deps).filter(d => d.startsWith('@activepieces/piece-')); console.log(`Found ${pieceNames.length} pieces to bootstrap...`); for (const name of pieceNames) { await loadPiece(name); } console.log(`Bootstrap complete. ${Object.keys(pieces).length} pieces ready.`); } catch (err) { console.error('Bootstrap failed:', err); } }; app.get('/health', (req: Request, res: Response) => { res.json({ status: 'ok', pieces_loaded: Object.keys(pieces).length, loaded_names: Object.keys(pieces) }); }); // Endpoint to list available pieces (metadata only) app.get('/pieces', (req: Request, res: Response) => { const metadata = Object.entries(pieces).map(([name, p]) => { const actions = typeof p.actions === 'function' ? p.actions() : (p as any).actions || {}; const triggers = typeof p.triggers === 'function' ? p.triggers() : (p as any).triggers || {}; return { name: name, displayName: p.displayName, logoUrl: p.logoUrl, version: (p as any).version || '0.0.0', actions: Object.keys(actions), triggers: Object.keys(triggers), }; }); res.json(metadata); }); // Endpoint to get full details for a specific piece // SECURITY: Require authentication for dynamic piece loading (can trigger npm install) app.get('/pieces/:name', authenticateRequest, async (req: Request, res: Response) => { const name = encodeURIComponent(req.params.name); // Explicitly allow dots and slashes in piece names if they aren't caught by express router correctly // but usually @activepieces/piece-foo works as req.params.name let piece = pieces[req.params.name]; if (!piece) { piece = await loadPiece(req.params.name) || null; } if (!piece) { return res.status(404).json({ error: 'Piece not found' }); } const actions = typeof piece.actions === 'function' ? piece.actions() : (piece as any).actions || {}; const triggers = typeof piece.triggers === 'function' ? piece.triggers() : (piece as any).triggers || {}; res.json({ name: req.params.name, displayName: piece.displayName, logoUrl: piece.logoUrl, authors: piece.authors, actions: actions, triggers: triggers, auth: piece.auth }); }); // Endpoint to execute an action // SECURITY: Require authentication for action execution app.post('/execute/action', authenticateRequest, async (req: Request, res: Response) => { try { const { pieceName, actionName, props, auth } = req.body; let piece = pieces[pieceName]; if (!piece) { piece = await loadPiece(pieceName) || null; } if (!piece) { return res.status(404).json({ error: `Piece ${pieceName} not found` }); } const actions = typeof piece.actions === 'function' ? piece.actions() : (piece as any).actions || {}; const action = actions[actionName]; if (!action) { return res.status(404).json({ error: `Action ${actionName} not found in piece ${pieceName}` }); } // Context Preparation const context = { propsValue: props || {}, auth: auth || {}, store: { put: async () => { }, get: async () => null, delete: async () => { }, } as any, webhookUrl: '', files: { write: async () => '', } as any, serverUrl: '', project: { id: 'atom-project' } as any, flow: { id: 'atom-flow' } as any }; const result = await action.run(context); res.json({ success: true, output: result }); } catch (error: any) { console.error('Execution error:', error); res.status(500).json({ success: false, error: error.message }); } }); // Management: Install a piece // SECURITY: Require authentication for package installation app.post('/sys/install', authenticateRequest, async (req: Request, res: Response) => { const { packageName } = req.body; // SECURITY: Validate package name before installation if (!packageName || !isValidPackageName(packageName)) { return res.status(400).json({ success: false, error: 'Invalid package name. Package names must follow npm naming conventions.' }); } try { console.log(`Installing ${packageName}...`); // Use safe installation method with spawn instead of exec await safeNpmInstall(packageName); const piece = await loadPiece(packageName); if (piece) { res.json({ success: true, message: `Installed and loaded ${packageName}` }); } else { res.json({ success: false, error: 'Package installed but no piece export found' }); } } catch (e: any) { res.status(500).json({ success: false, error: e.message }); } }); app.listen(PORT, async () => { console.log(`Atom Piece Engine running on port ${PORT}`); await bootstrap(); });