= 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.

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