fix(stacks): harden stack management with security, validation, and logging (#520)

* fix(stacks): harden stack management with security fixes, validation alignment, and logging

Validate WebSocket stack names with isValidStackName() to close a
path-traversal gap on the /api/stacks/:stackName/logs WS endpoint.
Align POST /api/stacks to use the canonical validator (allows underscores).
Replace error: any catch blocks with error: unknown + type narrowing.
Add cache invalidation to PUT /api/stacks/:stackName/env.
Rename DELETE param from :name to :stackName for consistency.

Add standard [Stacks] lifecycle logs and diagnostic [Stacks:debug] logs
gated behind the Developer Mode toggle (with 5s TTL cache).
Extract shared isDebugEnabled() and getErrorMessage() utilities.

Frontend: roll back optimistic status on API failure, guard unsaved
changes when switching stacks, pre-check duplicate names in App Store.

* docs(settings): update Developer Mode description to mention debug diagnostics
This commit is contained in:
Anso
2026-04-12 05:43:15 -04:00
committed by GitHub
parent 3ad1ab5c84
commit 2465f7607e
11 changed files with 350 additions and 30 deletions
+10
View File
@@ -10,6 +10,8 @@ import { LogFormatter } from './LogFormatter';
import { NodeRegistry } from './NodeRegistry';
import { RegistryService } from './RegistryService';
import { isDebugEnabled } from '../utils/debug';
/**
* ComposeService - local docker compose CLI execution.
*
@@ -113,6 +115,9 @@ export class ComposeService {
async deployStack(stackName: string, ws?: WebSocket, atomic?: boolean): Promise<void> {
const stackDir = path.join(this.baseDir, stackName);
const debug = isDebugEnabled();
const t0 = Date.now();
if (debug) console.debug('[ComposeService:debug] deployStack', { stackName, stackDir, atomic });
const sendOutput = (data: string) => {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(data);
};
@@ -166,6 +171,7 @@ export class ComposeService {
}
}
}
if (debug) console.debug(`[ComposeService:debug] deployStack completed in ${Date.now() - t0}ms`, { stackName });
} catch (deployError) {
// Atomic: auto-rollback on failure
if (atomic) {
@@ -299,6 +305,9 @@ export class ComposeService {
async updateStack(stackName: string, ws?: WebSocket, atomic?: boolean): Promise<void> {
const stackDir = path.join(this.baseDir, stackName);
const debug = isDebugEnabled();
const t0 = Date.now();
if (debug) console.debug('[ComposeService:debug] updateStack', { stackName, stackDir, atomic });
const sendOutput = (data: string) => {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(data);
};
@@ -358,6 +367,7 @@ export class ComposeService {
}
sendOutput('=== Stack updated successfully ===\n');
if (debug) console.debug(`[ComposeService:debug] updateStack completed in ${Date.now() - t0}ms`, { stackName });
} catch (updateError) {
// Atomic: auto-rollback on failure
if (atomic) {
@@ -12,6 +12,8 @@ function getBackupBaseDir(): string {
return path.join(dataDir, 'backups');
}
import { isDebugEnabled } from '../utils/debug';
/**
* FileSystemService - local-only file I/O for compose stack management.
*
@@ -52,6 +54,7 @@ export class FileSystemService {
const filePath = path.join(stackDir, file);
try {
await fsPromises.access(filePath);
if (isDebugEnabled()) console.debug('[FileSystemService:debug] Resolved compose file', { stackName, file });
return filePath;
} catch {
// continue
@@ -249,6 +252,8 @@ export class FileSystemService {
* holds sencho.db and encryption.key.
*/
async backupStackFiles(stackName: string): Promise<void> {
const debug = isDebugEnabled();
const t0 = Date.now();
const stackDir = path.join(this.baseDir, stackName);
const backupDir = path.join(getBackupBaseDir(), stackName);
await fsPromises.mkdir(backupDir, { recursive: true });
@@ -282,9 +287,12 @@ export class FileSystemService {
// Write timestamp marker
await fsPromises.writeFile(path.join(backupDir, '.timestamp'), Date.now().toString(), 'utf-8');
if (debug) console.debug(`[FileSystemService:debug] Backup completed in ${Date.now() - t0}ms`, { stackName });
}
async restoreStackFiles(stackName: string): Promise<void> {
const debug = isDebugEnabled();
const t0 = Date.now();
const stackDir = path.join(this.baseDir, stackName);
const backupDir = path.join(getBackupBaseDir(), stackName);
@@ -293,6 +301,7 @@ export class FileSystemService {
if (item === '.timestamp') continue;
await fsPromises.copyFile(path.join(backupDir, item), path.join(stackDir, item));
}
if (debug) console.debug(`[FileSystemService:debug] Restore completed in ${Date.now() - t0}ms`, { stackName, files: items.filter(i => i !== '.timestamp') });
}
async getBackupInfo(stackName: string): Promise<{ exists: boolean; timestamp: number | null }> {