File size: 10,350 Bytes
383cb38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309

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 <key>
 */
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 <key>.'
        });
        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<void> {
    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<string, any> = {};

// Helper to load a piece safely
const loadPiece = async (pieceName: string): Promise<Piece | null> => {
    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();
});