= 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/core/backup.ts b/src/core/backup.ts
index 9d3fb17..b184776 100644
--- a/src/core/backup.ts
+++ b/src/core/backup.ts
@@ -1,8 +1,9 @@
import type { Payload } from 'payload'
-import { del, list } from '@vercel/blob'
import { EJSON } from 'bson'
+import type { BackupStorageAdapter } from './storage/types'
+
import {
createBlobName,
getCurrentDbName,
@@ -10,13 +11,10 @@ import {
sanitizeBackupLabel,
} from '../utils/index'
import { createTarGzip } from './archive'
-import {
- type BackupBlobAccessLevel,
- putBackupBlobContent,
- readBackupBlobContentFlexible,
-} from './backupBlobIO'
+import { type BackupBlobAccessLevel } 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 +45,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 {
@@ -67,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([
@@ -138,10 +138,10 @@ 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)
+ const storage = resolveBackupStorage({ blobAccess, blobToken: token })
const currentHostname = getCurrentHostname()
const currentDbName = getCurrentDbName()
@@ -166,11 +166,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 +180,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')
}
}
@@ -240,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
@@ -261,13 +256,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')
}
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/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'),
+ }
+}
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())
+}
diff --git a/src/core/storage/s3.ts b/src/core/storage/s3.ts
new file mode 100644
index 0000000..18b1926
--- /dev/null
+++ b/src/core/storage/s3.ts
@@ -0,0 +1,204 @@
+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 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 = this.buildClient()
+ }
+ 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,
+ }
+ }
+ }
+}
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
+}
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 ?? '')
+ }
+}
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-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..9ffb80e 100644
--- a/src/endpoints/paths/admin-restore.ts
+++ b/src/endpoints/paths/admin-restore.ts
@@ -11,6 +11,11 @@ import {
resolveBackupBlobToken,
} from '../../core/backupSettings'
import { restoreBackup } from '../../core/restore'
+import {
+ getBackupStorageKind,
+ isBackupStorageConfigured,
+ resolveBackupStorage,
+} from '../../core/storage'
import { completeBackupTask, createBackupTask, failBackupTask } from '../../core/taskProgress'
import { jsonError, readRequestJson, requireBackupAdmin } from '../shared'
@@ -26,7 +31,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,10 +56,11 @@ 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)
}
+ const storage = resolveBackupStorage({ blobAccess, blobToken })
const { pollSecret, taskId } = await createBackupTask(payload, 'restore', 'Restore queued')
payload.logger.info({ taskId, url }, '[backup-endpoint] Restore queued')
@@ -68,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 01b47dc..eb28eb5 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,
}
}
@@ -142,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 {
@@ -194,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).
@@ -207,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-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..c772a51 100644
--- a/src/endpoints/paths/cron-restore.ts
+++ b/src/endpoints/paths/cron-restore.ts
@@ -7,6 +7,11 @@ import {
resolveBackupBlobToken,
} from '../../core/backupSettings'
import { restoreBackup } from '../../core/restore'
+import {
+ getBackupStorageKind,
+ isBackupStorageConfigured,
+ resolveBackupStorage,
+} from '../../core/storage'
import { readRequestJson, requireCronBearer } from '../shared'
export function createCronRestoreEndpoint(): Endpoint {
@@ -20,7 +25,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,17 +40,19 @@ 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,
})
}
+ 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/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')
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/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/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')
- })
})
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')
+ })
+ })
+})