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.
Files changed (46) hide show
  1. package/dist/App.m4hg4bxq.js +218 -0
  2. package/dist/index.html +4 -2
  3. package/dist/styles.css +1 -1
  4. package/package.json +1 -1
  5. package/src/auth/index.ts +386 -0
  6. package/src/auth/middleware.ts +183 -0
  7. package/src/binary.ts +19 -1
  8. package/src/db.ts +688 -45
  9. package/src/integrations/composio.ts +437 -0
  10. package/src/integrations/index.ts +80 -0
  11. package/src/openapi.ts +1724 -0
  12. package/src/routes/api.ts +1476 -118
  13. package/src/routes/auth.ts +242 -0
  14. package/src/server.ts +121 -11
  15. package/src/web/App.tsx +64 -19
  16. package/src/web/components/agents/AgentCard.tsx +24 -22
  17. package/src/web/components/agents/AgentPanel.tsx +810 -45
  18. package/src/web/components/agents/AgentsView.tsx +81 -9
  19. package/src/web/components/agents/CreateAgentModal.tsx +28 -1
  20. package/src/web/components/api/ApiDocsPage.tsx +583 -0
  21. package/src/web/components/auth/CreateAccountStep.tsx +176 -0
  22. package/src/web/components/auth/LoginPage.tsx +91 -0
  23. package/src/web/components/auth/index.ts +2 -0
  24. package/src/web/components/common/Icons.tsx +56 -0
  25. package/src/web/components/common/Modal.tsx +184 -1
  26. package/src/web/components/dashboard/Dashboard.tsx +70 -22
  27. package/src/web/components/index.ts +3 -0
  28. package/src/web/components/layout/Header.tsx +135 -18
  29. package/src/web/components/layout/Sidebar.tsx +87 -43
  30. package/src/web/components/mcp/IntegrationsPanel.tsx +743 -0
  31. package/src/web/components/mcp/McpPage.tsx +451 -63
  32. package/src/web/components/onboarding/OnboardingWizard.tsx +64 -8
  33. package/src/web/components/settings/SettingsPage.tsx +340 -26
  34. package/src/web/components/tasks/TasksPage.tsx +22 -20
  35. package/src/web/components/telemetry/TelemetryPage.tsx +163 -61
  36. package/src/web/context/AuthContext.tsx +230 -0
  37. package/src/web/context/ProjectContext.tsx +182 -0
  38. package/src/web/context/index.ts +5 -0
  39. package/src/web/hooks/useAgents.ts +18 -6
  40. package/src/web/hooks/useOnboarding.ts +20 -4
  41. package/src/web/hooks/useProviders.ts +15 -5
  42. package/src/web/icon.png +0 -0
  43. package/src/web/index.html +1 -1
  44. package/src/web/styles.css +12 -0
  45. package/src/web/types.ts +10 -1
  46. 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
- // Create a new agent
297
- create(agent: Omit<Agent, "created_at" | "updated_at" | "status" | "port">): Agent {
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', NULL, ?, ?)
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", port?: number): Agent | null {
387
- return this.update(id, { status, port: port ?? null });
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', port = NULL");
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(server.id, server.name, server.type, server.package, server.command, server.args, envEncrypted, now);
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
- const sql = `SELECT * FROM telemetry_events ${where} ORDER BY timestamp DESC LIMIT ? OFFSET ?`;
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
- FROM telemetry_events
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 where = agentId ? "WHERE agent_id = ?" : "";
907
- const params = agentId ? [agentId] : [];
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
- FROM telemetry_events
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);