From 41e15b3fa1bb4c3f596a8d1ff957e6315767fe5a Mon Sep 17 00:00:00 2001 From: triebdev Date: Fri, 12 Jun 2026 15:54:05 +0530 Subject: [PATCH 01/13] chore(deps): add AWS SDK as optional peer dependencies Declare @aws-sdk/client-s3, @aws-sdk/lib-storage and @aws-sdk/s3-request-presigner as optional peer dependencies (and dev dependencies for the build/tests). They are loaded lazily only when the S3 target is used, so Vercel-only installs are unaffected. --- package.json | 19 +- pnpm-lock.yaml | 540 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 558 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index f1c142d..6336140 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@trieb.work/payload-plugin-backup-mongodb", "version": "0.1.7", - "description": "Payload CMS plugin for MongoDB backup and recovery via Vercel Blob Storage", + "description": "Payload CMS plugin for MongoDB backup and recovery via Vercel Blob or AWS S3 (S3-compatible: R2, MinIO)", "keywords": [ "payload", "payloadcms", @@ -111,6 +111,9 @@ "tar-stream": "^3.1.7" }, "devDependencies": { + "@aws-sdk/client-s3": "^3.700.0", + "@aws-sdk/lib-storage": "^3.700.0", + "@aws-sdk/s3-request-presigner": "^3.700.0", "@changesets/cli": "^2.29.8", "@eslint/eslintrc": "^3.2.0", "@payloadcms/db-mongodb": "3.82.1", @@ -153,12 +156,26 @@ "vitest": "4.1.5" }, "peerDependencies": { + "@aws-sdk/client-s3": "^3.0.0", + "@aws-sdk/lib-storage": "^3.0.0", + "@aws-sdk/s3-request-presigner": "^3.0.0", "@payloadcms/db-mongodb": "^3.0.0", "@payloadcms/ui": "^3.0.0", "next": "^15.0.0", "payload": "^3.0.0", "react": "^19.0.0" }, + "peerDependenciesMeta": { + "@aws-sdk/client-s3": { + "optional": true + }, + "@aws-sdk/lib-storage": { + "optional": true + }, + "@aws-sdk/s3-request-presigner": { + "optional": true + } + }, "packageManager": "pnpm@9.15.9+sha512.68046141893c66fad01c079231128e9afb89ef87e2691d69e4d40eee228988295fd4682181bae55b58418c3a253bde65a505ec7c5f9403ece5cc3cd37dcf2531", "engines": { "node": "^18.20.2 || >=20.9.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2f619c..df773b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,15 @@ importers: specifier: ^3.1.7 version: 3.1.8 devDependencies: + '@aws-sdk/client-s3': + specifier: ^3.700.0 + version: 3.1067.0 + '@aws-sdk/lib-storage': + specifier: ^3.700.0 + version: 3.1067.0(@aws-sdk/client-s3@3.1067.0) + '@aws-sdk/s3-request-presigner': + specifier: ^3.700.0 + version: 3.1067.0 '@changesets/cli': specifier: ^2.29.8 version: 2.31.0(@types/node@22.19.9) @@ -148,6 +157,119 @@ packages: resolution: {integrity: sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==} engines: {node: '>= 16'} + '@aws-crypto/crc32@5.2.0': + resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/crc32c@5.2.0': + resolution: {integrity: sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==} + + '@aws-crypto/sha1-browser@5.2.0': + resolution: {integrity: sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==} + + '@aws-crypto/sha256-browser@5.2.0': + resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} + + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/supports-web-crypto@5.2.0': + resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} + + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + + '@aws-sdk/checksums@3.1000.5': + resolution: {integrity: sha512-zOXUUnilC6lgCsQtp77p/QNPmRlTES9Xi6tlDwbR6kfC/kz5PCzZckgHWm5z+8DskdwuMAbFDq61x3zr10GEEQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/client-s3@3.1067.0': + resolution: {integrity: sha512-3f64o9YWzwJ9WzMIC4JlUQiMOm7R/EtkIDyFdj8yaQXuh8SR9ezz2R32UMpvTlVMtpoPan3Uj8oveAHr2UeExw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.974.20': + resolution: {integrity: sha512-7sDi2B2N3mc3nf1nz6FyEx/FCrJ1N1QnBmraHHQNabFaeAh2IaOOLml48/rHOD1bICHgTRkbBgNTvUzEr5Z35g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.46': + resolution: {integrity: sha512-+GPXVS2srMOlH74S+SmC1gVuP2TvUZ0siuC0onKO93q+udP+M72dmY8wJfVQ5CX9z/9X5A1HHwz5yRIGBtskvQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.48': + resolution: {integrity: sha512-fA5loSdlocacRxyUXtpoHSMuk5rsIKRDzQYVMnMxjcmFeZshaJlJ8lymy/hYKji6sne/UmNGj5pxuEs6kq/Qcg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.972.53': + resolution: {integrity: sha512-ZfdhIOR41q8TcWEnUac+gCOb+O2LBWdHLmjedXpXz4IEFW2ppNuFcm6p0sMTavpM+zD5TYfpH5Gp7guRyqSgsQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.52': + resolution: {integrity: sha512-9hu2oR0qH7Fst5Tzdx+UWxm+w5zCXtErTLtOOW5hwwQc170CLwOeniRxyFY6s9mHfGEfC5zFukNBdKBwJR8mhQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.55': + resolution: {integrity: sha512-zMGLa/dhESVqmCD7mmIFFKSwSFrJGScvCXcjvBZEVOOMauFS5JRQvLTMukFpMEFWiV6dTAlsen2ATDBulLPtbg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.46': + resolution: {integrity: sha512-VUoNFBIjWrUN8NbFiQiuxQEgFjvziAlBRPK+ddh27aj65gk0BYu6bLZnrdrNZwpW6vAihtSUtEMQ1PUJ32QRPA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.972.52': + resolution: {integrity: sha512-nb2/n4o/HQf+FVpVbZe9vCTFngmuDoIsltMgLAtjixaKzvzhB4J8WSDFyWgnErgLHk55ctWH+I4PU+LIHhyffg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.52': + resolution: {integrity: sha512-lKj6aRSGbqLmpYmM24bY7a1Xmfcq2vkE3hv8CSPYfc1yCu0BPu/XEJ1L4Fm61MsU6ULLNSG8UGsffNoFUBjESA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/lib-storage@3.1067.0': + resolution: {integrity: sha512-GPM59Kv0RJBXEgR5rhx13S3eXK7pnhw8rYgzk/duWM8XYUfmYs6T6Le15T5eU5AXljBlcMYXsfyBxlZNJnnZJw==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@aws-sdk/client-s3': ^3.1067.0 + + '@aws-sdk/middleware-flexible-checksums@3.974.30': + resolution: {integrity: sha512-OaIhub+3yTgfFWPzKO8OzOZFIMUoJaiS5v67y3spQg7SoULGoMx4jKVBbE+uhnzkiZXQ+rEDS0RqrK4/aD1yJw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-sdk-s3@3.972.51': + resolution: {integrity: sha512-keQgcIUTcHL0Qn7guhsuLaxQU36r9norCrxgaPH4DNCwon4TPtXdI/UdYuycl9vj3Dlwc3YR1dfL3U+6iIwJ6w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.997.20': + resolution: {integrity: sha512-IYJuLpXp2DEILVQpQOy0PMpkftv0AHEOCn52o0atyOaumA0CdWQ3klPyXdViGYLbNpESsVFMVybvHUeZAuiGxA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/s3-request-presigner@3.1067.0': + resolution: {integrity: sha512-BXoy6vCLR/ns2a09knkFgs18fX+EozyOsxX/eHwEXH3DgRh/w2z24X3bamU0ouc5DAGQRKmLeRcYKd7YasmcpQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.34': + resolution: {integrity: sha512-mx1L5qlumSOt/nKM3BFaHE2HVkWwz0i4Bw0pyYO42FfX/FeLlo8YI6csC0gSPprEk6fTIqI+CZN9RwUwKd5krQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1066.0': + resolution: {integrity: sha512-UqEUJq7dqa44hneLDUcX7UJy95cg8YqEWyakRpvIPnrNS3Mq+UlQHgCDGu5pvwAPtlIW4qcYbvW6reG6++FyvA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.973.12': + resolution: {integrity: sha512-43ajd1NF0RMgX5k0hxCNUyEdrtFUsb2aHT2QvpktSC/2Eyb2Jr/JPVqdp0XIoaHWikZJq5tNWSLO6kB5q2eMCA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-locate-window@3.965.7': + resolution: {integrity: sha512-M0D6oIpohdNHjc7udzTHEQyot0+0iuA36jc2I9Hps+f/GtKi2HO/pyijQnCnNcwZqLB5+rtn81z3eZK/GyjAmA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.29': + resolution: {integrity: sha512-fk0niuGFxfi8yIJuMVM4mhwObkiQSuwZFj3tAPrLVx64Pk3BkrEIpqjzHKY4hKoEBUD6Jg/S74Zj9jy+5F3DnQ==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.2.4': + resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -1216,6 +1338,9 @@ packages: cpu: [x64] os: [win32] + '@nodable/entities@2.2.0': + resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1561,6 +1686,42 @@ packages: resolution: {integrity: sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==} engines: {node: '>=14.16'} + '@smithy/core@3.24.6': + resolution: {integrity: sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.3.8': + resolution: {integrity: sha512-5cAM+KZC02sTqDt6NaLXyu50M/GNMd1eTzDVR8Lb0BBsVtu7RWHo47VPPEEv1vt3Yub6uzr+M5FHC+GtoT0USg==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.4.6': + resolution: {integrity: sha512-FEwEYJ1jlBKdhe9TPzfghEi1bP55ZeEImlDkEa62bBBYzUcnB6RUCyuiS2mqKt6ZVjUbBgcNhzfIctH+Hevx9g==} + engines: {node: '>=18.0.0'} + + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} + + '@smithy/node-http-handler@4.7.7': + resolution: {integrity: sha512-ZAFvHXrEk6K180EVhmZVg8GU5pUH5BSFqRs27JW3j1qEFx9YyYwWFx17x/MHcjALYimGAji7qEOlF1++be+G5A==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.4.6': + resolution: {integrity: sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.14.3': + resolution: {integrity: sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -2127,6 +2288,9 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + anynum@1.0.0: + resolution: {integrity: sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA==} + arch@3.0.0: resolution: {integrity: sha512-AmIAC+Wtm2AU8lGfTtHsw0Y9Qtftx2YXEEtiBP10xFUtMOA+sHHx6OAddyL52mUKh1vsXQ6/w1mVDptZCyUt4Q==} @@ -2302,6 +2466,9 @@ packages: body-scroll-lock@4.0.0-beta.0: resolution: {integrity: sha512-a7tP5+0Mw3YlUJcGAKUqIBkYYGlYxk2fnCasq/FUph1hadxlTRjF+gAcZksxANnaMnALjxEddmSi/H3OR8ugcQ==} + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + brace-expansion@1.1.14: resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} @@ -2334,6 +2501,9 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer@5.6.0: + resolution: {integrity: sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==} + buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} @@ -3032,6 +3202,10 @@ packages: events-universal@1.0.1: resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -3080,6 +3254,13 @@ packages: fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-xml-builder@1.2.0: + resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} + + fast-xml-parser@5.7.3: + resolution: {integrity: sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==} + hasBin: true + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -4254,6 +4435,10 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-expression-matcher@1.5.0: + resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} + engines: {node: '>=14.0.0'} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -4473,6 +4658,10 @@ packages: readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -4806,6 +4995,9 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + stream-browserify@3.0.0: + resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==} + streamsearch@1.1.0: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} @@ -4891,6 +5083,9 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} + strnum@2.4.0: + resolution: {integrity: sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==} + strtok3@10.3.5: resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} engines: {node: '>=18'} @@ -5299,6 +5494,10 @@ packages: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} + xml-naming@0.1.0: + resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} + engines: {node: '>=16.0.0'} + xss@1.0.15: resolution: {integrity: sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==} engines: {node: '>= 0.10.0'} @@ -5364,6 +5563,255 @@ snapshots: '@types/json-schema': 7.0.15 js-yaml: 4.1.1 + '@aws-crypto/crc32@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.12 + tslib: 2.8.1 + + '@aws-crypto/crc32c@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.12 + tslib: 2.8.1 + + '@aws-crypto/sha1-browser@5.2.0': + dependencies: + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.12 + '@aws-sdk/util-locate-window': 3.965.7 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-browser@5.2.0': + dependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.12 + '@aws-sdk/util-locate-window': 3.965.7 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-js@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.12 + tslib: 2.8.1 + + '@aws-crypto/supports-web-crypto@5.2.0': + dependencies: + tslib: 2.8.1 + + '@aws-crypto/util@5.2.0': + dependencies: + '@aws-sdk/types': 3.973.12 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/checksums@3.1000.5': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@aws-crypto/crc32c': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/core': 3.974.20 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/client-s3@3.1067.0': + dependencies: + '@aws-crypto/sha1-browser': 5.2.0 + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.20 + '@aws-sdk/credential-provider-node': 3.972.55 + '@aws-sdk/middleware-flexible-checksums': 3.974.30 + '@aws-sdk/middleware-sdk-s3': 3.972.51 + '@aws-sdk/signature-v4-multi-region': 3.996.34 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.6 + '@smithy/fetch-http-handler': 5.4.6 + '@smithy/node-http-handler': 4.7.7 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/core@3.974.20': + dependencies: + '@aws-sdk/types': 3.973.12 + '@aws-sdk/xml-builder': 3.972.29 + '@aws/lambda-invoke-store': 0.2.4 + '@smithy/core': 3.24.6 + '@smithy/signature-v4': 5.4.6 + '@smithy/types': 4.14.3 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.46': + dependencies: + '@aws-sdk/core': 3.974.20 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.48': + dependencies: + '@aws-sdk/core': 3.974.20 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.6 + '@smithy/fetch-http-handler': 5.4.6 + '@smithy/node-http-handler': 4.7.7 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.972.53': + dependencies: + '@aws-sdk/core': 3.974.20 + '@aws-sdk/credential-provider-env': 3.972.46 + '@aws-sdk/credential-provider-http': 3.972.48 + '@aws-sdk/credential-provider-login': 3.972.52 + '@aws-sdk/credential-provider-process': 3.972.46 + '@aws-sdk/credential-provider-sso': 3.972.52 + '@aws-sdk/credential-provider-web-identity': 3.972.52 + '@aws-sdk/nested-clients': 3.997.20 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.6 + '@smithy/credential-provider-imds': 4.3.8 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.52': + dependencies: + '@aws-sdk/core': 3.974.20 + '@aws-sdk/nested-clients': 3.997.20 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-node@3.972.55': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.46 + '@aws-sdk/credential-provider-http': 3.972.48 + '@aws-sdk/credential-provider-ini': 3.972.53 + '@aws-sdk/credential-provider-process': 3.972.46 + '@aws-sdk/credential-provider-sso': 3.972.52 + '@aws-sdk/credential-provider-web-identity': 3.972.52 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.6 + '@smithy/credential-provider-imds': 4.3.8 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.46': + dependencies: + '@aws-sdk/core': 3.974.20 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.972.52': + dependencies: + '@aws-sdk/core': 3.974.20 + '@aws-sdk/nested-clients': 3.997.20 + '@aws-sdk/token-providers': 3.1066.0 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.52': + dependencies: + '@aws-sdk/core': 3.974.20 + '@aws-sdk/nested-clients': 3.997.20 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/lib-storage@3.1067.0(@aws-sdk/client-s3@3.1067.0)': + dependencies: + '@aws-sdk/client-s3': 3.1067.0 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + buffer: 5.6.0 + events: 3.3.0 + stream-browserify: 3.0.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-flexible-checksums@3.974.30': + dependencies: + '@aws-sdk/checksums': 3.1000.5 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.972.51': + dependencies: + '@aws-sdk/core': 3.974.20 + '@aws-sdk/signature-v4-multi-region': 3.996.34 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.20': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.20 + '@aws-sdk/signature-v4-multi-region': 3.996.34 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.6 + '@smithy/fetch-http-handler': 5.4.6 + '@smithy/node-http-handler': 4.7.7 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/s3-request-presigner@3.1067.0': + dependencies: + '@aws-sdk/core': 3.974.20 + '@aws-sdk/signature-v4-multi-region': 3.996.34 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.34': + dependencies: + '@aws-sdk/types': 3.973.12 + '@smithy/signature-v4': 5.4.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1066.0': + dependencies: + '@aws-sdk/core': 3.974.20 + '@aws-sdk/nested-clients': 3.997.20 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/types@3.973.12': + dependencies: + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/util-locate-window@3.965.7': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.29': + dependencies: + '@smithy/types': 4.14.3 + fast-xml-parser: 5.7.3 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.2.4': {} + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -6548,6 +6996,8 @@ snapshots: '@next/swc-win32-x64-msvc@16.2.3': optional: true + '@nodable/entities@2.2.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -6948,6 +7398,54 @@ snapshots: '@sindresorhus/is@5.6.0': {} + '@smithy/core@3.24.6': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.3.8': + dependencies: + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.4.6': + dependencies: + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/node-http-handler@4.7.7': + dependencies: + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@smithy/signature-v4@5.4.6': + dependencies: + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@smithy/types@4.14.3': + dependencies: + tslib: 2.8.1 + + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-utf8@2.3.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 + '@standard-schema/spec@1.1.0': {} '@swc-node/core@1.14.1(@swc/core@1.15.30)(@swc/types@0.1.26)': @@ -7637,6 +8135,8 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.2 + anynum@1.0.0: {} + arch@3.0.0: {} argparse@1.0.10: @@ -7815,6 +8315,8 @@ snapshots: body-scroll-lock@4.0.0-beta.0: {} + bowser@2.14.1: {} + brace-expansion@1.1.14: dependencies: balanced-match: 1.0.2 @@ -7848,6 +8350,11 @@ snapshots: buffer-from@1.1.2: {} + buffer@5.6.0: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + buffer@5.7.1: dependencies: base64-js: 1.5.1 @@ -8818,6 +9325,8 @@ snapshots: transitivePeerDependencies: - bare-abort-controller + events@3.3.0: {} + execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -8873,6 +9382,18 @@ snapshots: fast-uri@3.1.0: {} + fast-xml-builder@1.2.0: + dependencies: + path-expression-matcher: 1.5.0 + xml-naming: 0.1.0 + + fast-xml-parser@5.7.3: + dependencies: + '@nodable/entities': 2.2.0 + fast-xml-builder: 1.2.0 + path-expression-matcher: 1.5.0 + strnum: 2.4.0 + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -10182,6 +10703,8 @@ snapshots: path-exists@4.0.0: {} + path-expression-matcher@1.5.0: {} + path-is-absolute@1.0.1: {} path-key@3.1.1: {} @@ -10442,6 +10965,12 @@ snapshots: string_decoder: 1.1.1 util-deprecate: 1.0.2 + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + readdirp@3.6.0: dependencies: picomatch: 2.3.2 @@ -10863,6 +11392,11 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 + stream-browserify@3.0.0: + dependencies: + inherits: 2.0.4 + readable-stream: 3.6.2 + streamsearch@1.1.0: {} streamx@2.25.0: @@ -10977,6 +11511,10 @@ snapshots: strip-json-comments@5.0.3: {} + strnum@2.4.0: + dependencies: + anynum: 1.0.0 + strtok3@10.3.5: dependencies: '@tokenizer/token': 0.3.0 @@ -11396,6 +11934,8 @@ snapshots: dependencies: is-wsl: 3.1.1 + xml-naming@0.1.0: {} + xss@1.0.15: dependencies: commander: 2.20.3 From 09984f19290dba1a23f0a52cae07e4f8cf45ccc2 Mon Sep 17 00:00:00 2001 From: triebdev Date: Fri, 12 Jun 2026 15:54:09 +0530 Subject: [PATCH 02/13] feat(storage): define provider-agnostic backup storage adapter interface Introduce BackupStorageAdapter plus the object/ref/validation types describing the operations the plugin performs on backup archives (list, put, del, read, stream, validate). This is the seam both the Vercel Blob and S3 targets implement. --- src/core/storage/types.ts | 64 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 src/core/storage/types.ts diff --git a/src/core/storage/types.ts b/src/core/storage/types.ts new file mode 100644 index 0000000..dd82e0a --- /dev/null +++ b/src/core/storage/types.ts @@ -0,0 +1,64 @@ +import type { BackupBlobAccessLevel } from '../backupBlobIO' + +/** Selectable backup target. Vercel Blob is the historical default; `s3` covers AWS S3 and any + * S3-compatible store (Cloudflare R2, MinIO, …) — they share one implementation. */ +export type BackupStorageKind = 's3' | 'vercel-blob' + +/** + * Backup archive as seen by the dashboard and the cron/list endpoints. The field names match the + * objects Vercel Blob's `list()` returns so existing consumers (UI, restore-by-`url`) keep working; + * the S3 adapter synthesizes the same shape (with a presigned `downloadUrl`/`url`). + */ +export interface BackupStorageObject { + /** Time-limited URL suitable for downloading the object (presigned for S3). */ + downloadUrl: string + /** Object key without any provider prefix, e.g. `backups/manual---db---host---2-1700.json`. */ + pathname: string + /** Object size in bytes. */ + size: number + /** Upload/last-modified time. */ + uploadedAt: Date | string + /** Stable reference used for download/delete (Vercel blob URL; presigned GET for S3). */ + url: string +} + +/** Result of probing the target's credentials/reachability. */ +export interface BackupStorageValidation { + /** Detected Vercel Blob access level, when applicable. */ + access?: BackupBlobAccessLevel + /** Short description when validation fails. */ + error?: string + ok: boolean +} + +/** A reference good enough to read or delete a single archive across providers. */ +export interface BackupStorageRef { + downloadUrl?: null | string + pathname: string + url?: null | string +} + +/** + * Provider-agnostic operations the plugin performs on backup archives. Implementations live in + * `vercelBlob.ts` and `s3.ts`; callers obtain one via {@link resolveBackupStorage}. + */ +export interface BackupStorageAdapter { + /** Delete a single archive. */ + del(ref: BackupStorageRef): Promise + readonly kind: BackupStorageKind + /** List archives under a pathname prefix (e.g. `backups/` or `backups/cron-`). */ + list(prefix: string): Promise + /** Open a readable stream for streaming a download response, or null when not found. */ + openDownloadStream( + ref: BackupStorageRef, + ): Promise<{ contentType: string; stream: ReadableStream } | null> + /** Upload an archive at `pathname`. */ + put( + pathname: string, + body: Buffer | string | Uint8Array, + ): Promise<{ pathname: string; url: string }> + /** Read the full bytes of an archive. */ + read(ref: BackupStorageRef): Promise + /** Probe credentials / reachability without persisting anything. */ + validate(): Promise +} From a251cb3917cecb29b583102d44cd9bbe56dc886e Mon Sep 17 00:00:00 2001 From: triebdev Date: Fri, 12 Jun 2026 15:54:27 +0530 Subject: [PATCH 03/13] feat(storage): add env-based backup target configuration Add BACKUP_STORAGE selector (vercel-blob default, s3 opt-in) and BACKUP_S3_* parsing with sensible defaults (region, forcePathStyle when an endpoint is set, optional key prefix, AWS default-credential-chain fallback). Includes getBackupStorageKind, isS3Configured, loadS3Config. --- src/core/storage/config.ts | 82 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 src/core/storage/config.ts diff --git a/src/core/storage/config.ts b/src/core/storage/config.ts new file mode 100644 index 0000000..1c7b24f --- /dev/null +++ b/src/core/storage/config.ts @@ -0,0 +1,82 @@ +import type { BackupStorageKind } from './types' + +/** + * Resolved S3 configuration. Credentials are optional: when omitted, the AWS SDK's default + * provider chain is used (env vars `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`, shared config, + * instance/role credentials, …), which is the recommended setup on AWS. + */ +export interface BackupS3Config { + accessKeyId?: string + bucket: string + /** Custom endpoint for S3-compatible stores (Cloudflare R2, MinIO). Omit for AWS S3. */ + endpoint?: string + /** Path-style addressing — required for MinIO and some R2 setups. Defaults true when an endpoint is set. */ + forcePathStyle: boolean + /** Optional key namespace prepended to every object key (for sharing a bucket). No trailing slash. */ + prefix: string + region: string + secretAccessKey?: string + sessionToken?: string +} + +function env(name: string): string | undefined { + const v = process.env[name] + if (typeof v !== 'string') { + return undefined + } + const trimmed = v.trim() + return trimmed.length > 0 ? trimmed : undefined +} + +function parseBool(raw: string | undefined, fallback: boolean): boolean { + if (raw === undefined) { + return fallback + } + return ['1', 'on', 'true', 'yes'].includes(raw.toLowerCase()) +} + +/** + * Selected backup target. Precedence: explicit `override` (plugin option) → `BACKUP_STORAGE` + * env var → `vercel-blob` (default, so existing deployments are unaffected). + */ +export function getBackupStorageKind(override?: BackupStorageKind): BackupStorageKind { + if (override === 's3' || override === 'vercel-blob') { + return override + } + const raw = env('BACKUP_STORAGE')?.toLowerCase() + if (raw === 's3') { + return 's3' + } + return 'vercel-blob' +} + +/** True when the S3 target has the minimum configuration (a bucket) to operate. */ +export function isS3Configured(): boolean { + return Boolean(env('BACKUP_S3_BUCKET')) +} + +/** + * Reads S3 configuration from the environment. Throws a descriptive error when the bucket is + * missing so endpoints can surface a clear failure instead of an opaque SDK error. + */ +export function loadS3Config(): BackupS3Config { + const bucket = env('BACKUP_S3_BUCKET') + if (!bucket) { + throw new Error('BACKUP_S3_BUCKET is required when BACKUP_STORAGE=s3') + } + const endpoint = env('BACKUP_S3_ENDPOINT') + const rawPrefix = env('BACKUP_S3_PREFIX') ?? '' + const accessKeyId = env('BACKUP_S3_ACCESS_KEY_ID') ?? env('AWS_ACCESS_KEY_ID') + const secretAccessKey = env('BACKUP_S3_SECRET_ACCESS_KEY') ?? env('AWS_SECRET_ACCESS_KEY') + + return { + accessKeyId, + bucket, + endpoint, + forcePathStyle: parseBool(env('BACKUP_S3_FORCE_PATH_STYLE'), Boolean(endpoint)), + prefix: rawPrefix.replace(/\/+$/, ''), + region: env('BACKUP_S3_REGION') ?? env('AWS_REGION') ?? 'us-east-1', + secretAccessKey, + sessionToken: env('BACKUP_S3_SESSION_TOKEN') ?? env('AWS_SESSION_TOKEN'), + } +} From 3eada0fcdaed81e5e3f5acecdcea9931a73c6d26 Mon Sep 17 00:00:00 2001 From: triebdev Date: Fri, 12 Jun 2026 15:54:47 +0530 Subject: [PATCH 04/13] feat(storage): add Vercel Blob storage adapter Wrap the existing @vercel/blob helpers behind the new adapter interface without changing behaviour, so the historical default target keeps working exactly as before. --- src/core/storage/vercelBlob.ts | 69 ++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 src/core/storage/vercelBlob.ts diff --git a/src/core/storage/vercelBlob.ts b/src/core/storage/vercelBlob.ts new file mode 100644 index 0000000..4a8a67b --- /dev/null +++ b/src/core/storage/vercelBlob.ts @@ -0,0 +1,69 @@ +import { del, list } from '@vercel/blob' + +import type { + BackupStorageAdapter, + BackupStorageObject, + BackupStorageRef, + BackupStorageValidation, +} from './types' + +import { + type BackupBlobAccessLevel, + putBackupBlobContent, + readBackupBlobContentFlexible, + streamBackupBlobForDownload, +} from '../backupBlobIO' +import { validateBackupBlobToken } from '../blobTokenValidate' + +/** + * Vercel Blob backup target — the historical default. This is a thin wrapper around the existing + * `@vercel/blob` helpers so behaviour (and the test seams that mock `@vercel/blob`) is unchanged; + * the adapter only generalizes the call shape so S3 can slot in alongside it. + */ +export class VercelBlobStorage implements BackupStorageAdapter { + private readonly access: BackupBlobAccessLevel + + private readonly token: string | undefined + readonly kind = 'vercel-blob' + + constructor(options: { access?: BackupBlobAccessLevel; token?: string }) { + this.token = options.token + this.access = options.access ?? 'public' + } + + async del(ref: BackupStorageRef): Promise { + await del(ref.url ?? ref.pathname, { token: this.token }) + } + + async list(prefix: string): Promise { + const { blobs } = await list({ limit: 1000, prefix, token: this.token }) + return blobs as unknown as BackupStorageObject[] + } + + async openDownloadStream(ref: BackupStorageRef) { + return streamBackupBlobForDownload({ + blobUrl: ref.url, + downloadUrl: ref.downloadUrl, + pathname: ref.pathname, + preferredAccess: this.access, + token: this.token ?? '', + }) + } + + async put(pathname: string, body: Buffer | string | Uint8Array) { + await putBackupBlobContent(pathname, body, this.token, this.access) + return { pathname, url: '' } + } + + async read(ref: BackupStorageRef): Promise { + return readBackupBlobContentFlexible( + ref.pathname, + ref.downloadUrl ?? ref.url ?? '', + this.token ?? '', + ) + } + + async validate(): Promise { + return validateBackupBlobToken(this.token ?? '') + } +} From 9e8126e950b7345bf49a858a94430103b3f8d213 Mon Sep 17 00:00:00 2001 From: triebdev Date: Fri, 12 Jun 2026 15:55:04 +0530 Subject: [PATCH 05/13] feat(storage): add AWS S3 storage adapter (S3/R2/MinIO) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the S3 target with multipart upload, paginated list, presigned downloads and key-prefix support. The AWS SDK is imported dynamically so it stays optional. The same implementation serves any S3-compatible store (Cloudflare R2, MinIO) — only the endpoint/forcePathStyle config differs. --- src/core/storage/s3.ts | 202 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 src/core/storage/s3.ts diff --git a/src/core/storage/s3.ts b/src/core/storage/s3.ts new file mode 100644 index 0000000..12e1bc4 --- /dev/null +++ b/src/core/storage/s3.ts @@ -0,0 +1,202 @@ +import type { S3Client } from '@aws-sdk/client-s3' + +import type { BackupS3Config } from './config' +import type { + BackupStorageAdapter, + BackupStorageObject, + BackupStorageRef, + BackupStorageValidation, +} from './types' + +/** Presigned download URLs are valid this long — long enough to start a restore/download. */ +const SIGNED_URL_TTL_SECONDS = 3600 + +function contentTypeFor(pathname: string): string { + if (pathname.endsWith('.json')) { + return 'application/json' + } + if (pathname.endsWith('.gz') || pathname.endsWith('.tar.gz')) { + return 'application/gzip' + } + return 'application/octet-stream' +} + +/** + * AWS S3 backup target. The same implementation serves any S3-compatible store (Cloudflare R2, + * MinIO, …) — only the `endpoint`/`forcePathStyle` config differs, never the code. The AWS SDK is + * loaded lazily via dynamic import so it stays an optional dependency for Vercel-only installs. + */ +export class S3BackupStorage implements BackupStorageAdapter { + private readonly cfg: BackupS3Config + + private clientPromise: null | Promise = null + readonly kind = 's3' + + constructor(cfg: BackupS3Config) { + this.cfg = cfg + } + + private async client(): Promise { + if (!this.clientPromise) { + this.clientPromise = (async () => { + const { S3Client } = await this.sdk() + const { accessKeyId, secretAccessKey, sessionToken } = this.cfg + return new S3Client({ + ...(this.cfg.endpoint ? { endpoint: this.cfg.endpoint } : {}), + forcePathStyle: this.cfg.forcePathStyle, + region: this.cfg.region, + // When keys are absent, fall back to the SDK's default credential provider chain + // (env vars, shared config, instance/role credentials). + ...(accessKeyId && secretAccessKey ? + { credentials: { accessKeyId, secretAccessKey, sessionToken } } + : {}), + }) + })() + } + return this.clientPromise + } + + /** Strip the configured key prefix, returning the provider-agnostic `backups/...` pathname. */ + private fromKey(key: string): string { + if (!this.cfg.prefix) { + return key + } + const lead = `${this.cfg.prefix}/` + return key.startsWith(lead) ? key.slice(lead.length) : key + } + + private async presignGet(key: string): Promise { + const { GetObjectCommand } = await this.sdk() + const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner') + const client = await this.client() + return getSignedUrl(client, new GetObjectCommand({ Bucket: this.cfg.bucket, Key: key }), { + expiresIn: SIGNED_URL_TTL_SECONDS, + }) + } + + private async sdk() { + try { + return await import('@aws-sdk/client-s3') + } catch { + throw new Error( + 'BACKUP_STORAGE=s3 requires the AWS SDK. Install it with: ' + + 'pnpm add @aws-sdk/client-s3 @aws-sdk/lib-storage @aws-sdk/s3-request-presigner', + ) + } + } + + /** Prepend the configured key prefix to a provider-agnostic `backups/...` pathname. */ + private toKey(pathname: string): string { + return this.cfg.prefix ? `${this.cfg.prefix}/${pathname}` : pathname + } + + async del(ref: BackupStorageRef): Promise { + const { DeleteObjectCommand } = await this.sdk() + const client = await this.client() + await client.send( + new DeleteObjectCommand({ Bucket: this.cfg.bucket, Key: this.toKey(ref.pathname) }), + ) + } + + async list(prefix: string): Promise { + const { ListObjectsV2Command } = await this.sdk() + const client = await this.client() + const keyPrefix = this.toKey(prefix) + + const objects: BackupStorageObject[] = [] + let continuationToken: string | undefined + do { + const res = await client.send( + new ListObjectsV2Command({ + Bucket: this.cfg.bucket, + ContinuationToken: continuationToken, + Prefix: keyPrefix, + }), + ) + for (const obj of res.Contents ?? []) { + if (!obj.Key) { + continue + } + const pathname = this.fromKey(obj.Key) + const url = await this.presignGet(obj.Key) + objects.push({ + downloadUrl: url, + pathname, + size: obj.Size ?? 0, + uploadedAt: obj.LastModified ?? new Date(0), + url, + }) + } + continuationToken = res.IsTruncated ? res.NextContinuationToken : undefined + } while (continuationToken) + + return objects + } + + async openDownloadStream(ref: BackupStorageRef) { + const { GetObjectCommand } = await this.sdk() + const client = await this.client() + try { + const res = await client.send( + new GetObjectCommand({ Bucket: this.cfg.bucket, Key: this.toKey(ref.pathname) }), + ) + if (!res.Body) { + return null + } + const stream = ( + res.Body as { transformToWebStream: () => ReadableStream } + ).transformToWebStream() + return { + contentType: res.ContentType || contentTypeFor(ref.pathname), + stream, + } + } catch { + return null + } + } + + async put(pathname: string, body: Buffer | string | Uint8Array) { + const { Upload } = await import('@aws-sdk/lib-storage') + const client = await this.client() + const key = this.toKey(pathname) + await new Upload({ + client, + params: { + Body: typeof body === 'string' ? Buffer.from(body) : body, + Bucket: this.cfg.bucket, + ContentType: contentTypeFor(pathname), + Key: key, + }, + }).done() + return { pathname, url: await this.presignGet(key) } + } + + async read(ref: BackupStorageRef): Promise { + const { GetObjectCommand } = await this.sdk() + const client = await this.client() + const res = await client.send( + new GetObjectCommand({ Bucket: this.cfg.bucket, Key: this.toKey(ref.pathname) }), + ) + if (!res.Body) { + throw new Error(`Failed to read backup object (${ref.pathname}): empty body`) + } + const bytes = await ( + res.Body as { transformToByteArray: () => Promise } + ).transformToByteArray() + return Buffer.from(bytes) + } + + async validate(): Promise { + try { + const { HeadBucketCommand } = await this.sdk() + const client = await this.client() + await client.send(new HeadBucketCommand({ Bucket: this.cfg.bucket })) + return { ok: true } + } catch (error) { + return { + error: error instanceof Error ? error.message : 'S3 bucket not reachable', + ok: false, + } + } + } +} From 4e84d59936b1117837ab642a396ef1a446822b92 Mon Sep 17 00:00:00 2001 From: triebdev Date: Fri, 12 Jun 2026 15:55:24 +0530 Subject: [PATCH 06/13] feat(storage): add storage adapter factory and resolver resolveBackupStorage builds the adapter for the active target; isBackupStorageConfigured lets endpoints return 503 early (Vercel needs a token, S3 needs a bucket). Re-export the public storage surface. --- src/core/storage/index.ts | 47 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/core/storage/index.ts diff --git a/src/core/storage/index.ts b/src/core/storage/index.ts new file mode 100644 index 0000000..4a09052 --- /dev/null +++ b/src/core/storage/index.ts @@ -0,0 +1,47 @@ +import type { BackupBlobAccessLevel } from '../backupBlobIO' +import type { BackupStorageAdapter, BackupStorageKind } from './types' + +import { getBackupStorageKind, isS3Configured, loadS3Config } from './config' +import { S3BackupStorage } from './s3' +import { VercelBlobStorage } from './vercelBlob' + +export type { BackupS3Config } from './config' +export { getBackupStorageKind, isS3Configured, loadS3Config } from './config' +export type { + BackupStorageAdapter, + BackupStorageKind, + BackupStorageObject, + BackupStorageRef, + BackupStorageValidation, +} from './types' + +export interface ResolveBackupStorageOptions { + /** Vercel Blob access level (ignored for S3). */ + blobAccess?: BackupBlobAccessLevel + /** Vercel Blob read/write token (ignored for S3). */ + blobToken?: string + /** Force a target; defaults to {@link getBackupStorageKind} (plugin option / env / vercel-blob). */ + kind?: BackupStorageKind +} + +/** + * Builds the backup storage adapter for the active target. Vercel Blob is the default; `s3` is + * selected via the `BACKUP_STORAGE` env var (or plugin option) and reads `BACKUP_S3_*` config. + */ +export function resolveBackupStorage( + options: ResolveBackupStorageOptions = {}, +): BackupStorageAdapter { + const kind = getBackupStorageKind(options.kind) + if (kind === 's3') { + return new S3BackupStorage(loadS3Config()) + } + return new VercelBlobStorage({ access: options.blobAccess, token: options.blobToken }) +} + +/** + * True when the active target has enough configuration to serve requests. Used by endpoints to + * return 503 early: Vercel Blob needs a token; S3 needs a bucket. + */ +export function isBackupStorageConfigured(blobToken?: string): boolean { + return getBackupStorageKind() === 's3' ? isS3Configured() : Boolean(blobToken?.trim()) +} From dd840ee19d16e3f7a84a42cd76a8f92c430ba5da Mon Sep 17 00:00:00 2001 From: triebdev Date: Fri, 12 Jun 2026 15:55:55 +0530 Subject: [PATCH 07/13] refactor(core): route backup list/create/prune through the storage adapter listBackups and createBackup (archive upload + cron pruning) now go through the resolved adapter instead of calling @vercel/blob directly. The Vercel path is unchanged; the S3 path is selected via config. Media bundling still reads the Payload upload store as before. --- src/core/backup.ts | 43 +++++++++++++++---------------------------- 1 file changed, 15 insertions(+), 28 deletions(-) diff --git a/src/core/backup.ts b/src/core/backup.ts index 9d3fb17..f78fbfa 100644 --- a/src/core/backup.ts +++ b/src/core/backup.ts @@ -1,6 +1,6 @@ import type { Payload } from 'payload' -import { del, list } from '@vercel/blob' +import { list } from '@vercel/blob' import { EJSON } from 'bson' import { @@ -10,13 +10,10 @@ import { sanitizeBackupLabel, } from '../utils/index' import { createTarGzip } from './archive' -import { - type BackupBlobAccessLevel, - putBackupBlobContent, - readBackupBlobContentFlexible, -} from './backupBlobIO' +import { type BackupBlobAccessLevel, readBackupBlobContentFlexible } from './backupBlobIO' import { getResolvedCronBackupSettings, resolveBackupBlobToken } from './backupSettings' import { getDb } from './db' +import { getBackupStorageKind, resolveBackupStorage } from './storage' import { updateBackupTask } from './taskProgress' export const COLLECTION_FILE_NAME = 'collections.json' @@ -47,16 +44,15 @@ export async function listBackups( blobToken?: string } = {}, ) { - const token = await resolveBackupListToken(payload, options.blobToken) - if (!token) { - return [] + const kind = getBackupStorageKind() + if (kind === 'vercel-blob') { + const token = await resolveBackupListToken(payload, options.blobToken) + if (!token) { + return [] + } + return resolveBackupStorage({ blobToken: token, kind }).list('backups/') } - const { blobs } = await list({ - limit: 1000, - prefix: 'backups/', - token, - }) - return blobs + return resolveBackupStorage({ kind }).list('backups/') } function resolveBlobToken(blobToken?: string): string | undefined { @@ -142,6 +138,7 @@ export async function createBackup( const token = resolveBlobToken(blobToken) const skip = new Set(skipCollections ?? []) const resolvedBackupsToKeep = backupsToKeep ?? (Number(process.env.BACKUPS_TO_KEEP) || 10) + const storage = resolveBackupStorage({ blobAccess, blobToken: token }) const currentHostname = getCurrentHostname() const currentDbName = getCurrentDbName() @@ -166,11 +163,7 @@ export async function createBackup( } if (cron) { - const { blobs } = await list({ - limit: 1000, - prefix: 'backups/cron-', - token, - }) + const blobs = await storage.list('backups/cron-') const sorted = blobs.sort( (a, b) => new Date(b.uploadedAt).getTime() - new Date(a.uploadedAt).getTime(), ) @@ -184,7 +177,7 @@ export async function createBackup( } } for (const blob of oldest) { - await del(blob.url, { token }) + await storage.del({ pathname: blob.pathname, url: blob.url }) payload.logger.info({ pathname: blob.pathname }, '[backup] Deleted old backup') } } @@ -261,13 +254,7 @@ export async function createBackup( message: 'Uploading backup to blob storage', }) } - const effectiveAccess = await putBackupBlobContent(name, backupFile, token, blobAccess) - if (effectiveAccess !== blobAccess) { - payload.logger.warn( - { name, effectiveAccess, preferredAccess: blobAccess }, - '[backup] Blob store rejected preferred access level; uploaded with fallback', - ) - } + await storage.put(name, backupFile) payload.logger.info({ name, durationMs: Date.now() - t0 }, '[backup] Backup complete') } From 71279f842ca27745d7a00ff69a6aa6d7240e5e91 Mon Sep 17 00:00:00 2001 From: triebdev Date: Fri, 12 Jun 2026 15:56:21 +0530 Subject: [PATCH 08/13] refactor(endpoints): make storage gates target-aware; route delete/download via adapter Replace the Vercel-token 503/access gates with isBackupStorageConfigured so the S3 target passes, and route delete/download through the adapter. Restore works for S3 via the presigned download URL. --- src/endpoints/paths/admin-backup-download.ts | 15 ++++++++------- src/endpoints/paths/admin-delete.ts | 14 ++++++++++---- src/endpoints/paths/admin-manual.ts | 3 ++- src/endpoints/paths/admin-restore.ts | 5 +++-- src/endpoints/paths/cron-list.ts | 3 ++- src/endpoints/paths/cron-restore.ts | 5 +++-- src/endpoints/paths/cron-run.ts | 3 ++- 7 files changed, 30 insertions(+), 18 deletions(-) diff --git a/src/endpoints/paths/admin-backup-download.ts b/src/endpoints/paths/admin-backup-download.ts index fa77556..b4776eb 100644 --- a/src/endpoints/paths/admin-backup-download.ts +++ b/src/endpoints/paths/admin-backup-download.ts @@ -2,12 +2,12 @@ import type { Endpoint, PayloadRequest } from 'payload' import type { BackupPluginOptions } from '../../types' -import { streamBackupBlobForDownload } from '../../core/backupBlobIO' import { getResolvedCronBackupSettings, resolveBackupBlobAccess, resolveBackupBlobToken, } from '../../core/backupSettings' +import { isBackupStorageConfigured, resolveBackupStorage } from '../../core/storage' import { requireBackupAdmin } from '../shared' function queryParam(req: PayloadRequest, key: string): string { @@ -38,7 +38,7 @@ export function createAdminBackupDownloadEndpoint(options: BackupPluginOptions): const { payload } = req const settings = await getResolvedCronBackupSettings(payload) const token = resolveBackupBlobToken(settings).trim() - if (!token) { + if (!isBackupStorageConfigured(token)) { return new Response('Service unavailable', { status: 503 }) } @@ -50,13 +50,14 @@ export function createAdminBackupDownloadEndpoint(options: BackupPluginOptions): const blobUrl = queryParam(req, 'url') const downloadUrl = queryParam(req, 'downloadUrl') - const preferred = resolveBackupBlobAccess(settings) - const opened = await streamBackupBlobForDownload({ - blobUrl: blobUrl || undefined, + const storage = resolveBackupStorage({ + blobAccess: resolveBackupBlobAccess(settings), + blobToken: token, + }) + const opened = await storage.openDownloadStream({ downloadUrl: downloadUrl || undefined, pathname, - preferredAccess: preferred, - token, + url: blobUrl || undefined, }) if (!opened) { diff --git a/src/endpoints/paths/admin-delete.ts b/src/endpoints/paths/admin-delete.ts index fc51a9d..dd030e2 100644 --- a/src/endpoints/paths/admin-delete.ts +++ b/src/endpoints/paths/admin-delete.ts @@ -1,11 +1,15 @@ import type { Endpoint } from 'payload' -import { del } from '@vercel/blob' import { after } from 'next/server' import type { BackupPluginOptions } from '../../types' -import { getResolvedCronBackupSettings, resolveBackupBlobToken } from '../../core/backupSettings' +import { + getResolvedCronBackupSettings, + resolveBackupBlobAccess, + resolveBackupBlobToken, +} from '../../core/backupSettings' +import { isBackupStorageConfigured, resolveBackupStorage } from '../../core/storage' import { completeBackupTask, createBackupTask, @@ -25,9 +29,11 @@ export function createAdminDeleteEndpoint(options: BackupPluginOptions): Endpoin const { payload } = req const settings = await getResolvedCronBackupSettings(payload) const blobToken = resolveBackupBlobToken(settings) - if (!blobToken) { + const blobAccess = resolveBackupBlobAccess(settings) + if (!isBackupStorageConfigured(blobToken)) { return jsonError('Service unavailable', 503) } + const storage = resolveBackupStorage({ blobAccess, blobToken }) const body = (await readRequestJson(req)) as { pathname?: string; url?: string } const blobUrl = body?.url @@ -50,7 +56,7 @@ export function createAdminDeleteEndpoint(options: BackupPluginOptions): Endpoin message: `Deleting backup ${pathname}`, status: 'running', }) - .then(() => del(blobUrl, { token: blobToken })) + .then(() => storage.del({ pathname, url: blobUrl })) .then(() => completeBackupTask(payload, taskId, `Deleted backup ${pathname}`)) .catch(async (error) => { await failBackupTask(payload, taskId, error) diff --git a/src/endpoints/paths/admin-manual.ts b/src/endpoints/paths/admin-manual.ts index 2d7d132..b68f9ea 100644 --- a/src/endpoints/paths/admin-manual.ts +++ b/src/endpoints/paths/admin-manual.ts @@ -10,6 +10,7 @@ import { resolveBackupBlobAccess, resolveBackupBlobToken, } from '../../core/backupSettings' +import { isBackupStorageConfigured } from '../../core/storage' import { completeBackupTask, createBackupTask, failBackupTask } from '../../core/taskProgress' import { sanitizeBackupLabel } from '../../utils/index' import { jsonError, readRequestJson, requireBackupAdmin } from '../shared' @@ -26,7 +27,7 @@ export function createAdminManualEndpoint(options: BackupPluginOptions): Endpoin const settings = await getResolvedCronBackupSettings(payload) const blobToken = resolveBackupBlobToken(settings) const blobAccess = resolveBackupBlobAccess(settings) - if (!blobToken) { + if (!isBackupStorageConfigured(blobToken)) { return jsonError('Service unavailable', 503) } diff --git a/src/endpoints/paths/admin-restore.ts b/src/endpoints/paths/admin-restore.ts index d9e3c37..82d24fd 100644 --- a/src/endpoints/paths/admin-restore.ts +++ b/src/endpoints/paths/admin-restore.ts @@ -11,6 +11,7 @@ import { resolveBackupBlobToken, } from '../../core/backupSettings' import { restoreBackup } from '../../core/restore' +import { getBackupStorageKind, isBackupStorageConfigured } from '../../core/storage' import { completeBackupTask, createBackupTask, failBackupTask } from '../../core/taskProgress' import { jsonError, readRequestJson, requireBackupAdmin } from '../shared' @@ -26,7 +27,7 @@ export function createAdminRestoreEndpoint(options: BackupPluginOptions): Endpoi const settings = await getResolvedCronBackupSettings(payload) const blobToken = resolveBackupBlobToken(settings) const blobAccess = resolveBackupBlobAccess(settings) - if (!blobToken) { + if (!isBackupStorageConfigured(blobToken)) { return jsonError('Service unavailable', 503) } @@ -51,7 +52,7 @@ export function createAdminRestoreEndpoint(options: BackupPluginOptions): Endpoi } const backupRead = resolveBackupArchiveRead(settings, pathname) - if (blobAccess === 'private' && !backupRead) { + if (getBackupStorageKind() === 'vercel-blob' && blobAccess === 'private' && !backupRead) { return jsonError('Missing pathname (required for dedicated backup blob store)', 400) } diff --git a/src/endpoints/paths/cron-list.ts b/src/endpoints/paths/cron-list.ts index d81fb29..d2e1ca0 100644 --- a/src/endpoints/paths/cron-list.ts +++ b/src/endpoints/paths/cron-list.ts @@ -1,6 +1,7 @@ import type { Endpoint } from 'payload' import { listBackups, resolveBackupListToken } from '../../core/backup' +import { isBackupStorageConfigured } from '../../core/storage' import { requireCronBearer } from '../shared' export function createCronListEndpoint(): Endpoint { @@ -13,7 +14,7 @@ export function createCronListEndpoint(): Endpoint { const { payload } = req const backupBlobToken = await resolveBackupListToken(payload) - if (!backupBlobToken) { + if (!isBackupStorageConfigured(backupBlobToken)) { return new Response('Service unavailable', { status: 503 }) } payload.logger.info('[backup-endpoint] Listing backups') diff --git a/src/endpoints/paths/cron-restore.ts b/src/endpoints/paths/cron-restore.ts index 9617e21..6f621f4 100644 --- a/src/endpoints/paths/cron-restore.ts +++ b/src/endpoints/paths/cron-restore.ts @@ -7,6 +7,7 @@ import { resolveBackupBlobToken, } from '../../core/backupSettings' import { restoreBackup } from '../../core/restore' +import { getBackupStorageKind, isBackupStorageConfigured } from '../../core/storage' import { readRequestJson, requireCronBearer } from '../shared' export function createCronRestoreEndpoint(): Endpoint { @@ -20,7 +21,7 @@ export function createCronRestoreEndpoint(): Endpoint { const settings = await getResolvedCronBackupSettings(payload) const blobToken = resolveBackupBlobToken(settings) const blobAccess = resolveBackupBlobAccess(settings) - if (!blobToken) { + if (!isBackupStorageConfigured(blobToken)) { return new Response('Service unavailable', { status: 503 }) } @@ -35,7 +36,7 @@ export function createCronRestoreEndpoint(): Endpoint { } const backupRead = resolveBackupArchiveRead(settings, pathname) - if (blobAccess === 'private' && !backupRead) { + if (getBackupStorageKind() === 'vercel-blob' && blobAccess === 'private' && !backupRead) { return new Response('Missing pathname (required for dedicated backup blob store)', { status: 400, }) diff --git a/src/endpoints/paths/cron-run.ts b/src/endpoints/paths/cron-run.ts index 0edd977..d6e4bda 100644 --- a/src/endpoints/paths/cron-run.ts +++ b/src/endpoints/paths/cron-run.ts @@ -10,6 +10,7 @@ import { resolveBackupBlobAccess, resolveBackupBlobToken, } from '../../core/backupSettings' +import { isBackupStorageConfigured } from '../../core/storage' import { requireCronBearer } from '../shared' export function createCronRunEndpoint(options: BackupPluginOptions): Endpoint { @@ -24,7 +25,7 @@ export function createCronRunEndpoint(options: BackupPluginOptions): Endpoint { const settings = await getResolvedCronBackupSettings(payload) const blobToken = resolveBackupBlobToken(settings) const blobAccess = resolveBackupBlobAccess(settings) - if (!blobToken) { + if (!isBackupStorageConfigured(blobToken)) { return new Response('Service unavailable', { status: 503 }) } payload.logger.info('[backup-endpoint] Cron backup request accepted') From 869e91cb9755600646ee413a1032abdbbc5d8b66 Mon Sep 17 00:00:00 2001 From: triebdev Date: Fri, 12 Jun 2026 15:56:38 +0530 Subject: [PATCH 09/13] feat(admin-ui): surface the active backup target; hide Vercel-only controls for S3 Expose the active target (and a non-secret S3 summary) from the settings endpoint, show a Storage pill on the dashboard, and add a Storage target section to the settings modal. When S3 is active the Vercel-only dedicated-token section is hidden and the dashboard lists S3 backups. Target selection stays config-driven (BACKUP_STORAGE). --- .../BackupSettingsModal.client.tsx | 242 +++++++++++------- src/components/BackupDashboard/index.tsx | 19 +- src/endpoints/paths/admin-settings.ts | 20 ++ 3 files changed, 184 insertions(+), 97 deletions(-) diff --git a/src/components/BackupDashboard/BackupSettingsModal.client.tsx b/src/components/BackupDashboard/BackupSettingsModal.client.tsx index 10278fc..3608253 100644 --- a/src/components/BackupDashboard/BackupSettingsModal.client.tsx +++ b/src/components/BackupDashboard/BackupSettingsModal.client.tsx @@ -110,6 +110,15 @@ interface BackupSettingsApiResponse { includeMediaForCron?: boolean pluginBackupsToKeepOverride?: boolean skipMongoCollections?: string[] + storage?: { + kind: 's3' | 'vercel-blob' + s3?: { + bucket: string + endpoint?: string + prefix?: string + region: string + } | null + } transfer?: { deferred?: boolean failed: number @@ -160,6 +169,7 @@ export const BackupSettingsModal: FC = () => { }>({ status: 'idle' }) const [pluginOverridesRetention, setPluginOverridesRetention] = useState(false) const [cronInfo, setCronInfo] = useState(undefined) + const [storage, setStorage] = useState(undefined) const [transferSummary, setTransferSummary] = useState< BackupSettingsApiResponse['transfer'] | null >(null) @@ -258,6 +268,7 @@ export const BackupSettingsModal: FC = () => { setTokenCheck({ status: 'idle' }) setPluginOverridesRetention(settingsJson.pluginBackupsToKeepOverride === true) setCronInfo(settingsJson.cron ?? null) + setStorage(settingsJson.storage) setIncludeMediaBlobs(settingsJson.includeMediaForCron === true) const skip = @@ -641,6 +652,44 @@ export const BackupSettingsModal: FC = () => {

+ {storage ? + <> +

Storage target

+ {storage.kind === 's3' ? + <> +

+ Backups use AWS S3 (or S3-compatible). Selected via the{' '} + BACKUP_STORAGE environment variable. +

+ {storage.s3 ? +

+ Bucket {storage.s3.bucket} · region{' '} + {storage.s3.region} + {storage.s3.endpoint ? + <> + {' '} + · endpoint {storage.s3.endpoint} + + : null} + {storage.s3.prefix ? + <> + {' '} + · prefix {storage.s3.prefix} + + : null} +

+ :

+ Set BACKUP_S3_BUCKET (and credentials) to finish configuring S3. +

+ } + + :

+ Backups use Vercel Blob (default). Set{' '} + BACKUP_STORAGE=s3 in the environment to use AWS S3 instead. +

+ } + + : null} {phase !== 'idle' ? <>

Schedule

@@ -728,106 +777,111 @@ export const BackupSettingsModal: FC = () => { />
-

- Dedicated backup storage{' '} - (optional) -

-

- Separate Vercel Blob token for backups/. Without one, archives share the - default media store. -

-
- -
- onChangeBackupBlobToken(e.target.value)} - placeholder="vercel_blob_rw_..." - spellCheck={false} - type="text" - value={backupBlobReadWriteToken} - /> - {tokenStatusPill} -
- {tokenCheck.status === 'invalid' && tokenCheck.message ? -

- {tokenCheck.message} + {storage?.kind === 's3' ? null : ( + <> +

+ Dedicated backup storage{' '} + (optional)

- : null} - {savePhase === 'transferring' && transferLive && transferLive.total > 0 ? -
-
-
-
-

- Copying backup files: {transferLive.transferred + transferLive.failed} /{' '} - {transferLive.total} - {transferLive.failed > 0 ? ` (${transferLive.failed} failed)` : ''} - {transferLive.pathname ? - <> -
- - {transferLive.pathname.length > 56 ? - `${transferLive.pathname.slice(0, 28)}…${transferLive.pathname.slice(-24)}` - : transferLive.pathname} - - - : null} -

-
- : null} - {willSaveNewRawToken ? - <> -
+

+ Separate Vercel Blob token for backups/. Without one, archives share + the default media store. +

+
+ +
setTransferBackupBlobs(e.target.checked)} - type="checkbox" + aria-label="Backup Blob read/write token" + autoComplete="off" + className="restore-preview__settings-input restore-preview__settings-input--wide" + id="backup-blob-token" + onChange={(e) => onChangeBackupBlobToken(e.target.value)} + placeholder="vercel_blob_rw_..." + spellCheck={false} + type="text" + value={backupBlobReadWriteToken} /> - - + {tokenStatusPill}
- {transferBackupBlobs ? -
- setDeleteBackupBlobsFromSource(e.target.checked)} - type="checkbox" - /> - - + {tokenCheck.status === 'invalid' && tokenCheck.message ? +

+ {tokenCheck.message} +

+ : null} + {savePhase === 'transferring' && transferLive && transferLive.total > 0 ? +
+
+
+
+

+ Copying backup files: {transferLive.transferred + transferLive.failed} /{' '} + {transferLive.total} + {transferLive.failed > 0 ? ` (${transferLive.failed} failed)` : ''} + {transferLive.pathname ? + <> +
+ + {transferLive.pathname.length > 56 ? + `${transferLive.pathname.slice(0, 28)}…${transferLive.pathname.slice(-24)}` + : transferLive.pathname} + + + : null} +

: null} - - : null} -
+ {willSaveNewRawToken ? + <> +
+ setTransferBackupBlobs(e.target.checked)} + type="checkbox" + /> + + +
+ {transferBackupBlobs ? +
+ setDeleteBackupBlobsFromSource(e.target.checked)} + type="checkbox" + /> + + +
+ : null} + + : null} +
+ + )} = async ({ i18n, pa ) } + const storageKind = getBackupStorageKind() const backupBlobToken = await resolveBackupListToken(payload) - const hasBlobToken = backupBlobToken.trim().length > 0 + const storageConfigured = isBackupStorageConfigured(backupBlobToken) - const blobs = hasBlobToken ? await listBackups(payload, { blobToken: backupBlobToken }) : [] + const blobs = storageConfigured ? await listBackups(payload, { blobToken: backupBlobToken }) : [] const sortedBlobs = [...blobs].sort((a, b) => { const ta = getBackupSortTimeMs(transformBlobName(a.pathname), new Date(a.uploadedAt)) const tb = getBackupSortTimeMs(transformBlobName(b.pathname), new Date(b.uploadedAt)) @@ -125,7 +127,14 @@ export const BackupDashboard: React.FC = async ({ i18n, pa Backups (experimental) - {!hasBlobToken && ( + {!storageConfigured && storageKind === 's3' && ( +

+ Set BACKUP_S3_BUCKET (and + credentials) to enable the S3 backup target. Until then, the list stays empty and cron + or manual backups cannot run. +

+ )} + {!storageConfigured && storageKind === 'vercel-blob' && (

Add a Vercel Blob read/write token: set environment variable{' '} BLOB_READ_WRITE_TOKEN, or @@ -136,6 +145,10 @@ export const BackupDashboard: React.FC = async ({ i18n, pa

+ + Storage + {storageKind === 's3' ? 'AWS S3' : 'Vercel Blob'} + Total {sortedBlobs.length} backup{sortedBlobs.length === 1 ? '' : 's'} diff --git a/src/endpoints/paths/admin-settings.ts b/src/endpoints/paths/admin-settings.ts index 01b47dc..7f5925f 100644 --- a/src/endpoints/paths/admin-settings.ts +++ b/src/endpoints/paths/admin-settings.ts @@ -15,6 +15,7 @@ import { toPayloadSkipRows, } from '../../core/backupSettings' import { validateBackupBlobToken } from '../../core/blobTokenValidate' +import { getBackupStorageKind, isS3Configured, loadS3Config } from '../../core/storage' import { completeBackupTask, createBackupTask, @@ -35,6 +36,24 @@ function clampBackupsToKeep(n: unknown): number { return Math.min(365, Math.max(1, Math.floor(n))) } +/** Active backup target + a non-secret S3 summary for the admin UI. */ +function backupStorageSummary() { + const kind = getBackupStorageKind() + if (kind !== 's3' || !isS3Configured()) { + return { kind } + } + const cfg = loadS3Config() + return { + kind, + s3: { + bucket: cfg.bucket, + endpoint: cfg.endpoint, + prefix: cfg.prefix || undefined, + region: cfg.region, + }, + } +} + function buildSettingsJson( stored: Awaited>, options: BackupPluginOptions, @@ -82,6 +101,7 @@ function buildSettingsJson( includeMediaForCron: stored.includeMediaForCron, pluginBackupsToKeepOverride: typeof options.backupsToKeep === 'number', skipMongoCollections: stored.skipMongoCollections, + storage: backupStorageSummary(), transfer: t, } } From 5408509004ae3a2084c35c76df40830f6318c059 Mon Sep 17 00:00:00 2001 From: triebdev Date: Fri, 12 Jun 2026 15:56:54 +0530 Subject: [PATCH 10/13] test(storage): cover the S3 adapter and target configuration Unit-test config resolution (selector precedence, defaults, credential fallbacks) and the S3 adapter (multipart put, paginated list mapping, presigned URL, prefix round-trip, delete, validate) by mocking the AWS SDK the same way existing suites mock @vercel/blob. --- tests/integration/s3Storage.test.ts | 169 ++++++++++++++++++++++++++++ tests/unit/storageConfig.test.ts | 108 ++++++++++++++++++ 2 files changed, 277 insertions(+) create mode 100644 tests/integration/s3Storage.test.ts create mode 100644 tests/unit/storageConfig.test.ts diff --git a/tests/integration/s3Storage.test.ts b/tests/integration/s3Storage.test.ts new file mode 100644 index 0000000..c7fe82c --- /dev/null +++ b/tests/integration/s3Storage.test.ts @@ -0,0 +1,169 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { BackupS3Config } from '../../src/core/storage/config.js' + +import { S3BackupStorage } from '../../src/core/storage/s3.js' + +// Shared spies, hoisted so the `vi.mock` factories below can reference them. +const { getSignedUrlMock, sendMock, uploadDone } = vi.hoisted(() => ({ + getSignedUrlMock: vi.fn(async () => 'https://signed.example/object'), + sendMock: vi.fn(), + uploadDone: vi.fn(async () => ({})), +})) + +// Minimal fakes for the AWS SDK. Each command records its kind + input so `sendMock` can branch. +vi.mock('@aws-sdk/client-s3', () => { + const makeCommand = (kind: string) => + class { + __kind = kind + input: Record + constructor(input: Record) { + this.input = input + } + } + return { + DeleteObjectCommand: makeCommand('delete'), + GetObjectCommand: makeCommand('get'), + HeadBucketCommand: makeCommand('head'), + ListObjectsV2Command: makeCommand('list'), + S3Client: vi.fn(function (this: { send: typeof sendMock }) { + this.send = sendMock + }), + } +}) + +vi.mock('@aws-sdk/lib-storage', () => ({ + Upload: vi.fn(function (this: { done: typeof uploadDone }) { + this.done = uploadDone + }), +})) + +vi.mock('@aws-sdk/s3-request-presigner', () => ({ + getSignedUrl: getSignedUrlMock, +})) + +import { Upload } from '@aws-sdk/lib-storage' + +const baseConfig: BackupS3Config = { + bucket: 'payload-backups', + forcePathStyle: true, + prefix: '', + region: 'us-east-1', +} + +describe('S3BackupStorage', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('uploads via multipart Upload with the derived content type', async () => { + const storage = new S3BackupStorage(baseConfig) + const res = await storage.put('backups/manual---db---host---2-1700000000000.json', '{"a":1}') + + expect(Upload).toHaveBeenCalledOnce() + const params = vi.mocked(Upload).mock.calls[0][0].params as unknown as Record + expect(params.Bucket).toBe('payload-backups') + expect(params.Key).toBe('backups/manual---db---host---2-1700000000000.json') + expect(params.ContentType).toBe('application/json') + expect(uploadDone).toHaveBeenCalledOnce() + expect(res.url).toBe('https://signed.example/object') + }) + + it('lists objects and maps them to the blob shape with a presigned url', async () => { + sendMock.mockResolvedValueOnce({ + Contents: [ + { + Key: 'backups/manual---db---host---2-1700000000000.json', + LastModified: new Date('2024-01-02T03:04:05Z'), + Size: 1234, + }, + ], + IsTruncated: false, + }) + + const storage = new S3BackupStorage(baseConfig) + const objects = await storage.list('backups/') + + const listInput = sendMock.mock.calls[0][0].input as Record + expect(listInput.Bucket).toBe('payload-backups') + expect(listInput.Prefix).toBe('backups/') + expect(objects).toHaveLength(1) + expect(objects[0]).toMatchObject({ + downloadUrl: 'https://signed.example/object', + pathname: 'backups/manual---db---host---2-1700000000000.json', + size: 1234, + url: 'https://signed.example/object', + }) + }) + + it('paginates list results across continuation tokens', async () => { + sendMock + .mockResolvedValueOnce({ + Contents: [{ Key: 'backups/a.json', LastModified: new Date(), Size: 1 }], + IsTruncated: true, + NextContinuationToken: 'next', + }) + .mockResolvedValueOnce({ + Contents: [{ Key: 'backups/b.json', LastModified: new Date(), Size: 2 }], + IsTruncated: false, + }) + + const storage = new S3BackupStorage(baseConfig) + const objects = await storage.list('backups/') + + expect(sendMock).toHaveBeenCalledTimes(2) + expect(objects.map((o) => o.pathname)).toEqual(['backups/a.json', 'backups/b.json']) + }) + + it('reads object bytes into a Buffer', async () => { + sendMock.mockResolvedValueOnce({ + Body: { transformToByteArray: async () => new Uint8Array([1, 2, 3]) }, + }) + + const storage = new S3BackupStorage(baseConfig) + const buf = await storage.read({ pathname: 'backups/x.json' }) + + expect(Buffer.isBuffer(buf)).toBe(true) + expect([...buf]).toEqual([1, 2, 3]) + }) + + it('deletes by pathname', async () => { + sendMock.mockResolvedValueOnce({}) + const storage = new S3BackupStorage(baseConfig) + await storage.del({ pathname: 'backups/x.json' }) + + const cmd = sendMock.mock.calls[0][0] + expect(cmd.__kind).toBe('delete') + expect(cmd.input.Key).toBe('backups/x.json') + }) + + it('applies the configured key prefix on write and strips it on list', async () => { + const storage = new S3BackupStorage({ ...baseConfig, prefix: 'team-a' }) + + await storage.put('backups/x.json', 'data') + const putParams = vi.mocked(Upload).mock.calls[0][0].params as unknown as Record< + string, + unknown + > + expect(putParams.Key).toBe('team-a/backups/x.json') + + sendMock.mockResolvedValueOnce({ + Contents: [{ Key: 'team-a/backups/x.json', LastModified: new Date(), Size: 1 }], + IsTruncated: false, + }) + const objects = await storage.list('backups/') + const listInput = sendMock.mock.calls[0][0].input as Record + expect(listInput.Prefix).toBe('team-a/backups/') + expect(objects[0].pathname).toBe('backups/x.json') + }) + + it('validate() returns ok when HeadBucket succeeds and not ok when it throws', async () => { + const storage = new S3BackupStorage(baseConfig) + + sendMock.mockResolvedValueOnce({}) + expect(await storage.validate()).toEqual({ ok: true }) + + sendMock.mockRejectedValueOnce(new Error('Forbidden')) + expect(await storage.validate()).toEqual({ error: 'Forbidden', ok: false }) + }) +}) diff --git a/tests/unit/storageConfig.test.ts b/tests/unit/storageConfig.test.ts new file mode 100644 index 0000000..5240c50 --- /dev/null +++ b/tests/unit/storageConfig.test.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { + getBackupStorageKind, + isS3Configured, + loadS3Config, +} from '../../src/core/storage/config.js' + +const S3_ENV_KEYS = [ + 'BACKUP_STORAGE', + 'BACKUP_S3_BUCKET', + 'BACKUP_S3_REGION', + 'BACKUP_S3_ENDPOINT', + 'BACKUP_S3_FORCE_PATH_STYLE', + 'BACKUP_S3_PREFIX', + 'BACKUP_S3_ACCESS_KEY_ID', + 'BACKUP_S3_SECRET_ACCESS_KEY', + 'BACKUP_S3_SESSION_TOKEN', + 'AWS_ACCESS_KEY_ID', + 'AWS_SECRET_ACCESS_KEY', + 'AWS_REGION', + 'AWS_SESSION_TOKEN', +] + +describe('storage config', () => { + let saved: Record + + beforeEach(() => { + saved = {} + for (const key of S3_ENV_KEYS) { + saved[key] = process.env[key] + delete process.env[key] + } + }) + + afterEach(() => { + for (const key of S3_ENV_KEYS) { + if (saved[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = saved[key] + } + } + }) + + describe('getBackupStorageKind', () => { + it('defaults to vercel-blob', () => { + expect(getBackupStorageKind()).toBe('vercel-blob') + }) + + it('reads s3 from BACKUP_STORAGE (case-insensitive)', () => { + process.env.BACKUP_STORAGE = 'S3' + expect(getBackupStorageKind()).toBe('s3') + }) + + it('honors an explicit override over the env var', () => { + process.env.BACKUP_STORAGE = 's3' + expect(getBackupStorageKind('vercel-blob')).toBe('vercel-blob') + }) + }) + + describe('isS3Configured', () => { + it('is false without a bucket and true with one', () => { + expect(isS3Configured()).toBe(false) + process.env.BACKUP_S3_BUCKET = 'my-bucket' + expect(isS3Configured()).toBe(true) + }) + }) + + describe('loadS3Config', () => { + it('throws when the bucket is missing', () => { + expect(() => loadS3Config()).toThrow(/BACKUP_S3_BUCKET/) + }) + + it('defaults region/forcePathStyle and trims the prefix', () => { + process.env.BACKUP_S3_BUCKET = 'my-bucket' + process.env.BACKUP_S3_PREFIX = 'team-a/' + const cfg = loadS3Config() + expect(cfg.bucket).toBe('my-bucket') + expect(cfg.region).toBe('us-east-1') + expect(cfg.forcePathStyle).toBe(false) + expect(cfg.prefix).toBe('team-a') + expect(cfg.accessKeyId).toBeUndefined() + }) + + it('defaults forcePathStyle to true when a custom endpoint is set (R2/MinIO)', () => { + process.env.BACKUP_S3_BUCKET = 'my-bucket' + process.env.BACKUP_S3_ENDPOINT = 'http://127.0.0.1:9000' + expect(loadS3Config().forcePathStyle).toBe(true) + }) + + it('prefers BACKUP_S3_* credentials but falls back to AWS_* ', () => { + process.env.BACKUP_S3_BUCKET = 'my-bucket' + process.env.AWS_ACCESS_KEY_ID = 'aws-key' + process.env.AWS_SECRET_ACCESS_KEY = 'aws-secret' + process.env.AWS_REGION = 'eu-central-1' + const fallback = loadS3Config() + expect(fallback.accessKeyId).toBe('aws-key') + expect(fallback.region).toBe('eu-central-1') + + process.env.BACKUP_S3_ACCESS_KEY_ID = 'backup-key' + process.env.BACKUP_S3_SECRET_ACCESS_KEY = 'backup-secret' + const preferred = loadS3Config() + expect(preferred.accessKeyId).toBe('backup-key') + expect(preferred.secretAccessKey).toBe('backup-secret') + }) + }) +}) From a2fa9d2502fcc5917b3c66359d4e1f8fc0c3217a Mon Sep 17 00:00:00 2001 From: triebdev Date: Fri, 12 Jun 2026 15:57:09 +0530 Subject: [PATCH 11/13] docs: document the S3 backup target; harden env gitignore; add changeset Add a 'Choosing a backup target' README section with AWS and R2 examples, document the new env vars in dev/.env.example, ignore real .env files (keeping .example templates tracked), and add the changeset. --- .changeset/native-s3-backup-target.md | 10 +++ .gitignore | 5 ++ README.md | 92 ++++++++++++++++++++++++--- dev/.env.example | 25 ++++++++ 4 files changed, 122 insertions(+), 10 deletions(-) create mode 100644 .changeset/native-s3-backup-target.md diff --git a/.changeset/native-s3-backup-target.md b/.changeset/native-s3-backup-target.md new file mode 100644 index 0000000..0714337 --- /dev/null +++ b/.changeset/native-s3-backup-target.md @@ -0,0 +1,10 @@ +--- +'@trieb.work/payload-plugin-backup-mongodb': minor +--- + +Add native AWS S3 as a selectable backup target alongside Vercel Blob. + +Set `BACKUP_STORAGE=s3` and configure `BACKUP_S3_*` env vars to store backups in +S3 — or any S3-compatible store (Cloudflare R2, MinIO) via `BACKUP_S3_ENDPOINT`. +Vercel Blob remains the default, so existing deployments are unaffected. The AWS +SDK is an optional peer dependency, loaded only when the S3 target is used. diff --git a/.gitignore b/.gitignore index 85aecef..f99d484 100644 --- a/.gitignore +++ b/.gitignore @@ -30,7 +30,12 @@ yarn-debug.log* yarn-error.log* # local env files +.env .env*.local +dev/.env +# keep the committed templates +!.env.example +!dev/.env.example # vercel .vercel diff --git a/README.md b/README.md index eada618..8cf23bc 100644 --- a/README.md +++ b/README.md @@ -76,14 +76,84 @@ originated at ### Environment variables -| Variable | Required | Purpose | -| ------------------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `MONGODB_URI` | yes | MongoDB connection string (used by Payload and to label backups with the DB name). | -| `BLOB_READ_WRITE_TOKEN` | yes | Default Vercel Blob store for backups and media. Can be overridden in admin settings. | -| `CRON_SECRET` | for cron | Bearer token for every `/api/backup-mongodb/cron/*` call. Vercel Cron can supply this. | -| `NEXT_PUBLIC_SERVER_URL` | optional | Used to label backups with the current host. Falls back to `VERCEL_URL` when set. | -| `BACKUPS_TO_KEEP` | optional | Default retention for cron backups if the settings document has not been edited. Default `10`. | -| `PAYLOAD_BACKUP_ALLOWED_ROLES` | optional | Comma-separated role slugs that may see the **Backups** dashboard (case-insensitive). Use `*` to allow any authenticated user. When unset, the plugin falls back to requiring a `role` with slug `admin`, or allows everyone when the users collection has no `roles` field. Overridden by the `access` plugin option. | +| Variable | Required | Purpose | +| ------------------------------ | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MONGODB_URI` | yes | MongoDB connection string (used by Payload and to label backups with the DB name). | +| `BACKUP_STORAGE` | optional | Backup target: `vercel-blob` (default) or `s3`. See [Choosing a backup target](#choosing-a-backup-target-vercel-blob-or-s3). | +| `BLOB_READ_WRITE_TOKEN` | for Vercel Blob | Default Vercel Blob store for backups and media. Required when `BACKUP_STORAGE=vercel-blob` (the default). Can be overridden in admin settings. | +| `CRON_SECRET` | for cron | Bearer token for every `/api/backup-mongodb/cron/*` call. Vercel Cron can supply this. | +| `NEXT_PUBLIC_SERVER_URL` | optional | Used to label backups with the current host. Falls back to `VERCEL_URL` when set. | +| `BACKUPS_TO_KEEP` | optional | Default retention for cron backups if the settings document has not been edited. Default `10`. | +| `PAYLOAD_BACKUP_ALLOWED_ROLES` | optional | Comma-separated role slugs that may see the **Backups** dashboard (case-insensitive). Use `*` to allow any authenticated user. When unset, the plugin falls back to requiring a `role` with slug `admin`, or allows everyone when the users collection has no `roles` field. Overridden by the `access` plugin option. | + +--- + +## Choosing a backup target (Vercel Blob or S3) + +Backups can be stored in **Vercel Blob** (default) or **AWS S3**. The S3 target +also works with any S3-compatible store — **Cloudflare R2**, **MinIO**, etc. — +because they share the same API; only the configuration differs, never the +plugin code. + +Select the target with `BACKUP_STORAGE`. When unset it stays `vercel-blob`, so +existing deployments are unaffected. + +### Using S3 + +S3 is enabled when `BACKUP_STORAGE=s3`. Install the AWS SDK (declared as +optional peer dependencies, so Vercel-only installs don't pull it in): + +```bash +pnpm add @aws-sdk/client-s3 @aws-sdk/lib-storage @aws-sdk/s3-request-presigner +``` + +| Variable | Required | Purpose | +| ----------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------- | +| `BACKUP_STORAGE` | yes | Set to `s3`. | +| `BACKUP_S3_BUCKET` | yes | Target bucket name. | +| `BACKUP_S3_REGION` | optional | Region. Falls back to `AWS_REGION`, then `us-east-1`. | +| `BACKUP_S3_ENDPOINT` | optional | Custom endpoint for S3-compatible stores (R2, MinIO). Omit for AWS S3. | +| `BACKUP_S3_FORCE_PATH_STYLE` | optional | Path-style addressing. Defaults to `true` when an endpoint is set (needed for MinIO and some R2 setups). | +| `BACKUP_S3_PREFIX` | optional | Key namespace prepended to every object (for sharing a bucket). Backups still list/restore by their `backups/…` name. | +| `BACKUP_S3_ACCESS_KEY_ID` | optional | Access key. Falls back to `AWS_ACCESS_KEY_ID`. Omit to use the AWS default credential chain (IAM roles, etc.). | +| `BACKUP_S3_SECRET_ACCESS_KEY` | optional | Secret key. Falls back to `AWS_SECRET_ACCESS_KEY`. | +| `BACKUP_S3_SESSION_TOKEN` | optional | Session token for temporary credentials. Falls back to `AWS_SESSION_TOKEN`. | + +Credentials are optional: when omitted, the AWS SDK's default provider chain is +used (environment, shared config, or instance/role credentials — recommended on +AWS). + +**AWS S3** + +```bash +BACKUP_STORAGE=s3 +BACKUP_S3_BUCKET=my-payload-backups +BACKUP_S3_REGION=eu-central-1 +# Credentials via IAM role (recommended) or: +# BACKUP_S3_ACCESS_KEY_ID=... +# BACKUP_S3_SECRET_ACCESS_KEY=... +``` + +**Cloudflare R2** + +```bash +BACKUP_STORAGE=s3 +BACKUP_S3_BUCKET=my-payload-backups +BACKUP_S3_REGION=auto +BACKUP_S3_ENDPOINT=https://.r2.cloudflarestorage.com +BACKUP_S3_FORCE_PATH_STYLE=true +BACKUP_S3_ACCESS_KEY_ID= +BACKUP_S3_SECRET_ACCESS_KEY= +``` + +Backups are stored under the same `backups/…` keys (see +[Blob naming](#blob-naming)), so listing and restore work identically across +targets. Downloads stream through the plugin's authenticated endpoint, so the +bucket does **not** need to be public. + +> **Note on media:** the "include media" option bundles files from your Payload +> upload store. The configurable target above governs where backup **archives** +> live; media is read from / restored to your existing upload store as before. --- @@ -407,8 +477,10 @@ workflow opens a “version packages” PR or runs `pnpm run release` (`build` + ## Roadmap / ideas - Multipart / very large backup uploads (Vercel Blob supports large objects; may - need to switch past single-part limits). -- Additional storage adapters (S3, R2, filesystem, etc.). + need to switch past single-part limits). _(S3 uploads already use multipart.)_ +- Additional storage adapters (filesystem, etc.). _(AWS S3 and S3-compatible + stores such as R2/MinIO are supported — see + [Choosing a backup target](#choosing-a-backup-target-vercel-blob-or-s3).)_ - Scheduler-agnostic display when not using Vercel (`vercel.json` is currently used for the schedule summary where available). - Streaming restore for very large databases. diff --git a/dev/.env.example b/dev/.env.example index 4af105a..b9b6936 100644 --- a/dev/.env.example +++ b/dev/.env.example @@ -10,3 +10,28 @@ PAYLOAD_SECRET=YOUR_SECRET_HERE # Leave unset to fall back to the default (admin role, or all users when the # users collection has no `roles` field). # PAYLOAD_BACKUP_ALLOWED_ROLES=admin,superadmin + +# ── Backup target ───────────────────────────────────────────────────────── +# Where backup archives are stored. `vercel-blob` (default) or `s3`. +# BACKUP_STORAGE=vercel-blob + +# Vercel Blob target (used when BACKUP_STORAGE=vercel-blob). The default store +# for backups and media; can be overridden in the admin Backup settings. +# BLOB_READ_WRITE_TOKEN=vercel_blob_rw_xxxxxxxx + +# AWS S3 / S3-compatible target (used when BACKUP_STORAGE=s3). Works with AWS S3, +# Cloudflare R2, MinIO, etc. — same code, only the config differs. +# BACKUP_S3_BUCKET=my-payload-backups +# BACKUP_S3_REGION=us-east-1 +# Custom endpoint for R2/MinIO (omit for AWS). forcePathStyle defaults true when set. +# BACKUP_S3_ENDPOINT=http://127.0.0.1:9000 +# BACKUP_S3_FORCE_PATH_STYLE=true +# Optional key namespace when sharing a bucket. +# BACKUP_S3_PREFIX=team-a +# Credentials are optional: omit to use the AWS default chain (env/role). Otherwise: +# BACKUP_S3_ACCESS_KEY_ID=your-access-key-id +# BACKUP_S3_SECRET_ACCESS_KEY=your-secret-access-key +# BACKUP_S3_SESSION_TOKEN=optional-session-token + +# Required for cron endpoints (/api/backup-mongodb/cron/*). +# CRON_SECRET=your-cron-secret From a063906fa0f98251276b5117a95d82c5c7386f19 Mon Sep 17 00:00:00 2001 From: Jannik Zinkl Date: Thu, 18 Jun 2026 23:00:13 +0200 Subject: [PATCH 12/13] docs: remove seed functionality; update README for multi-target storage Remove the seedDemoDumpUrl option, admin/seed endpoint, and restoreSeedMedia helper. Update README to reflect Vercel Blob + S3 target support, clarify storage configuration in settings, and remove seed-related API documentation. Adjust backup/restore to route media I/O through the storage adapter instead of direct Vercel Blob calls. --- README.md | 91 ++++----- src/collections/BackupTasks.ts | 2 +- src/core/backup.ts | 52 +++--- src/core/restore.ts | 89 +++------ src/core/storage/s3.ts | 30 +-- src/core/taskProgress.ts | 2 +- src/endpoints/index.ts | 6 - src/endpoints/paths/admin-restore.ts | 8 +- src/endpoints/paths/admin-seed.ts | 49 ----- src/endpoints/paths/admin-settings.ts | 20 +- src/endpoints/paths/cron-restore.ts | 8 +- src/endpoints/shared.ts | 7 - src/index.ts | 2 +- src/publicApiPaths.ts | 1 - src/types.ts | 5 - tests/README.md | 7 - tests/integration/adminEndpointAuth.test.ts | 1 - tests/integration/backup.test.ts | 61 ++++-- tests/integration/s3Independence.test.ts | 197 ++++++++++++++++++++ tests/unit/plugin.test.ts | 11 -- 20 files changed, 391 insertions(+), 258 deletions(-) delete mode 100644 src/endpoints/paths/admin-seed.ts create mode 100644 tests/integration/s3Independence.test.ts diff --git a/README.md b/README.md index 8cf23bc..efbc4dc 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,10 @@ A **Payload CMS v3** plugin for **MongoDB only** — not Postgres, SQLite, or other database adapters. It handles Mongo + media backup, restore, and scheduled -retention with zero meta-database and a built-in admin UI. Backups live directly -in Vercel Blob Storage, so a fresh install can list and restore any prior backup -without bootstrapping a database first. +retention with zero meta-database and a built-in admin UI. Backups live in +**Vercel Blob Storage** (default) or **AWS S3 / S3-compatible stores** (R2, +MinIO, etc.), so a fresh install can list and restore any prior backup without +bootstrapping a database first. ![Backup dashboard embedded in the Payload admin panel](./docs/screenshots/dashboard.png) @@ -51,15 +52,13 @@ originated at you do not add `app/.../route.ts` files for this plugin. - **Pluggable blob storage.** Uses `BLOB_READ_WRITE_TOKEN` by default (the same store you often use with `@payloadcms/storage-vercel-blob`), or point backups - at a dedicated Vercel Blob store in the admin. Both **public** and **private** - access stores are supported; a validation step detects which modes the store - accepts. -- **Resumable long-running tasks.** Manual backups, restores, and seed runs are - tracked in a hidden `backup-tasks` collection (TTL, 30 min). The UI polls - progress with a short-lived `pollSecret` so long jobs stay observable even - across reloads. -- **Demo/seed support.** Optional `seedDemoDumpUrl` registers a one-click seed - endpoint for templates and starters. + at a dedicated Vercel Blob store in the admin. Alternatively set + `BACKUP_STORAGE=s3` and use AWS S3, Cloudflare R2, MinIO, etc. Both **public** + and **private** access stores are supported; a validation step detects which + modes the store accepts. +- **Resumable long-running tasks.** Manual backups and restores are tracked in a + hidden `backup-tasks` collection (TTL, 30 min). The UI polls progress with a + short-lived `pollSecret` so long jobs stay observable even across reloads. - **Tested.** Vitest covers archive, backup, restore, task progress, blob I/O, endpoint auth, cron parsing, and blob-name helpers. @@ -70,8 +69,13 @@ originated at - Payload **v3+** (same major as your other `@payloadcms/*` packages). - MongoDB with Payload’s mongoose adapter (`@payloadcms/db-mongodb`). - MongoDB server (any version supported by that adapter). -- A Vercel Blob read/write token (`BLOB_READ_WRITE_TOKEN`). Vercel hosting is - **not** required — any Node runtime that can reach Vercel Blob works. +- A backup target: + - **Vercel Blob:** `BLOB_READ_WRITE_TOKEN` (the default target). Vercel + hosting is **not** required — any Node runtime that can reach Vercel Blob + works. + - **S3:** `BACKUP_STORAGE=s3` plus `BACKUP_S3_BUCKET` (and optionally + credentials). See + [Choosing a backup target](#choosing-a-backup-target-vercel-blob-or-s3). - Next.js **15+** and React **19+** (the usual Payload 3 + App Router stack). ### Environment variables @@ -160,7 +164,7 @@ bucket does **not** need to be public. ## Installation and getting started Use this flow when **adding the plugin to an existing Payload 3 + Next.js app** -that already has MongoDB and (typically) Vercel Blob configured. +that already has MongoDB and a blob/S3 store configured. ### 1. Add the package @@ -289,8 +293,10 @@ Retention, storage token, and cron collection skip list live in one modal: - **Retention:** how many **cron** archives to keep; manual backups are not pruned by this. - **Dedicated backup storage (optional):** a separate Vercel Blob read/write - token so backups can live in a different store than media. The UI can validate - the token and optionally copy existing `backups/*` objects to the new store. + token (when `BACKUP_STORAGE=vercel-blob`) so backups can live in a different + store than media. The UI can validate the token and optionally copy existing + `backups/*` objects to the new store. For S3, configure the target bucket via + environment variables. - **Collection selection for cron:** defaults to all collections, with per-collection opt-out. @@ -309,12 +315,6 @@ type BackupPluginOptions = { /** Default cron retention when no value is stored in settings. Falls back to `BACKUPS_TO_KEEP` or `10`. */ backupsToKeep?: number - /** - * If set, registers `POST /api/backup-mongodb/admin/seed` (demo dump + public seed media where applicable). - * Omit in production unless you need it. - */ - seedDemoDumpUrl?: string - /** * Custom access check for admin routes and the dashboard. Overrides the * `PAYLOAD_BACKUP_ALLOWED_ROLES` env var when provided. @@ -328,19 +328,6 @@ type BackupPluginOptions = { } ``` -Example with custom access and seed URL (typical for starters): - -```ts -backupMongodbPlugin({ - access: (user) => - Array.isArray((user as { roles?: { slug?: string }[] })?.roles) && - (user as { roles: { slug?: string }[] }).roles.some( - (r) => r?.slug === 'admin' || r?.slug === 'superadmin', - ), - seedDemoDumpUrl: 'https://example.com/seed/demo-db.json', -}) -``` - --- ## HTTP API (Payload REST) @@ -361,18 +348,17 @@ Authorization: Bearer ### Admin (session cookie, or `pollSecret` for `/task/:id` where applicable) -| Method | Path | -| -------------- | -------------------------------------------------------------- | -| `POST` | `/api/backup-mongodb/admin/manual` | -| `POST` | `/api/backup-mongodb/admin/restore` | -| `POST` | `/api/backup-mongodb/admin/backup-preview` | -| `POST` | `/api/backup-mongodb/admin/restore-preview` | -| `POST` | `/api/backup-mongodb/admin/delete` | -| `GET` | `/api/backup-mongodb/admin/backup-download` | -| `GET` | `/api/backup-mongodb/admin/task/:id` | -| `GET` / `POST` | `/api/backup-mongodb/admin/settings` | -| `POST` | `/api/backup-mongodb/admin/validate-blob-token` | -| `POST` | `/api/backup-mongodb/admin/seed` — if `seedDemoDumpUrl` is set | +| Method | Path | +| -------------- | ----------------------------------------------- | +| `POST` | `/api/backup-mongodb/admin/manual` | +| `POST` | `/api/backup-mongodb/admin/restore` | +| `POST` | `/api/backup-mongodb/admin/backup-preview` | +| `POST` | `/api/backup-mongodb/admin/restore-preview` | +| `POST` | `/api/backup-mongodb/admin/delete` | +| `GET` | `/api/backup-mongodb/admin/backup-download` | +| `GET` | `/api/backup-mongodb/admin/task/:id` | +| `GET` / `POST` | `/api/backup-mongodb/admin/settings` | +| `POST` | `/api/backup-mongodb/admin/validate-blob-token` | --- @@ -423,10 +409,11 @@ flows handle both. ### Overriding the backup store -To keep backups in a **different** Vercel Blob project than media, open **Backup -settings** in the admin, paste a dedicated `BLOB_READ_WRITE_TOKEN`, validate, -and optionally migrate existing `backups/*` objects to the new store before -switching. +To keep backups in a **different** Vercel Blob project than media (when +`BACKUP_STORAGE=vercel-blob`), open **Backup settings** in the admin, paste a +dedicated `BLOB_READ_WRITE_TOKEN`, validate, and optionally migrate existing +`backups/*` objects to the new store before switching. For S3, the target is +controlled entirely by environment variables. --- diff --git a/src/collections/BackupTasks.ts b/src/collections/BackupTasks.ts index ff24d70..e7c82b3 100644 --- a/src/collections/BackupTasks.ts +++ b/src/collections/BackupTasks.ts @@ -15,7 +15,7 @@ export const BackupTasksCollection: CollectionConfig = { { name: 'kind', type: 'select', - options: ['backup', 'restore', 'seed', 'delete', 'blobTransfer'], + options: ['backup', 'restore', 'delete', 'blobTransfer'], required: true, }, { diff --git a/src/core/backup.ts b/src/core/backup.ts index f78fbfa..b184776 100644 --- a/src/core/backup.ts +++ b/src/core/backup.ts @@ -1,8 +1,9 @@ import type { Payload } from 'payload' -import { list } from '@vercel/blob' import { EJSON } from 'bson' +import type { BackupStorageAdapter } from './storage/types' + import { createBlobName, getCurrentDbName, @@ -10,7 +11,7 @@ import { sanitizeBackupLabel, } from '../utils/index' import { createTarGzip } from './archive' -import { type BackupBlobAccessLevel, readBackupBlobContentFlexible } from './backupBlobIO' +import { type BackupBlobAccessLevel } from './backupBlobIO' import { getResolvedCronBackupSettings, resolveBackupBlobToken } from './backupSettings' import { getDb } from './db' import { getBackupStorageKind, resolveBackupStorage } from './storage' @@ -63,38 +64,41 @@ function resolveBlobToken(blobToken?: string): string | undefined { } /** - * @param mediaListToken Token for listing/fetching **Payload media** blobs (usually - * `BLOB_READ_WRITE_TOKEN`). When omitted, uses env then falls back to `backupBlobToken`. + * Builds a tar.gz archive containing the collection dump plus any media files retrieved + * through the given storage adapter. The adapter abstracts Vercel Blob vs S3 so this + * function stays target-agnostic. */ export async function createMediaBackupFile( collectionBackupFile: string, mediaCollection: { filename: string }[], - backupBlobToken?: string, - mediaListToken?: string, + storage: BackupStorageAdapter, payload?: Payload, ): Promise { - const envMedia = (process.env.BLOB_READ_WRITE_TOKEN || '').trim() - const tokenForMedia = - resolveBlobToken(mediaListToken) ?? - (envMedia.length > 0 ? envMedia : undefined) ?? - resolveBlobToken(backupBlobToken) const mediaFiles = await Promise.all( mediaCollection.map(async (media) => { - const matchingFiles = await list({ limit: 2, prefix: media.filename, token: tokenForMedia }) - const blob = matchingFiles.blobs.find((blob) => blob.pathname === media.filename) - if (!blob) { + try { + const matchingFiles = await storage.list(media.filename) + const blob = matchingFiles.find((blob) => blob.pathname === media.filename) + if (!blob) { + payload?.logger.warn( + { filename: media.filename }, + '[backup] File was in collection but not in blob storage', + ) + return undefined + } + const content = await storage.read({ + downloadUrl: blob.downloadUrl, + pathname: blob.pathname, + url: blob.url, + }) + return { name: media.filename, content } + } catch (err) { payload?.logger.warn( - { filename: media.filename }, - '[backup] File was in collection but not in blob storage', + { err, filename: media.filename }, + '[backup] Failed to read media file from storage', ) return undefined } - const content = await readBackupBlobContentFlexible( - blob.pathname, - blob.downloadUrl, - tokenForMedia ?? '', - ) - return { name: media.filename, content } }), ) return await createTarGzip([ @@ -134,7 +138,6 @@ export async function createBackup( } = options const label = cron ? '' : sanitizeBackupLabel(options.label) const blobAccess: BackupBlobAccessLevel = options.blobAccess ?? 'public' - const envMedia = (process.env.BLOB_READ_WRITE_TOKEN || '').trim() const token = resolveBlobToken(blobToken) const skip = new Set(skipCollections ?? []) const resolvedBackupsToKeep = backupsToKeep ?? (Number(process.env.BACKUPS_TO_KEEP) || 10) @@ -233,8 +236,7 @@ export async function createBackup( await createMediaBackupFile( collectionBackupFile, (allData?.['media'] as { filename: string }[] | undefined) || [], - token, - envMedia.length > 0 ? envMedia : undefined, + storage, payload, ) : collectionBackupFile diff --git a/src/core/restore.ts b/src/core/restore.ts index 635b54d..80c3f4e 100644 --- a/src/core/restore.ts +++ b/src/core/restore.ts @@ -1,17 +1,14 @@ import type { Payload } from 'payload' import { EJSON } from 'bson' -import fs from 'node:fs/promises' -import path from 'node:path' + +import type { BackupStorageAdapter } from './storage/types' import { resolveTarGzip } from './archive' import { COLLECTION_FILE_NAME } from './backup' -import { - type BackupBlobAccessLevel, - putBackupBlobContent, - readBackupBlobContentFlexible, -} from './backupBlobIO' +import { type BackupBlobAccessLevel, readBackupBlobContentFlexible } from './backupBlobIO' import { getDb } from './db' +import { resolveBackupStorage } from './storage' import { updateBackupTask } from './taskProgress' export interface RestoreBackupOptions { @@ -33,6 +30,11 @@ export interface RestoreBackupOptions { * collections still restore). Default true. */ restoreArchiveMedia?: boolean + /** + * Storage adapter for media file operations. When omitted, a default adapter is resolved + * from `blobAccess`/`blobToken`. + */ + storage?: BackupStorageAdapter } export async function restoreBackup( @@ -47,6 +49,7 @@ export async function restoreBackup( const blobToken = options?.blobToken const blobAccess: BackupBlobAccessLevel = options?.blobAccess ?? 'public' const backupRead = options?.backupRead + const storage = options?.storage ?? resolveBackupStorage({ blobAccess, blobToken }) const t0 = Date.now() const urlBase = downloadUrl.split('?')?.[0] @@ -117,25 +120,25 @@ export async function restoreBackup( }) } const mediaResults = await Promise.all( - medias.map((media) => - putBackupBlobContent(media.name, media.content, blobToken, blobAccess).then( - (effectiveAccess) => ({ name: media.name, effectiveAccess }), - ), - ), + medias.map(async (media) => { + try { + await storage.put(media.name, media.content) + return { name: media.name, ok: true } + } catch (err) { + payload.logger.error({ name: media.name, err }, '[restore] Failed to upload media file') + return { name: media.name, ok: false } + } + }), ) - const mismatched = mediaResults.filter((r) => r.effectiveAccess !== blobAccess) - if (mismatched.length > 0) { - payload.logger.warn( - { count: mismatched.length, preferredAccess: blobAccess }, - '[restore] Blob store rejected preferred access level for media; uploaded with fallback', - ) + const failed = mediaResults.filter((r) => !r.ok) + if (failed.length > 0) { + payload.logger.warn({ count: failed.length }, '[restore] Some media files failed to upload') } - mediaResults.forEach((result) => { - payload.logger.debug( - { name: result.name, access: result.effectiveAccess }, - '[restore] Media file uploaded', - ) - }) + mediaResults + .filter((r) => r.ok) + .forEach((result) => { + payload.logger.debug({ name: result.name }, '[restore] Media file uploaded') + }) } else { throw new Error(`File type of backup ${downloadUrl} not supported`) } @@ -204,41 +207,3 @@ export async function restoreBackup( payload.logger.info({ durationMs: Date.now() - t0 }, '[restore] Restore complete') } - -export async function restoreSeedMedia(payload: Payload, taskId?: string): Promise { - const files = await fs.readdir(path.join(process.cwd(), 'public/seed/media')) - if (taskId) { - await updateBackupTask(payload, taskId, { - message: `Restoring ${files.length} seed media file${files.length === 1 ? '' : 's'}`, - status: 'running', - }) - } - for (const file of files) { - const data = await fs.readFile(path.join(process.cwd(), 'public/seed/media', file)) - if (process.env.BLOB_READ_WRITE_TOKEN) { - const effectiveAccess = await putBackupBlobContent( - file, - data, - process.env.BLOB_READ_WRITE_TOKEN, - 'public', - ) - payload.logger.info( - { access: effectiveAccess, file }, - '[restore] Restored seed media to Vercel Blob storage', - ) - } else { - const folderPath = path.join(process.cwd(), 'public/media') - const publicPath = path.join(folderPath, file) - await fs.mkdir(folderPath, { recursive: true }) - await fs.writeFile(publicPath, data) - payload.logger.info({ file, publicPath }, '[restore] Restored seed media to public directory') - } - if (taskId) { - await updateBackupTask(payload, taskId, { - message: `Restored seed media file ${file}`, - }) - } - } - payload.logger.info({ count: files.length }, '[restore] Restored all seed media files') - return files -} diff --git a/src/core/storage/s3.ts b/src/core/storage/s3.ts index 12e1bc4..18b1926 100644 --- a/src/core/storage/s3.ts +++ b/src/core/storage/s3.ts @@ -36,22 +36,24 @@ export class S3BackupStorage implements BackupStorageAdapter { this.cfg = cfg } + private async buildClient(): Promise { + const { S3Client } = await this.sdk() + const { accessKeyId, secretAccessKey, sessionToken } = this.cfg + return new S3Client({ + ...(this.cfg.endpoint ? { endpoint: this.cfg.endpoint } : {}), + forcePathStyle: this.cfg.forcePathStyle, + region: this.cfg.region, + // When keys are absent, fall back to the SDK's default credential provider chain + // (env vars, shared config, instance/role credentials). + ...(accessKeyId && secretAccessKey ? + { credentials: { accessKeyId, secretAccessKey, sessionToken } } + : {}), + }) + } + private async client(): Promise { if (!this.clientPromise) { - this.clientPromise = (async () => { - const { S3Client } = await this.sdk() - const { accessKeyId, secretAccessKey, sessionToken } = this.cfg - return new S3Client({ - ...(this.cfg.endpoint ? { endpoint: this.cfg.endpoint } : {}), - forcePathStyle: this.cfg.forcePathStyle, - region: this.cfg.region, - // When keys are absent, fall back to the SDK's default credential provider chain - // (env vars, shared config, instance/role credentials). - ...(accessKeyId && secretAccessKey ? - { credentials: { accessKeyId, secretAccessKey, sessionToken } } - : {}), - }) - })() + this.clientPromise = this.buildClient() } return this.clientPromise } diff --git a/src/core/taskProgress.ts b/src/core/taskProgress.ts index 3054fe0..6734d93 100644 --- a/src/core/taskProgress.ts +++ b/src/core/taskProgress.ts @@ -2,7 +2,7 @@ import type { Payload } from 'payload' import { randomBytes, timingSafeEqual } from 'node:crypto' -export type BackupTaskKind = 'backup' | 'blobTransfer' | 'delete' | 'restore' | 'seed' +export type BackupTaskKind = 'backup' | 'blobTransfer' | 'delete' | 'restore' export type BackupTaskStatus = 'completed' | 'failed' | 'queued' | 'running' export type BackupTaskProgress = { diff --git a/src/endpoints/index.ts b/src/endpoints/index.ts index 6539539..1662919 100644 --- a/src/endpoints/index.ts +++ b/src/endpoints/index.ts @@ -7,7 +7,6 @@ import { createAdminDeleteEndpoint } from './paths/admin-delete' import { createAdminManualEndpoint } from './paths/admin-manual' import { createAdminPreviewEndpoints } from './paths/admin-preview' import { createAdminRestoreEndpoint } from './paths/admin-restore' -import { createAdminSeedEndpoint } from './paths/admin-seed' import { createAdminSettingsEndpoints } from './paths/admin-settings' import { createAdminTaskEndpoint } from './paths/admin-task' import { createAdminValidateBlobTokenEndpoint } from './paths/admin-validate-blob-token' @@ -30,10 +29,5 @@ export function createBackupMongodbEndpoints(options: BackupPluginOptions): Endp createAdminValidateBlobTokenEndpoint(options), ] - const seed = createAdminSeedEndpoint(options) - if (seed) { - endpoints.push(seed) - } - return endpoints } diff --git a/src/endpoints/paths/admin-restore.ts b/src/endpoints/paths/admin-restore.ts index 82d24fd..9ffb80e 100644 --- a/src/endpoints/paths/admin-restore.ts +++ b/src/endpoints/paths/admin-restore.ts @@ -11,7 +11,11 @@ import { resolveBackupBlobToken, } from '../../core/backupSettings' import { restoreBackup } from '../../core/restore' -import { getBackupStorageKind, isBackupStorageConfigured } from '../../core/storage' +import { + getBackupStorageKind, + isBackupStorageConfigured, + resolveBackupStorage, +} from '../../core/storage' import { completeBackupTask, createBackupTask, failBackupTask } from '../../core/taskProgress' import { jsonError, readRequestJson, requireBackupAdmin } from '../shared' @@ -56,6 +60,7 @@ export function createAdminRestoreEndpoint(options: BackupPluginOptions): Endpoi return jsonError('Missing pathname (required for dedicated backup blob store)', 400) } + const storage = resolveBackupStorage({ blobAccess, blobToken }) const { pollSecret, taskId } = await createBackupTask(payload, 'restore', 'Restore queued') payload.logger.info({ taskId, url }, '[backup-endpoint] Restore queued') @@ -69,6 +74,7 @@ export function createAdminRestoreEndpoint(options: BackupPluginOptions): Endpoi blobAccess, blobToken, restoreArchiveMedia, + storage, }) .then(() => completeBackupTask(payload, taskId, 'Restore completed')) .catch(async (error) => { diff --git a/src/endpoints/paths/admin-seed.ts b/src/endpoints/paths/admin-seed.ts deleted file mode 100644 index fe91740..0000000 --- a/src/endpoints/paths/admin-seed.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { Endpoint } from 'payload' - -import { after } from 'next/server' - -import type { BackupPluginOptions } from '../../types' - -import { restoreBackup, restoreSeedMedia } from '../../core/restore' -import { completeBackupTask, createBackupTask, failBackupTask } from '../../core/taskProgress' -import { requireBackupAdmin, requireBlobEnv } from '../shared' - -export function createAdminSeedEndpoint(options: BackupPluginOptions): Endpoint | null { - const seedUrl = options.seedDemoDumpUrl - if (!seedUrl) { - return null - } - - return { - handler: async (req) => { - const blobErr = requireBlobEnv() - if (blobErr) { - return blobErr - } - - const auth = await requireBackupAdmin(req, options) - if (auth instanceof Response) { - return auth - } - - const { payload } = req - const { pollSecret, taskId } = await createBackupTask(payload, 'seed', 'Seed queued') - - payload.logger.info({ taskId }, '[backup-endpoint] Seed queued') - - after( - restoreSeedMedia(payload, taskId) - .then(() => restoreBackup(payload, seedUrl, ['users', 'roles'], false, taskId)) - .then(() => completeBackupTask(payload, taskId, 'Seed completed')) - .catch(async (error) => { - await failBackupTask(payload, taskId, error) - payload.logger.error({ err: error, taskId }, '[backup-endpoint] Seed failed') - }), - ) - - return Response.json({ pollSecret, taskId }, { status: 202 }) - }, - method: 'post', - path: '/backup-mongodb/admin/seed', - } -} diff --git a/src/endpoints/paths/admin-settings.ts b/src/endpoints/paths/admin-settings.ts index 7f5925f..eb28eb5 100644 --- a/src/endpoints/paths/admin-settings.ts +++ b/src/endpoints/paths/admin-settings.ts @@ -162,8 +162,9 @@ export function createAdminSettingsEndpoints(options: BackupPluginOptions): Endp // Access detection: re-probe when the token actually changes; preserve existing otherwise; // clear when the override is removed (falls back to heuristic on the default env token). + // Skip Vercel validation when the active target is S3. let backupBlobAccessForDb: 'private' | 'public' | null = stored.backupBlobAccess - if (!preserveTokenField) { + if (!preserveTokenField && getBackupStorageKind() !== 's3') { if (tokenForDb.length === 0) { backupBlobAccessForDb = null } else { @@ -214,6 +215,21 @@ export function createAdminSettingsEndpoints(options: BackupPluginOptions): Endp const humanDescription = vercelCron?.schedule != null ? describeCronSchedule(vercelCron.schedule) : null + // Blob transfer is a Vercel-specific operation (moving blobs between tokens). + // Skip entirely when the active target is S3. + if (getBackupStorageKind() === 's3' || !transferBackupBlobs) { + return Response.json( + buildSettingsJson(stored, options, vercelCron, humanDescription, { + deferred: false, + failed: 0, + performed: false, + skipped: 0, + total: 0, + transferred: 0, + }), + ) + } + const newBlobToken = stored.backupBlobReadWriteToken.trim() // Read blobs from the *previous* store when rotating tokens; otherwise first-time setup // reads from BLOB_READ_WRITE_TOKEN (default Vercel store). @@ -227,7 +243,7 @@ export function createAdminSettingsEndpoints(options: BackupPluginOptions): Endp sourceTokenForTransfer.length > 0 && newBlobToken !== sourceTokenForTransfer - if (!transferBackupBlobs || !shouldTransferToNewBlobToken) { + if (!shouldTransferToNewBlobToken) { return Response.json( buildSettingsJson(stored, options, vercelCron, humanDescription, { deferred: false, diff --git a/src/endpoints/paths/cron-restore.ts b/src/endpoints/paths/cron-restore.ts index 6f621f4..c772a51 100644 --- a/src/endpoints/paths/cron-restore.ts +++ b/src/endpoints/paths/cron-restore.ts @@ -7,7 +7,11 @@ import { resolveBackupBlobToken, } from '../../core/backupSettings' import { restoreBackup } from '../../core/restore' -import { getBackupStorageKind, isBackupStorageConfigured } from '../../core/storage' +import { + getBackupStorageKind, + isBackupStorageConfigured, + resolveBackupStorage, +} from '../../core/storage' import { readRequestJson, requireCronBearer } from '../shared' export function createCronRestoreEndpoint(): Endpoint { @@ -42,11 +46,13 @@ export function createCronRestoreEndpoint(): Endpoint { }) } + const storage = resolveBackupStorage({ blobAccess, blobToken }) payload.logger.info({ url }, '[backup-endpoint] Restore request accepted') await restoreBackup(payload, url, [], false, undefined, { backupRead: backupRead ?? undefined, blobAccess, blobToken, + storage, }) payload.logger.info({ url }, '[backup-endpoint] Restore request finished') return Response.json({ message: 'Backup restore finished' }, { status: 202 }) diff --git a/src/endpoints/shared.ts b/src/endpoints/shared.ts index 0a6ddfb..0c52a30 100644 --- a/src/endpoints/shared.ts +++ b/src/endpoints/shared.ts @@ -17,13 +17,6 @@ export function jsonError(message: string, status: number): Response { return Response.json({ error: message }, { status }) } -export function requireBlobEnv(): null | Response { - if (!process.env.BLOB_READ_WRITE_TOKEN) { - return jsonError('Service unavailable', 503) - } - return null -} - export function requireCronBearer(req: PayloadRequest): null | Response { // Security gate for cron/external backup routes: only a matching CRON_SECRET bearer may pass. const authHeader = req.headers.get('authorization') diff --git a/src/index.ts b/src/index.ts index 6dc80b7..9edfe5d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,7 +10,7 @@ export { getBackupSourcePreviewForManual } from './core/backupSourcePreview' export type { BackupSourcePreviewResponse } from './core/backupSourcePreview' export { getDb } from './core/db' export type { MongoDb } from './core/db' -export { restoreBackup, restoreSeedMedia } from './core/restore' +export { restoreBackup } from './core/restore' export type { RestoreBackupOptions } from './core/restore' export { buildCollectionPreviewGroups, diff --git a/src/publicApiPaths.ts b/src/publicApiPaths.ts index f8406b2..5c3f43d 100644 --- a/src/publicApiPaths.ts +++ b/src/publicApiPaths.ts @@ -9,7 +9,6 @@ export const backupPluginPublicApiPaths = { adminManual: '/api/backup-mongodb/admin/manual', adminRestore: '/api/backup-mongodb/admin/restore', adminRestorePreview: '/api/backup-mongodb/admin/restore-preview', - adminSeed: '/api/backup-mongodb/admin/seed', adminSettings: '/api/backup-mongodb/admin/settings', adminTask: (id: string) => `/api/backup-mongodb/admin/task/${encodeURIComponent(id)}`, adminValidateBlobToken: '/api/backup-mongodb/admin/validate-blob-token', diff --git a/src/types.ts b/src/types.ts index b016184..8ce4b8d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,9 +13,4 @@ export type BackupPluginOptions = { backupsToKeep?: number /** Enable/disable the plugin entirely. Default: true */ enabled?: boolean - /** - * When set, registers POST {@code /api/backup-mongodb/admin/seed} (demo DB + media seed). - * Omit to disable the seed endpoint entirely. - */ - seedDemoDumpUrl?: string } diff --git a/tests/README.md b/tests/README.md index 5ffe7da..7348c2b 100644 --- a/tests/README.md +++ b/tests/README.md @@ -251,13 +251,6 @@ Still deferred (needs a real or mocked blob endpoint): happy-path short-circuit is covered; the async enqueue can be a follow-up.)_ -### Admin seed endpoint (integration) - -- [ ] **P2** — `POST /admin/seed` is only registered when `seedDemoDumpUrl` is - set, returns 503 without blob env, and otherwise queues - `restoreSeedMedia` + `restoreBackup`. _(follow-up; coverage today is only - that the endpoint is conditionally registered.)_ - ### Admin backup-download endpoint (integration) - [ ] **P2** — `GET /admin/backup-download` requires auth, validates that diff --git a/tests/integration/adminEndpointAuth.test.ts b/tests/integration/adminEndpointAuth.test.ts index a4c2120..a6fecf4 100644 --- a/tests/integration/adminEndpointAuth.test.ts +++ b/tests/integration/adminEndpointAuth.test.ts @@ -35,7 +35,6 @@ vi.mock('../../src/core/backup', () => ({ vi.mock('../../src/core/restore', () => ({ restoreBackup: vi.fn(async () => undefined), - restoreSeedMedia: vi.fn(async () => []), })) vi.mock('../../src/core/taskProgress', async () => { diff --git a/tests/integration/backup.test.ts b/tests/integration/backup.test.ts index 171b4a9..74ed4b1 100644 --- a/tests/integration/backup.test.ts +++ b/tests/integration/backup.test.ts @@ -212,6 +212,16 @@ describe('createBackup', () => { }) describe('createMediaBackupFile', () => { + const mockStorage = { + del: vi.fn(), + kind: 'vercel-blob' as const, + list: vi.fn(), + openDownloadStream: vi.fn(), + put: vi.fn(), + read: vi.fn(), + validate: vi.fn(), + } + beforeEach(() => { vi.clearAllMocks() }) @@ -225,16 +235,11 @@ describe('createMediaBackupFile', () => { uploadedAt: new Date(), url: 'https://blob.com/image.png', } - vi.mocked(list).mockResolvedValue({ blobs: [mockBlobFile], cursor: undefined, hasMore: false }) - - global.fetch = vi.fn().mockResolvedValue({ - arrayBuffer: vi.fn().mockResolvedValue(Buffer.from('fake-image-data').buffer), - ok: true, - status: 200, - }) as any + mockStorage.list.mockResolvedValue([mockBlobFile]) + mockStorage.read.mockResolvedValue(Buffer.from('fake-image-data')) const mediaCollection = [{ filename: 'image.png' }] - const result = await createMediaBackupFile('{"pages":[]}', mediaCollection) + const result = await createMediaBackupFile('{"pages":[]}', mediaCollection, mockStorage) expect(result).toBeInstanceOf(Buffer) expect(result[0]).toBe(0x1f) @@ -242,7 +247,7 @@ describe('createMediaBackupFile', () => { }) it('skips missing media files with a warning', async () => { - vi.mocked(list).mockResolvedValue({ blobs: [], cursor: undefined, hasMore: false }) + mockStorage.list.mockResolvedValue([]) const warn = vi.fn() const mockPayload = { logger: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn }, @@ -251,8 +256,7 @@ describe('createMediaBackupFile', () => { const result = await createMediaBackupFile( '{}', [{ filename: 'missing.png' }], - undefined, - undefined, + mockStorage, mockPayload, ) @@ -262,4 +266,39 @@ describe('createMediaBackupFile', () => { ) expect(result).toBeInstanceOf(Buffer) }) + + it('isolates failures per media file', async () => { + mockStorage.list.mockImplementation(async (prefix: string) => { + if (prefix === 'ok.png') { + return [ + { + downloadUrl: 'https://blob.com/ok.png', + pathname: 'ok.png', + size: 1, + uploadedAt: new Date(), + url: 'https://blob.com/ok.png', + }, + ] + } + return [] + }) + mockStorage.read.mockRejectedValue(new Error('network error')) + const warn = vi.fn() + const mockPayload = { + logger: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn }, + } as unknown as Payload + + const result = await createMediaBackupFile( + '{}', + [{ filename: 'ok.png' }], + mockStorage, + mockPayload, + ) + + expect(warn).toHaveBeenCalledWith( + { err: expect.any(Error), filename: 'ok.png' }, + expect.stringMatching(/Failed to read media file from storage/i), + ) + expect(result).toBeInstanceOf(Buffer) + }) }) diff --git a/tests/integration/s3Independence.test.ts b/tests/integration/s3Independence.test.ts new file mode 100644 index 0000000..756eb59 --- /dev/null +++ b/tests/integration/s3Independence.test.ts @@ -0,0 +1,197 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { createBackup } from '../../src/core/backup.js' +import { restoreBackup } from '../../src/core/restore.js' +import { resolveBackupStorage } from '../../src/core/storage/index.js' +import { S3BackupStorage } from '../../src/core/storage/s3.js' + +const S3_ENV_KEYS = [ + 'BACKUP_STORAGE', + 'BACKUP_S3_BUCKET', + 'BACKUP_S3_REGION', + 'BACKUP_S3_ENDPOINT', + 'BACKUP_S3_FORCE_PATH_STYLE', + 'BACKUP_S3_PREFIX', + 'BACKUP_S3_ACCESS_KEY_ID', + 'BACKUP_S3_SECRET_ACCESS_KEY', +] + +// Track whether any @vercel/blob function was invoked. +let vercelBlobCalls = 0 + +vi.mock('@vercel/blob', () => { + const makeThrowing = (name: string) => + vi.fn(() => { + vercelBlobCalls += 1 + throw new Error(`@vercel/blob ${name} should not be called when BACKUP_STORAGE=s3`) + }) + return { + del: makeThrowing('del'), + list: makeThrowing('list'), + put: makeThrowing('put'), + } +}) + +vi.mock('@aws-sdk/client-s3', () => { + const makeCommand = (kind: string) => + class { + __kind = kind + input: Record + constructor(input: Record) { + this.input = input + } + } + return { + DeleteObjectCommand: makeCommand('delete'), + GetObjectCommand: makeCommand('get'), + HeadBucketCommand: makeCommand('head'), + ListObjectsV2Command: makeCommand('list'), + S3Client: vi.fn(function (this: { send: typeof vi.fn }) { + this.send = vi.fn() + }), + } +}) + +vi.mock('@aws-sdk/lib-storage', () => ({ + Upload: vi.fn(function (this: { done: typeof vi.fn }) { + this.done = vi.fn().mockResolvedValue({}) + }), +})) + +vi.mock('@aws-sdk/s3-request-presigner', () => ({ + getSignedUrl: vi.fn().mockResolvedValue('https://signed.example/object'), +})) + +const mockDb = { + collection: vi.fn().mockReturnValue({ + bulkWrite: vi.fn().mockResolvedValue({ modifiedCount: 0, upsertedCount: 0 }), + deleteMany: vi.fn().mockResolvedValue({}), + find: vi.fn().mockReturnValue({ toArray: vi.fn().mockResolvedValue([]) }), + indexes: vi.fn().mockResolvedValue([]), + }), + listCollections: vi.fn().mockReturnValue({ toArray: vi.fn().mockResolvedValue([]) }), +} + +const mockPayload = { + db: { name: 'mongoose', connection: { db: mockDb } }, + logger: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() }, +} as any + +describe('S3 independence — no Vercel blob calls when BACKUP_STORAGE=s3', () => { + let savedEnv: Record + + beforeEach(() => { + savedEnv = {} + for (const key of S3_ENV_KEYS) { + savedEnv[key] = process.env[key] + } + // Ensure no Vercel env token leaks in. + delete process.env.BLOB_READ_WRITE_TOKEN + + process.env.BACKUP_STORAGE = 's3' + process.env.BACKUP_S3_BUCKET = 'test-bucket' + process.env.BACKUP_S3_REGION = 'us-east-1' + + vercelBlobCalls = 0 + vi.clearAllMocks() + }) + + afterEach(() => { + for (const key of S3_ENV_KEYS) { + if (savedEnv[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = savedEnv[key] + } + } + }) + + it('resolveBackupStorage returns S3BackupStorage when BACKUP_STORAGE=s3', () => { + const storage = resolveBackupStorage() + expect(storage).toBeInstanceOf(S3BackupStorage) + expect(storage.kind).toBe('s3') + }) + + it('createBackup does not invoke any @vercel/blob function when BACKUP_STORAGE=s3', async () => { + await createBackup(mockPayload, { blobToken: undefined, includeMedia: false }) + expect(vercelBlobCalls).toBe(0) + }) + + it('restoreBackup does not invoke any @vercel/blob function when BACKUP_STORAGE=s3', async () => { + const url = 'https://signed.example/object/backup.json' + global.fetch = vi.fn().mockResolvedValue({ + arrayBuffer: vi.fn().mockResolvedValue(new TextEncoder().encode('{"pages":[]}').buffer), + ok: true, + status: 200, + }) as any + + await restoreBackup(mockPayload, url, [], false, undefined, { + blobAccess: 'public', + blobToken: undefined, + restoreArchiveMedia: false, + }) + + expect(vercelBlobCalls).toBe(0) + }) + + it('createMediaBackupFile does not invoke @vercel/blob when given an S3 adapter', async () => { + const { createMediaBackupFile } = await import('../../src/core/backup.js') + const storage = resolveBackupStorage() + storage.list = vi.fn().mockResolvedValue([]) + storage.read = vi.fn().mockResolvedValue(Buffer.from('fake')) + + const result = await createMediaBackupFile('{"pages":[]}', [], storage) + expect(result).toBeInstanceOf(Buffer) + expect(vercelBlobCalls).toBe(0) + }) + + it('settings endpoint skips Vercel blob validation when BACKUP_STORAGE=s3', async () => { + const { createAdminSettingsEndpoints } = + await import('../../src/endpoints/paths/admin-settings.js') + + const eps = createAdminSettingsEndpoints({}) + const patch = eps.find( + (e) => e.path === '/backup-mongodb/admin/settings' && e.method === 'patch', + ) + if (!patch) { + throw new Error('missing patch endpoint') + } + + const mockPayload = { + auth: vi.fn().mockResolvedValue({ user: { id: 'u1', role: 'admin' } }), + find: vi.fn().mockResolvedValue({ + docs: [ + { + id: 'settings-1', + backupBlobAccess: 'public', + backupBlobReadWriteToken: '', + backupsToKeep: 10, + includeMediaForCron: false, + skipMongoCollections: [], + }, + ], + }), + logger: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() }, + update: vi.fn().mockResolvedValue({ id: 'settings-1' }), + } as any + + const req = { + headers: new Headers(), + json: vi.fn().mockResolvedValue({ + backupBlobReadWriteToken: 'vercel_blob_rw_new_token', + backupsToKeep: 5, + }), + payload: mockPayload, + searchParams: new URLSearchParams(), + user: { id: 'u1', role: 'admin' }, + } as any + + const res = await patch.handler(req) + expect(res.status).toBe(200) + const body = (await res.json()) as Record + // When S3 is active, Vercel token validation is skipped so the settings + // save succeeds even though the token would be rejected by Vercel. + expect(body.error).toBeUndefined() + expect(body.id).toBe('settings-1') + }) +}) diff --git a/tests/unit/plugin.test.ts b/tests/unit/plugin.test.ts index 5e12cd3..847c77b 100644 --- a/tests/unit/plugin.test.ts +++ b/tests/unit/plugin.test.ts @@ -71,15 +71,4 @@ describe('backupMongodbPlugin', () => { expect(afterDashboard[0]).toBe(existingAfter) expect(afterDashboard[1]).toMatch(/BackupDashboard/) }) - - it('does not register the seed endpoint unless seedDemoDumpUrl is set', () => { - const withoutSeed = applyPlugin({}, baseConfig()) - const withSeed = applyPlugin({ seedDemoDumpUrl: 'https://x/y.json' }, baseConfig()) - - const pathsWithout = (withoutSeed.endpoints ?? []).map((e: { path: string }) => e.path) - const pathsWith = (withSeed.endpoints ?? []).map((e: { path: string }) => e.path) - - expect(pathsWithout).not.toContain('/backup-mongodb/admin/seed') - expect(pathsWith).toContain('/backup-mongodb/admin/seed') - }) }) From c05098ffd1fb6ca77f624125a051a8340bb2d985 Mon Sep 17 00:00:00 2001 From: Jannik Zinkl Date: Mon, 22 Jun 2026 10:46:04 +0200 Subject: [PATCH 13/13] chore: bump @playwright/test from 1.58.2 to 1.61.0 --- package.json | 2 +- pnpm-lock.yaml | 54 +++++++++++++++++++++++++------------------------- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/package.json b/package.json index 6336140..4a71736 100644 --- a/package.json +++ b/package.json @@ -122,7 +122,7 @@ "@payloadcms/richtext-lexical": "3.82.1", "@payloadcms/translations": "3.82.1", "@payloadcms/ui": "3.82.1", - "@playwright/test": "1.58.2", + "@playwright/test": "1.61.0", "@swc-node/register": "1.10.9", "@swc/cli": "0.6.0", "@types/node": "22.19.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df773b1..7599352 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,19 +44,19 @@ importers: version: 3.28.0(@typescript-eslint/eslint-plugin@8.59.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4)(typescript@5.7.3))(eslint@9.39.4)(typescript@5.7.3))(ts-api-utils@2.5.0(typescript@5.7.3)) '@payloadcms/next': specifier: 3.82.1 - version: 3.82.1(@types/react@19.2.14)(graphql@16.13.2)(monaco-editor@0.55.1)(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(payload@3.82.1(graphql@16.13.2)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3) + version: 3.82.1(@types/react@19.2.14)(graphql@16.13.2)(monaco-editor@0.55.1)(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.61.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(payload@3.82.1(graphql@16.13.2)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3) '@payloadcms/richtext-lexical': specifier: 3.82.1 - version: 3.82.1(@faceless-ui/modal@3.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@faceless-ui/scroll-info@2.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@payloadcms/next@3.82.1(@types/react@19.2.14)(graphql@16.13.2)(monaco-editor@0.55.1)(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(payload@3.82.1(graphql@16.13.2)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3))(@types/react@19.2.14)(monaco-editor@0.55.1)(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(payload@3.82.1(graphql@16.13.2)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)(yjs@13.6.30) + version: 3.82.1(@faceless-ui/modal@3.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@faceless-ui/scroll-info@2.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@payloadcms/next@3.82.1(@types/react@19.2.14)(graphql@16.13.2)(monaco-editor@0.55.1)(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.61.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(payload@3.82.1(graphql@16.13.2)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3))(@types/react@19.2.14)(monaco-editor@0.55.1)(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.61.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(payload@3.82.1(graphql@16.13.2)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)(yjs@13.6.30) '@payloadcms/translations': specifier: 3.82.1 version: 3.82.1 '@payloadcms/ui': specifier: 3.82.1 - version: 3.82.1(@types/react@19.2.14)(monaco-editor@0.55.1)(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(payload@3.82.1(graphql@16.13.2)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3) + version: 3.82.1(@types/react@19.2.14)(monaco-editor@0.55.1)(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.61.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(payload@3.82.1(graphql@16.13.2)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3) '@playwright/test': - specifier: 1.58.2 - version: 1.58.2 + specifier: 1.61.0 + version: 1.61.0 '@swc-node/register': specifier: 1.10.9 version: 1.10.9(@swc/core@1.15.30)(@swc/types@0.1.26)(typescript@5.7.3) @@ -107,7 +107,7 @@ importers: version: 10.1.4 next: specifier: 16.2.3 - version: 16.2.3(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0) + version: 16.2.3(@babel/core@7.29.0)(@playwright/test@1.61.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0) open: specifier: ^10.1.0 version: 10.2.0 @@ -1546,8 +1546,8 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} - '@playwright/test@1.58.2': - resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + '@playwright/test@1.61.0': + resolution: {integrity: sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==} engines: {node: '>=18'} hasBin: true @@ -4510,13 +4510,13 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} - playwright-core@1.58.2: - resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + playwright-core@1.61.0: + resolution: {integrity: sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==} engines: {node: '>=18'} hasBin: true - playwright@1.58.2: - resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + playwright@1.61.0: + resolution: {integrity: sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==} engines: {node: '>=18'} hasBin: true @@ -7197,14 +7197,14 @@ snapshots: transitivePeerDependencies: - typescript - '@payloadcms/next@3.82.1(@types/react@19.2.14)(graphql@16.13.2)(monaco-editor@0.55.1)(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(payload@3.82.1(graphql@16.13.2)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)': + '@payloadcms/next@3.82.1(@types/react@19.2.14)(graphql@16.13.2)(monaco-editor@0.55.1)(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.61.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(payload@3.82.1(graphql@16.13.2)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)': dependencies: '@dnd-kit/core': 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@dnd-kit/modifiers': 9.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) '@dnd-kit/sortable': 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) '@payloadcms/graphql': 3.82.1(graphql@16.13.2)(payload@3.82.1(graphql@16.13.2)(typescript@5.7.3))(typescript@5.7.3) '@payloadcms/translations': 3.82.1 - '@payloadcms/ui': 3.82.1(@types/react@19.2.14)(monaco-editor@0.55.1)(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(payload@3.82.1(graphql@16.13.2)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3) + '@payloadcms/ui': 3.82.1(@types/react@19.2.14)(monaco-editor@0.55.1)(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.61.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(payload@3.82.1(graphql@16.13.2)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3) busboy: 1.6.0 dequal: 2.0.3 file-type: 21.3.4 @@ -7212,7 +7212,7 @@ snapshots: graphql-http: 1.22.4(graphql@16.13.2) graphql-playground-html: 1.6.30 http-status: 2.1.0 - next: 16.2.3(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0) + next: 16.2.3(@babel/core@7.29.0)(@playwright/test@1.61.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0) path-to-regexp: 6.3.0 payload: 3.82.1(graphql@16.13.2)(typescript@5.7.3) qs-esm: 8.0.1 @@ -7226,7 +7226,7 @@ snapshots: - supports-color - typescript - '@payloadcms/richtext-lexical@3.82.1(@faceless-ui/modal@3.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@faceless-ui/scroll-info@2.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@payloadcms/next@3.82.1(@types/react@19.2.14)(graphql@16.13.2)(monaco-editor@0.55.1)(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(payload@3.82.1(graphql@16.13.2)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3))(@types/react@19.2.14)(monaco-editor@0.55.1)(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(payload@3.82.1(graphql@16.13.2)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)(yjs@13.6.30)': + '@payloadcms/richtext-lexical@3.82.1(@faceless-ui/modal@3.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@faceless-ui/scroll-info@2.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@payloadcms/next@3.82.1(@types/react@19.2.14)(graphql@16.13.2)(monaco-editor@0.55.1)(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.61.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(payload@3.82.1(graphql@16.13.2)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3))(@types/react@19.2.14)(monaco-editor@0.55.1)(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.61.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(payload@3.82.1(graphql@16.13.2)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)(yjs@13.6.30)': dependencies: '@faceless-ui/modal': 3.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@faceless-ui/scroll-info': 2.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -7241,9 +7241,9 @@ snapshots: '@lexical/selection': 0.41.0 '@lexical/table': 0.41.0 '@lexical/utils': 0.41.0 - '@payloadcms/next': 3.82.1(@types/react@19.2.14)(graphql@16.13.2)(monaco-editor@0.55.1)(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(payload@3.82.1(graphql@16.13.2)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3) + '@payloadcms/next': 3.82.1(@types/react@19.2.14)(graphql@16.13.2)(monaco-editor@0.55.1)(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.61.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(payload@3.82.1(graphql@16.13.2)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3) '@payloadcms/translations': 3.82.1 - '@payloadcms/ui': 3.82.1(@types/react@19.2.14)(monaco-editor@0.55.1)(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(payload@3.82.1(graphql@16.13.2)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3) + '@payloadcms/ui': 3.82.1(@types/react@19.2.14)(monaco-editor@0.55.1)(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.61.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(payload@3.82.1(graphql@16.13.2)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3) '@types/uuid': 10.0.0 acorn: 8.16.0 bson-objectid: 2.0.4 @@ -7276,7 +7276,7 @@ snapshots: dependencies: date-fns: 4.1.0 - '@payloadcms/ui@3.82.1(@types/react@19.2.14)(monaco-editor@0.55.1)(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(payload@3.82.1(graphql@16.13.2)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)': + '@payloadcms/ui@3.82.1(@types/react@19.2.14)(monaco-editor@0.55.1)(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.61.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(payload@3.82.1(graphql@16.13.2)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)': dependencies: '@date-fns/tz': 1.2.0 '@dnd-kit/core': 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -7291,7 +7291,7 @@ snapshots: date-fns: 4.1.0 dequal: 2.0.3 md5: 2.3.0 - next: 16.2.3(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0) + next: 16.2.3(@babel/core@7.29.0)(@playwright/test@1.61.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0) object-to-formdata: 4.5.1 payload: 3.82.1(graphql@16.13.2)(typescript@5.7.3) qs-esm: 8.0.1 @@ -7313,9 +7313,9 @@ snapshots: '@pinojs/redact@0.4.0': {} - '@playwright/test@1.58.2': + '@playwright/test@1.61.0': dependencies: - playwright: 1.58.2 + playwright: 1.61.0 '@preact/signals-core@1.14.1': {} @@ -10501,7 +10501,7 @@ snapshots: transitivePeerDependencies: - supports-color - next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0): + next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.61.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0): dependencies: '@next/env': 16.2.3 '@swc/helpers': 0.5.15 @@ -10520,7 +10520,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.2.3 '@next/swc-win32-arm64-msvc': 16.2.3 '@next/swc-win32-x64-msvc': 16.2.3 - '@playwright/test': 1.58.2 + '@playwright/test': 1.61.0 sass: 1.99.0 sharp: 0.34.5 transitivePeerDependencies: @@ -10813,11 +10813,11 @@ snapshots: dependencies: find-up: 4.1.0 - playwright-core@1.58.2: {} + playwright-core@1.61.0: {} - playwright@1.58.2: + playwright@1.61.0: dependencies: - playwright-core: 1.58.2 + playwright-core: 1.61.0 optionalDependencies: fsevents: 2.3.2