wulf-pulse/lib/services/pipeline-steps/db-query.ts

76 lines
2.3 KiB
TypeScript

/**
* Step Executor: db_query
* Run a parameterized read-only SQL query against local Postgres.
* Useful for trend analysis, history lookups, aggregations.
*
* Config:
* query: SQL string with $1, $2 etc. placeholders
* params: array of template strings for parameter values
* output_key: context key to store results (default: 'query_result')
* single_row: if true, store only first row instead of array
*
* Security: Only SELECT statements allowed. No mutations.
*/
import { registerStepExecutor } from '../pipeline-engine';
import { postgresClient } from '../postgres-client';
import { StepExecutorResult, PipelineStep, PipelineContext } from '../../types/pipeline';
const FORBIDDEN_KEYWORDS = [
'INSERT', 'UPDATE', 'DELETE', 'DROP', 'ALTER', 'CREATE', 'TRUNCATE',
'GRANT', 'REVOKE', 'COPY', 'EXECUTE', 'CALL',
];
registerStepExecutor('db_query', async (
step: PipelineStep,
context: PipelineContext,
executionId: number
): Promise<StepExecutorResult> => {
const config = step.config as {
query?: string;
params?: string[];
output_key?: string;
single_row?: boolean;
};
const query = config.query || '';
const params = config.params || [];
const outputKey = config.output_key || 'query_result';
const singleRow = config.single_row ?? false;
if (!query) {
return { success: false, output: {}, error: 'query is required' };
}
// Security: block mutations
const upperQuery = query.toUpperCase().replace(/\s+/g, ' ');
for (const keyword of FORBIDDEN_KEYWORDS) {
// Check for keyword as a standalone word (not inside a string literal)
const regex = new RegExp(`\\b${keyword}\\b`);
if (regex.test(upperQuery)) {
return {
success: false,
output: {},
error: `Forbidden SQL keyword: ${keyword}. Only SELECT queries are allowed.`,
};
}
}
try {
const result = await postgresClient.query(query, params);
const rows = result.rows;
const value = singleRow ? (rows[0] || null) : rows;
return {
success: true,
output: {
[outputKey]: value,
[`${outputKey}_count`]: rows.length,
},
};
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return { success: false, output: { [outputKey]: null }, error: `DB query failed: ${msg}` };
}
});