diff --git a/backend/package-lock.json b/backend/package-lock.json index 07641106..adab834d 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -11,6 +11,7 @@ "license": "SEE LICENSE IN LICENSE", "dependencies": { "@aws-sdk/client-ecr": "^3.1019.0", + "@aws-sdk/client-s3": "^3.1037.0", "@types/compression": "^1.8.1", "@types/cors": "^2.8.19", "@types/dockerode": "^4.0.1", @@ -41,6 +42,7 @@ "otplib": "^12.0.1", "semver": "^7.7.4", "systeminformation": "^5.31.1", + "tar-stream": "^3.1.8", "ws": "^8.19.0", "yaml": "^2.8.2", "zod": "^4.3.6" @@ -55,6 +57,7 @@ "@types/multer": "^2.1.0", "@types/node": "^25.3.0", "@types/supertest": "^7.2.0", + "@types/tar-stream": "^3.1.4", "@types/yaml": "^1.9.6", "eslint": "^10.1.0", "nodemon": "^3.1.13", @@ -65,6 +68,83 @@ "vitest": "^4.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/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/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/@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/@aws-crypto/sha1-browser/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/@aws-crypto/sha1-browser/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/@aws-crypto/sha256-browser": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", @@ -241,23 +321,90 @@ "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/core": { - "version": "3.973.27", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.27.tgz", - "integrity": "sha512-CUZ5m8hwMCH6OYI4Li/WgMfIEx10Q2PLI9Y3XOUTPGZJ53aZ0007jCv+X/ywsaERyKPdw5MRZWk877roQksQ4A==", + "node_modules/@aws-sdk/client-s3": { + "version": "3.1037.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1037.0.tgz", + "integrity": "sha512-DBmA1jAW8ST6C4srBxeL1/RLIir/d8WOm4s4mi59mGp6mBktHM59Kwb7GuURaCO60cotuce5zr0sKpMLPcBQyA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/xml-builder": "^3.972.17", - "@smithy/core": "^3.23.14", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/property-provider": "^4.2.13", - "@smithy/protocol-http": "^5.3.13", - "@smithy/signature-v4": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", + "@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.5", + "@aws-sdk/credential-provider-node": "^3.972.36", + "@aws-sdk/middleware-bucket-endpoint": "^3.972.10", + "@aws-sdk/middleware-expect-continue": "^3.972.10", + "@aws-sdk/middleware-flexible-checksums": "^3.974.13", + "@aws-sdk/middleware-host-header": "^3.972.10", + "@aws-sdk/middleware-location-constraint": "^3.972.10", + "@aws-sdk/middleware-logger": "^3.972.10", + "@aws-sdk/middleware-recursion-detection": "^3.972.11", + "@aws-sdk/middleware-sdk-s3": "^3.972.34", + "@aws-sdk/middleware-ssec": "^3.972.10", + "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/region-config-resolver": "^3.972.13", + "@aws-sdk/signature-v4-multi-region": "^3.996.22", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-endpoints": "^3.996.8", + "@aws-sdk/util-user-agent-browser": "^3.972.10", + "@aws-sdk/util-user-agent-node": "^3.973.21", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", + "@smithy/eventstream-serde-browser": "^4.2.14", + "@smithy/eventstream-serde-config-resolver": "^4.3.14", + "@smithy/eventstream-serde-node": "^4.2.14", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/hash-blob-browser": "^4.2.15", + "@smithy/hash-node": "^4.2.14", + "@smithy/hash-stream-node": "^4.2.14", + "@smithy/invalid-dependency": "^4.2.14", + "@smithy/md5-js": "^4.2.14", + "@smithy/middleware-content-length": "^4.2.14", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-serde": "^4.2.20", + "@smithy/middleware-stack": "^4.2.14", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", - "@smithy/util-middleware": "^4.2.13", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.4", + "@smithy/util-stream": "^4.5.25", + "@smithy/util-utf8": "^4.2.2", + "@smithy/util-waiter": "^4.2.16", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.974.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.5.tgz", + "integrity": "sha512-lMPlYlYfQdNZhlkJgnkmESwrY+hNh3PljmZ+37oAqLNdJ6rnILAwFSyc6B3bJeDOtMORNnMQIej0aTRuOlDyhQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/xml-builder": "^3.972.19", + "@smithy/core": "^3.23.17", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/signature-v4": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -265,16 +412,29 @@ "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.25", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.25.tgz", - "integrity": "sha512-6QfI0wv4jpG5CrdO/AO0JfZ2ux+tKwJPrUwmvxXF50vI5KIypKVGNF6b4vlkYEnKumDTI1NX2zUBi8JoU5QU3A==", + "node_modules/@aws-sdk/crc64-nvme": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.7.tgz", + "integrity": "sha512-QUagVVBbC8gODCF6e1aV0mE2TXWB9Opz4k8EJFdNrujUVQm5R4AjJa1mpOqzwOuROBzqJU9zawzig7M96L8Ejg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/types": "^3.973.7", - "@smithy/property-provider": "^4.2.13", - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.31", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.31.tgz", + "integrity": "sha512-X/yGB73LmDW/6MdDJGCDzZBUXnM3ys4vs9l+5ZTJmiEswDdP1OjeoAFlFjVGS9o4KB2wZWQ9KOfdVNSSK6Ep3w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -282,20 +442,20 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.27", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.27.tgz", - "integrity": "sha512-3V3Usj9Gs93h865DqN4M2NWJhC5kXU9BvZskfN3+69omuYlE3TZxOEcVQtBGLOloJB7BVfJKXVLqeNhOzHqSlQ==", + "version": "3.972.33", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.33.tgz", + "integrity": "sha512-c0ZF+lwoWVvX5iCaGKL5T/4DnIw88CGqxA0BcBs3U86mIp5EZYPVg+KSPkMXOyokmADvNewiMUfSG2uFwjRp0g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/types": "^3.973.7", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/property-provider": "^4.2.13", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/util-stream": "^4.5.22", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/types": "^3.973.8", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/property-provider": "^4.2.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/util-stream": "^4.5.25", "tslib": "^2.6.2" }, "engines": { @@ -303,24 +463,24 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.29", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.29.tgz", - "integrity": "sha512-SiBuAnXecCbT/OpAf3vqyI/AVE3mTaYr9ShXLybxZiPLBiPCCOIWSGAtYYGQWMRvobBTiqOewaB+wcgMMZI2Aw==", + "version": "3.972.35", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.35.tgz", + "integrity": "sha512-jsU4u/cRkKFLKQS0k918FQ27fzXLG5ENiLWQMYE6581zLeI2hWh04ptlrvZMB3wJT/5d+vSzJk74X1CMFr4y8Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/credential-provider-env": "^3.972.25", - "@aws-sdk/credential-provider-http": "^3.972.27", - "@aws-sdk/credential-provider-login": "^3.972.29", - "@aws-sdk/credential-provider-process": "^3.972.25", - "@aws-sdk/credential-provider-sso": "^3.972.29", - "@aws-sdk/credential-provider-web-identity": "^3.972.29", - "@aws-sdk/nested-clients": "^3.996.19", - "@aws-sdk/types": "^3.973.7", - "@smithy/credential-provider-imds": "^4.2.13", - "@smithy/property-provider": "^4.2.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-env": "^3.972.31", + "@aws-sdk/credential-provider-http": "^3.972.33", + "@aws-sdk/credential-provider-login": "^3.972.35", + "@aws-sdk/credential-provider-process": "^3.972.31", + "@aws-sdk/credential-provider-sso": "^3.972.35", + "@aws-sdk/credential-provider-web-identity": "^3.972.35", + "@aws-sdk/nested-clients": "^3.997.3", + "@aws-sdk/types": "^3.973.8", + "@smithy/credential-provider-imds": "^4.2.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -328,18 +488,18 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.29", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.29.tgz", - "integrity": "sha512-OGOslTbOlxXexKMqhxCEbBQbUIfuhGxU5UXw3Fm56ypXHvrXH4aTt/xb5Y884LOoteP1QST1lVZzHfcTnWhiPQ==", + "version": "3.972.35", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.35.tgz", + "integrity": "sha512-5oa3j0cA50jPqgNhZ9XdJVopuzUf1klRb28/2MfLYWWiPi9DRVvbrBWT+DidbHTT36520VuXZJahQwR+YgSjrg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/nested-clients": "^3.996.19", - "@aws-sdk/types": "^3.973.7", - "@smithy/property-provider": "^4.2.13", - "@smithy/protocol-http": "^5.3.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/nested-clients": "^3.997.3", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -347,22 +507,22 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.30", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.30.tgz", - "integrity": "sha512-FMnAnWxc8PG+ZrZ2OBKzY4luCUJhe9CG0B9YwYr4pzrYGLXBS2rl+UoUvjGbAwiptxRL6hyA3lFn03Bv1TLqTw==", + "version": "3.972.36", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.36.tgz", + "integrity": "sha512-4nT2T8Z7vH8KE9EdjEsuIlHpZSlcaK2PrKbQBjuUGU46BCCzF3WvP0u0Uiosni3Ykmmn4rWLVawoOCLotUtCbg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.25", - "@aws-sdk/credential-provider-http": "^3.972.27", - "@aws-sdk/credential-provider-ini": "^3.972.29", - "@aws-sdk/credential-provider-process": "^3.972.25", - "@aws-sdk/credential-provider-sso": "^3.972.29", - "@aws-sdk/credential-provider-web-identity": "^3.972.29", - "@aws-sdk/types": "^3.973.7", - "@smithy/credential-provider-imds": "^4.2.13", - "@smithy/property-provider": "^4.2.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", + "@aws-sdk/credential-provider-env": "^3.972.31", + "@aws-sdk/credential-provider-http": "^3.972.33", + "@aws-sdk/credential-provider-ini": "^3.972.35", + "@aws-sdk/credential-provider-process": "^3.972.31", + "@aws-sdk/credential-provider-sso": "^3.972.35", + "@aws-sdk/credential-provider-web-identity": "^3.972.35", + "@aws-sdk/types": "^3.973.8", + "@smithy/credential-provider-imds": "^4.2.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -370,16 +530,16 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.25", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.25.tgz", - "integrity": "sha512-HR7ynNRdNhNsdVCOCegy1HsfsRzozCOPtD3RzzT1JouuaHobWyRfJzCBue/3jP7gECHt+kQyZUvwg/cYLWurNQ==", + "version": "3.972.31", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.31.tgz", + "integrity": "sha512-eKeT4MXumpBJsrDLCYcSzIkFPVTFn/es7It2oogp2OhU/ic7P/+xzFpQx9ZhwtXS57Mc5S42BPWi7lHmvs/nYg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/types": "^3.973.7", - "@smithy/property-provider": "^4.2.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -387,18 +547,18 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.29", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.29.tgz", - "integrity": "sha512-HWv4SEq3jZDYPlwryZVef97+U8CxxRos5mK8sgGO1dQaFZpV5giZLzqGE5hkDmh2csYcBO2uf5XHjPTpZcJlig==", + "version": "3.972.35", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.35.tgz", + "integrity": "sha512-bCuBdfnj0KGDMdLp6utMTLiJcFN2ek9EgZinxQZZSc3FxjJ/HSqeqab2cjbnoNfy8RM6suDCsRkmVY1izp9I+A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/nested-clients": "^3.996.19", - "@aws-sdk/token-providers": "3.1026.0", - "@aws-sdk/types": "^3.973.7", - "@smithy/property-provider": "^4.2.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/nested-clients": "^3.997.3", + "@aws-sdk/token-providers": "3.1036.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -406,17 +566,75 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.29", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.29.tgz", - "integrity": "sha512-PdMBza1WEKEUPFEmMGCfnU2RYCz9MskU2e8JxjyUOsMKku7j9YaDKvbDi2dzC0ihFoM6ods2SbhfAAro+Gwlew==", + "version": "3.972.35", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.35.tgz", + "integrity": "sha512-swW6Bwvl8lanyEMtZOWE/oR6yqcRQH4HTQZUVsnDVgoXvRjRywpYpLv2BWwjUFyjPrqsdX6FeTkf4tMSe/qFTQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/nested-clients": "^3.996.19", - "@aws-sdk/types": "^3.973.7", - "@smithy/property-provider": "^4.2.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/nested-clients": "^3.997.3", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-bucket-endpoint": { + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.10.tgz", + "integrity": "sha512-Vbc2frZH7wXlMNd+ZZSXUEs/l1Sv8Jj4zUnIfwrYF5lwaLdXHZ9xx4U3rjUcaye3HRhFVc+E5DbBxpRAbB16BA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-arn-parser": "^3.972.3", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-config-provider": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-expect-continue": { + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.10.tgz", + "integrity": "sha512-2Yn0f1Qiq/DjxYR3wfI3LokXnjOhFM7Ssn4LTdFDIxRMCE6I32MAsVnhPX1cUZsuVA9tiZtwwhlSLAtFGxAZlQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-flexible-checksums": { + "version": "3.974.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.13.tgz", + "integrity": "sha512-b6QUe2hQX9XsnCzp6mtzVaERhganDKeb8lmGL6pVhr7rRVH9S9keDFW7uKytuuqmcY5943FixoGqn/QL+sbUBA==", + "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.5", + "@aws-sdk/crc64-nvme": "^3.972.7", + "@aws-sdk/types": "^3.973.8", + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-stream": "^4.5.25", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -424,14 +642,28 @@ } }, "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.9.tgz", - "integrity": "sha512-je5vRdNw4SkuTnmRbFZLdye4sQ0faLt8kwka5wnnSU30q1mHO4X+idGEJOOE+Tn1ME7Oryn05xxkDvIb3UaLaQ==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.10.tgz", + "integrity": "sha512-IJSsIMeVQ8MMCPbuh1AbltkFhLBLXn7aejzfX5YKT/VLDHn++Dcz8886tXckE+wQssyPUhaXrJhdakO2VilRhg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-location-constraint": { + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.10.tgz", + "integrity": "sha512-rI3NZvJcEvjoD0+0PI0iUAwlPw2IlSlhyvgBK/3WkKJQE/YiKFedd9dMN2lVacdNxPNhxL/jzQaKQdrGtQagjQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -439,13 +671,13 @@ } }, "node_modules/@aws-sdk/middleware-logger": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.9.tgz", - "integrity": "sha512-HsVgDrruhqI28RkaXALm8grJ7Agc1wF6Et0xh6pom8NdO2VdO/SD9U/tPwUjewwK/pVoka+EShBxyCvgsPCtog==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.10.tgz", + "integrity": "sha512-OOuGvvz1Dm20SjZo5oEBePFqxt5nf8AwkNDSyUHvD9/bfNASmstcYxFAHUowy4n6Io7mWUZ04JURZwSBvyQanQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@smithy/types": "^4.14.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -453,15 +685,54 @@ } }, "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.972.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.10.tgz", - "integrity": "sha512-RVQQbq5orQ/GHUnXvqEOj2HHPBJm+mM+ySwZKS5UaLBwra5ugRtiH09PLUoOZRl7a1YzaOzXSuGbn9iD5j60WQ==", + "version": "3.972.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.11.tgz", + "integrity": "sha512-+zz6f79Kj9V5qFK2P+D8Ehjnw4AhphAlCAsPjUqEcInA9umtSSKMrHbSagEeOIsDNuvVrH98bjRHcyQukTrhaQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", + "@aws-sdk/types": "^3.973.8", "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.34.tgz", + "integrity": "sha512-/UL96JKjsjdodcRRMKl99tLQvK6Oi9ptLC9iU1yiTF/ruaDX0mtBBtnLNZDxIZRJOCVOtB49ed1YaTadqygk8Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-arn-parser": "^3.972.3", + "@smithy/core": "^3.23.17", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/signature-v4": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-stream": "^4.5.25", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-ssec": { + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.10.tgz", + "integrity": "sha512-Gli9A0u8EVVb+5bFDGS/QbSVg28w/wpEidg1ggVcSj65BDTdGR6punsOcVjqdiu1i42WHWo51MCvARPIIz9juw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -469,18 +740,18 @@ } }, "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.972.29", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.29.tgz", - "integrity": "sha512-f/sIRzuTfEjg6NsbMYvye2VsmnQoNgntntleQyx5uGacUYzszbfIlO3GcI6G6daWUmTm0IDZc11qMHWwF0o0mQ==", + "version": "3.972.35", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.35.tgz", + "integrity": "sha512-hOFWNOjVmOocpRlrU04nYxjMOeoe0Obu5AXEuhB8zblMCPl3cG1hdluQCZERRKFyhMQjwZnDbhSHjoMUjetFGw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-endpoints": "^3.996.6", - "@smithy/core": "^3.23.14", - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", - "@smithy/util-retry": "^4.3.0", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-endpoints": "^3.996.8", + "@smithy/core": "^3.23.17", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-retry": "^4.3.4", "tslib": "^2.6.2" }, "engines": { @@ -488,47 +759,48 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.996.19", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.19.tgz", - "integrity": "sha512-uFkmCDXvmQYLanlYdOFS0+MQWkrj9wPMt/ZCc/0J0fjPim6F5jBVBmEomvGY/j77ILW6GTPwN22Jc174Mhkw6Q==", + "version": "3.997.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.3.tgz", + "integrity": "sha512-SivE6GP228IVgfsrr2c/vqTg95X0Qj39Yw4uIrcddpkUzIltNMoNOR62leHOLhODfjv9K8X2mPTwS69A5kT0nQ==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/middleware-host-header": "^3.972.9", - "@aws-sdk/middleware-logger": "^3.972.9", - "@aws-sdk/middleware-recursion-detection": "^3.972.10", - "@aws-sdk/middleware-user-agent": "^3.972.29", - "@aws-sdk/region-config-resolver": "^3.972.11", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-endpoints": "^3.996.6", - "@aws-sdk/util-user-agent-browser": "^3.972.9", - "@aws-sdk/util-user-agent-node": "^3.973.15", - "@smithy/config-resolver": "^4.4.14", - "@smithy/core": "^3.23.14", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/hash-node": "^4.2.13", - "@smithy/invalid-dependency": "^4.2.13", - "@smithy/middleware-content-length": "^4.2.13", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/middleware-retry": "^4.5.0", - "@smithy/middleware-serde": "^4.2.17", - "@smithy/middleware-stack": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/middleware-host-header": "^3.972.10", + "@aws-sdk/middleware-logger": "^3.972.10", + "@aws-sdk/middleware-recursion-detection": "^3.972.11", + "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/region-config-resolver": "^3.972.13", + "@aws-sdk/signature-v4-multi-region": "^3.996.22", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-endpoints": "^3.996.8", + "@aws-sdk/util-user-agent-browser": "^3.972.10", + "@aws-sdk/util-user-agent-node": "^3.973.21", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/hash-node": "^4.2.14", + "@smithy/invalid-dependency": "^4.2.14", + "@smithy/middleware-content-length": "^4.2.14", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-serde": "^4.2.20", + "@smithy/middleware-stack": "^4.2.14", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.45", - "@smithy/util-defaults-mode-node": "^4.2.49", - "@smithy/util-endpoints": "^3.3.4", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.0", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -537,15 +809,32 @@ } }, "node_modules/@aws-sdk/region-config-resolver": { - "version": "3.972.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.11.tgz", - "integrity": "sha512-6Q8B1dcx6BBqUTY1Mc/eROKA0FImEEY5VPSd6AGPEUf0ErjExz4snVqa9kNJSoVDV1rKaNf3qrWojgcKW+SdDg==", + "version": "3.972.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.13.tgz", + "integrity": "sha512-CvJ2ZIjK/jVD/lbOpowBVElJyC1YxLTIJ13yM0AEo0t2v7swOzGjSA6lJGH+DwZXQhcjUjoYwc8bVYCX5MDr1A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@smithy/config-resolver": "^4.4.14", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/types": "^4.14.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/config-resolver": "^4.4.17", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.22.tgz", + "integrity": "sha512-/rXhMXteD+BqhFd0nYprAgcZ/KtU+963uftPqd3tiFcFfooHZINXUGtOmo2SQjRVauCTNqIEzkwuSETdZFqTTA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "^3.972.34", + "@aws-sdk/types": "^3.973.8", + "@smithy/protocol-http": "^5.3.14", + "@smithy/signature-v4": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -553,17 +842,17 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1026.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1026.0.tgz", - "integrity": "sha512-Ieq/HiRrbEtrYP387Nes0XlR7H1pJiJOZKv+QyQzMYpvTiDs0VKy2ZB3E2Zf+aFovWmeE7lRE4lXyF7dYM6GgA==", + "version": "3.1036.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1036.0.tgz", + "integrity": "sha512-aNSJ6jjDYayxN9ZA1JpycVScX93Lx03kKZ1EXt3DGOTahcWVLJj3oLAlop0xKP+vP2Ga2t49p1tEaMkTbCCaZA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/nested-clients": "^3.996.19", - "@aws-sdk/types": "^3.973.7", - "@smithy/property-provider": "^4.2.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/nested-clients": "^3.997.3", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -571,12 +860,24 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.973.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.7.tgz", - "integrity": "sha512-reXRwoJ6CfChoqAsBszUYajAF8Z2LRE+CRcKocvFSMpIiLOtYU3aJ9trmn6VVPAzbbY5LXF+FfmUslbXk1SYFg==", + "version": "3.973.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", + "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-arn-parser": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.3.tgz", + "integrity": "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", "tslib": "^2.6.2" }, "engines": { @@ -584,15 +885,15 @@ } }, "node_modules/@aws-sdk/util-endpoints": { - "version": "3.996.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.6.tgz", - "integrity": "sha512-2nUQ+2ih7CShuKHpGSIYvvAIOHy52dOZguYG36zptBukhw6iFwcvGfG0tes0oZFWQqEWvgZe9HLWaNlvXGdOrg==", + "version": "3.996.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.8.tgz", + "integrity": "sha512-oOZHcRDihk5iEe5V25NVWg45b3qEA8OpHWVdU/XQh8Zj4heVPAJqWvMphQnU7LkufmUo10EpvFPZuQMiFLJK3g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", - "@smithy/util-endpoints": "^3.3.4", + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-endpoints": "^3.4.2", "tslib": "^2.6.2" }, "engines": { @@ -612,27 +913,27 @@ } }, "node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.9.tgz", - "integrity": "sha512-sn/LMzTbGjYqCCF24390WxPd6hkpoSptiUn5DzVp4cD71yqw+yGEGm1YCxyEoPXyc8qciM8UzLJcZBFslxo5Uw==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.10.tgz", + "integrity": "sha512-FAzqXvfEssGdSIz8ejatan0bOdx1qefBWKF/gWmVBXIP1HkS7v/wjjaqrAGGKvyihrXTXW00/2/1nTJtxpXz7g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@smithy/types": "^4.14.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.973.15", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.15.tgz", - "integrity": "sha512-fYn3s9PtKdgQkczGZCFMgkNEe8aq1JCVbnRqjqN9RSVW43xn2RV9xdcZ3z01a48Jpkuh/xCmBKJxdLOo4Ozg7w==", + "version": "3.973.21", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.21.tgz", + "integrity": "sha512-Av4UHTcAWgdvbN0IP9pbtf4Qa1+6LtJqQdZWj5pLn5J67w0pnJJAZZ+7JPPcj2KN3378zD2JDM9DwJKEyvyMTQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-user-agent": "^3.972.29", - "@aws-sdk/types": "^3.973.7", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/types": "^4.14.0", + "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/types": "^3.973.8", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, @@ -649,13 +950,13 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.17", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.17.tgz", - "integrity": "sha512-Ra7hjqAZf1OXRRMueB13qex7mFJRDK/pgCvdSFemXBT8KCGnQDPoKzHY1SjN+TjJVmnpSF14W5tJ1vDamFu+Gg==", + "version": "3.972.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.19.tgz", + "integrity": "sha512-Cw8IOMdBUEIl8ZlhRC3Dc/E64D5B5/8JhV6vhPLiPfJwcRC84S6F8aBOIi/N4vR9ZyA4I5Cc0Ateb/9EHaJXeQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", - "fast-xml-parser": "5.5.8", + "@smithy/types": "^4.14.1", + "fast-xml-parser": "5.7.1", "tslib": "^2.6.2" }, "engines": { @@ -989,6 +1290,18 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@otplib/core": { "version": "12.0.1", "resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz", @@ -1385,17 +1698,42 @@ "dev": true, "license": "MIT" }, - "node_modules/@smithy/config-resolver": { - "version": "4.4.14", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.14.tgz", - "integrity": "sha512-N55f8mPEccpzKetUagdvmAy8oohf0J5cuj9jLI1TaSceRlq0pJsIZepY3kmAXAhyxqXPV6hDerDQhqQPKWgAoQ==", + "node_modules/@smithy/chunked-blob-reader": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.2.tgz", + "integrity": "sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.13", - "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader-native": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.3.tgz", + "integrity": "sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-base64": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "4.4.17", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.17.tgz", + "integrity": "sha512-TzDZcAnhTyAHbXVxWZo7/tEcrIeFq20IBk8So3OLOetWpR8EwY/yEqBMBFaJMeyEiREDq4NfEl+qO3OAUD+vbQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.14", + "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", - "@smithy/util-endpoints": "^3.3.4", - "@smithy/util-middleware": "^4.2.13", + "@smithy/util-endpoints": "^3.4.2", + "@smithy/util-middleware": "^4.2.14", "tslib": "^2.6.2" }, "engines": { @@ -1403,18 +1741,18 @@ } }, "node_modules/@smithy/core": { - "version": "3.23.14", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.14.tgz", - "integrity": "sha512-vJ0IhpZxZAkFYOegMKSrxw7ujhhT2pass/1UEcZ4kfl5srTAqtPU5I7MdYQoreVas3204ykCiNhY1o7Xlz6Yyg==", + "version": "3.23.17", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.17.tgz", + "integrity": "sha512-x7BlLbUFL8NWCGjMF9C+1N5cVCxcPa7g6Tv9B4A2luWx3be3oU8hQ96wIwxe/s7OhIzvoJH73HAUSg5JXVlEtQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-stream": "^4.5.22", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-stream": "^4.5.25", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" @@ -1424,15 +1762,85 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.13.tgz", - "integrity": "sha512-wboCPijzf6RJKLOvnjDAiBxGSmSnGXj35o5ZAWKDaHa/cvQ5U3ZJ13D4tMCE8JG4dxVAZFy/P0x/V9CwwdfULQ==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.14.tgz", + "integrity": "sha512-Au28zBN48ZAoXdooGUHemuVBrkE+Ie6RPmGNIAJsFqj33Vhb6xAgRifUydZ2aY+M+KaMAETAlKk5NC5h1G7wpg==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.13", - "@smithy/property-provider": "^4.2.13", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-codec": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.14.tgz", + "integrity": "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.1", + "@smithy/util-hex-encoding": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-browser": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.14.tgz", + "integrity": "sha512-8IelTCtTctWRbb+0Dcy+C0aICh1qa0qWXqgjcXDmMuCvPJRnv26hiDZoAau2ILOniki65mCPKqOQs/BaWvO4CQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.14", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-config-resolver": { + "version": "4.3.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.14.tgz", + "integrity": "sha512-sqHiHpYRYo3FJlaIxD1J8PhbcmJAm7IuM16mVnwSkCToD7g00IBZzKuiLNMGmftULmEUX6/UAz8/NN5uMP8bVA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-node": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.14.tgz", + "integrity": "sha512-Ht/8BuGlKfFTy0H3+8eEu0vdpwGztCnaLLXtpXNdQqiR7Hj4vFScU3T436vRAjATglOIPjJXronY+1WxxNLSiw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.14", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-universal": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.14.tgz", + "integrity": "sha512-lWyt4T2XQZUZgK3tQ3Wn0w3XBvZsK/vjTuJl6bXbnGZBHH0ZUSONTYiK9TgjTTzU54xQr3DRFwpjmhp0oLm3gg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-codec": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1440,14 +1848,14 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.3.16", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.16.tgz", - "integrity": "sha512-nYDRUIvNd4mFmuXraRWt6w5UsZTNqtj4hXJA/iiOD4tuseIdLP9Lq38teH/SZTcIFCa2f+27o7hYpIsWktJKEQ==", + "version": "5.3.17", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.17.tgz", + "integrity": "sha512-bXOvQzaSm6MnmLaWA1elgfQcAtN4UP3vXqV97bHuoOrHQOJiLT3ds6o9eo5bqd0TJfRFpzdGnDQdW3FACiAVdw==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.13", - "@smithy/querystring-builder": "^4.2.13", - "@smithy/types": "^4.14.0", + "@smithy/protocol-http": "^5.3.14", + "@smithy/querystring-builder": "^4.2.14", + "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" }, @@ -1455,13 +1863,28 @@ "node": ">=18.0.0" } }, - "node_modules/@smithy/hash-node": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.13.tgz", - "integrity": "sha512-4/oy9h0jjmY80a2gOIo75iLl8TOPhmtx4E2Hz+PfMjvx/vLtGY4TMU/35WRyH2JHPfT5CVB38u4JRow7gnmzJA==", + "node_modules/@smithy/hash-blob-browser": { + "version": "4.2.15", + "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.15.tgz", + "integrity": "sha512-0PJ4Al3fg2nM4qKrAIxyNcApgqHAXcBkN8FeizOz69z0rb26uZ6lMESYtxegaTlXB5Hj84JfwMPavMrwDMjucA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/chunked-blob-reader": "^5.2.2", + "@smithy/chunked-blob-reader-native": "^4.2.3", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.14.tgz", + "integrity": "sha512-8ZBDY2DD4wr+GGjTpPtiglEsqr0lUP+KHqgZcWczFf6qeZ/YRjMIOoQWVQlmwu7EtxKTd8YXD8lblmYcpBIA1g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" @@ -1470,13 +1893,27 @@ "node": ">=18.0.0" } }, - "node_modules/@smithy/invalid-dependency": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.13.tgz", - "integrity": "sha512-jvC0RB/8BLj2SMIkY0Npl425IdnxZJxInpZJbu563zIRnVjpDMXevU3VMCRSabaLB0kf/eFIOusdGstrLJ8IDg==", + "node_modules/@smithy/hash-stream-node": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.14.tgz", + "integrity": "sha512-tw4GANWkZPb6+BdD4Fgucqzey2+r73Z/GRo9zklsCdwrnxxumUV83ZIaBDdudV4Ylazw3EPTiJZhpX42105ruQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.14.tgz", + "integrity": "sha512-c21qJiTSb25xvvOp+H2TNZzPCngrvl5vIPqPB8zQ/DmJF4QWXO19x1dWfMJZ6wZuuWUPPm0gV8C0cU3+ifcWuw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1495,14 +1932,28 @@ "node": ">=18.0.0" } }, - "node_modules/@smithy/middleware-content-length": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.13.tgz", - "integrity": "sha512-IPMLm/LE4AZwu6qiE8Rr8vJsWhs9AtOdySRXrOM7xnvclp77Tyh7hMs/FRrMf26kgIe67vFJXXOSmVxS7oKeig==", + "node_modules/@smithy/md5-js": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.14.tgz", + "integrity": "sha512-V2v0vx+h0iUSNG1Alt+GNBMSLGCrl9iVsdd+Ap67HPM9PN479x12V8LkuMoKImNZxn3MXeuyUjls+/7ZACZghA==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.14.tgz", + "integrity": "sha512-xhHq7fX4/3lv5NHxLUk3OeEvl0xZ+Ek3qIbWaCL4f9JwgDZEclPBElljaZCAItdGPQl/kSM4LPMOpy1MYgprpw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1510,18 +1961,18 @@ } }, "node_modules/@smithy/middleware-endpoint": { - "version": "4.4.29", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.29.tgz", - "integrity": "sha512-R9Q/58U+qBiSARGWbAbFLczECg/RmysRksX6Q8BaQEpt75I7LI6WGDZnjuC9GXSGKljEbA7N118LhGaMbfrTXw==", + "version": "4.4.32", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.32.tgz", + "integrity": "sha512-ZZkgyjnJppiZbIm6Qbx92pbXYi1uzenIvGhBSCDlc7NwuAkiqSgS75j1czAD25ZLs2FjMjYy1q7gyRVWG6JA0Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.14", - "@smithy/middleware-serde": "^4.2.17", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", - "@smithy/util-middleware": "^4.2.13", + "@smithy/core": "^3.23.17", + "@smithy/middleware-serde": "^4.2.20", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-middleware": "^4.2.14", "tslib": "^2.6.2" }, "engines": { @@ -1529,19 +1980,19 @@ } }, "node_modules/@smithy/middleware-retry": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.5.0.tgz", - "integrity": "sha512-/NzISn4grj/BRFVua/xnQwF+7fakYZgimpw2dfmlPgcqecBMKxpB9g5mLYRrmBD5OrPoODokw4Vi1hrSR4zRyw==", + "version": "4.5.5", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.5.5.tgz", + "integrity": "sha512-wnYOpB5vATFKWrY2Z9Alb0KhjZI6AbzU6Fbz3Hq2GnURdRYWB4q+qWivQtSTwXcmWUA3MZ6krfwL6Cq5MAbxsA==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.14", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/protocol-http": "^5.3.13", - "@smithy/service-error-classification": "^4.2.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.0", + "@smithy/core": "^3.23.17", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/service-error-classification": "^4.3.0", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.4", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" }, @@ -1550,14 +2001,14 @@ } }, "node_modules/@smithy/middleware-serde": { - "version": "4.2.17", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.17.tgz", - "integrity": "sha512-0T2mcaM6v9W1xku86Dk0bEW7aEseG6KenFkPK98XNw0ZhOqOiD1MrMsdnQw9QsL3/Oa85T53iSMlm0SZdSuIEQ==", + "version": "4.2.20", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.20.tgz", + "integrity": "sha512-Lx9JMO9vArPtiChE3wbEZ5akMIDQpWQtlu90lhACQmNOXcGXRbaDywMHDzuDZ2OkZzP+9wQfZi3YJT9F67zTQQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.14", - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", + "@smithy/core": "^3.23.17", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1565,12 +2016,12 @@ } }, "node_modules/@smithy/middleware-stack": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.13.tgz", - "integrity": "sha512-g72jN/sGDLyTanrCLH9fhg3oysO3f7tQa6eWWsMyn2BiYNCgjF24n4/I9wff/5XidFvjj9ilipAoQrurTUrLvw==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.14.tgz", + "integrity": "sha512-2dvkUKLuFdKsCRmOE4Mn63co0Djtsm+JMh0bYZQupN1pJwMeE8FmQmRLLzzEMN0dnNi7CDCYYH8F0EVwWiPBeA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1578,14 +2029,14 @@ } }, "node_modules/@smithy/node-config-provider": { - "version": "4.3.13", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.13.tgz", - "integrity": "sha512-iGxQ04DsKXLckbgnX4ipElrOTk+IHgTyu0q0WssZfYhDm9CQWHmu6cOeI5wmWRxpXbBDhIIfXMWz5tPEtcVqbw==", + "version": "4.3.14", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.14.tgz", + "integrity": "sha512-S+gFjyo/weSVL0P1b9Ts8C/CwIfNCgUPikk3sl6QVsfE/uUuO+QsF+NsE/JkpvWqqyz1wg7HFdiaZuj5CoBMRg==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1593,14 +2044,14 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.5.2", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.5.2.tgz", - "integrity": "sha512-/oD7u8M0oj2ZTFw7GkuuHWpIxtWdLlnyNkbrWcyVYhd5RJNDuczdkb0wfnQICyNFrVPlr8YHOhamjNy3zidhmA==", + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.6.1.tgz", + "integrity": "sha512-iB+orM4x3xrr57X3YaXazfKnntl0LHlZB1kcXSGzMV1Tt0+YwEjGlbjk/44qEGtBzXAz6yFDzkYTKSV6Pj2HUg==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.13", - "@smithy/querystring-builder": "^4.2.13", - "@smithy/types": "^4.14.0", + "@smithy/protocol-http": "^5.3.14", + "@smithy/querystring-builder": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1608,12 +2059,12 @@ } }, "node_modules/@smithy/property-provider": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.13.tgz", - "integrity": "sha512-bGzUCthxRmezuxkbu9wD33wWg9KX3hJpCXpQ93vVkPrHn9ZW6KNNdY5xAUWNuRCwQ+VyboFuWirG1lZhhkcyRQ==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.14.tgz", + "integrity": "sha512-WuM31CgfsnQ/10i7NYr0PyxqknD72Y5uMfUMVSniPjbEPceiTErb4eIqJQ+pdxNEAUEWrewrGjIRjVbVHsxZiQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1621,12 +2072,12 @@ } }, "node_modules/@smithy/protocol-http": { - "version": "5.3.13", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.13.tgz", - "integrity": "sha512-+HsmuJUF4u8POo6s8/a2Yb/AQ5t/YgLovCuHF9oxbocqv+SZ6gd8lC2duBFiCA/vFHoHQhoq7QjqJqZC6xOxxg==", + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.14.tgz", + "integrity": "sha512-dN5F8kHx8RNU0r+pCwNmFZyz6ChjMkzShy/zup6MtkRmmix4vZzJdW+di7x//b1LiynIev88FM18ie+wwPcQtQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1634,12 +2085,12 @@ } }, "node_modules/@smithy/querystring-builder": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.13.tgz", - "integrity": "sha512-tG4aOYFCZdPMjbgfhnIQ322H//ojujldp1SrHPHpBSb3NqgUp3dwiUGRJzie87hS1DYwWGqDuPaowoDF+rYCbQ==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.14.tgz", + "integrity": "sha512-XYA5Z0IqTeF+5XDdh4BBmSA0HvbgVZIyv4cmOoUheDNR57K1HgBp9ukUMx3Cr3XpDHHpLBnexPE3LAtDsZkj2A==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" }, @@ -1648,12 +2099,12 @@ } }, "node_modules/@smithy/querystring-parser": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.13.tgz", - "integrity": "sha512-hqW3Q4P+CDzUyQ87GrboGMeD7XYNMOF+CuTwu936UQRB/zeYn3jys8C3w+wMkDfY7CyyyVwZQ5cNFoG0x1pYmA==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.14.tgz", + "integrity": "sha512-hr+YyqBD23GVvRxGGrcc/oOeNlK3PzT5Fu4dzrDXxzS1LpFiuL2PQQqKPs87M79aW7ziMs+nvB3qdw77SqE7Lw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1661,24 +2112,24 @@ } }, "node_modules/@smithy/service-error-classification": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.13.tgz", - "integrity": "sha512-a0s8XZMfOC/qpqq7RCPvJlk93rWFrElH6O++8WJKz0FqnA4Y7fkNi/0mnGgSH1C4x6MFsuBA8VKu4zxFrMe5Vw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.3.0.tgz", + "integrity": "sha512-9jKsBYQRPR0xBLgc2415RsA5PIcP2sis4oBdN9s0D13cg1B1284mNTjx9Yc+BEERXzuPm5ObktI96OxsKh8E9A==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0" + "@smithy/types": "^4.14.1" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.4.8", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.8.tgz", - "integrity": "sha512-VZCZx2bZasxdqxVgEAhREvDSlkatTPnkdWy1+Kiy8w7kYPBosW0V5IeDwzDUMvWBt56zpK658rx1cOBFOYaPaw==", + "version": "4.4.9", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.9.tgz", + "integrity": "sha512-495/V2I15SHgedSJoDPD23JuSfKAp726ZI1V0wtjB07Wh7q/0tri/0e0DLefZCHgxZonrGKt/OCTpAtP1wE1kQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1686,16 +2137,16 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.3.13", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.13.tgz", - "integrity": "sha512-YpYSyM0vMDwKbHD/JA7bVOF6kToVRpa+FM5ateEVRpsTNu564g1muBlkTubXhSKKYXInhpADF46FPyrZcTLpXg==", + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.14.tgz", + "integrity": "sha512-1D9Y/nmlVjCeSivCbhZ7hgEpmHyY1h0GvpSZt3l0xcD9JjmjVC1CHOozS6+Gh+/ldMH8JuJ6cujObQqfayAVFA==", "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^4.2.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", - "@smithy/util-middleware": "^4.2.13", + "@smithy/util-middleware": "^4.2.14", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" @@ -1705,17 +2156,17 @@ } }, "node_modules/@smithy/smithy-client": { - "version": "4.12.9", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.9.tgz", - "integrity": "sha512-ovaLEcTU5olSeHcRXcxV6viaKtpkHZumn6Ps0yn7dRf2rRSfy794vpjOtrWDO0d1auDSvAqxO+lyhERSXQ03EQ==", + "version": "4.12.13", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.13.tgz", + "integrity": "sha512-y/Pcj1V9+qG98gyu1gvftHB7rDpdh+7kIBIggs55yGm3JdtBV8GT8IFF3a1qxZ79QnaJHX9GXzvBG6tAd+czJA==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.14", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/middleware-stack": "^4.2.13", - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", - "@smithy/util-stream": "^4.5.22", + "@smithy/core": "^3.23.17", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-stack": "^4.2.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-stream": "^4.5.25", "tslib": "^2.6.2" }, "engines": { @@ -1723,9 +2174,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.0.tgz", - "integrity": "sha512-OWgntFLW88kx2qvf/c/67Vno1yuXm/f9M7QFAtVkkO29IJXGBIg0ycEaBTH0kvCtwmvZxRujrgP5a86RvsXJAQ==", + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.1.tgz", + "integrity": "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -1735,13 +2186,13 @@ } }, "node_modules/@smithy/url-parser": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.13.tgz", - "integrity": "sha512-2G03yoboIRZlZze2+PT4GZEjgwQsJjUgn6iTsvxA02bVceHR6vp4Cuk7TUnPFWKF+ffNUk3kj4COwkENS2K3vw==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.14.tgz", + "integrity": "sha512-p06BiBigJ8bTA3MgnOfCtDUWnAMY0YfedO/GRpmc7p+wg3KW8vbXy1xwSu5ASy0wV7rRYtlfZOIKH4XqfhjSQQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/querystring-parser": "^4.2.13", - "@smithy/types": "^4.14.0", + "@smithy/querystring-parser": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1812,14 +2263,14 @@ } }, "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.3.45", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.45.tgz", - "integrity": "sha512-ag9sWc6/nWZAuK3Wm9KlFJUnRkXLrXn33RFjIAmCTFThqLHY+7wCst10BGq56FxslsDrjhSie46c8OULS+BiIw==", + "version": "4.3.49", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.49.tgz", + "integrity": "sha512-a5bNrdiONYB/qE2BuKegvUMd/+ZDwdg4vsNuuSzYE8qs2EYAdK9CynL+Rzn29PbPiUqoz/cbpRbcLzD5lEevHw==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", + "@smithy/property-provider": "^4.2.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1827,17 +2278,17 @@ } }, "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.2.49", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.49.tgz", - "integrity": "sha512-jlN6vHwE8gY5AfiFBavtD3QtCX2f7lM3BKkz7nFKSNfFR5nXLXLg6sqXTJEEyDwtxbztIDBQCfjsGVXlIru2lQ==", + "version": "4.2.54", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.54.tgz", + "integrity": "sha512-g1cvrJvOnzeJgEdf7AE4luI7gp6L8weE0y9a9wQUSGtjb8QRHDbCJYuE4Sy0SD9N8RrnNPFsPltAz/OSoBR9Zw==", "license": "Apache-2.0", "dependencies": { - "@smithy/config-resolver": "^4.4.14", - "@smithy/credential-provider-imds": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/property-provider": "^4.2.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", + "@smithy/config-resolver": "^4.4.17", + "@smithy/credential-provider-imds": "^4.2.14", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1845,13 +2296,13 @@ } }, "node_modules/@smithy/util-endpoints": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.4.tgz", - "integrity": "sha512-BKoR/ubPp9KNKFxPpg1J28N1+bgu8NGAtJblBP7yHy8yQPBWhIAv9+l92SlQLpolGm71CVO+btB60gTgzT0wog==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.4.2.tgz", + "integrity": "sha512-a55Tr+3OKld4TTtnT+RhKOQHyPxm3j/xL4OR83WBUhLJaKDS9dnJ7arRMOp3t31dcLhApwG9bgvrRXBHlLdIkg==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.13", - "@smithy/types": "^4.14.0", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1871,12 +2322,12 @@ } }, "node_modules/@smithy/util-middleware": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.13.tgz", - "integrity": "sha512-GTooyrlmRTqvUen4eK7/K1p6kryF7bnDfq6XsAbIsf2mo51B/utaH+XThY6dKgNCWzMAaH/+OLmqaBuLhLWRow==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.14.tgz", + "integrity": "sha512-1Su2vj9RYNDEv/V+2E+jXkkwGsgR7dc4sfHn9Z7ruzQHJIEni9zzw5CauvRXlFJfmgcqYP8fWa0dkh2Q2YaQyw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1884,13 +2335,13 @@ } }, "node_modules/@smithy/util-retry": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.3.0.tgz", - "integrity": "sha512-tSOPQNT/4KfbvqeMovWC3g23KSYy8czHd3tlN+tOYVNIDLSfxIsrPJihYi5TpNcoV789KWtgChUVedh2y6dDPg==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.3.4.tgz", + "integrity": "sha512-FY1UQQ1VFmMwiYp1GVS4MeaGD5O0blLNYK0xCRHU+mJgeoH/hSY8Ld8sJWKQ6uznkh14HveRGQJncgPyNl9J+A==", "license": "Apache-2.0", "dependencies": { - "@smithy/service-error-classification": "^4.2.13", - "@smithy/types": "^4.14.0", + "@smithy/service-error-classification": "^4.3.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1898,14 +2349,14 @@ } }, "node_modules/@smithy/util-stream": { - "version": "4.5.22", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.22.tgz", - "integrity": "sha512-3H8iq/0BfQjUs2/4fbHZ9aG9yNzcuZs24LPkcX1Q7Z+qpqaGM8+qbGmE8zo9m2nCRgamyvS98cHdcWvR6YUsew==", + "version": "4.5.25", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.25.tgz", + "integrity": "sha512-/PFpG4k8Ze8Ei+mMKj3oiPICYekthuzePZMgZbCqMiXIHHf4n2aZ4Ps0aSRShycFTGuj/J6XldmC0x0DwednIA==", "license": "Apache-2.0", "dependencies": { - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/types": "^4.14.0", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", @@ -1942,12 +2393,12 @@ } }, "node_modules/@smithy/util-waiter": { - "version": "4.2.15", - "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.15.tgz", - "integrity": "sha512-oUt9o7n8hBv3BL56sLSneL0XeigZSuem0Hr78JaoK33D9oKieyCvVP8eTSe3j7g2mm/S1DvzxKieG7JEWNJUNg==", + "version": "4.2.16", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.16.tgz", + "integrity": "sha512-GtclrKoZ3Lt7jPQ7aTIYKfjY92OgceScftVnkTsG8e1KV8rkvZgN+ny6YSRhd9hxB8rZtwVbmln7NTvE5O3GmQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2325,6 +2776,16 @@ "@types/superagent": "^8.1.0" } }, + "node_modules/@types/tar-stream": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/tar-stream/-/tar-stream-3.1.4.tgz", + "integrity": "sha512-921gW0+g29mCJX0fRvqeHzBlE/XclDaAG0Ousy1LCghsOhvaKacDeRGEVzQP9IPfKn8Vysy7FEXAIxycpc/CMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -2889,6 +3350,20 @@ "proxy-from-env": "^2.1.0" } }, + "node_modules/b4a": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", + "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/balanced-match": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz", @@ -2899,6 +3374,97 @@ "node": "20 || >=22" } }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.1.tgz", + "integrity": "sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.0.tgz", + "integrity": "sha512-JTjuZyNIDpw+GytMO4a6TK1VXdVKKJr6DRxEHasyuYyShV2deuiHJK/ahGZlebc+SG0/wJCB9XK8gprBGDFi/Q==", + "license": "Apache-2.0", + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "license": "Apache-2.0", + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.0.tgz", + "integrity": "sha512-3zAJRZMDFGjdn+RVnNpF9kuELw+0Fl3lpndM4NcEOhb9zwtSo/deETfuIwMSE5BXanA0FrN1qVjffGwAg2Y7EA==", + "license": "Apache-2.0", + "dependencies": { + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.2.tgz", + "integrity": "sha512-/9a2j4ac6ckpmAHvod/ob7x439OAHst/drc2Clnq+reRYd/ovddwcF4LfoxHyNk5AuGBnPg+HqFjmE/Zpq6v0A==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -4066,6 +4632,15 @@ "node": ">=0.8.x" } }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -4152,6 +4727,12 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -4190,9 +4771,9 @@ "license": "BSD-3-Clause" }, "node_modules/fast-xml-builder": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", - "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz", + "integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==", "funding": [ { "type": "github", @@ -4205,9 +4786,9 @@ } }, "node_modules/fast-xml-parser": { - "version": "5.5.8", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz", - "integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==", + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.1.tgz", + "integrity": "sha512-8Cc3f8GUGUULg34pBch/KGyPLglS+OFs05deyOlY7fL2MTagYPKrVQNmR1fLF/yJ9PH5ZSTd3YDF6pnmeZU+zA==", "funding": [ { "type": "github", @@ -4216,9 +4797,10 @@ ], "license": "MIT", "dependencies": { - "fast-xml-builder": "^1.1.4", - "path-expression-matcher": "^1.2.0", - "strnum": "^2.2.0" + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.5", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" @@ -5923,9 +6505,9 @@ } }, "node_modules/path-expression-matcher": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.4.0.tgz", - "integrity": "sha512-s4DQMxIdhj3jLFWd9LxHOplj4p9yQ4ffMGowFf3cpEgrrJjEhN0V5nxw4Ye1EViAGDoL4/1AeO6qHpqYPOzE4Q==", + "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", @@ -6654,6 +7236,17 @@ "node": ">=10.0.0" } }, + "node_modules/streamx": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", + "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/strict-event-emitter-types": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz", @@ -6803,7 +7396,7 @@ "tar-stream": "^2.1.4" } }, - "node_modules/tar-stream": { + "node_modules/tar-fs/node_modules/tar-stream": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", @@ -6819,6 +7412,36 @@ "node": ">=6" } }, + "node_modules/tar-stream": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz", + "integrity": "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/thirty-two": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index 2ddfea26..ab55045f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -28,6 +28,7 @@ "@types/multer": "^2.1.0", "@types/node": "^25.3.0", "@types/supertest": "^7.2.0", + "@types/tar-stream": "^3.1.4", "@types/yaml": "^1.9.6", "eslint": "^10.1.0", "nodemon": "^3.1.13", @@ -39,6 +40,7 @@ }, "dependencies": { "@aws-sdk/client-ecr": "^3.1019.0", + "@aws-sdk/client-s3": "^3.1037.0", "@types/compression": "^1.8.1", "@types/cors": "^2.8.19", "@types/dockerode": "^4.0.1", @@ -69,6 +71,7 @@ "otplib": "^12.0.1", "semver": "^7.7.4", "systeminformation": "^5.31.1", + "tar-stream": "^3.1.8", "ws": "^8.19.0", "yaml": "^2.8.2", "zod": "^4.3.6" diff --git a/backend/src/__tests__/cloud-backup-routes.test.ts b/backend/src/__tests__/cloud-backup-routes.test.ts new file mode 100644 index 00000000..fd8686f7 --- /dev/null +++ b/backend/src/__tests__/cloud-backup-routes.test.ts @@ -0,0 +1,193 @@ +/** + * Tests for /api/cloud-backup routes — tier gating (community/skipper/admiral), + * admin gating, config CRUD round-trip with secret encryption, audit logging. + * The S3 SDK is mocked at the module level so no network calls happen. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import request from 'supertest'; +import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; + +const sentSpy = vi.fn(); + +vi.mock('@aws-sdk/client-s3', () => { + class S3Client { async send(cmd: { name: string; input: Record }) { return sentSpy(cmd); } } + class PutObjectCommand { name = 'PutObjectCommand'; constructor(public input: Record) {} } + class GetObjectCommand { name = 'GetObjectCommand'; constructor(public input: Record) {} } + class ListObjectsV2Command { name = 'ListObjectsV2Command'; constructor(public input: Record) {} } + class DeleteObjectCommand { name = 'DeleteObjectCommand'; constructor(public input: Record) {} } + class HeadBucketCommand { name = 'HeadBucketCommand'; constructor(public input: Record) {} } + return { S3Client, PutObjectCommand, GetObjectCommand, ListObjectsV2Command, DeleteObjectCommand, HeadBucketCommand }; +}); + +let tmpDir: string; +let app: import('express').Express; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let LicenseService: typeof import('../services/LicenseService').LicenseService; +let authCookie: string; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); + ({ LicenseService } = await import('../services/LicenseService')); + + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral'); + + ({ app } = await import('../index')); + authCookie = await loginAsTestAdmin(app); +}); + +afterAll(() => { + cleanupTestDb(tmpDir); +}); + +beforeEach(() => { + sentSpy.mockReset(); + const db = DatabaseService.getInstance(); + for (const k of [ + 'cloud_backup_provider', + 'cloud_backup_endpoint', + 'cloud_backup_region', + 'cloud_backup_bucket', + 'cloud_backup_access_key', + 'cloud_backup_secret_key', + 'cloud_backup_path_prefix', + 'cloud_backup_auto_upload', + ]) { + db.updateGlobalSetting(k, ''); + } +}); + +describe('Cloud backup tier gating', () => { + it('rejects community tier with PAID_REQUIRED', async () => { + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community'); + const res = await request(app).get('/api/cloud-backup/config').set('Cookie', authCookie); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PAID_REQUIRED'); + }); + + it('rejects skipper tier with ADMIRAL_REQUIRED', async () => { + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('paid'); + vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValueOnce('skipper'); + const res = await request(app).get('/api/cloud-backup/config').set('Cookie', authCookie); + expect(res.status).toBe(403); + expect(res.body.code).toBe('ADMIRAL_REQUIRED'); + }); + + it('admiral tier reaches the handler', async () => { + const res = await request(app).get('/api/cloud-backup/config').set('Cookie', authCookie); + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('provider', 'disabled'); + }); +}); + +describe('Cloud backup config CRUD', () => { + it('redacts secret_key on read; persists encrypted ciphertext', async () => { + const putRes = await request(app) + .put('/api/cloud-backup/config') + .set('Cookie', authCookie) + .send({ + provider: 'custom', + custom: { + endpoint: 'https://s3.example.com', + region: 'us-east-1', + bucket: 'b', + access_key: 'AKIA1234', + secret_key: 'super-secret', + path_prefix: 'sencho/', + auto_upload: true, + }, + }); + expect(putRes.status).toBe(204); + + const stored = DatabaseService.getInstance().getGlobalSettings().cloud_backup_secret_key; + expect(stored.startsWith('enc:')).toBe(true); + expect(stored.includes('super-secret')).toBe(false); + + const getRes = await request(app).get('/api/cloud-backup/config').set('Cookie', authCookie); + expect(getRes.status).toBe(200); + expect(getRes.body.provider).toBe('custom'); + expect(getRes.body.custom.secret_key).toBe('***'); + expect(getRes.body.custom.bucket).toBe('b'); + }); + + it('preserves saved secret when client sends "***"', async () => { + const db = DatabaseService.getInstance(); + await request(app) + .put('/api/cloud-backup/config') + .set('Cookie', authCookie) + .send({ + provider: 'custom', + custom: { endpoint: 'https://e', region: 'r', bucket: 'b', access_key: 'a', secret_key: 'first-secret', path_prefix: 's/', auto_upload: false }, + }); + const firstStored = db.getGlobalSettings().cloud_backup_secret_key; + + await request(app) + .put('/api/cloud-backup/config') + .set('Cookie', authCookie) + .send({ + provider: 'custom', + custom: { endpoint: 'https://e', region: 'r2', bucket: 'b', access_key: 'a', secret_key: '***', path_prefix: 's/', auto_upload: true }, + }); + const secondStored = db.getGlobalSettings().cloud_backup_secret_key; + expect(secondStored).toBe(firstStored); + expect(db.getGlobalSettings().cloud_backup_region).toBe('r2'); + expect(db.getGlobalSettings().cloud_backup_auto_upload).toBe('1'); + }); + + it('rejects invalid provider value', async () => { + const res = await request(app) + .put('/api/cloud-backup/config') + .set('Cookie', authCookie) + .send({ provider: 'bogus' }); + expect(res.status).toBe(400); + }); + + it('rejects custom config missing required fields', async () => { + const res = await request(app) + .put('/api/cloud-backup/config') + .set('Cookie', authCookie) + .send({ provider: 'custom', custom: { endpoint: '', bucket: '', access_key: '' } }); + expect(res.status).toBe(400); + }); +}); + +describe('Cloud backup audit log', () => { + it('writes audit row with the cloud-backup summary on PUT /config', async () => { + await request(app) + .put('/api/cloud-backup/config') + .set('Cookie', authCookie) + .send({ + provider: 'custom', + custom: { endpoint: 'https://s3.example.com', region: 'r', bucket: 'b', access_key: 'a', secret_key: 's', path_prefix: 'p/', auto_upload: false }, + }); + const { entries } = DatabaseService.getInstance().getAuditLogs({ limit: 50 }); + const cloudEntry = entries.find(e => e.path.includes('/cloud-backup/config') && e.method === 'PUT'); + expect(cloudEntry).toBeDefined(); + expect(cloudEntry!.summary).toBe('Updated cloud backup config'); + }); +}); + +describe('Cloud backup test endpoint', () => { + it('reports failure when no provider is configured', async () => { + const res = await request(app).post('/api/cloud-backup/test').set('Cookie', authCookie).send({}); + expect(res.status).toBe(200); + expect(res.body.success).toBe(false); + }); + + it('reports success when HeadBucketCommand resolves', async () => { + const db = DatabaseService.getInstance(); + const { CryptoService } = await import('../services/CryptoService'); + db.updateGlobalSetting('cloud_backup_provider', 'custom'); + db.updateGlobalSetting('cloud_backup_endpoint', 'https://s3.example.com'); + db.updateGlobalSetting('cloud_backup_region', 'us-east-1'); + db.updateGlobalSetting('cloud_backup_bucket', 'b'); + db.updateGlobalSetting('cloud_backup_access_key', 'a'); + db.updateGlobalSetting('cloud_backup_secret_key', CryptoService.getInstance().encrypt('s')); + + sentSpy.mockResolvedValueOnce({}); + const res = await request(app).post('/api/cloud-backup/test').set('Cookie', authCookie).send({}); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); +}); diff --git a/backend/src/__tests__/cloud-backup-service.test.ts b/backend/src/__tests__/cloud-backup-service.test.ts new file mode 100644 index 00000000..6b74c299 --- /dev/null +++ b/backend/src/__tests__/cloud-backup-service.test.ts @@ -0,0 +1,242 @@ +/** + * Tests for CloudBackupService — provider resolution, encryption round-trip, + * archive format, and S3 client invocation. The S3 SDK is mocked at the + * module level so no network calls happen. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import * as zlib from 'zlib'; +import * as tar from 'tar-stream'; +import { Readable } from 'stream'; +import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; + +const sentSpy = vi.fn(); +const s3ClientCtorSpy = vi.fn(); + +vi.mock('@aws-sdk/client-s3', () => { + class S3Client { + constructor(opts: unknown) { s3ClientCtorSpy(opts); } + async send(cmd: { name: string; input: Record }) { return sentSpy(cmd); } + } + class PutObjectCommand { name = 'PutObjectCommand'; constructor(public input: Record) {} } + class GetObjectCommand { name = 'GetObjectCommand'; constructor(public input: Record) {} } + class ListObjectsV2Command { name = 'ListObjectsV2Command'; constructor(public input: Record) {} } + class DeleteObjectCommand { name = 'DeleteObjectCommand'; constructor(public input: Record) {} } + class HeadBucketCommand { name = 'HeadBucketCommand'; constructor(public input: Record) {} } + return { S3Client, PutObjectCommand, GetObjectCommand, ListObjectsV2Command, DeleteObjectCommand, HeadBucketCommand }; +}); + +let tmpDir: string; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let CryptoService: typeof import('../services/CryptoService').CryptoService; +let CloudBackupService: typeof import('../services/CloudBackupService').CloudBackupService; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); + ({ CryptoService } = await import('../services/CryptoService')); + ({ CloudBackupService } = await import('../services/CloudBackupService')); +}); + +afterAll(() => { + CloudBackupService.getInstance().stop(); + cleanupTestDb(tmpDir); +}); + +beforeEach(() => { + sentSpy.mockReset(); + s3ClientCtorSpy.mockReset(); + const db = DatabaseService.getInstance(); + // Reset all cloud-backup-related settings between tests. + for (const k of [ + 'cloud_backup_provider', + 'cloud_backup_endpoint', + 'cloud_backup_region', + 'cloud_backup_bucket', + 'cloud_backup_access_key', + 'cloud_backup_secret_key', + 'cloud_backup_path_prefix', + 'cloud_backup_auto_upload', + ]) { + db.updateGlobalSetting(k, ''); + } + for (const k of [ + 'sencho_cloud_backup_endpoint', + 'sencho_cloud_backup_bucket', + 'sencho_cloud_backup_access_key', + 'sencho_cloud_backup_secret_key', + 'sencho_cloud_backup_path_prefix', + 'sencho_cloud_backup_quota_bytes', + 'sencho_cloud_backup_provisioned_at', + ]) { + db.setSystemState(k, ''); + } + db.setSystemState('instance_id', 'test-instance-id'); +}); + +describe('CloudBackupService — provider resolution', () => { + it('returns "disabled" when no provider is set', () => { + expect(CloudBackupService.getInstance().getProvider()).toBe('disabled'); + expect(CloudBackupService.getInstance().isEnabled()).toBe(false); + expect(CloudBackupService.getInstance().getResolvedConfig()).toBeNull(); + }); + + it('returns null config for custom provider when fields are missing', () => { + const db = DatabaseService.getInstance(); + db.updateGlobalSetting('cloud_backup_provider', 'custom'); + db.updateGlobalSetting('cloud_backup_endpoint', 'https://s3.example.com'); + // bucket, access_key, secret_key still missing + expect(CloudBackupService.getInstance().getResolvedConfig()).toBeNull(); + }); + + it('decrypts custom secret_key on read', () => { + const db = DatabaseService.getInstance(); + const crypto = CryptoService.getInstance(); + db.updateGlobalSetting('cloud_backup_provider', 'custom'); + db.updateGlobalSetting('cloud_backup_endpoint', 'https://s3.example.com'); + db.updateGlobalSetting('cloud_backup_region', 'us-east-1'); + db.updateGlobalSetting('cloud_backup_bucket', 'my-bucket'); + db.updateGlobalSetting('cloud_backup_access_key', 'AKIA1234'); + db.updateGlobalSetting('cloud_backup_secret_key', crypto.encrypt('plaintext-secret')); + db.updateGlobalSetting('cloud_backup_auto_upload', '1'); + + const cfg = CloudBackupService.getInstance().getResolvedConfig(); + expect(cfg).not.toBeNull(); + expect(cfg!.provider).toBe('custom'); + expect(cfg!.secretKey).toBe('plaintext-secret'); + expect(cfg!.autoUpload).toBe(true); + }); + + it('resolves sencho provider from system_state and forces auto_upload on', () => { + const db = DatabaseService.getInstance(); + const crypto = CryptoService.getInstance(); + db.updateGlobalSetting('cloud_backup_provider', 'sencho'); + db.setSystemState('sencho_cloud_backup_endpoint', 'https://r2.example.com'); + db.setSystemState('sencho_cloud_backup_bucket', 'sencho-cloud-backups'); + db.setSystemState('sencho_cloud_backup_access_key', 'R2-ACCESS'); + db.setSystemState('sencho_cloud_backup_secret_key', crypto.encrypt('R2-SECRET')); + db.setSystemState('sencho_cloud_backup_path_prefix', 'tenants/123/'); + db.setSystemState('sencho_cloud_backup_quota_bytes', '524288000'); + + const cfg = CloudBackupService.getInstance().getResolvedConfig(); + expect(cfg!.provider).toBe('sencho'); + expect(cfg!.region).toBe('auto'); + expect(cfg!.secretKey).toBe('R2-SECRET'); + expect(cfg!.autoUpload).toBe(true); + expect(cfg!.quotaBytes).toBe(524_288_000); + }); +}); + +describe('CloudBackupService — uploadSnapshot', () => { + function seedCustomProvider() { + const db = DatabaseService.getInstance(); + const crypto = CryptoService.getInstance(); + db.updateGlobalSetting('cloud_backup_provider', 'custom'); + db.updateGlobalSetting('cloud_backup_endpoint', 'https://s3.example.com'); + db.updateGlobalSetting('cloud_backup_region', 'us-east-1'); + db.updateGlobalSetting('cloud_backup_bucket', 'my-bucket'); + db.updateGlobalSetting('cloud_backup_access_key', 'AKIA1234'); + db.updateGlobalSetting('cloud_backup_secret_key', crypto.encrypt('test-secret')); + db.updateGlobalSetting('cloud_backup_path_prefix', 'sencho/'); + db.updateGlobalSetting('cloud_backup_auto_upload', '1'); + } + + it('uploads a snapshot with correct object key and gzipped tar archive', async () => { + seedCustomProvider(); + const db = DatabaseService.getInstance(); + + const snapshotId = db.createSnapshot('Test backup', 'admin', 1, 1, '[]'); + db.insertSnapshotFiles(snapshotId, [ + { nodeId: 1, nodeName: 'gateway', stackName: 'web', filename: 'compose.yaml', content: 'services: {}\n' }, + { nodeId: 1, nodeName: 'gateway', stackName: 'web', filename: '.env', content: 'KEY=value\n' }, + ]); + + sentSpy.mockResolvedValue({}); + await CloudBackupService.getInstance().uploadSnapshot(snapshotId); + + expect(s3ClientCtorSpy).toHaveBeenCalledWith(expect.objectContaining({ + endpoint: 'https://s3.example.com', + region: 'us-east-1', + forcePathStyle: true, + credentials: { accessKeyId: 'AKIA1234', secretAccessKey: 'test-secret' }, + })); + + const putCall = sentSpy.mock.calls.find(c => c[0].name === 'PutObjectCommand'); + expect(putCall).toBeDefined(); + const input = putCall![0].input as { Bucket: string; Key: string; Body: Buffer; ContentType: string }; + expect(input.Bucket).toBe('my-bucket'); + expect(input.Key).toContain('sencho/instances/test-instance-id/snapshots/'); + expect(input.Key).toMatch(/\.tar\.gz$/); + expect(input.ContentType).toBe('application/gzip'); + expect(Buffer.isBuffer(input.Body)).toBe(true); + expect(input.Body.byteLength).toBeGreaterThan(0); + + const decompressed = zlib.gunzipSync(input.Body); + const entries: Array<{ name: string; content: string }> = await new Promise((resolve, reject) => { + const extract = tar.extract(); + const list: Array<{ name: string; content: string }> = []; + extract.on('entry', (header, stream, next) => { + const chunks: Buffer[] = []; + stream.on('data', (c: Buffer) => chunks.push(c)); + stream.on('end', () => { list.push({ name: header.name, content: Buffer.concat(chunks).toString('utf-8') }); next(); }); + stream.resume(); + }); + extract.on('finish', () => resolve(list)); + extract.on('error', reject); + Readable.from(decompressed).pipe(extract); + }); + + const meta = entries.find(e => e.name === 'metadata.json'); + expect(meta).toBeDefined(); + const parsed = JSON.parse(meta!.content); + expect(parsed.id).toBe(snapshotId); + expect(parsed.instance_id).toBe('test-instance-id'); + expect(parsed.archive_version).toBe(1); + + expect(entries.find(e => e.name === 'nodes/1_gateway/web/compose.yaml')).toBeDefined(); + expect(entries.find(e => e.name === 'nodes/1_gateway/web/.env')).toBeDefined(); + + expect(CloudBackupService.getInstance().getUploadStatus(snapshotId).status).toBe('success'); + }); + + it('records failure status when upload throws', async () => { + seedCustomProvider(); + const db = DatabaseService.getInstance(); + const snapshotId = db.createSnapshot('Failing', 'admin', 0, 0, '[]'); + + sentSpy.mockRejectedValueOnce(new Error('AccessDenied: bad creds')); + await expect(CloudBackupService.getInstance().uploadSnapshot(snapshotId)).rejects.toThrow(/bad creds/); + + const status = CloudBackupService.getInstance().getUploadStatus(snapshotId); + expect(status.status).toBe('failed'); + expect(status.error).toContain('bad creds'); + }); + + it('throws when no provider is configured', async () => { + await expect(CloudBackupService.getInstance().uploadSnapshot(999)).rejects.toThrow(/not configured/i); + }); +}); + +describe('CloudBackupService — listCloudSnapshots', () => { + it('parses snapshot ID from object key and sorts by lastModified desc', async () => { + const db = DatabaseService.getInstance(); + const crypto = CryptoService.getInstance(); + db.updateGlobalSetting('cloud_backup_provider', 'custom'); + db.updateGlobalSetting('cloud_backup_endpoint', 'https://s3.example.com'); + db.updateGlobalSetting('cloud_backup_region', 'us-east-1'); + db.updateGlobalSetting('cloud_backup_bucket', 'b'); + db.updateGlobalSetting('cloud_backup_access_key', 'a'); + db.updateGlobalSetting('cloud_backup_secret_key', crypto.encrypt('s')); + db.updateGlobalSetting('cloud_backup_path_prefix', 'sencho/'); + + sentSpy.mockResolvedValueOnce({ + Contents: [ + { Key: 'sencho/instances/test-instance-id/snapshots/3_2026-01-01_a.tar.gz', Size: 100, LastModified: new Date('2026-01-01T00:00:00Z') }, + { Key: 'sencho/instances/test-instance-id/snapshots/7_2026-04-01_b.tar.gz', Size: 200, LastModified: new Date('2026-04-01T00:00:00Z') }, + ], + }); + const list = await CloudBackupService.getInstance().listCloudSnapshots(); + expect(list).toHaveLength(2); + expect(list[0].snapshotId).toBe(7); + expect(list[1].snapshotId).toBe(3); + }); +}); diff --git a/backend/src/__tests__/scheduler-service.test.ts b/backend/src/__tests__/scheduler-service.test.ts index 93d6966e..ad6105f9 100644 --- a/backend/src/__tests__/scheduler-service.test.ts +++ b/backend/src/__tests__/scheduler-service.test.ts @@ -145,6 +145,16 @@ vi.mock('../services/NotificationService', () => ({ }, })); +vi.mock('../services/CloudBackupService', () => ({ + CloudBackupService: { + getInstance: () => ({ + isEnabled: () => false, + isAutoUploadOn: () => false, + uploadSnapshot: vi.fn().mockResolvedValue(undefined), + }), + }, +})); + vi.mock('../services/NodeRegistry', () => ({ NodeRegistry: { getInstance: () => ({ diff --git a/backend/src/index.ts b/backend/src/index.ts index 131bbb7c..cf2d4e40 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -18,6 +18,7 @@ import { webhooksRouter } from './routes/webhooks'; import { usersRouter } from './routes/users'; import { gitSourcesRouter, stackGitSourceRouter } from './routes/gitSources'; import { fleetRouter } from './routes/fleet'; +import { cloudBackupRouter } from './routes/cloudBackup'; import { permissionsRouter } from './routes/permissions'; import { convertRouter } from './routes/convert'; import { alertsRouter } from './routes/alerts'; @@ -91,6 +92,7 @@ app.use('/api/stacks', stackLabelsRouter); app.use('/api/api-tokens', apiTokensRouter); app.use('/api/audit-log', auditLogRouter); app.use('/api/fleet', fleetRouter); +app.use('/api/cloud-backup', cloudBackupRouter); app.use('/api/webhooks', webhooksRouter); app.use('/api/users', usersRouter); app.use('/api/git-sources', gitSourcesRouter); diff --git a/backend/src/routes/cloudBackup.ts b/backend/src/routes/cloudBackup.ts new file mode 100644 index 00000000..78225403 --- /dev/null +++ b/backend/src/routes/cloudBackup.ts @@ -0,0 +1,243 @@ +import { Router, type Request, type Response } from 'express'; +import { CloudBackupService } from '../services/CloudBackupService'; +import { DatabaseService } from '../services/DatabaseService'; +import { CryptoService } from '../services/CryptoService'; +import { requireAdmin, requireAdmiral } from '../middleware/tierGates'; +import { rejectApiTokenScope } from '../middleware/apiTokenScope'; +import { getErrorMessage } from '../utils/errors'; + +const SCOPE_MESSAGE = 'API tokens cannot manage cloud backup configuration.'; +const SECRET_REDACTED = '***'; +const VALID_PROVIDERS = new Set(['disabled', 'sencho', 'custom']); + +function parseSnapshotIdParam(req: Request, res: Response): number | null { + const raw = req.params.id as string | undefined; + const parsed = parseInt(raw ?? '', 10); + if (isNaN(parsed) || parsed <= 0) { + res.status(400).json({ error: 'Invalid snapshot ID' }); + return null; + } + return parsed; +} + +function decodeObjectKey(req: Request, res: Response): string | null { + const raw = req.params.keyB64 as string | undefined; + if (!raw) { + res.status(400).json({ error: 'Missing object key' }); + return null; + } + try { + const decoded = Buffer.from(raw, 'base64url').toString('utf-8'); + if (!decoded || decoded.includes('..') || decoded.startsWith('/')) { + res.status(400).json({ error: 'Invalid object key' }); + return null; + } + return decoded; + } catch { + res.status(400).json({ error: 'Invalid object key encoding' }); + return null; + } +} + +export const cloudBackupRouter = Router(); + +cloudBackupRouter.get('/config', (req: Request, res: Response): void => { + if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return; + if (!requireAdmiral(req, res)) return; + try { + const db = DatabaseService.getInstance(); + const settings = db.getGlobalSettings(); + const provider = CloudBackupService.getInstance().getProvider(); + const senchoProvisioned = !!db.getSystemState('sencho_cloud_backup_provisioned_at'); + res.json({ + provider, + sencho_provisioned: senchoProvisioned, + sencho_provisioned_at: db.getSystemState('sencho_cloud_backup_provisioned_at'), + custom: { + endpoint: settings.cloud_backup_endpoint || '', + region: settings.cloud_backup_region || '', + bucket: settings.cloud_backup_bucket || '', + access_key: settings.cloud_backup_access_key || '', + secret_key: settings.cloud_backup_secret_key ? SECRET_REDACTED : '', + path_prefix: settings.cloud_backup_path_prefix || 'sencho/', + auto_upload: settings.cloud_backup_auto_upload === '1', + }, + }); + } catch (error) { + console.error('[CloudBackup] config get error:', error); + res.status(500).json({ error: 'Failed to load cloud backup config' }); + } +}); + +cloudBackupRouter.put('/config', (req: Request, res: Response): void => { + if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return; + if (!requireAdmin(req, res)) return; + if (!requireAdmiral(req, res)) return; + try { + const body = req.body ?? {}; + const provider = body.provider as string | undefined; + if (!provider || !VALID_PROVIDERS.has(provider)) { + res.status(400).json({ error: 'provider must be one of: disabled, sencho, custom' }); + return; + } + const db = DatabaseService.getInstance(); + const crypto = CryptoService.getInstance(); + db.updateGlobalSetting('cloud_backup_provider', provider); + + if (provider === 'custom') { + const c = body.custom ?? {}; + const endpoint = typeof c.endpoint === 'string' ? c.endpoint.trim() : ''; + const region = typeof c.region === 'string' ? c.region.trim() : ''; + const bucket = typeof c.bucket === 'string' ? c.bucket.trim() : ''; + const accessKey = typeof c.access_key === 'string' ? c.access_key.trim() : ''; + const pathPrefix = typeof c.path_prefix === 'string' ? c.path_prefix.trim() : 'sencho/'; + const autoUpload = c.auto_upload === true || c.auto_upload === '1' ? '1' : '0'; + + if (!endpoint || !bucket || !accessKey) { + res.status(400).json({ error: 'endpoint, bucket, and access_key are required for custom S3.' }); + return; + } + if (!/^https?:\/\//i.test(endpoint)) { + res.status(400).json({ error: 'endpoint must start with http:// or https://' }); + return; + } + + db.updateGlobalSetting('cloud_backup_endpoint', endpoint); + db.updateGlobalSetting('cloud_backup_region', region); + db.updateGlobalSetting('cloud_backup_bucket', bucket); + db.updateGlobalSetting('cloud_backup_access_key', accessKey); + db.updateGlobalSetting('cloud_backup_path_prefix', pathPrefix); + db.updateGlobalSetting('cloud_backup_auto_upload', autoUpload); + + const incomingSecret = typeof c.secret_key === 'string' ? c.secret_key : ''; + if (incomingSecret && incomingSecret !== SECRET_REDACTED) { + db.updateGlobalSetting('cloud_backup_secret_key', crypto.encrypt(incomingSecret)); + } + } + + res.status(204).send(); + } catch (error) { + console.error('[CloudBackup] config update error:', error); + res.status(500).json({ error: 'Failed to save cloud backup config' }); + } +}); + +cloudBackupRouter.post('/test', async (req: Request, res: Response): Promise => { + if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return; + if (!requireAdmin(req, res)) return; + if (!requireAdmiral(req, res)) return; + try { + const result = await CloudBackupService.getInstance().testConnection(); + res.json(result); + } catch (error) { + console.error('[CloudBackup] test error:', error); + res.status(500).json({ success: false, error: getErrorMessage(error, 'Connection test failed') }); + } +}); + +cloudBackupRouter.post('/provision', async (req: Request, res: Response): Promise => { + if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return; + if (!requireAdmin(req, res)) return; + if (!requireAdmiral(req, res)) return; + try { + const result = await CloudBackupService.getInstance().provisionSenchoCloudBackup(); + if (!result.success) { + res.status(400).json({ error: result.error || 'Provisioning failed' }); + return; + } + res.json({ success: true, quota_bytes: result.quotaBytes }); + } catch (error) { + console.error('[CloudBackup] provision error:', error); + res.status(500).json({ error: 'Failed to provision Sencho Cloud Backup' }); + } +}); + +cloudBackupRouter.get('/usage', async (req: Request, res: Response): Promise => { + if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return; + if (!requireAdmiral(req, res)) return; + try { + const svc = CloudBackupService.getInstance(); + if (svc.getProvider() !== 'sencho') { + res.status(400).json({ error: 'Usage is only available for Sencho Cloud Backup' }); + return; + } + const usage = await svc.getSenchoCloudBackupUsage(); + res.json(usage); + } catch (error) { + console.error('[CloudBackup] usage error:', error); + res.status(502).json({ error: getErrorMessage(error, 'Failed to fetch usage') }); + } +}); + +cloudBackupRouter.get('/snapshots', async (req: Request, res: Response): Promise => { + if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return; + if (!requireAdmiral(req, res)) return; + try { + const entries = await CloudBackupService.getInstance().listCloudSnapshots(); + res.json(entries); + } catch (error) { + console.error('[CloudBackup] list error:', error); + res.status(502).json({ error: getErrorMessage(error, 'Failed to list cloud snapshots') }); + } +}); + +cloudBackupRouter.post('/upload/:id', async (req: Request, res: Response): Promise => { + if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return; + if (!requireAdmin(req, res)) return; + if (!requireAdmiral(req, res)) return; + const id = parseSnapshotIdParam(req, res); + if (id == null) return; + try { + const db = DatabaseService.getInstance(); + if (!db.getSnapshot(id)) { + res.status(404).json({ error: 'Snapshot not found' }); + return; + } + await CloudBackupService.getInstance().uploadSnapshot(id); + res.status(202).json({ status: 'success', snapshot_id: id }); + } catch (error) { + console.error('[CloudBackup] upload error:', error); + res.status(502).json({ error: getErrorMessage(error, 'Cloud upload failed') }); + } +}); + +cloudBackupRouter.get('/status/:id', (req: Request, res: Response): void => { + if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return; + if (!requireAdmiral(req, res)) return; + const id = parseSnapshotIdParam(req, res); + if (id == null) return; + res.json(CloudBackupService.getInstance().getUploadStatus(id)); +}); + +cloudBackupRouter.get('/object/:keyB64/download', async (req: Request, res: Response): Promise => { + if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return; + if (!requireAdmiral(req, res)) return; + const objectKey = decodeObjectKey(req, res); + if (!objectKey) return; + try { + const buffer = await CloudBackupService.getInstance().downloadSnapshot(objectKey); + const filename = objectKey.split('/').pop() || 'snapshot.tar.gz'; + res.setHeader('Content-Type', 'application/gzip'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.setHeader('Content-Length', String(buffer.byteLength)); + res.end(buffer); + } catch (error) { + console.error('[CloudBackup] download error:', error); + res.status(502).json({ error: getErrorMessage(error, 'Failed to download cloud snapshot') }); + } +}); + +cloudBackupRouter.delete('/object/:keyB64', async (req: Request, res: Response): Promise => { + if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return; + if (!requireAdmin(req, res)) return; + if (!requireAdmiral(req, res)) return; + const objectKey = decodeObjectKey(req, res); + if (!objectKey) return; + try { + await CloudBackupService.getInstance().deleteCloudSnapshot(objectKey); + res.status(204).send(); + } catch (error) { + console.error('[CloudBackup] delete error:', error); + res.status(502).json({ error: getErrorMessage(error, 'Failed to delete cloud snapshot') }); + } +}); diff --git a/backend/src/routes/fleet.ts b/backend/src/routes/fleet.ts index 88e308ef..3b97510d 100644 --- a/backend/src/routes/fleet.ts +++ b/backend/src/routes/fleet.ts @@ -20,6 +20,8 @@ import { getLatestVersion } from '../utils/version-check'; import { isValidStackName } from '../utils/validation'; import { isDebugEnabled } from '../utils/debug'; import { getErrorMessage } from '../utils/errors'; +import { CloudBackupService } from '../services/CloudBackupService'; +import { NotificationService } from '../services/NotificationService'; const updateTracker = FleetUpdateTrackerService.getInstance(); const UPDATE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes @@ -819,6 +821,17 @@ fleetRouter.post('/snapshots', authMiddleware, async (req: Request, res: Respons db.insertSnapshotFiles(snapshotId, allFiles); } + const cloudSvc = CloudBackupService.getInstance(); + if (cloudSvc.isEnabled() && cloudSvc.isAutoUploadOn()) { + void cloudSvc.uploadSnapshot(snapshotId).catch(uploadErr => { + const message = uploadErr instanceof Error ? uploadErr.message : String(uploadErr); + console.error('[Fleet Snapshot] Cloud upload failed:', message); + void NotificationService.getInstance() + .dispatchAlert('error', 'system', `Cloud backup upload failed for snapshot ${snapshotId}: ${message}`) + .catch(() => { /* notification dispatch is best-effort */ }); + }); + } + console.log('[Fleet] Snapshot created:', capturedNodes.length, 'nodes,', totalStacks, 'stacks'); if (isDebugEnabled()) { console.debug(`[Fleet:debug] Snapshot ${snapshotId} capture completed in ${Date.now() - captureStart}ms, ${allFiles.length} file(s) stored`); diff --git a/backend/src/services/CloudBackupService.ts b/backend/src/services/CloudBackupService.ts new file mode 100644 index 00000000..662366ac --- /dev/null +++ b/backend/src/services/CloudBackupService.ts @@ -0,0 +1,400 @@ +/** + * CloudBackupService — off-site replication for fleet snapshots. + * + * Two providers share the same S3-compatible code path: + * - 'sencho' : managed Sencho Cloud Backup. Credentials provisioned by + * sencho.io/api/cloud-backup/provision and stored in system_state. + * - 'custom' : bring-your-own-bucket. Credentials user-configured in global_settings. + * + * Routes call this service after a snapshot is persisted to SQLite. The service + * reads snapshot rows from DatabaseService, packs them into a tar.gz archive, + * and uploads via @aws-sdk/client-s3. + */ + +import { + S3Client, + PutObjectCommand, + GetObjectCommand, + ListObjectsV2Command, + DeleteObjectCommand, + HeadBucketCommand, +} from '@aws-sdk/client-s3'; +import { Readable } from 'stream'; +import * as zlib from 'zlib'; +import * as tar from 'tar-stream'; +import axios from 'axios'; +import { DatabaseService, type FleetSnapshotFile } from './DatabaseService'; +import { CryptoService } from './CryptoService'; +import { LicenseService } from './LicenseService'; +import { getErrorMessage } from '../utils/errors'; +import { isDebugEnabled } from '../utils/debug'; + +export type CloudProvider = 'disabled' | 'sencho' | 'custom'; +export type UploadStatus = 'idle' | 'uploading' | 'success' | 'failed'; + +export interface ResolvedCloudConfig { + provider: 'sencho' | 'custom'; + endpoint: string; + region: string; + bucket: string; + accessKey: string; + secretKey: string; + pathPrefix: string; + autoUpload: boolean; + quotaBytes?: number; +} + +export interface CloudSnapshotEntry { + objectKey: string; + sizeBytes: number; + lastModified: string | null; + snapshotId: number | null; +} + +export interface ProvisionResult { + success: boolean; + quotaBytes?: number; + error?: string; +} + +export interface UploadStatusEntry { + status: UploadStatus; + objectKey?: string; + error?: string; + updatedAt: number; +} + +const SENCHO_CLOUD_BACKUP_API_DEFAULT = 'https://sencho.io'; +const PROVIDER_KEY = 'cloud_backup_provider'; +const SUCCESS_STATUS_TTL_MS = 5 * 60 * 1000; + +export class CloudBackupService { + private static instance: CloudBackupService; + private uploadStatus = new Map(); + private statusGcTimer: ReturnType | null = null; + + private constructor() { + this.statusGcTimer = setInterval(() => this.gcUploadStatus(), 60 * 1000); + if (typeof this.statusGcTimer.unref === 'function') this.statusGcTimer.unref(); + } + + public static getInstance(): CloudBackupService { + if (!CloudBackupService.instance) { + CloudBackupService.instance = new CloudBackupService(); + } + return CloudBackupService.instance; + } + + public stop(): void { + if (this.statusGcTimer) { + clearInterval(this.statusGcTimer); + this.statusGcTimer = null; + } + } + + // ─── Configuration ───────────────────────────────────────────────────────── + + public getProvider(): CloudProvider { + const value = DatabaseService.getInstance().getGlobalSettings()[PROVIDER_KEY]; + if (value === 'sencho' || value === 'custom') return value; + return 'disabled'; + } + + public isEnabled(): boolean { + return this.getResolvedConfig() !== null; + } + + public isAutoUploadOn(): boolean { + const cfg = this.getResolvedConfig(); + return cfg?.autoUpload === true; + } + + public getResolvedConfig(): ResolvedCloudConfig | null { + const provider = this.getProvider(); + if (provider === 'disabled') return null; + const db = DatabaseService.getInstance(); + const crypto = CryptoService.getInstance(); + + if (provider === 'sencho') { + const endpoint = db.getSystemState('sencho_cloud_backup_endpoint'); + const bucket = db.getSystemState('sencho_cloud_backup_bucket'); + const accessKey = db.getSystemState('sencho_cloud_backup_access_key'); + const secretRaw = db.getSystemState('sencho_cloud_backup_secret_key'); + const pathPrefix = db.getSystemState('sencho_cloud_backup_path_prefix') || ''; + const quotaRaw = db.getSystemState('sencho_cloud_backup_quota_bytes'); + if (!endpoint || !bucket || !accessKey || !secretRaw) return null; + return { + provider: 'sencho', + endpoint, + region: 'auto', + bucket, + accessKey, + secretKey: crypto.decrypt(secretRaw), + pathPrefix, + autoUpload: true, + quotaBytes: quotaRaw ? parseInt(quotaRaw, 10) : undefined, + }; + } + + const settings = db.getGlobalSettings(); + const endpoint = settings.cloud_backup_endpoint; + const region = settings.cloud_backup_region; + const bucket = settings.cloud_backup_bucket; + const accessKey = settings.cloud_backup_access_key; + const secretRaw = settings.cloud_backup_secret_key; + const pathPrefix = settings.cloud_backup_path_prefix || 'sencho/'; + if (!endpoint || !bucket || !accessKey || !secretRaw) return null; + return { + provider: 'custom', + endpoint, + region: region || 'us-east-1', + bucket, + accessKey, + secretKey: crypto.decrypt(secretRaw), + pathPrefix, + autoUpload: settings.cloud_backup_auto_upload === '1', + }; + } + + // ─── Sencho Cloud Backup lifecycle ───────────────────────────────────────── + + public async provisionSenchoCloudBackup(): Promise { + const db = DatabaseService.getInstance(); + const crypto = CryptoService.getInstance(); + const licenseKey = db.getSystemState('license_key'); + if (!licenseKey) return { success: false, error: 'No license key found. Activate an Admiral license first.' }; + + const variant = LicenseService.getInstance().getVariant(); + if (variant !== 'admiral') return { success: false, error: 'Sencho Cloud Backup requires the Admiral tier.' }; + + const apiBase = process.env.SENCHO_CLOUD_BACKUP_API || SENCHO_CLOUD_BACKUP_API_DEFAULT; + try { + const res = await axios.post(`${apiBase}/api/cloud-backup/provision`, { license_key: licenseKey }, { timeout: 15000 }); + const data = res.data as { + endpoint: string; + region?: string; + bucket: string; + access_key: string; + secret_key: string; + path_prefix: string; + quota_bytes: number; + }; + db.setSystemState('sencho_cloud_backup_endpoint', data.endpoint); + db.setSystemState('sencho_cloud_backup_bucket', data.bucket); + db.setSystemState('sencho_cloud_backup_access_key', data.access_key); + db.setSystemState('sencho_cloud_backup_secret_key', crypto.encrypt(data.secret_key)); + db.setSystemState('sencho_cloud_backup_path_prefix', data.path_prefix); + db.setSystemState('sencho_cloud_backup_quota_bytes', String(data.quota_bytes)); + db.setSystemState('sencho_cloud_backup_provisioned_at', new Date().toISOString()); + db.updateGlobalSetting(PROVIDER_KEY, 'sencho'); + return { success: true, quotaBytes: data.quota_bytes }; + } catch (err) { + const responseError = (err as { response?: { data?: { error?: string } } }).response?.data?.error; + return { success: false, error: responseError || getErrorMessage(err, 'Failed to provision Sencho Cloud Backup.') }; + } + } + + public async refreshSenchoCloudBackupCredentials(): Promise { + const result = await this.provisionSenchoCloudBackup(); + if (!result.success) throw new Error(result.error || 'Failed to refresh Sencho Cloud Backup credentials.'); + } + + public async getSenchoCloudBackupUsage(): Promise<{ used_bytes: number; quota_bytes: number; object_count: number }> { + const db = DatabaseService.getInstance(); + const licenseKey = db.getSystemState('license_key'); + if (!licenseKey) throw new Error('No license key found.'); + const apiBase = process.env.SENCHO_CLOUD_BACKUP_API || SENCHO_CLOUD_BACKUP_API_DEFAULT; + const res = await axios.post(`${apiBase}/api/cloud-backup/usage`, { license_key: licenseKey }, { timeout: 15000 }); + const data = res.data as { used_bytes: number; quota_bytes: number; object_count: number }; + return data; + } + + // ─── S3 operations ───────────────────────────────────────────────────────── + + public async testConnection(): Promise<{ success: boolean; error?: string }> { + const cfg = this.getResolvedConfig(); + if (!cfg) return { success: false, error: 'No cloud backup configuration is active.' }; + try { + const client = this.buildS3Client(cfg); + await client.send(new HeadBucketCommand({ Bucket: cfg.bucket })); + return { success: true }; + } catch (err) { + return { success: false, error: getErrorMessage(err, 'Connection test failed.') }; + } + } + + public async uploadSnapshot(snapshotId: number): Promise { + const cfg = this.getResolvedConfig(); + if (!cfg) throw new Error('Cloud backup is not configured.'); + const db = DatabaseService.getInstance(); + const snapshot = db.getSnapshot(snapshotId); + if (!snapshot) throw new Error(`Snapshot ${snapshotId} not found.`); + const files = db.getSnapshotFiles(snapshotId); + const objectKey = this.buildObjectKey(cfg, snapshot.id, snapshot.description, snapshot.created_at); + + this.setStatus(snapshotId, { status: 'uploading', objectKey, updatedAt: Date.now() }); + try { + const archive = await this.buildArchive(snapshot, files); + const client = this.buildS3Client(cfg); + await client.send(new PutObjectCommand({ + Bucket: cfg.bucket, + Key: objectKey, + Body: archive, + ContentType: 'application/gzip', + })); + this.setStatus(snapshotId, { status: 'success', objectKey, updatedAt: Date.now() }); + if (isDebugEnabled()) { + console.log(`[CloudBackup:debug] Uploaded snapshot ${snapshotId} (${archive.byteLength} bytes) to ${cfg.bucket}/${objectKey}`); + } + } catch (err) { + const message = getErrorMessage(err, 'Cloud upload failed.'); + this.setStatus(snapshotId, { status: 'failed', objectKey, error: message, updatedAt: Date.now() }); + throw new Error(message); + } + } + + public async downloadSnapshot(objectKey: string): Promise { + const cfg = this.getResolvedConfig(); + if (!cfg) throw new Error('Cloud backup is not configured.'); + const client = this.buildS3Client(cfg); + const result = await client.send(new GetObjectCommand({ Bucket: cfg.bucket, Key: objectKey })); + const body = result.Body as Readable | undefined; + if (!body) throw new Error('Empty response body.'); + return await streamToBuffer(body); + } + + public async listCloudSnapshots(): Promise { + const cfg = this.getResolvedConfig(); + if (!cfg) return []; + const client = this.buildS3Client(cfg); + const prefix = `${cfg.pathPrefix}instances/${this.getInstanceId()}/snapshots/`; + const result = await client.send(new ListObjectsV2Command({ + Bucket: cfg.bucket, + Prefix: prefix, + MaxKeys: 1000, + })); + const objects = result.Contents || []; + return objects + .filter(o => !!o.Key) + .map(o => { + const key = o.Key as string; + const basename = key.split('/').pop() || key; + const idMatch = basename.match(/^(\d+)_/); + return { + objectKey: key, + sizeBytes: o.Size ?? 0, + lastModified: o.LastModified ? o.LastModified.toISOString() : null, + snapshotId: idMatch ? parseInt(idMatch[1], 10) : null, + }; + }) + .sort((a, b) => (b.lastModified || '').localeCompare(a.lastModified || '')); + } + + public async deleteCloudSnapshot(objectKey: string): Promise { + const cfg = this.getResolvedConfig(); + if (!cfg) throw new Error('Cloud backup is not configured.'); + const client = this.buildS3Client(cfg); + await client.send(new DeleteObjectCommand({ Bucket: cfg.bucket, Key: objectKey })); + } + + // ─── Status tracking ─────────────────────────────────────────────────────── + + public getUploadStatus(snapshotId: number): UploadStatusEntry { + return this.uploadStatus.get(snapshotId) || { status: 'idle', updatedAt: 0 }; + } + + private setStatus(snapshotId: number, entry: UploadStatusEntry): void { + this.uploadStatus.set(snapshotId, entry); + } + + private gcUploadStatus(): void { + const now = Date.now(); + for (const [id, entry] of this.uploadStatus.entries()) { + if (entry.status === 'success' && now - entry.updatedAt > SUCCESS_STATUS_TTL_MS) { + this.uploadStatus.delete(id); + } + } + } + + // ─── Internals ───────────────────────────────────────────────────────────── + + private buildS3Client(cfg: ResolvedCloudConfig): S3Client { + return new S3Client({ + endpoint: cfg.endpoint, + region: cfg.region, + credentials: { accessKeyId: cfg.accessKey, secretAccessKey: cfg.secretKey }, + forcePathStyle: true, + }); + } + + private getInstanceId(): string { + return DatabaseService.getInstance().getSystemState('instance_id') || 'unknown'; + } + + private buildObjectKey(cfg: ResolvedCloudConfig, snapshotId: number, description: string, createdAt: number): string { + const ts = new Date(createdAt).toISOString().replace(/[:.]/g, '-'); + const slug = description.slice(0, 40).replace(/[^a-z0-9]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'snapshot'; + return `${cfg.pathPrefix}instances/${this.getInstanceId()}/snapshots/${snapshotId}_${ts}_${slug}.tar.gz`; + } + + private async buildArchive( + snapshot: { id: number; description: string; created_by: string; node_count: number; stack_count: number; skipped_nodes: string; created_at: number }, + files: FleetSnapshotFile[], + ): Promise { + const pack = tar.pack(); + const metadata = { + id: snapshot.id, + description: snapshot.description, + created_by: snapshot.created_by, + created_at: snapshot.created_at, + node_count: snapshot.node_count, + stack_count: snapshot.stack_count, + skipped_nodes: safeParseJson(snapshot.skipped_nodes, []), + instance_id: this.getInstanceId(), + archive_version: 1, + }; + pack.entry({ name: 'metadata.json' }, JSON.stringify(metadata, null, 2)); + + for (const file of files) { + const safeNodeName = sanitizePathSegment(file.node_name); + const safeStackName = sanitizePathSegment(file.stack_name); + const safeFilename = sanitizePathSegment(file.filename); + const entryPath = `nodes/${file.node_id}_${safeNodeName}/${safeStackName}/${safeFilename}`; + pack.entry({ name: entryPath }, file.content); + } + pack.finalize(); + + const gzip = zlib.createGzip(); + const chunks: Buffer[] = []; + return await new Promise((resolve, reject) => { + gzip.on('data', (chunk: Buffer) => chunks.push(chunk)); + gzip.on('end', () => resolve(Buffer.concat(chunks))); + gzip.on('error', reject); + pack.on('error', reject); + pack.pipe(gzip); + }); + } +} + +// ─── Helpers ─────────────────────────────────────────────────────────────────── + +function streamToBuffer(stream: Readable): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + stream.on('data', (chunk: Buffer) => chunks.push(chunk)); + stream.on('end', () => resolve(Buffer.concat(chunks))); + stream.on('error', reject); + }); +} + +function sanitizePathSegment(input: string): string { + // Preserve dotfiles like `.env` while neutralising traversal (`..`) and path separators. + const cleaned = input.replace(/\.\./g, '_').replace(/[\\/]/g, '_'); + if (/^\.+$/.test(cleaned)) return '_'; + return cleaned; +} + +function safeParseJson(value: string | null | undefined, fallback: T): T { + if (!value) return fallback; + try { return JSON.parse(value) as T; } catch { return fallback; } +} diff --git a/backend/src/services/SchedulerService.ts b/backend/src/services/SchedulerService.ts index 4a427ffc..33b33195 100644 --- a/backend/src/services/SchedulerService.ts +++ b/backend/src/services/SchedulerService.ts @@ -15,6 +15,7 @@ import { NotificationService } from './NotificationService'; import TrivyService from './TrivyService'; import type { ScanAllNodeImagesResult } from './TrivyService'; import TrivyInstaller from './TrivyInstaller'; +import { CloudBackupService } from './CloudBackupService'; const TRIVY_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; const TRIVY_UPDATE_CHECK_STARTUP_DELAY_MS = 5 * 60 * 1000; @@ -501,11 +502,25 @@ export class SchedulerService { db.insertSnapshotFiles(snapshotId, allFiles); } - if (isDebugEnabled()) { - console.debug(`[SchedulerService:debug] Snapshot task ${task.id}: captured ${capturedNodes.length} node(s), ${totalStacks} stack(s), ${allFiles.length} file(s), skipped ${skippedNodes.length}`); + let cloudUploadNote = ''; + const cloudSvc = CloudBackupService.getInstance(); + if (cloudSvc.isEnabled() && cloudSvc.isAutoUploadOn()) { + try { + await cloudSvc.uploadSnapshot(snapshotId); + cloudUploadNote = ', cloud upload OK'; + } catch (err) { + const message = getErrorMessage(err, 'Cloud upload failed'); + console.error('[SchedulerService] Cloud upload failed:', message); + this.safeDispatch('warning', 'system', `Cloud backup failed for scheduled snapshot ${snapshotId}: ${message}`); + cloudUploadNote = ', cloud upload FAILED'; + } } - return `Fleet snapshot created (id=${snapshotId}, ${capturedNodes.length} node(s), ${totalStacks} stack(s)${skippedNodes.length > 0 ? `, ${skippedNodes.length} skipped` : ''})`; + if (isDebugEnabled()) { + console.debug(`[SchedulerService:debug] Snapshot task ${task.id}: captured ${capturedNodes.length} node(s), ${totalStacks} stack(s), ${allFiles.length} file(s), skipped ${skippedNodes.length}${cloudUploadNote}`); + } + + return `Fleet snapshot created (id=${snapshotId}, ${capturedNodes.length} node(s), ${totalStacks} stack(s)${skippedNodes.length > 0 ? `, ${skippedNodes.length} skipped` : ''}${cloudUploadNote})`; } private async executePrune(task: ScheduledTask): Promise { diff --git a/backend/src/utils/audit-summaries.ts b/backend/src/utils/audit-summaries.ts index 74b0e5dd..4602d997 100644 --- a/backend/src/utils/audit-summaries.ts +++ b/backend/src/utils/audit-summaries.ts @@ -86,6 +86,13 @@ export const AUDIT_ROUTE_SUMMARIES: Record = { 'POST /fleet/nodes/*/update': 'Triggered fleet node update', 'POST /fleet/update-all': 'Triggered fleet-wide update', + // Cloud backup + 'PUT /cloud-backup/config': 'Updated cloud backup config', + 'POST /cloud-backup/test': 'Tested cloud backup connection', + 'POST /cloud-backup/provision': 'Provisioned Sencho Cloud Backup', + 'POST /cloud-backup/upload': 'Uploaded snapshot to cloud', + 'DELETE /cloud-backup/object': 'Deleted cloud snapshot', + // SSO 'PUT /sso/config': 'Updated SSO configuration', 'DELETE /sso/config': 'Deleted SSO configuration', diff --git a/docs/features/fleet-backups.mdx b/docs/features/fleet-backups.mdx index e2c990a1..38b13075 100644 --- a/docs/features/fleet-backups.mdx +++ b/docs/features/fleet-backups.mdx @@ -82,6 +82,59 @@ Sencho writes the snapshot's files back to the target node: Admins can delete snapshots from the list view by clicking the trash icon on the right side of each row. A confirmation dialog asks you to confirm before the snapshot is permanently removed. Deleting a snapshot removes all captured file data from the database. This action cannot be undone. +## Cloud Backup + + + Cloud Backup requires an Admiral license. Configure it in **Settings → Cloud Backup**. + + +Cloud Backup mirrors every fleet snapshot to off-site storage so your snapshots survive local disk failure. Two storage modes are supported. + +### Sencho Cloud Backup (included) + +A managed 500 MB allowance backed by Cloudflare R2, included with every Admiral license. Open **Settings → Cloud Backup**, choose **Sencho Cloud Backup**, and click **Activate**. Sencho exchanges your license key for scoped storage credentials and starts replicating new snapshots automatically. The settings panel shows your storage usage and lets you reprovision credentials if needed. + +### Custom S3 (BYOB) + +Bring any S3-compatible bucket: AWS S3, MinIO, Backblaze B2, Wasabi, or your own Cloudflare R2 token. Choose **Custom S3** in the storage-mode dropdown and fill in: + +- **Endpoint URL** (e.g. `https://s3.us-east-1.amazonaws.com`, `https://my-minio.example.com:9000`) +- **Region** (e.g. `us-east-1`, or `auto` for R2) +- **Bucket** name +- **Path Prefix** (default `sencho/`) +- **Access Key ID** and **Secret Access Key** +- **Auto-upload** toggle + +Click **Test** to verify connectivity, then **Save**. Secret keys are encrypted at rest. Sencho only sends them to your configured endpoint. + +### Manual upload vs auto-upload + +When auto-upload is on, every fleet snapshot is replicated as soon as it is created. Manual snapshots from the **Fleet → Snapshots** view upload asynchronously so the UI returns immediately; scheduled snapshots block on the upload so the task's success status reflects cloud durability. + +To upload a single snapshot on demand, open the **Snapshots** tab in Fleet View. Each row that hasn't been mirrored yet shows a cloud-upload action next to the **View** button. Once a snapshot is in the cloud, a small cloud icon appears next to its description. + +### Browsing and downloading cloud snapshots + +The **Cloud Snapshots** panel in **Settings → Cloud Backup** lists every archive currently in your bucket, with size and last-modified timestamp. Click the download icon to save a `.tar.gz` archive locally for off-host disaster recovery. Each archive contains a `metadata.json` describing the snapshot and a `nodes/` tree with the captured compose and environment files, organised by node and stack. + +### Restoring from a cloud snapshot + +For in-place rollback, use the **Restore** action on the snapshot detail view as described above; the local copy is the source of truth for live restore. Cloud snapshots cover the disaster-recovery case where the local disk is gone: download the archive, extract it, and bring up a fresh Sencho instance pointed at the recovered files. + +### Cloud Backup troubleshooting + +#### Bad credentials + +If **Test** reports an authentication error, double-check the Access Key ID, Secret Access Key, and bucket. Some providers require you to enable S3-compatible API access on the bucket separately. For MinIO, verify the user has read/write permission on the target bucket. + +#### Over quota (Sencho Cloud Backup) + +Sencho Cloud Backup has a 500 MB allowance per license. When you hit the cap, new uploads fail with a quota error. Delete older cloud snapshots from the **Cloud Snapshots** panel to free space. The local copies are unaffected. + +#### Network timeout or 5xx error + +Transient errors surface as a notification. Retry by clicking the cloud-upload action on the snapshot row, or wait for the next scheduled snapshot which will retry on its own. Persistent failures usually indicate an endpoint outage; verify the storage provider is reachable from your Sencho host. + ## Access control | Action | Admin | Node Admin | Deployer | Auditor | Viewer | diff --git a/docs/images/cloud-backup/cloud-backup-custom.png b/docs/images/cloud-backup/cloud-backup-custom.png new file mode 100644 index 00000000..5a007d77 Binary files /dev/null and b/docs/images/cloud-backup/cloud-backup-custom.png differ diff --git a/docs/images/cloud-backup/cloud-backup-disabled.png b/docs/images/cloud-backup/cloud-backup-disabled.png new file mode 100644 index 00000000..f751d880 Binary files /dev/null and b/docs/images/cloud-backup/cloud-backup-disabled.png differ diff --git a/frontend/src/components/FleetSnapshots.tsx b/frontend/src/components/FleetSnapshots.tsx index aea5e867..92f1721d 100644 --- a/frontend/src/components/FleetSnapshots.tsx +++ b/frontend/src/components/FleetSnapshots.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react'; import { Camera, ArrowLeft, Server, Layers, FileText, AlertTriangle, Trash2, Eye, ChevronDown, ChevronLeft, ChevronRight, Plus, Loader2, RotateCcw, + Cloud, CloudUpload, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -20,6 +21,7 @@ import { import { ScrollArea } from '@/components/ui/scroll-area'; import { apiFetch } from '@/lib/api'; import { useAuth } from '@/context/AuthContext'; +import { useLicense } from '@/context/LicenseContext'; import { toast } from '@/components/ui/toast-store'; // --- Types --- @@ -66,6 +68,8 @@ const PAGE_SIZE = 10; export default function FleetSnapshots() { const { isAdmin } = useAuth(); + const { license, isPaid } = useLicense(); + const isAdmiral = isPaid && license?.variant === 'admiral'; const [snapshots, setSnapshots] = useState([]); const [loading, setLoading] = useState(true); @@ -81,6 +85,8 @@ export default function FleetSnapshots() { const [restoringStack, setRestoringStack] = useState(null); const [deletingId, setDeletingId] = useState(null); const [page, setPage] = useState(0); + const [cloudSnapshotIds, setCloudSnapshotIds] = useState>(new Set()); + const [uploadingId, setUploadingId] = useState(null); const totalPages = Math.max(1, Math.ceil(snapshots.length / PAGE_SIZE)); const safePage = Math.min(page, totalPages - 1); @@ -111,6 +117,37 @@ export default function FleetSnapshots() { fetchSnapshots(); }, [fetchSnapshots]); + const fetchCloudSnapshots = useCallback(async () => { + if (!isAdmiral) return; + try { + const res = await apiFetch('/cloud-backup/snapshots', { localOnly: true }); + if (!res.ok) return; + const data = await res.json() as Array<{ snapshotId: number | null }>; + setCloudSnapshotIds(new Set(data.map(d => d.snapshotId).filter((id): id is number => id != null))); + } catch { + // best-effort; cloud indicators stay hidden on failure + } + }, [isAdmiral]); + + useEffect(() => { + fetchCloudSnapshots(); + }, [fetchCloudSnapshots]); + + const handleCloudUpload = async (id: number) => { + setUploadingId(id); + try { + const res = await apiFetch(`/cloud-backup/upload/${id}`, { method: 'POST', localOnly: true }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error((data as { error?: string }).error || `Upload failed (${res.status})`); + toast.success('Snapshot uploaded to cloud.'); + await fetchCloudSnapshots(); + } catch (err) { + toast.error((err as Error)?.message || 'Cloud upload failed.'); + } finally { + setUploadingId(null); + } + }; + const handleCreate = async () => { setCreating(true); const loadingId = toast.loading('Creating fleet snapshot...'); @@ -533,11 +570,20 @@ export default function FleetSnapshots() { {new Date(snapshot.created_at).toLocaleString()} - {snapshot.description ? ( - snapshot.description - ) : ( - No description - )} +
+ {snapshot.description ? ( + {snapshot.description} + ) : ( + No description + )} + {cloudSnapshotIds.has(snapshot.id) && ( + + )} +
{snapshot.node_count} node{snapshot.node_count !== 1 ? 's' : ''} @@ -568,6 +614,22 @@ export default function FleetSnapshots() { View + {isAdmin && isAdmiral && !cloudSnapshotIds.has(snapshot.id) && ( + + )} {isAdmin && ( diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 855bfa98..255f9ed4 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -39,6 +39,7 @@ import { NotificationRoutingSection, WebhooksSection, SecuritySection, + CloudBackupSection, DeveloperSection, AppStoreSection, SupportSection, @@ -318,6 +319,7 @@ export function SettingsModal({ isOpen, onClose, initialSection, onLabelsChanged case 'notification-routing': return ; case 'webhooks': return ; case 'security': return ; + case 'cloud-backup': return ; case 'developer': return ( ('disabled'); + const [senchoProvisioned, setSenchoProvisioned] = useState(false); + const [custom, setCustom] = useState(EMPTY_CUSTOM); + const [originalSecretSaved, setOriginalSecretSaved] = useState(false); + const [usage, setUsage] = useState(null); + const [snapshots, setSnapshots] = useState([]); + const [testing, setTesting] = useState(false); + const [provisioning, setProvisioning] = useState(false); + const [deleteKey, setDeleteKey] = useState(null); + + const loadConfig = useCallback(async () => { + try { + const res = await apiFetch('/cloud-backup/config'); + if (!res.ok) throw new Error(`Failed to load config (${res.status})`); + const data: ConfigResponse = await res.json(); + setProvider(data.provider); + setSenchoProvisioned(data.sencho_provisioned); + setCustom({ ...data.custom, secret_key: '' }); + setOriginalSecretSaved(!!data.custom.secret_key); + } catch (err) { + toast.error((err as Error)?.message || 'Failed to load cloud backup config.'); + } finally { + setLoading(false); + } + }, []); + + const loadUsage = useCallback(async () => { + try { + const res = await apiFetch('/cloud-backup/usage'); + if (res.ok) setUsage(await res.json()); + } catch { + // Usage is informational; failures shouldn't surface as toasts. + } + }, []); + + const loadSnapshots = useCallback(async () => { + try { + const res = await apiFetch('/cloud-backup/snapshots'); + if (res.ok) setSnapshots(await res.json()); + } catch { + // Best-effort; the panel renders empty when listing fails. + } + }, []); + + useEffect(() => { + loadConfig(); + }, [loadConfig]); + + useEffect(() => { + if (provider === 'sencho' && senchoProvisioned) loadUsage(); + }, [provider, senchoProvisioned, loadUsage]); + + useEffect(() => { + if (provider !== 'disabled') loadSnapshots(); + else setSnapshots([]); + }, [provider, loadSnapshots]); + + const handleProviderChange = async (next: string) => { + const nextProvider = next as Provider; + setProvider(nextProvider); + if (nextProvider === 'sencho' && !senchoProvisioned) return; + setSaving(true); + try { + const body = nextProvider === 'custom' ? { provider: nextProvider, custom: { ...custom, secret_key: '' } } : { provider: nextProvider }; + const res = await apiFetch('/cloud-backup/config', { + method: 'PUT', + body: JSON.stringify(body), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err?.error || 'Failed to save provider'); + } + toast.success('Cloud backup provider updated.'); + } catch (err) { + toast.error((err as Error)?.message || 'Failed to update provider.'); + } finally { + setSaving(false); + } + }; + + const handleSaveCustom = async () => { + setSaving(true); + try { + const payload = { + provider: 'custom', + custom: { + endpoint: custom.endpoint, + region: custom.region, + bucket: custom.bucket, + access_key: custom.access_key, + secret_key: custom.secret_key || (originalSecretSaved ? '***' : ''), + path_prefix: custom.path_prefix, + auto_upload: custom.auto_upload, + }, + }; + const res = await apiFetch('/cloud-backup/config', { + method: 'PUT', + body: JSON.stringify(payload), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err?.error || 'Failed to save configuration'); + } + toast.success('Custom S3 configuration saved.'); + setCustom(c => ({ ...c, secret_key: '' })); + setOriginalSecretSaved(true); + loadSnapshots(); + } catch (err) { + toast.error((err as Error)?.message || 'Failed to save configuration.'); + } finally { + setSaving(false); + } + }; + + const handleTest = async () => { + setTesting(true); + try { + const res = await apiFetch('/cloud-backup/test', { method: 'POST' }); + const data = await res.json().catch(() => ({})); + if (data.success) toast.success('Connection successful.'); + else toast.error(data.error || 'Connection test failed.'); + } catch (err) { + toast.error((err as Error)?.message || 'Connection test failed.'); + } finally { + setTesting(false); + } + }; + + const handleProvision = async () => { + setProvisioning(true); + try { + const res = await apiFetch('/cloud-backup/provision', { method: 'POST' }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data?.error) throw new Error(data?.error || 'Provisioning failed.'); + toast.success('Sencho Cloud Backup activated.'); + setSenchoProvisioned(true); + await Promise.all([loadConfig(), loadUsage(), loadSnapshots()]); + } catch (err) { + toast.error((err as Error)?.message || 'Provisioning failed.'); + } finally { + setProvisioning(false); + } + }; + + const handleAutoUploadToggle = async (next: boolean) => { + setCustom(c => ({ ...c, auto_upload: next })); + try { + const res = await apiFetch('/cloud-backup/config', { + method: 'PUT', + body: JSON.stringify({ + provider: 'custom', + custom: { ...custom, auto_upload: next, secret_key: originalSecretSaved ? '***' : '' }, + }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err?.error || 'Failed to update auto-upload'); + } + } catch (err) { + toast.error((err as Error)?.message || 'Failed to update auto-upload.'); + setCustom(c => ({ ...c, auto_upload: !next })); + } + }; + + const confirmDelete = async () => { + if (!deleteKey) return; + try { + const encoded = btoa(deleteKey).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + const res = await apiFetch(`/cloud-backup/object/${encoded}`, { method: 'DELETE' }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err?.error || 'Failed to delete cloud snapshot'); + } + toast.success('Cloud snapshot deleted.'); + loadSnapshots(); + if (provider === 'sencho') loadUsage(); + } catch (err) { + toast.error((err as Error)?.message || 'Failed to delete cloud snapshot.'); + } finally { + setDeleteKey(null); + } + }; + + if (loading) { + return ( +
+ + +
+ ); + } + + const usagePercent = usage && usage.quota_bytes > 0 ? Math.min(100, Math.round((usage.used_bytes / usage.quota_bytes) * 100)) : 0; + const usageColor = usagePercent >= 90 ? 'var(--destructive)' : usagePercent >= 80 ? 'var(--warning)' : 'var(--brand)'; + + return ( + +
+
+ + +

+ Choose where fleet snapshots are replicated. Sencho Cloud Backup is included with the Admiral tier. +

+
+ + {provider === 'sencho' && !senchoProvisioned && ( +
+
+ + Activate Sencho Cloud Backup +
+

+ Activates a 500 MB allowance backed by Cloudflare R2, scoped to this Admiral license. +

+ +
+ )} + + {provider === 'sencho' && senchoProvisioned && ( +
+
+
+ + Sencho Cloud Backup +
+
+ + +
+
+ + {usage && ( +
+
+ Storage used + + {formatBytes(usage.used_bytes)} / {formatBytes(usage.quota_bytes)} ({usage.object_count} objects) + +
+
+
+
+
+ )} + +
+ +

+ Auto-upload is on for Sencho Cloud Backup. Every fleet snapshot is replicated within seconds. +

+
+
+ )} + + {provider === 'custom' && ( +
+
+
+ + Custom S3 Configuration +
+
+ + +
+
+ +
+
+ + setCustom({ ...custom, endpoint: e.target.value })} + /> +
+
+ + setCustom({ ...custom, region: e.target.value })} + /> +
+
+ + setCustom({ ...custom, bucket: e.target.value })} + /> +
+
+ + setCustom({ ...custom, path_prefix: e.target.value })} + /> +
+
+ + setCustom({ ...custom, access_key: e.target.value })} + /> +
+
+ + setCustom({ ...custom, secret_key: e.target.value })} + /> +
+
+ +
+
+ +

Automatically upload every fleet snapshot to this bucket.

+
+ +
+
+ )} + + {provider !== 'disabled' && ( +
+
+ Cloud Snapshots + +
+ {snapshots.length === 0 ? ( +
+ + No cloud snapshots yet. The next fleet snapshot will appear here. +
+ ) : ( +
    + {snapshots.map(s => ( +
  • +
    +
    {s.objectKey.split('/').pop()}
    +
    + {formatBytes(s.sizeBytes)} {s.lastModified ? `· ${new Date(s.lastModified).toLocaleString()}` : ''} +
    +
    +
    + + +
    +
  • + ))} +
+ )} +
+ )} + + !open && setDeleteKey(null)}> + + + + + Delete cloud snapshot? + + + This permanently removes the archive from your bucket. The local SQLite copy is unaffected. + + + + Cancel + + Delete + + + + +
+ + ); +} diff --git a/frontend/src/components/settings/index.ts b/frontend/src/components/settings/index.ts index d6a2d2b6..ca3ce66e 100644 --- a/frontend/src/components/settings/index.ts +++ b/frontend/src/components/settings/index.ts @@ -6,6 +6,7 @@ export { SystemSection } from './SystemSection'; export { NotificationsSection } from './NotificationsSection'; export { WebhooksSection } from './WebhooksSection'; export { SecuritySection } from './SecuritySection'; +export { CloudBackupSection } from './CloudBackupSection'; export { DeveloperSection } from './DeveloperSection'; export { AppStoreSection } from './AppStoreSection'; export { SupportSection } from './SupportSection'; diff --git a/frontend/src/components/settings/registry.ts b/frontend/src/components/settings/registry.ts index 5c73fc1f..d02a3b50 100644 --- a/frontend/src/components/settings/registry.ts +++ b/frontend/src/components/settings/registry.ts @@ -114,6 +114,17 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [ adminOnly: true, hiddenOnRemote: true, }, + { + id: 'cloud-backup', + group: 'system', + label: 'Cloud Backup', + description: 'Mirror fleet snapshots to Sencho Cloud Backup or any S3-compatible storage.', + keywords: ['cloud', 'backup', 'snapshot', 's3', 'r2', 'minio', 'storage', 'offsite'], + tier: 'admiral', + scope: 'global', + adminOnly: true, + hiddenOnRemote: true, + }, { id: 'nodes', group: 'system', diff --git a/frontend/src/components/settings/types.ts b/frontend/src/components/settings/types.ts index 6fb95bae..15a2411d 100644 --- a/frontend/src/components/settings/types.ts +++ b/frontend/src/components/settings/types.ts @@ -37,6 +37,7 @@ export type SectionId = | 'notifications' | 'webhooks' | 'security' + | 'cloud-backup' | 'developer' | 'nodes' | 'appstore'