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

3
.gitignore vendored
View File

@@ -65,3 +65,6 @@ tmp/
# Internal docs + Claude instructions: kept local-only, not in the shared repo
docs/
/CLAUDE.md
# Client-facing feature screenshots (real PII — do not commit)
docs/feature-screenshots/

View File

@@ -126,6 +126,7 @@
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"sonner": "^2.0.7",
"ssh2-sftp-client": "^12.1.1",
"svgo": "^4.0.1",
"tailwind-merge": "^3.6.0",
"tesseract.js": "^7.0.0",
@@ -154,6 +155,7 @@
"@types/papaparse": "^5.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/ssh2-sftp-client": "^9.0.6",
"@types/topojson-client": "^3.1.5",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.6",

111
pnpm-lock.yaml generated
View File

@@ -295,6 +295,9 @@ importers:
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
ssh2-sftp-client:
specifier: ^12.1.1
version: 12.1.1
svgo:
specifier: ^4.0.1
version: 4.0.1
@@ -374,6 +377,9 @@ importers:
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.14)
'@types/ssh2-sftp-client':
specifier: ^9.0.6
version: 9.0.6
'@types/topojson-client':
specifier: ^3.1.5
version: 3.1.5
@@ -3095,6 +3101,9 @@ packages:
'@types/node@14.18.63':
resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==}
'@types/node@18.19.130':
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
'@types/node@20.19.41':
resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==}
@@ -3127,6 +3136,12 @@ packages:
'@types/readdir-glob@1.1.5':
resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==}
'@types/ssh2-sftp-client@9.0.6':
resolution: {integrity: sha512-4+KvXO/V77y9VjI2op2T8+RCGI/GXQAwR0q5Qkj/EJ5YSeyKszqZP6F8i3H3txYoBqjc7sgorqyvBP3+w1EHyg==}
'@types/ssh2@1.15.5':
resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==}
'@types/tedious@4.0.14':
resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==}
@@ -3582,6 +3597,9 @@ packages:
resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
engines: {node: '>= 0.4'}
asn1@0.2.6:
resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
@@ -3694,6 +3712,9 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
bcrypt-pbkdf@1.0.2:
resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
better-auth@1.6.11:
resolution: {integrity: sha512-Wwt6+q07dwIhsp6XiM7L1qSXVUWBEtNl+eZvwM778CguFqDZFBN9Pt6LtFaHl55t8Z+Zc//5kxcbgDY8/79vFQ==}
peerDependencies:
@@ -3856,6 +3877,10 @@ packages:
resolution: {integrity: sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==}
engines: {node: '>=0.2.0'}
buildcheck@0.0.7:
resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==}
engines: {node: '>=10.0.0'}
bullmq@5.76.8:
resolution: {integrity: sha512-v3WTwA8diFtsADaJ8eK2ozyi2CYK9rDZCeoKF+dIPF/MUL8HxAOa3H72Gmu1lC4yKlho6t1PwNr/QpDVqaNEZQ==}
engines: {node: '>=12.22.0'}
@@ -3995,6 +4020,10 @@ packages:
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
concat-stream@2.0.0:
resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==}
engines: {'0': node >= 6.0}
conf@15.1.0:
resolution: {integrity: sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og==}
engines: {node: '>=20'}
@@ -4023,6 +4052,10 @@ packages:
country-flag-icons@1.6.17:
resolution: {integrity: sha512-Nmik0289ZVZSI3c7mJR/amg6DyY7Z59b0sTFSKayeX72mHfPzCPJygwJs2pYgQULzuAyWeCUgwAJ+Dq8OR+JFw==}
cpu-features@0.0.10:
resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==}
engines: {node: '>=10.0.0'}
crc-32@1.2.2:
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
engines: {node: '>=0.8'}
@@ -5723,6 +5756,9 @@ packages:
msgpackr@2.0.1:
resolution: {integrity: sha512-9J+tqTEsbHqY8YohazYgty7LgerFIWxvMLpUjqETSmjHojtJm2WnX2kK/2a1fLI7CO7ERP1YSEUXMucz4j+yBA==}
nan@2.27.0:
resolution: {integrity: sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==}
nanoid@3.3.12:
resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -6629,6 +6665,14 @@ packages:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
ssh2-sftp-client@12.1.1:
resolution: {integrity: sha512-wYVDgwkpcKG2iPGQQ+QR33xkWqLFIaVrYvA+uON4pmxTPaPuB81f1aooUEPN75e/9DCK6rrKYXb6zR6zP3+EtA==}
engines: {node: '>=18.20.4'}
ssh2@1.17.0:
resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==}
engines: {node: '>=10.16.0'}
stable-hash@0.0.5:
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
@@ -6966,6 +7010,9 @@ packages:
tw-animate-css@1.4.0:
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
tweetnacl@0.14.5:
resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==}
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@@ -6994,6 +7041,9 @@ packages:
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
engines: {node: '>= 0.4'}
typedarray@0.0.6:
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
typescript-eslint@8.59.3:
resolution: {integrity: sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -7021,6 +7071,9 @@ packages:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'}
undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
@@ -9990,6 +10043,10 @@ snapshots:
'@types/node@14.18.63': {}
'@types/node@18.19.130':
dependencies:
undici-types: 5.26.5
'@types/node@20.19.41':
dependencies:
undici-types: 6.21.0
@@ -10030,6 +10087,14 @@ snapshots:
dependencies:
'@types/node': 20.19.41
'@types/ssh2-sftp-client@9.0.6':
dependencies:
'@types/ssh2': 1.15.5
'@types/ssh2@1.15.5':
dependencies:
'@types/node': 18.19.130
'@types/tedious@4.0.14':
dependencies:
'@types/node': 20.19.41
@@ -10582,6 +10647,10 @@ snapshots:
get-intrinsic: 1.3.0
is-array-buffer: 3.0.5
asn1@0.2.6:
dependencies:
safer-buffer: 2.1.2
assertion-error@2.0.1: {}
ast-types-flow@0.0.8: {}
@@ -10663,6 +10732,10 @@ snapshots:
baseline-browser-mapping@2.10.29: {}
bcrypt-pbkdf@1.0.2:
dependencies:
tweetnacl: 0.14.5
better-auth@1.6.11(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9))(mongodb@7.1.0(socks@2.8.8))(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.6):
dependencies:
'@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
@@ -10803,6 +10876,9 @@ snapshots:
buffers@0.1.1: {}
buildcheck@0.0.7:
optional: true
bullmq@5.76.8:
dependencies:
cron-parser: 4.9.0
@@ -10936,6 +11012,13 @@ snapshots:
concat-map@0.0.1: {}
concat-stream@2.0.0:
dependencies:
buffer-from: 1.1.2
inherits: 2.0.4
readable-stream: 3.6.2
typedarray: 0.0.6
conf@15.1.0:
dependencies:
ajv: 8.20.0
@@ -10971,6 +11054,12 @@ snapshots:
country-flag-icons@1.6.17: {}
cpu-features@0.0.10:
dependencies:
buildcheck: 0.0.7
nan: 2.27.0
optional: true
crc-32@1.2.2: {}
crc32-stream@4.0.3:
@@ -12736,6 +12825,9 @@ snapshots:
optionalDependencies:
msgpackr-extract: 3.0.3
nan@2.27.0:
optional: true
nanoid@3.3.12: {}
nanostores@1.3.0: {}
@@ -13765,6 +13857,19 @@ snapshots:
split2@4.2.0: {}
ssh2-sftp-client@12.1.1:
dependencies:
concat-stream: 2.0.0
ssh2: 1.17.0
ssh2@1.17.0:
dependencies:
asn1: 0.2.6
bcrypt-pbkdf: 1.0.2
optionalDependencies:
cpu-features: 0.0.10
nan: 2.27.0
stable-hash@0.0.5: {}
stackback@0.0.2: {}
@@ -14103,6 +14208,8 @@ snapshots:
tw-animate-css@1.4.0: {}
tweetnacl@0.14.5: {}
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
@@ -14146,6 +14253,8 @@ snapshots:
possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10
typedarray@0.0.6: {}
typescript-eslint@8.59.3(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3):
dependencies:
'@typescript-eslint/eslint-plugin': 8.59.3(@typescript-eslint/parser@8.59.3(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)
@@ -14172,6 +14281,8 @@ snapshots:
has-symbols: 1.1.0
which-boxed-primitive: 1.1.1
undici-types@5.26.5: {}
undici-types@6.21.0: {}
undici@7.25.0: {}

View File

@@ -0,0 +1,48 @@
/**
* Produce a full disaster-recovery bundle (db.dump + every blob + manifest.json)
* to a local file. Same code path as the admin "Download full backup" button
* (`createFullBackupTar`), minus the HTTP layer — for headless/ops use and for
* rehearsing the restore runbook (docs/backup-restore-runbook.md).
*
* pnpm tsx scripts/create-full-backup.ts [outfile.tar]
*
* Defaults the output name to ./pn-crm-backup-<timestamp>.tar in the CWD.
*/
import 'dotenv/config';
import { copyFile } from 'node:fs/promises';
import path from 'node:path';
import { createFullBackupTar } from '@/lib/services/backup-export.service';
import { logger } from '@/lib/logger';
async function main(): Promise<void> {
const { tarPath, filename, manifest, cleanup } = await createFullBackupTar();
try {
const dest = path.resolve(process.argv[2] ?? filename);
await copyFile(tarPath, dest);
logger.info(
{
dest,
storageBackend: manifest.storageBackend,
dbDumpBytes: manifest.database.sizeBytes,
blobs: manifest.counts.blobs,
blobBytes: manifest.counts.blobBytes,
skipped: manifest.counts.skipped,
},
'Full backup written',
);
if (manifest.skipped.length) {
logger.warn({ skipped: manifest.skipped }, 'Some referenced blobs were missing in storage');
}
} finally {
await cleanup();
}
}
main()
.then(() => process.stdout.write('', () => process.exit(0)))
.catch((err) => {
logger.error({ err }, 'Full backup failed');
process.stderr.write('', () => process.exit(1));
});

31
scripts/decrypt-backup.ts Normal file
View File

@@ -0,0 +1,31 @@
/**
* Decrypt an encrypted backup bundle (`*.tar.enc`) produced when a destination
* has bundle encryption enabled. Restore step — see
* docs/backup-restore-runbook.md.
*
* BACKUP_PASSPHRASE='…' pnpm tsx scripts/decrypt-backup.ts <in.tar.enc> <out.tar>
*
* The passphrase is read from $BACKUP_PASSPHRASE (not argv, to keep it out of
* shell history / the process list).
*/
import { decryptFileToFile } from '@/lib/services/backup-destinations/bundle-encryption';
async function main(): Promise<void> {
const [input, output] = process.argv.slice(2);
const passphrase = process.env.BACKUP_PASSPHRASE;
if (!input || !output) {
throw new Error(
'Usage: BACKUP_PASSPHRASE=… pnpm tsx scripts/decrypt-backup.ts <in.tar.enc> <out.tar>',
);
}
if (!passphrase) throw new Error('Set BACKUP_PASSPHRASE in the environment');
await decryptFileToFile(input, output, passphrase);
process.stdout.write(`Decrypted → ${output}\n`, () => process.exit(0));
}
main().catch((err) => {
process.stderr.write(
`Decrypt failed: ${err instanceof Error ? err.message : String(err)}\n`,
() => process.exit(1),
);
});

View File

@@ -1,4 +1,5 @@
import { BackupAdminPanel } from '@/components/admin/backup-admin-panel';
import { BackupDestinationsCard } from '@/components/admin/backup-destinations-card';
import { PageHeader } from '@/components/shared/page-header';
export default function BackupManagementPage() {
@@ -7,9 +8,10 @@ export default function BackupManagementPage() {
<PageHeader
title="Backup & Restore"
eyebrow="ADMIN"
description="Trigger ad-hoc database snapshots, browse the history, and download a .dump file for offline restore."
description="Download a full backup, configure where automated backups are pushed, and browse history. Restore steps live in docs/backup-restore-runbook.md."
/>
<BackupAdminPanel />
<BackupDestinationsCard />
</div>
);
}

View File

@@ -0,0 +1,62 @@
import { NextResponse } from 'next/server';
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { createAuditLog } from '@/lib/audit';
import { errorResponse, NotFoundError } from '@/lib/errors';
import {
deleteDestination,
updateDestination,
type DestinationInput,
} from '@/lib/services/backup-destinations.service';
import { backupDestinationSchema } from '@/lib/validators/backup-destinations';
export const runtime = 'nodejs';
/** Update a backup destination. Super-admin only. */
export const PUT = withAuth(async (req, ctx, params) => {
try {
requireSuperAdmin(ctx, 'admin.backup.destinations.update');
const id = params.id;
if (!id) throw new NotFoundError('Backup destination');
const body = await parseBody(req, backupDestinationSchema);
const updated = await updateDestination(id, body as DestinationInput);
await createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'update',
entityType: 'backup_destination',
entityId: id,
severity: 'warning',
metadata: { name: updated.name, type: updated.type, enabled: updated.enabled },
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: updated });
} catch (error) {
return errorResponse(error);
}
});
/** Delete a backup destination. Super-admin only. */
export const DELETE = withAuth(async (_req, ctx, params) => {
try {
requireSuperAdmin(ctx, 'admin.backup.destinations.delete');
const id = params.id;
if (!id) throw new NotFoundError('Backup destination');
await deleteDestination(id);
await createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'delete',
entityType: 'backup_destination',
entityId: id,
severity: 'warning',
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -0,0 +1,38 @@
import { NextResponse } from 'next/server';
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
import { createAuditLog } from '@/lib/audit';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { pushBackupToDestination } from '@/lib/services/backup-destinations.service';
export const runtime = 'nodejs';
// A full backup (pg_dump + every blob) is assembled before the push, so allow
// a long run on large datasets.
export const maxDuration = 3600;
/** Assemble a fresh full backup and push it to this destination now. Super-admin. */
export const POST = withAuth(async (_req, ctx, params) => {
try {
requireSuperAdmin(ctx, 'admin.backup.destinations.run');
const id = params.id;
if (!id) throw new NotFoundError('Backup destination');
const result = await pushBackupToDestination(id, {
trigger: 'manual',
triggeredBy: ctx.userId,
});
await createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'backup_export',
entityType: 'backup_destination',
entityId: id,
severity: 'warning',
metadata: { bytes: result.bytes, remoteRef: result.remoteRef },
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { testDestination } from '@/lib/services/backup-destinations.service';
export const runtime = 'nodejs';
/**
* Test connectivity to a destination (connect + verify the target dir/bucket).
* Returns `{ data: { ok: true } }` or a structured error the UI can surface.
* Super-admin only.
*/
export const POST = withAuth(async (_req, ctx, params) => {
try {
requireSuperAdmin(ctx, 'admin.backup.destinations.test');
const id = params.id;
if (!id) throw new NotFoundError('Backup destination');
await testDestination(id);
return NextResponse.json({ data: { ok: true } });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -0,0 +1,47 @@
import { NextResponse } from 'next/server';
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { createAuditLog } from '@/lib/audit';
import { errorResponse } from '@/lib/errors';
import {
createDestination,
listDestinations,
type DestinationInput,
} from '@/lib/services/backup-destinations.service';
import { backupDestinationSchema } from '@/lib/validators/backup-destinations';
export const runtime = 'nodejs';
/** List configured backup destinations (secrets masked). Super-admin only. */
export const GET = withAuth(async (_req, ctx) => {
try {
requireSuperAdmin(ctx, 'admin.backup.destinations.list');
return NextResponse.json({ data: await listDestinations() });
} catch (error) {
return errorResponse(error);
}
});
/** Create a backup destination. Super-admin only. */
export const POST = withAuth(async (req, ctx) => {
try {
requireSuperAdmin(ctx, 'admin.backup.destinations.create');
const body = await parseBody(req, backupDestinationSchema);
const created = await createDestination(body as DestinationInput);
await createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'create',
entityType: 'backup_destination',
entityId: created.id,
severity: 'warning',
metadata: { name: created.name, type: created.type, enabled: created.enabled },
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: created }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -0,0 +1,71 @@
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import { Readable } from 'node:stream';
import { NextResponse } from 'next/server';
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
import { createAuditLog } from '@/lib/audit';
import { errorResponse } from '@/lib/errors';
import { createFullBackupTar } from '@/lib/services/backup-export.service';
export const runtime = 'nodejs';
// A full backup pg_dumps the DB and streams every blob; on a large dataset the
// assembly phase can run for a while before the download starts. Lift the
// platform timeout accordingly (no-op on hosts without a hard cap).
export const maxDuration = 3600;
/**
* Stream a full disaster-recovery bundle (db.dump + all blobs + manifest.json)
* as a tar download. Super-admin only — this egresses every tenant's data.
*
* The bundle is assembled to a temp file first, so any failure (pg_dump,
* storage read) surfaces as a clean JSON error *before* the download begins
* rather than as a truncated tar. The temp file is removed once the response
* stream closes (including on client disconnect).
*/
export const GET = withAuth(async (_req, ctx) => {
try {
requireSuperAdmin(ctx, 'admin.backup.export');
const { tarPath, filename, manifest, cleanup } = await createFullBackupTar();
await createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'backup_export',
entityType: 'system_backup',
entityId: filename,
severity: 'warning',
source: 'user',
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
metadata: {
storageBackend: manifest.storageBackend,
blobs: manifest.counts.blobs,
blobBytes: manifest.counts.blobBytes,
skipped: manifest.counts.skipped,
dbDumpBytes: manifest.database.sizeBytes,
},
});
const { size } = await stat(tarPath);
const nodeStream = createReadStream(tarPath);
// Remove the temp tar once it's been fully sent or the client bails.
nodeStream.on('close', () => void cleanup());
nodeStream.on('error', () => void cleanup());
const webStream = Readable.toWeb(nodeStream) as ReadableStream<Uint8Array>;
return new NextResponse(webStream, {
status: 200,
headers: {
'Content-Type': 'application/x-tar',
'Content-Length': String(size),
'Content-Disposition': `attachment; filename="${filename}"`,
'Cache-Control': 'no-store',
},
});
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -0,0 +1,43 @@
import { NextResponse } from 'next/server';
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { createAuditLog } from '@/lib/audit';
import { errorResponse } from '@/lib/errors';
import { getSchedule, setSchedule } from '@/lib/services/backup-destinations.service';
import { backupScheduleSchema } from '@/lib/validators/backup-destinations';
export const runtime = 'nodejs';
/** Read the global automated-backup schedule. Super-admin only. */
export const GET = withAuth(async (_req, ctx) => {
try {
requireSuperAdmin(ctx, 'admin.backup.schedule.get');
return NextResponse.json({ data: { schedule: await getSchedule() } });
} catch (error) {
return errorResponse(error);
}
});
/** Set the global automated-backup schedule (off | daily | weekly). Super-admin. */
export const PUT = withAuth(async (req, ctx) => {
try {
requireSuperAdmin(ctx, 'admin.backup.schedule.set');
const { schedule } = await parseBody(req, backupScheduleSchema);
await setSchedule(schedule, ctx.userId);
await createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'update',
entityType: 'backup_schedule',
entityId: 'global',
severity: 'warning',
metadata: { schedule },
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: { schedule } });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -1,7 +1,14 @@
'use client';
import { useEffect, useState } from 'react';
import { Loader2, Download, Database, RefreshCw, AlertTriangle } from 'lucide-react';
import {
Loader2,
Download,
Database,
RefreshCw,
AlertTriangle,
HardDriveDownload,
} from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@@ -94,6 +101,18 @@ export function BackupAdminPanel() {
}
}
function downloadFullBundle() {
// Streams a tar (db.dump + every blob + manifest.json) straight to disk via
// the GET endpoint (cookie auth). The server assembles the bundle before
// the first byte arrives, so the browser download sits "pending" for a
// moment on large datasets — flag that so it doesn't look stuck.
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
toast.info(
'Preparing full backup — your download starts once the server finishes assembling it.',
);
triggerUrlDownload('/api/v1/admin/backup/export', `pn-crm-backup-${stamp}.tar`);
}
return (
<div className="space-y-4">
<Card>
@@ -126,6 +145,32 @@ export function BackupAdminPanel() {
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0">
<div>
<CardTitle className="text-base flex items-center gap-2">
<HardDriveDownload className="h-4 w-4" aria-hidden />
Full disaster-recovery export
</CardTitle>
<CardDescription>
Bundles the database dump <em>and every stored file</em> (documents, berth PDFs,
brochures, GDPR exports) into one <code>.tar</code> and downloads it to this computer.
Use this as an offsite, storage-backend-independent backup.
</CardDescription>
</div>
<Button variant="outline" onClick={downloadFullBundle}>
<HardDriveDownload className="me-1.5 h-4 w-4" aria-hidden />
Download full backup
</Button>
</CardHeader>
<CardContent className="text-xs text-muted-foreground">
The archive contains <code>db.dump</code>, <code>blobs/&lt;key&gt;</code> for every file,
and a <code>manifest.json</code> with a SHA-256 per object for restore-side verification.
Unlike the DB-only backup above, this does not depend on the active storage backend
surviving. Restore steps live in <code>docs/backup-restore-runbook.md</code>.
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">History</CardTitle>

View File

@@ -0,0 +1,604 @@
'use client';
import { useEffect, useState } from 'react';
import {
Loader2,
Plus,
Server,
Cloud,
FolderTree,
Trash2,
Pencil,
PlugZap,
UploadCloud,
ShieldCheck,
} from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
type DestType = 'sftp' | 's3' | 'filesystem';
type Schedule = 'off' | 'daily' | 'weekly';
interface Destination {
id: string;
name: string;
type: DestType;
enabled: boolean;
config: Record<string, unknown>;
retentionCount: number | null;
encryptBundle: boolean;
encryptionKeyIsSet: boolean;
lastRunAt: string | null;
lastStatus: string | null;
lastError: string | null;
lastBackupBytes: number | null;
}
const TYPE_META: Record<DestType, { label: string; icon: typeof Server; hint: string }> = {
sftp: { label: 'SFTP / SSH server', icon: Server, hint: 'Push to a server over SFTP.' },
s3: { label: 'S3-compatible', icon: Cloud, hint: 'AWS S3, Backblaze B2, Wasabi, R2, MinIO.' },
filesystem: {
label: 'Mounted path / NAS',
icon: FolderTree,
hint: 'A directory this server can write to.',
},
};
const STATUS_TONE: Record<string, string> = {
ok: 'text-emerald-700',
failed: 'text-rose-700',
};
function formatBytes(n: number | null): string {
if (n === null) return '—';
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
export function BackupDestinationsCard() {
const [destinations, setDestinations] = useState<Destination[]>([]);
const [schedule, setSchedule] = useState<Schedule>('off');
const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState<string | null>(null);
const [editing, setEditing] = useState<Destination | 'new' | null>(null);
useEffect(() => {
void load();
}, []);
async function load() {
setLoading(true);
try {
const [d, s] = await Promise.all([
apiFetch<{ data: Destination[] }>('/api/v1/admin/backup/destinations'),
apiFetch<{ data: { schedule: Schedule } }>('/api/v1/admin/backup/schedule'),
]);
setDestinations(d.data);
setSchedule(s.data.schedule);
} catch (err) {
toastError(err);
} finally {
setLoading(false);
}
}
async function changeSchedule(value: Schedule) {
setSchedule(value);
try {
await apiFetch('/api/v1/admin/backup/schedule', {
method: 'PUT',
body: { schedule: value },
});
toast.success(
value === 'off' ? 'Automated backups turned off' : `Automated backups: ${value}`,
);
} catch (err) {
toastError(err);
void load();
}
}
async function test(id: string) {
setBusyId(id);
try {
await apiFetch(`/api/v1/admin/backup/destinations/${id}/test`, { method: 'POST' });
toast.success('Connection OK');
} catch (err) {
toastError(err);
} finally {
setBusyId(null);
}
}
async function runNow(id: string) {
setBusyId(id);
try {
toast.info('Backing up — assembling the bundle, then pushing. This can take a minute.');
await apiFetch(`/api/v1/admin/backup/destinations/${id}/run`, { method: 'POST' });
toast.success('Backup pushed');
await load();
} catch (err) {
toastError(err);
await load();
} finally {
setBusyId(null);
}
}
async function remove(id: string) {
if (!confirm('Delete this backup destination? This does not delete already-pushed backups.'))
return;
setBusyId(id);
try {
await apiFetch(`/api/v1/admin/backup/destinations/${id}`, { method: 'DELETE' });
toast.success('Destination deleted');
await load();
} catch (err) {
toastError(err);
} finally {
setBusyId(null);
}
}
return (
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0">
<div>
<CardTitle className="text-base flex items-center gap-2">
<UploadCloud className="h-4 w-4" aria-hidden />
Automated backup destinations
</CardTitle>
<CardDescription>
Where scheduled backups are pushed. Each destination receives the same full bundle
(database + every file) you can download above.
</CardDescription>
</div>
<Button size="sm" onClick={() => setEditing('new')}>
<Plus className="me-1.5 h-4 w-4" aria-hidden />
Add destination
</Button>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-3">
<Label htmlFor="backup-schedule" className="text-sm">
Schedule
</Label>
<Select value={schedule} onValueChange={(v) => void changeSchedule(v as Schedule)}>
<SelectTrigger id="backup-schedule" className="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="off">Off</SelectItem>
<SelectItem value="daily">Daily (02:00)</SelectItem>
<SelectItem value="weekly">Weekly (Sun 02:00)</SelectItem>
</SelectContent>
</Select>
<span className="text-xs text-muted-foreground">
Pushes to every enabled destination below.
</span>
</div>
{loading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden /> Loading
</div>
) : destinations.length === 0 ? (
<p className="text-sm text-muted-foreground">
No destinations yet. Add one to enable automated offsite backups.
</p>
) : (
<ul className="divide-y rounded-md border">
{destinations.map((d) => {
const Icon = TYPE_META[d.type].icon;
return (
<li key={d.id} className="flex items-center justify-between gap-3 p-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" aria-hidden />
<span className="font-medium truncate">{d.name}</span>
{d.encryptBundle && (
<ShieldCheck
className="h-3.5 w-3.5 text-emerald-600"
aria-label="Encrypted"
/>
)}
{!d.enabled && (
<span className="text-xs rounded-full bg-muted px-1.5 py-0.5 text-muted-foreground">
disabled
</span>
)}
</div>
<div className="mt-0.5 text-xs text-muted-foreground">
{TYPE_META[d.type].label}
{d.lastStatus && (
<>
{' · '}
<span className={STATUS_TONE[d.lastStatus] ?? ''}>
{d.lastStatus === 'ok' ? 'last OK' : 'last FAILED'}
</span>
{d.lastRunAt && ` ${new Date(d.lastRunAt).toLocaleString()}`}
{d.lastStatus === 'ok' && ` (${formatBytes(d.lastBackupBytes)})`}
</>
)}
{d.lastStatus === 'failed' && d.lastError && (
<span className="block text-rose-700 truncate">{d.lastError}</span>
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<Button
size="sm"
variant="ghost"
disabled={busyId === d.id}
onClick={() => void test(d.id)}
>
<PlugZap className="me-1 h-3.5 w-3.5" aria-hidden />
Test
</Button>
<Button
size="sm"
variant="outline"
disabled={busyId === d.id}
onClick={() => void runNow(d.id)}
>
{busyId === d.id ? (
<Loader2 className="me-1 h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<UploadCloud className="me-1 h-3.5 w-3.5" aria-hidden />
)}
Back up now
</Button>
<Button size="icon" variant="ghost" onClick={() => setEditing(d)}>
<Pencil className="h-3.5 w-3.5" aria-hidden />
</Button>
<Button
size="icon"
variant="ghost"
disabled={busyId === d.id}
onClick={() => void remove(d.id)}
>
<Trash2 className="h-3.5 w-3.5 text-rose-600" aria-hidden />
</Button>
</div>
</li>
);
})}
</ul>
)}
</CardContent>
{editing && (
<DestinationDialog
destination={editing === 'new' ? null : editing}
onClose={() => setEditing(null)}
onSaved={() => {
setEditing(null);
void load();
}}
/>
)}
</Card>
);
}
// ─── add/edit dialog ─────────────────────────────────────────────────────────
interface DialogProps {
destination: Destination | null;
onClose: () => void;
onSaved: () => void;
}
function DestinationDialog({ destination, onClose, onSaved }: DialogProps) {
const isEdit = Boolean(destination);
const cfg = (destination?.config ?? {}) as Record<string, unknown>;
const str = (k: string) => (typeof cfg[k] === 'string' ? (cfg[k] as string) : '');
const num = (k: string) => (typeof cfg[k] === 'number' ? String(cfg[k]) : '');
const [name, setName] = useState(destination?.name ?? '');
const [type, setType] = useState<DestType>(destination?.type ?? 'sftp');
const [enabled, setEnabled] = useState(destination?.enabled ?? true);
const [retention, setRetention] = useState(
destination?.retentionCount != null ? String(destination.retentionCount) : '',
);
const [encryptBundle, setEncryptBundle] = useState(destination?.encryptBundle ?? false);
const [encryptionKey, setEncryptionKey] = useState('');
const [saving, setSaving] = useState(false);
// Config fields (controlled). Secrets start blank on edit (kept server-side).
const [c, setC] = useState<Record<string, string>>({
directory: str('directory'),
host: str('host'),
port: num('port'),
username: str('username'),
password: '',
privateKey: '',
passphrase: '',
remoteDir: str('remoteDir'),
hostFingerprint: str('hostFingerprint'),
endpoint: str('endpoint'),
region: str('region'),
bucket: str('bucket'),
accessKey: str('accessKey'),
secretKey: '',
prefix: str('prefix'),
});
const set = (k: string, v: string) => setC((prev) => ({ ...prev, [k]: v }));
function buildConfig(): Record<string, unknown> {
if (type === 'filesystem') return { directory: c.directory };
if (type === 'sftp') {
return {
host: c.host,
...(c.port ? { port: Number(c.port) } : {}),
username: c.username,
...(c.password ? { password: c.password } : {}),
...(c.privateKey ? { privateKey: c.privateKey } : {}),
...(c.passphrase ? { passphrase: c.passphrase } : {}),
remoteDir: c.remoteDir,
...(c.hostFingerprint ? { hostFingerprint: c.hostFingerprint } : {}),
};
}
return {
endpoint: c.endpoint,
...(c.region ? { region: c.region } : {}),
bucket: c.bucket,
accessKey: c.accessKey,
...(c.secretKey ? { secretKey: c.secretKey } : {}),
...(c.prefix ? { prefix: c.prefix } : {}),
};
}
async function save() {
setSaving(true);
try {
const body = {
name,
type,
enabled,
config: buildConfig(),
retentionCount: retention ? Number(retention) : null,
encryptBundle,
...(encryptionKey ? { encryptionKey } : {}),
};
if (isEdit && destination) {
await apiFetch(`/api/v1/admin/backup/destinations/${destination.id}`, {
method: 'PUT',
body,
});
} else {
await apiFetch('/api/v1/admin/backup/destinations', {
method: 'POST',
body,
});
}
toast.success(isEdit ? 'Destination updated' : 'Destination added');
onSaved();
} catch (err) {
toastError(err);
} finally {
setSaving(false);
}
}
const secretPlaceholder = isEdit ? 'unchanged — leave blank to keep' : '';
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit destination' : 'Add backup destination'}</DialogTitle>
<DialogDescription>{TYPE_META[type].hint}</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<Field label="Name">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Hetzner box"
/>
</Field>
<Field label="Type">
<Select value={type} onValueChange={(v) => setType(v as DestType)} disabled={isEdit}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sftp">SFTP / SSH server</SelectItem>
<SelectItem value="s3">S3-compatible</SelectItem>
<SelectItem value="filesystem">Mounted path / NAS</SelectItem>
</SelectContent>
</Select>
</Field>
{type === 'filesystem' && (
<Field label="Directory">
<Input
value={c.directory}
onChange={(e) => set('directory', e.target.value)}
placeholder="/mnt/backups"
/>
</Field>
)}
{type === 'sftp' && (
<>
<div className="grid grid-cols-3 gap-2">
<div className="col-span-2">
<Field label="Host">
<Input
value={c.host}
onChange={(e) => set('host', e.target.value)}
placeholder="backups.example.com"
/>
</Field>
</div>
<Field label="Port">
<Input
value={c.port}
onChange={(e) => set('port', e.target.value)}
placeholder="22"
/>
</Field>
</div>
<Field label="Username">
<Input value={c.username} onChange={(e) => set('username', e.target.value)} />
</Field>
<Field label="Password">
<Input
type="password"
value={c.password}
onChange={(e) => set('password', e.target.value)}
placeholder={secretPlaceholder}
/>
</Field>
<Field label="Private key (optional, instead of password)">
<textarea
className="w-full rounded-md border px-2 py-1.5 text-xs font-mono"
rows={3}
value={c.privateKey}
onChange={(e) => set('privateKey', e.target.value)}
placeholder={secretPlaceholder || '-----BEGIN OPENSSH PRIVATE KEY-----'}
/>
</Field>
<Field label="Remote directory">
<Input
value={c.remoteDir}
onChange={(e) => set('remoteDir', e.target.value)}
placeholder="/srv/pn-crm-backups"
/>
</Field>
<Field label="Host key fingerprint (optional, sha256 — pins the server)">
<Input
value={c.hostFingerprint}
onChange={(e) => set('hostFingerprint', e.target.value)}
placeholder="aa:bb:cc… or hex"
/>
</Field>
</>
)}
{type === 's3' && (
<>
<Field label="Endpoint">
<Input
value={c.endpoint}
onChange={(e) => set('endpoint', e.target.value)}
placeholder="https://s3.us-west.example.com"
/>
</Field>
<div className="grid grid-cols-2 gap-2">
<Field label="Bucket">
<Input value={c.bucket} onChange={(e) => set('bucket', e.target.value)} />
</Field>
<Field label="Region (optional)">
<Input
value={c.region}
onChange={(e) => set('region', e.target.value)}
placeholder="us-east-1"
/>
</Field>
</div>
<Field label="Access key">
<Input value={c.accessKey} onChange={(e) => set('accessKey', e.target.value)} />
</Field>
<Field label="Secret key">
<Input
type="password"
value={c.secretKey}
onChange={(e) => set('secretKey', e.target.value)}
placeholder={secretPlaceholder}
/>
</Field>
<Field label="Prefix (optional)">
<Input
value={c.prefix}
onChange={(e) => set('prefix', e.target.value)}
placeholder="crm-backups/"
/>
</Field>
</>
)}
<div className="grid grid-cols-2 gap-2">
<Field label="Keep last N (blank = all)">
<Input
value={retention}
onChange={(e) => setRetention(e.target.value)}
placeholder="7"
/>
</Field>
<div className="flex items-end pb-1">
<label className="flex items-center gap-2 text-sm">
<Switch checked={enabled} onCheckedChange={setEnabled} />
Enabled (auto-pushed)
</label>
</div>
</div>
<div className="rounded-md border p-3 space-y-2">
<label className="flex items-center gap-2 text-sm">
<Switch checked={encryptBundle} onCheckedChange={setEncryptBundle} />
Encrypt the bundle before sending (AES-256)
</label>
{encryptBundle && (
<Field label="Passphrase (needed to restore — store it safely)">
<Input
type="password"
value={encryptionKey}
onChange={(e) => setEncryptionKey(e.target.value)}
placeholder={
destination?.encryptionKeyIsSet ? 'unchanged — leave blank to keep' : ''
}
/>
</Field>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button onClick={() => void save()} disabled={saving || !name}>
{saving && <Loader2 className="me-1.5 h-4 w-4 animate-spin" aria-hidden />}
{isEdit ? 'Save changes' : 'Add destination'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">{label}</Label>
{children}
</div>
);
}

View File

@@ -40,6 +40,10 @@ export type AuditAction =
// Branding (port logo upload pipeline).
| 'branding.logo.uploaded'
| 'branding.logo.archived'
// Full-bundle backup export (DB dump + every blob) downloaded by an
// operator. A cross-tenant data egress — logged at warning severity so the
// audit filter surfaces it distinctly from routine reads.
| 'backup_export'
// System / background events.
| 'webhook_delivered'
| 'webhook_failed'

View File

@@ -0,0 +1,23 @@
-- Admin-configurable backup destinations (Phase 4b).
-- Each row is a place scheduled/manual full-bundle backups are pushed to.
-- Secrets inside `config` are AES-GCM-encrypted by the application layer.
CREATE TABLE IF NOT EXISTS "backup_destinations" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"type" text NOT NULL,
"enabled" boolean DEFAULT false NOT NULL,
"config" jsonb DEFAULT '{}'::jsonb NOT NULL,
"retention_count" integer,
"encrypt_bundle" boolean DEFAULT false NOT NULL,
"encryption_key_encrypted" text,
"last_run_at" timestamptz,
"last_status" text,
"last_error" text,
"last_backup_bytes" bigint,
"created_at" timestamptz DEFAULT now() NOT NULL,
"updated_at" timestamptz DEFAULT now() NOT NULL
);
CREATE INDEX IF NOT EXISTS "idx_backup_destinations_enabled"
ON "backup_destinations" ("enabled");

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;

View File

@@ -38,6 +38,17 @@ export const maintenanceWorker = new Worker(
await refreshRates();
break;
}
case 'database-backup': {
// Scheduled full-bundle backup pushed to every enabled destination.
// No-op until an admin turns the schedule on AND enables a destination
// (`backup_schedule` setting + `backup_destinations`). Replaces the
// previous silent no-op (this case did not exist before).
const { runScheduledBackupPush } =
await import('@/lib/services/backup-destinations.service');
const summary = await runScheduledBackupPush();
logger.info(summary, 'Scheduled backup push complete');
break;
}
case 'form-expiry-check': {
const result = await db
.update(formSubmissions)

View File

@@ -0,0 +1,458 @@
/**
* Admin-configurable backup destinations — service layer.
* See docs/superpowers/specs/2026-06-04-backup-destinations-design.md.
*
* Responsibilities:
* - CRUD over `backup_destinations` with secret encryption at rest + masking on
* read (mirrors the send-from-accounts pattern: API returns only `*IsSet`).
* - test / manual-push / prune to a destination.
* - scheduled push to all enabled destinations, with failure alerting.
*
* Every push transports the exact SHA-verified tar from `createFullBackupTar()`
* — the same bundle admins download — optionally AES-256 encrypted first.
*/
import { unlink } from 'node:fs/promises';
import { and, eq, isNull } from 'drizzle-orm';
import { createAuditLog } from '@/lib/audit';
import { db } from '@/lib/db';
import {
backupDestinations,
backupJobs,
systemSettings,
type BackupDestination,
} from '@/lib/db/schema/system';
import { userPortRoles, userProfiles } from '@/lib/db/schema/users';
import { logger } from '@/lib/logger';
import { createNotification } from '@/lib/services/notifications.service';
import { createFullBackupTar } from '@/lib/services/backup-export.service';
import { decrypt, encrypt } from '@/lib/utils/encryption';
import {
buildTransport,
type BackupDestinationType,
type BackupTransport,
} from './backup-destinations';
import { encryptFileToFile } from './backup-destinations/bundle-encryption';
// ─── secret config handling ─────────────────────────────────────────────────
const SECRET_FIELDS: Record<BackupDestinationType, string[]> = {
sftp: ['password', 'privateKey', 'passphrase'],
s3: ['secretKey'],
filesystem: [],
};
type Cfg = Record<string, unknown>;
/**
* Prepare an incoming config for storage: encrypt every secret field that
* carries a new non-empty value; for blank/absent secret fields, carry over the
* already-encrypted value from `existing` (so "leave unchanged" works on edit).
* Non-secret fields are taken from `incoming` (falling back to `existing`).
*/
export function serializeConfig(type: BackupDestinationType, incoming: Cfg, existing?: Cfg): Cfg {
const secrets = new Set(SECRET_FIELDS[type] ?? []);
const out: Cfg = {};
// Non-secret fields from incoming (or carry existing if omitted).
for (const [k, v] of Object.entries(incoming)) {
if (!secrets.has(k)) out[k] = v;
}
for (const field of secrets) {
const incomingVal = incoming[field];
if (typeof incomingVal === 'string' && incomingVal.length > 0) {
out[field] = encrypt(incomingVal);
} else if (existing && typeof existing[field] === 'string') {
out[field] = existing[field];
}
}
return out;
}
/** Decrypt the secret fields of a stored config back to plaintext for transport use. */
export function decryptConfig(type: BackupDestinationType, stored: Cfg): Cfg {
const secrets = new Set(SECRET_FIELDS[type] ?? []);
const out: Cfg = { ...stored };
for (const field of secrets) {
const v = stored[field];
if (typeof v === 'string' && v.length > 0) {
try {
out[field] = decrypt(v);
} catch (err) {
logger.error({ err, field }, 'Failed to decrypt backup destination secret');
delete out[field];
}
}
}
return out;
}
/** Strip secret fields from a stored config and expose `<field>IsSet` markers. */
export function maskConfig(type: BackupDestinationType, stored: Cfg): Cfg {
const secrets = new Set(SECRET_FIELDS[type] ?? []);
const out: Cfg = {};
for (const [k, v] of Object.entries(stored)) {
if (!secrets.has(k)) out[k] = v;
}
for (const field of secrets) {
out[`${field}IsSet`] =
typeof stored[field] === 'string' && (stored[field] as string).length > 0;
}
return out;
}
export interface MaskedDestination {
id: string;
name: string;
type: BackupDestinationType;
enabled: boolean;
config: Cfg;
retentionCount: number | null;
encryptBundle: boolean;
encryptionKeyIsSet: boolean;
lastRunAt: Date | null;
lastStatus: string | null;
lastError: string | null;
lastBackupBytes: number | null;
createdAt: Date;
updatedAt: Date;
}
function mask(row: BackupDestination): MaskedDestination {
const type = row.type as BackupDestinationType;
return {
id: row.id,
name: row.name,
type,
enabled: row.enabled,
config: maskConfig(type, (row.config ?? {}) as Cfg),
retentionCount: row.retentionCount,
encryptBundle: row.encryptBundle,
encryptionKeyIsSet: Boolean(row.encryptionKeyEncrypted),
lastRunAt: row.lastRunAt,
lastStatus: row.lastStatus,
lastError: row.lastError,
lastBackupBytes: row.lastBackupBytes,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
// ─── schedule ────────────────────────────────────────────────────────────────
export type BackupSchedule = 'off' | 'daily' | 'weekly';
/** Whether a scheduled push should run for `date` under `schedule`. */
export function isScheduleDue(schedule: BackupSchedule, date: Date): boolean {
if (schedule === 'off') return false;
if (schedule === 'daily') return true;
return date.getUTCDay() === 0; // weekly → Sundays
}
export async function getSchedule(): Promise<BackupSchedule> {
const [row] = await db
.select()
.from(systemSettings)
.where(and(eq(systemSettings.key, 'backup_schedule'), isNull(systemSettings.portId)));
const v = row?.value;
return v === 'daily' || v === 'weekly' ? v : 'off';
}
export async function setSchedule(value: BackupSchedule, userId: string): Promise<void> {
const existing = await db.query.systemSettings.findFirst({
where: and(eq(systemSettings.key, 'backup_schedule'), isNull(systemSettings.portId)),
});
if (existing) {
await db
.update(systemSettings)
.set({ value, updatedBy: userId, updatedAt: new Date() })
.where(and(eq(systemSettings.key, 'backup_schedule'), isNull(systemSettings.portId)));
} else {
await db
.insert(systemSettings)
.values({ key: 'backup_schedule', value, portId: null, updatedBy: userId });
}
}
// ─── CRUD ──────────────────────────────────────────────────────────────────
export async function listDestinations(): Promise<MaskedDestination[]> {
const rows = await db.query.backupDestinations.findMany({
orderBy: (d, { asc }) => [asc(d.createdAt)],
});
return rows.map(mask);
}
export interface DestinationInput {
name: string;
type: BackupDestinationType;
enabled?: boolean;
config: Cfg;
retentionCount?: number | null;
encryptBundle?: boolean;
/** Plaintext bundle passphrase; encrypted at rest. Blank = leave unchanged. */
encryptionKey?: string;
}
export async function createDestination(input: DestinationInput): Promise<MaskedDestination> {
const [row] = await db
.insert(backupDestinations)
.values({
name: input.name,
type: input.type,
enabled: input.enabled ?? false,
config: serializeConfig(input.type, input.config),
retentionCount: input.retentionCount ?? null,
encryptBundle: input.encryptBundle ?? false,
encryptionKeyEncrypted:
input.encryptionKey && input.encryptionKey.length > 0 ? encrypt(input.encryptionKey) : null,
})
.returning();
if (!row) throw new Error('Failed to create backup destination');
return mask(row);
}
export async function updateDestination(
id: string,
input: DestinationInput,
): Promise<MaskedDestination> {
const existing = await db.query.backupDestinations.findFirst({
where: eq(backupDestinations.id, id),
});
if (!existing) throw new Error('Backup destination not found');
const [row] = await db
.update(backupDestinations)
.set({
name: input.name,
type: input.type,
enabled: input.enabled ?? existing.enabled,
config: serializeConfig(input.type, input.config, (existing.config ?? {}) as Cfg),
retentionCount: input.retentionCount ?? null,
encryptBundle: input.encryptBundle ?? false,
encryptionKeyEncrypted:
input.encryptionKey && input.encryptionKey.length > 0
? encrypt(input.encryptionKey)
: existing.encryptionKeyEncrypted,
updatedAt: new Date(),
})
.where(eq(backupDestinations.id, id))
.returning();
if (!row) throw new Error('Failed to update backup destination');
return mask(row);
}
export async function deleteDestination(id: string): Promise<void> {
await db.delete(backupDestinations).where(eq(backupDestinations.id, id));
}
// ─── transport helpers ────────────────────────────────────────────────────
function transportFor(row: BackupDestination): BackupTransport {
const type = row.type as BackupDestinationType;
return buildTransport(type, decryptConfig(type, (row.config ?? {}) as Cfg));
}
export async function testDestination(id: string): Promise<void> {
const row = await db.query.backupDestinations.findFirst({
where: eq(backupDestinations.id, id),
});
if (!row) throw new Error('Backup destination not found');
await transportFor(row).test();
}
// ─── push ─────────────────────────────────────────────────────────────────
interface PushOpts {
/** Reuse an already-assembled tar (scheduled push assembles once for all). */
tarPath?: string;
filename?: string;
trigger: 'manual' | 'cron';
triggeredBy?: string | null;
}
export async function pushBackupToDestination(
id: string,
opts: PushOpts,
): Promise<{
bytes: number;
remoteRef: string;
}> {
const row = await db.query.backupDestinations.findFirst({
where: eq(backupDestinations.id, id),
});
if (!row) throw new Error('Backup destination not found');
const transport = transportFor(row);
let tarPath = opts.tarPath;
let filename = opts.filename;
let ownTar: (() => Promise<void>) | null = null;
let encPath: string | null = null;
try {
if (!tarPath || !filename) {
const made = await createFullBackupTar();
tarPath = made.tarPath;
filename = made.filename;
ownTar = made.cleanup;
}
// Optional client-side encryption before the bytes leave this server.
let uploadPath = tarPath;
let remoteName = filename;
if (row.encryptBundle) {
if (!row.encryptionKeyEncrypted) {
throw new Error('Destination has encryption enabled but no passphrase configured');
}
const passphrase = decrypt(row.encryptionKeyEncrypted);
encPath = `${tarPath}.enc`;
await encryptFileToFile(tarPath, encPath, passphrase);
uploadPath = encPath;
remoteName = `${filename}.enc`;
}
const { bytes, remoteRef } = await transport.push(uploadPath, remoteName);
await transport.prune(row.retentionCount).catch((err) => {
logger.warn({ err, destinationId: id }, 'Backup prune failed (push succeeded)');
});
await db
.update(backupDestinations)
.set({
lastRunAt: new Date(),
lastStatus: 'ok',
lastError: null,
lastBackupBytes: bytes,
})
.where(eq(backupDestinations.id, id));
await db.insert(backupJobs).values({
status: 'completed',
trigger: opts.trigger,
triggeredBy: opts.triggeredBy ?? null,
sizeBytes: bytes,
storagePath: remoteRef,
completedAt: new Date(),
});
logger.info({ destinationId: id, bytes, remoteRef }, 'Backup pushed to destination');
return { bytes, remoteRef };
} catch (err) {
const message = err instanceof Error ? err.message : 'unknown';
await db
.update(backupDestinations)
.set({ lastRunAt: new Date(), lastStatus: 'failed', lastError: message })
.where(eq(backupDestinations.id, id));
await db
.insert(backupJobs)
.values({
status: 'failed',
trigger: opts.trigger,
triggeredBy: opts.triggeredBy ?? null,
errorMessage: `[${row.name}] ${message}`,
completedAt: new Date(),
})
.catch(() => {});
await notifyBackupFailure(row.name, message, opts.trigger);
throw err;
} finally {
if (encPath) await unlink(encPath).catch(() => {});
if (ownTar) await ownTar();
}
}
/**
* Scheduled push: assemble the bundle ONCE and fan it out to every enabled
* destination. Per-destination failures are isolated (one bad server doesn't
* skip the others) and alerted.
*/
export async function runScheduledBackupPush(now = new Date()): Promise<{
ran: boolean;
pushed: number;
failed: number;
}> {
const schedule = await getSchedule();
if (!isScheduleDue(schedule, now)) {
logger.info({ schedule }, 'Scheduled backup not due');
return { ran: false, pushed: 0, failed: 0 };
}
const enabled = await db.query.backupDestinations.findMany({
where: eq(backupDestinations.enabled, true),
});
if (enabled.length === 0) {
logger.warn('Backup schedule is on but no destinations are enabled');
return { ran: false, pushed: 0, failed: 0 };
}
const bundle = await createFullBackupTar();
let pushed = 0;
let failed = 0;
try {
for (const dest of enabled) {
try {
await pushBackupToDestination(dest.id, {
tarPath: bundle.tarPath,
filename: bundle.filename,
trigger: 'cron',
});
pushed += 1;
} catch (err) {
failed += 1;
logger.error({ err, destinationId: dest.id }, 'Scheduled push to destination failed');
}
}
} finally {
await bundle.cleanup();
}
logger.info({ pushed, failed, total: enabled.length }, 'Scheduled backup push complete');
return { ran: true, pushed, failed };
}
// ─── failure alerting ────────────────────────────────────────────────────
async function notifyBackupFailure(
destinationName: string,
message: string,
trigger: 'manual' | 'cron',
): Promise<void> {
// Guaranteed signal: an error-severity audit row (visible in /admin/audit).
await createAuditLog({
userId: null,
portId: null,
action: 'job_failed',
entityType: 'backup_destination',
entityId: destinationName,
severity: 'error',
source: trigger === 'cron' ? 'cron' : 'job',
metadata: { destination: destinationName, error: message },
});
// Best-effort: in-app system alert to every super-admin (per their port).
try {
const admins = await db
.select({ userId: userProfiles.userId, portId: userPortRoles.portId })
.from(userProfiles)
.innerJoin(userPortRoles, eq(userPortRoles.userId, userProfiles.userId))
.where(eq(userProfiles.isSuperAdmin, true));
const seen = new Set<string>();
for (const a of admins) {
const key = `${a.userId}:${a.portId}`;
if (seen.has(key)) continue;
seen.add(key);
await createNotification({
portId: a.portId,
userId: a.userId,
type: 'system_alert',
title: 'Backup push failed',
description: `Backup to "${destinationName}" failed: ${message}`,
dedupeKey: `backup-fail:${destinationName}`,
cooldownMs: 60 * 60 * 1000,
});
}
} catch (err) {
logger.error({ err }, 'Failed to notify super-admins of backup failure');
}
}

View File

@@ -0,0 +1,109 @@
/**
* Opt-in client-side encryption for backup bundles
* (docs/superpowers/specs/2026-06-04-backup-destinations-design.md).
*
* When a destination has `encryptBundle` on, the tar is encrypted to
* `<name>.tar.enc` before it leaves this server, so a compromised destination
* (untrusted SFTP host, third-party bucket) never holds raw signed contracts +
* GDPR data.
*
* Format (AES-256-GCM, scrypt KDF):
*
* ┌────────┬──────────┬──────────┬──────────────┬──────────┐
* │ magic │ salt │ iv │ ciphertext … │ authTag │
* │ 5 bytes│ 16 bytes │ 12 bytes │ (streamed) │ 16 bytes │
* └────────┴──────────┴──────────┴──────────────┴──────────┘
*
* Streaming throughout (memory stays O(chunk)). The auth tag is written last
* because GCM only produces it after the final block; decryption reads it from
* the file tail first, then streams the ciphertext through the decipher.
*/
import { createCipheriv, createDecipheriv, randomBytes, scrypt as scryptCb } from 'node:crypto';
import { createReadStream, createWriteStream } from 'node:fs';
import { open, stat } from 'node:fs/promises';
import { pipeline } from 'node:stream/promises';
import { promisify } from 'node:util';
const scrypt = promisify(scryptCb);
const MAGIC = Buffer.from('PNBK1', 'ascii'); // 5 bytes
const SALT_LEN = 16;
const IV_LEN = 12;
const TAG_LEN = 16;
const HEADER_LEN = MAGIC.length + SALT_LEN + IV_LEN; // 33
async function deriveKey(passphrase: string, salt: Buffer): Promise<Buffer> {
return (await scrypt(passphrase, salt, 32)) as Buffer;
}
/** Encrypt `srcPath` → `destPath` with a passphrase-derived AES-256-GCM key. */
export async function encryptFileToFile(
srcPath: string,
destPath: string,
passphrase: string,
): Promise<void> {
const salt = randomBytes(SALT_LEN);
const iv = randomBytes(IV_LEN);
const key = await deriveKey(passphrase, salt);
const cipher = createCipheriv('aes-256-gcm', key, iv);
const out = createWriteStream(destPath);
out.write(Buffer.concat([MAGIC, salt, iv]));
// Pipe plaintext → cipher → file, writing to `out` by hand (rather than
// letting pipeline end it) so we can append the auth tag once the cipher has
// flushed its final block.
await pipeline(createReadStream(srcPath), cipher, async (source) => {
for await (const chunk of source) {
if (!out.write(chunk as Buffer)) {
await new Promise<void>((resolve) => out.once('drain', () => resolve()));
}
}
});
out.write(cipher.getAuthTag());
await new Promise<void>((resolve, reject) => {
out.end((err?: Error | null) => (err ? reject(err) : resolve()));
});
}
/** Decrypt a file produced by {@link encryptFileToFile}. Throws on wrong key / tamper. */
export async function decryptFileToFile(
srcPath: string,
destPath: string,
passphrase: string,
): Promise<void> {
const { size } = await stat(srcPath);
if (size < HEADER_LEN + TAG_LEN) {
throw new Error('Encrypted backup is too small / not a PNBK1 bundle');
}
// Read the fixed header + the trailing auth tag.
const fh = await open(srcPath, 'r');
try {
const header = Buffer.alloc(HEADER_LEN);
await fh.read(header, 0, HEADER_LEN, 0);
if (!header.subarray(0, MAGIC.length).equals(MAGIC)) {
throw new Error('Not a PNBK1 encrypted backup (bad magic)');
}
const salt = header.subarray(MAGIC.length, MAGIC.length + SALT_LEN);
const iv = header.subarray(MAGIC.length + SALT_LEN, HEADER_LEN);
const tag = Buffer.alloc(TAG_LEN);
await fh.read(tag, 0, TAG_LEN, size - TAG_LEN);
const key = await deriveKey(passphrase, salt);
const decipher = createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(tag);
// Stream only the ciphertext region [HEADER_LEN, size - TAG_LEN).
const cipherStream = createReadStream(srcPath, {
start: HEADER_LEN,
end: size - TAG_LEN - 1,
});
await pipeline(cipherStream, decipher, createWriteStream(destPath));
} finally {
await fh.close();
}
}

View File

@@ -0,0 +1,47 @@
/**
* Filesystem backup transport — pushes the bundle to a configured directory
* (a mounted volume / NAS share). The simplest destination: no network, just a
* path the app can write to.
*/
import { constants } from 'node:fs';
import { access, copyFile, readdir, stat, unlink } from 'node:fs/promises';
import path from 'node:path';
import {
BACKUP_NAME_PREFIX,
sortBundlesNewestFirst,
type BackupTransport,
type FilesystemDestConfig,
} from './types';
export class FilesystemTransport implements BackupTransport {
constructor(private readonly cfg: FilesystemDestConfig) {}
async test(): Promise<void> {
if (!this.cfg.directory) throw new Error('No directory configured');
await access(this.cfg.directory, constants.W_OK).catch(() => {
throw new Error(`Directory not writable or does not exist: ${this.cfg.directory}`);
});
const s = await stat(this.cfg.directory);
if (!s.isDirectory()) throw new Error(`Not a directory: ${this.cfg.directory}`);
}
async push(localPath: string, remoteName: string): Promise<{ remoteRef: string; bytes: number }> {
const dest = path.join(this.cfg.directory, remoteName);
await copyFile(localPath, dest);
const s = await stat(dest);
return { remoteRef: dest, bytes: s.size };
}
async prune(retentionCount: number | null): Promise<{ deleted: number }> {
if (retentionCount === null || retentionCount < 0) return { deleted: 0 };
const entries = await readdir(this.cfg.directory);
const bundles = sortBundlesNewestFirst(entries.filter((n) => n.startsWith(BACKUP_NAME_PREFIX)));
const toDelete = bundles.slice(retentionCount);
for (const name of toDelete) {
await unlink(path.join(this.cfg.directory, name)).catch(() => {});
}
return { deleted: toDelete.length };
}
}

View File

@@ -0,0 +1,36 @@
/**
* Backup destination transport factory. Given a destination type + its
* *decrypted* runtime config, build the matching transport.
*/
import { FilesystemTransport } from './filesystem';
import { S3Transport } from './s3';
import { SftpTransport } from './sftp';
import type {
BackupDestinationType,
BackupTransport,
FilesystemDestConfig,
S3DestConfig,
SftpDestConfig,
} from './types';
export function buildTransport(
type: BackupDestinationType,
config: Record<string, unknown>,
): BackupTransport {
switch (type) {
case 'filesystem':
return new FilesystemTransport(config as unknown as FilesystemDestConfig);
case 'sftp':
return new SftpTransport(config as unknown as SftpDestConfig);
case 's3':
return new S3Transport(config as unknown as S3DestConfig);
default:
throw new Error(`Unknown backup destination type: ${String(type)}`);
}
}
export { FilesystemTransport } from './filesystem';
export { SftpTransport } from './sftp';
export { S3Transport, parseS3Endpoint } from './s3';
export * from './types';

View File

@@ -0,0 +1,104 @@
/**
* S3-compatible backup transport — pushes the bundle to any S3 API endpoint
* (AWS S3, Backblaze B2, Wasabi, Cloudflare R2, MinIO). Reuses the `minio`
* client the storage backend already depends on, so no new SDK.
*/
import path from 'node:path';
import { Client as MinioClient } from 'minio';
import {
BACKUP_NAME_PREFIX,
sortBundlesNewestFirst,
type BackupTransport,
type S3DestConfig,
} from './types';
/** Split a configured endpoint (host or URL) into minio's endPoint/port/useSSL. */
export function parseS3Endpoint(
endpoint: string,
cfg: { useSSL?: boolean; port?: number },
): { endPoint: string; port?: number; useSSL: boolean } {
let host = endpoint.trim();
let useSSL = cfg.useSSL ?? true;
let port = cfg.port;
const m = /^(https?):\/\/([^/:]+)(?::(\d+))?/i.exec(host);
if (m) {
useSSL = m[1]!.toLowerCase() === 'https';
host = m[2]!;
if (m[3]) port = Number(m[3]);
} else {
host = host.replace(/\/.*$/, '');
}
return { endPoint: host, ...(port ? { port } : {}), useSSL };
}
export class S3Transport implements BackupTransport {
private readonly prefix: string;
constructor(private readonly cfg: S3DestConfig) {
// Normalise prefix to "" or "dir/".
const p = (cfg.prefix ?? '').replace(/^\/+|\/+$/g, '');
this.prefix = p ? `${p}/` : '';
}
private client(): MinioClient {
const { endPoint, port, useSSL } = parseS3Endpoint(this.cfg.endpoint, {
useSSL: this.cfg.useSSL,
port: this.cfg.port,
});
return new MinioClient({
endPoint,
...(port ? { port } : {}),
useSSL,
accessKey: this.cfg.accessKey,
secretKey: this.cfg.secretKey,
...(this.cfg.region ? { region: this.cfg.region } : {}),
});
}
async test(): Promise<void> {
const exists = await this.client().bucketExists(this.cfg.bucket);
if (!exists) throw new Error(`Bucket not found or not accessible: ${this.cfg.bucket}`);
}
async push(localPath: string, remoteName: string): Promise<{ remoteRef: string; bytes: number }> {
const key = `${this.prefix}${remoteName}`;
await this.client().fPutObject(this.cfg.bucket, key, localPath, {
'Content-Type': 'application/x-tar',
});
const { stat } = await import('node:fs/promises');
const s = await stat(localPath);
return { remoteRef: `s3://${this.cfg.bucket}/${key}`, bytes: s.size };
}
async prune(retentionCount: number | null): Promise<{ deleted: number }> {
if (retentionCount === null || retentionCount < 0) return { deleted: 0 };
const client = this.client();
const names = await this.listBundleKeys(client);
const sorted = sortBundlesNewestFirst(names.map((k) => path.posix.basename(k)));
const keepBasenames = new Set(sorted.slice(0, retentionCount));
const toDelete = names.filter(
(k) =>
path.posix.basename(k).startsWith(BACKUP_NAME_PREFIX) &&
!keepBasenames.has(path.posix.basename(k)),
);
for (const key of toDelete) await client.removeObject(this.cfg.bucket, key);
return { deleted: toDelete.length };
}
private listBundleKeys(client: MinioClient): Promise<string[]> {
return new Promise((resolve, reject) => {
const keys: string[] = [];
const stream = client.listObjectsV2(this.cfg.bucket, this.prefix, true);
stream.on('data', (obj) => {
if (obj.name && path.posix.basename(obj.name).startsWith(BACKUP_NAME_PREFIX)) {
keys.push(obj.name);
}
});
stream.on('error', reject);
stream.on('end', () => resolve(keys));
});
}
}

View File

@@ -0,0 +1,102 @@
/**
* SFTP/SSH backup transport — pushes the bundle to a remote server over SFTP.
* This is the "separate server" destination most deployments will use.
*
* Host-key handling: when `hostFingerprint` is set, the server's key is verified
* against it (sha256, colons/whitespace ignored) and the connection is rejected
* on mismatch — defends against MITM. With no fingerprint configured we accept
* on first use (TOFU); admins should pin the fingerprint for untrusted networks.
*/
import { createHash } from 'node:crypto';
import path from 'node:path';
import SftpClient from 'ssh2-sftp-client';
import { logger } from '@/lib/logger';
import {
BACKUP_NAME_PREFIX,
sortBundlesNewestFirst,
type BackupTransport,
type SftpDestConfig,
} from './types';
function normalizeFingerprint(fp: string): string {
return fp
.replace(/^sha256:/i, '')
.replace(/[:\s]/g, '')
.toLowerCase();
}
export class SftpTransport implements BackupTransport {
constructor(private readonly cfg: SftpDestConfig) {}
private connectOptions(): SftpClient.ConnectOptions {
const expected = this.cfg.hostFingerprint
? normalizeFingerprint(this.cfg.hostFingerprint)
: null;
return {
host: this.cfg.host,
port: this.cfg.port ?? 22,
username: this.cfg.username,
...(this.cfg.password ? { password: this.cfg.password } : {}),
...(this.cfg.privateKey ? { privateKey: this.cfg.privateKey } : {}),
...(this.cfg.passphrase ? { passphrase: this.cfg.passphrase } : {}),
// ssh2 calls this with the server's host key; hash + compare to the pin.
hostVerifier: (key: Buffer): boolean => {
if (!expected) return true;
const actual = createHash('sha256').update(key).digest('hex');
const ok = actual === expected;
if (!ok) logger.error({ host: this.cfg.host }, 'SFTP host-key fingerprint mismatch');
return ok;
},
} as SftpClient.ConnectOptions;
}
private async withClient<T>(fn: (c: SftpClient) => Promise<T>): Promise<T> {
const client = new SftpClient();
try {
await client.connect(this.connectOptions());
return await fn(client);
} finally {
await client.end().catch(() => {});
}
}
async test(): Promise<void> {
await this.withClient(async (c) => {
// Ensure the remote dir exists (create recursively if needed) and is usable.
const exists = await c.exists(this.cfg.remoteDir);
if (!exists) await c.mkdir(this.cfg.remoteDir, true);
});
}
async push(localPath: string, remoteName: string): Promise<{ remoteRef: string; bytes: number }> {
return this.withClient(async (c) => {
const exists = await c.exists(this.cfg.remoteDir);
if (!exists) await c.mkdir(this.cfg.remoteDir, true);
const remotePath = path.posix.join(this.cfg.remoteDir, remoteName);
await c.fastPut(localPath, remotePath);
const s = await c.stat(remotePath);
return { remoteRef: `sftp://${this.cfg.host}${remotePath}`, bytes: s.size };
});
}
async prune(retentionCount: number | null): Promise<{ deleted: number }> {
if (retentionCount === null || retentionCount < 0) return { deleted: 0 };
return this.withClient(async (c) => {
const list = await c.list(this.cfg.remoteDir);
const bundles = sortBundlesNewestFirst(
list
.filter((e) => e.type === '-' && e.name.startsWith(BACKUP_NAME_PREFIX))
.map((e) => e.name),
);
const toDelete = bundles.slice(retentionCount);
for (const name of toDelete) {
await c.delete(path.posix.join(this.cfg.remoteDir, name)).catch(() => {});
}
return { deleted: toDelete.length };
});
}
}

View File

@@ -0,0 +1,60 @@
/**
* Backup destination transport contract + per-type config shapes.
* See docs/superpowers/specs/2026-06-04-backup-destinations-design.md.
*
* Config shapes here are the *decrypted, runtime* form. Secrets are stored
* AES-GCM-encrypted in the `backup_destinations.config` jsonb and decrypted by
* the service layer before a transport is constructed.
*/
export type BackupDestinationType = 'sftp' | 's3' | 'filesystem';
/** Filename prefix every full-bundle tar carries (`createFullBackupTar`). */
export const BACKUP_NAME_PREFIX = 'pn-crm-backup-';
export interface BackupTransport {
/** Verify the destination is reachable + writable. Throws on failure. */
test(): Promise<void>;
/** Upload `localPath` to the destination as `remoteName`. */
push(localPath: string, remoteName: string): Promise<{ remoteRef: string; bytes: number }>;
/** Keep the `retentionCount` newest bundles; null = keep all. */
prune(retentionCount: number | null): Promise<{ deleted: number }>;
}
export interface FilesystemDestConfig {
/** Absolute path to a mounted volume / NAS directory the app can write to. */
directory: string;
}
export interface SftpDestConfig {
host: string;
port?: number;
username: string;
/** One of password / privateKey is required. */
password?: string;
privateKey?: string;
/** Passphrase for an encrypted private key. */
passphrase?: string;
remoteDir: string;
/** Optional pinned host-key fingerprint (sha256 hex). When set, the
* connection is rejected unless the server's key matches. */
hostFingerprint?: string;
}
export interface S3DestConfig {
endpoint: string;
region?: string;
bucket: string;
accessKey: string;
secretKey: string;
/** Key prefix within the bucket (e.g. "crm-backups/"). */
prefix?: string;
/** Default true; set false only for pl-text local MinIO test endpoints. */
useSSL?: boolean;
port?: number;
}
/** Sort backup bundle filenames newest-first (timestamp-in-name sorts lexically). */
export function sortBundlesNewestFirst(names: string[]): string[] {
return names.filter((n) => n.startsWith(BACKUP_NAME_PREFIX)).sort((a, b) => b.localeCompare(a));
}

View File

@@ -0,0 +1,297 @@
/**
* Full-bundle backup export (Phase 4a — docs/storage-migration-and-backup-plan.md).
*
* Today's `runBackup()` (backup.service.ts) dumps ONLY the database, buffers the
* whole dump in memory, and writes it back to the SAME storage backend it would
* lose if storage died. This module produces a *complete*, backend-agnostic
* disaster-recovery bundle:
*
* bundle.tar
* ├── db.dump (pg_dump --format=custom of the live DB)
* ├── blobs/<storage_key> (every blob referenced by a DB row)
* └── manifest.json (sha256 + size per object, for restore-side verify)
*
* Streaming is mandatory: blobs are piped backend → tar with a known size
* (`stats.size`, which is what makes archiver stream instead of buffering the
* whole entry into memory) so total memory stays O(largest chunk), not
* O(total bytes). The tar is assembled to a temp file first, then handed to the
* caller to stream to the operator — so a mid-assembly failure surfaces as a
* clean error rather than a truncated download.
*
* The assembler (`assembleBackupTar`) is pure w.r.t. its inputs (a storage
* backend, a pre-produced dump file, a blob-ref list) so it is unit-tested with
* an in-memory backend; the orchestrator (`createFullBackupTar`) wires in
* pg_dump + the live blob inventory.
*/
import { createHash } from 'node:crypto';
import { createReadStream, createWriteStream } from 'node:fs';
import { stat, unlink } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { Transform } from 'node:stream';
import archiver from 'archiver';
import { env } from '@/lib/env';
import { logger } from '@/lib/logger';
import { getStorageBackend, type StorageBackend } from '@/lib/storage';
import { collectStorageRefs } from '@/lib/storage/migrate';
import { runPgDump } from '@/lib/services/backup.service';
/** A blob the bundle should attempt to include. */
export interface BackupBlobRef {
tableName: string;
pk: string;
key: string;
}
export interface BackupManifestBlobEntry {
table: string;
pk: string;
key: string;
sizeBytes: number;
sha256: string;
}
export interface BackupSkippedEntry {
table: string;
pk: string;
key: string;
reason: string;
}
export interface BackupManifest {
formatVersion: number;
createdAt: string;
storageBackend: string;
database: {
file: string;
format: string;
sizeBytes: number;
sha256: string;
};
blobs: BackupManifestBlobEntry[];
skipped: BackupSkippedEntry[];
counts: {
blobs: number;
blobBytes: number;
skipped: number;
};
}
/**
* Pipe `source` into the archive under `name`, computing the sha256 and byte
* count of exactly the bytes that pass through. Resolves once the entry has
* been fully consumed by archiver.
*
* `stats.size` MUST be supplied: archiver's tar plugin streams the entry only
* when `data.stats` is present (otherwise it buffers the whole stream into
* memory via `collectStream` to discover the size — the exact behaviour we're
* avoiding for multi-GB blob sets).
*/
function appendHashedStream(
archive: archiver.Archiver,
source: NodeJS.ReadableStream,
name: string,
declaredSize: number,
now: Date,
): Promise<{ sha256: string; bytes: number }> {
const hash = createHash('sha256');
let bytes = 0;
const tee = new Transform({
transform(chunk: Buffer, _enc, cb) {
hash.update(chunk);
bytes += chunk.length;
cb(null, chunk);
},
});
const done = new Promise<{ sha256: string; bytes: number }>((resolve, reject) => {
source.on('error', (err) => tee.destroy(err instanceof Error ? err : new Error(String(err))));
tee.on('error', reject);
tee.on('end', () => resolve({ sha256: hash.digest('hex'), bytes }));
});
source.pipe(tee);
archive.append(tee, {
name,
date: now,
// A minimal fs.Stats-like object. `size` engages archiver's streaming
// tar path; `mode`/`mtime` keep the header deterministic.
stats: {
size: declaredSize,
mode: 0o644,
mtime: now,
isFile: () => true,
isDirectory: () => false,
} as unknown as import('node:fs').Stats,
});
return done;
}
/**
* Assemble a backup tar at `outFilePath` from a pre-produced pg_dump file and a
* list of blob references. Returns the manifest describing the bundle.
*/
export async function assembleBackupTar(opts: {
backend: StorageBackend;
dumpFilePath: string;
blobRefs: BackupBlobRef[];
outFilePath: string;
storageBackendName: string;
now: Date;
}): Promise<BackupManifest> {
const { backend, dumpFilePath, blobRefs, outFilePath, storageBackendName, now } = opts;
const archive = archiver('tar');
const output = createWriteStream(outFilePath);
const finished = new Promise<void>((resolve, reject) => {
output.on('close', () => resolve());
output.on('error', reject);
archive.on('error', reject);
archive.on('warning', (err: Error & { code?: string }) => {
// Non-fatal (e.g. ENOENT on a vanished file) — log and keep going.
logger.warn({ err }, 'archiver warning during backup export');
});
});
archive.pipe(output);
// 1. db.dump
const dumpStat = await stat(dumpFilePath);
const dump = await appendHashedStream(
archive,
createReadStream(dumpFilePath),
'db.dump',
dumpStat.size,
now,
);
// 2. blobs (one at a time so memory stays bounded)
const blobs: BackupManifestBlobEntry[] = [];
const skipped: BackupSkippedEntry[] = [];
for (const ref of blobRefs) {
const head = await backend.head(ref.key);
if (!head) {
skipped.push({
table: ref.tableName,
pk: ref.pk,
key: ref.key,
reason: 'missing-in-storage',
});
continue;
}
let source: NodeJS.ReadableStream;
try {
source = await backend.get(ref.key);
} catch (err) {
skipped.push({
table: ref.tableName,
pk: ref.pk,
key: ref.key,
reason: `unreadable: ${err instanceof Error ? err.message : 'unknown'}`,
});
continue;
}
const { sha256, bytes } = await appendHashedStream(
archive,
source,
`blobs/${ref.key}`,
head.sizeBytes,
now,
);
blobs.push({ table: ref.tableName, pk: ref.pk, key: ref.key, sizeBytes: bytes, sha256 });
}
// 3. manifest.json (last — it carries the sha256 of every prior entry)
const manifest: BackupManifest = {
formatVersion: 1,
createdAt: now.toISOString(),
storageBackend: storageBackendName,
database: {
file: 'db.dump',
format: 'pg_dump-custom',
sizeBytes: dump.bytes,
sha256: dump.sha256,
},
blobs,
skipped,
counts: {
blobs: blobs.length,
blobBytes: blobs.reduce((acc, b) => acc + b.sizeBytes, 0),
skipped: skipped.length,
},
};
archive.append(Buffer.from(JSON.stringify(manifest, null, 2)), {
name: 'manifest.json',
date: now,
});
await archive.finalize();
await finished;
return manifest;
}
export interface FullBackupResult {
tarPath: string;
filename: string;
manifest: BackupManifest;
/** Removes the assembled tar. The intermediate dump is removed eagerly. */
cleanup: () => Promise<void>;
}
/**
* Orchestrate a full backup: pg_dump the live DB, inventory every blob, and
* assemble the bundle to a temp tar. The caller streams `tarPath` to the
* operator and invokes `cleanup()` when the download finishes.
*
* `backup_jobs` blobs (prior pg_dump artefacts) are excluded so a full export
* doesn't recursively bundle previous backups.
*/
export async function createFullBackupTar(): Promise<FullBackupResult> {
const now = new Date();
const id = crypto.randomUUID();
const dumpPath = path.join(tmpdir(), `pn-fullbackup-${id}.dump`);
const tarPath = path.join(tmpdir(), `pn-fullbackup-${id}.tar`);
try {
await runPgDump(env.DATABASE_URL, dumpPath);
const backend = await getStorageBackend();
const refs = await collectStorageRefs({ excludeTables: ['backup_jobs'] });
const blobRefs: BackupBlobRef[] = refs.map((r) => ({
tableName: r.tableName,
pk: r.pk,
key: r.key,
}));
const manifest = await assembleBackupTar({
backend,
dumpFilePath: dumpPath,
blobRefs,
outFilePath: tarPath,
storageBackendName: backend.name,
now,
});
logger.info(
{ blobs: manifest.counts.blobs, skipped: manifest.counts.skipped },
'Full backup bundle assembled',
);
const stamp = now.toISOString().replace(/[:.]/g, '-');
return {
tarPath,
filename: `pn-crm-backup-${stamp}.tar`,
manifest,
cleanup: async () => {
await unlink(tarPath).catch(() => {});
},
};
} finally {
// The dump is already inside the tar (or assembly failed) — drop it now.
await unlink(dumpPath).catch(() => {});
}
}

View File

@@ -88,24 +88,63 @@ export async function runBackup({ trigger, triggeredBy }: RunBackupArgs): Promis
}
}
function runPgDump(databaseUrl: string, outFile: string): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn('pg_dump', ['--format=custom', '--no-owner', databaseUrl]);
const out = createWriteStream(outFile);
child.stdout.pipe(out);
export interface RunPgDumpOpts {
/** Override the binary (tests inject `node`). Defaults to `pg_dump`. */
command?: string;
/** Build the argv from the connection URL. Defaults to a custom-format dump. */
buildArgs?: (databaseUrl: string) => string[];
}
export function runPgDump(
databaseUrl: string,
outFile: string,
opts: RunPgDumpOpts = {},
): Promise<void> {
const command = opts.command ?? 'pg_dump';
const args = (opts.buildArgs ?? ((url) => ['--format=custom', '--no-owner', url]))(databaseUrl);
return new Promise((resolve, reject) => {
const child = spawn(command, args);
const out = createWriteStream(outFile);
// `stdout.pipe(out)` auto-ends `out` when the child's stdout closes, so the
// file's `finish` event can fire *before* the process `close` event. Gate
// resolution on BOTH having happened rather than attaching the `finish`
// listener inside the `close` handler (which raced and hung when `finish`
// had already fired).
let stderr = '';
let settled = false;
let exitCode: number | null = null;
let fileFlushed = false;
const fail = (err: Error): void => {
if (settled) return;
settled = true;
reject(err);
};
const maybeResolve = (): void => {
if (settled || exitCode === null || !fileFlushed) return;
if (exitCode === 0) {
settled = true;
resolve();
} else {
fail(new Error(`pg_dump exited ${exitCode}: ${stderr}`));
}
};
child.stderr.on('data', (b) => {
stderr += b.toString();
});
child.on('error', (err) => reject(err));
child.on('close', (code) => {
out.end();
child.on('error', fail);
out.on('error', fail);
out.on('finish', () => {
if (code === 0) resolve();
else reject(new Error(`pg_dump exited ${code}: ${stderr}`));
fileFlushed = true;
maybeResolve();
});
child.on('close', (code) => {
exitCode = code;
maybeResolve();
});
child.stdout.pipe(out);
});
}

View File

@@ -136,7 +136,7 @@ async function markRowMigrated(
`);
}
interface RowRef {
export interface RowRef {
tableName: string;
pk: string;
key: string;
@@ -164,6 +164,22 @@ async function listKeysFor(tbl: StorageKeyTable): Promise<RowRef[]> {
}));
}
/**
* Inventory every blob reference across all blob-bearing tables. Used by the
* full-backup exporter (Phase 4a) to enumerate what to bundle. `excludeTables`
* lets the exporter drop `backup_jobs` so a full export doesn't recursively
* include prior backup artefacts.
*/
export async function collectStorageRefs(opts?: { excludeTables?: string[] }): Promise<RowRef[]> {
const exclude = new Set(opts?.excludeTables ?? []);
const all: RowRef[] = [];
for (const tbl of TABLES_WITH_STORAGE_KEYS) {
if (exclude.has(tbl.table)) continue;
all.push(...(await listKeysFor(tbl)));
}
return all;
}
// ─── streaming + sha256 verify ──────────────────────────────────────────────
/**

View File

@@ -0,0 +1,66 @@
import { z } from 'zod';
/** Per-type connection config. Secret fields are optional so an edit can leave
* them blank to keep the stored (encrypted) value; create-time omission is
* surfaced by the destination's "Test connection" instead. */
const filesystemConfigSchema = z.object({
directory: z.string().min(1, 'Directory is required'),
});
const sftpConfigSchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.number().int().positive().max(65535).optional(),
username: z.string().min(1, 'Username is required'),
password: z.string().optional(),
privateKey: z.string().optional(),
passphrase: z.string().optional(),
remoteDir: z.string().min(1, 'Remote directory is required'),
hostFingerprint: z.string().optional(),
});
const s3ConfigSchema = z.object({
endpoint: z.string().min(1, 'Endpoint is required'),
region: z.string().optional(),
bucket: z.string().min(1, 'Bucket is required'),
accessKey: z.string().min(1, 'Access key is required'),
secretKey: z.string().optional(),
prefix: z.string().optional(),
useSSL: z.boolean().optional(),
port: z.number().int().positive().max(65535).optional(),
});
const CONFIG_SCHEMA_BY_TYPE = {
filesystem: filesystemConfigSchema,
sftp: sftpConfigSchema,
s3: s3ConfigSchema,
} as const;
export const backupDestinationSchema = z
.object({
name: z.string().min(1).max(120),
type: z.enum(['sftp', 's3', 'filesystem']),
enabled: z.boolean().optional(),
config: z.record(z.string(), z.unknown()),
retentionCount: z.number().int().min(0).max(10000).nullable().optional(),
encryptBundle: z.boolean().optional(),
encryptionKey: z.string().optional(),
})
.superRefine((val, ctx) => {
const schema = CONFIG_SCHEMA_BY_TYPE[val.type];
const result = schema.safeParse(val.config);
if (!result.success) {
for (const issue of result.error.issues) {
ctx.addIssue({ ...issue, path: ['config', ...issue.path] });
}
}
if (val.encryptBundle && !val.encryptionKey) {
// Allowed on update (keeps existing key); the route/service enforces a key
// exists before a push. Surfaced here only as a soft hint via no error.
}
});
export const backupScheduleSchema = z.object({
schedule: z.enum(['off', 'daily', 'weekly']),
});
export type BackupDestinationInput = z.infer<typeof backupDestinationSchema>;

View File

@@ -0,0 +1,75 @@
/**
* Unit test for opt-in backup-bundle encryption
* (`src/lib/services/backup-destinations/bundle-encryption.ts`).
*
* Contract: AES-256-GCM streaming encryption with a scrypt-derived key.
* - round-trips arbitrary bytes (small + multi-chunk),
* - rejects the wrong passphrase (GCM auth failure),
* - rejects tampered ciphertext (GCM auth failure).
*/
import { createHash, randomBytes } from 'node:crypto';
import { mkdtempSync, readFileSync, rmSync, writeFileSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
decryptFileToFile,
encryptFileToFile,
} from '@/lib/services/backup-destinations/bundle-encryption';
function sha(p: string): string {
return createHash('sha256').update(readFileSync(p)).digest('hex');
}
describe('backup bundle encryption', () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(path.join(tmpdir(), 'pn-enc-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it('round-trips a multi-chunk file with the correct passphrase', async () => {
const plain = path.join(dir, 'bundle.tar');
// ~400 KB of pseudo-random bytes → multiple cipher chunks.
writeFileSync(plain, randomBytes(400 * 1024));
const enc = path.join(dir, 'bundle.tar.enc');
const dec = path.join(dir, 'bundle.roundtrip.tar');
await encryptFileToFile(plain, enc, 'correct horse battery staple');
// Ciphertext must differ from plaintext and not be empty.
expect(statSync(enc).size).toBeGreaterThan(0);
expect(sha(enc)).not.toBe(sha(plain));
await decryptFileToFile(enc, dec, 'correct horse battery staple');
expect(sha(dec)).toBe(sha(plain));
});
it('rejects the wrong passphrase', async () => {
const plain = path.join(dir, 'b.tar');
writeFileSync(plain, Buffer.from('top secret contract bytes'));
const enc = path.join(dir, 'b.tar.enc');
await encryptFileToFile(plain, enc, 'right-pass');
await expect(decryptFileToFile(enc, path.join(dir, 'out.tar'), 'WRONG-pass')).rejects.toThrow();
});
it('rejects tampered ciphertext', async () => {
const plain = path.join(dir, 'c.tar');
writeFileSync(plain, randomBytes(64 * 1024));
const enc = path.join(dir, 'c.tar.enc');
await encryptFileToFile(plain, enc, 'pw');
// Flip a byte in the middle of the ciphertext region.
const buf = readFileSync(enc);
const mid = Math.floor(buf.length / 2);
buf[mid] = (buf[mid] ?? 0) ^ 0xff;
writeFileSync(enc, buf);
await expect(decryptFileToFile(enc, path.join(dir, 'out.tar'), 'pw')).rejects.toThrow();
});
});

View File

@@ -0,0 +1,86 @@
/**
* Unit tests for the pure helpers of the backup-destinations service:
* schedule-due logic + secret config (serialize → encrypt at rest →
* decrypt for use, mask for API). DB-backed CRUD is covered by the e2e
* verification, not here.
*/
import { describe, expect, it } from 'vitest';
import {
decryptConfig,
isScheduleDue,
maskConfig,
serializeConfig,
} from '@/lib/services/backup-destinations.service';
describe('isScheduleDue', () => {
// 2026-06-07 is a Sunday; 2026-06-08 a Monday.
const sunday = new Date('2026-06-07T02:00:00Z');
const monday = new Date('2026-06-08T02:00:00Z');
it('off is never due', () => {
expect(isScheduleDue('off', sunday)).toBe(false);
expect(isScheduleDue('off', monday)).toBe(false);
});
it('daily is always due', () => {
expect(isScheduleDue('daily', sunday)).toBe(true);
expect(isScheduleDue('daily', monday)).toBe(true);
});
it('weekly is due only on Sunday', () => {
expect(isScheduleDue('weekly', sunday)).toBe(true);
expect(isScheduleDue('weekly', monday)).toBe(false);
});
});
describe('secret config handling', () => {
it('serialize encrypts secrets, decrypt restores them, mask hides them', () => {
const incoming = {
host: 'box.example.com',
username: 'crm',
password: 'hunter2',
remoteDir: '/backups',
};
const stored = serializeConfig('sftp', incoming);
// Stored password must not be the plaintext.
expect(stored.password).not.toBe('hunter2');
expect(stored.host).toBe('box.example.com');
// Decrypt restores the plaintext for transport use.
expect(decryptConfig('sftp', stored)).toMatchObject({
host: 'box.example.com',
username: 'crm',
password: 'hunter2',
remoteDir: '/backups',
});
// Mask hides the secret and exposes only a *IsSet marker.
const masked = maskConfig('sftp', stored);
expect(masked.password).toBeUndefined();
expect(masked.passwordIsSet).toBe(true);
expect(masked.host).toBe('box.example.com');
});
it('update with a blank secret keeps the existing encrypted value', () => {
const original = serializeConfig('s3', {
endpoint: 'https://s3.example.com',
bucket: 'b',
accessKey: 'AK',
secretKey: 'SUPERSECRET',
});
// Admin edits the bucket but leaves the secret key blank (unchanged).
const updated = serializeConfig(
's3',
{ endpoint: 'https://s3.example.com', bucket: 'b2', accessKey: 'AK', secretKey: '' },
original,
);
expect(updated.bucket).toBe('b2');
expect(decryptConfig('s3', updated).secretKey).toBe('SUPERSECRET');
});
it('filesystem has no secrets to mask', () => {
const stored = serializeConfig('filesystem', { directory: '/mnt/nas/backups' });
expect(maskConfig('filesystem', stored)).toEqual({ directory: '/mnt/nas/backups' });
});
});

View File

@@ -0,0 +1,72 @@
/**
* Unit test for the filesystem (mounted-path / NAS) backup transport
* (`src/lib/services/backup-destinations/filesystem.ts`).
*/
import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { FilesystemTransport } from '@/lib/services/backup-destinations/filesystem';
import { BACKUP_NAME_PREFIX } from '@/lib/services/backup-destinations/types';
describe('FilesystemTransport', () => {
let work: string;
let destDir: string;
beforeEach(() => {
work = mkdtempSync(path.join(tmpdir(), 'pn-fstr-'));
destDir = path.join(work, 'backups');
mkdirSync(destDir);
});
afterEach(() => {
rmSync(work, { recursive: true, force: true });
});
it('test() rejects when the directory does not exist', async () => {
const t = new FilesystemTransport({ directory: path.join(work, 'nope') });
await expect(t.test()).rejects.toThrow();
});
it('push() copies the bundle into the destination directory', async () => {
const local = path.join(work, `${BACKUP_NAME_PREFIX}2026-06-04.tar`);
writeFileSync(local, Buffer.from('BUNDLE-BYTES'));
const t = new FilesystemTransport({ directory: destDir });
const res = await t.push(local, `${BACKUP_NAME_PREFIX}2026-06-04.tar`);
expect(res.bytes).toBe('BUNDLE-BYTES'.length);
const landed = readFileSync(path.join(destDir, `${BACKUP_NAME_PREFIX}2026-06-04.tar`), 'utf8');
expect(landed).toBe('BUNDLE-BYTES');
});
it('prune() keeps the N newest bundles and ignores unrelated files', async () => {
// Five backups (timestamp-in-name sorts chronologically) + an unrelated file.
for (const d of ['01', '02', '03', '04', '05']) {
writeFileSync(path.join(destDir, `${BACKUP_NAME_PREFIX}2026-06-${d}.tar`), 'x');
}
writeFileSync(path.join(destDir, 'unrelated-keepme.txt'), 'y');
const t = new FilesystemTransport({ directory: destDir });
const { deleted } = await t.prune(2);
expect(deleted).toBe(3);
const remaining = readdirSync(destDir).sort();
expect(remaining).toEqual([
`${BACKUP_NAME_PREFIX}2026-06-04.tar`,
`${BACKUP_NAME_PREFIX}2026-06-05.tar`,
'unrelated-keepme.txt',
]);
});
it('prune(null) keeps everything', async () => {
for (const d of ['01', '02', '03']) {
writeFileSync(path.join(destDir, `${BACKUP_NAME_PREFIX}2026-06-${d}.tar`), 'x');
}
const t = new FilesystemTransport({ directory: destDir });
const { deleted } = await t.prune(null);
expect(deleted).toBe(0);
expect(readdirSync(destDir).length).toBe(3);
});
});

View File

@@ -0,0 +1,63 @@
/**
* Unit test for the transport factory + S3 endpoint parser
* (`src/lib/services/backup-destinations/`).
*/
import { describe, expect, it } from 'vitest';
import {
buildTransport,
FilesystemTransport,
parseS3Endpoint,
S3Transport,
SftpTransport,
} from '@/lib/services/backup-destinations';
describe('buildTransport', () => {
it('builds the right transport per type', () => {
expect(buildTransport('filesystem', { directory: '/x' })).toBeInstanceOf(FilesystemTransport);
expect(buildTransport('sftp', { host: 'h', username: 'u', remoteDir: '/d' })).toBeInstanceOf(
SftpTransport,
);
expect(
buildTransport('s3', { endpoint: 'h', bucket: 'b', accessKey: 'a', secretKey: 's' }),
).toBeInstanceOf(S3Transport);
});
it('throws on an unknown type', () => {
// @ts-expect-error testing the runtime guard
expect(() => buildTransport('ftp', {})).toThrow(/Unknown backup destination type/);
});
});
describe('parseS3Endpoint', () => {
it('parses an https URL into host + ssl', () => {
expect(parseS3Endpoint('https://s3.eu-central.example.com', {})).toEqual({
endPoint: 's3.eu-central.example.com',
useSSL: true,
});
});
it('parses an http URL with a port', () => {
expect(parseS3Endpoint('http://localhost:9000', {})).toEqual({
endPoint: 'localhost',
port: 9000,
useSSL: false,
});
});
it('treats a bare host as ssl-by-default', () => {
expect(parseS3Endpoint('s3.amazonaws.com', {})).toEqual({
endPoint: 's3.amazonaws.com',
useSSL: true,
});
});
it('honours explicit useSSL=false on a bare host', () => {
expect(parseS3Endpoint('minio.internal', { useSSL: false, port: 9000 })).toEqual({
endPoint: 'minio.internal',
port: 9000,
useSSL: false,
});
});
});

View File

@@ -0,0 +1,63 @@
/**
* Unit test for `runPgDump` in backup.service.ts.
*
* Regression: the dump promise must resolve once BOTH (a) the child process
* exits 0 and (b) the output file is fully flushed — regardless of which event
* fires first. The original implementation attached the file's `finish`
* listener *inside* the child `close` handler, but `stdout.pipe(out)`
* auto-ends the file when the child's stdout closes, so `finish` frequently
* fired before the listener was attached → the promise hung forever (observed
* end-to-end against a real pg_dump).
*
* We drive the spawn with `node` instead of `pg_dump` (injected via opts) so
* the test is deterministic and needs no database.
*/
import { readFileSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { runPgDump } from '@/lib/services/backup.service';
describe('runPgDump', () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(path.join(tmpdir(), 'pn-pgdump-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it('resolves and writes the child stdout to the output file on exit 0', async () => {
const out = path.join(dir, 'a.dump');
await runPgDump('ignored://url', out, {
command: process.execPath,
buildArgs: () => ['-e', 'process.stdout.write("DUMP-CONTENTS-1234")'],
});
expect(readFileSync(out, 'utf8')).toBe('DUMP-CONTENTS-1234');
});
it('rejects with the captured stderr when the child exits non-zero', async () => {
const out = path.join(dir, 'b.dump');
await expect(
runPgDump('ignored://url', out, {
command: process.execPath,
buildArgs: () => ['-e', 'process.stderr.write("kaboom"); process.exit(3)'],
}),
).rejects.toThrow(/exited 3.*kaboom/s);
});
it('rejects when the command cannot be spawned', async () => {
const out = path.join(dir, 'c.dump');
await expect(
runPgDump('ignored://url', out, {
command: '/nonexistent/definitely-not-a-real-binary-xyz',
buildArgs: () => [],
}),
).rejects.toBeTruthy();
});
});

View File

@@ -0,0 +1,208 @@
/**
* Unit test for the full-bundle backup tar assembler
* (`assembleBackupTar` in `src/lib/services/backup-export.service.ts`).
*
* Phase 4a of docs/storage-migration-and-backup-plan.md: assemble a single
* tar containing `db.dump` (a pre-produced pg_dump file) + `blobs/<key>` for
* every blob-bearing row + a `manifest.json` describing the bundle with a
* sha256 per object so a restore can verify integrity.
*
* Uses an in-memory storage backend (no MinIO) and a synthetic dump file
* (no pg_dump). The produced tar is read back with the system `tar` CLI so we
* assert against the real archive bytes, not archiver internals.
*/
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { mkdtempSync, readFileSync, rmSync, writeFileSync, readdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { Readable } from 'node:stream';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { assembleBackupTar } from '@/lib/services/backup-export.service';
import type { PresignOpts, PutOpts, StorageBackend } from '@/lib/storage';
class InMemoryBackend implements StorageBackend {
readonly name = 's3' as const;
readonly store = new Map<string, { body: Buffer; contentType: string }>();
async put(
key: string,
body: Buffer | NodeJS.ReadableStream,
opts: PutOpts,
): Promise<{ key: string; sizeBytes: number; sha256: string }> {
const buffer = Buffer.isBuffer(body) ? body : await streamToBuffer(body);
this.store.set(key, { body: buffer, contentType: opts.contentType });
return {
key,
sizeBytes: buffer.length,
sha256: createHash('sha256').update(buffer).digest('hex'),
};
}
async get(key: string): Promise<NodeJS.ReadableStream> {
const r = this.store.get(key);
if (!r) throw new Error(`not found: ${key}`);
// Chunk the body so multi-chunk streaming is exercised.
const chunks: Buffer[] = [];
for (let i = 0; i < r.body.length; i += 64 * 1024) {
chunks.push(r.body.subarray(i, i + 64 * 1024));
}
return Readable.from(chunks.length ? chunks : [Buffer.alloc(0)]);
}
async head(key: string) {
const r = this.store.get(key);
if (!r) return null;
return { sizeBytes: r.body.length, contentType: r.contentType };
}
async delete(key: string): Promise<void> {
this.store.delete(key);
}
async listByPrefix(prefix: string): Promise<string[]> {
return [...this.store.keys()].filter((k) => k.startsWith(prefix));
}
async presignUpload(_key: string, _opts: PresignOpts) {
return { url: 'mem://upload', method: 'PUT' as const };
}
async presignDownload(_key: string, _opts: PresignOpts) {
return { url: 'mem://download', expiresAt: new Date(Date.now() + 1000) };
}
}
async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const c of stream) chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c as string));
return Buffer.concat(chunks);
}
function sha256(buf: Buffer): string {
return createHash('sha256').update(buf).digest('hex');
}
describe('assembleBackupTar', () => {
let workDir: string;
beforeEach(() => {
workDir = mkdtempSync(path.join(tmpdir(), 'pn-backup-test-'));
});
afterEach(() => {
rmSync(workDir, { recursive: true, force: true });
});
it('bundles the db dump + every blob with a verifiable manifest', async () => {
const backend = new InMemoryBackend();
// A small blob and a larger multi-chunk blob (exercises streaming).
const smallBody = Buffer.from('the quick brown fox');
const bigBody = Buffer.alloc(300 * 1024);
for (let i = 0; i < bigBody.length; i++) bigBody[i] = (i * 7) % 256;
await backend.put('port-nimara/files/a.bin', smallBody, { contentType: 'application/pdf' });
await backend.put('port-nimara/berths/big.pdf', bigBody, { contentType: 'application/pdf' });
// Synthetic pg_dump file.
const dumpBody = Buffer.from('PGDMP-FAKE-CUSTOM-FORMAT-CONTENTS\n'.repeat(1000));
const dumpPath = path.join(workDir, 'db.dump');
writeFileSync(dumpPath, dumpBody);
const outPath = path.join(workDir, 'bundle.tar');
const now = new Date('2026-06-04T12:00:00.000Z');
const manifest = await assembleBackupTar({
backend,
dumpFilePath: dumpPath,
blobRefs: [
{ tableName: 'files', pk: 'f1', key: 'port-nimara/files/a.bin' },
{ tableName: 'berth_pdf_versions', pk: 'b1', key: 'port-nimara/berths/big.pdf' },
],
outFilePath: outPath,
storageBackendName: 's3',
now,
});
// ── manifest shape ──────────────────────────────────────────────────────
expect(manifest.formatVersion).toBe(1);
expect(manifest.createdAt).toBe('2026-06-04T12:00:00.000Z');
expect(manifest.storageBackend).toBe('s3');
expect(manifest.database.file).toBe('db.dump');
expect(manifest.database.sizeBytes).toBe(dumpBody.length);
expect(manifest.database.sha256).toBe(sha256(dumpBody));
expect(manifest.counts.blobs).toBe(2);
expect(manifest.counts.blobBytes).toBe(smallBody.length + bigBody.length);
expect(manifest.counts.skipped).toBe(0);
const smallEntry = manifest.blobs.find((b) => b.key === 'port-nimara/files/a.bin');
expect(smallEntry).toMatchObject({
table: 'files',
pk: 'f1',
sizeBytes: smallBody.length,
sha256: sha256(smallBody),
});
// ── extract the real tar and verify bytes ────────────────────────────────
const extractDir = path.join(workDir, 'extract');
execFileSync('mkdir', ['-p', extractDir]);
execFileSync('tar', ['-xf', outPath, '-C', extractDir]);
const extractedDump = readFileSync(path.join(extractDir, 'db.dump'));
expect(extractedDump.equals(dumpBody)).toBe(true);
const extractedSmall = readFileSync(path.join(extractDir, 'blobs/port-nimara/files/a.bin'));
expect(extractedSmall.equals(smallBody)).toBe(true);
const extractedBig = readFileSync(path.join(extractDir, 'blobs/port-nimara/berths/big.pdf'));
expect(extractedBig.equals(bigBody)).toBe(true);
// The on-disk manifest matches the returned one and verifies the bytes.
const onDiskManifest = JSON.parse(readFileSync(path.join(extractDir, 'manifest.json'), 'utf8'));
expect(onDiskManifest).toEqual(manifest);
for (const entry of manifest.blobs) {
const bytes = readFileSync(path.join(extractDir, 'blobs', entry.key));
expect(sha256(bytes)).toBe(entry.sha256);
expect(bytes.length).toBe(entry.sizeBytes);
}
});
it('records referenced-but-missing blobs as skipped instead of failing', async () => {
const backend = new InMemoryBackend();
await backend.put('port-nimara/files/present.bin', Buffer.from('here'), {
contentType: 'application/octet-stream',
});
const dumpPath = path.join(workDir, 'db.dump');
writeFileSync(dumpPath, Buffer.from('dump'));
const outPath = path.join(workDir, 'bundle.tar');
const manifest = await assembleBackupTar({
backend,
dumpFilePath: dumpPath,
blobRefs: [
{ tableName: 'files', pk: 'f1', key: 'port-nimara/files/present.bin' },
{ tableName: 'files', pk: 'f2', key: 'port-nimara/files/GONE.bin' },
],
outFilePath: outPath,
storageBackendName: 's3',
now: new Date('2026-06-04T12:00:00.000Z'),
});
expect(manifest.counts.blobs).toBe(1);
expect(manifest.counts.skipped).toBe(1);
expect(manifest.skipped).toEqual([
expect.objectContaining({ table: 'files', pk: 'f2', key: 'port-nimara/files/GONE.bin' }),
]);
// The missing blob must NOT appear in the archive.
const extractDir = path.join(workDir, 'extract');
execFileSync('mkdir', ['-p', extractDir]);
execFileSync('tar', ['-xf', outPath, '-C', extractDir]);
const blobFiles = readdirSync(path.join(extractDir, 'blobs', 'port-nimara', 'files'));
expect(blobFiles).toEqual(['present.bin']);
});
});