godcrm/backend/services/data-source/crud.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

338 lines
9.1 KiB
JavaScript

// data-source/crud.js — CRUD operations, validation, and connection testing
import { dbGet, dbAll, dbRun } from '../../database/connection.js';
import crypto from 'crypto';
import { dbLogger } from '../../utils/logger.js';
import { decryptCredential } from '../../utils/encryption.js';
/**
* Valid data source types
*/
const VALID_TYPES = [
'ssh+mysql',
'ssh+postgres',
'direct+mysql',
'direct+postgres',
'local_mysql',
'local_postgresql',
'mysql',
'postgresql',
'sqlite',
'internal' // Internal SQLite tables (users, spaces, projects, etc.)
];
/**
* Get data source by ID
* @param {string} id - Data source ID
* @returns {Promise<Object>} Data source
*/
async function get(id) {
const dataSource = await dbGet(`
SELECT * FROM data_sources WHERE id = ?
`, [id]);
if (!dataSource) {
throw new Error('Data source not found');
}
return dataSource;
}
/**
* List all data sources for a workspace
* @param {string} workspaceId - Workspace ID
* @returns {Promise<Array>} List of data sources
*/
async function list(workspaceId) {
return await dbAll(`
SELECT * FROM data_sources
WHERE workspace_id = ?
ORDER BY created_at DESC
`, [workspaceId]);
}
/**
* Create a new data source
* @param {Object} params - Data source parameters
* @param {Object} service - Reference to the DataSourceService instance (for method calls)
* @returns {Promise<Object>} Created data source
*/
async function create(params, service) {
const {
workspaceId,
projectId = null,
userId,
name,
description = null,
type,
sshHost = null,
sshPort = 22,
sshUsername = null,
sshKeyName = null,
dbHost = null,
dbPort = null,
dbName = null,
dbUsername = null,
dbPasswordKey = null,
dbPasswordEncrypted = null,
sshPrivateKeyEncrypted = null
} = params;
// Validation
if (!name) {
throw new Error('name is required');
}
if (!VALID_TYPES.includes(type)) {
throw new Error(`Invalid type. Must be one of: ${VALID_TYPES.join(', ')}`);
}
// Validate SSH parameters for ssh+ types
if (type.startsWith('ssh+')) {
if (!sshHost || !sshUsername || !sshKeyName) {
throw new Error('SSH parameters are required for SSH tunnel types (sshHost, sshUsername, sshKeyName)');
}
}
// Validate DB parameters (dbPasswordKey can be optional for some cases)
if (!dbHost || !dbName || !dbUsername) {
throw new Error('Database parameters are required (dbHost, dbName, dbUsername)');
}
// Generate unique ID
const id = `ds_${crypto.randomBytes(8).toString('hex')}`;
// Check if this is the first data source in workspace
const existingCount = await dbGet(`
SELECT COUNT(*) as count FROM data_sources WHERE workspace_id = ?
`, [workspaceId]);
const isFirstDataSource = existingCount.count === 0;
// Insert into database
await dbRun(`
INSERT INTO data_sources (
id, workspace_id, created_by, name, description, type,
ssh_host, ssh_port, ssh_username, ssh_key_name,
db_host, db_port, db_name, db_username, db_password_key,
db_password_encrypted, ssh_private_key_encrypted
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
id, workspaceId, userId, name, description, type,
sshHost, sshPort, sshUsername, sshKeyName,
dbHost, dbPort, dbName, dbUsername, dbPasswordKey,
dbPasswordEncrypted, sshPrivateKeyEncrypted
]);
// If this is the first data source in this Space, create "Источники данных" project
let databasesTableId = null;
if (isFirstDataSource) {
const project = await service.createDataSourcesProject(workspaceId, userId);
if (project) {
// Find the "Базы данных" table in the created project
const table = await dbGet(`
SELECT id FROM universal_tables
WHERE project_id = ? AND name = 'Базы данных'
`, [project.id]);
databasesTableId = table?.id;
}
} else {
// Find existing "Базы данных" table in System Data project (by type)
const project = await dbGet(`
SELECT id FROM projects WHERE space_id = ? AND type = 'system_data'
`, [workspaceId]);
if (project) {
const table = await dbGet(`
SELECT id FROM universal_tables
WHERE project_id = ? AND name = 'Базы данных'
`, [project.id]);
databasesTableId = table?.id;
}
}
// Add entry to "Базы данных" table if it exists
if (databasesTableId) {
await service.addToDatabasesTable(databasesTableId, {
name,
type,
host: dbHost,
port: dbPort,
database: dbName,
username: dbUsername,
password: '***', // Masked password
status: 'disconnected',
data_source_id: id
});
}
// Get the target project for external tables
// Use provided projectId first, otherwise look for System Data project (by type)
let targetProjectId = projectId;
if (!targetProjectId) {
const project = await dbGet(`
SELECT id FROM projects WHERE space_id = ? AND type = 'system_data'
`, [workspaceId]);
targetProjectId = project?.id;
}
// Auto-import tables from the data source
if (targetProjectId) {
try {
dbLogger.info({ targetProjectId }, 'Auto-importing tables');
await service.importExternalTables(id, targetProjectId);
} catch (error) {
dbLogger.error({ err: error }, 'Failed to auto-import tables');
// Don't fail the create - tables can be imported manually later
}
} else {
dbLogger.debug('No target project found, skipping auto-import');
}
// Fetch and return created data source
return await get(id);
}
/**
* Update data source
* @param {string} id - Data source ID
* @param {Object} updates - Fields to update
* @returns {Promise<Object>} Updated data source
*/
async function update(id, updates) {
// Check if data source exists
await get(id);
// Build UPDATE query
const allowedFields = [
'name', 'description', 'type',
'ssh_host', 'ssh_port', 'ssh_username', 'ssh_key_name',
'db_host', 'db_port', 'db_name', 'db_username', 'db_password_key',
'db_password_encrypted', 'ssh_private_key_encrypted',
'sync_enabled', 'sync_interval_minutes'
];
const setClauses = [];
const values = [];
for (const [key, value] of Object.entries(updates)) {
if (allowedFields.includes(key)) {
setClauses.push(`${key} = ?`);
values.push(value);
}
}
if (setClauses.length === 0) {
throw new Error('No valid fields to update');
}
// Add updated_at
setClauses.push('updated_at = CURRENT_TIMESTAMP');
values.push(id);
await dbRun(`
UPDATE data_sources
SET ${setClauses.join(', ')}
WHERE id = ?
`, values);
return await get(id);
}
/**
* Delete data source
* @param {string} id - Data source ID
*/
async function del(id) {
// Check if exists
await get(id);
await dbRun('DELETE FROM data_sources WHERE id = ?', [id]);
}
/**
* Test connection to data source
* @param {string} id - Data source ID
* @returns {Promise<Object>} Test result
*/
async function testConnection(id) {
const dataSource = await get(id);
const result = {
status: 'failed',
error: 'Connection testing not implemented yet',
tested_at: new Date().toISOString()
};
// Update test status in database
await dbRun(`
UPDATE data_sources
SET last_test_at = ?, last_test_status = ?, last_test_error = ?
WHERE id = ?
`, [result.tested_at, result.status, result.error, id]);
return result;
}
/**
* Validate data source configuration
* @param {Object} config - Configuration to validate
* @returns {boolean} True if valid
* @throws {Error} If configuration is invalid
*/
function validateConfig(config) {
const { type } = config;
if (!VALID_TYPES.includes(type)) {
throw new Error(`Invalid type: ${type}`);
}
// Validate SSH parameters for ssh+ types
if (type.startsWith('ssh+')) {
const requiredSshFields = ['sshHost', 'sshPort', 'sshUsername', 'sshKeyName'];
for (const field of requiredSshFields) {
if (!config[field]) {
throw new Error(`SSH field required: ${field}`);
}
}
}
// Validate DB parameters (dbPasswordKey is optional for local connections)
const requiredDbFields = ['dbHost', 'dbPort', 'dbName', 'dbUsername'];
for (const field of requiredDbFields) {
if (!config[field]) {
throw new Error(`Database field required: ${field}`);
}
}
return true;
}
/**
* ADR-064: Get decrypted password for establishing actual DB connections
* This is the ONLY place where passwords should be decrypted.
* @param {Object} dataSource - Data source object from database
* @returns {string} Decrypted password (empty string if none set)
*/
function getDecryptedPassword(dataSource) {
if (dataSource.db_password_encrypted) {
try {
return decryptCredential(dataSource.db_password_encrypted);
} catch (err) {
dbLogger.error({ err, dataSourceId: dataSource.id }, 'Failed to decrypt data source password');
throw new Error('Failed to decrypt data source credentials');
}
}
return '';
}
export {
VALID_TYPES,
get,
list,
create,
update,
del as delete_,
testConnection,
validateConfig,
getDecryptedPassword,
};