From 5353041f976bab8e48ceae74e9d8cdc741455814 Mon Sep 17 00:00:00 2001 From: devhenryno Date: Tue, 2 Jun 2026 10:49:58 +0100 Subject: [PATCH] feat(backup): add automated daily database backup job with S3 off-site storage (#376) Schedules a daily pg_dump + gzip upload to an S3-compatible bucket, enforces a 30-day retention policy with automatic pruning, and dispatches Slack and/or email alerts within one retry cycle when a backup fails. The job is wired into the existing jobGovernance retry framework (3 attempts, exponential backoff) and registers a clean shutdown handler alongside the APY snapshot scheduler. --- backend/package-lock.json | 1002 ++++++++++++++++++++- backend/package.json | 3 +- backend/src/__tests__/dbBackupJob.test.ts | 377 ++++++++ backend/src/dbBackupJob.ts | 298 ++++++ backend/src/index.ts | 7 + backend/src/jobGovernance.ts | 8 +- 6 files changed, 1650 insertions(+), 45 deletions(-) create mode 100644 backend/src/__tests__/dbBackupJob.test.ts create mode 100644 backend/src/dbBackupJob.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 00f1e17c..eab6f19b 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@aws-sdk/client-s3": "^3.1058.0", "@opentelemetry/exporter-trace-otlp-http": "^0.218.0", "@opentelemetry/instrumentation-express": "^0.66.0", "@opentelemetry/instrumentation-http": "^0.218.0", @@ -17,7 +18,7 @@ "@opentelemetry/semantic-conventions": "^1.41.1", "@prisma/client": "^5.10.0", "@prisma/instrumentation": "^7.8.0", - "@stellar/stellar-base": "^14.0.0", + "@stellar/stellar-base": "^13.1.0", "@stellar/stellar-sdk": "^13.0.0", "cors": "^2.8.6", "decimal.js": "^10.6.0", @@ -49,6 +50,680 @@ "typescript": "^5.1.0" } }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/crc32/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-crypto/crc32c": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", + "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/crc32c/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-crypto/sha1-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", + "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/supports-web-crypto/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1058.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1058.0.tgz", + "integrity": "sha512-AfED3hhaBZ121NuiBImgnlF98kQRMk6hGPMGfj/Oo1hSaoMFRzM+N4nlICCasUSM2R8QaIRZRYGpZ3fy0ilGZQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha1-browser": "5.2.0", + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/credential-provider-node": "^3.972.48", + "@aws-sdk/middleware-bucket-endpoint": "^3.972.17", + "@aws-sdk/middleware-expect-continue": "^3.972.14", + "@aws-sdk/middleware-flexible-checksums": "^3.974.23", + "@aws-sdk/middleware-location-constraint": "^3.972.11", + "@aws-sdk/middleware-sdk-s3": "^3.972.44", + "@aws-sdk/middleware-ssec": "^3.972.11", + "@aws-sdk/signature-v4-multi-region": "^3.996.30", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/fetch-http-handler": "^5.4.5", + "@smithy/node-http-handler": "^4.7.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-sdk/core": { + "version": "3.974.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.15.tgz", + "integrity": "sha512-UpA0rTGW/tHGITcCqHisbuuEPraYg9GG+mWmXjY5+RxZBMLGe6aL9oe0ix50LztwAcPIkGZLH0yWdMIkCM10hw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.9", + "@aws-sdk/xml-builder": "^3.972.26", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.24.5", + "@smithy/signature-v4": "^5.4.5", + "@smithy/types": "^4.14.2", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-sdk/crc64-nvme": { + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.9.tgz", + "integrity": "sha512-P+QGozmXn2mZZI7sDgk+aUm+RTI61MPSFB+Ir2vjEjEbEsE4e7hYtzrDvAUxZy9ko81h53e11+F/GYlvwDkaOQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/crc64-nvme/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.41.tgz", + "integrity": "sha512-n1EbJ98yvPWWdHZZv8bRBMqqDQJrtgtxyJ4xLy2Uqrh25BCOZQ7nnS1CsFXvuH8r0b0KVHDZEGEH5FxmEMP8jg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.43.tgz", + "integrity": "sha512-TT76RN1NkI9WoyZqCNxOw6/WBMF7pYOTJcXbMokNFU+euSG40Kaf/t/FhDACVZWP+43wEM6ZynIPIkzS1wR1iA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/fetch-http-handler": "^5.4.5", + "@smithy/node-http-handler": "^4.7.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.46.tgz", + "integrity": "sha512-hvcgcwOiS0nb2XFb5Op1Pz/vYaWz5K8kKullziGpdNRuG0NwzRXseuPt2CoBqknHGaSPVesu1aOn2OcctEYdCA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/credential-provider-env": "^3.972.41", + "@aws-sdk/credential-provider-http": "^3.972.43", + "@aws-sdk/credential-provider-login": "^3.972.45", + "@aws-sdk/credential-provider-process": "^3.972.41", + "@aws-sdk/credential-provider-sso": "^3.972.45", + "@aws-sdk/credential-provider-web-identity": "^3.972.45", + "@aws-sdk/nested-clients": "^3.997.13", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/credential-provider-imds": "^4.3.6", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.45", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.45.tgz", + "integrity": "sha512-MZQv4SNjByk1iOKmrqmzcUF/uCB05wjvEHyXKxmGQTUANTIVayX6HPUF0bzkWLvtnkH7sAn9kUCfkXbSpj9sDA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/nested-clients": "^3.997.13", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.48", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.48.tgz", + "integrity": "sha512-QIbtJP0olSLZ2ImEu636pP+7JJbPfaL3xSJIFXhu472CWuondCc4bGOa8OeyhOFet8z4H1D/ZFKXc39FboWwYA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.41", + "@aws-sdk/credential-provider-http": "^3.972.43", + "@aws-sdk/credential-provider-ini": "^3.972.46", + "@aws-sdk/credential-provider-process": "^3.972.41", + "@aws-sdk/credential-provider-sso": "^3.972.45", + "@aws-sdk/credential-provider-web-identity": "^3.972.45", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/credential-provider-imds": "^4.3.6", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.41.tgz", + "integrity": "sha512-7I/n1zkysouLOWvkEhjNEP4vMnD2v4kzzr3/3QBdrripEpn7ap1/I5DF3Hou1SUqkKWo1f3oPGMyFAA1FAMvsQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.45", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.45.tgz", + "integrity": "sha512-oHgbz/eFD8IKiksqDsz9ZMU4A59BpQq4QwJedBnGD80ZqYcHPPHZBwjBnxLVkB7iRVVHWpDclR8yWdD2PkQIUA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/nested-clients": "^3.997.13", + "@aws-sdk/token-providers": "3.1056.0", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.45", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.45.tgz", + "integrity": "sha512-CDhzKdb2onv5bpnjn/acgdNmJOQthPDLsPizU7rZflsEcgMMp8Mlri+U5hdxf8ldvZJpvM3vLU6D56vfJm5AMQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/nested-clients": "^3.997.13", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-sdk/middleware-bucket-endpoint": { + "version": "3.972.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.17.tgz", + "integrity": "sha512-lbDmWuHenc+kiwCNrxz4MyN6nkxCWyTXPIWuspJN0ibziu+8CXci7vI1bK9MAkwy8cwJOEXNu0gBM5S0uTGRIg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-bucket-endpoint/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-sdk/middleware-expect-continue": { + "version": "3.972.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.14.tgz", + "integrity": "sha512-3TNFEVGO4sWZj9TEXOCZLzGEctXHnaO4fk2EQ8KVaboTbwHmEPEQrm17Xb9koImUIXEw0sgi2xtHjg7LuTS3rA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-expect-continue/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-sdk/middleware-flexible-checksums": { + "version": "3.974.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.23.tgz", + "integrity": "sha512-4nPKARo2lfKvQGUt2fPA5NlS/mEohckdxpuC9ecbjVfj7B7NFFYHeTg+Bf5BEQwdn3yRfUIzFiEkPp8Yuaw3wA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@aws-crypto/crc32c": "5.2.0", + "@aws-crypto/util": "5.2.0", + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/crc64-nvme": "^3.972.9", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-flexible-checksums/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-sdk/middleware-location-constraint": { + "version": "3.972.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.11.tgz", + "integrity": "sha512-hkfspNUP4criAH6ton6BGKgnm5dZx+7bUOy1YqlTfejDeUPAM23D81q/IX+hdlS3KUsfwGz5ADTqZWKBEUpf4A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.9", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-location-constraint/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.44.tgz", + "integrity": "sha512-8HQsRg1NpX8vR4vNl1E8pyLnqZroq9VSL2vZQVSgBqp6wv6365LzYD08/c9FFh/9FTg7YRc7aTtEmXF0ir/pqg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/signature-v4-multi-region": "^3.996.30", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-sdk/middleware-ssec": { + "version": "3.972.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.11.tgz", + "integrity": "sha512-7PQvGNhtveKlvVqNahqWx5yrwxP7ecwAoB1dYBf8eKwfo2tzzCbNnW+q2nO3N066ktQaB4iBQbDRWtizm+amoQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.9", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-ssec/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.13.tgz", + "integrity": "sha512-2pA6eyb5nSo/ZD2cayhOTEMoGQYgspq0RI05GDLkzQ3ajZ6isS6waV6E92Am/hz4LIlLUTrbwPLurJ/fuiHvkg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/signature-v4-multi-region": "^3.996.30", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/fetch-http-handler": "^5.4.5", + "@smithy/node-http-handler": "^4.7.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.30", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.30.tgz", + "integrity": "sha512-HULDLMVzkmTSEv6//7kx2kRevp/VYUpm8hJNNFbmhxDn0fUiGTxVcM9yg31TukvTq8nyOBDUN2gH0o5IRbKjdw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.9", + "@smithy/signature-v4": "^5.4.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1056.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1056.0.tgz", + "integrity": "sha512-81duvlltQlsfn5K+o8zILcystBRdbT1G2JJYVCML5NZHBz4CL/zf+sAemCtBh/uh6RQUMyInGeZLQ7/8igZhbA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/nested-clients": "^3.997.13", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.9.tgz", + "integrity": "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.26", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.26.tgz", + "integrity": "sha512-cDbrqvDS73whl6YAPSPq0U6whzG6UWI9PuWh0wrUuGoZexhWEqhdunbukV7iBoaWnFV1AODutM5hOD6rtn439g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.2", + "fast-xml-parser": "5.7.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -1613,25 +2288,11 @@ "url": "https://opencollective.com/js-sdsl" } }, - "node_modules/@noble/curves": { - "version": "1.9.7", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", - "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.8.0" - }, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, "license": "MIT", "engines": { "node": "^14.21.3 || >=16" @@ -1640,6 +2301,18 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@nodable/entities": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.1.tgz", + "integrity": "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2447,6 +3120,180 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@smithy/core": { + "version": "3.24.6", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.6.tgz", + "integrity": "sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.7.tgz", + "integrity": "sha512-xj8gq/bjFABAh6qWPSDCYcY3kzQIm4b561C+YnHH4zGq8rOgzQ3Shk+JGlpUxSd41UGiO6FkLdUCtNX1FAeHgg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.4.6", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.6.tgz", + "integrity": "sha512-FEwEYJ1jlBKdhe9TPzfghEi1bP55ZeEImlDkEa62bBBYzUcnB6RUCyuiS2mqKt6ZVjUbBgcNhzfIctH+Hevx9g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/is-array-buffer/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.7.6", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.6.tgz", + "integrity": "sha512-3fya8i7GrJilQouk4cZJKdy5k8MWQBpjfXrRNaXDedH8r779tr0jcxyH3+yoTmsluc2+vF4S343yFbnvu8ExDQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@smithy/signature-v4": { + "version": "5.4.6", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.6.tgz", + "integrity": "sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@smithy/types": { + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.3.tgz", + "integrity": "sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-buffer-from/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-utf8/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/@stellar/js-xdr": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-3.1.2.tgz", @@ -2454,20 +3301,23 @@ "license": "Apache-2.0" }, "node_modules/@stellar/stellar-base": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@stellar/stellar-base/-/stellar-base-14.1.0.tgz", - "integrity": "sha512-A8kFli6QGy22SRF45IjgPAJfUNGjnI+R7g4DF5NZYVsD1kGf7B4ITyc4OPclLV9tqNI4/lXxafGEw0JEUbHixw==", + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/@stellar/stellar-base/-/stellar-base-13.1.0.tgz", + "integrity": "sha512-90EArG+eCCEzDGj3OJNoCtwpWDwxjv+rs/RNPhvg4bulpjN/CSRj+Ys/SalRcfM4/WRC5/qAfjzmJBAuquWhkA==", "license": "Apache-2.0", "dependencies": { - "@noble/curves": "^1.9.6", "@stellar/js-xdr": "^3.1.2", "base32.js": "^0.1.0", - "bignumber.js": "^9.3.1", + "bignumber.js": "^9.1.2", "buffer": "^6.0.3", - "sha.js": "^2.4.12" + "sha.js": "^2.3.6", + "tweetnacl": "^1.0.3" }, "engines": { - "node": ">=20.0.0" + "node": ">=18.0.0" + }, + "optionalDependencies": { + "sodium-native": "^4.3.3" } }, "node_modules/@stellar/stellar-sdk": { @@ -2489,26 +3339,6 @@ "node": ">=18.0.0" } }, - "node_modules/@stellar/stellar-sdk/node_modules/@stellar/stellar-base": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/@stellar/stellar-base/-/stellar-base-13.1.0.tgz", - "integrity": "sha512-90EArG+eCCEzDGj3OJNoCtwpWDwxjv+rs/RNPhvg4bulpjN/CSRj+Ys/SalRcfM4/WRC5/qAfjzmJBAuquWhkA==", - "license": "Apache-2.0", - "dependencies": { - "@stellar/js-xdr": "^3.1.2", - "base32.js": "^0.1.0", - "bignumber.js": "^9.1.2", - "buffer": "^6.0.3", - "sha.js": "^2.3.6", - "tweetnacl": "^1.0.3" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "sodium-native": "^4.3.3" - } - }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3481,6 +4311,12 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", @@ -4745,6 +5581,43 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.7", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -4974,6 +5847,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -6842,6 +7716,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -7826,6 +8715,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/superagent": { "version": "8.1.2", "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.1.2.tgz", @@ -8342,6 +9243,21 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/backend/package.json b/backend/package.json index b176e82f..9bb8128e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -25,7 +25,7 @@ "author": "", "license": "MIT", "dependencies": { - "@stellar/stellar-base": "^13.1.0", + "@aws-sdk/client-s3": "^3.1058.0", "@opentelemetry/exporter-trace-otlp-http": "^0.218.0", "@opentelemetry/instrumentation-express": "^0.66.0", "@opentelemetry/instrumentation-http": "^0.218.0", @@ -34,6 +34,7 @@ "@opentelemetry/semantic-conventions": "^1.41.1", "@prisma/client": "^5.10.0", "@prisma/instrumentation": "^7.8.0", + "@stellar/stellar-base": "^13.1.0", "@stellar/stellar-sdk": "^13.0.0", "cors": "^2.8.6", "decimal.js": "^10.6.0", diff --git a/backend/src/__tests__/dbBackupJob.test.ts b/backend/src/__tests__/dbBackupJob.test.ts new file mode 100644 index 00000000..57697d29 --- /dev/null +++ b/backend/src/__tests__/dbBackupJob.test.ts @@ -0,0 +1,377 @@ +/** + * Tests for the daily database backup job (Issue #376). + */ + +import { EventEmitter, PassThrough } from 'stream'; + +// ─── Mock child_process ─────────────────────────────────────────────────────── + +const mockSpawn = jest.fn(); +jest.mock('child_process', () => ({ spawn: mockSpawn })); + +// ─── Mock @aws-sdk/client-s3 ───────────────────────────────────────────────── + +const mockS3Send = jest.fn(); +jest.mock('@aws-sdk/client-s3', () => { + return { + S3Client: jest.fn().mockImplementation(() => ({ send: mockS3Send })), + PutObjectCommand: jest.fn().mockImplementation((input) => ({ input })), + ListObjectsV2Command: jest.fn().mockImplementation((input) => ({ input })), + DeleteObjectCommand: jest.fn().mockImplementation((input) => ({ input })), + }; +}); + +// ─── Mock emailService ──────────────────────────────────────────────────────── + +const mockSendEmail = jest.fn().mockResolvedValue(true); +jest.mock('../emailService', () => ({ + emailService: { sendEmail: mockSendEmail }, +})); + +// ─── Mock logger ───────────────────────────────────────────────────────────── + +jest.mock('../middleware/structuredLogging', () => ({ + logger: { log: jest.fn() }, +})); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** Builds a fake child_process object that writes `data` to stdout then exits. */ +function makeFakeChild(data: string, exitCode: number = 0) { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const child = new EventEmitter() as any; + child.stdout = stdout; + child.stderr = stderr; + + setImmediate(() => { + stdout.write(Buffer.from(data)); + stdout.end(); + child.emit('close', exitCode); + }); + + return child; +} + +// ─── Import SUT after mocks are in place ───────────────────────────────────── + +import { + dumpDatabase, + uploadBackup, + pruneOldBackups, + sendBackupFailureAlert, + runDbBackupJob, + startDbBackupScheduler, +} from '../dbBackupJob'; + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +beforeEach(() => { + jest.clearAllMocks(); + + process.env.DATABASE_URL = 'postgresql://user:pass@localhost:5432/yieldvault'; + process.env.BACKUP_S3_BUCKET = 'yieldvault-backups'; + process.env.BACKUP_S3_REGION = 'us-east-1'; + delete process.env.BACKUP_S3_ENDPOINT; + delete process.env.AWS_ACCESS_KEY_ID; + delete process.env.BACKUP_SLACK_WEBHOOK_URL; + delete process.env.BACKUP_ALERT_EMAIL; + delete process.env.BACKUP_RETENTION_DAYS; + delete process.env.BACKUP_ENABLED; +}); + +// ─── dumpDatabase() ─────────────────────────────────────────────────────────── + +describe('dumpDatabase()', () => { + it('returns a gzip-compressed Buffer when pg_dump succeeds', async () => { + mockSpawn.mockReturnValue(makeFakeChild('-- SQL dump content --')); + const result = await dumpDatabase(); + expect(Buffer.isBuffer(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + }); + + it('passes --dbname and DATABASE_URL to pg_dump', async () => { + mockSpawn.mockReturnValue(makeFakeChild('')); + await dumpDatabase(); + expect(mockSpawn).toHaveBeenCalledWith( + 'pg_dump', + ['--dbname', 'postgresql://user:pass@localhost:5432/yieldvault'], + expect.objectContaining({ stdio: ['ignore', 'pipe', 'pipe'] }), + ); + }); + + it('rejects when pg_dump exits with non-zero code', async () => { + mockSpawn.mockReturnValue(makeFakeChild('', 1)); + await expect(dumpDatabase()).rejects.toThrow('pg_dump exited with code 1'); + }); + + it('rejects when DATABASE_URL is not set', async () => { + delete process.env.DATABASE_URL; + await expect(dumpDatabase()).rejects.toThrow('DATABASE_URL is not set'); + }); + + it('rejects when the child process emits an error event', async () => { + const child = makeFakeChild(''); + mockSpawn.mockReturnValue(child); + setImmediate(() => child.emit('error', new Error('spawn ENOENT'))); + await expect(dumpDatabase()).rejects.toThrow('spawn ENOENT'); + }); +}); + +// ─── uploadBackup() ─────────────────────────────────────────────────────────── + +describe('uploadBackup()', () => { + it('calls S3Client.send with PutObjectCommand', async () => { + mockS3Send.mockResolvedValue({}); + const body = Buffer.from('compressed-data'); + await uploadBackup('backups/2025-01-01/2025-01-01T02-00-00-000Z.sql.gz', body); + expect(mockS3Send).toHaveBeenCalledTimes(1); + const [cmd] = mockS3Send.mock.calls[0]; + expect(cmd.input.Bucket).toBe('yieldvault-backups'); + expect(cmd.input.Key).toBe('backups/2025-01-01/2025-01-01T02-00-00-000Z.sql.gz'); + expect(cmd.input.Body).toBe(body); + expect(cmd.input.ContentType).toBe('application/gzip'); + }); + + it('rejects when BACKUP_S3_BUCKET is not set', async () => { + delete process.env.BACKUP_S3_BUCKET; + await expect(uploadBackup('key', Buffer.from(''))).rejects.toThrow('BACKUP_S3_BUCKET is not set'); + }); + + it('propagates S3 send errors', async () => { + mockS3Send.mockRejectedValue(new Error('NoSuchBucket')); + await expect(uploadBackup('key', Buffer.from('data'))).rejects.toThrow('NoSuchBucket'); + }); +}); + +// ─── pruneOldBackups() ──────────────────────────────────────────────────────── + +describe('pruneOldBackups()', () => { + it('deletes objects older than retention days', async () => { + const oldDate = new Date(); + oldDate.setUTCDate(oldDate.getUTCDate() - 31); + + const recentDate = new Date(); + recentDate.setUTCDate(recentDate.getUTCDate() - 5); + + mockS3Send.mockImplementation((cmd: any) => { + if (cmd.input.ContinuationToken !== undefined || cmd.input.Prefix) { + return Promise.resolve({ + Contents: [ + { Key: 'backups/old.sql.gz', LastModified: oldDate }, + { Key: 'backups/recent.sql.gz', LastModified: recentDate }, + ], + NextContinuationToken: undefined, + }); + } + return Promise.resolve({}); + }); + + const deleted = await pruneOldBackups(); + expect(deleted).toBe(1); + + const deleteCalls = mockS3Send.mock.calls.filter( + ([cmd]: any[]) => cmd.input.Key !== undefined && cmd.input.Prefix === undefined, + ); + expect(deleteCalls).toHaveLength(1); + expect(deleteCalls[0][0].input.Key).toBe('backups/old.sql.gz'); + }); + + it('respects custom BACKUP_RETENTION_DAYS', async () => { + process.env.BACKUP_RETENTION_DAYS = '7'; + + const eightDaysAgo = new Date(); + eightDaysAgo.setUTCDate(eightDaysAgo.getUTCDate() - 8); + + const sixDaysAgo = new Date(); + sixDaysAgo.setUTCDate(sixDaysAgo.getUTCDate() - 6); + + mockS3Send.mockImplementation((cmd: any) => { + if (cmd.input.Prefix !== undefined) { + return Promise.resolve({ + Contents: [ + { Key: 'backups/old.sql.gz', LastModified: eightDaysAgo }, + { Key: 'backups/recent.sql.gz', LastModified: sixDaysAgo }, + ], + }); + } + return Promise.resolve({}); + }); + + const deleted = await pruneOldBackups(); + expect(deleted).toBe(1); + }); + + it('returns 0 when there are no objects to prune', async () => { + mockS3Send.mockResolvedValue({ Contents: [], NextContinuationToken: undefined }); + const deleted = await pruneOldBackups(); + expect(deleted).toBe(0); + }); + + it('handles S3 pagination via NextContinuationToken', async () => { + const oldDate = new Date(); + oldDate.setUTCDate(oldDate.getUTCDate() - 35); + + let calls = 0; + mockS3Send.mockImplementation((cmd: any) => { + if (cmd.input.Prefix !== undefined) { + calls += 1; + if (calls === 1) { + return Promise.resolve({ + Contents: [{ Key: 'backups/old-1.sql.gz', LastModified: oldDate }], + NextContinuationToken: 'page2', + }); + } + return Promise.resolve({ + Contents: [{ Key: 'backups/old-2.sql.gz', LastModified: oldDate }], + NextContinuationToken: undefined, + }); + } + return Promise.resolve({}); + }); + + const deleted = await pruneOldBackups(); + expect(deleted).toBe(2); + }); + + it('rejects when BACKUP_S3_BUCKET is not set', async () => { + delete process.env.BACKUP_S3_BUCKET; + await expect(pruneOldBackups()).rejects.toThrow('BACKUP_S3_BUCKET is not set'); + }); +}); + +// ─── sendBackupFailureAlert() ───────────────────────────────────────────────── + +describe('sendBackupFailureAlert()', () => { + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = jest.fn(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it('POSTs to BACKUP_SLACK_WEBHOOK_URL when set', async () => { + process.env.BACKUP_SLACK_WEBHOOK_URL = 'https://hooks.slack.com/test'; + (global.fetch as jest.Mock).mockResolvedValue({ ok: true }); + + await sendBackupFailureAlert(new Error('disk full')); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://hooks.slack.com/test', + expect.objectContaining({ method: 'POST' }), + ); + const body = JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body); + expect(body.text).toContain('disk full'); + }); + + it('sends an email when BACKUP_ALERT_EMAIL is set', async () => { + process.env.BACKUP_ALERT_EMAIL = 'ops@yieldvault.finance'; + + await sendBackupFailureAlert(new Error('upload timeout')); + + expect(mockSendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'ops@yieldvault.finance', + subject: expect.stringContaining('Backup Failed'), + }), + ); + expect(mockSendEmail.mock.calls[0][0].text).toContain('upload timeout'); + }); + + it('sends both Slack and email when both are configured', async () => { + process.env.BACKUP_SLACK_WEBHOOK_URL = 'https://hooks.slack.com/test'; + process.env.BACKUP_ALERT_EMAIL = 'ops@yieldvault.finance'; + (global.fetch as jest.Mock).mockResolvedValue({ ok: true }); + + await sendBackupFailureAlert(new Error('s3 error')); + + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(mockSendEmail).toHaveBeenCalledTimes(1); + }); + + it('does not throw when neither Slack nor email is configured', async () => { + await expect(sendBackupFailureAlert(new Error('fail'))).resolves.not.toThrow(); + }); + + it('does not throw when the Slack POST fails', async () => { + process.env.BACKUP_SLACK_WEBHOOK_URL = 'https://hooks.slack.com/test'; + (global.fetch as jest.Mock).mockRejectedValue(new Error('network error')); + + await expect(sendBackupFailureAlert(new Error('backup failed'))).resolves.not.toThrow(); + }); +}); + +// ─── runDbBackupJob() ───────────────────────────────────────────────────────── + +describe('runDbBackupJob()', () => { + it('returns a BackupResult with key, sizeBytes, and deletedCount', async () => { + mockSpawn.mockReturnValue(makeFakeChild('-- SQL content --')); + mockS3Send.mockResolvedValue({ Contents: [], NextContinuationToken: undefined }); + + const result = await runDbBackupJob(); + + expect(result).toMatchObject({ + key: expect.stringMatching(/^backups\/\d{4}-\d{2}-\d{2}\/.+\.sql\.gz$/), + sizeBytes: expect.any(Number), + deletedCount: 0, + }); + expect(result.sizeBytes).toBeGreaterThan(0); + }); + + it('uploads to S3 and reports deleted count', async () => { + const oldDate = new Date(); + oldDate.setUTCDate(oldDate.getUTCDate() - 35); + + mockSpawn.mockReturnValue(makeFakeChild('SELECT 1;')); + mockS3Send.mockImplementation((cmd: any) => { + if (cmd.input.Prefix !== undefined) { + return Promise.resolve({ + Contents: [{ Key: 'backups/very-old.sql.gz', LastModified: oldDate }], + }); + } + return Promise.resolve({}); + }); + + const result = await runDbBackupJob(); + expect(result.deletedCount).toBe(1); + + // PutObject + ListObjectsV2 + DeleteObject = 3 calls + expect(mockS3Send).toHaveBeenCalledTimes(3); + }); + + it('throws when pg_dump fails', async () => { + mockSpawn.mockReturnValue(makeFakeChild('', 1)); + await expect(runDbBackupJob()).rejects.toThrow('pg_dump exited with code 1'); + }); + + it('includes date-stamped path in the S3 key', async () => { + mockSpawn.mockReturnValue(makeFakeChild('data')); + mockS3Send.mockResolvedValue({ Contents: [] }); + + const result = await runDbBackupJob(); + const today = new Date().toISOString().slice(0, 10); + expect(result.key).toContain(today); + expect(result.key).toMatch(/\.sql\.gz$/); + }); +}); + +// ─── startDbBackupScheduler() ──────────────────────────────────────────────── + +describe('startDbBackupScheduler()', () => { + it('returns a cancel function that clears the timer', () => { + const stop = startDbBackupScheduler(); + expect(typeof stop).toBe('function'); + stop(); + }); + + it('returns a no-op when BACKUP_ENABLED=false', () => { + process.env.BACKUP_ENABLED = 'false'; + const stop = startDbBackupScheduler(); + expect(typeof stop).toBe('function'); + // Calling stop() on a disabled scheduler should not throw. + expect(() => stop()).not.toThrow(); + }); +}); diff --git a/backend/src/dbBackupJob.ts b/backend/src/dbBackupJob.ts new file mode 100644 index 00000000..cdceb536 --- /dev/null +++ b/backend/src/dbBackupJob.ts @@ -0,0 +1,298 @@ +/** + * @file dbBackupJob.ts + * Daily database backup job with S3-compatible off-site storage (Issue #376). + * + * Schedules a daily backup that: + * 1. Runs pg_dump and gzip-compresses the output in memory. + * 2. Uploads the compressed dump to an S3-compatible bucket. + * 3. Prunes backup objects older than BACKUP_RETENTION_DAYS (default 30). + * 4. Sends a Slack and/or email alert if the job fails after all retries. + * + * Environment variables: + * BACKUP_ENABLED – set to 'false' to disable (default: true) + * BACKUP_S3_BUCKET – required: destination bucket name + * BACKUP_S3_PREFIX – key prefix inside the bucket (default: 'backups/') + * BACKUP_S3_REGION – AWS/S3-compatible region (default: 'us-east-1') + * BACKUP_S3_ENDPOINT – custom endpoint URL for S3-compatible stores (MinIO, R2, B2…) + * AWS_ACCESS_KEY_ID – S3 access key + * AWS_SECRET_ACCESS_KEY – S3 secret key + * BACKUP_RETENTION_DAYS – days to keep backups (default: 30) + * BACKUP_SCHEDULE_HOUR_UTC – UTC hour to run daily backup (default: 2) + * BACKUP_SLACK_WEBHOOK_URL – Slack incoming webhook for failure alerts + * BACKUP_ALERT_EMAIL – email address for failure alerts + * DATABASE_URL – Postgres connection string (already required by Prisma) + */ + +import { spawn } from 'child_process'; +import { createGzip } from 'zlib'; +import { + S3Client, + PutObjectCommand, + ListObjectsV2Command, + DeleteObjectCommand, +} from '@aws-sdk/client-s3'; +import { emailService } from './emailService'; +import { logger } from './middleware/structuredLogging'; +import { runJobWithRetry } from './jobGovernance'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface BackupResult { + key: string; + sizeBytes: number; + deletedCount: number; +} + +// ─── Config helpers ─────────────────────────────────────────────────────────── + +function getRetentionDays(): number { + return parseInt(process.env.BACKUP_RETENTION_DAYS || '30', 10); +} + +function getS3Prefix(): string { + return process.env.BACKUP_S3_PREFIX || 'backups/'; +} + +export function buildS3Client(): S3Client { + const endpoint = process.env.BACKUP_S3_ENDPOINT; + return new S3Client({ + region: process.env.BACKUP_S3_REGION || 'us-east-1', + ...(endpoint ? { endpoint } : {}), + ...(process.env.AWS_ACCESS_KEY_ID + ? { + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '', + }, + } + : {}), + // Path-style addressing is required for most self-hosted S3-compatible stores. + forcePathStyle: !!endpoint, + }); +} + +// ─── pg_dump + gzip ────────────────────────────────────────────────────────── + +export async function dumpDatabase(): Promise { + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) throw new Error('DATABASE_URL is not set'); + + return new Promise((resolve, reject) => { + let settled = false; + const fail = (err: Error) => { + if (!settled) { + settled = true; + reject(err); + } + }; + + const child = spawn('pg_dump', ['--dbname', databaseUrl], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + + const gzip = createGzip(); + const chunks: Buffer[] = []; + + child.stdout.pipe(gzip); + gzip.on('data', (chunk: Buffer) => chunks.push(chunk)); + gzip.on('end', () => { + if (!settled) { + settled = true; + resolve(Buffer.concat(chunks)); + } + }); + gzip.on('error', fail); + + child.on('error', fail); + child.on('close', (code) => { + if (code !== 0) { + fail(new Error(`pg_dump exited with code ${code}`)); + } + }); + + child.stderr.on('data', (data: Buffer) => { + const msg = data.toString().trim(); + if (msg) logger.log('warn', 'pg_dump stderr output', { message: msg }); + }); + }); +} + +// ─── S3 upload ──────────────────────────────────────────────────────────────── + +export async function uploadBackup(key: string, body: Buffer): Promise { + const bucket = process.env.BACKUP_S3_BUCKET; + if (!bucket) throw new Error('BACKUP_S3_BUCKET is not set'); + + const client = buildS3Client(); + await client.send( + new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: body, + ContentType: 'application/gzip', + ContentLength: body.length, + Metadata: { + createdAt: new Date().toISOString(), + }, + }), + ); +} + +// ─── Retention pruning ─────────────────────────────────────────────────────── + +export async function pruneOldBackups(): Promise { + const bucket = process.env.BACKUP_S3_BUCKET; + if (!bucket) throw new Error('BACKUP_S3_BUCKET is not set'); + + const retentionDays = getRetentionDays(); + const cutoff = new Date(); + cutoff.setUTCDate(cutoff.getUTCDate() - retentionDays); + + const client = buildS3Client(); + let deleted = 0; + let continuationToken: string | undefined; + + do { + const listResp = await client.send( + new ListObjectsV2Command({ + Bucket: bucket, + Prefix: getS3Prefix(), + ...(continuationToken ? { ContinuationToken: continuationToken } : {}), + }), + ); + + for (const obj of listResp.Contents ?? []) { + if (obj.LastModified && obj.Key && obj.LastModified < cutoff) { + await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: obj.Key })); + deleted += 1; + logger.log('info', 'Pruned old backup', { key: obj.Key, lastModified: obj.LastModified }); + } + } + + continuationToken = listResp.NextContinuationToken; + } while (continuationToken); + + return deleted; +} + +// ─── Failure alerts ────────────────────────────────────────────────────────── + +export async function sendBackupFailureAlert(error: Error): Promise { + const ts = new Date().toISOString(); + const message = `Database backup failed at ${ts}: ${error.message}`; + + logger.log('error', 'Database backup failure alert dispatched', { + error: error.message, + timestamp: ts, + }); + + const slackUrl = process.env.BACKUP_SLACK_WEBHOOK_URL; + if (slackUrl) { + try { + const resp = await fetch(slackUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text: `:rotating_light: *YieldVault DB Backup Failed*\n>${message}`, + }), + }); + if (!resp.ok) { + logger.log('warn', 'Slack backup alert returned non-OK status', { status: resp.status }); + } + } catch (err) { + logger.log('error', 'Failed to POST Slack backup alert', { + error: err instanceof Error ? err.message : String(err), + }); + } + } + + const alertEmail = process.env.BACKUP_ALERT_EMAIL; + if (alertEmail) { + await emailService.sendEmail({ + to: alertEmail, + subject: 'YieldVault: Database Backup Failed', + text: message, + html: `

Database backup failed

${message}

`, + }); + } +} + +// ─── Core job ──────────────────────────────────────────────────────────────── + +export async function runDbBackupJob(): Promise { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const date = new Date().toISOString().slice(0, 10); + const key = `${getS3Prefix()}${date}/${timestamp}.sql.gz`; + + logger.log('info', 'Database backup job started', { key }); + + const body = await dumpDatabase(); + await uploadBackup(key, body); + + logger.log('info', 'Database backup uploaded', { key, sizeBytes: body.length }); + + const deletedCount = await pruneOldBackups(); + + logger.log('info', 'Database backup job completed', { + key, + sizeBytes: body.length, + deletedCount, + }); + + return { key, sizeBytes: body.length, deletedCount }; +} + +// ─── Scheduler ─────────────────────────────────────────────────────────────── + +function msUntilNextBackupUtc(): number { + const scheduleHour = parseInt(process.env.BACKUP_SCHEDULE_HOUR_UTC || '2', 10); + const now = new Date(); + const next = new Date(); + next.setUTCHours(scheduleHour, 0, 0, 0); + if (next.getTime() <= now.getTime()) { + next.setUTCDate(next.getUTCDate() + 1); + } + return next.getTime() - now.getTime(); +} + +let schedulerTimer: ReturnType | null = null; + +export function startDbBackupScheduler(): () => void { + const enabled = process.env.BACKUP_ENABLED !== 'false'; + if (!enabled) { + logger.log('info', 'DB backup scheduler disabled via BACKUP_ENABLED=false'); + return () => {}; + } + + const schedule = async () => { + try { + await runJobWithRetry('databaseBackup', () => runDbBackupJob()); + } catch (err) { + await sendBackupFailureAlert( + err instanceof Error ? err : new Error(String(err)), + ); + } finally { + const delay = msUntilNextBackupUtc(); + logger.log('info', 'DB backup next run scheduled', { + inMs: delay, + nextRun: new Date(Date.now() + delay).toISOString(), + }); + schedulerTimer = setTimeout(schedule, delay); + } + }; + + const initialDelay = msUntilNextBackupUtc(); + logger.log('info', 'DB backup scheduler started', { + firstRunIn: initialDelay, + nextRun: new Date(Date.now() + initialDelay).toISOString(), + }); + schedulerTimer = setTimeout(schedule, initialDelay); + + return () => { + if (schedulerTimer) { + clearTimeout(schedulerTimer); + schedulerTimer = null; + logger.log('info', 'DB backup scheduler stopped'); + } + }; +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 51c28236..fba47e0a 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -30,6 +30,7 @@ import { } from './impersonationSessionService'; import { generateAdminReceipt, getAdminReceipt, listAdminReceipts, verifyReceiptSignature } from './adminReceipt'; import { startApySnapshotScheduler } from './apySnapshot'; +import { startDbBackupScheduler } from './dbBackupJob'; import { sorobanCircuitBreaker } from './circuitBreaker'; import { correlationIdMiddleware, CorrelationIdRequest } from './middleware/correlationId'; import { structuredLoggingMiddleware, logger, LogLevel } from './middleware/structuredLogging'; @@ -3010,6 +3011,12 @@ if (process.env.NODE_ENV !== 'test') { stopApyScheduler(); }); + // ─── Database Backup Scheduler (Issue #376) ────────────────────────────────── + const stopDbBackupScheduler = startDbBackupScheduler(); + shutdownHandler.onShutdown(async () => { + stopDbBackupScheduler(); + }); + // Register event polling service shutdown shutdownHandler.onShutdown(async () => { stopEventPollingService(); diff --git a/backend/src/jobGovernance.ts b/backend/src/jobGovernance.ts index d7132e6b..3cc07b58 100644 --- a/backend/src/jobGovernance.ts +++ b/backend/src/jobGovernance.ts @@ -1,4 +1,4 @@ -export type JobName = 'priceRefresh' | 'positionReconciliation' | 'reportGeneration'; +export type JobName = 'priceRefresh' | 'positionReconciliation' | 'reportGeneration' | 'databaseBackup'; export interface JobPolicy { maxAttempts: number; @@ -46,6 +46,12 @@ export const JOB_POLICIES: Record = { backoffMultiplier: 2, deadLetterThreshold: 2, }, + databaseBackup: { + maxAttempts: 3, + baseDelayMs: 10000, + backoffMultiplier: 2, + deadLetterThreshold: 2, + }, }; class JobGovernanceStore {