@agenticmail/enterprise 0.5.263 → 0.5.264
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-heartbeat-555FI2S4.js +510 -0
- package/dist/chunk-D4SZ5O2E.js +1224 -0
- package/dist/chunk-F5NPN7BG.js +3778 -0
- package/dist/chunk-JOEBIVIE.js +4732 -0
- package/dist/cli-agent-JIJ4TZPF.js +1768 -0
- package/dist/cli-serve-KCMTALAR.js +114 -0
- package/dist/cli.js +3 -3
- package/dist/index.js +3 -3
- package/dist/routes-B74M2BGU.js +14652 -0
- package/dist/runtime-KFA4Y37B.js +45 -0
- package/dist/server-J6BO4HOX.js +15 -0
- package/dist/setup-IVOR5YBA.js +20 -0
- package/package.json +1 -1
- package/src/database-access/agent-tools.ts +178 -0
- package/src/database-access/connection-manager.ts +943 -0
- package/src/database-access/index.ts +21 -0
- package/src/database-access/query-sanitizer.ts +220 -0
- package/src/database-access/routes.ts +193 -0
- package/src/database-access/types.ts +224 -0
- package/src/engine/routes.ts +7 -1
|
@@ -0,0 +1,943 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database Connection Manager
|
|
3
|
+
*
|
|
4
|
+
* Manages connection pools for all supported database types.
|
|
5
|
+
* Handles: connection lifecycle, pooling, health checks, SSH tunnels.
|
|
6
|
+
*
|
|
7
|
+
* All credentials are loaded from the SecureVault — never stored in memory
|
|
8
|
+
* longer than needed for connection establishment.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
DatabaseType,
|
|
13
|
+
DatabaseConnectionConfig,
|
|
14
|
+
AgentDatabaseAccess,
|
|
15
|
+
DatabaseQuery,
|
|
16
|
+
QueryResult,
|
|
17
|
+
ConnectionPoolStats,
|
|
18
|
+
DatabasePermission,
|
|
19
|
+
} from './types.js';
|
|
20
|
+
import { sanitizeQuery, classifyQuery, sanitizeForLogging, type SanitizeResult } from './query-sanitizer.js';
|
|
21
|
+
import crypto from 'crypto';
|
|
22
|
+
|
|
23
|
+
// ─── Driver Interfaces ───────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
interface DatabaseDriver {
|
|
26
|
+
connect(config: DatabaseConnectionConfig, credentials: ConnectionCredentials): Promise<DatabaseConnection>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface DatabaseConnection {
|
|
30
|
+
query(sql: string, params?: any[]): Promise<{ rows: any[]; fields?: { name: string; type: string }[]; affectedRows?: number }>;
|
|
31
|
+
close(): Promise<void>;
|
|
32
|
+
ping(): Promise<boolean>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface ConnectionCredentials {
|
|
36
|
+
password?: string;
|
|
37
|
+
connectionString?: string;
|
|
38
|
+
sshPrivateKey?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── Connection Pool Entry ───────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
interface PoolEntry {
|
|
44
|
+
connection: DatabaseConnection;
|
|
45
|
+
connectionId: string;
|
|
46
|
+
createdAt: number;
|
|
47
|
+
lastUsedAt: number;
|
|
48
|
+
inUse: boolean;
|
|
49
|
+
queryCount: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ─── Rate Limiter ────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
class RateLimiter {
|
|
55
|
+
private windows = new Map<string, { count: number; resetAt: number }>();
|
|
56
|
+
|
|
57
|
+
check(key: string, maxPerMinute: number): boolean {
|
|
58
|
+
const now = Date.now();
|
|
59
|
+
const window = this.windows.get(key);
|
|
60
|
+
if (!window || window.resetAt <= now) {
|
|
61
|
+
this.windows.set(key, { count: 1, resetAt: now + 60_000 });
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
if (window.count >= maxPerMinute) return false;
|
|
65
|
+
window.count++;
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ─── Audit Logger ────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
interface AuditLogDeps {
|
|
73
|
+
engineDb?: { execute(sql: string, params?: any[]): Promise<any> };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ─── Connection Manager ──────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
export class DatabaseConnectionManager {
|
|
79
|
+
private pools = new Map<string, PoolEntry[]>();
|
|
80
|
+
private configs = new Map<string, DatabaseConnectionConfig>();
|
|
81
|
+
private agentAccess = new Map<string, AgentDatabaseAccess[]>(); // agentId → accesses
|
|
82
|
+
private drivers = new Map<DatabaseType, DatabaseDriver>();
|
|
83
|
+
private rateLimiter = new RateLimiter();
|
|
84
|
+
private stats = new Map<string, { queries: number; errors: number; totalTimeMs: number; lastActivity: number }>();
|
|
85
|
+
private engineDb?: { execute(sql: string, params?: any[]): Promise<any>; all?(sql: string, params?: any[]): Promise<any[]> };
|
|
86
|
+
private vault?: {
|
|
87
|
+
storeSecret(orgId: string, name: string, category: string, plaintext: string, metadata?: Record<string, any>): Promise<any>;
|
|
88
|
+
getSecret(orgId: string, name: string, category: string): Promise<{ plaintext: string } | null>;
|
|
89
|
+
deleteSecret(id: string): Promise<void>;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
constructor(deps?: { engineDb?: any; vault?: any }) {
|
|
93
|
+
this.engineDb = deps?.engineDb;
|
|
94
|
+
this.vault = deps?.vault;
|
|
95
|
+
this.registerBuiltinDrivers();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ─── Initialization ──────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
async setDb(db: any): Promise<void> {
|
|
101
|
+
this.engineDb = db;
|
|
102
|
+
await this.init();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async init(): Promise<void> {
|
|
106
|
+
await this.ensureTable();
|
|
107
|
+
await this.loadConnections();
|
|
108
|
+
await this.loadAgentAccess();
|
|
109
|
+
console.log(`[db-access] Initialized: ${this.configs.size} connections, ${this.agentAccess.size} agent mappings`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private async ensureTable(): Promise<void> {
|
|
113
|
+
if (!this.engineDb) return;
|
|
114
|
+
try {
|
|
115
|
+
await this.engineDb.execute(`
|
|
116
|
+
CREATE TABLE IF NOT EXISTS database_connections (
|
|
117
|
+
id TEXT PRIMARY KEY,
|
|
118
|
+
org_id TEXT NOT NULL,
|
|
119
|
+
name TEXT NOT NULL,
|
|
120
|
+
type TEXT NOT NULL,
|
|
121
|
+
config JSONB NOT NULL DEFAULT '{}',
|
|
122
|
+
status TEXT NOT NULL DEFAULT 'inactive',
|
|
123
|
+
last_tested_at TEXT,
|
|
124
|
+
last_error TEXT,
|
|
125
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
126
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
127
|
+
)
|
|
128
|
+
`);
|
|
129
|
+
await this.engineDb.execute(`
|
|
130
|
+
CREATE TABLE IF NOT EXISTS agent_database_access (
|
|
131
|
+
id TEXT PRIMARY KEY,
|
|
132
|
+
org_id TEXT NOT NULL,
|
|
133
|
+
agent_id TEXT NOT NULL,
|
|
134
|
+
connection_id TEXT NOT NULL REFERENCES database_connections(id) ON DELETE CASCADE,
|
|
135
|
+
permissions TEXT NOT NULL DEFAULT '["read"]',
|
|
136
|
+
query_limits JSONB,
|
|
137
|
+
schema_access JSONB,
|
|
138
|
+
log_all_queries INTEGER NOT NULL DEFAULT 0,
|
|
139
|
+
require_approval INTEGER NOT NULL DEFAULT 0,
|
|
140
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
141
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
142
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
143
|
+
UNIQUE(agent_id, connection_id)
|
|
144
|
+
)
|
|
145
|
+
`);
|
|
146
|
+
await this.engineDb.execute(`
|
|
147
|
+
CREATE TABLE IF NOT EXISTS database_audit_log (
|
|
148
|
+
id TEXT PRIMARY KEY,
|
|
149
|
+
org_id TEXT NOT NULL,
|
|
150
|
+
agent_id TEXT NOT NULL,
|
|
151
|
+
agent_name TEXT,
|
|
152
|
+
connection_id TEXT NOT NULL,
|
|
153
|
+
connection_name TEXT,
|
|
154
|
+
operation TEXT NOT NULL,
|
|
155
|
+
query TEXT NOT NULL,
|
|
156
|
+
param_count INTEGER NOT NULL DEFAULT 0,
|
|
157
|
+
rows_affected INTEGER NOT NULL DEFAULT 0,
|
|
158
|
+
execution_time_ms INTEGER NOT NULL DEFAULT 0,
|
|
159
|
+
success INTEGER NOT NULL DEFAULT 1,
|
|
160
|
+
error TEXT,
|
|
161
|
+
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
|
|
162
|
+
)
|
|
163
|
+
`);
|
|
164
|
+
// Index for audit log queries
|
|
165
|
+
await this.engineDb.execute(`CREATE INDEX IF NOT EXISTS idx_db_audit_agent ON database_audit_log(agent_id, timestamp)`).catch(() => {});
|
|
166
|
+
await this.engineDb.execute(`CREATE INDEX IF NOT EXISTS idx_db_audit_conn ON database_audit_log(connection_id, timestamp)`).catch(() => {});
|
|
167
|
+
} catch (err: any) {
|
|
168
|
+
console.error(`[db-access] Table creation failed:`, err.message);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private async loadConnections(): Promise<void> {
|
|
173
|
+
if (!this.engineDb) return;
|
|
174
|
+
try {
|
|
175
|
+
const getter = (this.engineDb as any).all || ((sql: string, params?: any[]) => this.engineDb!.execute(sql, params));
|
|
176
|
+
const rows = await getter.call(this.engineDb, 'SELECT * FROM database_connections');
|
|
177
|
+
for (const row of (rows || [])) {
|
|
178
|
+
const config = this.rowToConfig(row);
|
|
179
|
+
this.configs.set(config.id, config);
|
|
180
|
+
}
|
|
181
|
+
} catch (err: any) {
|
|
182
|
+
console.warn(`[db-access] Failed to load connections: ${err.message}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private async loadAgentAccess(): Promise<void> {
|
|
187
|
+
if (!this.engineDb) return;
|
|
188
|
+
try {
|
|
189
|
+
const getter = (this.engineDb as any).all || ((sql: string, params?: any[]) => this.engineDb!.execute(sql, params));
|
|
190
|
+
const rows = await getter.call(this.engineDb, 'SELECT * FROM agent_database_access WHERE enabled = 1');
|
|
191
|
+
for (const row of (rows || [])) {
|
|
192
|
+
const access = this.rowToAccess(row);
|
|
193
|
+
const list = this.agentAccess.get(access.agentId) || [];
|
|
194
|
+
list.push(access);
|
|
195
|
+
this.agentAccess.set(access.agentId, list);
|
|
196
|
+
}
|
|
197
|
+
} catch (err: any) {
|
|
198
|
+
console.warn(`[db-access] Failed to load agent access: ${err.message}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ─── CRUD: Connections ─────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
async createConnection(config: Omit<DatabaseConnectionConfig, 'id' | 'createdAt' | 'updatedAt'>, credentials?: { password?: string; connectionString?: string }): Promise<DatabaseConnectionConfig> {
|
|
205
|
+
const id = crypto.randomUUID();
|
|
206
|
+
const now = new Date().toISOString();
|
|
207
|
+
const full: DatabaseConnectionConfig = { ...config, id, createdAt: now, updatedAt: now };
|
|
208
|
+
|
|
209
|
+
// Store credentials in vault
|
|
210
|
+
if (credentials?.password && this.vault) {
|
|
211
|
+
await this.vault.storeSecret(config.orgId, `db:${id}:password`, 'database_credential', credentials.password);
|
|
212
|
+
}
|
|
213
|
+
if (credentials?.connectionString && this.vault) {
|
|
214
|
+
await this.vault.storeSecret(config.orgId, `db:${id}:connection_string`, 'database_credential', credentials.connectionString);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Store config (without credentials) in DB
|
|
218
|
+
if (this.engineDb) {
|
|
219
|
+
await this.engineDb.execute(
|
|
220
|
+
`INSERT INTO database_connections (id, org_id, name, type, config, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
221
|
+
[id, full.orgId, full.name, full.type, JSON.stringify(this.configToStorable(full)), full.status, now, now]
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
this.configs.set(id, full);
|
|
226
|
+
console.log(`[db-access] Connection created: ${full.name} (${full.type})`);
|
|
227
|
+
return full;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async updateConnection(id: string, updates: Partial<DatabaseConnectionConfig>, credentials?: { password?: string; connectionString?: string }): Promise<DatabaseConnectionConfig | null> {
|
|
231
|
+
const existing = this.configs.get(id);
|
|
232
|
+
if (!existing) return null;
|
|
233
|
+
|
|
234
|
+
const updated = { ...existing, ...updates, id, updatedAt: new Date().toISOString() };
|
|
235
|
+
|
|
236
|
+
if (credentials?.password && this.vault) {
|
|
237
|
+
await this.vault.storeSecret(existing.orgId, `db:${id}:password`, 'database_credential', credentials.password);
|
|
238
|
+
}
|
|
239
|
+
if (credentials?.connectionString && this.vault) {
|
|
240
|
+
await this.vault.storeSecret(existing.orgId, `db:${id}:connection_string`, 'database_credential', credentials.connectionString);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (this.engineDb) {
|
|
244
|
+
await this.engineDb.execute(
|
|
245
|
+
`UPDATE database_connections SET name = ?, type = ?, config = ?, status = ?, updated_at = ? WHERE id = ?`,
|
|
246
|
+
[updated.name, updated.type, JSON.stringify(this.configToStorable(updated)), updated.status, updated.updatedAt, id]
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
this.configs.set(id, updated);
|
|
251
|
+
|
|
252
|
+
// Close existing pool connections on config change
|
|
253
|
+
await this.closePool(id);
|
|
254
|
+
|
|
255
|
+
return updated;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async deleteConnection(id: string): Promise<boolean> {
|
|
259
|
+
const config = this.configs.get(id);
|
|
260
|
+
if (!config) return false;
|
|
261
|
+
|
|
262
|
+
await this.closePool(id);
|
|
263
|
+
|
|
264
|
+
// Remove vault secrets
|
|
265
|
+
if (this.vault) {
|
|
266
|
+
try {
|
|
267
|
+
const pwSecret = await this.vault.getSecret(config.orgId, `db:${id}:password`, 'database_credential');
|
|
268
|
+
if (pwSecret) await this.vault.deleteSecret((pwSecret as any).id);
|
|
269
|
+
const csSecret = await this.vault.getSecret(config.orgId, `db:${id}:connection_string`, 'database_credential');
|
|
270
|
+
if (csSecret) await this.vault.deleteSecret((csSecret as any).id);
|
|
271
|
+
} catch { /* non-fatal */ }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (this.engineDb) {
|
|
275
|
+
await this.engineDb.execute('DELETE FROM agent_database_access WHERE connection_id = ?', [id]);
|
|
276
|
+
await this.engineDb.execute('DELETE FROM database_connections WHERE id = ?', [id]);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
this.configs.delete(id);
|
|
280
|
+
// Remove from all agent access lists
|
|
281
|
+
for (const [agentId, list] of this.agentAccess) {
|
|
282
|
+
const filtered = list.filter(a => a.connectionId !== id);
|
|
283
|
+
if (filtered.length === 0) this.agentAccess.delete(agentId);
|
|
284
|
+
else this.agentAccess.set(agentId, filtered);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
console.log(`[db-access] Connection deleted: ${config.name}`);
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
getConnection(id: string): DatabaseConnectionConfig | undefined {
|
|
292
|
+
return this.configs.get(id);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
listConnections(orgId: string): DatabaseConnectionConfig[] {
|
|
296
|
+
return [...this.configs.values()].filter(c => c.orgId === orgId);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ─── CRUD: Agent Access ────────────────────────────────────────────────────
|
|
300
|
+
|
|
301
|
+
async grantAccess(access: Omit<AgentDatabaseAccess, 'id' | 'createdAt' | 'updatedAt'>): Promise<AgentDatabaseAccess> {
|
|
302
|
+
const id = crypto.randomUUID();
|
|
303
|
+
const now = new Date().toISOString();
|
|
304
|
+
const full: AgentDatabaseAccess = { ...access, id, createdAt: now, updatedAt: now };
|
|
305
|
+
|
|
306
|
+
if (this.engineDb) {
|
|
307
|
+
await this.engineDb.execute(
|
|
308
|
+
`INSERT INTO agent_database_access (id, org_id, agent_id, connection_id, permissions, query_limits, schema_access, log_all_queries, require_approval, enabled, created_at, updated_at)
|
|
309
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
310
|
+
[
|
|
311
|
+
id, full.orgId, full.agentId, full.connectionId,
|
|
312
|
+
JSON.stringify(full.permissions), JSON.stringify(full.queryLimits || null),
|
|
313
|
+
JSON.stringify(full.schemaAccess || null), full.logAllQueries ? 1 : 0,
|
|
314
|
+
full.requireApproval ? 1 : 0, full.enabled ? 1 : 0, now, now,
|
|
315
|
+
]
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const list = this.agentAccess.get(full.agentId) || [];
|
|
320
|
+
list.push(full);
|
|
321
|
+
this.agentAccess.set(full.agentId, list);
|
|
322
|
+
|
|
323
|
+
return full;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async revokeAccess(agentId: string, connectionId: string): Promise<boolean> {
|
|
327
|
+
if (this.engineDb) {
|
|
328
|
+
await this.engineDb.execute(
|
|
329
|
+
'DELETE FROM agent_database_access WHERE agent_id = ? AND connection_id = ?',
|
|
330
|
+
[agentId, connectionId]
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const list = this.agentAccess.get(agentId) || [];
|
|
335
|
+
const filtered = list.filter(a => a.connectionId !== connectionId);
|
|
336
|
+
if (filtered.length === 0) this.agentAccess.delete(agentId);
|
|
337
|
+
else this.agentAccess.set(agentId, filtered);
|
|
338
|
+
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async updateAccess(agentId: string, connectionId: string, updates: Partial<AgentDatabaseAccess>): Promise<AgentDatabaseAccess | null> {
|
|
343
|
+
const list = this.agentAccess.get(agentId) || [];
|
|
344
|
+
const idx = list.findIndex(a => a.connectionId === connectionId);
|
|
345
|
+
if (idx === -1) return null;
|
|
346
|
+
|
|
347
|
+
const updated = { ...list[idx], ...updates, updatedAt: new Date().toISOString() };
|
|
348
|
+
list[idx] = updated;
|
|
349
|
+
this.agentAccess.set(agentId, list);
|
|
350
|
+
|
|
351
|
+
if (this.engineDb) {
|
|
352
|
+
await this.engineDb.execute(
|
|
353
|
+
`UPDATE agent_database_access SET permissions = ?, query_limits = ?, schema_access = ?, log_all_queries = ?, require_approval = ?, enabled = ?, updated_at = ? WHERE agent_id = ? AND connection_id = ?`,
|
|
354
|
+
[
|
|
355
|
+
JSON.stringify(updated.permissions), JSON.stringify(updated.queryLimits || null),
|
|
356
|
+
JSON.stringify(updated.schemaAccess || null), updated.logAllQueries ? 1 : 0,
|
|
357
|
+
updated.requireApproval ? 1 : 0, updated.enabled ? 1 : 0, updated.updatedAt,
|
|
358
|
+
agentId, connectionId,
|
|
359
|
+
]
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return updated;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
getAgentAccess(agentId: string): AgentDatabaseAccess[] {
|
|
367
|
+
return this.agentAccess.get(agentId) || [];
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
getConnectionAgents(connectionId: string): AgentDatabaseAccess[] {
|
|
371
|
+
const result: AgentDatabaseAccess[] = [];
|
|
372
|
+
for (const list of this.agentAccess.values()) {
|
|
373
|
+
for (const a of list) {
|
|
374
|
+
if (a.connectionId === connectionId) result.push(a);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return result;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ─── Query Execution ──────────────────────────────────────────────────────
|
|
381
|
+
|
|
382
|
+
async executeQuery(query: DatabaseQuery): Promise<QueryResult> {
|
|
383
|
+
const startMs = Date.now();
|
|
384
|
+
const queryId = crypto.randomUUID();
|
|
385
|
+
|
|
386
|
+
// 1. Validate agent has access to this connection
|
|
387
|
+
const access = this.getAgentAccess(query.agentId).find(a => a.connectionId === query.connectionId && a.enabled);
|
|
388
|
+
if (!access) {
|
|
389
|
+
return { success: false, error: 'Agent does not have access to this database', executionTimeMs: 0, queryId };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const config = this.configs.get(query.connectionId);
|
|
393
|
+
if (!config || config.status !== 'active') {
|
|
394
|
+
return { success: false, error: 'Database connection is not active', executionTimeMs: 0, queryId };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// 2. Rate limit check
|
|
398
|
+
const rateLimit = access.queryLimits?.maxQueriesPerMinute ?? 60;
|
|
399
|
+
const rateKey = `${query.agentId}:${query.connectionId}`;
|
|
400
|
+
if (!this.rateLimiter.check(rateKey, rateLimit)) {
|
|
401
|
+
return { success: false, error: `Rate limit exceeded (${rateLimit}/min)`, executionTimeMs: 0, queryId };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// 3. Sanitize SQL query
|
|
405
|
+
if (!query.sql) {
|
|
406
|
+
return { success: false, error: 'No SQL query provided', executionTimeMs: 0, queryId };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const sanitizeResult = sanitizeQuery(query.sql, access.permissions, config, access);
|
|
410
|
+
if (!sanitizeResult.allowed) {
|
|
411
|
+
await this.logAudit(query, config, access, 'read', 0, false, sanitizeResult.reason || 'Query blocked', startMs, queryId);
|
|
412
|
+
return { success: false, error: sanitizeResult.reason, executionTimeMs: Date.now() - startMs, queryId };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const finalSql = sanitizeResult.sanitizedQuery || query.sql;
|
|
416
|
+
|
|
417
|
+
// 4. Check concurrent query limit
|
|
418
|
+
const maxConcurrent = access.queryLimits?.maxConcurrentQueries ?? config.queryLimits?.maxConcurrentQueries ?? 5;
|
|
419
|
+
// (In production, track active queries per connection — simplified here)
|
|
420
|
+
|
|
421
|
+
// 5. Execute with timeout
|
|
422
|
+
const timeout = access.queryLimits?.queryTimeoutMs ?? config.queryLimits?.queryTimeoutMs ?? 30_000;
|
|
423
|
+
|
|
424
|
+
try {
|
|
425
|
+
const conn = await this.getPooledConnection(query.connectionId);
|
|
426
|
+
|
|
427
|
+
const result = await Promise.race([
|
|
428
|
+
conn.query(finalSql, query.params),
|
|
429
|
+
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('Query timeout')), timeout)),
|
|
430
|
+
]);
|
|
431
|
+
|
|
432
|
+
const execMs = Date.now() - startMs;
|
|
433
|
+
const rows = result.rows || [];
|
|
434
|
+
const affectedRows = result.affectedRows ?? rows.length;
|
|
435
|
+
|
|
436
|
+
// Check row limits
|
|
437
|
+
const maxRows = sanitizeResult.operation === 'read'
|
|
438
|
+
? (access.queryLimits?.maxRowsRead ?? config.queryLimits?.maxRowsRead ?? 10000)
|
|
439
|
+
: sanitizeResult.operation === 'write'
|
|
440
|
+
? (access.queryLimits?.maxRowsWrite ?? config.queryLimits?.maxRowsWrite ?? 1000)
|
|
441
|
+
: (access.queryLimits?.maxRowsDelete ?? config.queryLimits?.maxRowsDelete ?? 100);
|
|
442
|
+
|
|
443
|
+
const truncated = rows.length > maxRows;
|
|
444
|
+
const limitedRows = truncated ? rows.slice(0, maxRows) : rows;
|
|
445
|
+
|
|
446
|
+
// Update stats
|
|
447
|
+
this.updateStats(query.connectionId, execMs, false);
|
|
448
|
+
|
|
449
|
+
// Audit log
|
|
450
|
+
const shouldLog = access.logAllQueries || sanitizeResult.operation !== 'read';
|
|
451
|
+
if (shouldLog) {
|
|
452
|
+
await this.logAudit(query, config, access, sanitizeResult.operation, affectedRows, true, undefined, startMs, queryId);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return {
|
|
456
|
+
success: true,
|
|
457
|
+
rows: limitedRows,
|
|
458
|
+
rowCount: limitedRows.length,
|
|
459
|
+
affectedRows,
|
|
460
|
+
fields: result.fields,
|
|
461
|
+
executionTimeMs: execMs,
|
|
462
|
+
truncated,
|
|
463
|
+
queryId,
|
|
464
|
+
};
|
|
465
|
+
} catch (err: any) {
|
|
466
|
+
const execMs = Date.now() - startMs;
|
|
467
|
+
this.updateStats(query.connectionId, execMs, true);
|
|
468
|
+
await this.logAudit(query, config, access, sanitizeResult.operation, 0, false, err.message, startMs, queryId);
|
|
469
|
+
return { success: false, error: err.message, executionTimeMs: execMs, queryId };
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ─── Connection Testing ────────────────────────────────────────────────────
|
|
474
|
+
|
|
475
|
+
async testConnection(connectionId: string): Promise<{ success: boolean; latencyMs: number; error?: string; version?: string }> {
|
|
476
|
+
const config = this.configs.get(connectionId);
|
|
477
|
+
if (!config) return { success: false, latencyMs: 0, error: 'Connection not found' };
|
|
478
|
+
|
|
479
|
+
const startMs = Date.now();
|
|
480
|
+
try {
|
|
481
|
+
const conn = await this.getPooledConnection(connectionId);
|
|
482
|
+
const alive = await conn.ping();
|
|
483
|
+
const latencyMs = Date.now() - startMs;
|
|
484
|
+
|
|
485
|
+
// Update status
|
|
486
|
+
await this.updateConnection(connectionId, {
|
|
487
|
+
status: alive ? 'active' : 'error',
|
|
488
|
+
lastTestedAt: new Date().toISOString(),
|
|
489
|
+
lastError: alive ? undefined : 'Ping failed',
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
return { success: alive, latencyMs };
|
|
493
|
+
} catch (err: any) {
|
|
494
|
+
await this.updateConnection(connectionId, {
|
|
495
|
+
status: 'error',
|
|
496
|
+
lastTestedAt: new Date().toISOString(),
|
|
497
|
+
lastError: err.message,
|
|
498
|
+
});
|
|
499
|
+
return { success: false, latencyMs: Date.now() - startMs, error: err.message };
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ─── Pool Management ───────────────────────────────────────────────────────
|
|
504
|
+
|
|
505
|
+
private async getPooledConnection(connectionId: string): Promise<DatabaseConnection> {
|
|
506
|
+
const pool = this.pools.get(connectionId) || [];
|
|
507
|
+
|
|
508
|
+
// Find idle connection
|
|
509
|
+
const idle = pool.find(p => !p.inUse);
|
|
510
|
+
if (idle) {
|
|
511
|
+
idle.inUse = true;
|
|
512
|
+
idle.lastUsedAt = Date.now();
|
|
513
|
+
return this.wrapPooledConnection(idle);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Create new connection
|
|
517
|
+
const config = this.configs.get(connectionId);
|
|
518
|
+
if (!config) throw new Error('Connection not found');
|
|
519
|
+
|
|
520
|
+
const maxPool = config.pool?.max ?? 10;
|
|
521
|
+
if (pool.length >= maxPool) throw new Error('Connection pool exhausted');
|
|
522
|
+
|
|
523
|
+
const driver = this.drivers.get(config.type);
|
|
524
|
+
if (!driver) throw new Error(`No driver for database type: ${config.type}`);
|
|
525
|
+
|
|
526
|
+
// Load credentials from vault
|
|
527
|
+
const credentials = await this.loadCredentials(config);
|
|
528
|
+
const connection = await driver.connect(config, credentials);
|
|
529
|
+
|
|
530
|
+
const entry: PoolEntry = {
|
|
531
|
+
connection,
|
|
532
|
+
connectionId,
|
|
533
|
+
createdAt: Date.now(),
|
|
534
|
+
lastUsedAt: Date.now(),
|
|
535
|
+
inUse: true,
|
|
536
|
+
queryCount: 0,
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
pool.push(entry);
|
|
540
|
+
this.pools.set(connectionId, pool);
|
|
541
|
+
|
|
542
|
+
return this.wrapPooledConnection(entry);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
private wrapPooledConnection(entry: PoolEntry): DatabaseConnection {
|
|
546
|
+
return {
|
|
547
|
+
query: async (sql, params) => {
|
|
548
|
+
entry.queryCount++;
|
|
549
|
+
return entry.connection.query(sql, params);
|
|
550
|
+
},
|
|
551
|
+
close: async () => { entry.inUse = false; }, // Return to pool, don't actually close
|
|
552
|
+
ping: () => entry.connection.ping(),
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
private async closePool(connectionId: string): Promise<void> {
|
|
557
|
+
const pool = this.pools.get(connectionId) || [];
|
|
558
|
+
for (const entry of pool) {
|
|
559
|
+
try { await entry.connection.close(); } catch { /* ignore */ }
|
|
560
|
+
}
|
|
561
|
+
this.pools.delete(connectionId);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private async loadCredentials(config: DatabaseConnectionConfig): Promise<ConnectionCredentials> {
|
|
565
|
+
const creds: ConnectionCredentials = {};
|
|
566
|
+
if (this.vault) {
|
|
567
|
+
try {
|
|
568
|
+
const pw = await this.vault.getSecret(config.orgId, `db:${config.id}:password`, 'database_credential');
|
|
569
|
+
if (pw) creds.password = pw.plaintext;
|
|
570
|
+
} catch { /* no password stored */ }
|
|
571
|
+
try {
|
|
572
|
+
const cs = await this.vault.getSecret(config.orgId, `db:${config.id}:connection_string`, 'database_credential');
|
|
573
|
+
if (cs) creds.connectionString = cs.plaintext;
|
|
574
|
+
} catch { /* no connection string stored */ }
|
|
575
|
+
if (config.sshTunnel?.enabled) {
|
|
576
|
+
try {
|
|
577
|
+
const ssh = await this.vault.getSecret(config.orgId, `db:${config.id}:ssh_key`, 'database_credential');
|
|
578
|
+
if (ssh) creds.sshPrivateKey = ssh.plaintext;
|
|
579
|
+
} catch { /* no SSH key stored */ }
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return creds;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ─── Stats ─────────────────────────────────────────────────────────────────
|
|
586
|
+
|
|
587
|
+
getPoolStats(connectionId: string): ConnectionPoolStats {
|
|
588
|
+
const pool = this.pools.get(connectionId) || [];
|
|
589
|
+
const s = this.stats.get(connectionId) || { queries: 0, errors: 0, totalTimeMs: 0, lastActivity: 0 };
|
|
590
|
+
|
|
591
|
+
return {
|
|
592
|
+
connectionId,
|
|
593
|
+
totalConnections: pool.length,
|
|
594
|
+
activeConnections: pool.filter(p => p.inUse).length,
|
|
595
|
+
idleConnections: pool.filter(p => !p.inUse).length,
|
|
596
|
+
waitingRequests: 0,
|
|
597
|
+
queriesExecuted: s.queries,
|
|
598
|
+
averageQueryTimeMs: s.queries > 0 ? Math.round(s.totalTimeMs / s.queries) : 0,
|
|
599
|
+
errorCount: s.errors,
|
|
600
|
+
lastActivityAt: s.lastActivity ? new Date(s.lastActivity).toISOString() : '',
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
private updateStats(connectionId: string, timeMs: number, isError: boolean): void {
|
|
605
|
+
const s = this.stats.get(connectionId) || { queries: 0, errors: 0, totalTimeMs: 0, lastActivity: 0 };
|
|
606
|
+
s.queries++;
|
|
607
|
+
s.totalTimeMs += timeMs;
|
|
608
|
+
if (isError) s.errors++;
|
|
609
|
+
s.lastActivity = Date.now();
|
|
610
|
+
this.stats.set(connectionId, s);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// ─── Audit Logging ─────────────────────────────────────────────────────────
|
|
614
|
+
|
|
615
|
+
private async logAudit(
|
|
616
|
+
query: DatabaseQuery,
|
|
617
|
+
config: DatabaseConnectionConfig,
|
|
618
|
+
access: AgentDatabaseAccess,
|
|
619
|
+
operation: string,
|
|
620
|
+
rowsAffected: number,
|
|
621
|
+
success: boolean,
|
|
622
|
+
error: string | undefined,
|
|
623
|
+
startMs: number,
|
|
624
|
+
queryId: string,
|
|
625
|
+
): Promise<void> {
|
|
626
|
+
if (!this.engineDb) return;
|
|
627
|
+
try {
|
|
628
|
+
await this.engineDb.execute(
|
|
629
|
+
`INSERT INTO database_audit_log (id, org_id, agent_id, connection_id, connection_name, operation, query, param_count, rows_affected, execution_time_ms, success, error, timestamp)
|
|
630
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
631
|
+
[
|
|
632
|
+
queryId, config.orgId, query.agentId, query.connectionId, config.name,
|
|
633
|
+
operation, sanitizeForLogging(query.sql || ''), (query.params || []).length,
|
|
634
|
+
rowsAffected, Date.now() - startMs, success ? 1 : 0, error || null,
|
|
635
|
+
new Date().toISOString(),
|
|
636
|
+
]
|
|
637
|
+
);
|
|
638
|
+
} catch (err: any) {
|
|
639
|
+
console.warn(`[db-access] Audit log failed: ${err.message}`);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
async getAuditLog(opts: { orgId: string; agentId?: string; connectionId?: string; limit?: number; offset?: number }): Promise<any[]> {
|
|
644
|
+
if (!this.engineDb) return [];
|
|
645
|
+
const conditions = ['org_id = ?'];
|
|
646
|
+
const params: any[] = [opts.orgId];
|
|
647
|
+
if (opts.agentId) { conditions.push('agent_id = ?'); params.push(opts.agentId); }
|
|
648
|
+
if (opts.connectionId) { conditions.push('connection_id = ?'); params.push(opts.connectionId); }
|
|
649
|
+
params.push(opts.limit ?? 100, opts.offset ?? 0);
|
|
650
|
+
|
|
651
|
+
const getter = (this.engineDb as any).all || ((sql: string, p?: any[]) => this.engineDb!.execute(sql, p));
|
|
652
|
+
return getter.call(this.engineDb,
|
|
653
|
+
`SELECT * FROM database_audit_log WHERE ${conditions.join(' AND ')} ORDER BY timestamp DESC LIMIT ? OFFSET ?`,
|
|
654
|
+
params
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// ─── Driver Registration ───────────────────────────────────────────────────
|
|
659
|
+
|
|
660
|
+
registerDriver(type: DatabaseType, driver: DatabaseDriver): void {
|
|
661
|
+
this.drivers.set(type, driver);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
private registerBuiltinDrivers(): void {
|
|
665
|
+
// PostgreSQL / CockroachDB / Supabase / Neon
|
|
666
|
+
const pgDriver: DatabaseDriver = {
|
|
667
|
+
async connect(config, credentials) {
|
|
668
|
+
if (credentials.connectionString) {
|
|
669
|
+
const pgMod = await import('postgres' as string);
|
|
670
|
+
const pgFn = pgMod.default || pgMod;
|
|
671
|
+
const sql = pgFn(credentials.connectionString, {
|
|
672
|
+
max: config.pool?.max ?? 10,
|
|
673
|
+
idle_timeout: (config.pool?.idleTimeoutMs ?? 30000) / 1000,
|
|
674
|
+
connect_timeout: (config.pool?.acquireTimeoutMs ?? 10000) / 1000,
|
|
675
|
+
ssl: config.ssl ? (config.sslRejectUnauthorized === false ? 'prefer' as any : 'require' as any) : false,
|
|
676
|
+
});
|
|
677
|
+
return {
|
|
678
|
+
async query(q: string, params?: any[]) {
|
|
679
|
+
const result = params?.length
|
|
680
|
+
? await sql.unsafe(q, params)
|
|
681
|
+
: await sql.unsafe(q);
|
|
682
|
+
return {
|
|
683
|
+
rows: [...result],
|
|
684
|
+
affectedRows: result.count,
|
|
685
|
+
fields: result.columns?.map((c: any) => ({ name: c.name, type: String(c.type) })),
|
|
686
|
+
};
|
|
687
|
+
},
|
|
688
|
+
async close() { await sql.end(); },
|
|
689
|
+
async ping() { try { await sql`SELECT 1`; return true; } catch { return false; } },
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
// Fallback: construct connection from parts
|
|
693
|
+
const connStr = `postgresql://${encodeURIComponent(config.username || '')}:${encodeURIComponent(credentials.password || '')}@${config.host || 'localhost'}:${config.port || 5432}/${config.database || 'postgres'}`;
|
|
694
|
+
const pgMod2 = await import('postgres' as string);
|
|
695
|
+
const postgres = pgMod2.default || pgMod2;
|
|
696
|
+
const sql = postgres(connStr, {
|
|
697
|
+
max: config.pool?.max ?? 10,
|
|
698
|
+
ssl: config.ssl ? {} : false,
|
|
699
|
+
});
|
|
700
|
+
return {
|
|
701
|
+
async query(q: string, params?: any[]) {
|
|
702
|
+
const result = params?.length ? await sql.unsafe(q, params) : await sql.unsafe(q);
|
|
703
|
+
return { rows: [...result], affectedRows: result.count };
|
|
704
|
+
},
|
|
705
|
+
async close() { await sql.end(); },
|
|
706
|
+
async ping() { try { await sql`SELECT 1`; return true; } catch { return false; } },
|
|
707
|
+
};
|
|
708
|
+
},
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
this.drivers.set('postgresql', pgDriver);
|
|
712
|
+
this.drivers.set('cockroachdb', pgDriver);
|
|
713
|
+
this.drivers.set('supabase', pgDriver);
|
|
714
|
+
this.drivers.set('neon', pgDriver);
|
|
715
|
+
|
|
716
|
+
// MySQL / MariaDB / PlanetScale
|
|
717
|
+
const mysqlDriver: DatabaseDriver = {
|
|
718
|
+
async connect(config, credentials) {
|
|
719
|
+
const mysql2 = await import('mysql2/promise' as string);
|
|
720
|
+
const pool = mysql2.createPool({
|
|
721
|
+
host: config.host || 'localhost',
|
|
722
|
+
port: config.port || 3306,
|
|
723
|
+
user: config.username,
|
|
724
|
+
password: credentials.password,
|
|
725
|
+
database: config.database,
|
|
726
|
+
connectionLimit: config.pool?.max ?? 10,
|
|
727
|
+
ssl: config.ssl ? {} : undefined,
|
|
728
|
+
uri: credentials.connectionString || undefined,
|
|
729
|
+
});
|
|
730
|
+
return {
|
|
731
|
+
async query(q: string, params?: any[]) {
|
|
732
|
+
const [rows, fields] = await pool.execute(q, params);
|
|
733
|
+
const resultRows = Array.isArray(rows) ? rows : [];
|
|
734
|
+
return {
|
|
735
|
+
rows: resultRows as any[],
|
|
736
|
+
affectedRows: (rows as any).affectedRows,
|
|
737
|
+
fields: (fields as any[])?.map((f: any) => ({ name: f.name, type: String(f.type) })),
|
|
738
|
+
};
|
|
739
|
+
},
|
|
740
|
+
async close() { await pool.end(); },
|
|
741
|
+
async ping() { try { await pool.execute('SELECT 1'); return true; } catch { return false; } },
|
|
742
|
+
};
|
|
743
|
+
},
|
|
744
|
+
};
|
|
745
|
+
|
|
746
|
+
this.drivers.set('mysql', mysqlDriver);
|
|
747
|
+
this.drivers.set('mariadb', mysqlDriver);
|
|
748
|
+
this.drivers.set('planetscale', mysqlDriver);
|
|
749
|
+
|
|
750
|
+
// SQLite
|
|
751
|
+
this.drivers.set('sqlite', {
|
|
752
|
+
async connect(config, _credentials) {
|
|
753
|
+
const { default: Database } = await import('better-sqlite3' as string);
|
|
754
|
+
const db = new Database(config.database || ':memory:', { readonly: !config.host });
|
|
755
|
+
return {
|
|
756
|
+
async query(q: string, params?: any[]) {
|
|
757
|
+
try {
|
|
758
|
+
if (q.trim().toUpperCase().startsWith('SELECT') || q.trim().toUpperCase().startsWith('WITH') || q.trim().toUpperCase().startsWith('PRAGMA')) {
|
|
759
|
+
const stmt = db.prepare(q);
|
|
760
|
+
const rows = params?.length ? stmt.all(...params) : stmt.all();
|
|
761
|
+
return { rows, fields: rows.length > 0 ? Object.keys(rows[0]).map(k => ({ name: k, type: 'unknown' })) : [] };
|
|
762
|
+
} else {
|
|
763
|
+
const stmt = db.prepare(q);
|
|
764
|
+
const result = params?.length ? stmt.run(...params) : stmt.run();
|
|
765
|
+
return { rows: [], affectedRows: result.changes };
|
|
766
|
+
}
|
|
767
|
+
} catch (err: any) {
|
|
768
|
+
throw err;
|
|
769
|
+
}
|
|
770
|
+
},
|
|
771
|
+
async close() { db.close(); },
|
|
772
|
+
async ping() { try { db.prepare('SELECT 1').get(); return true; } catch { return false; } },
|
|
773
|
+
};
|
|
774
|
+
},
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
// Turso / LibSQL
|
|
778
|
+
this.drivers.set('turso', {
|
|
779
|
+
async connect(config, credentials) {
|
|
780
|
+
const { createClient } = await import('@libsql/client' as string);
|
|
781
|
+
const client = createClient({
|
|
782
|
+
url: credentials.connectionString || `libsql://${config.host}`,
|
|
783
|
+
authToken: credentials.password,
|
|
784
|
+
});
|
|
785
|
+
return {
|
|
786
|
+
async query(q: string, params?: any[]) {
|
|
787
|
+
const result = await client.execute({ sql: q, args: params || [] });
|
|
788
|
+
return {
|
|
789
|
+
rows: result.rows as any[],
|
|
790
|
+
affectedRows: result.rowsAffected,
|
|
791
|
+
fields: result.columns?.map((c: any) => ({ name: String(c), type: 'unknown' })),
|
|
792
|
+
};
|
|
793
|
+
},
|
|
794
|
+
async close() { client.close(); },
|
|
795
|
+
async ping() { try { await client.execute('SELECT 1'); return true; } catch { return false; } },
|
|
796
|
+
};
|
|
797
|
+
},
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
// MongoDB
|
|
801
|
+
this.drivers.set('mongodb', {
|
|
802
|
+
async connect(config, credentials) {
|
|
803
|
+
const { MongoClient } = await import('mongodb' as string);
|
|
804
|
+
const uri = credentials.connectionString || `mongodb://${config.username}:${encodeURIComponent(credentials.password || '')}@${config.host || 'localhost'}:${config.port || 27017}/${config.database || 'admin'}`;
|
|
805
|
+
const client = new MongoClient(uri, {
|
|
806
|
+
maxPoolSize: config.pool?.max ?? 10,
|
|
807
|
+
tls: config.ssl || false,
|
|
808
|
+
});
|
|
809
|
+
await client.connect();
|
|
810
|
+
const db = client.db(config.database);
|
|
811
|
+
return {
|
|
812
|
+
async query(q: string, _params?: any[]) {
|
|
813
|
+
// MongoDB queries come as JSON strings: { collection: "users", operation: "find", filter: {...} }
|
|
814
|
+
try {
|
|
815
|
+
const cmd = JSON.parse(q);
|
|
816
|
+
const collection = db.collection(cmd.collection);
|
|
817
|
+
if (cmd.operation === 'find') {
|
|
818
|
+
const cursor = collection.find(cmd.filter || {});
|
|
819
|
+
if (cmd.limit) cursor.limit(cmd.limit);
|
|
820
|
+
if (cmd.sort) cursor.sort(cmd.sort);
|
|
821
|
+
const rows = await cursor.toArray();
|
|
822
|
+
return { rows };
|
|
823
|
+
} else if (cmd.operation === 'insertOne') {
|
|
824
|
+
const result = await collection.insertOne(cmd.document);
|
|
825
|
+
return { rows: [{ insertedId: result.insertedId }], affectedRows: 1 };
|
|
826
|
+
} else if (cmd.operation === 'insertMany') {
|
|
827
|
+
const result = await collection.insertMany(cmd.documents);
|
|
828
|
+
return { rows: [{ insertedCount: result.insertedCount }], affectedRows: result.insertedCount };
|
|
829
|
+
} else if (cmd.operation === 'updateOne' || cmd.operation === 'updateMany') {
|
|
830
|
+
const fn = cmd.operation === 'updateOne' ? collection.updateOne.bind(collection) : collection.updateMany.bind(collection);
|
|
831
|
+
const result = await fn(cmd.filter || {}, cmd.update);
|
|
832
|
+
return { rows: [], affectedRows: result.modifiedCount };
|
|
833
|
+
} else if (cmd.operation === 'deleteOne' || cmd.operation === 'deleteMany') {
|
|
834
|
+
const fn = cmd.operation === 'deleteOne' ? collection.deleteOne.bind(collection) : collection.deleteMany.bind(collection);
|
|
835
|
+
const result = await fn(cmd.filter || {});
|
|
836
|
+
return { rows: [], affectedRows: result.deletedCount };
|
|
837
|
+
} else if (cmd.operation === 'aggregate') {
|
|
838
|
+
const rows = await collection.aggregate(cmd.pipeline || []).toArray();
|
|
839
|
+
return { rows };
|
|
840
|
+
} else if (cmd.operation === 'count') {
|
|
841
|
+
const count = await collection.countDocuments(cmd.filter || {});
|
|
842
|
+
return { rows: [{ count }] };
|
|
843
|
+
}
|
|
844
|
+
throw new Error(`Unknown MongoDB operation: ${cmd.operation}`);
|
|
845
|
+
} catch (err: any) {
|
|
846
|
+
if (err.message.startsWith('Unknown MongoDB')) throw err;
|
|
847
|
+
throw new Error(`MongoDB query error: ${err.message}`);
|
|
848
|
+
}
|
|
849
|
+
},
|
|
850
|
+
async close() { await client.close(); },
|
|
851
|
+
async ping() { try { await db.command({ ping: 1 }); return true; } catch { return false; } },
|
|
852
|
+
};
|
|
853
|
+
},
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
// Redis
|
|
857
|
+
this.drivers.set('redis', {
|
|
858
|
+
async connect(config, credentials) {
|
|
859
|
+
// Dynamic import — redis is optional
|
|
860
|
+
const redisUrl = credentials.connectionString || `redis://${config.username ? config.username + ':' : ''}${credentials.password ? credentials.password + '@' : ''}${config.host || 'localhost'}:${config.port || 6379}`;
|
|
861
|
+
// Use a minimal Redis interface via net socket to avoid hard dependency
|
|
862
|
+
const net = await import('net');
|
|
863
|
+
const socket = new net.Socket();
|
|
864
|
+
await new Promise<void>((resolve, reject) => {
|
|
865
|
+
socket.connect(config.port || 6379, config.host || 'localhost', () => resolve());
|
|
866
|
+
socket.on('error', reject);
|
|
867
|
+
});
|
|
868
|
+
if (credentials.password) {
|
|
869
|
+
await sendRedisCommand(socket, `AUTH ${credentials.password}`);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
async function sendRedisCommand(sock: any, cmd: string): Promise<string> {
|
|
873
|
+
return new Promise((resolve, reject) => {
|
|
874
|
+
let data = '';
|
|
875
|
+
const onData = (chunk: Buffer) => { data += chunk.toString(); sock.off('data', onData); resolve(data); };
|
|
876
|
+
sock.on('data', onData);
|
|
877
|
+
sock.write(cmd + '\r\n');
|
|
878
|
+
setTimeout(() => { sock.off('data', onData); reject(new Error('Redis timeout')); }, 5000);
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
return {
|
|
883
|
+
async query(q: string) {
|
|
884
|
+
const result = await sendRedisCommand(socket, q);
|
|
885
|
+
return { rows: [{ result: result.trim() }] };
|
|
886
|
+
},
|
|
887
|
+
async close() { socket.destroy(); },
|
|
888
|
+
async ping() { try { const r = await sendRedisCommand(socket, 'PING'); return r.includes('PONG'); } catch { return false; } },
|
|
889
|
+
};
|
|
890
|
+
},
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// ─── Row Mapping ───────────────────────────────────────────────────────────
|
|
895
|
+
|
|
896
|
+
private rowToConfig(row: any): DatabaseConnectionConfig {
|
|
897
|
+
const config = typeof row.config === 'string' ? JSON.parse(row.config) : (row.config || {});
|
|
898
|
+
return {
|
|
899
|
+
id: row.id,
|
|
900
|
+
orgId: row.org_id,
|
|
901
|
+
name: row.name,
|
|
902
|
+
type: row.type,
|
|
903
|
+
...config,
|
|
904
|
+
status: row.status,
|
|
905
|
+
lastTestedAt: row.last_tested_at,
|
|
906
|
+
lastError: row.last_error,
|
|
907
|
+
createdAt: row.created_at,
|
|
908
|
+
updatedAt: row.updated_at,
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
private rowToAccess(row: any): AgentDatabaseAccess {
|
|
913
|
+
return {
|
|
914
|
+
id: row.id,
|
|
915
|
+
orgId: row.org_id,
|
|
916
|
+
agentId: row.agent_id,
|
|
917
|
+
connectionId: row.connection_id,
|
|
918
|
+
permissions: typeof row.permissions === 'string' ? JSON.parse(row.permissions) : (row.permissions || ['read']),
|
|
919
|
+
queryLimits: typeof row.query_limits === 'string' ? JSON.parse(row.query_limits) : row.query_limits,
|
|
920
|
+
schemaAccess: typeof row.schema_access === 'string' ? JSON.parse(row.schema_access) : row.schema_access,
|
|
921
|
+
logAllQueries: !!row.log_all_queries,
|
|
922
|
+
requireApproval: !!row.require_approval,
|
|
923
|
+
enabled: !!row.enabled,
|
|
924
|
+
createdAt: row.created_at,
|
|
925
|
+
updatedAt: row.updated_at,
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
private configToStorable(config: DatabaseConnectionConfig): Record<string, any> {
|
|
930
|
+
// Strip fields stored in their own columns
|
|
931
|
+
const { id, orgId, name, type, status, lastTestedAt, lastError, createdAt, updatedAt, ...rest } = config;
|
|
932
|
+
return rest;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// ─── Cleanup ───────────────────────────────────────────────────────────────
|
|
936
|
+
|
|
937
|
+
async shutdown(): Promise<void> {
|
|
938
|
+
for (const [connId] of this.pools) {
|
|
939
|
+
await this.closePool(connId);
|
|
940
|
+
}
|
|
941
|
+
console.log('[db-access] All connection pools closed');
|
|
942
|
+
}
|
|
943
|
+
}
|