feat(backup): full DR bundle export + admin-configurable offsite destinations
Backend-agnostic disaster-recovery backup engine that runs on the current storage backend (no storage cutover required): - Full-bundle export: db.dump (pg_dump custom) + every storage blob + manifest.json with per-object SHA-256, streamed as a tar. Entry points: admin UI download, GET /api/v1/admin/backup/export, scripts/create-full-backup.ts. - Admin-configurable push destinations (backup_destinations table, migration 0091): SFTP/SSH, S3-compatible (reuses the minio client), and mounted path/NAS behind one transport interface (test/push/prune). Secrets AES-GCM at rest; API returns only *IsSet markers. - Opt-in per-destination AES-256 bundle encryption (scrypt KDF, streamed) + scripts/decrypt-backup.ts for restore. - Wired the previously-dead database-backup cron to runScheduledBackupPush (push to enabled destinations, prune to retention, alert super-admins on failure). Tests: 1608 unit/integration pass; tsc + lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -371,3 +371,43 @@ export const backupJobs = pgTable(
|
||||
|
||||
export type BackupJob = typeof backupJobs.$inferSelect;
|
||||
export type NewBackupJob = typeof backupJobs.$inferInsert;
|
||||
|
||||
/**
|
||||
* Admin-configurable destinations that scheduled/manual backups are pushed to.
|
||||
* Each row transports the exact full-bundle tar produced by
|
||||
* `createFullBackupTar()` (db.dump + blobs + manifest) — see
|
||||
* docs/superpowers/specs/2026-06-04-backup-destinations-design.md.
|
||||
*
|
||||
* `config` holds the type-specific connection settings; any secret inside it
|
||||
* (SFTP password / private key, S3 secret key) is AES-GCM-encrypted via
|
||||
* `@/lib/utils/encryption` before storage and never returned raw (the API
|
||||
* surfaces only `*IsSet` markers, mirroring the send-from-accounts pattern).
|
||||
*/
|
||||
export const backupDestinations = pgTable(
|
||||
'backup_destinations',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
name: text('name').notNull(),
|
||||
type: text('type').notNull(), // 'sftp' | 's3' | 'filesystem'
|
||||
enabled: boolean('enabled').notNull().default(false),
|
||||
config: jsonb('config').notNull().default({}),
|
||||
/** Keep last N bundles at this destination; null = keep all. */
|
||||
retentionCount: integer('retention_count'),
|
||||
/** Opt-in client-side AES-256 encryption of the bundle before push. */
|
||||
encryptBundle: boolean('encrypt_bundle').notNull().default(false),
|
||||
/** The bundle passphrase, itself AES-GCM-encrypted at rest. */
|
||||
encryptionKeyEncrypted: text('encryption_key_encrypted'),
|
||||
lastRunAt: timestamp('last_run_at', { withTimezone: true }),
|
||||
lastStatus: text('last_status'), // 'ok' | 'failed'
|
||||
lastError: text('last_error'),
|
||||
lastBackupBytes: bigint('last_backup_bytes', { mode: 'number' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [index('idx_backup_destinations_enabled').on(table.enabled)],
|
||||
);
|
||||
|
||||
export type BackupDestination = typeof backupDestinations.$inferSelect;
|
||||
export type NewBackupDestination = typeof backupDestinations.$inferInsert;
|
||||
|
||||
Reference in New Issue
Block a user