3 * @module ScriptsRoutes
4 * @description PowerShell ISE-style script library with per-tenant isolation. Manages script CRUD operations, execution on agents via WebSocket, execution history tracking, and agent script synchronization via manifest/download endpoints. Supports both tenant-specific and global scripts.
5 * @see {@link ../services/websocketManager} for agent WebSocket connections
6 * @see {@link ../db} for database connection
7 * @apiDefine ScriptsGroup Scripts
9 * @apiHeader {string} Authorization Bearer token required.
10 * @apiHeader {string} X-Tenant-ID Tenant context header required.
11 * @apiError (Error 401) Unauthorized Missing or invalid token.
12 * @apiError (Error 403) Forbidden Cannot modify global scripts or access unauthorized resources.
13 * @apiError (Error 404) NotFound Script or execution not found.
14 * @apiError (Error 409) Conflict Script with this name already exists.
15 * @apiError (Error 500) ServerError Internal server error.
17const express = require('express');
18const fs = require('fs');
19const path = require('path');
20const router = express.Router();
21const wsManager = require('../services/websocketManager');
22const pool = require('../db');
24// ============================================
25// SCRIPT CRUD OPERATIONS
26// ============================================
29 * @api {get} /scripts/list List all scripts for current tenant
30 * @apiName ListScripts
32 * @apiDescription Retrieve all scripts accessible to the current tenant (tenant-specific + global scripts). Results are ordered by global status first, then alphabetically by name.
33 * @apiSuccess {object[]} scripts List of scripts.
34 * @apiSuccess {string} scripts.script_id Script ID.
35 * @apiSuccess {string} scripts.script_name Script name.
36 * @apiSuccess {string} scripts.script_description Script description.
37 * @apiSuccess {string} scripts.script_content Script content.
38 * @apiSuccess {string} scripts.script_language Language (powershell, bash, etc.).
39 * @apiSuccess {boolean} scripts.is_global Whether script is global (read-only).
40 * @apiExample {curl} Example usage:
41 * curl -H "Authorization: Bearer <token>" -H "X-Tenant-ID: <id>" https://api.example.com/scripts/list
43router.get('/list', async (req, res) => {
45 const tenant_id = req.headers['x-tenant-id'];
47 // Handle "null" string as actual NULL
48 const safeTenantId = (tenant_id === 'null' || !tenant_id) ? null : tenant_id;
50 const result = await pool.query(`
51 SELECT * FROM v_scripts_with_tenant
52 WHERE (tenant_id = $1 OR is_global = true)
53 ORDER BY is_global DESC, script_name ASC
56 res.json(result.rows);
58 console.error('[Scripts] List error:', err);
59 res.status(500).json({ error: 'Failed to list scripts' });
64 * @api {get} /scripts/manifest Get script manifest for agent sync
65 * @apiName GetScriptManifest
67 * @apiDescription Returns a manifest of all scripts accessible to the current tenant with version info and hashes for agent synchronization. Used by agents to detect and download new or updated scripts.
68 * @apiSuccess {object[]} scripts List of script manifest entries.
69 * @apiSuccess {string} scripts.script_id Script ID.
70 * @apiSuccess {string} scripts.script_name Script name.
71 * @apiSuccess {string} scripts.script_hash SHA256 hash of script content.
72 * @apiSuccess {number} scripts.version Script version.
73 * @apiSuccess {number} scripts.file_size Script file size in bytes.
74 * @apiSuccess {boolean} scripts.is_global Whether script is global.
75 * @apiSuccess {number} count Total script count.
76 * @apiSuccess {string} timestamp Manifest generation timestamp.
77 * @apiExample {curl} Example usage:
78 * curl -H "Authorization: Bearer <token>" -H "X-Tenant-ID: <id>" https://api.example.com/scripts/manifest
80router.get('/manifest', async (req, res) => {
82 const tenant_id = req.headers['x-tenant-id'];
84 // Handle "null" string as actual NULL
85 const safeTenantId = (tenant_id === 'null' || !tenant_id) ? null : tenant_id;
87 // Get manifest entries for this tenant + global scripts
88 const result = await pool.query(`
98 FROM script_manifest sm
99 JOIN scripts s ON sm.script_id = s.script_id
100 WHERE (sm.tenant_id = $1 OR sm.is_global = true)
101 ORDER BY sm.is_global DESC, sm.script_name ASC
105 scripts: result.rows,
106 count: result.rows.length,
107 timestamp: new Date().toISOString()
110 console.error('[Scripts] Manifest error:', err);
111 res.status(500).json({ error: 'Failed to get script manifest' });
116 * @api {get} /scripts/:script_id Get single script by ID
119 * @apiDescription Retrieve a single script by ID. Script must be accessible to the current tenant (tenant-specific or global).
120 * @apiParam {string} script_id Script ID.
121 * @apiSuccess {object} script Script object.
122 * @apiSuccess {string} script.script_id Script ID.
123 * @apiSuccess {string} script.script_name Script name.
124 * @apiSuccess {string} script.script_description Script description.
125 * @apiSuccess {string} script.script_content Script content.
126 * @apiSuccess {string} script.script_language Language (powershell, bash, etc.).
127 * @apiSuccess {boolean} script.is_global Whether script is global.
128 * @apiError (Error 404) NotFound Script not found or not accessible.
129 * @apiExample {curl} Example usage:
130 * curl -H "Authorization: Bearer <token>" -H "X-Tenant-ID: <id>" https://api.example.com/scripts/abc123
132router.get('/:script_id', async (req, res) => {
134 const { script_id } = req.params;
135 const tenant_id = req.headers['x-tenant-id'];
137 const result = await pool.query(`
138 SELECT * FROM scripts
139 WHERE script_id = $1 AND (tenant_id = $2 OR is_global = true)
140 `, [script_id, tenant_id]);
142 if (result.rows.length === 0) {
143 return res.status(404).json({ error: 'Script not found' });
146 res.json(result.rows[0]);
148 console.error('[Scripts] Get error:', err);
149 res.status(500).json({ error: 'Failed to get script' });
154 * @api {post} /scripts/create Create new script
155 * @apiName CreateScript
157 * @apiDescription Create a new script for the current tenant. Script name and content are required.
158 * @apiParam {string} script_name Script name (unique per tenant).
159 * @apiParam {string} script_content Script content/code.
160 * @apiParam {string} [script_description] Script description.
161 * @apiParam {string} [script_language=powershell] Script language (powershell, bash, python, etc.).
162 * @apiSuccess {object} script Created script object.
163 * @apiError (Error 400) BadRequest Script name and content are required.
164 * @apiError (Error 409) Conflict Script with this name already exists.
165 * @apiExample {curl} Example usage:
166 * curl -X POST -H "Authorization: Bearer <token>" -H "X-Tenant-ID: <id>" -d '{"script_name":"Install Updates","script_content":"Get-WindowsUpdate","script_language":"powershell"}' https://api.example.com/scripts/create
168router.post('/create', async (req, res) => {
170 const { script_name, script_description, script_content, script_language } = req.body;
171 const tenant_id = req.headers['x-tenant-id'];
172 const user_id = req.user?.user_id; // From auth middleware
174 if (!script_name || !script_content) {
175 return res.status(400).json({ error: 'Script name and content are required' });
178 const result = await pool.query(`
179 INSERT INTO scripts (tenant_id, script_name, script_description, script_content, script_language, created_by)
180 VALUES ($1, $2, $3, $4, $5, $6)
182 `, [tenant_id, script_name, script_description, script_content, script_language || 'powershell', user_id]);
184 res.json(result.rows[0]);
186 if (err.code === '23505') { // Unique violation
187 return res.status(409).json({ error: 'Script with this name already exists' });
189 console.error('[Scripts] Create error:', err);
190 res.status(500).json({ error: 'Failed to create script' });
195 * @api {put} /scripts/:script_id Update script
196 * @apiName UpdateScript
198 * @apiDescription Update an existing tenant-specific script. Global scripts cannot be edited. Only provided fields will be updated.
199 * @apiParam {string} script_id Script ID.
200 * @apiParam {string} [script_name] New script name.
201 * @apiParam {string} [script_description] New script description.
202 * @apiParam {string} [script_content] New script content.
203 * @apiParam {string} [script_language] New script language.
204 * @apiSuccess {object} script Updated script object.
205 * @apiError (Error 403) Forbidden Cannot edit global scripts.
206 * @apiError (Error 404) NotFound Script not found or unauthorized.
207 * @apiExample {curl} Example usage:
208 * curl -X PUT -H "Authorization: Bearer <token>" -H "X-Tenant-ID: <id>" -d '{"script_content":"Updated script content"}' https://api.example.com/scripts/abc123
210router.put('/:script_id', async (req, res) => {
212 const { script_id } = req.params;
213 const { script_name, script_description, script_content, script_language } = req.body;
214 const tenant_id = req.headers['x-tenant-id'];
216 // Can't edit global scripts
217 const checkResult = await pool.query(`
218 SELECT is_global FROM scripts WHERE script_id = $1
221 if (checkResult.rows.length === 0) {
222 return res.status(404).json({ error: 'Script not found' });
225 if (checkResult.rows[0].is_global) {
226 return res.status(403).json({ error: 'Cannot edit global scripts' });
229 const result = await pool.query(`
231 SET script_name = COALESCE($1, script_name),
232 script_description = COALESCE($2, script_description),
233 script_content = COALESCE($3, script_content),
234 script_language = COALESCE($4, script_language),
236 WHERE script_id = $5 AND tenant_id = $6
238 `, [script_name, script_description, script_content, script_language, script_id, tenant_id]);
240 if (result.rows.length === 0) {
241 return res.status(404).json({ error: 'Script not found or unauthorized' });
244 res.json(result.rows[0]);
246 console.error('[Scripts] Update error:', err);
247 res.status(500).json({ error: 'Failed to update script' });
252 * @api {delete} /scripts/:script_id Delete script
253 * @apiName DeleteScript
255 * @apiDescription Delete a tenant-specific script. Global scripts cannot be deleted.
256 * @apiParam {string} script_id Script ID.
257 * @apiSuccess {boolean} success Operation success status.
258 * @apiSuccess {string} message Status message.
259 * @apiError (Error 403) Forbidden Cannot delete global scripts.
260 * @apiError (Error 404) NotFound Script not found or unauthorized.
261 * @apiExample {curl} Example usage:
262 * curl -X DELETE -H "Authorization: Bearer <token>" -H "X-Tenant-ID: <id>" https://api.example.com/scripts/abc123
264router.delete('/:script_id', async (req, res) => {
266 const { script_id } = req.params;
267 const tenant_id = req.headers['x-tenant-id'];
269 // Can't delete global scripts
270 const checkResult = await pool.query(`
271 SELECT is_global FROM scripts WHERE script_id = $1
274 if (checkResult.rows.length === 0) {
275 return res.status(404).json({ error: 'Script not found' });
278 if (checkResult.rows[0].is_global) {
279 return res.status(403).json({ error: 'Cannot delete global scripts' });
282 const result = await pool.query(`
284 WHERE script_id = $1 AND tenant_id = $2
286 `, [script_id, tenant_id]);
288 if (result.rows.length === 0) {
289 return res.status(404).json({ error: 'Script not found or unauthorized' });
292 res.json({ success: true, message: 'Script deleted' });
294 console.error('[Scripts] Delete error:', err);
295 res.status(500).json({ error: 'Failed to delete script' });
299// ============================================
301// ============================================
304 * @api {post} /scripts/execute/:agent_uuid Execute script on agent
305 * @apiName ExecuteScript
307 * @apiDescription Execute a script on a connected agent via WebSocket. Provide either script_id (to fetch from library) or script_content (for ad-hoc execution). Creates an execution record and returns execution_id for tracking.
308 * @apiParam {string} agent_uuid Agent UUID to execute on.
309 * @apiParam {string} [script_id] Script ID from library (mutually exclusive with script_content).
310 * @apiParam {string} [script_content] Ad-hoc script content (mutually exclusive with script_id).
311 * @apiSuccess {boolean} success Operation success status.
312 * @apiSuccess {string} execution_id Execution ID for tracking.
313 * @apiSuccess {string} message Status message.
314 * @apiError (Error 400) BadRequest No script content provided.
315 * @apiError (Error 404) NotFound Agent not connected or script not found.
316 * @apiExample {curl} Example usage:
317 * curl -X POST -H "Authorization: Bearer <token>" -H "X-Tenant-ID: <id>" -d '{"script_id":"abc123"}' https://api.example.com/scripts/execute/agent-uuid-here
319router.post('/execute/:agent_uuid', async (req, res) => {
321 const { agent_uuid } = req.params;
322 const { script_id, script_content } = req.body;
323 const tenant_id = req.headers['x-tenant-id'];
324 const user_id = req.user?.user_id;
326 // Either script_id or script_content must be provided
327 let scriptToRun = script_content;
328 let scriptIdToLog = script_id;
330 if (script_id && !script_content) {
331 // Fetch script content from database
332 const scriptResult = await pool.query(`
333 SELECT script_content FROM scripts
334 WHERE script_id = $1 AND (tenant_id = $2 OR is_global = true)
335 `, [script_id, tenant_id]);
337 if (scriptResult.rows.length === 0) {
338 return res.status(404).json({ error: 'Script not found' });
341 scriptToRun = scriptResult.rows[0].script_content;
345 return res.status(400).json({ error: 'No script content provided' });
348 // Check if agent is connected
349 const ws = wsManager.agentConnections.get(agent_uuid);
350 if (!ws || ws.readyState !== 1) {
351 return res.status(404).json({ error: 'Agent not connected' });
354 // Create execution record
355 const executionResult = await pool.query(`
356 INSERT INTO script_executions (script_id, agent_uuid, tenant_id, executed_by, execution_status)
357 VALUES ($1, $2, $3, $4, 'running')
358 RETURNING execution_id
359 `, [scriptIdToLog, agent_uuid, tenant_id, user_id]);
361 const execution_id = executionResult.rows[0].execution_id;
363 // Send script to agent via WebSocket
364 ws.send(JSON.stringify({
367 payload: { script: scriptToRun }
370 console.log(`[Scripts] Executing script on ${agent_uuid}, execution_id: ${execution_id}`);
375 message: 'Script execution started'
378 console.error('[Scripts] Execute error:', err);
379 res.status(500).json({ error: 'Failed to execute script' });
384 * @api {get} /scripts/executions/:script_id Get execution history for script
385 * @apiName GetScriptExecutions
387 * @apiDescription Retrieve execution history for a specific script (last 50 executions). Includes agent hostname and executor name.
388 * @apiParam {string} script_id Script ID.
389 * @apiSuccess {object[]} executions List of executions.
390 * @apiSuccess {string} executions.execution_id Execution ID.
391 * @apiSuccess {string} executions.agent_uuid Agent UUID.
392 * @apiSuccess {string} executions.hostname Agent hostname.
393 * @apiSuccess {string} executions.execution_status Status (running, completed, failed).
394 * @apiSuccess {string} executions.stdout Script output.
395 * @apiSuccess {string} executions.stderr Script errors.
396 * @apiSuccess {number} executions.exit_code Exit code.
397 * @apiSuccess {string} executions.executed_by_name Executor name.
398 * @apiSuccess {Date} executions.started_at Execution start time.
399 * @apiSuccess {Date} executions.completed_at Execution completion time.
400 * @apiExample {curl} Example usage:
401 * curl -H "Authorization: Bearer <token>" -H "X-Tenant-ID: <id>" https://api.example.com/scripts/executions/abc123
403router.get('/executions/:script_id', async (req, res) => {
405 const { script_id } = req.params;
406 const tenant_id = req.headers['x-tenant-id'];
408 const result = await pool.query(`
412 u.name as executed_by_name
413 FROM script_executions se
414 LEFT JOIN agents a ON se.agent_uuid = a.agent_uuid
415 LEFT JOIN users u ON se.executed_by = u.user_id
416 WHERE se.script_id = $1 AND se.tenant_id = $2
417 ORDER BY se.started_at DESC
419 `, [script_id, tenant_id]);
421 res.json(result.rows);
423 console.error('[Scripts] Execution history error:', err);
424 res.status(500).json({ error: 'Failed to get execution history' });
429 * @api {get} /scripts/execution/:execution_id Get execution result
430 * @apiName GetExecution
432 * @apiDescription Retrieve details of a single script execution by execution ID.
433 * @apiParam {string} execution_id Execution ID.
434 * @apiSuccess {object} execution Execution object.
435 * @apiSuccess {string} execution.execution_id Execution ID.
436 * @apiSuccess {string} execution.script_id Script ID.
437 * @apiSuccess {string} execution.agent_uuid Agent UUID.
438 * @apiSuccess {string} execution.execution_status Status (running, completed, failed).
439 * @apiSuccess {string} execution.stdout Script output.
440 * @apiSuccess {string} execution.stderr Script errors.
441 * @apiSuccess {number} execution.exit_code Exit code.
442 * @apiSuccess {Date} execution.started_at Execution start time.
443 * @apiSuccess {Date} execution.completed_at Execution completion time.
444 * @apiError (Error 404) NotFound Execution not found.
445 * @apiExample {curl} Example usage:
446 * curl -H "Authorization: Bearer <token>" -H "X-Tenant-ID: <id>" https://api.example.com/scripts/execution/exec-123
448router.get('/execution/:execution_id', async (req, res) => {
450 const { execution_id } = req.params;
451 const tenant_id = req.headers['x-tenant-id'];
453 const result = await pool.query(`
454 SELECT * FROM script_executions
455 WHERE execution_id = $1 AND tenant_id = $2
456 `, [execution_id, tenant_id]);
458 if (result.rows.length === 0) {
459 return res.status(404).json({ error: 'Execution not found' });
462 res.json(result.rows[0]);
464 console.error('[Scripts] Get execution error:', err);
465 res.status(500).json({ error: 'Failed to get execution' });
469// ============================================
470// SCRIPT MANIFEST FOR AGENT SYNC
471// ============================================
474 * @api {get} /scripts/download/:script_id Download script for agent sync
475 * @apiName DownloadScript
477 * @apiDescription Download script content by ID with hash and version info for agent synchronization. Used by agents to fetch new or updated scripts from the manifest.
478 * @apiParam {string} script_id Script ID.
479 * @apiSuccess {string} script_id Script ID.
480 * @apiSuccess {string} script_name Script name.
481 * @apiSuccess {string} script_content Script content.
482 * @apiSuccess {string} script_language Script language.
483 * @apiSuccess {string} hash SHA256 hash of script content.
484 * @apiSuccess {number} version Script version.
485 * @apiError (Error 404) NotFound Script not found or not accessible.
486 * @apiExample {curl} Example usage:
487 * curl -H "Authorization: Bearer <token>" -H "X-Tenant-ID: <id>" https://api.example.com/scripts/download/abc123
489router.get('/download/:script_id', async (req, res) => {
491 const { script_id } = req.params;
492 const tenant_id = req.headers['x-tenant-id'];
494 // Handle "null" string as actual NULL
495 const safeTenantId = (tenant_id === 'null' || !tenant_id) ? null : tenant_id;
497 // Get script content (must be accessible to this tenant)
498 const result = await pool.query(`
507 JOIN script_manifest sm ON s.script_id = sm.script_id
508 WHERE s.script_id = $1 AND (s.tenant_id = $2 OR s.is_global = true)
509 `, [script_id, safeTenantId]);
511 if (result.rows.length === 0) {
512 return res.status(404).json({ error: 'Script not found' });
515 const script = result.rows[0];
518 script_id: script.script_id,
519 script_name: script.script_name,
520 script_content: script.script_content,
521 script_language: script.script_language,
522 hash: script.script_hash,
523 version: script.version
526 console.error('[Scripts] Download error:', err);
527 res.status(500).json({ error: 'Failed to download script' });
531module.exports = router;