apteva 0.2.7 → 0.2.9
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/App.m4hg4bxq.js +218 -0
- package/dist/index.html +4 -2
- package/dist/styles.css +1 -1
- package/package.json +1 -1
- package/src/auth/index.ts +386 -0
- package/src/auth/middleware.ts +183 -0
- package/src/binary.ts +19 -1
- package/src/db.ts +688 -45
- package/src/integrations/composio.ts +437 -0
- package/src/integrations/index.ts +80 -0
- package/src/openapi.ts +1724 -0
- package/src/routes/api.ts +1476 -118
- package/src/routes/auth.ts +242 -0
- package/src/server.ts +121 -11
- package/src/web/App.tsx +64 -19
- package/src/web/components/agents/AgentCard.tsx +24 -22
- package/src/web/components/agents/AgentPanel.tsx +810 -45
- package/src/web/components/agents/AgentsView.tsx +81 -9
- package/src/web/components/agents/CreateAgentModal.tsx +28 -1
- package/src/web/components/api/ApiDocsPage.tsx +583 -0
- package/src/web/components/auth/CreateAccountStep.tsx +176 -0
- package/src/web/components/auth/LoginPage.tsx +91 -0
- package/src/web/components/auth/index.ts +2 -0
- package/src/web/components/common/Icons.tsx +56 -0
- package/src/web/components/common/Modal.tsx +184 -1
- package/src/web/components/dashboard/Dashboard.tsx +70 -22
- package/src/web/components/index.ts +3 -0
- package/src/web/components/layout/Header.tsx +135 -18
- package/src/web/components/layout/Sidebar.tsx +87 -43
- package/src/web/components/mcp/IntegrationsPanel.tsx +743 -0
- package/src/web/components/mcp/McpPage.tsx +451 -63
- package/src/web/components/onboarding/OnboardingWizard.tsx +64 -8
- package/src/web/components/settings/SettingsPage.tsx +340 -26
- package/src/web/components/tasks/TasksPage.tsx +22 -20
- package/src/web/components/telemetry/TelemetryPage.tsx +163 -61
- package/src/web/context/AuthContext.tsx +230 -0
- package/src/web/context/ProjectContext.tsx +182 -0
- package/src/web/context/index.ts +5 -0
- package/src/web/hooks/useAgents.ts +18 -6
- package/src/web/hooks/useOnboarding.ts +20 -4
- package/src/web/hooks/useProviders.ts +15 -5
- package/src/web/icon.png +0 -0
- package/src/web/index.html +1 -1
- package/src/web/styles.css +12 -0
- package/src/web/types.ts +10 -1
- package/dist/App.3kb50qa3.js +0 -213
package/src/db.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
import { mkdirSync, existsSync } from "fs";
|
|
4
|
-
import { encryptObject, decryptObject } from "./crypto";
|
|
4
|
+
import { encrypt, decrypt, encryptObject, decryptObject } from "./crypto";
|
|
5
|
+
import { randomBytes } from "crypto";
|
|
5
6
|
|
|
6
7
|
// Types
|
|
7
8
|
export interface AgentFeatures {
|
|
@@ -11,6 +12,8 @@ export interface AgentFeatures {
|
|
|
11
12
|
operator: boolean;
|
|
12
13
|
mcp: boolean;
|
|
13
14
|
realtime: boolean;
|
|
15
|
+
files: boolean;
|
|
16
|
+
agents: boolean;
|
|
14
17
|
}
|
|
15
18
|
|
|
16
19
|
export const DEFAULT_FEATURES: AgentFeatures = {
|
|
@@ -20,6 +23,8 @@ export const DEFAULT_FEATURES: AgentFeatures = {
|
|
|
20
23
|
operator: false,
|
|
21
24
|
mcp: false,
|
|
22
25
|
realtime: false,
|
|
26
|
+
files: false,
|
|
27
|
+
agents: false,
|
|
23
28
|
};
|
|
24
29
|
|
|
25
30
|
export interface Agent {
|
|
@@ -32,6 +37,26 @@ export interface Agent {
|
|
|
32
37
|
port: number | null;
|
|
33
38
|
features: AgentFeatures;
|
|
34
39
|
mcp_servers: string[]; // Array of MCP server IDs
|
|
40
|
+
project_id: string | null; // Optional project grouping
|
|
41
|
+
api_key_encrypted: string | null; // Encrypted API key for agent authentication
|
|
42
|
+
created_at: string;
|
|
43
|
+
updated_at: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface Project {
|
|
47
|
+
id: string;
|
|
48
|
+
name: string;
|
|
49
|
+
description: string | null;
|
|
50
|
+
color: string; // Hex color for UI display
|
|
51
|
+
created_at: string;
|
|
52
|
+
updated_at: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ProjectRow {
|
|
56
|
+
id: string;
|
|
57
|
+
name: string;
|
|
58
|
+
description: string | null;
|
|
59
|
+
color: string;
|
|
35
60
|
created_at: string;
|
|
36
61
|
updated_at: string;
|
|
37
62
|
}
|
|
@@ -46,6 +71,8 @@ export interface AgentRow {
|
|
|
46
71
|
port: number | null;
|
|
47
72
|
features: string | null;
|
|
48
73
|
mcp_servers: string | null;
|
|
74
|
+
project_id: string | null;
|
|
75
|
+
api_key_encrypted: string | null;
|
|
49
76
|
created_at: string;
|
|
50
77
|
updated_at: string;
|
|
51
78
|
}
|
|
@@ -83,8 +110,11 @@ export interface McpServer {
|
|
|
83
110
|
command: string | null;
|
|
84
111
|
args: string | null;
|
|
85
112
|
env: Record<string, string>;
|
|
113
|
+
url: string | null; // For http type: the remote server URL
|
|
114
|
+
headers: Record<string, string>; // For http type: auth headers
|
|
86
115
|
port: number | null;
|
|
87
116
|
status: "stopped" | "running";
|
|
117
|
+
source: string | null; // e.g., "composio", "smithery", null for local
|
|
88
118
|
created_at: string;
|
|
89
119
|
}
|
|
90
120
|
|
|
@@ -96,8 +126,11 @@ export interface McpServerRow {
|
|
|
96
126
|
command: string | null;
|
|
97
127
|
args: string | null;
|
|
98
128
|
env: string | null;
|
|
129
|
+
url: string | null;
|
|
130
|
+
headers: string | null;
|
|
99
131
|
port: number | null;
|
|
100
132
|
status: string;
|
|
133
|
+
source: string | null;
|
|
101
134
|
created_at: string;
|
|
102
135
|
}
|
|
103
136
|
|
|
@@ -272,6 +305,72 @@ function runMigrations() {
|
|
|
272
305
|
CREATE INDEX IF NOT EXISTS idx_telemetry_trace ON telemetry_events(trace_id);
|
|
273
306
|
`,
|
|
274
307
|
},
|
|
308
|
+
{
|
|
309
|
+
name: "010_create_users",
|
|
310
|
+
sql: `
|
|
311
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
312
|
+
id TEXT PRIMARY KEY,
|
|
313
|
+
username TEXT UNIQUE NOT NULL,
|
|
314
|
+
password_hash TEXT NOT NULL,
|
|
315
|
+
email TEXT,
|
|
316
|
+
role TEXT NOT NULL DEFAULT 'user',
|
|
317
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
318
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
319
|
+
last_login_at TEXT
|
|
320
|
+
);
|
|
321
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
|
322
|
+
`,
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
name: "011_create_sessions",
|
|
326
|
+
sql: `
|
|
327
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
328
|
+
id TEXT PRIMARY KEY,
|
|
329
|
+
user_id TEXT NOT NULL,
|
|
330
|
+
refresh_token_hash TEXT NOT NULL,
|
|
331
|
+
expires_at TEXT NOT NULL,
|
|
332
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
333
|
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
334
|
+
);
|
|
335
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
|
|
336
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
|
|
337
|
+
`,
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
name: "012_create_projects",
|
|
341
|
+
sql: `
|
|
342
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
343
|
+
id TEXT PRIMARY KEY,
|
|
344
|
+
name TEXT NOT NULL,
|
|
345
|
+
description TEXT,
|
|
346
|
+
color TEXT NOT NULL DEFAULT '#6366f1',
|
|
347
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
348
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
349
|
+
);
|
|
350
|
+
CREATE INDEX IF NOT EXISTS idx_projects_name ON projects(name);
|
|
351
|
+
`,
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
name: "013_add_agent_project_id",
|
|
355
|
+
sql: `
|
|
356
|
+
ALTER TABLE agents ADD COLUMN project_id TEXT REFERENCES projects(id) ON DELETE SET NULL;
|
|
357
|
+
CREATE INDEX IF NOT EXISTS idx_agents_project ON agents(project_id);
|
|
358
|
+
`,
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
name: "014_add_mcp_server_url_headers",
|
|
362
|
+
sql: `
|
|
363
|
+
ALTER TABLE mcp_servers ADD COLUMN url TEXT;
|
|
364
|
+
ALTER TABLE mcp_servers ADD COLUMN headers TEXT DEFAULT '{}';
|
|
365
|
+
ALTER TABLE mcp_servers ADD COLUMN source TEXT;
|
|
366
|
+
`,
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
name: "015_add_agent_api_key",
|
|
370
|
+
sql: `
|
|
371
|
+
ALTER TABLE agents ADD COLUMN api_key_encrypted TEXT;
|
|
372
|
+
`,
|
|
373
|
+
},
|
|
275
374
|
];
|
|
276
375
|
|
|
277
376
|
// Check which migrations have been applied
|
|
@@ -289,20 +388,92 @@ function runMigrations() {
|
|
|
289
388
|
db.run("INSERT INTO migrations (name) VALUES (?)", [migration.name]);
|
|
290
389
|
}
|
|
291
390
|
}
|
|
391
|
+
|
|
392
|
+
// Schema upgrade migrations (check actual table structure)
|
|
393
|
+
runSchemaUpgrades();
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Handle schema changes that require checking actual table structure
|
|
397
|
+
function runSchemaUpgrades() {
|
|
398
|
+
// Check if users table needs migration from email-based to username-based
|
|
399
|
+
const tableInfo = db.query("PRAGMA table_info(users)").all() as { name: string }[];
|
|
400
|
+
const columns = new Set(tableInfo.map(c => c.name));
|
|
401
|
+
|
|
402
|
+
// Old schema has 'email' as required + 'name', new schema has 'username' + optional 'email'
|
|
403
|
+
if (columns.has("name") && !columns.has("username")) {
|
|
404
|
+
console.log("[db] Migrating users table from email-based to username-based auth...");
|
|
405
|
+
|
|
406
|
+
// Get existing users
|
|
407
|
+
const existingUsers = db.query("SELECT * FROM users").all() as any[];
|
|
408
|
+
|
|
409
|
+
// Drop old table and indexes
|
|
410
|
+
db.run("DROP INDEX IF EXISTS idx_users_email");
|
|
411
|
+
db.run("DROP TABLE users");
|
|
412
|
+
|
|
413
|
+
// Create new schema
|
|
414
|
+
db.run(`
|
|
415
|
+
CREATE TABLE users (
|
|
416
|
+
id TEXT PRIMARY KEY,
|
|
417
|
+
username TEXT UNIQUE NOT NULL,
|
|
418
|
+
password_hash TEXT NOT NULL,
|
|
419
|
+
email TEXT,
|
|
420
|
+
role TEXT NOT NULL DEFAULT 'user',
|
|
421
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
422
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
423
|
+
last_login_at TEXT
|
|
424
|
+
)
|
|
425
|
+
`);
|
|
426
|
+
db.run("CREATE UNIQUE INDEX idx_users_username ON users(username)");
|
|
427
|
+
|
|
428
|
+
// Migrate existing users (use part before @ in email as username)
|
|
429
|
+
for (const user of existingUsers) {
|
|
430
|
+
const username = user.email.split("@")[0].replace(/[^a-zA-Z0-9_]/g, "_").slice(0, 20);
|
|
431
|
+
db.run(
|
|
432
|
+
`INSERT INTO users (id, username, password_hash, email, role, created_at, updated_at, last_login_at)
|
|
433
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
434
|
+
[user.id, username, user.password_hash, user.email, user.role, user.created_at, user.updated_at, user.last_login_at]
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (existingUsers.length > 0) {
|
|
439
|
+
console.log(`[db] Migrated ${existingUsers.length} user(s). Usernames derived from email addresses.`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Generate a unique API key for an agent
|
|
445
|
+
function generateAgentApiKey(agentId: string): string {
|
|
446
|
+
const randomPart = randomBytes(24).toString("hex");
|
|
447
|
+
return `agt_${randomPart}`;
|
|
292
448
|
}
|
|
293
449
|
|
|
294
450
|
// Agent CRUD operations
|
|
295
451
|
export const AgentDB = {
|
|
296
|
-
//
|
|
297
|
-
|
|
452
|
+
// Get the next available port for a new agent (starting from 4100)
|
|
453
|
+
getNextAvailablePort(): number {
|
|
454
|
+
const BASE_PORT = 4100;
|
|
455
|
+
const row = db.query("SELECT MAX(port) as max_port FROM agents").get() as { max_port: number | null };
|
|
456
|
+
if (row.max_port === null) {
|
|
457
|
+
return BASE_PORT;
|
|
458
|
+
}
|
|
459
|
+
return row.max_port + 1;
|
|
460
|
+
},
|
|
461
|
+
|
|
462
|
+
// Create a new agent with a permanently assigned port and API key
|
|
463
|
+
create(agent: Omit<Agent, "created_at" | "updated_at" | "status" | "api_key_encrypted"> & { port?: number }): Agent {
|
|
298
464
|
const now = new Date().toISOString();
|
|
299
465
|
const featuresJson = JSON.stringify(agent.features || DEFAULT_FEATURES);
|
|
300
466
|
const mcpServersJson = JSON.stringify(agent.mcp_servers || []);
|
|
467
|
+
// Assign port permanently at creation time
|
|
468
|
+
const port = agent.port ?? this.getNextAvailablePort();
|
|
469
|
+
// Generate and encrypt API key
|
|
470
|
+
const apiKey = generateAgentApiKey(agent.id);
|
|
471
|
+
const apiKeyEncrypted = encrypt(apiKey);
|
|
301
472
|
const stmt = db.prepare(`
|
|
302
|
-
INSERT INTO agents (id, name, model, provider, system_prompt, features, mcp_servers, status, port, created_at, updated_at)
|
|
303
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, 'stopped',
|
|
473
|
+
INSERT INTO agents (id, name, model, provider, system_prompt, features, mcp_servers, project_id, status, port, api_key_encrypted, created_at, updated_at)
|
|
474
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'stopped', ?, ?, ?, ?)
|
|
304
475
|
`);
|
|
305
|
-
stmt.run(agent.id, agent.name, agent.model, agent.provider, agent.system_prompt, featuresJson, mcpServersJson, now, now);
|
|
476
|
+
stmt.run(agent.id, agent.name, agent.model, agent.provider, agent.system_prompt, featuresJson, mcpServersJson, agent.project_id || null, port, apiKeyEncrypted, now, now);
|
|
306
477
|
return this.findById(agent.id)!;
|
|
307
478
|
},
|
|
308
479
|
|
|
@@ -364,6 +535,10 @@ export const AgentDB = {
|
|
|
364
535
|
fields.push("mcp_servers = ?");
|
|
365
536
|
values.push(JSON.stringify(updates.mcp_servers));
|
|
366
537
|
}
|
|
538
|
+
if (updates.project_id !== undefined) {
|
|
539
|
+
fields.push("project_id = ?");
|
|
540
|
+
values.push(updates.project_id);
|
|
541
|
+
}
|
|
367
542
|
|
|
368
543
|
if (fields.length > 0) {
|
|
369
544
|
fields.push("updated_at = ?");
|
|
@@ -376,20 +551,30 @@ export const AgentDB = {
|
|
|
376
551
|
return this.findById(id);
|
|
377
552
|
},
|
|
378
553
|
|
|
554
|
+
// Find agents by project
|
|
555
|
+
findByProject(projectId: string | null): Agent[] {
|
|
556
|
+
if (projectId === null) {
|
|
557
|
+
const rows = db.query("SELECT * FROM agents WHERE project_id IS NULL ORDER BY created_at DESC").all() as AgentRow[];
|
|
558
|
+
return rows.map(rowToAgent);
|
|
559
|
+
}
|
|
560
|
+
const rows = db.query("SELECT * FROM agents WHERE project_id = ? ORDER BY created_at DESC").all(projectId) as AgentRow[];
|
|
561
|
+
return rows.map(rowToAgent);
|
|
562
|
+
},
|
|
563
|
+
|
|
379
564
|
// Delete agent
|
|
380
565
|
delete(id: string): boolean {
|
|
381
566
|
const result = db.run("DELETE FROM agents WHERE id = ?", [id]);
|
|
382
567
|
return result.changes > 0;
|
|
383
568
|
},
|
|
384
569
|
|
|
385
|
-
// Set agent status
|
|
386
|
-
setStatus(id: string, status: "stopped" | "running"
|
|
387
|
-
return this.update(id, { status
|
|
570
|
+
// Set agent status (port is permanently assigned, don't change it)
|
|
571
|
+
setStatus(id: string, status: "stopped" | "running"): Agent | null {
|
|
572
|
+
return this.update(id, { status });
|
|
388
573
|
},
|
|
389
574
|
|
|
390
|
-
// Reset all agents to stopped (on server restart)
|
|
575
|
+
// Reset all agents to stopped (on server restart) - keep ports as they're permanent
|
|
391
576
|
resetAllStatus(): void {
|
|
392
|
-
db.run("UPDATE agents SET status = 'stopped'
|
|
577
|
+
db.run("UPDATE agents SET status = 'stopped'");
|
|
393
578
|
},
|
|
394
579
|
|
|
395
580
|
// Count agents
|
|
@@ -403,8 +588,157 @@ export const AgentDB = {
|
|
|
403
588
|
const row = db.query("SELECT COUNT(*) as count FROM agents WHERE status = 'running'").get() as { count: number };
|
|
404
589
|
return row.count;
|
|
405
590
|
},
|
|
591
|
+
|
|
592
|
+
// Get decrypted API key for an agent
|
|
593
|
+
getApiKey(id: string): string | null {
|
|
594
|
+
const agent = this.findById(id);
|
|
595
|
+
if (!agent || !agent.api_key_encrypted) {
|
|
596
|
+
return null;
|
|
597
|
+
}
|
|
598
|
+
try {
|
|
599
|
+
return decrypt(agent.api_key_encrypted);
|
|
600
|
+
} catch {
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
},
|
|
604
|
+
|
|
605
|
+
// Regenerate API key for an agent
|
|
606
|
+
regenerateApiKey(id: string): string | null {
|
|
607
|
+
const agent = this.findById(id);
|
|
608
|
+
if (!agent) return null;
|
|
609
|
+
|
|
610
|
+
const newApiKey = generateAgentApiKey(id);
|
|
611
|
+
const encrypted = encrypt(newApiKey);
|
|
612
|
+
const now = new Date().toISOString();
|
|
613
|
+
|
|
614
|
+
db.run(
|
|
615
|
+
"UPDATE agents SET api_key_encrypted = ?, updated_at = ? WHERE id = ?",
|
|
616
|
+
[encrypted, now, id]
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
return newApiKey;
|
|
620
|
+
},
|
|
621
|
+
|
|
622
|
+
// Ensure agent has an API key (for migration of existing agents)
|
|
623
|
+
ensureApiKey(id: string): string | null {
|
|
624
|
+
const agent = this.findById(id);
|
|
625
|
+
if (!agent) return null;
|
|
626
|
+
|
|
627
|
+
// If agent already has a key, return it
|
|
628
|
+
if (agent.api_key_encrypted) {
|
|
629
|
+
try {
|
|
630
|
+
return decrypt(agent.api_key_encrypted);
|
|
631
|
+
} catch {
|
|
632
|
+
// Key is corrupted, regenerate
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Generate new key for agents without one
|
|
637
|
+
return this.regenerateApiKey(id);
|
|
638
|
+
},
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
// Project CRUD operations
|
|
642
|
+
export const ProjectDB = {
|
|
643
|
+
// Create a new project
|
|
644
|
+
create(project: { name: string; description?: string | null; color?: string }): Project {
|
|
645
|
+
const id = generateId();
|
|
646
|
+
const now = new Date().toISOString();
|
|
647
|
+
const color = project.color || "#6366f1";
|
|
648
|
+
|
|
649
|
+
db.run(
|
|
650
|
+
`INSERT INTO projects (id, name, description, color, created_at, updated_at)
|
|
651
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
652
|
+
[id, project.name, project.description || null, color, now, now]
|
|
653
|
+
);
|
|
654
|
+
|
|
655
|
+
return this.findById(id)!;
|
|
656
|
+
},
|
|
657
|
+
|
|
658
|
+
// Find project by ID
|
|
659
|
+
findById(id: string): Project | null {
|
|
660
|
+
const row = db.query("SELECT * FROM projects WHERE id = ?").get(id) as ProjectRow | null;
|
|
661
|
+
return row ? rowToProject(row) : null;
|
|
662
|
+
},
|
|
663
|
+
|
|
664
|
+
// Get all projects
|
|
665
|
+
findAll(): Project[] {
|
|
666
|
+
const rows = db.query("SELECT * FROM projects ORDER BY name ASC").all() as ProjectRow[];
|
|
667
|
+
return rows.map(rowToProject);
|
|
668
|
+
},
|
|
669
|
+
|
|
670
|
+
// Update project
|
|
671
|
+
update(id: string, updates: Partial<Omit<Project, "id" | "created_at">>): Project | null {
|
|
672
|
+
const project = this.findById(id);
|
|
673
|
+
if (!project) return null;
|
|
674
|
+
|
|
675
|
+
const fields: string[] = [];
|
|
676
|
+
const values: unknown[] = [];
|
|
677
|
+
|
|
678
|
+
if (updates.name !== undefined) {
|
|
679
|
+
fields.push("name = ?");
|
|
680
|
+
values.push(updates.name);
|
|
681
|
+
}
|
|
682
|
+
if (updates.description !== undefined) {
|
|
683
|
+
fields.push("description = ?");
|
|
684
|
+
values.push(updates.description);
|
|
685
|
+
}
|
|
686
|
+
if (updates.color !== undefined) {
|
|
687
|
+
fields.push("color = ?");
|
|
688
|
+
values.push(updates.color);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
if (fields.length > 0) {
|
|
692
|
+
fields.push("updated_at = ?");
|
|
693
|
+
values.push(new Date().toISOString());
|
|
694
|
+
values.push(id);
|
|
695
|
+
|
|
696
|
+
db.run(`UPDATE projects SET ${fields.join(", ")} WHERE id = ?`, values);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
return this.findById(id);
|
|
700
|
+
},
|
|
701
|
+
|
|
702
|
+
// Delete project (agents will have project_id set to NULL)
|
|
703
|
+
delete(id: string): boolean {
|
|
704
|
+
const result = db.run("DELETE FROM projects WHERE id = ?", [id]);
|
|
705
|
+
return result.changes > 0;
|
|
706
|
+
},
|
|
707
|
+
|
|
708
|
+
// Count projects
|
|
709
|
+
count(): number {
|
|
710
|
+
const row = db.query("SELECT COUNT(*) as count FROM projects").get() as { count: number };
|
|
711
|
+
return row.count;
|
|
712
|
+
},
|
|
713
|
+
|
|
714
|
+
// Get agent count per project
|
|
715
|
+
getAgentCounts(): Map<string | null, number> {
|
|
716
|
+
const rows = db.query(`
|
|
717
|
+
SELECT project_id, COUNT(*) as count
|
|
718
|
+
FROM agents
|
|
719
|
+
GROUP BY project_id
|
|
720
|
+
`).all() as { project_id: string | null; count: number }[];
|
|
721
|
+
|
|
722
|
+
const counts = new Map<string | null, number>();
|
|
723
|
+
for (const row of rows) {
|
|
724
|
+
counts.set(row.project_id, row.count);
|
|
725
|
+
}
|
|
726
|
+
return counts;
|
|
727
|
+
},
|
|
406
728
|
};
|
|
407
729
|
|
|
730
|
+
// Helper to convert DB row to Project type
|
|
731
|
+
function rowToProject(row: ProjectRow): Project {
|
|
732
|
+
return {
|
|
733
|
+
id: row.id,
|
|
734
|
+
name: row.name,
|
|
735
|
+
description: row.description,
|
|
736
|
+
color: row.color,
|
|
737
|
+
created_at: row.created_at,
|
|
738
|
+
updated_at: row.updated_at,
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
|
|
408
742
|
// Thread CRUD operations
|
|
409
743
|
export const ThreadDB = {
|
|
410
744
|
create(id: string, agentId: string, title?: string): void {
|
|
@@ -493,6 +827,8 @@ function rowToAgent(row: AgentRow): Agent {
|
|
|
493
827
|
port: row.port,
|
|
494
828
|
features,
|
|
495
829
|
mcp_servers,
|
|
830
|
+
project_id: row.project_id,
|
|
831
|
+
api_key_encrypted: row.api_key_encrypted,
|
|
496
832
|
created_at: row.created_at,
|
|
497
833
|
updated_at: row.updated_at,
|
|
498
834
|
};
|
|
@@ -583,13 +919,17 @@ function rowToProviderKey(row: ProviderKeyRow): ProviderKey {
|
|
|
583
919
|
export const McpServerDB = {
|
|
584
920
|
create(server: Omit<McpServer, "created_at" | "status" | "port">): McpServer {
|
|
585
921
|
const now = new Date().toISOString();
|
|
586
|
-
// Encrypt env vars (credentials) before storing
|
|
922
|
+
// Encrypt env vars and headers (credentials) before storing
|
|
587
923
|
const envEncrypted = encryptObject(server.env || {});
|
|
924
|
+
const headersEncrypted = encryptObject(server.headers || {});
|
|
588
925
|
const stmt = db.prepare(`
|
|
589
|
-
INSERT INTO mcp_servers (id, name, type, package, command, args, env, status, port, created_at)
|
|
590
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, 'stopped', NULL, ?)
|
|
926
|
+
INSERT INTO mcp_servers (id, name, type, package, command, args, env, url, headers, source, status, port, created_at)
|
|
927
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'stopped', NULL, ?)
|
|
591
928
|
`);
|
|
592
|
-
stmt.run(
|
|
929
|
+
stmt.run(
|
|
930
|
+
server.id, server.name, server.type, server.package, server.command, server.args,
|
|
931
|
+
envEncrypted, server.url || null, headersEncrypted, server.source || null, now
|
|
932
|
+
);
|
|
593
933
|
return this.findById(server.id)!;
|
|
594
934
|
},
|
|
595
935
|
|
|
@@ -640,6 +980,19 @@ export const McpServerDB = {
|
|
|
640
980
|
// Encrypt env vars (credentials) before storing
|
|
641
981
|
values.push(encryptObject(updates.env));
|
|
642
982
|
}
|
|
983
|
+
if (updates.url !== undefined) {
|
|
984
|
+
fields.push("url = ?");
|
|
985
|
+
values.push(updates.url);
|
|
986
|
+
}
|
|
987
|
+
if (updates.headers !== undefined) {
|
|
988
|
+
fields.push("headers = ?");
|
|
989
|
+
// Encrypt headers (may contain auth tokens) before storing
|
|
990
|
+
values.push(encryptObject(updates.headers));
|
|
991
|
+
}
|
|
992
|
+
if (updates.source !== undefined) {
|
|
993
|
+
fields.push("source = ?");
|
|
994
|
+
values.push(updates.source);
|
|
995
|
+
}
|
|
643
996
|
if (updates.port !== undefined) {
|
|
644
997
|
fields.push("port = ?");
|
|
645
998
|
values.push(updates.port);
|
|
@@ -678,8 +1031,9 @@ export const McpServerDB = {
|
|
|
678
1031
|
|
|
679
1032
|
// Helper to convert DB row to McpServer type
|
|
680
1033
|
function rowToMcpServer(row: McpServerRow): McpServer {
|
|
681
|
-
// Decrypt env vars (handles both encrypted and legacy unencrypted data)
|
|
1034
|
+
// Decrypt env vars and headers (handles both encrypted and legacy unencrypted data)
|
|
682
1035
|
const env = row.env ? decryptObject(row.env) : {};
|
|
1036
|
+
const headers = row.headers ? decryptObject(row.headers) : {};
|
|
683
1037
|
return {
|
|
684
1038
|
id: row.id,
|
|
685
1039
|
name: row.name,
|
|
@@ -688,13 +1042,55 @@ function rowToMcpServer(row: McpServerRow): McpServer {
|
|
|
688
1042
|
command: row.command,
|
|
689
1043
|
args: row.args,
|
|
690
1044
|
env,
|
|
1045
|
+
url: row.url,
|
|
1046
|
+
headers,
|
|
691
1047
|
port: row.port,
|
|
692
1048
|
status: row.status as "stopped" | "running",
|
|
1049
|
+
source: row.source,
|
|
693
1050
|
created_at: row.created_at,
|
|
694
1051
|
};
|
|
695
1052
|
}
|
|
696
1053
|
|
|
697
1054
|
// Telemetry Event types
|
|
1055
|
+
// User types
|
|
1056
|
+
export interface User {
|
|
1057
|
+
id: string;
|
|
1058
|
+
username: string;
|
|
1059
|
+
password_hash: string;
|
|
1060
|
+
email: string | null; // Optional, for password recovery only
|
|
1061
|
+
role: "admin" | "user";
|
|
1062
|
+
created_at: string;
|
|
1063
|
+
updated_at: string;
|
|
1064
|
+
last_login_at: string | null;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
export interface UserRow {
|
|
1068
|
+
id: string;
|
|
1069
|
+
username: string;
|
|
1070
|
+
password_hash: string;
|
|
1071
|
+
email: string | null;
|
|
1072
|
+
role: string;
|
|
1073
|
+
created_at: string;
|
|
1074
|
+
updated_at: string;
|
|
1075
|
+
last_login_at: string | null;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
export interface Session {
|
|
1079
|
+
id: string;
|
|
1080
|
+
user_id: string;
|
|
1081
|
+
refresh_token_hash: string;
|
|
1082
|
+
expires_at: string;
|
|
1083
|
+
created_at: string;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
export interface SessionRow {
|
|
1087
|
+
id: string;
|
|
1088
|
+
user_id: string;
|
|
1089
|
+
refresh_token_hash: string;
|
|
1090
|
+
expires_at: string;
|
|
1091
|
+
created_at: string;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
698
1094
|
export interface TelemetryEvent {
|
|
699
1095
|
id: string;
|
|
700
1096
|
agent_id: string;
|
|
@@ -779,6 +1175,7 @@ export const TelemetryDB = {
|
|
|
779
1175
|
// Query events with filters
|
|
780
1176
|
query(filters: {
|
|
781
1177
|
agent_id?: string;
|
|
1178
|
+
project_id?: string | null; // Filter by project (null = unassigned agents)
|
|
782
1179
|
category?: string;
|
|
783
1180
|
level?: string;
|
|
784
1181
|
trace_id?: string;
|
|
@@ -791,27 +1188,35 @@ export const TelemetryDB = {
|
|
|
791
1188
|
const params: unknown[] = [];
|
|
792
1189
|
|
|
793
1190
|
if (filters.agent_id) {
|
|
794
|
-
conditions.push("agent_id = ?");
|
|
1191
|
+
conditions.push("t.agent_id = ?");
|
|
795
1192
|
params.push(filters.agent_id);
|
|
796
1193
|
}
|
|
1194
|
+
if (filters.project_id !== undefined) {
|
|
1195
|
+
if (filters.project_id === null) {
|
|
1196
|
+
conditions.push("a.project_id IS NULL");
|
|
1197
|
+
} else {
|
|
1198
|
+
conditions.push("a.project_id = ?");
|
|
1199
|
+
params.push(filters.project_id);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
797
1202
|
if (filters.category) {
|
|
798
|
-
conditions.push("category = ?");
|
|
1203
|
+
conditions.push("t.category = ?");
|
|
799
1204
|
params.push(filters.category);
|
|
800
1205
|
}
|
|
801
1206
|
if (filters.level) {
|
|
802
|
-
conditions.push("level = ?");
|
|
1207
|
+
conditions.push("t.level = ?");
|
|
803
1208
|
params.push(filters.level);
|
|
804
1209
|
}
|
|
805
1210
|
if (filters.trace_id) {
|
|
806
|
-
conditions.push("trace_id = ?");
|
|
1211
|
+
conditions.push("t.trace_id = ?");
|
|
807
1212
|
params.push(filters.trace_id);
|
|
808
1213
|
}
|
|
809
1214
|
if (filters.since) {
|
|
810
|
-
conditions.push("timestamp >= ?");
|
|
1215
|
+
conditions.push("t.timestamp >= ?");
|
|
811
1216
|
params.push(filters.since);
|
|
812
1217
|
}
|
|
813
1218
|
if (filters.until) {
|
|
814
|
-
conditions.push("timestamp <= ?");
|
|
1219
|
+
conditions.push("t.timestamp <= ?");
|
|
815
1220
|
params.push(filters.until);
|
|
816
1221
|
}
|
|
817
1222
|
|
|
@@ -819,7 +1224,11 @@ export const TelemetryDB = {
|
|
|
819
1224
|
const limit = filters.limit || 100;
|
|
820
1225
|
const offset = filters.offset || 0;
|
|
821
1226
|
|
|
822
|
-
|
|
1227
|
+
// Join with agents table when filtering by project
|
|
1228
|
+
const needsJoin = filters.project_id !== undefined;
|
|
1229
|
+
const sql = needsJoin
|
|
1230
|
+
? `SELECT t.* FROM telemetry_events t JOIN agents a ON t.agent_id = a.id ${where} ORDER BY t.timestamp DESC LIMIT ? OFFSET ?`
|
|
1231
|
+
: `SELECT * FROM telemetry_events t ${where} ORDER BY t.timestamp DESC LIMIT ? OFFSET ?`;
|
|
823
1232
|
params.push(limit, offset);
|
|
824
1233
|
|
|
825
1234
|
const rows = db.query(sql).all(...params) as TelemetryEventRow[];
|
|
@@ -829,6 +1238,7 @@ export const TelemetryDB = {
|
|
|
829
1238
|
// Get usage stats
|
|
830
1239
|
getUsage(filters: {
|
|
831
1240
|
agent_id?: string;
|
|
1241
|
+
project_id?: string | null;
|
|
832
1242
|
since?: string;
|
|
833
1243
|
until?: string;
|
|
834
1244
|
group_by?: "agent" | "day";
|
|
@@ -843,17 +1253,26 @@ export const TelemetryDB = {
|
|
|
843
1253
|
}> {
|
|
844
1254
|
const conditions: string[] = [];
|
|
845
1255
|
const params: unknown[] = [];
|
|
1256
|
+
const needsJoin = filters.project_id !== undefined;
|
|
846
1257
|
|
|
847
1258
|
if (filters.agent_id) {
|
|
848
|
-
conditions.push("agent_id = ?");
|
|
1259
|
+
conditions.push("t.agent_id = ?");
|
|
849
1260
|
params.push(filters.agent_id);
|
|
850
1261
|
}
|
|
1262
|
+
if (filters.project_id !== undefined) {
|
|
1263
|
+
if (filters.project_id === null) {
|
|
1264
|
+
conditions.push("a.project_id IS NULL");
|
|
1265
|
+
} else {
|
|
1266
|
+
conditions.push("a.project_id = ?");
|
|
1267
|
+
params.push(filters.project_id);
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
851
1270
|
if (filters.since) {
|
|
852
|
-
conditions.push("timestamp >= ?");
|
|
1271
|
+
conditions.push("t.timestamp >= ?");
|
|
853
1272
|
params.push(filters.since);
|
|
854
1273
|
}
|
|
855
1274
|
if (filters.until) {
|
|
856
|
-
conditions.push("timestamp <= ?");
|
|
1275
|
+
conditions.push("t.timestamp <= ?");
|
|
857
1276
|
params.push(filters.until);
|
|
858
1277
|
}
|
|
859
1278
|
|
|
@@ -863,22 +1282,26 @@ export const TelemetryDB = {
|
|
|
863
1282
|
let selectFields = "";
|
|
864
1283
|
|
|
865
1284
|
if (filters.group_by === "day") {
|
|
866
|
-
groupBy = "GROUP BY date(timestamp)";
|
|
867
|
-
selectFields = "date(timestamp) as date,";
|
|
1285
|
+
groupBy = "GROUP BY date(t.timestamp)";
|
|
1286
|
+
selectFields = "date(t.timestamp) as date,";
|
|
868
1287
|
} else if (filters.group_by === "agent") {
|
|
869
|
-
groupBy = "GROUP BY agent_id";
|
|
870
|
-
selectFields = "agent_id,";
|
|
1288
|
+
groupBy = "GROUP BY t.agent_id";
|
|
1289
|
+
selectFields = "t.agent_id as agent_id,";
|
|
871
1290
|
}
|
|
872
1291
|
|
|
1292
|
+
const fromClause = needsJoin
|
|
1293
|
+
? "FROM telemetry_events t JOIN agents a ON t.agent_id = a.id"
|
|
1294
|
+
: "FROM telemetry_events t";
|
|
1295
|
+
|
|
873
1296
|
const sql = `
|
|
874
1297
|
SELECT
|
|
875
1298
|
${selectFields}
|
|
876
|
-
COALESCE(SUM(CASE WHEN category = 'LLM' THEN json_extract(data, '$.input_tokens') ELSE 0 END), 0) as input_tokens,
|
|
877
|
-
COALESCE(SUM(CASE WHEN category = 'LLM' THEN json_extract(data, '$.output_tokens') ELSE 0 END), 0) as output_tokens,
|
|
878
|
-
COALESCE(SUM(CASE WHEN category = 'LLM' THEN 1 ELSE 0 END), 0) as llm_calls,
|
|
879
|
-
COALESCE(SUM(CASE WHEN category = 'TOOL' THEN 1 ELSE 0 END), 0) as tool_calls,
|
|
880
|
-
COALESCE(SUM(CASE WHEN level = 'error' THEN 1 ELSE 0 END), 0) as errors
|
|
881
|
-
|
|
1299
|
+
COALESCE(SUM(CASE WHEN t.category = 'LLM' THEN json_extract(t.data, '$.input_tokens') ELSE 0 END), 0) as input_tokens,
|
|
1300
|
+
COALESCE(SUM(CASE WHEN t.category = 'LLM' THEN json_extract(t.data, '$.output_tokens') ELSE 0 END), 0) as output_tokens,
|
|
1301
|
+
COALESCE(SUM(CASE WHEN t.category = 'LLM' THEN 1 ELSE 0 END), 0) as llm_calls,
|
|
1302
|
+
COALESCE(SUM(CASE WHEN t.category = 'TOOL' THEN 1 ELSE 0 END), 0) as tool_calls,
|
|
1303
|
+
COALESCE(SUM(CASE WHEN t.level = 'error' THEN 1 ELSE 0 END), 0) as errors
|
|
1304
|
+
${fromClause}
|
|
882
1305
|
${where}
|
|
883
1306
|
${groupBy}
|
|
884
1307
|
`;
|
|
@@ -895,7 +1318,7 @@ export const TelemetryDB = {
|
|
|
895
1318
|
},
|
|
896
1319
|
|
|
897
1320
|
// Get summary stats
|
|
898
|
-
getStats(agentId?: string): {
|
|
1321
|
+
getStats(filters: { agentId?: string; projectId?: string | null } = {}): {
|
|
899
1322
|
total_events: number;
|
|
900
1323
|
total_llm_calls: number;
|
|
901
1324
|
total_tool_calls: number;
|
|
@@ -903,18 +1326,37 @@ export const TelemetryDB = {
|
|
|
903
1326
|
total_input_tokens: number;
|
|
904
1327
|
total_output_tokens: number;
|
|
905
1328
|
} {
|
|
906
|
-
const
|
|
907
|
-
const params
|
|
1329
|
+
const conditions: string[] = [];
|
|
1330
|
+
const params: unknown[] = [];
|
|
1331
|
+
const needsJoin = filters.projectId !== undefined;
|
|
1332
|
+
|
|
1333
|
+
if (filters.agentId) {
|
|
1334
|
+
conditions.push("t.agent_id = ?");
|
|
1335
|
+
params.push(filters.agentId);
|
|
1336
|
+
}
|
|
1337
|
+
if (filters.projectId !== undefined) {
|
|
1338
|
+
if (filters.projectId === null) {
|
|
1339
|
+
conditions.push("a.project_id IS NULL");
|
|
1340
|
+
} else {
|
|
1341
|
+
conditions.push("a.project_id = ?");
|
|
1342
|
+
params.push(filters.projectId);
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
1347
|
+
const fromClause = needsJoin
|
|
1348
|
+
? "FROM telemetry_events t JOIN agents a ON t.agent_id = a.id"
|
|
1349
|
+
: "FROM telemetry_events t";
|
|
908
1350
|
|
|
909
1351
|
const sql = `
|
|
910
1352
|
SELECT
|
|
911
1353
|
COUNT(*) as total_events,
|
|
912
|
-
COALESCE(SUM(CASE WHEN category = 'LLM' THEN 1 ELSE 0 END), 0) as total_llm_calls,
|
|
913
|
-
COALESCE(SUM(CASE WHEN category = 'TOOL' THEN 1 ELSE 0 END), 0) as total_tool_calls,
|
|
914
|
-
COALESCE(SUM(CASE WHEN level = 'error' THEN 1 ELSE 0 END), 0) as total_errors,
|
|
915
|
-
COALESCE(SUM(CASE WHEN category = 'LLM' THEN json_extract(data, '$.input_tokens') ELSE 0 END), 0) as total_input_tokens,
|
|
916
|
-
COALESCE(SUM(CASE WHEN category = 'LLM' THEN json_extract(data, '$.output_tokens') ELSE 0 END), 0) as total_output_tokens
|
|
917
|
-
|
|
1354
|
+
COALESCE(SUM(CASE WHEN t.category = 'LLM' THEN 1 ELSE 0 END), 0) as total_llm_calls,
|
|
1355
|
+
COALESCE(SUM(CASE WHEN t.category = 'TOOL' THEN 1 ELSE 0 END), 0) as total_tool_calls,
|
|
1356
|
+
COALESCE(SUM(CASE WHEN t.level = 'error' THEN 1 ELSE 0 END), 0) as total_errors,
|
|
1357
|
+
COALESCE(SUM(CASE WHEN t.category = 'LLM' THEN json_extract(t.data, '$.input_tokens') ELSE 0 END), 0) as total_input_tokens,
|
|
1358
|
+
COALESCE(SUM(CASE WHEN t.category = 'LLM' THEN json_extract(t.data, '$.output_tokens') ELSE 0 END), 0) as total_output_tokens
|
|
1359
|
+
${fromClause}
|
|
918
1360
|
${where}
|
|
919
1361
|
`;
|
|
920
1362
|
|
|
@@ -978,6 +1420,207 @@ function rowToTelemetryEvent(row: TelemetryEventRow): TelemetryEvent {
|
|
|
978
1420
|
};
|
|
979
1421
|
}
|
|
980
1422
|
|
|
1423
|
+
// User operations
|
|
1424
|
+
export const UserDB = {
|
|
1425
|
+
// Create a new user
|
|
1426
|
+
create(user: { username: string; password_hash: string; email?: string | null; role?: "admin" | "user" }): User {
|
|
1427
|
+
const id = generateId();
|
|
1428
|
+
const now = new Date().toISOString();
|
|
1429
|
+
const role = user.role || "user";
|
|
1430
|
+
|
|
1431
|
+
db.run(
|
|
1432
|
+
`INSERT INTO users (id, username, password_hash, email, role, created_at, updated_at)
|
|
1433
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
1434
|
+
[id, user.username.toLowerCase(), user.password_hash, user.email || null, role, now, now]
|
|
1435
|
+
);
|
|
1436
|
+
|
|
1437
|
+
return this.findById(id)!;
|
|
1438
|
+
},
|
|
1439
|
+
|
|
1440
|
+
// Find user by ID
|
|
1441
|
+
findById(id: string): User | null {
|
|
1442
|
+
const row = db.query("SELECT * FROM users WHERE id = ?").get(id) as UserRow | null;
|
|
1443
|
+
return row ? rowToUser(row) : null;
|
|
1444
|
+
},
|
|
1445
|
+
|
|
1446
|
+
// Find user by username
|
|
1447
|
+
findByUsername(username: string): User | null {
|
|
1448
|
+
const row = db.query("SELECT * FROM users WHERE username = ?").get(username.toLowerCase()) as UserRow | null;
|
|
1449
|
+
return row ? rowToUser(row) : null;
|
|
1450
|
+
},
|
|
1451
|
+
|
|
1452
|
+
// Find user by email (for password recovery)
|
|
1453
|
+
findByEmail(email: string): User | null {
|
|
1454
|
+
const row = db.query("SELECT * FROM users WHERE email = ?").get(email.toLowerCase()) as UserRow | null;
|
|
1455
|
+
return row ? rowToUser(row) : null;
|
|
1456
|
+
},
|
|
1457
|
+
|
|
1458
|
+
// Get all users
|
|
1459
|
+
findAll(): User[] {
|
|
1460
|
+
const rows = db.query("SELECT * FROM users ORDER BY created_at DESC").all() as UserRow[];
|
|
1461
|
+
return rows.map(rowToUser);
|
|
1462
|
+
},
|
|
1463
|
+
|
|
1464
|
+
// Update user
|
|
1465
|
+
update(id: string, updates: Partial<Omit<User, "id" | "created_at">>): User | null {
|
|
1466
|
+
const user = this.findById(id);
|
|
1467
|
+
if (!user) return null;
|
|
1468
|
+
|
|
1469
|
+
const fields: string[] = [];
|
|
1470
|
+
const values: unknown[] = [];
|
|
1471
|
+
|
|
1472
|
+
if (updates.username !== undefined) {
|
|
1473
|
+
fields.push("username = ?");
|
|
1474
|
+
values.push(updates.username.toLowerCase());
|
|
1475
|
+
}
|
|
1476
|
+
if (updates.password_hash !== undefined) {
|
|
1477
|
+
fields.push("password_hash = ?");
|
|
1478
|
+
values.push(updates.password_hash);
|
|
1479
|
+
}
|
|
1480
|
+
if (updates.email !== undefined) {
|
|
1481
|
+
fields.push("email = ?");
|
|
1482
|
+
values.push(updates.email);
|
|
1483
|
+
}
|
|
1484
|
+
if (updates.role !== undefined) {
|
|
1485
|
+
fields.push("role = ?");
|
|
1486
|
+
values.push(updates.role);
|
|
1487
|
+
}
|
|
1488
|
+
if (updates.last_login_at !== undefined) {
|
|
1489
|
+
fields.push("last_login_at = ?");
|
|
1490
|
+
values.push(updates.last_login_at);
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
if (fields.length > 0) {
|
|
1494
|
+
fields.push("updated_at = ?");
|
|
1495
|
+
values.push(new Date().toISOString());
|
|
1496
|
+
values.push(id);
|
|
1497
|
+
|
|
1498
|
+
db.run(`UPDATE users SET ${fields.join(", ")} WHERE id = ?`, values);
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
return this.findById(id);
|
|
1502
|
+
},
|
|
1503
|
+
|
|
1504
|
+
// Delete user
|
|
1505
|
+
delete(id: string): boolean {
|
|
1506
|
+
const result = db.run("DELETE FROM users WHERE id = ?", [id]);
|
|
1507
|
+
return result.changes > 0;
|
|
1508
|
+
},
|
|
1509
|
+
|
|
1510
|
+
// Update last login
|
|
1511
|
+
updateLastLogin(id: string): void {
|
|
1512
|
+
db.run("UPDATE users SET last_login_at = ? WHERE id = ?", [new Date().toISOString(), id]);
|
|
1513
|
+
},
|
|
1514
|
+
|
|
1515
|
+
// Count users
|
|
1516
|
+
count(): number {
|
|
1517
|
+
const row = db.query("SELECT COUNT(*) as count FROM users").get() as { count: number };
|
|
1518
|
+
return row.count;
|
|
1519
|
+
},
|
|
1520
|
+
|
|
1521
|
+
// Check if any users exist
|
|
1522
|
+
hasUsers(): boolean {
|
|
1523
|
+
return this.count() > 0;
|
|
1524
|
+
},
|
|
1525
|
+
|
|
1526
|
+
// Count admins
|
|
1527
|
+
countAdmins(): number {
|
|
1528
|
+
const row = db.query("SELECT COUNT(*) as count FROM users WHERE role = 'admin'").get() as { count: number };
|
|
1529
|
+
return row.count;
|
|
1530
|
+
},
|
|
1531
|
+
};
|
|
1532
|
+
|
|
1533
|
+
// Helper to convert DB row to User type
|
|
1534
|
+
function rowToUser(row: UserRow): User {
|
|
1535
|
+
return {
|
|
1536
|
+
id: row.id,
|
|
1537
|
+
username: row.username,
|
|
1538
|
+
password_hash: row.password_hash,
|
|
1539
|
+
email: row.email,
|
|
1540
|
+
role: row.role as "admin" | "user",
|
|
1541
|
+
created_at: row.created_at,
|
|
1542
|
+
updated_at: row.updated_at,
|
|
1543
|
+
last_login_at: row.last_login_at,
|
|
1544
|
+
};
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
// Session operations
|
|
1548
|
+
export const SessionDB = {
|
|
1549
|
+
// Create a new session
|
|
1550
|
+
create(session: { user_id: string; refresh_token_hash: string; expires_at: string }): Session {
|
|
1551
|
+
const id = generateId();
|
|
1552
|
+
const now = new Date().toISOString();
|
|
1553
|
+
|
|
1554
|
+
db.run(
|
|
1555
|
+
`INSERT INTO sessions (id, user_id, refresh_token_hash, expires_at, created_at)
|
|
1556
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
1557
|
+
[id, session.user_id, session.refresh_token_hash, session.expires_at, now]
|
|
1558
|
+
);
|
|
1559
|
+
|
|
1560
|
+
return this.findById(id)!;
|
|
1561
|
+
},
|
|
1562
|
+
|
|
1563
|
+
// Find session by ID
|
|
1564
|
+
findById(id: string): Session | null {
|
|
1565
|
+
const row = db.query("SELECT * FROM sessions WHERE id = ?").get(id) as SessionRow | null;
|
|
1566
|
+
return row ? rowToSession(row) : null;
|
|
1567
|
+
},
|
|
1568
|
+
|
|
1569
|
+
// Find session by refresh token hash
|
|
1570
|
+
findByTokenHash(tokenHash: string): Session | null {
|
|
1571
|
+
const row = db.query("SELECT * FROM sessions WHERE refresh_token_hash = ?").get(tokenHash) as SessionRow | null;
|
|
1572
|
+
return row ? rowToSession(row) : null;
|
|
1573
|
+
},
|
|
1574
|
+
|
|
1575
|
+
// Get all sessions for a user
|
|
1576
|
+
findByUser(userId: string): Session[] {
|
|
1577
|
+
const rows = db.query("SELECT * FROM sessions WHERE user_id = ? ORDER BY created_at DESC").all(userId) as SessionRow[];
|
|
1578
|
+
return rows.map(rowToSession);
|
|
1579
|
+
},
|
|
1580
|
+
|
|
1581
|
+
// Delete session
|
|
1582
|
+
delete(id: string): boolean {
|
|
1583
|
+
const result = db.run("DELETE FROM sessions WHERE id = ?", [id]);
|
|
1584
|
+
return result.changes > 0;
|
|
1585
|
+
},
|
|
1586
|
+
|
|
1587
|
+
// Delete session by token hash
|
|
1588
|
+
deleteByTokenHash(tokenHash: string): boolean {
|
|
1589
|
+
const result = db.run("DELETE FROM sessions WHERE refresh_token_hash = ?", [tokenHash]);
|
|
1590
|
+
return result.changes > 0;
|
|
1591
|
+
},
|
|
1592
|
+
|
|
1593
|
+
// Delete all sessions for a user
|
|
1594
|
+
deleteByUser(userId: string): number {
|
|
1595
|
+
const result = db.run("DELETE FROM sessions WHERE user_id = ?", [userId]);
|
|
1596
|
+
return result.changes;
|
|
1597
|
+
},
|
|
1598
|
+
|
|
1599
|
+
// Delete expired sessions
|
|
1600
|
+
deleteExpired(): number {
|
|
1601
|
+
const result = db.run("DELETE FROM sessions WHERE expires_at < ?", [new Date().toISOString()]);
|
|
1602
|
+
return result.changes;
|
|
1603
|
+
},
|
|
1604
|
+
|
|
1605
|
+
// Check if session is valid (exists and not expired)
|
|
1606
|
+
isValid(id: string): boolean {
|
|
1607
|
+
const session = this.findById(id);
|
|
1608
|
+
if (!session) return false;
|
|
1609
|
+
return new Date(session.expires_at) > new Date();
|
|
1610
|
+
},
|
|
1611
|
+
};
|
|
1612
|
+
|
|
1613
|
+
// Helper to convert DB row to Session type
|
|
1614
|
+
function rowToSession(row: SessionRow): Session {
|
|
1615
|
+
return {
|
|
1616
|
+
id: row.id,
|
|
1617
|
+
user_id: row.user_id,
|
|
1618
|
+
refresh_token_hash: row.refresh_token_hash,
|
|
1619
|
+
expires_at: row.expires_at,
|
|
1620
|
+
created_at: row.created_at,
|
|
1621
|
+
};
|
|
1622
|
+
}
|
|
1623
|
+
|
|
981
1624
|
// Generate unique ID
|
|
982
1625
|
export function generateId(): string {
|
|
983
1626
|
return Math.random().toString(36).substring(2, 15);
|