@ian2018cs/agenthub 0.1.0 → 0.1.2

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.
@@ -21,25 +21,25 @@ const c = {
21
21
  dim: (text) => `${colors.dim}${text}${colors.reset}`,
22
22
  };
23
23
 
24
- // Use DATABASE_PATH environment variable if set, otherwise use default location
25
- // Resolve relative paths from project root (one level up from server/)
24
+ // Use DATABASE_PATH environment variable if set, otherwise use DATA_DIR/auth.db
25
+ // DATA_DIR defaults to ./data relative to project root
26
+ const PROJECT_ROOT = path.join(__dirname, '../..');
27
+ const DATA_DIR = process.env.DATA_DIR || path.join(PROJECT_ROOT, 'data');
26
28
  const DB_PATH = process.env.DATABASE_PATH
27
- ? path.resolve(path.join(__dirname, '../..'), process.env.DATABASE_PATH)
28
- : path.join(__dirname, 'auth.db');
29
+ ? path.resolve(PROJECT_ROOT, process.env.DATABASE_PATH)
30
+ : path.join(DATA_DIR, 'auth.db');
29
31
  const INIT_SQL_PATH = path.join(__dirname, 'init.sql');
30
32
 
31
- // Ensure database directory exists if custom path is provided
32
- if (process.env.DATABASE_PATH) {
33
- const dbDir = path.dirname(DB_PATH);
34
- try {
35
- if (!fs.existsSync(dbDir)) {
36
- fs.mkdirSync(dbDir, { recursive: true });
37
- console.log(`Created database directory: ${dbDir}`);
38
- }
39
- } catch (error) {
40
- console.error(`Failed to create database directory ${dbDir}:`, error.message);
41
- throw error;
33
+ // Ensure database directory exists
34
+ const dbDir = path.dirname(DB_PATH);
35
+ try {
36
+ if (!fs.existsSync(dbDir)) {
37
+ fs.mkdirSync(dbDir, { recursive: true });
38
+ console.log(`Created database directory: ${dbDir}`);
42
39
  }
40
+ } catch (error) {
41
+ console.error(`Failed to create database directory ${dbDir}:`, error.message);
42
+ throw error;
43
43
  }
44
44
 
45
45
  // Create database connection
@@ -50,6 +50,7 @@ const appInstallPath = path.join(__dirname, '../..');
50
50
  console.log('');
51
51
  console.log(c.dim('═'.repeat(60)));
52
52
  console.log(`${c.info('[INFO]')} App Installation: ${c.bright(appInstallPath)}`);
53
+ console.log(`${c.info('[INFO]')} Data Directory: ${c.dim(path.relative(appInstallPath, DATA_DIR))}`);
53
54
  console.log(`${c.info('[INFO]')} Database: ${c.dim(path.relative(appInstallPath, DB_PATH))}`);
54
55
  if (process.env.DATABASE_PATH) {
55
56
  console.log(` ${c.dim('(Using custom DATABASE_PATH from environment)')}`);
@@ -62,6 +63,23 @@ const runMigrations = () => {
62
63
  const tableInfo = db.prepare("PRAGMA table_info(users)").all();
63
64
  const columnNames = tableInfo.map(col => col.name);
64
65
 
66
+ // Create verification_codes table for email login
67
+ db.exec(`
68
+ CREATE TABLE IF NOT EXISTS verification_codes (
69
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
70
+ email TEXT NOT NULL,
71
+ code TEXT NOT NULL,
72
+ type TEXT DEFAULT 'login' CHECK(type IN ('login', 'register')),
73
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
74
+ expires_at DATETIME NOT NULL,
75
+ attempts INTEGER DEFAULT 0,
76
+ used BOOLEAN DEFAULT 0,
77
+ ip_address TEXT
78
+ )
79
+ `);
80
+ db.exec('CREATE INDEX IF NOT EXISTS idx_verification_codes_email ON verification_codes(email)');
81
+ db.exec('CREATE INDEX IF NOT EXISTS idx_verification_codes_expires ON verification_codes(expires_at)');
82
+
65
83
  // Create usage_records table for tracking token usage
66
84
  db.exec(`
67
85
  CREATE TABLE IF NOT EXISTS usage_records (
@@ -137,6 +155,25 @@ const runMigrations = () => {
137
155
  // Create index for status (safe to run even if already exists)
138
156
  db.exec('CREATE INDEX IF NOT EXISTS idx_users_status ON users(status)');
139
157
 
158
+ // Add email column if not exists (for email verification login)
159
+ if (!columnNames.includes('email')) {
160
+ console.log('Running migration: Adding email column');
161
+ db.exec('ALTER TABLE users ADD COLUMN email TEXT');
162
+ }
163
+ db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email)');
164
+
165
+ // Create email domain whitelist table
166
+ db.exec(`
167
+ CREATE TABLE IF NOT EXISTS email_domain_whitelist (
168
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
169
+ domain TEXT UNIQUE NOT NULL,
170
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
171
+ created_by INTEGER,
172
+ FOREIGN KEY (created_by) REFERENCES users(id)
173
+ )
174
+ `);
175
+ db.exec('CREATE INDEX IF NOT EXISTS idx_email_domain_whitelist_domain ON email_domain_whitelist(domain)');
176
+
140
177
  console.log('Database migrations completed successfully');
141
178
  } catch (error) {
142
179
  console.error('Error running migrations:', error.message);
@@ -281,7 +318,7 @@ const userDb = {
281
318
  getAllUsers: () => {
282
319
  try {
283
320
  return db.prepare(
284
- 'SELECT id, username, uuid, role, status, created_at, last_login FROM users ORDER BY created_at DESC'
321
+ 'SELECT id, username, email, uuid, role, status, created_at, last_login FROM users ORDER BY created_at DESC'
285
322
  ).all();
286
323
  } catch (err) {
287
324
  throw err;
@@ -313,6 +350,142 @@ const userDb = {
313
350
  } catch (err) {
314
351
  throw err;
315
352
  }
353
+ },
354
+
355
+ // Get user by email
356
+ getUserByEmail: (email) => {
357
+ try {
358
+ return db.prepare('SELECT * FROM users WHERE email = ? AND is_active = 1').get(email);
359
+ } catch (err) {
360
+ throw err;
361
+ }
362
+ },
363
+
364
+ // Create user with email (for email verification login)
365
+ createUserWithEmail: (email, uuid, role) => {
366
+ try {
367
+ const stmt = db.prepare(
368
+ 'INSERT INTO users (email, uuid, role) VALUES (?, ?, ?)'
369
+ );
370
+ const result = stmt.run(email, uuid, role);
371
+ return { id: result.lastInsertRowid, email, uuid, role };
372
+ } catch (err) {
373
+ throw err;
374
+ }
375
+ },
376
+
377
+ // Check if email exists
378
+ emailExists: (email) => {
379
+ try {
380
+ const row = db.prepare('SELECT COUNT(*) as count FROM users WHERE email = ?').get(email);
381
+ return row.count > 0;
382
+ } catch (err) {
383
+ throw err;
384
+ }
385
+ }
386
+ };
387
+
388
+ // Verification codes database operations
389
+ const verificationDb = {
390
+ // Generate and store verification code
391
+ createCode: (email, type = 'login', ipAddress = null) => {
392
+ try {
393
+ const code = Math.floor(100000 + Math.random() * 900000).toString(); // 6-digit code
394
+ const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString(); // 5 minutes
395
+
396
+ const stmt = db.prepare(`
397
+ INSERT INTO verification_codes (email, code, type, expires_at, ip_address)
398
+ VALUES (?, ?, ?, ?, ?)
399
+ `);
400
+ const result = stmt.run(email, code, type, expiresAt, ipAddress);
401
+ return { id: result.lastInsertRowid, code };
402
+ } catch (err) {
403
+ throw err;
404
+ }
405
+ },
406
+
407
+ // Verify code and check if valid
408
+ verifyCode: (email, code) => {
409
+ try {
410
+ const now = new Date().toISOString();
411
+
412
+ // Find valid, unused code
413
+ const record = db.prepare(`
414
+ SELECT * FROM verification_codes
415
+ WHERE email = ? AND code = ? AND used = 0 AND expires_at > ?
416
+ ORDER BY created_at DESC LIMIT 1
417
+ `).get(email, code, now);
418
+
419
+ if (!record) {
420
+ return { valid: false, error: 'invalid_code' };
421
+ }
422
+
423
+ if (record.attempts >= 5) {
424
+ return { valid: false, error: 'max_attempts' };
425
+ }
426
+
427
+ // Mark as used
428
+ db.prepare('UPDATE verification_codes SET used = 1 WHERE id = ?').run(record.id);
429
+
430
+ return { valid: true, type: record.type };
431
+ } catch (err) {
432
+ throw err;
433
+ }
434
+ },
435
+
436
+ // Increment attempt count
437
+ incrementAttempts: (email, code) => {
438
+ try {
439
+ db.prepare(`
440
+ UPDATE verification_codes SET attempts = attempts + 1
441
+ WHERE email = ? AND code = ? AND used = 0
442
+ `).run(email, code);
443
+ } catch (err) {
444
+ throw err;
445
+ }
446
+ },
447
+
448
+ // Check rate limit for sending codes
449
+ canSendCode: (email) => {
450
+ try {
451
+ const oneMinuteAgo = new Date(Date.now() - 60 * 1000).toISOString();
452
+ const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
453
+
454
+ // Check per-email rate limit (1 per minute)
455
+ const recentByEmail = db.prepare(`
456
+ SELECT COUNT(*) as count FROM verification_codes
457
+ WHERE email = ? AND created_at > ?
458
+ `).get(email, oneMinuteAgo);
459
+
460
+ if (recentByEmail.count >= 1) {
461
+ return { allowed: false, error: 'rate_limit_email', waitSeconds: 60 };
462
+ }
463
+
464
+ // Check per-email hourly limit (10 per hour)
465
+ const hourlyByEmail = db.prepare(`
466
+ SELECT COUNT(*) as count FROM verification_codes
467
+ WHERE email = ? AND created_at > ?
468
+ `).get(email, oneHourAgo);
469
+
470
+ if (hourlyByEmail.count >= 10) {
471
+ return { allowed: false, error: 'rate_limit_hourly' };
472
+ }
473
+
474
+ return { allowed: true };
475
+ } catch (err) {
476
+ throw err;
477
+ }
478
+ },
479
+
480
+ // Cleanup expired codes
481
+ cleanupExpired: () => {
482
+ try {
483
+ const now = new Date().toISOString();
484
+ const result = db.prepare('DELETE FROM verification_codes WHERE expires_at < ?').run(now);
485
+ return result.changes;
486
+ } catch (err) {
487
+ throw err;
488
+ }
316
489
  }
317
490
  };
318
491
 
@@ -515,9 +688,81 @@ const usageDb = {
515
688
  }
516
689
  };
517
690
 
691
+ // Email domain whitelist database operations
692
+ const domainWhitelistDb = {
693
+ // Get all whitelisted domains
694
+ getAllDomains: () => {
695
+ try {
696
+ return db.prepare('SELECT * FROM email_domain_whitelist ORDER BY domain ASC').all();
697
+ } catch (err) {
698
+ throw err;
699
+ }
700
+ },
701
+
702
+ // Add a domain to whitelist
703
+ addDomain: (domain, createdBy = null) => {
704
+ try {
705
+ const normalizedDomain = domain.toLowerCase().trim();
706
+ const stmt = db.prepare('INSERT INTO email_domain_whitelist (domain, created_by) VALUES (?, ?)');
707
+ const result = stmt.run(normalizedDomain, createdBy);
708
+ return { id: result.lastInsertRowid, domain: normalizedDomain };
709
+ } catch (err) {
710
+ if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') {
711
+ throw new Error('域名已存在');
712
+ }
713
+ throw err;
714
+ }
715
+ },
716
+
717
+ // Remove a domain from whitelist
718
+ removeDomain: (id) => {
719
+ try {
720
+ const result = db.prepare('DELETE FROM email_domain_whitelist WHERE id = ?').run(id);
721
+ return result.changes > 0;
722
+ } catch (err) {
723
+ throw err;
724
+ }
725
+ },
726
+
727
+ // Check if email domain is allowed
728
+ isEmailAllowed: (email) => {
729
+ try {
730
+ // First check if whitelist is empty (allow all if no restrictions)
731
+ const count = db.prepare('SELECT COUNT(*) as count FROM email_domain_whitelist').get();
732
+ if (count.count === 0) {
733
+ return true; // No whitelist configured, allow all
734
+ }
735
+
736
+ // Extract domain from email
737
+ const domain = email.toLowerCase().split('@')[1];
738
+ if (!domain) {
739
+ return false;
740
+ }
741
+
742
+ // Check if domain is in whitelist
743
+ const row = db.prepare('SELECT id FROM email_domain_whitelist WHERE domain = ?').get(domain);
744
+ return !!row;
745
+ } catch (err) {
746
+ throw err;
747
+ }
748
+ },
749
+
750
+ // Get whitelist count
751
+ getCount: () => {
752
+ try {
753
+ const row = db.prepare('SELECT COUNT(*) as count FROM email_domain_whitelist').get();
754
+ return row.count;
755
+ } catch (err) {
756
+ throw err;
757
+ }
758
+ }
759
+ };
760
+
518
761
  export {
519
762
  db,
520
763
  initializeDatabase,
521
764
  userDb,
522
- usageDb
765
+ usageDb,
766
+ verificationDb,
767
+ domainWhitelistDb
523
768
  };
@@ -2,10 +2,14 @@
2
2
  PRAGMA foreign_keys = ON;
3
3
 
4
4
  -- Users table (multi-user system)
5
+ -- Supports two login methods:
6
+ -- 1. Email verification code (email field, no password_hash)
7
+ -- 2. Username/password (username + password_hash, created by admin)
5
8
  CREATE TABLE IF NOT EXISTS users (
6
9
  id INTEGER PRIMARY KEY AUTOINCREMENT,
7
- username TEXT UNIQUE NOT NULL,
8
- password_hash TEXT NOT NULL,
10
+ username TEXT UNIQUE,
11
+ password_hash TEXT,
12
+ email TEXT UNIQUE,
9
13
  uuid TEXT,
10
14
  role TEXT DEFAULT 'user' CHECK(role IN ('admin', 'user')),
11
15
  status TEXT DEFAULT 'active' CHECK(status IN ('active', 'disabled')),
@@ -20,4 +24,32 @@ CREATE TABLE IF NOT EXISTS users (
20
24
  -- Indexes for performance (base indexes only)
21
25
  -- Note: Indexes for uuid, role, status are created in migrations to support upgrades
22
26
  CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
23
- CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active);
27
+ CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active);
28
+ CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
29
+
30
+ -- Verification codes table for email login
31
+ CREATE TABLE IF NOT EXISTS verification_codes (
32
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
33
+ email TEXT NOT NULL,
34
+ code TEXT NOT NULL,
35
+ type TEXT DEFAULT 'login' CHECK(type IN ('login', 'register')),
36
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
37
+ expires_at DATETIME NOT NULL,
38
+ attempts INTEGER DEFAULT 0,
39
+ used BOOLEAN DEFAULT 0,
40
+ ip_address TEXT
41
+ );
42
+
43
+ CREATE INDEX IF NOT EXISTS idx_verification_codes_email ON verification_codes(email);
44
+ CREATE INDEX IF NOT EXISTS idx_verification_codes_expires ON verification_codes(expires_at);
45
+
46
+ -- Email domain whitelist for registration
47
+ CREATE TABLE IF NOT EXISTS email_domain_whitelist (
48
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
49
+ domain TEXT UNIQUE NOT NULL,
50
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
51
+ created_by INTEGER,
52
+ FOREIGN KEY (created_by) REFERENCES users(id)
53
+ );
54
+
55
+ CREATE INDEX IF NOT EXISTS idx_email_domain_whitelist_domain ON email_domain_whitelist(domain);
@@ -65,17 +65,18 @@ const authenticateToken = async (req, res, next) => {
65
65
  }
66
66
  };
67
67
 
68
- // Generate JWT token (never expires)
68
+ // Generate JWT token with 30-day expiration
69
69
  const generateToken = (user) => {
70
70
  return jwt.sign(
71
71
  {
72
72
  userId: user.id,
73
- username: user.username,
73
+ username: user.username || user.email,
74
+ email: user.email,
74
75
  uuid: user.uuid,
75
76
  role: user.role
76
77
  },
77
- JWT_SECRET
78
- // No expiration - token lasts forever
78
+ JWT_SECRET,
79
+ { expiresIn: '30d' } // 30-day expiration
79
80
  );
80
81
  };
81
82
 
@@ -1,7 +1,9 @@
1
1
  import express from 'express';
2
- import { userDb } from '../database/db.js';
2
+ import bcrypt from 'bcrypt';
3
+ import { v4 as uuidv4 } from 'uuid';
4
+ import { userDb, domainWhitelistDb } from '../database/db.js';
3
5
  import { authenticateToken } from '../middleware/auth.js';
4
- import { deleteUserDirectories } from '../services/user-directories.js';
6
+ import { deleteUserDirectories, initUserDirectories } from '../services/user-directories.js';
5
7
 
6
8
  const router = express.Router();
7
9
 
@@ -28,6 +30,58 @@ router.get('/users', (req, res) => {
28
30
  }
29
31
  });
30
32
 
33
+ // Create a new user with username and password (admin only)
34
+ router.post('/users', async (req, res) => {
35
+ try {
36
+ const { username, password } = req.body;
37
+
38
+ if (!username || !password) {
39
+ return res.status(400).json({ error: '用户名和密码不能为空' });
40
+ }
41
+
42
+ if (username.length < 3 || password.length < 6) {
43
+ return res.status(400).json({
44
+ error: '用户名至少3个字符,密码至少6个字符'
45
+ });
46
+ }
47
+
48
+ // Check if username already exists
49
+ const existingUser = userDb.getUserByUsername(username);
50
+ if (existingUser) {
51
+ return res.status(409).json({ error: '用户名已存在' });
52
+ }
53
+
54
+ // Hash password
55
+ const saltRounds = 12;
56
+ const passwordHash = await bcrypt.hash(password, saltRounds);
57
+
58
+ // Create user
59
+ const uuid = uuidv4();
60
+ const user = userDb.createUserFull(username, passwordHash, uuid, 'user');
61
+
62
+ // Initialize user directories
63
+ await initUserDirectories(uuid);
64
+
65
+ res.json({
66
+ success: true,
67
+ user: {
68
+ id: user.id,
69
+ username: user.username,
70
+ uuid: user.uuid,
71
+ role: user.role
72
+ }
73
+ });
74
+
75
+ } catch (error) {
76
+ console.error('Error creating user:', error);
77
+ if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
78
+ res.status(409).json({ error: '用户名已存在' });
79
+ } else {
80
+ res.status(500).json({ error: '创建用户失败' });
81
+ }
82
+ }
83
+ });
84
+
31
85
  // Update user status
32
86
  router.patch('/users/:id', (req, res) => {
33
87
  try {
@@ -86,4 +140,61 @@ router.delete('/users/:id', async (req, res) => {
86
140
  }
87
141
  });
88
142
 
143
+ // ==================== Email Domain Whitelist ====================
144
+
145
+ // Get all whitelisted domains
146
+ router.get('/email-domains', (req, res) => {
147
+ try {
148
+ const domains = domainWhitelistDb.getAllDomains();
149
+ res.json({ domains });
150
+ } catch (error) {
151
+ console.error('Error fetching email domains:', error);
152
+ res.status(500).json({ error: '获取域名列表失败' });
153
+ }
154
+ });
155
+
156
+ // Add a domain to whitelist
157
+ router.post('/email-domains', (req, res) => {
158
+ try {
159
+ const { domain } = req.body;
160
+
161
+ if (!domain) {
162
+ return res.status(400).json({ error: '域名不能为空' });
163
+ }
164
+
165
+ // Validate domain format
166
+ const domainRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$/;
167
+ if (!domainRegex.test(domain)) {
168
+ return res.status(400).json({ error: '域名格式无效' });
169
+ }
170
+
171
+ const result = domainWhitelistDb.addDomain(domain, req.user.id);
172
+ res.json({ success: true, domain: result });
173
+ } catch (error) {
174
+ console.error('Error adding email domain:', error);
175
+ if (error.message === '域名已存在') {
176
+ res.status(409).json({ error: error.message });
177
+ } else {
178
+ res.status(500).json({ error: '添加域名失败' });
179
+ }
180
+ }
181
+ });
182
+
183
+ // Remove a domain from whitelist
184
+ router.delete('/email-domains/:id', (req, res) => {
185
+ try {
186
+ const { id } = req.params;
187
+ const success = domainWhitelistDb.removeDomain(parseInt(id));
188
+
189
+ if (success) {
190
+ res.json({ success: true, message: '域名已删除' });
191
+ } else {
192
+ res.status(404).json({ error: '域名不存在' });
193
+ }
194
+ } catch (error) {
195
+ console.error('Error removing email domain:', error);
196
+ res.status(500).json({ error: '删除域名失败' });
197
+ }
198
+ });
199
+
89
200
  export default router;