feat(backup): full DR bundle export + admin-configurable offsite destinations
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m52s
Build & Push Docker Images / build-and-push (push) Successful in 11m59s

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:
2026-06-04 11:23:42 +02:00
parent 05950ae0b6
commit fe863a588e
35 changed files with 3125 additions and 15 deletions

View File

@@ -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;