/** * Database layer for Onebox using SQLite */ import * as plugins from '../plugins.ts'; import type { IService, IRegistry, IRegistryToken, INginxConfig, ISslCertificate, IDnsRecord, IMetric, ILogEntry, IUser, ISetting, IPlatformService, IPlatformResource, IPlatformRequirements, TPlatformServiceType, IDomain, ICertificate, ICertRequirement, } from '../types.ts'; // Type alias for sqlite bind parameters type BindValue = string | number | bigint | boolean | null | undefined | Uint8Array; import { logger } from '../logging.ts'; import { getErrorMessage } from '../utils/error.ts'; export class OneboxDatabase { private db: InstanceType | null = null; private dbPath: string; constructor(dbPath = './.nogit/onebox.db') { this.dbPath = dbPath; } /** * Initialize database connection and create tables */ async init(): Promise { try { // Ensure data directory exists const dbDir = plugins.path.dirname(this.dbPath); await Deno.mkdir(dbDir, { recursive: true }); // Open database this.db = new plugins.sqlite.DB(this.dbPath); logger.info(`Database initialized at ${this.dbPath}`); // Create tables await this.createTables(); // Run migrations if needed await this.runMigrations(); } catch (error) { logger.error(`Failed to initialize database: ${getErrorMessage(error)}`); throw error; } } /** * Create all database tables */ private async createTables(): Promise { if (!this.db) throw new Error('Database not initialized'); // Services table this.query(` CREATE TABLE IF NOT EXISTS services ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, image TEXT NOT NULL, registry TEXT, env_vars TEXT NOT NULL, port INTEGER NOT NULL, domain TEXT, container_id TEXT, status TEXT NOT NULL DEFAULT 'stopped', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ) `); // Registries table this.query(` CREATE TABLE IF NOT EXISTS registries ( id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT NOT NULL UNIQUE, username TEXT NOT NULL, password_encrypted TEXT NOT NULL, created_at INTEGER NOT NULL ) `); // Nginx configs table this.query(` CREATE TABLE IF NOT EXISTS nginx_configs ( id INTEGER PRIMARY KEY AUTOINCREMENT, service_id INTEGER NOT NULL, domain TEXT NOT NULL, port INTEGER NOT NULL, ssl_enabled INTEGER NOT NULL DEFAULT 0, config_template TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE ) `); // SSL certificates table this.query(` CREATE TABLE IF NOT EXISTS ssl_certificates ( id INTEGER PRIMARY KEY AUTOINCREMENT, domain TEXT NOT NULL UNIQUE, cert_path TEXT NOT NULL, key_path TEXT NOT NULL, full_chain_path TEXT NOT NULL, expiry_date INTEGER NOT NULL, issuer TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ) `); // DNS records table this.query(` CREATE TABLE IF NOT EXISTS dns_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, domain TEXT NOT NULL UNIQUE, type TEXT NOT NULL, value TEXT NOT NULL, cloudflare_id TEXT, zone_id TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ) `); // Metrics table this.query(` CREATE TABLE IF NOT EXISTS metrics ( id INTEGER PRIMARY KEY AUTOINCREMENT, service_id INTEGER NOT NULL, timestamp INTEGER NOT NULL, cpu_percent REAL NOT NULL, memory_used INTEGER NOT NULL, memory_limit INTEGER NOT NULL, network_rx_bytes INTEGER NOT NULL, network_tx_bytes INTEGER NOT NULL, FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE ) `); // Create index for metrics queries this.query(` CREATE INDEX IF NOT EXISTS idx_metrics_service_timestamp ON metrics(service_id, timestamp DESC) `); // Logs table this.query(` CREATE TABLE IF NOT EXISTS logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, service_id INTEGER NOT NULL, timestamp INTEGER NOT NULL, message TEXT NOT NULL, level TEXT NOT NULL, source TEXT NOT NULL, FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE ) `); // Create index for logs queries this.query(` CREATE INDEX IF NOT EXISTS idx_logs_service_timestamp ON logs(service_id, timestamp DESC) `); // Users table this.query(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'user', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ) `); // Settings table this.query(` CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at INTEGER NOT NULL ) `); // Version table for migrations this.query(` CREATE TABLE IF NOT EXISTS migrations ( version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL ) `); logger.debug('Database tables created successfully'); } /** * Run database migrations */ private async runMigrations(): Promise { if (!this.db) throw new Error('Database not initialized'); try { const currentVersion = this.getMigrationVersion(); logger.info(`Current database migration version: ${currentVersion}`); // Migration 1: Initial schema if (currentVersion === 0) { logger.info('Setting initial migration version to 1'); this.setMigrationVersion(1); } // Migration 2: Convert timestamp columns from INTEGER to REAL const updatedVersion = this.getMigrationVersion(); if (updatedVersion < 2) { logger.info('Running migration 2: Converting timestamps to REAL...'); // For each table, we need to: // 1. Create new table with REAL timestamps // 2. Copy data // 3. Drop old table // 4. Rename new table // SSL certificates this.query(` CREATE TABLE ssl_certificates_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, domain TEXT NOT NULL UNIQUE, cert_path TEXT NOT NULL, key_path TEXT NOT NULL, full_chain_path TEXT NOT NULL, expiry_date REAL NOT NULL, issuer TEXT NOT NULL, created_at REAL NOT NULL, updated_at REAL NOT NULL ) `); this.query(`INSERT INTO ssl_certificates_new SELECT * FROM ssl_certificates`); this.query(`DROP TABLE ssl_certificates`); this.query(`ALTER TABLE ssl_certificates_new RENAME TO ssl_certificates`); // Services this.query(` CREATE TABLE services_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, image TEXT NOT NULL, registry TEXT, env_vars TEXT NOT NULL, port INTEGER NOT NULL, domain TEXT, container_id TEXT, status TEXT NOT NULL DEFAULT 'stopped', created_at REAL NOT NULL, updated_at REAL NOT NULL ) `); this.query(`INSERT INTO services_new SELECT * FROM services`); this.query(`DROP TABLE services`); this.query(`ALTER TABLE services_new RENAME TO services`); // Registries this.query(` CREATE TABLE registries_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT NOT NULL UNIQUE, username TEXT NOT NULL, password_encrypted TEXT NOT NULL, created_at REAL NOT NULL ) `); this.query(`INSERT INTO registries_new SELECT * FROM registries`); this.query(`DROP TABLE registries`); this.query(`ALTER TABLE registries_new RENAME TO registries`); // Nginx configs this.query(` CREATE TABLE nginx_configs_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, service_id INTEGER NOT NULL, domain TEXT NOT NULL, port INTEGER NOT NULL, ssl_enabled INTEGER NOT NULL DEFAULT 0, config_template TEXT NOT NULL, created_at REAL NOT NULL, updated_at REAL NOT NULL, FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE ) `); this.query(`INSERT INTO nginx_configs_new SELECT * FROM nginx_configs`); this.query(`DROP TABLE nginx_configs`); this.query(`ALTER TABLE nginx_configs_new RENAME TO nginx_configs`); // DNS records this.query(` CREATE TABLE dns_records_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, domain TEXT NOT NULL UNIQUE, type TEXT NOT NULL, value TEXT NOT NULL, cloudflare_id TEXT, zone_id TEXT, created_at REAL NOT NULL, updated_at REAL NOT NULL ) `); this.query(`INSERT INTO dns_records_new SELECT * FROM dns_records`); this.query(`DROP TABLE dns_records`); this.query(`ALTER TABLE dns_records_new RENAME TO dns_records`); // Metrics this.query(` CREATE TABLE metrics_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, service_id INTEGER NOT NULL, timestamp REAL NOT NULL, cpu_percent REAL NOT NULL, memory_used INTEGER NOT NULL, memory_limit INTEGER NOT NULL, network_rx_bytes INTEGER NOT NULL, network_tx_bytes INTEGER NOT NULL, FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE ) `); this.query(`INSERT INTO metrics_new SELECT * FROM metrics`); this.query(`DROP TABLE metrics`); this.query(`ALTER TABLE metrics_new RENAME TO metrics`); this.query(`CREATE INDEX IF NOT EXISTS idx_metrics_service_timestamp ON metrics(service_id, timestamp DESC)`); // Logs this.query(` CREATE TABLE logs_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, service_id INTEGER NOT NULL, timestamp REAL NOT NULL, message TEXT NOT NULL, level TEXT NOT NULL, source TEXT NOT NULL, FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE ) `); this.query(`INSERT INTO logs_new SELECT * FROM logs`); this.query(`DROP TABLE logs`); this.query(`ALTER TABLE logs_new RENAME TO logs`); this.query(`CREATE INDEX IF NOT EXISTS idx_logs_service_timestamp ON logs(service_id, timestamp DESC)`); // Users this.query(` CREATE TABLE users_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'user', created_at REAL NOT NULL, updated_at REAL NOT NULL ) `); this.query(`INSERT INTO users_new SELECT * FROM users`); this.query(`DROP TABLE users`); this.query(`ALTER TABLE users_new RENAME TO users`); // Settings this.query(` CREATE TABLE settings_new ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at REAL NOT NULL ) `); this.query(`INSERT INTO settings_new SELECT * FROM settings`); this.query(`DROP TABLE settings`); this.query(`ALTER TABLE settings_new RENAME TO settings`); // Migrations table itself this.query(` CREATE TABLE migrations_new ( version INTEGER PRIMARY KEY, applied_at REAL NOT NULL ) `); this.query(`INSERT INTO migrations_new SELECT * FROM migrations`); this.query(`DROP TABLE migrations`); this.query(`ALTER TABLE migrations_new RENAME TO migrations`); this.setMigrationVersion(2); logger.success('Migration 2 completed: All timestamps converted to REAL'); } // Migration 3: Domain management tables const version3 = this.getMigrationVersion(); if (version3 < 3) { logger.info('Running migration 3: Creating domain management tables...'); // 1. Create domains table this.query(` CREATE TABLE domains ( id INTEGER PRIMARY KEY AUTOINCREMENT, domain TEXT NOT NULL UNIQUE, dns_provider TEXT, cloudflare_zone_id TEXT, is_obsolete INTEGER NOT NULL DEFAULT 0, default_wildcard INTEGER NOT NULL DEFAULT 1, created_at REAL NOT NULL, updated_at REAL NOT NULL ) `); // 2. Create certificates table (renamed from ssl_certificates) this.query(` CREATE TABLE certificates ( id INTEGER PRIMARY KEY AUTOINCREMENT, domain_id INTEGER NOT NULL, cert_domain TEXT NOT NULL, is_wildcard INTEGER NOT NULL DEFAULT 0, cert_path TEXT NOT NULL, key_path TEXT NOT NULL, full_chain_path TEXT NOT NULL, expiry_date REAL NOT NULL, issuer TEXT NOT NULL, is_valid INTEGER NOT NULL DEFAULT 1, created_at REAL NOT NULL, updated_at REAL NOT NULL, FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE ) `); // 3. Create cert_requirements table this.query(` CREATE TABLE cert_requirements ( id INTEGER PRIMARY KEY AUTOINCREMENT, service_id INTEGER NOT NULL, domain_id INTEGER NOT NULL, subdomain TEXT NOT NULL, certificate_id INTEGER, status TEXT NOT NULL DEFAULT 'pending', created_at REAL NOT NULL, updated_at REAL NOT NULL, FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE, FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE, FOREIGN KEY (certificate_id) REFERENCES certificates(id) ON DELETE SET NULL ) `); // 4. Migrate existing ssl_certificates data // Extract unique base domains from existing certificates interface OldSslCert { id?: number; domain?: string; cert_path?: string; key_path?: string; full_chain_path?: string; expiry_date?: number; issuer?: string; created_at?: number; updated_at?: number; [key: number]: unknown; // Allow array-style access as fallback } const existingCerts = this.query('SELECT * FROM ssl_certificates'); const now = Date.now(); const domainMap = new Map(); // Create domain entries for each unique base domain for (const cert of existingCerts) { const domain = String(cert.domain ?? (cert as Record)[1]); if (!domainMap.has(domain)) { this.query( 'INSERT INTO domains (domain, dns_provider, is_obsolete, default_wildcard, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)', [domain, null, 0, 1, now, now] ); const result = this.query<{ id?: number; [key: number]: unknown }>('SELECT last_insert_rowid() as id'); const domainId = result[0].id ?? (result[0] as Record)[0]; domainMap.set(domain, Number(domainId)); } } // Migrate certificates to new table for (const cert of existingCerts) { const domain = String(cert.domain ?? (cert as Record)[1]); const domainId = domainMap.get(domain); this.query( `INSERT INTO certificates ( domain_id, cert_domain, is_wildcard, cert_path, key_path, full_chain_path, expiry_date, issuer, is_valid, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ domainId, domain, 0, // We don't know if it's wildcard, default to false String(cert.cert_path ?? (cert as Record)[2]), String(cert.key_path ?? (cert as Record)[3]), String(cert.full_chain_path ?? (cert as Record)[4]), Number(cert.expiry_date ?? (cert as Record)[5]), String(cert.issuer ?? (cert as Record)[6]), 1, // Assume valid Number(cert.created_at ?? (cert as Record)[7]), Number(cert.updated_at ?? (cert as Record)[8]) ] ); } // 5. Drop old ssl_certificates table this.query('DROP TABLE ssl_certificates'); // 6. Create indices for performance this.query('CREATE INDEX IF NOT EXISTS idx_domains_cloudflare_zone ON domains(cloudflare_zone_id)'); this.query('CREATE INDEX IF NOT EXISTS idx_certificates_domain ON certificates(domain_id)'); this.query('CREATE INDEX IF NOT EXISTS idx_certificates_expiry ON certificates(expiry_date)'); this.query('CREATE INDEX IF NOT EXISTS idx_cert_requirements_service ON cert_requirements(service_id)'); this.query('CREATE INDEX IF NOT EXISTS idx_cert_requirements_domain ON cert_requirements(domain_id)'); this.setMigrationVersion(3); logger.success('Migration 3 completed: Domain management tables created'); } // Migration 4: Add Onebox Registry support columns to services table const version4 = this.getMigrationVersion(); if (version4 < 4) { logger.info('Running migration 4: Adding Onebox Registry columns to services table...'); // Add new columns for registry support this.query(` ALTER TABLE services ADD COLUMN use_onebox_registry INTEGER DEFAULT 0 `); this.query(` ALTER TABLE services ADD COLUMN registry_repository TEXT `); this.query(` ALTER TABLE services ADD COLUMN registry_token TEXT `); this.query(` ALTER TABLE services ADD COLUMN registry_image_tag TEXT DEFAULT 'latest' `); this.query(` ALTER TABLE services ADD COLUMN auto_update_on_push INTEGER DEFAULT 0 `); this.query(` ALTER TABLE services ADD COLUMN image_digest TEXT `); this.setMigrationVersion(4); logger.success('Migration 4 completed: Onebox Registry columns added to services table'); } // Migration 5: Registry tokens table const version5 = this.getMigrationVersion(); if (version5 < 5) { logger.info('Running migration 5: Creating registry_tokens table...'); this.query(` CREATE TABLE registry_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE, token_type TEXT NOT NULL, scope TEXT NOT NULL, expires_at REAL, created_at REAL NOT NULL, last_used_at REAL, created_by TEXT NOT NULL ) `); // Create indices for performance this.query('CREATE INDEX IF NOT EXISTS idx_registry_tokens_type ON registry_tokens(token_type)'); this.query('CREATE INDEX IF NOT EXISTS idx_registry_tokens_hash ON registry_tokens(token_hash)'); this.setMigrationVersion(5); logger.success('Migration 5 completed: Registry tokens table created'); } // Migration 6: Drop registry_token column from services table (replaced by registry_tokens table) const version6 = this.getMigrationVersion(); if (version6 < 6) { logger.info('Running migration 6: Dropping registry_token column from services table...'); // SQLite doesn't support DROP COLUMN directly, so we need to recreate the table // Create new table without registry_token this.query(` CREATE TABLE services_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, image TEXT NOT NULL, registry TEXT, env_vars TEXT, port INTEGER NOT NULL, domain TEXT, container_id TEXT, status TEXT NOT NULL, created_at REAL NOT NULL, updated_at REAL NOT NULL, use_onebox_registry INTEGER DEFAULT 0, registry_repository TEXT, registry_image_tag TEXT DEFAULT 'latest', auto_update_on_push INTEGER DEFAULT 0, image_digest TEXT ) `); // Copy data (excluding registry_token) this.query(` INSERT INTO services_new ( id, name, image, registry, env_vars, port, domain, container_id, status, created_at, updated_at, use_onebox_registry, registry_repository, registry_image_tag, auto_update_on_push, image_digest ) SELECT id, name, image, registry, env_vars, port, domain, container_id, status, created_at, updated_at, use_onebox_registry, registry_repository, registry_image_tag, auto_update_on_push, image_digest FROM services `); // Drop old table this.query('DROP TABLE services'); // Rename new table this.query('ALTER TABLE services_new RENAME TO services'); // Recreate indices this.query('CREATE INDEX IF NOT EXISTS idx_services_name ON services(name)'); this.query('CREATE INDEX IF NOT EXISTS idx_services_status ON services(status)'); this.setMigrationVersion(6); logger.success('Migration 6 completed: registry_token column dropped from services table'); } // Migration 7: Platform services tables const version7 = this.getMigrationVersion(); if (version7 < 7) { logger.info('Running migration 7: Creating platform services tables...'); // Create platform_services table this.query(` CREATE TABLE platform_services ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, type TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'stopped', container_id TEXT, config TEXT NOT NULL DEFAULT '{}', admin_credentials_encrypted TEXT, created_at REAL NOT NULL, updated_at REAL NOT NULL ) `); // Create platform_resources table this.query(` CREATE TABLE platform_resources ( id INTEGER PRIMARY KEY AUTOINCREMENT, platform_service_id INTEGER NOT NULL, service_id INTEGER NOT NULL, resource_type TEXT NOT NULL, resource_name TEXT NOT NULL, credentials_encrypted TEXT NOT NULL, created_at REAL NOT NULL, FOREIGN KEY (platform_service_id) REFERENCES platform_services(id) ON DELETE CASCADE, FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE ) `); // Add platform_requirements column to services table this.query(` ALTER TABLE services ADD COLUMN platform_requirements TEXT DEFAULT '{}' `); // Create indices this.query('CREATE INDEX IF NOT EXISTS idx_platform_services_type ON platform_services(type)'); this.query('CREATE INDEX IF NOT EXISTS idx_platform_resources_service ON platform_resources(service_id)'); this.query('CREATE INDEX IF NOT EXISTS idx_platform_resources_platform ON platform_resources(platform_service_id)'); this.setMigrationVersion(7); logger.success('Migration 7 completed: Platform services tables created'); } } catch (error) { logger.error(`Migration failed: ${getErrorMessage(error)}`); if (error instanceof Error && error.stack) { logger.error(`Stack: ${error.stack}`); } throw error; } } /** * Get current migration version */ private getMigrationVersion(): number { if (!this.db) throw new Error('Database not initialized'); try { const result = this.query<{ version?: number | null; [key: number]: unknown }>('SELECT MAX(version) as version FROM migrations'); if (result.length === 0) return 0; // Handle both array and object access patterns const versionValue = result[0].version ?? (result[0] as Record)[0]; return versionValue !== null && versionValue !== undefined ? Number(versionValue) : 0; } catch (error) { logger.warn(`Error getting migration version: ${getErrorMessage(error)}, defaulting to 0`); return 0; } } /** * Set migration version */ private setMigrationVersion(version: number): void { if (!this.db) throw new Error('Database not initialized'); this.query('INSERT INTO migrations (version, applied_at) VALUES (?, ?)', [ version, Date.now(), ]); logger.debug(`Migration version set to ${version}`); } /** * Close database connection */ close(): void { if (this.db) { this.db.close(); this.db = null; logger.debug('Database connection closed'); } } /** * Execute a raw query */ query>(sql: string, params: BindValue[] = []): T[] { if (!this.db) { const error = new Error('Database not initialized'); console.error('Database access before initialization!'); console.error('Stack trace:', error.stack); throw error; } // For queries without parameters, use exec for better performance if (params.length === 0 && !sql.trim().toUpperCase().startsWith('SELECT')) { this.db.exec(sql); return [] as T[]; } // For SELECT queries or statements with parameters, use prepare().all() const stmt = this.db.prepare(sql); try { const result = stmt.all(...params); return result as T[]; } finally { stmt.finalize(); } } // ============ Services CRUD ============ async createService(service: Omit): Promise { if (!this.db) throw new Error('Database not initialized'); const now = Date.now(); this.query( `INSERT INTO services ( name, image, registry, env_vars, port, domain, container_id, status, created_at, updated_at, use_onebox_registry, registry_repository, registry_image_tag, auto_update_on_push, image_digest, platform_requirements ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ service.name, service.image, service.registry || null, JSON.stringify(service.envVars), service.port, service.domain || null, service.containerID || null, service.status, now, now, service.useOneboxRegistry ? 1 : 0, service.registryRepository || null, service.registryImageTag || 'latest', service.autoUpdateOnPush ? 1 : 0, service.imageDigest || null, JSON.stringify(service.platformRequirements || {}), ] ); return this.getServiceByName(service.name)!; } getServiceByName(name: string): IService | null { if (!this.db) throw new Error('Database not initialized'); const rows = this.query('SELECT * FROM services WHERE name = ?', [name]); if (rows.length > 0) { logger.info(`getServiceByName: raw row data: ${JSON.stringify(rows[0])}`); const service = this.rowToService(rows[0]); logger.info(`getServiceByName: service object containerID: ${service.containerID}`); return service; } return null; } getServiceByID(id: number): IService | null { if (!this.db) throw new Error('Database not initialized'); const rows = this.query('SELECT * FROM services WHERE id = ?', [id]); return rows.length > 0 ? this.rowToService(rows[0]) : null; } getAllServices(): IService[] { if (!this.db) throw new Error('Database not initialized'); const rows = this.query('SELECT * FROM services ORDER BY created_at DESC'); return rows.map((row) => this.rowToService(row)); } updateService(id: number, updates: Partial): void { if (!this.db) throw new Error('Database not initialized'); const fields: string[] = []; const values: BindValue[] = []; if (updates.image !== undefined) { fields.push('image = ?'); values.push(updates.image); } if (updates.registry !== undefined) { fields.push('registry = ?'); values.push(updates.registry); } if (updates.envVars !== undefined) { fields.push('env_vars = ?'); values.push(JSON.stringify(updates.envVars)); } if (updates.port !== undefined) { fields.push('port = ?'); values.push(updates.port); } if (updates.domain !== undefined) { fields.push('domain = ?'); values.push(updates.domain); } if (updates.containerID !== undefined) { fields.push('container_id = ?'); values.push(updates.containerID); } if (updates.status !== undefined) { fields.push('status = ?'); values.push(updates.status); } // Onebox Registry fields if (updates.useOneboxRegistry !== undefined) { fields.push('use_onebox_registry = ?'); values.push(updates.useOneboxRegistry ? 1 : 0); } if (updates.registryRepository !== undefined) { fields.push('registry_repository = ?'); values.push(updates.registryRepository); } if (updates.registryImageTag !== undefined) { fields.push('registry_image_tag = ?'); values.push(updates.registryImageTag); } if (updates.autoUpdateOnPush !== undefined) { fields.push('auto_update_on_push = ?'); values.push(updates.autoUpdateOnPush ? 1 : 0); } if (updates.imageDigest !== undefined) { fields.push('image_digest = ?'); values.push(updates.imageDigest); } if (updates.platformRequirements !== undefined) { fields.push('platform_requirements = ?'); values.push(JSON.stringify(updates.platformRequirements)); } fields.push('updated_at = ?'); values.push(Date.now()); values.push(id); this.query(`UPDATE services SET ${fields.join(', ')} WHERE id = ?`, values); } deleteService(id: number): void { if (!this.db) throw new Error('Database not initialized'); this.query('DELETE FROM services WHERE id = ?', [id]); } private rowToService(row: any): IService { // Handle env_vars JSON parsing safely let envVars = {}; const envVarsRaw = row.env_vars || row[4]; if (envVarsRaw && envVarsRaw !== 'undefined' && envVarsRaw !== 'null') { try { envVars = JSON.parse(String(envVarsRaw)); } catch (e) { logger.warn(`Failed to parse env_vars for service: ${getErrorMessage(e)}`); envVars = {}; } } // Handle platform_requirements JSON parsing safely let platformRequirements: IPlatformRequirements | undefined; const platformReqRaw = row.platform_requirements; if (platformReqRaw && platformReqRaw !== 'undefined' && platformReqRaw !== 'null' && platformReqRaw !== '{}') { try { platformRequirements = JSON.parse(String(platformReqRaw)); } catch (e) { logger.warn(`Failed to parse platform_requirements for service: ${getErrorMessage(e)}`); platformRequirements = undefined; } } return { id: Number(row.id || row[0]), name: String(row.name || row[1]), image: String(row.image || row[2]), registry: (row.registry || row[3]) ? String(row.registry || row[3]) : undefined, envVars, port: Number(row.port || row[5]), domain: (row.domain || row[6]) ? String(row.domain || row[6]) : undefined, containerID: (row.container_id || row[7]) ? String(row.container_id || row[7]) : undefined, status: String(row.status || row[8]) as IService['status'], createdAt: Number(row.created_at || row[9]), updatedAt: Number(row.updated_at || row[10]), // Onebox Registry fields useOneboxRegistry: row.use_onebox_registry ? Boolean(row.use_onebox_registry) : undefined, registryRepository: row.registry_repository ? String(row.registry_repository) : undefined, registryImageTag: row.registry_image_tag ? String(row.registry_image_tag) : undefined, autoUpdateOnPush: row.auto_update_on_push ? Boolean(row.auto_update_on_push) : undefined, imageDigest: row.image_digest ? String(row.image_digest) : undefined, // Platform service requirements platformRequirements, }; } // ============ Registries CRUD ============ async createRegistry(registry: Omit): Promise { if (!this.db) throw new Error('Database not initialized'); const now = Date.now(); this.query( 'INSERT INTO registries (url, username, password_encrypted, created_at) VALUES (?, ?, ?, ?)', [registry.url, registry.username, registry.passwordEncrypted, now] ); return this.getRegistryByURL(registry.url)!; } getRegistryByURL(url: string): IRegistry | null { if (!this.db) throw new Error('Database not initialized'); const rows = this.query('SELECT * FROM registries WHERE url = ?', [url]); return rows.length > 0 ? this.rowToRegistry(rows[0]) : null; } getAllRegistries(): IRegistry[] { if (!this.db) throw new Error('Database not initialized'); const rows = this.query('SELECT * FROM registries ORDER BY created_at DESC'); return rows.map((row) => this.rowToRegistry(row)); } deleteRegistry(url: string): void { if (!this.db) throw new Error('Database not initialized'); this.query('DELETE FROM registries WHERE url = ?', [url]); } private rowToRegistry(row: any): IRegistry { return { id: Number(row.id || row[0]), url: String(row.url || row[1]), username: String(row.username || row[2]), passwordEncrypted: String(row.password_encrypted || row[3]), createdAt: Number(row.created_at || row[4]), }; } // ============ Settings CRUD ============ getSetting(key: string): string | null { if (!this.db) throw new Error('Database not initialized'); const rows = this.query('SELECT value FROM settings WHERE key = ?', [key]); if (rows.length === 0) return null; // @db/sqlite returns rows as objects with column names as keys const value = (rows[0] as any).value || rows[0][0]; return value ? String(value) : null; } setSetting(key: string, value: string): void { if (!this.db) throw new Error('Database not initialized'); const now = Date.now(); this.query( 'INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)', [key, value, now] ); } getAllSettings(): Record { if (!this.db) throw new Error('Database not initialized'); const rows = this.query('SELECT key, value FROM settings'); const settings: Record = {}; for (const row of rows) { // @db/sqlite returns rows as objects with column names as keys const key = (row as any).key || row[0]; const value = (row as any).value || row[1]; settings[String(key)] = String(value); } return settings; } // ============ Users CRUD ============ async createUser(user: Omit): Promise { if (!this.db) throw new Error('Database not initialized'); const now = Date.now(); this.query( 'INSERT INTO users (username, password_hash, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?)', [user.username, user.passwordHash, user.role, now, now] ); return this.getUserByUsername(user.username)!; } getUserByUsername(username: string): IUser | null { if (!this.db) throw new Error('Database not initialized'); const rows = this.query('SELECT * FROM users WHERE username = ?', [username]); return rows.length > 0 ? this.rowToUser(rows[0]) : null; } getAllUsers(): IUser[] { if (!this.db) throw new Error('Database not initialized'); const rows = this.query('SELECT * FROM users ORDER BY created_at DESC'); return rows.map((row) => this.rowToUser(row)); } updateUserPassword(username: string, passwordHash: string): void { if (!this.db) throw new Error('Database not initialized'); this.query('UPDATE users SET password_hash = ?, updated_at = ? WHERE username = ?', [ passwordHash, Date.now(), username, ]); } deleteUser(username: string): void { if (!this.db) throw new Error('Database not initialized'); this.query('DELETE FROM users WHERE username = ?', [username]); } private rowToUser(row: any): IUser { return { id: Number(row.id || row[0]), username: String(row.username || row[1]), passwordHash: String(row.password_hash || row[2]), role: String(row.role || row[3]) as IUser['role'], createdAt: Number(row.created_at || row[4]), updatedAt: Number(row.updated_at || row[5]), }; } // ============ Metrics ============ addMetric(metric: Omit): void { if (!this.db) throw new Error('Database not initialized'); this.query( `INSERT INTO metrics (service_id, timestamp, cpu_percent, memory_used, memory_limit, network_rx_bytes, network_tx_bytes) VALUES (?, ?, ?, ?, ?, ?, ?)`, [ metric.serviceId, metric.timestamp, metric.cpuPercent, metric.memoryUsed, metric.memoryLimit, metric.networkRxBytes, metric.networkTxBytes, ] ); } getMetrics(serviceId: number, limit = 100): IMetric[] { if (!this.db) throw new Error('Database not initialized'); const rows = this.query( 'SELECT * FROM metrics WHERE service_id = ? ORDER BY timestamp DESC LIMIT ?', [serviceId, limit] ); return rows.map((row) => this.rowToMetric(row)); } private rowToMetric(row: any): IMetric { return { id: Number(row.id || row[0]), serviceId: Number(row.service_id || row[1]), timestamp: Number(row.timestamp || row[2]), cpuPercent: Number(row.cpu_percent || row[3]), memoryUsed: Number(row.memory_used || row[4]), memoryLimit: Number(row.memory_limit || row[5]), networkRxBytes: Number(row.network_rx_bytes || row[6]), networkTxBytes: Number(row.network_tx_bytes || row[7]), }; } // ============ Logs ============ addLog(log: Omit): void { if (!this.db) throw new Error('Database not initialized'); this.query( 'INSERT INTO logs (service_id, timestamp, message, level, source) VALUES (?, ?, ?, ?, ?)', [log.serviceId, log.timestamp, log.message, log.level, log.source] ); } getLogs(serviceId: number, limit = 1000): ILogEntry[] { if (!this.db) throw new Error('Database not initialized'); const rows = this.query( 'SELECT * FROM logs WHERE service_id = ? ORDER BY timestamp DESC LIMIT ?', [serviceId, limit] ); return rows.map((row) => this.rowToLog(row)); } private rowToLog(row: any): ILogEntry { return { id: Number(row.id || row[0]), serviceId: Number(row.service_id || row[1]), timestamp: Number(row.timestamp || row[2]), message: String(row.message || row[3]), level: String(row.level || row[4]) as ILogEntry['level'], source: String(row.source || row[5]) as ILogEntry['source'], }; } // ============ SSL Certificates ============ async createSSLCertificate(cert: Omit): Promise { if (!this.db) throw new Error('Database not initialized'); const now = Date.now(); this.query( `INSERT INTO ssl_certificates (domain, cert_path, key_path, full_chain_path, expiry_date, issuer, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [ cert.domain, cert.certPath, cert.keyPath, cert.fullChainPath, cert.expiryDate, cert.issuer, now, now, ] ); return this.getSSLCertificate(cert.domain)!; } getSSLCertificate(domain: string): ISslCertificate | null { if (!this.db) throw new Error('Database not initialized'); const rows = this.query('SELECT * FROM ssl_certificates WHERE domain = ?', [domain]); return rows.length > 0 ? this.rowToSSLCert(rows[0]) : null; } getAllSSLCertificates(): ISslCertificate[] { if (!this.db) throw new Error('Database not initialized'); const rows = this.query('SELECT * FROM ssl_certificates ORDER BY expiry_date ASC'); return rows.map((row) => this.rowToSSLCert(row)); } updateSSLCertificate(domain: string, updates: Partial): void { if (!this.db) throw new Error('Database not initialized'); const fields: string[] = []; const values: BindValue[] = []; if (updates.certPath) { fields.push('cert_path = ?'); values.push(updates.certPath); } if (updates.keyPath) { fields.push('key_path = ?'); values.push(updates.keyPath); } if (updates.fullChainPath) { fields.push('full_chain_path = ?'); values.push(updates.fullChainPath); } if (updates.expiryDate) { fields.push('expiry_date = ?'); values.push(updates.expiryDate); } fields.push('updated_at = ?'); values.push(Date.now()); values.push(domain); this.query(`UPDATE ssl_certificates SET ${fields.join(', ')} WHERE domain = ?`, values); } deleteSSLCertificate(domain: string): void { if (!this.db) throw new Error('Database not initialized'); this.query('DELETE FROM ssl_certificates WHERE domain = ?', [domain]); } private rowToSSLCert(row: any): ISslCertificate { return { id: Number(row.id || row[0]), domain: String(row.domain || row[1]), certPath: String(row.cert_path || row[2]), keyPath: String(row.key_path || row[3]), fullChainPath: String(row.full_chain_path || row[4]), expiryDate: Number(row.expiry_date || row[5]), issuer: String(row.issuer || row[6]), createdAt: Number(row.created_at || row[7]), updatedAt: Number(row.updated_at || row[8]), }; } // ============ Domains ============ createDomain(domain: Omit): IDomain { if (!this.db) throw new Error('Database not initialized'); this.query( `INSERT INTO domains (domain, dns_provider, cloudflare_zone_id, is_obsolete, default_wildcard, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, [ domain.domain, domain.dnsProvider, domain.cloudflareZoneId, domain.isObsolete ? 1 : 0, domain.defaultWildcard ? 1 : 0, domain.createdAt, domain.updatedAt, ] ); return this.getDomainByName(domain.domain)!; } getDomainByName(domain: string): IDomain | null { if (!this.db) throw new Error('Database not initialized'); const rows = this.query('SELECT * FROM domains WHERE domain = ?', [domain]); return rows.length > 0 ? this.rowToDomain(rows[0]) : null; } getDomainById(id: number): IDomain | null { if (!this.db) throw new Error('Database not initialized'); const rows = this.query('SELECT * FROM domains WHERE id = ?', [id]); return rows.length > 0 ? this.rowToDomain(rows[0]) : null; } getAllDomains(): IDomain[] { if (!this.db) throw new Error('Database not initialized'); const rows = this.query('SELECT * FROM domains ORDER BY domain ASC'); return rows.map((row) => this.rowToDomain(row)); } getDomainsByProvider(provider: 'cloudflare' | 'manual'): IDomain[] { if (!this.db) throw new Error('Database not initialized'); const rows = this.query('SELECT * FROM domains WHERE dns_provider = ? ORDER BY domain ASC', [ provider, ]); return rows.map((row) => this.rowToDomain(row)); } updateDomain(id: number, updates: Partial): void { if (!this.db) throw new Error('Database not initialized'); const fields: string[] = []; const values: BindValue[] = []; if (updates.domain !== undefined) { fields.push('domain = ?'); values.push(updates.domain); } if (updates.dnsProvider !== undefined) { fields.push('dns_provider = ?'); values.push(updates.dnsProvider); } if (updates.cloudflareZoneId !== undefined) { fields.push('cloudflare_zone_id = ?'); values.push(updates.cloudflareZoneId); } if (updates.isObsolete !== undefined) { fields.push('is_obsolete = ?'); values.push(updates.isObsolete ? 1 : 0); } if (updates.defaultWildcard !== undefined) { fields.push('default_wildcard = ?'); values.push(updates.defaultWildcard ? 1 : 0); } fields.push('updated_at = ?'); values.push(Date.now()); values.push(id); this.query(`UPDATE domains SET ${fields.join(', ')} WHERE id = ?`, values); } deleteDomain(id: number): void { if (!this.db) throw new Error('Database not initialized'); this.query('DELETE FROM domains WHERE id = ?', [id]); } private rowToDomain(row: any): IDomain { return { id: Number(row.id || row[0]), domain: String(row.domain || row[1]), dnsProvider: (row.dns_provider || row[2]) as IDomain['dnsProvider'], cloudflareZoneId: row.cloudflare_zone_id || row[3] || undefined, isObsolete: Boolean(row.is_obsolete || row[4]), defaultWildcard: Boolean(row.default_wildcard || row[5]), createdAt: Number(row.created_at || row[6]), updatedAt: Number(row.updated_at || row[7]), }; } // ============ Certificates ============ createCertificate(cert: Omit): ICertificate { if (!this.db) throw new Error('Database not initialized'); this.query( `INSERT INTO certificates (domain_id, cert_domain, is_wildcard, cert_path, key_path, full_chain_path, expiry_date, issuer, is_valid, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ cert.domainId, cert.certDomain, cert.isWildcard ? 1 : 0, cert.certPath, cert.keyPath, cert.fullChainPath, cert.expiryDate, cert.issuer, cert.isValid ? 1 : 0, cert.createdAt, cert.updatedAt, ] ); const rows = this.query('SELECT * FROM certificates WHERE id = last_insert_rowid()'); return this.rowToCertificate(rows[0]); } getCertificateById(id: number): ICertificate | null { if (!this.db) throw new Error('Database not initialized'); const rows = this.query('SELECT * FROM certificates WHERE id = ?', [id]); return rows.length > 0 ? this.rowToCertificate(rows[0]) : null; } getCertificatesByDomain(domainId: number): ICertificate[] { if (!this.db) throw new Error('Database not initialized'); const rows = this.query('SELECT * FROM certificates WHERE domain_id = ? ORDER BY expiry_date DESC', [ domainId, ]); return rows.map((row) => this.rowToCertificate(row)); } getAllCertificates(): ICertificate[] { if (!this.db) throw new Error('Database not initialized'); const rows = this.query('SELECT * FROM certificates ORDER BY expiry_date DESC'); return rows.map((row) => this.rowToCertificate(row)); } updateCertificate(id: number, updates: Partial): void { if (!this.db) throw new Error('Database not initialized'); const fields: string[] = []; const values: BindValue[] = []; if (updates.certDomain !== undefined) { fields.push('cert_domain = ?'); values.push(updates.certDomain); } if (updates.isWildcard !== undefined) { fields.push('is_wildcard = ?'); values.push(updates.isWildcard ? 1 : 0); } if (updates.certPath !== undefined) { fields.push('cert_path = ?'); values.push(updates.certPath); } if (updates.keyPath !== undefined) { fields.push('key_path = ?'); values.push(updates.keyPath); } if (updates.fullChainPath !== undefined) { fields.push('full_chain_path = ?'); values.push(updates.fullChainPath); } if (updates.expiryDate !== undefined) { fields.push('expiry_date = ?'); values.push(updates.expiryDate); } if (updates.issuer !== undefined) { fields.push('issuer = ?'); values.push(updates.issuer); } if (updates.isValid !== undefined) { fields.push('is_valid = ?'); values.push(updates.isValid ? 1 : 0); } fields.push('updated_at = ?'); values.push(Date.now()); values.push(id); this.query(`UPDATE certificates SET ${fields.join(', ')} WHERE id = ?`, values); } deleteCertificate(id: number): void { if (!this.db) throw new Error('Database not initialized'); this.query('DELETE FROM certificates WHERE id = ?', [id]); } private rowToCertificate(row: any): ICertificate { return { id: Number(row.id || row[0]), domainId: Number(row.domain_id || row[1]), certDomain: String(row.cert_domain || row[2]), isWildcard: Boolean(row.is_wildcard || row[3]), certPath: String(row.cert_path || row[4]), keyPath: String(row.key_path || row[5]), fullChainPath: String(row.full_chain_path || row[6]), expiryDate: Number(row.expiry_date || row[7]), issuer: String(row.issuer || row[8]), isValid: Boolean(row.is_valid || row[9]), createdAt: Number(row.created_at || row[10]), updatedAt: Number(row.updated_at || row[11]), }; } // ============ Certificate Requirements ============ createCertRequirement(req: Omit): ICertRequirement { if (!this.db) throw new Error('Database not initialized'); this.query( `INSERT INTO cert_requirements (service_id, domain_id, subdomain, certificate_id, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, [ req.serviceId, req.domainId, req.subdomain, req.certificateId, req.status, req.createdAt, req.updatedAt, ] ); const rows = this.query('SELECT * FROM cert_requirements WHERE id = last_insert_rowid()'); return this.rowToCertRequirement(rows[0]); } getCertRequirementById(id: number): ICertRequirement | null { if (!this.db) throw new Error('Database not initialized'); const rows = this.query('SELECT * FROM cert_requirements WHERE id = ?', [id]); return rows.length > 0 ? this.rowToCertRequirement(rows[0]) : null; } getCertRequirementsByService(serviceId: number): ICertRequirement[] { if (!this.db) throw new Error('Database not initialized'); const rows = this.query('SELECT * FROM cert_requirements WHERE service_id = ?', [serviceId]); return rows.map((row) => this.rowToCertRequirement(row)); } getCertRequirementsByDomain(domainId: number): ICertRequirement[] { if (!this.db) throw new Error('Database not initialized'); const rows = this.query('SELECT * FROM cert_requirements WHERE domain_id = ?', [domainId]); return rows.map((row) => this.rowToCertRequirement(row)); } getAllCertRequirements(): ICertRequirement[] { if (!this.db) throw new Error('Database not initialized'); const rows = this.query('SELECT * FROM cert_requirements ORDER BY created_at DESC'); return rows.map((row) => this.rowToCertRequirement(row)); } updateCertRequirement(id: number, updates: Partial): void { if (!this.db) throw new Error('Database not initialized'); const fields: string[] = []; const values: BindValue[] = []; if (updates.subdomain !== undefined) { fields.push('subdomain = ?'); values.push(updates.subdomain); } if (updates.certificateId !== undefined) { fields.push('certificate_id = ?'); values.push(updates.certificateId); } if (updates.status !== undefined) { fields.push('status = ?'); values.push(updates.status); } fields.push('updated_at = ?'); values.push(Date.now()); values.push(id); this.query(`UPDATE cert_requirements SET ${fields.join(', ')} WHERE id = ?`, values); } deleteCertRequirement(id: number): void { if (!this.db) throw new Error('Database not initialized'); this.query('DELETE FROM cert_requirements WHERE id = ?', [id]); } private rowToCertRequirement(row: any): ICertRequirement { return { id: Number(row.id || row[0]), serviceId: Number(row.service_id || row[1]), domainId: Number(row.domain_id || row[2]), subdomain: String(row.subdomain || row[3]), certificateId: row.certificate_id || row[4] || undefined, status: String(row.status || row[5]) as ICertRequirement['status'], createdAt: Number(row.created_at || row[6]), updatedAt: Number(row.updated_at || row[7]), }; } // ============ Registry Tokens ============ createRegistryToken(token: Omit): IRegistryToken { if (!this.db) throw new Error('Database not initialized'); const scopeJson = Array.isArray(token.scope) ? JSON.stringify(token.scope) : token.scope; this.query( `INSERT INTO registry_tokens (name, token_hash, token_type, scope, expires_at, created_at, last_used_at, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [ token.name, token.tokenHash, token.type, scopeJson, token.expiresAt, token.createdAt, token.lastUsedAt, token.createdBy, ] ); const rows = this.query('SELECT * FROM registry_tokens WHERE id = last_insert_rowid()'); return this.rowToRegistryToken(rows[0]); } getRegistryTokenById(id: number): IRegistryToken | null { if (!this.db) throw new Error('Database not initialized'); const rows = this.query('SELECT * FROM registry_tokens WHERE id = ?', [id]); return rows.length > 0 ? this.rowToRegistryToken(rows[0]) : null; } getRegistryTokenByHash(tokenHash: string): IRegistryToken | null { if (!this.db) throw new Error('Database not initialized'); const rows = this.query('SELECT * FROM registry_tokens WHERE token_hash = ?', [tokenHash]); return rows.length > 0 ? this.rowToRegistryToken(rows[0]) : null; } getAllRegistryTokens(): IRegistryToken[] { if (!this.db) throw new Error('Database not initialized'); const rows = this.query('SELECT * FROM registry_tokens ORDER BY created_at DESC'); return rows.map((row) => this.rowToRegistryToken(row)); } getRegistryTokensByType(type: 'global' | 'ci'): IRegistryToken[] { if (!this.db) throw new Error('Database not initialized'); const rows = this.query('SELECT * FROM registry_tokens WHERE token_type = ? ORDER BY created_at DESC', [type]); return rows.map((row) => this.rowToRegistryToken(row)); } updateRegistryTokenLastUsed(id: number): void { if (!this.db) throw new Error('Database not initialized'); this.query('UPDATE registry_tokens SET last_used_at = ? WHERE id = ?', [Date.now(), id]); } deleteRegistryToken(id: number): void { if (!this.db) throw new Error('Database not initialized'); this.query('DELETE FROM registry_tokens WHERE id = ?', [id]); } private rowToRegistryToken(row: any): IRegistryToken { // Parse scope - it's either 'all' or a JSON array let scope: 'all' | string[]; const scopeRaw = row.scope || row[4]; if (scopeRaw === 'all') { scope = 'all'; } else { try { scope = JSON.parse(String(scopeRaw)); } catch { scope = 'all'; } } return { id: Number(row.id || row[0]), name: String(row.name || row[1]), tokenHash: String(row.token_hash || row[2]), type: String(row.token_type || row[3]) as IRegistryToken['type'], scope, expiresAt: row.expires_at || row[5] ? Number(row.expires_at || row[5]) : null, createdAt: Number(row.created_at || row[6]), lastUsedAt: row.last_used_at || row[7] ? Number(row.last_used_at || row[7]) : null, createdBy: String(row.created_by || row[8]), }; } // ============ Platform Services CRUD ============ createPlatformService(service: Omit): IPlatformService { if (!this.db) throw new Error('Database not initialized'); const now = Date.now(); this.query( `INSERT INTO platform_services (name, type, status, container_id, config, admin_credentials_encrypted, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [ service.name, service.type, service.status, service.containerId || null, JSON.stringify(service.config), service.adminCredentialsEncrypted || null, now, now, ] ); return this.getPlatformServiceByName(service.name)!; } getPlatformServiceByName(name: string): IPlatformService | null { if (!this.db) throw new Error('Database not initialized'); const rows = this.query('SELECT * FROM platform_services WHERE name = ?', [name]); return rows.length > 0 ? this.rowToPlatformService(rows[0]) : null; } getPlatformServiceById(id: number): IPlatformService | null { if (!this.db) throw new Error('Database not initialized'); const rows = this.query('SELECT * FROM platform_services WHERE id = ?', [id]); return rows.length > 0 ? this.rowToPlatformService(rows[0]) : null; } getPlatformServiceByType(type: TPlatformServiceType): IPlatformService | null { if (!this.db) throw new Error('Database not initialized'); const rows = this.query('SELECT * FROM platform_services WHERE type = ?', [type]); return rows.length > 0 ? this.rowToPlatformService(rows[0]) : null; } getAllPlatformServices(): IPlatformService[] { if (!this.db) throw new Error('Database not initialized'); const rows = this.query('SELECT * FROM platform_services ORDER BY created_at DESC'); return rows.map((row) => this.rowToPlatformService(row)); } updatePlatformService(id: number, updates: Partial): void { if (!this.db) throw new Error('Database not initialized'); const fields: string[] = []; const values: BindValue[] = []; if (updates.status !== undefined) { fields.push('status = ?'); values.push(updates.status); } if (updates.containerId !== undefined) { fields.push('container_id = ?'); values.push(updates.containerId); } if (updates.config !== undefined) { fields.push('config = ?'); values.push(JSON.stringify(updates.config)); } if (updates.adminCredentialsEncrypted !== undefined) { fields.push('admin_credentials_encrypted = ?'); values.push(updates.adminCredentialsEncrypted); } fields.push('updated_at = ?'); values.push(Date.now()); values.push(id); this.query(`UPDATE platform_services SET ${fields.join(', ')} WHERE id = ?`, values); } deletePlatformService(id: number): void { if (!this.db) throw new Error('Database not initialized'); this.query('DELETE FROM platform_services WHERE id = ?', [id]); } private rowToPlatformService(row: any): IPlatformService { let config = { image: '', port: 0 }; const configRaw = row.config; if (configRaw) { try { config = JSON.parse(String(configRaw)); } catch (e) { logger.warn(`Failed to parse platform service config: ${getErrorMessage(e)}`); } } return { id: Number(row.id), name: String(row.name), type: String(row.type) as TPlatformServiceType, status: String(row.status) as IPlatformService['status'], containerId: row.container_id ? String(row.container_id) : undefined, config, adminCredentialsEncrypted: row.admin_credentials_encrypted ? String(row.admin_credentials_encrypted) : undefined, createdAt: Number(row.created_at), updatedAt: Number(row.updated_at), }; } // ============ Platform Resources CRUD ============ createPlatformResource(resource: Omit): IPlatformResource { if (!this.db) throw new Error('Database not initialized'); const now = Date.now(); this.query( `INSERT INTO platform_resources (platform_service_id, service_id, resource_type, resource_name, credentials_encrypted, created_at) VALUES (?, ?, ?, ?, ?, ?)`, [ resource.platformServiceId, resource.serviceId, resource.resourceType, resource.resourceName, resource.credentialsEncrypted, now, ] ); const rows = this.query('SELECT * FROM platform_resources WHERE id = last_insert_rowid()'); return this.rowToPlatformResource(rows[0]); } getPlatformResourceById(id: number): IPlatformResource | null { if (!this.db) throw new Error('Database not initialized'); const rows = this.query('SELECT * FROM platform_resources WHERE id = ?', [id]); return rows.length > 0 ? this.rowToPlatformResource(rows[0]) : null; } getPlatformResourcesByService(serviceId: number): IPlatformResource[] { if (!this.db) throw new Error('Database not initialized'); const rows = this.query('SELECT * FROM platform_resources WHERE service_id = ?', [serviceId]); return rows.map((row) => this.rowToPlatformResource(row)); } getPlatformResourcesByPlatformService(platformServiceId: number): IPlatformResource[] { if (!this.db) throw new Error('Database not initialized'); const rows = this.query('SELECT * FROM platform_resources WHERE platform_service_id = ?', [platformServiceId]); return rows.map((row) => this.rowToPlatformResource(row)); } getAllPlatformResources(): IPlatformResource[] { if (!this.db) throw new Error('Database not initialized'); const rows = this.query('SELECT * FROM platform_resources ORDER BY created_at DESC'); return rows.map((row) => this.rowToPlatformResource(row)); } deletePlatformResource(id: number): void { if (!this.db) throw new Error('Database not initialized'); this.query('DELETE FROM platform_resources WHERE id = ?', [id]); } deletePlatformResourcesByService(serviceId: number): void { if (!this.db) throw new Error('Database not initialized'); this.query('DELETE FROM platform_resources WHERE service_id = ?', [serviceId]); } private rowToPlatformResource(row: any): IPlatformResource { return { id: Number(row.id), platformServiceId: Number(row.platform_service_id), serviceId: Number(row.service_id), resourceType: String(row.resource_type) as IPlatformResource['resourceType'], resourceName: String(row.resource_name), credentialsEncrypted: String(row.credentials_encrypted), createdAt: Number(row.created_at), }; } }