/** * Backup storage service - CRUD operations for backup storage destinations. * Follows the same patterns as container-service.ts for credential management. */ import { randomUUID } from 'node:crypto'; import { eq } from 'drizzle-orm'; import { z } from 'zod'; import { getDb } from '../db/client'; import { type BackupStorage, type BackupStorageProvider, backupStorages } from '../db/schema'; import { decryptSecret, encryptSecret } from '../secrets/encryption'; import { getOrCreateMasterKey } from '../secrets/master-key'; import { EncryptionEnvelopeSchema, parseJsonWithValidation } from '../validation/schemas'; import type { LocalStorageConfig } from './storage-providers/local'; import type { StorageProvider } from './storage-providers/types'; /** * Zod schemas for provider-specific credentials (Rule 3.7) */ const LocalCredentialsSchema = z.object({ path: z.string().min(1), }); const S3CredentialsSchema = z.object({ bucket: z.string().min(1), region: z.string().min(1), endpoint: z.string().url(), accessKeyId: z.string().min(1), secretAccessKey: z.string().min(1), }); export type LocalCredentials = z.infer; export type S3Credentials = z.infer; export type StorageCredentials = LocalCredentials | S3Credentials; /** * Generate a storage ID from a human-readable name */ function generateStorageId(name: string): string { return name .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); } /** * Add a new backup storage destination */ export async function addBackupStorage(params: { name: string; providerName: BackupStorageProvider; credentials: Record; providerConfig?: Record; }): Promise { const db = getDb(); const id = randomUUID(); const storageId = generateStorageId(params.name); const now = new Date(); const existing = db .select() .from(backupStorages) .where(eq(backupStorages.storageId, storageId)) .limit(1) .all(); if (existing.length > 0) { throw new Error(`Storage ID '${storageId}' already exists. Please choose a different name.`); } const masterKey = await getOrCreateMasterKey(); const encrypted = encryptSecret(JSON.stringify(params.credentials), masterKey); const values = { id, storageId, name: params.name, providerName: params.providerName, credentialsEncrypted: JSON.stringify(encrypted), providerConfig: params.providerConfig ?? {}, verified: false, verifiedAt: null, verificationError: null, isDefault: false, createdAt: now, updatedAt: now, }; db.insert(backupStorages).values(values).run(); return { id, storageId, name: params.name, providerName: params.providerName, credentialsEncrypted: JSON.stringify(encrypted), providerConfig: params.providerConfig ?? {}, verified: false, verifiedAt: null, verificationError: null, isDefault: false, createdAt: now, updatedAt: now, }; } /** * Replace a storage destination's credentials. * * Clears the verification stamp in the same statement. A `verified` * flag describes the destination the credentials pointed at; once they * change it describes somewhere else, and carrying it forward is how * celilo-mgr ended up reporting `✓ Verified` for a macOS path on a * Linux host (#566). Callers re-verify against the new destination. */ export async function updateStorageCredentials( id: string, credentials: Record, ): Promise { const masterKey = await getOrCreateMasterKey(); const encrypted = encryptSecret(JSON.stringify(credentials), masterKey); getDb() .update(backupStorages) .set({ credentialsEncrypted: JSON.stringify(encrypted), verified: false, verifiedAt: null, verificationError: null, updatedAt: new Date(), }) .where(eq(backupStorages.id, id)) .run(); } /** * Get backup storage by storage ID (user-facing identifier) */ export function getBackupStorageByStorageId(storageId: string): BackupStorage | null { const db = getDb(); const result = db .select() .from(backupStorages) .where(eq(backupStorages.storageId, storageId)) .limit(1) .all(); if (result.length === 0) return null; return mapRow(result[0]); } /** * Get backup storage by internal ID */ export function getBackupStorage(id: string): BackupStorage | null { const db = getDb(); const result = db.select().from(backupStorages).where(eq(backupStorages.id, id)).limit(1).all(); if (result.length === 0) return null; return mapRow(result[0]); } /** * List all backup storage destinations */ export function listBackupStorages(): BackupStorage[] { const db = getDb(); const results = db.select().from(backupStorages).all(); return results.map(mapRow); } /** * Remove a backup storage destination */ export function removeBackupStorage(id: string): void { const db = getDb(); db.delete(backupStorages).where(eq(backupStorages.id, id)).run(); } /** * Set a storage as the default destination * Clears any previously-set default */ export function setDefaultBackupStorage(id: string): void { const db = getDb(); // Clear all defaults db.update(backupStorages).set({ isDefault: false, updatedAt: new Date() }).run(); // Set the new default db.update(backupStorages) .set({ isDefault: true, updatedAt: new Date() }) .where(eq(backupStorages.id, id)) .run(); } /** * Get the default backup storage */ export function getDefaultBackupStorage(): BackupStorage | null { const db = getDb(); const result = db .select() .from(backupStorages) .where(eq(backupStorages.isDefault, true)) .limit(1) .all(); if (result.length === 0) return null; return mapRow(result[0]); } /** * Get decrypted credentials for a storage destination */ export async function getStorageCredentials(id: string): Promise { const storage = getBackupStorage(id); if (!storage) { throw new Error(`Backup storage not found: ${id}`); } const masterKey = await getOrCreateMasterKey(); const encrypted = parseJsonWithValidation( storage.credentialsEncrypted, EncryptionEnvelopeSchema, 'storage credentials encryption envelope', ); const decrypted = decryptSecret(encrypted, masterKey); const parsed = JSON.parse(decrypted); if (storage.providerName === 'local') { return LocalCredentialsSchema.parse(parsed); } if (storage.providerName === 's3') { return S3CredentialsSchema.parse(parsed); } throw new Error(`Unsupported storage provider: ${storage.providerName}`); } /** * Create a StorageProvider instance for the given storage destination */ export async function createStorageProvider(id: string): Promise { const storage = getBackupStorage(id); if (!storage) { throw new Error(`Backup storage not found: ${id}`); } const credentials = await getStorageCredentials(id); if (storage.providerName === 'local') { const { createLocalStorageProvider } = await import('./storage-providers/local'); return createLocalStorageProvider(credentials as LocalStorageConfig); } if (storage.providerName === 's3') { const { createS3StorageProvider } = await import('./storage-providers/s3'); const s3Creds = credentials as S3Credentials; return createS3StorageProvider(s3Creds); } throw new Error(`Unsupported storage provider: ${storage.providerName}`); } /** * Verify a storage destination and update its status */ export async function verifyBackupStorage(id: string): Promise<{ storage: BackupStorage; result: { success: boolean; message: string }; }> { const provider = await createStorageProvider(id); const result = await provider.verify(); const db = getDb(); const now = new Date(); db.update(backupStorages) .set({ verified: result.success, verifiedAt: result.success ? now : null, verificationError: result.success ? null : result.message, updatedAt: now, }) .where(eq(backupStorages.id, id)) .run(); const updated = getBackupStorage(id); if (!updated) { throw new Error(`Failed to fetch updated storage: ${id}`); } return { storage: updated, result }; } function mapRow(row: typeof backupStorages.$inferSelect): BackupStorage { return { id: row.id, storageId: row.storageId, name: row.name, providerName: row.providerName, credentialsEncrypted: row.credentialsEncrypted, providerConfig: row.providerConfig, verified: Boolean(row.verified), verifiedAt: row.verifiedAt ? new Date(row.verifiedAt) : null, verificationError: row.verificationError, isDefault: Boolean(row.isDefault), createdAt: new Date(row.createdAt), updatedAt: new Date(row.updatedAt), }; }