import { drizzle } from "drizzle-orm/better-sqlite3"; import Database from "better-sqlite3"; import * as schema from "./schema.js"; import fs from "fs"; import path from "path"; import { databaseLogger } from "../../utils/logger.js"; import { DatabaseFileEncryption } from "../../utils/database-file-encryption.js"; import { SystemCrypto } from "../../utils/system-crypto.js"; import { DatabaseMigration } from "../../utils/database-migration.js"; import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; const dataDir = process.env.DATA_DIR || "./db/data"; const dbDir = path.resolve(dataDir); if (!fs.existsSync(dbDir)) { fs.mkdirSync(dbDir, { recursive: true }); } const enableFileEncryption = process.env.DB_FILE_ENCRYPTION !== "false"; const dbPath = path.join(dataDir, "db.sqlite"); const encryptedDbPath = `${dbPath}.encrypted`; const actualDbPath = ":memory:"; let memoryDatabase: Database.Database; let isNewDatabase = false; let sqlite: Database.Database; async function initializeDatabaseAsync(): Promise { const systemCrypto = SystemCrypto.getInstance(); await systemCrypto.getDatabaseKey(); if (enableFileEncryption) { try { if (DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath)) { const decryptedBuffer = await DatabaseFileEncryption.decryptDatabaseToBuffer(encryptedDbPath); memoryDatabase = new Database(decryptedBuffer); try { memoryDatabase .prepare("SELECT COUNT(*) as count FROM sessions") .get() as { count: number }; } catch { // expected - sessions table may not exist yet } } else { const migration = new DatabaseMigration(dataDir); const migrationStatus = migration.checkMigrationStatus(); if (migrationStatus.needsMigration) { const migrationResult = await migration.migrateDatabase(); if (migrationResult.success) { migration.cleanupOldBackups(); if ( DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath) ) { const decryptedBuffer = await DatabaseFileEncryption.decryptDatabaseToBuffer( encryptedDbPath, ); memoryDatabase = new Database(decryptedBuffer); isNewDatabase = false; } else { throw new Error( "Migration completed but encrypted database file not found", ); } } else { databaseLogger.error("Automatic database migration failed", null, { operation: "auto_migration_failed", error: migrationResult.error, migratedTables: migrationResult.migratedTables, migratedRows: migrationResult.migratedRows, duration: migrationResult.duration, backupPath: migrationResult.backupPath, }); throw new Error( `Database migration failed: ${migrationResult.error}. Backup available at: ${migrationResult.backupPath}`, ); } } else { memoryDatabase = new Database(":memory:"); isNewDatabase = true; } } } catch (error) { databaseLogger.error("Failed to initialize memory database", error, { operation: "db_memory_init_failed", errorMessage: error instanceof Error ? error.message : "Unknown error", errorStack: error instanceof Error ? error.stack : undefined, encryptedDbExists: DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath), databaseKeyAvailable: !!process.env.DATABASE_KEY, databaseKeyLength: process.env.DATABASE_KEY?.length || 0, }); try { const diagnosticInfo = DatabaseFileEncryption.getDiagnosticInfo(encryptedDbPath); databaseLogger.error( "Database encryption diagnostic completed - check logs above for details", null, { operation: "db_encryption_diagnostic_completed", filesConsistent: diagnosticInfo.validation.filesConsistent, sizeMismatch: diagnosticInfo.validation.sizeMismatch, }, ); } catch (diagError) { databaseLogger.warn("Failed to generate diagnostic information", { operation: "db_diagnostic_failed", error: diagError instanceof Error ? diagError.message : "Unknown error", }); } throw new Error( `Database decryption failed: ${error instanceof Error ? error.message : "Unknown error"}. This prevents data loss.`, { cause: error }, ); } } else { memoryDatabase = new Database(":memory:"); isNewDatabase = true; } } async function initializeCompleteDatabase(): Promise { await initializeDatabaseAsync(); databaseLogger.info(`Initializing SQLite database`, { operation: "db_init", path: actualDbPath, encrypted: enableFileEncryption && DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath), inMemory: true, isNewDatabase, }); sqlite = memoryDatabase; sqlite.exec("PRAGMA foreign_keys = ON"); db = drizzle(sqlite, { schema }); sqlite.exec(` CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, password_hash TEXT NOT NULL, is_admin INTEGER NOT NULL DEFAULT 0, is_oidc INTEGER NOT NULL DEFAULT 0, oidc_identifier TEXT, client_id TEXT, client_secret TEXT, issuer_url TEXT, authorization_url TEXT, token_url TEXT, identifier_path TEXT, name_path TEXT, scopes TEXT DEFAULT 'openid email profile', totp_secret TEXT, totp_enabled INTEGER NOT NULL DEFAULT 0, totp_backup_codes TEXT ); CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, jwt_token TEXT NOT NULL, device_type TEXT NOT NULL, device_info TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, expires_at TEXT NOT NULL, last_active_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS trusted_devices ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, device_fingerprint TEXT NOT NULL, device_type TEXT NOT NULL, device_info TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, expires_at TEXT NOT NULL, last_used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS ssh_data ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, name TEXT, ip TEXT NOT NULL, port INTEGER NOT NULL, username TEXT NOT NULL, folder TEXT, tags TEXT, pin INTEGER NOT NULL DEFAULT 0, auth_type TEXT NOT NULL, password TEXT, key TEXT, key_password TEXT, key_type TEXT, enable_terminal INTEGER NOT NULL DEFAULT 1, enable_tunnel INTEGER NOT NULL DEFAULT 1, tunnel_connections TEXT, enable_file_manager INTEGER NOT NULL DEFAULT 1, enable_docker INTEGER NOT NULL DEFAULT 0, default_path TEXT, autostart_password TEXT, autostart_key TEXT, autostart_key_password TEXT, force_keyboard_interactive TEXT, stats_config TEXT, docker_config TEXT, terminal_config TEXT, notes TEXT, use_socks5 INTEGER, socks5_host TEXT, socks5_port INTEGER, socks5_username TEXT, socks5_password TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS file_manager_recent ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, host_id INTEGER NOT NULL, name TEXT NOT NULL, path TEXT NOT NULL, last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS file_manager_pinned ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, host_id INTEGER NOT NULL, name TEXT NOT NULL, path TEXT NOT NULL, pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS file_manager_shortcuts ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, host_id INTEGER NOT NULL, name TEXT NOT NULL, path TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS dismissed_alerts ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, alert_id TEXT NOT NULL, dismissed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS ssh_credentials ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, name TEXT NOT NULL, description TEXT, folder TEXT, tags TEXT, auth_type TEXT NOT NULL, username TEXT, password TEXT, key TEXT, key_password TEXT, key_type TEXT, usage_count INTEGER NOT NULL DEFAULT 0, last_used TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS ssh_credential_usage ( id INTEGER PRIMARY KEY AUTOINCREMENT, credential_id INTEGER NOT NULL, host_id INTEGER NOT NULL, user_id TEXT NOT NULL, used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE, FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS snippets ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, name TEXT NOT NULL, content TEXT NOT NULL, description TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS c2s_tunnel_presets ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, name TEXT NOT NULL, config TEXT NOT NULL, platform TEXT, computer_name TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS ssh_folders ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, name TEXT NOT NULL, color TEXT, icon TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS recent_activity ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, type TEXT NOT NULL, host_id INTEGER NOT NULL, host_name TEXT, timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS command_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, host_id INTEGER NOT NULL, command TEXT NOT NULL, executed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS host_access ( id INTEGER PRIMARY KEY AUTOINCREMENT, host_id INTEGER NOT NULL, user_id TEXT, role_id INTEGER, granted_by TEXT NOT NULL, permission_level TEXT NOT NULL DEFAULT 'use', expires_at TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, last_accessed_at TEXT, access_count INTEGER NOT NULL DEFAULT 0, FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE, FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS roles ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, description TEXT, is_system INTEGER NOT NULL DEFAULT 0, permissions TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS user_roles ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, role_id INTEGER NOT NULL, granted_by TEXT, granted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(user_id, role_id), FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE, FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS audit_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, username TEXT NOT NULL, action TEXT NOT NULL, resource_type TEXT NOT NULL, resource_id TEXT, resource_name TEXT, details TEXT, ip_address TEXT, user_agent TEXT, success INTEGER NOT NULL, error_message TEXT, timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS session_recordings ( id INTEGER PRIMARY KEY AUTOINCREMENT, host_id INTEGER NOT NULL, user_id TEXT NOT NULL, access_id INTEGER, started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, ended_at TEXT, duration INTEGER, commands TEXT, dangerous_actions TEXT, recording_path TEXT, terminated_by_owner INTEGER DEFAULT 0, termination_reason TEXT, FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS api_keys ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, token_hash TEXT NOT NULL, token_prefix TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, expires_at TEXT, last_used_at TEXT, is_active INTEGER NOT NULL DEFAULT 1, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); `); try { const result = sqlite .prepare("DELETE FROM sessions WHERE expires_at <= ?") .run(new Date().toISOString()); if (result.changes > 0) { databaseLogger.info("Expired sessions cleaned up on startup", { operation: "db_init_session_cleanup", deletedSessions: result.changes, }); } } catch (e) { databaseLogger.warn("Could not clear expired sessions on startup", { operation: "db_init_session_cleanup_failed", error: e, }); } migrateSchema(); try { const row = sqlite .prepare("SELECT value FROM settings WHERE key = 'allow_registration'") .get(); if (!row) { sqlite .prepare( "INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')", ) .run(); } } catch (e) { databaseLogger.warn("Could not initialize default settings", { operation: "db_init", error: e, }); } try { const row = sqlite .prepare("SELECT value FROM settings WHERE key = 'allow_password_login'") .get(); if (!row) { sqlite .prepare( "INSERT INTO settings (key, value) VALUES ('allow_password_login', 'true')", ) .run(); } } catch (e) { databaseLogger.warn("Could not initialize allow_password_login setting", { operation: "db_init", error: e, }); } try { const row = sqlite .prepare("SELECT value FROM settings WHERE key = 'guac_enabled'") .get(); if (!row) { sqlite .prepare( "INSERT INTO settings (key, value) VALUES ('guac_enabled', 'true')", ) .run(); } } catch (e) { databaseLogger.warn("Could not initialize guac_enabled setting", { operation: "db_init", error: e, }); } try { const row = sqlite .prepare("SELECT value FROM settings WHERE key = 'guac_url'") .get(); if (!row) { sqlite .prepare( "INSERT INTO settings (key, value) VALUES ('guac_url', 'guacd:4822')", ) .run(); } } catch (e) { databaseLogger.warn("Could not initialize guac_url setting", { operation: "db_init", error: e, }); } } const addColumnIfNotExists = ( table: string, column: string, definition: string, ) => { try { sqlite .prepare( `SELECT "${column}" FROM ${table} LIMIT 1`, ) .get(); } catch { try { sqlite.exec(`ALTER TABLE ${table} ADD COLUMN "${column}" ${definition};`); } catch (alterError) { databaseLogger.warn(`Failed to add column ${column} to ${table}`, { operation: "schema_migration", table, column, error: alterError, }); } } }; const migrateSchema = () => { addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0"); addColumnIfNotExists("users", "is_oidc", "INTEGER NOT NULL DEFAULT 0"); addColumnIfNotExists("users", "oidc_identifier", "TEXT"); addColumnIfNotExists("users", "client_id", "TEXT"); addColumnIfNotExists("users", "client_secret", "TEXT"); addColumnIfNotExists("users", "issuer_url", "TEXT"); addColumnIfNotExists("users", "authorization_url", "TEXT"); addColumnIfNotExists("users", "token_url", "TEXT"); addColumnIfNotExists("users", "identifier_path", "TEXT"); addColumnIfNotExists("users", "name_path", "TEXT"); addColumnIfNotExists("users", "scopes", "TEXT"); addColumnIfNotExists("users", "totp_secret", "TEXT"); addColumnIfNotExists("users", "totp_enabled", "INTEGER NOT NULL DEFAULT 0"); addColumnIfNotExists("users", "totp_backup_codes", "TEXT"); addColumnIfNotExists("ssh_data", "name", "TEXT"); addColumnIfNotExists("ssh_data", "folder", "TEXT"); addColumnIfNotExists("ssh_data", "tags", "TEXT"); addColumnIfNotExists("ssh_data", "pin", "INTEGER NOT NULL DEFAULT 0"); addColumnIfNotExists( "ssh_data", "auth_type", 'TEXT NOT NULL DEFAULT "password"', ); addColumnIfNotExists("ssh_data", "password", "TEXT"); addColumnIfNotExists("ssh_data", "key", "TEXT"); addColumnIfNotExists("ssh_data", "key_password", "TEXT"); addColumnIfNotExists("ssh_data", "key_type", "TEXT"); addColumnIfNotExists( "ssh_data", "enable_terminal", "INTEGER NOT NULL DEFAULT 1", ); addColumnIfNotExists( "ssh_data", "enable_tunnel", "INTEGER NOT NULL DEFAULT 1", ); addColumnIfNotExists("ssh_data", "tunnel_connections", "TEXT"); addColumnIfNotExists("ssh_data", "jump_hosts", "TEXT"); addColumnIfNotExists( "ssh_data", "enable_file_manager", "INTEGER NOT NULL DEFAULT 1", ); addColumnIfNotExists("ssh_data", "default_path", "TEXT"); addColumnIfNotExists( "ssh_data", "created_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP", ); addColumnIfNotExists( "ssh_data", "updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP", ); addColumnIfNotExists("ssh_data", "force_keyboard_interactive", "TEXT"); addColumnIfNotExists("ssh_data", "autostart_password", "TEXT"); addColumnIfNotExists("ssh_data", "autostart_key", "TEXT"); addColumnIfNotExists("ssh_data", "autostart_key_password", "TEXT"); addColumnIfNotExists( "ssh_data", "credential_id", "INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL", ); addColumnIfNotExists( "ssh_data", "override_credential_username", "INTEGER", ); addColumnIfNotExists("ssh_data", "autostart_password", "TEXT"); addColumnIfNotExists("ssh_data", "autostart_key", "TEXT"); addColumnIfNotExists("ssh_data", "autostart_key_password", "TEXT"); addColumnIfNotExists("ssh_data", "stats_config", "TEXT"); addColumnIfNotExists("ssh_data", "terminal_config", "TEXT"); addColumnIfNotExists("ssh_data", "quick_actions", "TEXT"); addColumnIfNotExists( "ssh_data", "enable_docker", "INTEGER NOT NULL DEFAULT 0", ); addColumnIfNotExists("ssh_data", "docker_config", "TEXT"); addColumnIfNotExists("ssh_data", "connection_type", 'TEXT NOT NULL DEFAULT "ssh"'); addColumnIfNotExists("ssh_data", "domain", "TEXT"); addColumnIfNotExists("ssh_data", "security", "TEXT"); addColumnIfNotExists("ssh_data", "ignore_cert", "INTEGER NOT NULL DEFAULT 0"); addColumnIfNotExists("ssh_data", "guacamole_config", "TEXT"); addColumnIfNotExists("ssh_data", "notes", "TEXT"); addColumnIfNotExists("ssh_data", "use_socks5", "INTEGER"); addColumnIfNotExists("ssh_data", "socks5_host", "TEXT"); addColumnIfNotExists("ssh_data", "socks5_port", "INTEGER"); addColumnIfNotExists("ssh_data", "socks5_username", "TEXT"); addColumnIfNotExists("ssh_data", "socks5_password", "TEXT"); addColumnIfNotExists("ssh_data", "socks5_proxy_chain", "TEXT"); addColumnIfNotExists("ssh_data", "host_key_fingerprint", "TEXT"); addColumnIfNotExists("ssh_data", "host_key_type", "TEXT"); addColumnIfNotExists("ssh_data", "host_key_algorithm", "TEXT DEFAULT 'sha256'"); addColumnIfNotExists("ssh_data", "host_key_first_seen", "TEXT"); addColumnIfNotExists("ssh_data", "host_key_last_verified", "TEXT"); addColumnIfNotExists("ssh_data", "host_key_changed_count", "INTEGER DEFAULT 0"); addColumnIfNotExists( "ssh_data", "show_terminal_in_sidebar", "INTEGER NOT NULL DEFAULT 1", ); addColumnIfNotExists( "ssh_data", "show_file_manager_in_sidebar", "INTEGER NOT NULL DEFAULT 0", ); addColumnIfNotExists( "ssh_data", "show_tunnel_in_sidebar", "INTEGER NOT NULL DEFAULT 0", ); addColumnIfNotExists( "ssh_data", "show_docker_in_sidebar", "INTEGER NOT NULL DEFAULT 0", ); addColumnIfNotExists( "ssh_data", "show_server_stats_in_sidebar", "INTEGER NOT NULL DEFAULT 0", ); addColumnIfNotExists("ssh_credentials", "private_key", "TEXT"); addColumnIfNotExists("ssh_credentials", "public_key", "TEXT"); addColumnIfNotExists("ssh_credentials", "detected_key_type", "TEXT"); addColumnIfNotExists("ssh_credentials", "cert_public_key", "TEXT"); addColumnIfNotExists("ssh_credentials", "system_password", "TEXT"); addColumnIfNotExists("ssh_credentials", "system_key", "TEXT"); addColumnIfNotExists("ssh_credentials", "system_key_password", "TEXT"); try { const tableInfo = sqlite.prepare("PRAGMA table_info(ssh_credentials)").all() as Array<{ cid: number; name: string; type: string; notnull: number; dflt_value: string | null; pk: number; }>; const usernameCol = tableInfo.find((col) => col.name === "username"); if (usernameCol && usernameCol.notnull === 1) { const tempTableName = "ssh_credentials_temp_migration"; const allColumns = tableInfo.map((col) => col.name).join(", "); sqlite.exec(`PRAGMA foreign_keys = OFF`); sqlite.exec(` CREATE TABLE ${tempTableName} ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, name TEXT NOT NULL, description TEXT, folder TEXT, tags TEXT, auth_type TEXT NOT NULL, username TEXT, password TEXT, key TEXT, key_password TEXT, key_type TEXT, usage_count INTEGER NOT NULL DEFAULT 0, last_used TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, private_key TEXT, public_key TEXT, detected_key_type TEXT, system_password TEXT, system_key TEXT, system_key_password TEXT, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); INSERT INTO ${tempTableName} SELECT ${allColumns} FROM ssh_credentials; DROP TABLE ssh_credentials; ALTER TABLE ${tempTableName} RENAME TO ssh_credentials; `); sqlite.exec(`PRAGMA foreign_keys = ON`); databaseLogger.info("Successfully migrated ssh_credentials table to remove username NOT NULL constraint", { operation: "schema_migration_username_nullable", }); } } catch (migrationError) { databaseLogger.warn("Failed to migrate ssh_credentials username column", { operation: "schema_migration", error: migrationError, }); } addColumnIfNotExists("file_manager_recent", "host_id", "INTEGER NOT NULL"); addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL"); addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL"); addColumnIfNotExists("snippets", "folder", "TEXT"); addColumnIfNotExists("snippets", "order", "INTEGER NOT NULL DEFAULT 0"); addColumnIfNotExists("snippets", "host_filter", "TEXT"); try { sqlite .prepare("SELECT id FROM snippet_folders LIMIT 1") .get(); } catch { try { sqlite.exec(` CREATE TABLE IF NOT EXISTS snippet_folders ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, name TEXT NOT NULL, color TEXT, icon TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); `); } catch (createError) { databaseLogger.warn("Failed to create snippet_folders table", { operation: "schema_migration", error: createError, }); } } try { sqlite.prepare("SELECT id FROM snippet_access LIMIT 1").get(); } catch { try { sqlite.exec(` CREATE TABLE IF NOT EXISTS snippet_access ( id INTEGER PRIMARY KEY AUTOINCREMENT, snippet_id INTEGER NOT NULL, user_id TEXT, role_id INTEGER, granted_by TEXT NOT NULL, permission_level TEXT NOT NULL DEFAULT 'view', expires_at TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (snippet_id) REFERENCES snippets (id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE, FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE ); `); } catch (createError) { databaseLogger.warn("Failed to create snippet_access table", { operation: "schema_migration", error: createError, }); } } try { sqlite.prepare("SELECT id FROM c2s_tunnel_presets LIMIT 1").get(); } catch { try { sqlite.exec(` CREATE TABLE IF NOT EXISTS c2s_tunnel_presets ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, name TEXT NOT NULL, config TEXT NOT NULL, platform TEXT, computer_name TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); `); } catch (createError) { databaseLogger.warn("Failed to create c2s_tunnel_presets table", { operation: "schema_migration", error: createError, }); } } try { sqlite .prepare("SELECT id FROM sessions LIMIT 1") .get(); } catch { try { sqlite.exec(` CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, jwt_token TEXT NOT NULL, device_type TEXT NOT NULL, device_info TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, expires_at TEXT NOT NULL, last_active_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users (id) ); `); } catch (createError) { databaseLogger.warn("Failed to create sessions table", { operation: "schema_migration", error: createError, }); } } try { sqlite .prepare("SELECT id FROM trusted_devices LIMIT 1") .get(); } catch { try { sqlite.exec(` CREATE TABLE IF NOT EXISTS trusted_devices ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, device_fingerprint TEXT NOT NULL, device_type TEXT NOT NULL, device_info TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, expires_at TEXT NOT NULL, last_used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); `); } catch (createError) { databaseLogger.warn("Failed to create trusted_devices table", { operation: "schema_migration", error: createError, }); } } try { sqlite .prepare("SELECT id FROM network_topology LIMIT 1") .get(); } catch { try { sqlite.exec(` CREATE TABLE IF NOT EXISTS network_topology ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, topology TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); `); } catch (createError) { databaseLogger.warn("Failed to create network_topology table", { operation: "schema_migration", error: createError, }); } } try { sqlite .prepare("SELECT id FROM dashboard_preferences LIMIT 1") .get(); } catch { try { sqlite.exec(` CREATE TABLE IF NOT EXISTS dashboard_preferences ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL UNIQUE, layout TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); `); } catch (createError) { databaseLogger.warn("Failed to create dashboard_preferences table", { operation: "schema_migration", error: createError, }); } } try { sqlite.prepare("SELECT id FROM host_access LIMIT 1").get(); } catch { try { sqlite.exec(` CREATE TABLE IF NOT EXISTS host_access ( id INTEGER PRIMARY KEY AUTOINCREMENT, host_id INTEGER NOT NULL, user_id TEXT, role_id INTEGER, granted_by TEXT NOT NULL, permission_level TEXT NOT NULL DEFAULT 'use', expires_at TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, last_accessed_at TEXT, access_count INTEGER NOT NULL DEFAULT 0, FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE, FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE ); `); } catch (createError) { databaseLogger.warn("Failed to create host_access table", { operation: "schema_migration", error: createError, }); } } try { sqlite.prepare("SELECT role_id FROM host_access LIMIT 1").get(); } catch { try { sqlite.exec("ALTER TABLE host_access ADD COLUMN role_id INTEGER REFERENCES roles(id) ON DELETE CASCADE"); } catch (alterError) { databaseLogger.warn("Failed to add role_id column", { operation: "schema_migration", error: alterError, }); } } try { sqlite.prepare("SELECT override_credential_id FROM host_access LIMIT 1").get(); } catch { try { sqlite.exec("ALTER TABLE host_access ADD COLUMN override_credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL"); } catch (alterError) { databaseLogger.warn("Failed to add override_credential_id column", { operation: "schema_migration", error: alterError, }); } } try { sqlite.prepare("SELECT sudo_password FROM ssh_data LIMIT 1").get(); } catch { try { sqlite.exec("ALTER TABLE ssh_data ADD COLUMN sudo_password TEXT"); } catch (alterError) { databaseLogger.warn("Failed to add sudo_password column", { operation: "schema_migration", error: alterError, }); } } const sshDataMigrations: Array<{ column: string; sql: string }> = [ { column: "connection_type", sql: "ALTER TABLE ssh_data ADD COLUMN connection_type TEXT NOT NULL DEFAULT 'ssh'" }, { column: "credential_id", sql: "ALTER TABLE ssh_data ADD COLUMN credential_id INTEGER" }, { column: "override_credential_username", sql: "ALTER TABLE ssh_data ADD COLUMN override_credential_username INTEGER" }, { column: "jump_hosts", sql: "ALTER TABLE ssh_data ADD COLUMN jump_hosts TEXT" }, { column: "show_terminal_in_sidebar", sql: "ALTER TABLE ssh_data ADD COLUMN show_terminal_in_sidebar INTEGER NOT NULL DEFAULT 1" }, { column: "show_file_manager_in_sidebar", sql: "ALTER TABLE ssh_data ADD COLUMN show_file_manager_in_sidebar INTEGER NOT NULL DEFAULT 0" }, { column: "show_tunnel_in_sidebar", sql: "ALTER TABLE ssh_data ADD COLUMN show_tunnel_in_sidebar INTEGER NOT NULL DEFAULT 0" }, { column: "show_docker_in_sidebar", sql: "ALTER TABLE ssh_data ADD COLUMN show_docker_in_sidebar INTEGER NOT NULL DEFAULT 0" }, { column: "show_server_stats_in_sidebar", sql: "ALTER TABLE ssh_data ADD COLUMN show_server_stats_in_sidebar INTEGER NOT NULL DEFAULT 0" }, { column: "quick_actions", sql: "ALTER TABLE ssh_data ADD COLUMN quick_actions TEXT" }, { column: "domain", sql: "ALTER TABLE ssh_data ADD COLUMN domain TEXT" }, { column: "security", sql: "ALTER TABLE ssh_data ADD COLUMN security TEXT" }, { column: "ignore_cert", sql: "ALTER TABLE ssh_data ADD COLUMN ignore_cert INTEGER NOT NULL DEFAULT 0" }, { column: "guacamole_config", sql: "ALTER TABLE ssh_data ADD COLUMN guacamole_config TEXT" }, { column: "socks5_proxy_chain", sql: "ALTER TABLE ssh_data ADD COLUMN socks5_proxy_chain TEXT" }, { column: "host_key_fingerprint", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_fingerprint TEXT" }, { column: "host_key_type", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_type TEXT" }, { column: "host_key_algorithm", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_algorithm TEXT NOT NULL DEFAULT 'sha256'" }, { column: "host_key_first_seen", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_first_seen TEXT" }, { column: "host_key_last_verified", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_last_verified TEXT" }, { column: "host_key_changed_count", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_changed_count INTEGER NOT NULL DEFAULT 0" }, { column: "mac_address", sql: "ALTER TABLE ssh_data ADD COLUMN mac_address TEXT" }, { column: "port_knock_sequence", sql: "ALTER TABLE ssh_data ADD COLUMN port_knock_sequence TEXT" }, { column: "enable_ssh", sql: "ALTER TABLE ssh_data ADD COLUMN enable_ssh INTEGER NOT NULL DEFAULT 1" }, { column: "enable_rdp", sql: "ALTER TABLE ssh_data ADD COLUMN enable_rdp INTEGER NOT NULL DEFAULT 0" }, { column: "enable_vnc", sql: "ALTER TABLE ssh_data ADD COLUMN enable_vnc INTEGER NOT NULL DEFAULT 0" }, { column: "enable_telnet", sql: "ALTER TABLE ssh_data ADD COLUMN enable_telnet INTEGER NOT NULL DEFAULT 0" }, { column: "ssh_port", sql: "ALTER TABLE ssh_data ADD COLUMN ssh_port INTEGER DEFAULT 22" }, { column: "rdp_port", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_port INTEGER DEFAULT 3389" }, { column: "vnc_port", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_port INTEGER DEFAULT 5900" }, { column: "telnet_port", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_port INTEGER DEFAULT 23" }, { column: "rdp_user", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_user TEXT" }, { column: "rdp_password", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_password TEXT" }, { column: "rdp_domain", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_domain TEXT" }, { column: "rdp_security", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_security TEXT" }, { column: "rdp_ignore_cert", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_ignore_cert INTEGER DEFAULT 0" }, { column: "vnc_password", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_password TEXT" }, { column: "vnc_user", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_user TEXT" }, { column: "telnet_user", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_user TEXT" }, { column: "telnet_password", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_password TEXT" }, ]; for (const migration of sshDataMigrations) { try { sqlite.prepare(`SELECT ${migration.column} FROM ssh_data LIMIT 1`).get(); } catch { try { sqlite.exec(migration.sql); } catch (alterError) { databaseLogger.warn(`Failed to add ${migration.column} column`, { operation: "schema_migration", error: alterError, }); } } } // Copy unencrypted username/domain into protocol-specific columns for old guac hosts. // Passwords are handled via the legacy field name fallback in lazy-field-encryption.ts. const usernameDomainBackfills = [ { protocol: "rdp", sql: "UPDATE ssh_data SET rdp_user = username, rdp_password = password, rdp_domain = domain WHERE connection_type = 'rdp' AND rdp_user IS NULL AND rdp_password IS NULL", }, { protocol: "vnc", sql: "UPDATE ssh_data SET vnc_user = username, vnc_password = password WHERE connection_type = 'vnc' AND vnc_user IS NULL AND vnc_password IS NULL", }, { protocol: "telnet", sql: "UPDATE ssh_data SET telnet_user = username, telnet_password = password WHERE connection_type = 'telnet' AND telnet_user IS NULL AND telnet_password IS NULL", }, ]; for (const backfill of usernameDomainBackfills) { try { const result = sqlite.prepare(backfill.sql).run(); if (result.changes > 0) { databaseLogger.info( `Backfilled ${result.changes} ${backfill.protocol} host credential(s)`, { operation: "guac_credential_backfill" }, ); } } catch (e) { databaseLogger.warn(`Failed to backfill ${backfill.protocol} host credentials`, { operation: "guac_credential_backfill", error: e, }); } } try { sqlite.prepare("SELECT id FROM roles LIMIT 1").get(); } catch { try { sqlite.exec(` CREATE TABLE IF NOT EXISTS roles ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, description TEXT, is_system INTEGER NOT NULL DEFAULT 0, permissions TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); `); } catch (createError) { databaseLogger.warn("Failed to create roles table", { operation: "schema_migration", error: createError, }); } } try { sqlite.prepare("SELECT id FROM user_roles LIMIT 1").get(); } catch { try { sqlite.exec(` CREATE TABLE IF NOT EXISTS user_roles ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, role_id INTEGER NOT NULL, granted_by TEXT, granted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(user_id, role_id), FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE, FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE SET NULL ); `); } catch (createError) { databaseLogger.warn("Failed to create user_roles table", { operation: "schema_migration", error: createError, }); } } try { sqlite.prepare("SELECT id FROM audit_logs LIMIT 1").get(); } catch { try { sqlite.exec(` CREATE TABLE IF NOT EXISTS audit_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, username TEXT NOT NULL, action TEXT NOT NULL, resource_type TEXT NOT NULL, resource_id TEXT, resource_name TEXT, details TEXT, ip_address TEXT, user_agent TEXT, success INTEGER NOT NULL, error_message TEXT, timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); `); } catch (createError) { databaseLogger.warn("Failed to create audit_logs table", { operation: "schema_migration", error: createError, }); } } try { sqlite.prepare("SELECT id FROM session_recordings LIMIT 1").get(); } catch { try { sqlite.exec(` CREATE TABLE IF NOT EXISTS session_recordings ( id INTEGER PRIMARY KEY AUTOINCREMENT, host_id INTEGER NOT NULL, user_id TEXT NOT NULL, access_id INTEGER, started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, ended_at TEXT, duration INTEGER, commands TEXT, dangerous_actions TEXT, recording_path TEXT, terminated_by_owner INTEGER DEFAULT 0, termination_reason TEXT, FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL ); `); } catch (createError) { databaseLogger.warn("Failed to create session_recordings table", { operation: "schema_migration", error: createError, }); } } try { sqlite.prepare("SELECT id FROM shared_credentials LIMIT 1").get(); } catch { try { sqlite.exec(` CREATE TABLE IF NOT EXISTS shared_credentials ( id INTEGER PRIMARY KEY AUTOINCREMENT, host_access_id INTEGER NOT NULL, original_credential_id INTEGER NOT NULL, target_user_id TEXT NOT NULL, encrypted_username TEXT NOT NULL, encrypted_auth_type TEXT NOT NULL, encrypted_password TEXT, encrypted_key TEXT, encrypted_key_password TEXT, encrypted_key_type TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, needs_re_encryption INTEGER NOT NULL DEFAULT 0, FOREIGN KEY (host_access_id) REFERENCES host_access (id) ON DELETE CASCADE, FOREIGN KEY (original_credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE, FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE ); `); } catch (createError) { databaseLogger.warn("Failed to create shared_credentials table", { operation: "schema_migration", error: createError, }); } } try { sqlite.prepare("SELECT id FROM opkssh_tokens LIMIT 1").get(); } catch { try { sqlite.exec(` CREATE TABLE IF NOT EXISTS opkssh_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, host_id INTEGER NOT NULL, ssh_cert TEXT NOT NULL, private_key TEXT NOT NULL, email TEXT, sub TEXT, issuer TEXT, audience TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, expires_at TEXT NOT NULL, last_used TEXT, UNIQUE(user_id, host_id), FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE ); `); } catch (createError) { databaseLogger.warn("Failed to create opkssh_tokens table", { operation: "schema_migration", error: createError, }); } } try { sqlite.prepare("SELECT id FROM api_keys LIMIT 1").get(); } catch { try { sqlite.exec(` CREATE TABLE IF NOT EXISTS api_keys ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, token_hash TEXT NOT NULL, token_prefix TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, expires_at TEXT, last_used_at TEXT, is_active INTEGER NOT NULL DEFAULT 1, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); `); } catch (createError) { databaseLogger.warn("Failed to create api_keys table", { operation: "schema_migration", error: createError, }); } } try { const existingRoles = sqlite.prepare("SELECT name, is_system FROM roles").all() as Array<{ name: string; is_system: number }>; try { const validSystemRoles = ['admin', 'user']; const unwantedRoleNames = ['superAdmin', 'powerUser', 'readonly', 'member']; const deleteByName = sqlite.prepare("DELETE FROM roles WHERE name = ?"); for (const roleName of unwantedRoleNames) { deleteByName.run(roleName); } const deleteOldSystemRole = sqlite.prepare("DELETE FROM roles WHERE name = ? AND is_system = 1"); for (const role of existingRoles) { if (role.is_system === 1 && !validSystemRoles.includes(role.name) && !unwantedRoleNames.includes(role.name)) { deleteOldSystemRole.run(role.name); } } } catch (cleanupError) { databaseLogger.warn("Failed to clean up old system roles", { operation: "schema_migration", error: cleanupError, }); } const systemRoles = [ { name: "admin", displayName: "rbac.roles.admin", description: "Administrator with full access", permissions: null, }, { name: "user", displayName: "rbac.roles.user", description: "Regular user", permissions: null, }, ]; for (const role of systemRoles) { const existingRole = sqlite.prepare("SELECT id FROM roles WHERE name = ?").get(role.name); if (!existingRole) { try { sqlite.prepare(` INSERT INTO roles (name, display_name, description, is_system, permissions) VALUES (?, ?, ?, 1, ?) `).run(role.name, role.displayName, role.description, role.permissions); } catch (insertError) { databaseLogger.warn(`Failed to create system role: ${role.name}`, { operation: "schema_migration", error: insertError, }); } } } try { const adminUsers = sqlite.prepare("SELECT id FROM users WHERE is_admin = 1").all() as { id: string }[]; const normalUsers = sqlite.prepare("SELECT id FROM users WHERE is_admin = 0").all() as { id: string }[]; const adminRole = sqlite.prepare("SELECT id FROM roles WHERE name = 'admin'").get() as { id: number } | undefined; const userRole = sqlite.prepare("SELECT id FROM roles WHERE name = 'user'").get() as { id: number } | undefined; if (adminRole) { const insertUserRole = sqlite.prepare(` INSERT OR IGNORE INTO user_roles (user_id, role_id, granted_at) VALUES (?, ?, CURRENT_TIMESTAMP) `); for (const admin of adminUsers) { try { insertUserRole.run(admin.id, adminRole.id); } catch { // Ignore duplicate errors } } } if (userRole) { const insertUserRole = sqlite.prepare(` INSERT OR IGNORE INTO user_roles (user_id, role_id, granted_at) VALUES (?, ?, CURRENT_TIMESTAMP) `); for (const user of normalUsers) { try { insertUserRole.run(user.id, userRole.id); } catch { // Ignore duplicate errors } } } } catch (migrationError) { databaseLogger.warn("Failed to migrate existing users to roles", { operation: "schema_migration", error: migrationError, }); } } catch (seedError) { databaseLogger.warn("Failed to seed system roles", { operation: "schema_migration", error: seedError, }); } databaseLogger.success("Schema migration completed", { operation: "schema_migration", }); }; async function saveMemoryDatabaseToFile(): Promise { if (!memoryDatabase) return; try { const buffer = memoryDatabase.serialize(); if (!fs.existsSync(dataDir)) { fs.mkdirSync(dataDir, { recursive: true }); } try { memoryDatabase .prepare("SELECT COUNT(*) as count FROM sessions") .get() as { count: number }; } catch { // expected - sessions table may not exist yet } if (enableFileEncryption) { await DatabaseFileEncryption.encryptDatabaseFromBuffer( buffer, encryptedDbPath, ); } else { fs.writeFileSync(dbPath, buffer); } DatabaseSaveTrigger.markClean(); } catch (error) { databaseLogger.error("Failed to save in-memory database", error, { operation: "memory_db_save_failed", enableFileEncryption, }); } } async function handlePostInitFileEncryption() { if (!enableFileEncryption) return; try { if (memoryDatabase) { await saveMemoryDatabaseToFile(); setInterval(() => { if (DatabaseSaveTrigger.isDirty) { saveMemoryDatabaseToFile(); } }, 5 * 60 * 1000); DatabaseSaveTrigger.initialize(saveMemoryDatabaseToFile); } try { const migration = new DatabaseMigration(dataDir); migration.cleanupOldBackups(); } catch (cleanupError) { databaseLogger.warn("Failed to cleanup old migration files", { operation: "migration_cleanup_startup_failed", error: cleanupError instanceof Error ? cleanupError.message : "Unknown error", }); } } catch (error) { databaseLogger.error( "Failed to handle database file encryption setup", error, { operation: "db_encrypt_setup_failed", }, ); } } async function initializeDatabase(): Promise { await initializeCompleteDatabase(); await handlePostInitFileEncryption(); } export { initializeDatabase }; async function cleanupDatabase() { if (memoryDatabase) { try { await saveMemoryDatabaseToFile(); } catch (error) { databaseLogger.error( "Failed to save in-memory database before shutdown", error, { operation: "shutdown_save_failed", }, ); } } try { if (sqlite) { sqlite.close(); } } catch (error) { databaseLogger.warn("Error closing database connection", { operation: "db_close_error", error: error instanceof Error ? error.message : "Unknown error", }); } try { const tempDir = path.join(dataDir, ".temp"); if (fs.existsSync(tempDir)) { const files = fs.readdirSync(tempDir); for (const file of files) { try { fs.unlinkSync(path.join(tempDir, file)); } catch { // expected - file cleanup best effort } } try { fs.rmdirSync(tempDir); } catch { // expected - dir cleanup best effort } } } catch { // expected - temp dir cleanup best effort } } process.on("exit", () => { if (sqlite) { try { sqlite.close(); } catch { // expected - database may already be closed } } }); process.on("SIGINT", async () => { databaseLogger.info("Received SIGINT, cleaning up...", { operation: "shutdown", }); await cleanupDatabase(); process.exit(0); }); process.on("SIGTERM", async () => { databaseLogger.info("Received SIGTERM, cleaning up...", { operation: "shutdown", }); await cleanupDatabase(); process.exit(0); }); let db: ReturnType>; export function getDb(): ReturnType> { if (!db) { throw new Error( "Database not initialized. Ensure initializeDatabase() is called before accessing db.", ); } return db; } export function getSqlite(): Database.Database { if (!sqlite) { throw new Error( "SQLite not initialized. Ensure initializeDatabase() is called before accessing sqlite.", ); } return sqlite; } export { db }; export { DatabaseFileEncryption }; export const databasePaths = { main: actualDbPath, encrypted: encryptedDbPath, directory: dbDir, inMemory: true, }; export { saveMemoryDatabaseToFile }; export { DatabaseSaveTrigger };