feat: add encrypted online settings backup keys

Add immutable client-encrypted settings snapshots with independent MDD2 capability keys so fresh installs can restore configuration without transferring backup files. Keep credentials encrypted end to end, preserve queues and history during import, and avoid exposing identifiers in request URLs or errors. Include the persistent API with quota, rate limits, crash-safe storage locking, durability checks, and end-to-end race and recovery coverage.
This commit is contained in:
Sucukdeluxe
2026-08-07 18:29:06 +02:00
parent 5f19293ed2
commit 6e44e63167
22 changed files with 1832 additions and 44 deletions
+1
View File
@@ -0,0 +1 @@
data/
+36
View File
@@ -0,0 +1,36 @@
# Multi-Debrid Backup API
Die API speichert ausschließlich bereits clientseitig verschlüsselte, undurchsichtige Backups. Schlüssel und Klartext verlassen den Client nicht.
Jeder Export wird als eigener unveränderlicher Datensatz gespeichert. Es gibt keine automatische Ablaufzeit und ein neuer Export überschreibt oder löscht keine älteren Sicherungen.
## Konfiguration
| Variable | Standard | Bedeutung |
|---|---:|---|
| `HOST` | `127.0.0.1` | Bind-Adresse |
| `PORT` | `8787` | HTTP-Port hinter einem TLS-Reverse-Proxy |
| `BACKUP_DATA_DIR` | `./data` | Persistentes Datenverzeichnis |
| `ALLOWED_ORIGINS` | leer | Kommagetrennte erlaubte Browser-Origins |
| `RATE_LIMIT_MAX` | `60` | Maximalzahl pro IP und Zeitfenster |
| `RATE_LIMIT_WINDOW_MS` | `60000` | Länge des Zeitfensters |
| `UPLOAD_RATE_LIMIT_MAX` | `10` | Maximale neue Sicherungen pro IP und Upload-Zeitfenster |
| `UPLOAD_RATE_LIMIT_WINDOW_MS` | `3600000` | Länge des separaten Upload-Zeitfensters |
| `MAX_STORAGE_BYTES` | `10737418240` | Globale Obergrenze des persistenten Speichers in Bytes |
| `TRUST_PROXY` | `false` | `true`, wenn der vertrauenswürdige Proxy `X-Forwarded-For` überschreibt |
## Start
```powershell
$env:BACKUP_DATA_DIR = 'C:\ProgramData\MultiDebridBackup'
$env:ALLOWED_ORIGINS = 'https://downloads.24-music.de'
$env:MAX_STORAGE_BYTES = '10737418240'
$env:TRUST_PROXY = 'true'
npm start
```
Der Dienst sollte nur hinter einem TLS-Reverse-Proxy öffentlich erreichbar sein. Bei `TRUST_PROXY=true` muss dieser den eingehenden `X-Forwarded-For`-Header vollständig ersetzen. Das Datenverzeichnis benötigt regelmäßige Dateisystem-Backups.
## HTTP-Vertrag
`POST /v1/backups` akzeptiert `id`, `blob` und `deleteVerifier`. `POST /v1/backups/restore` akzeptiert `id` und liefert ausschließlich `blob`. `POST /v1/backups/delete` akzeptiert `id` und `deleteSecret`. Fehlerhafte Löschgeheimnisse und unbekannte IDs sind nicht unterscheidbar. IDs erscheinen nie in URLs.
+50
View File
@@ -0,0 +1,50 @@
{
"name": "multi-debrid-backup-api",
"version": "2.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "multi-debrid-backup-api",
"version": "2.0.0",
"dependencies": {
"proper-lockfile": "4.1.2"
},
"engines": {
"node": ">=20"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/proper-lockfile": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
"integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
"retry": "^0.12.0",
"signal-exit": "^3.0.2"
}
},
"node_modules/retry": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
"integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC"
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "multi-debrid-backup-api",
"version": "2.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/cli.mjs",
"test": "node --test"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"proper-lockfile": "4.1.2"
}
}
+40
View File
@@ -0,0 +1,40 @@
import { resolve } from 'node:path'
import { createBackupServer } from './server.mjs'
const port = Number.parseInt(process.env.PORT ?? '8787', 10)
const host = process.env.HOST ?? '127.0.0.1'
const rootDir = resolve(process.env.BACKUP_DATA_DIR ?? './data')
const allowedOrigins = (process.env.ALLOWED_ORIGINS ?? '')
.split(',')
.map(origin => origin.trim())
.filter(Boolean)
const rateLimit = {
max: Number.parseInt(process.env.RATE_LIMIT_MAX ?? '60', 10),
windowMs: Number.parseInt(process.env.RATE_LIMIT_WINDOW_MS ?? '60000', 10)
}
const uploadRateLimit = {
max: Number.parseInt(process.env.UPLOAD_RATE_LIMIT_MAX ?? '10', 10),
windowMs: Number.parseInt(process.env.UPLOAD_RATE_LIMIT_WINDOW_MS ?? '3600000', 10)
}
const maxStorageBytes = Number.parseInt(process.env.MAX_STORAGE_BYTES ?? String(10 * 1024 * 1024 * 1024), 10)
const trustedProxy = process.env.TRUST_PROXY === 'true'
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) throw new Error('Invalid PORT')
const server = createBackupServer({ rootDir, allowedOrigins, rateLimit, uploadRateLimit, maxStorageBytes, trustedProxy })
server.listen(port, host, () => {
process.stdout.write(`Backup API listening on ${host}:${port}\n`)
})
function shutdown() {
server.close(error => {
if (error) {
process.stderr.write('Backup API shutdown failed\n')
process.exitCode = 1
}
})
}
process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown)
+18
View File
@@ -0,0 +1,18 @@
import type { Server } from "node:http";
export interface BackupServerOptions {
rootDir: string;
allowedOrigins?: string[];
rateLimit?: {
max: number;
windowMs: number;
};
uploadRateLimit?: {
max: number;
windowMs: number;
};
maxStorageBytes?: number;
trustedProxy?: boolean;
}
export function createBackupServer(options: BackupServerOptions): Server;
+463
View File
@@ -0,0 +1,463 @@
import { createHash, timingSafeEqual, randomBytes } from 'node:crypto'
import { createServer } from 'node:http'
import { link, mkdir, open, readFile, readdir, stat, unlink } from 'node:fs/promises'
import { isIP } from 'node:net'
import { join } from 'node:path'
import lockfile from 'proper-lockfile'
const maxBlobBytes = 256 * 1024
const maxBodyBytes = 384 * 1024
const idPattern = /^[A-Za-z0-9_-]{22}$/
const verifierPattern = /^[A-Za-z0-9_-]{43}$/
const blobPattern = /^[A-Za-z0-9_-]+$/
const notFoundBody = '{"error":"not_found"}'
function isCanonicalBase64Url(value, byteLength, pattern) {
if (typeof value !== 'string' || !pattern.test(value)) return false
const decoded = Buffer.from(value, 'base64url')
return decoded.length === byteLength && decoded.toString('base64url') === value
}
function isValidBackup(payload) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false
const keys = Object.keys(payload).sort()
if (keys.join(',') !== 'blob,deleteVerifier,id') return false
if (!isCanonicalBase64Url(payload.id, 16, idPattern)) return false
if (!isCanonicalBase64Url(payload.deleteVerifier, 32, verifierPattern)) return false
if (typeof payload.blob !== 'string' || !blobPattern.test(payload.blob)) return false
const decoded = Buffer.from(payload.blob, 'base64url')
return decoded.length <= maxBlobBytes && decoded.toString('base64url') === payload.blob
}
function createRateLimiter({ max, windowMs }) {
const clients = new Map()
let requestCount = 0
return address => {
const now = Date.now()
requestCount += 1
if (requestCount % 1024 === 0) {
for (const [key, value] of clients) {
if (now - value.startedAt >= windowMs) clients.delete(key)
}
}
const current = clients.get(address)
if (!current || now - current.startedAt >= windowMs) {
clients.set(address, { startedAt: now, count: 1 })
return null
}
if (current.count >= max) return Math.max(1, Math.ceil((windowMs - (now - current.startedAt)) / 1000))
current.count += 1
return null
}
}
function readJsonBody(request) {
const declaredLength = Number.parseInt(request.headers['content-length'] ?? '', 10)
if (Number.isFinite(declaredLength) && declaredLength > maxBodyBytes) {
request.resume()
return Promise.resolve({ error: 413 })
}
return new Promise((resolve, reject) => {
let size = 0
let settled = false
const chunks = []
const cleanup = () => {
request.off('data', onData)
request.off('end', onEnd)
request.off('aborted', onAborted)
request.off('error', onError)
}
const finish = result => {
if (settled) return
settled = true
cleanup()
resolve(result)
}
const onData = chunk => {
size += chunk.length
if (size > maxBodyBytes) {
finish({ error: 413 })
request.resume()
return
}
chunks.push(chunk)
}
const onEnd = () => {
try {
finish({ value: JSON.parse(Buffer.concat(chunks).toString('utf8')) })
} catch {
finish({ error: 400 })
}
}
const onAborted = () => reject(new Error('Request aborted'))
const onError = error => reject(error)
request.on('data', onData)
request.on('end', onEnd)
request.on('aborted', onAborted)
request.on('error', onError)
})
}
function recordPath(rootDir, id) {
return join(rootDir, `${id}.json`)
}
function createMutationQueue() {
let pending = Promise.resolve()
return operation => {
const result = pending.then(operation, operation)
pending = result.catch(() => {})
return result
}
}
async function directoryUsage(rootDir) {
let total = 0
const entries = await readdir(rootDir, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.json')) continue
try {
total += (await stat(join(rootDir, entry.name))).size
} catch (error) {
if (error.code !== 'ENOENT') throw error
}
}
return total
}
async function syncDirectory(rootDir) {
let handle
try {
handle = await open(rootDir, 'r')
await handle.sync()
} catch (error) {
if (!['EISDIR', 'EINVAL', 'ENOTSUP', 'EPERM', 'EBADF'].includes(error.code)) throw error
} finally {
await handle?.close().catch(() => {})
}
}
async function withStorageLock(rootDir, operation) {
await mkdir(rootDir, { recursive: true })
const release = await lockfile.lock(rootDir, {
realpath: false,
lockfilePath: join(rootDir, '.storage.lock'),
stale: 30_000,
update: 10_000,
retries: {
retries: 100,
factor: 1.1,
minTimeout: 10,
maxTimeout: 100,
randomize: true
}
})
try {
return await operation()
} finally {
let releaseError
try {
await release()
} catch (error) {
releaseError = error
}
try {
await syncDirectory(rootDir)
} catch (error) {
releaseError ??= error
}
if (releaseError) throw releaseError
}
}
async function recordExists(rootDir, id) {
try {
await stat(recordPath(rootDir, id))
return true
} catch (error) {
if (error.code === 'ENOENT') return false
throw error
}
}
async function createRecord(rootDir, payload, maxStorageBytes) {
await mkdir(rootDir, { recursive: true })
if (await recordExists(rootDir, payload.id)) return 'duplicate'
const contents = Buffer.from(JSON.stringify({
version: 1,
blob: payload.blob,
deleteVerifier: payload.deleteVerifier,
createdAt: new Date().toISOString()
}), 'utf8')
if (await directoryUsage(rootDir) + contents.length > maxStorageBytes) return 'full'
const temporaryPath = join(rootDir, `.${randomBytes(16).toString('hex')}.tmp`)
let handle
let temporaryCreated = false
let published = false
try {
handle = await open(temporaryPath, 'wx', 0o600)
temporaryCreated = true
try {
await handle.writeFile(contents)
await handle.sync()
} finally {
await handle.close()
handle = undefined
}
try {
await link(temporaryPath, recordPath(rootDir, payload.id))
} catch (error) {
if (error.code === 'EEXIST') return 'duplicate'
throw error
}
published = true
return 'created'
} finally {
let cleanupError
try {
await handle?.close()
} catch (error) {
cleanupError = error
}
if (temporaryCreated) {
try {
await unlink(temporaryPath)
} catch (error) {
cleanupError ??= error
}
}
if (published) {
try {
await syncDirectory(rootDir)
} catch (error) {
cleanupError ??= error
}
}
if (cleanupError) throw cleanupError
}
}
async function readRecord(rootDir, id) {
try {
const raw = await readFile(recordPath(rootDir, id), 'utf8')
const record = JSON.parse(raw)
if (record?.version !== 1 || typeof record.blob !== 'string' || !isCanonicalBase64Url(record.deleteVerifier, 32, verifierPattern)) {
throw new Error('Invalid stored record')
}
return record
} catch (error) {
if (error.code === 'ENOENT') return null
throw error
}
}
function securityHeaders(response) {
response.setHeader('cache-control', 'no-store')
response.setHeader('x-content-type-options', 'nosniff')
response.setHeader('content-security-policy', "default-src 'none'")
response.setHeader('referrer-policy', 'no-referrer')
}
function sendJson(response, status, body) {
response.statusCode = status
response.setHeader('content-type', 'application/json; charset=utf-8')
response.end(JSON.stringify(body))
}
function sendNotFound(response) {
response.statusCode = 404
response.setHeader('content-type', 'application/json; charset=utf-8')
response.end(notFoundBody)
}
function authorizeOrigin(request, response, allowedOrigins) {
const origin = request.headers.origin
if (!origin) return true
if (!allowedOrigins.has(origin)) {
sendJson(response, 403, { error: 'origin_denied' })
return false
}
response.setHeader('access-control-allow-origin', origin)
response.setHeader('vary', 'Origin')
return true
}
function verifierMatches(secret, expectedVerifier) {
const actual = createHash('sha256').update(Buffer.from(secret, 'base64url')).digest()
const expected = Buffer.from(expectedVerifier, 'base64url')
return actual.length === expected.length && timingSafeEqual(actual, expected)
}
async function storageIsReady(rootDir) {
const probePath = join(rootDir, `.${randomBytes(16).toString('hex')}.health`)
try {
await mkdir(rootDir, { recursive: true })
const handle = await open(probePath, 'wx', 0o600)
await handle.close()
await unlink(probePath)
return true
} catch {
await unlink(probePath).catch(() => {})
return false
}
}
function clientAddress(request, trustedProxy) {
if (trustedProxy) {
const forwarded = request.headers['x-forwarded-for']
const candidate = Array.isArray(forwarded) ? forwarded[0] : forwarded?.split(',', 1)[0].trim()
if (candidate && isIP(candidate)) return candidate
}
return request.socket.remoteAddress ?? 'unknown'
}
export function createBackupServer(options) {
if (!options?.rootDir) throw new Error('rootDir is required')
const allowedOrigins = new Set(options.allowedOrigins ?? [])
const rateLimit = options.rateLimit ?? { max: 60, windowMs: 60_000 }
const uploadRateLimit = options.uploadRateLimit ?? { max: 10, windowMs: 3_600_000 }
const maxStorageBytes = options.maxStorageBytes ?? 10 * 1024 * 1024 * 1024
if (!Number.isSafeInteger(rateLimit.max) || rateLimit.max < 1 || !Number.isSafeInteger(rateLimit.windowMs) || rateLimit.windowMs < 1) {
throw new Error('Invalid rate limit')
}
if (!Number.isSafeInteger(uploadRateLimit.max) || uploadRateLimit.max < 1 || !Number.isSafeInteger(uploadRateLimit.windowMs) || uploadRateLimit.windowMs < 1) {
throw new Error('Invalid upload rate limit')
}
if (!Number.isSafeInteger(maxStorageBytes) || maxStorageBytes < 1) throw new Error('Invalid max storage size')
const consumeRateLimit = createRateLimiter(rateLimit)
const consumeUploadRateLimit = createRateLimiter(uploadRateLimit)
const runStorageMutation = createMutationQueue()
return createServer(async (request, response) => {
securityHeaders(response)
try {
const url = new URL(request.url, 'http://localhost')
if (!authorizeOrigin(request, response, allowedOrigins)) return
if (url.search) {
sendNotFound(response)
return
}
if (request.method === 'OPTIONS') {
const requestedMethod = request.headers['access-control-request-method']
if (!request.headers.origin || requestedMethod !== 'POST') {
sendJson(response, 400, { error: 'invalid_preflight' })
return
}
response.statusCode = 204
response.setHeader('access-control-allow-methods', 'POST, OPTIONS')
response.setHeader('access-control-allow-headers', 'content-type')
response.setHeader('access-control-max-age', '600')
response.end()
return
}
if (request.method === 'GET' && url.pathname === '/health') {
const ready = await storageIsReady(options.rootDir)
sendJson(response, ready ? 200 : 503, { status: ready ? 'ok' : 'unavailable' })
return
}
if (url.pathname === '/v1/backups/restore' || url.pathname === '/v1/backups/delete') {
const retryAfter = consumeRateLimit(clientAddress(request, options.trustedProxy === true))
if (retryAfter !== null) {
response.setHeader('retry-after', String(retryAfter))
sendJson(response, 429, { error: 'rate_limited' })
return
}
}
if (request.method === 'POST' && ['/v1/backups', '/v1/backups/restore', '/v1/backups/delete'].includes(url.pathname)) {
if (request.headers['content-type']?.split(';', 1)[0].trim().toLowerCase() !== 'application/json') {
sendJson(response, 415, { error: 'unsupported_media_type' })
return
}
const parsed = await readJsonBody(request)
if (parsed.error) {
if (parsed.error === 413) response.setHeader('connection', 'close')
sendJson(response, parsed.error, { error: parsed.error === 413 ? 'payload_too_large' : 'invalid_request' })
return
}
if (url.pathname === '/v1/backups/restore') {
const keys = parsed.value && typeof parsed.value === 'object' && !Array.isArray(parsed.value)
? Object.keys(parsed.value)
: []
if (keys.length !== 1 || keys[0] !== 'id' || !isCanonicalBase64Url(parsed.value.id, 16, idPattern)) {
sendNotFound(response)
return
}
const record = await readRecord(options.rootDir, parsed.value.id)
if (!record) {
sendNotFound(response)
return
}
sendJson(response, 200, { blob: record.blob })
return
}
if (url.pathname === '/v1/backups/delete') {
const keys = parsed.value && typeof parsed.value === 'object' && !Array.isArray(parsed.value)
? Object.keys(parsed.value).sort()
: []
const valid = keys.join(',') === 'deleteSecret,id'
&& isCanonicalBase64Url(parsed.value.id, 16, idPattern)
&& isCanonicalBase64Url(parsed.value.deleteSecret, 32, verifierPattern)
if (!valid) {
sendNotFound(response)
return
}
const deleted = await runStorageMutation(() => withStorageLock(options.rootDir, async () => {
const record = await readRecord(options.rootDir, parsed.value.id)
if (!record || !verifierMatches(parsed.value.deleteSecret, record.deleteVerifier)) return false
try {
await unlink(recordPath(options.rootDir, parsed.value.id))
return true
} catch (error) {
if (error.code === 'ENOENT') return false
throw error
}
}))
if (!deleted) {
sendNotFound(response)
return
}
response.statusCode = 204
response.end()
return
}
if (typeof parsed.value?.blob === 'string' && blobPattern.test(parsed.value.blob) && Buffer.from(parsed.value.blob, 'base64url').length > maxBlobBytes) {
sendJson(response, 413, { error: 'payload_too_large' })
return
}
if (!isValidBackup(parsed.value)) {
sendJson(response, 400, { error: 'invalid_request' })
return
}
const uploadRetryAfter = consumeUploadRateLimit(clientAddress(request, options.trustedProxy === true))
if (uploadRetryAfter !== null) {
response.setHeader('retry-after', String(uploadRetryAfter))
sendJson(response, 429, { error: 'rate_limited' })
return
}
const result = await runStorageMutation(() => withStorageLock(
options.rootDir,
() => createRecord(options.rootDir, parsed.value, maxStorageBytes)
))
if (result === 'duplicate') {
sendJson(response, 409, { error: 'already_exists' })
return
}
if (result === 'full') {
sendJson(response, 507, { error: 'insufficient_storage' })
return
}
sendJson(response, 201, { created: true })
return
}
sendNotFound(response)
} catch {
if (!response.headersSent) sendJson(response, 500, { error: 'internal_error' })
else response.destroy()
}
})
}
+511
View File
@@ -0,0 +1,511 @@
import assert from 'node:assert/strict'
import { createHash, randomBytes } from 'node:crypto'
import { mkdir, mkdtemp, readFile, readdir, rm, utimes, writeFile } from 'node:fs/promises'
import { request as createHttpRequest } from 'node:http'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import test from 'node:test'
import lockfile from 'proper-lockfile'
import { createBackupServer } from '../src/server.mjs'
const allowedOrigin = 'https://downloads.24-music.de'
function backupFixture() {
const deleteSecret = randomBytes(32).toString('base64url')
return {
deleteSecret,
payload: {
id: randomBytes(16).toString('base64url'),
blob: randomBytes(96).toString('base64url'),
deleteVerifier: createHash('sha256').update(Buffer.from(deleteSecret, 'base64url')).digest('base64url')
}
}
}
async function startApi(options = {}) {
const rootDir = await mkdtemp(join(tmpdir(), 'mdd-backup-api-'))
const server = createBackupServer({
rootDir,
allowedOrigins: [allowedOrigin],
rateLimit: { max: 100, windowMs: 60_000 },
...options
})
await new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
const address = server.address()
return {
rootDir,
server,
baseUrl: `http://127.0.0.1:${address.port}`,
async close() {
await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
await rm(rootDir, { recursive: true, force: true })
}
}
}
async function request(api, path, options = {}) {
return fetch(`${api.baseUrl}${path}`, options)
}
test('health endpoint reports readiness without exposing storage details', async t => {
const api = await startApi()
t.after(() => api.close())
const response = await request(api, '/health')
assert.equal(response.status, 200)
assert.deepEqual(await response.json(), { status: 'ok' })
assert.equal(response.headers.get('cache-control'), 'no-store')
})
test('health endpoint rejects an unusable storage path', async t => {
const container = await mkdtemp(join(tmpdir(), 'mdd-backup-health-'))
const rootDir = join(container, 'not-a-directory')
await writeFile(rootDir, 'occupied')
const server = createBackupServer({ rootDir, allowedOrigins: [allowedOrigin] })
await new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
const baseUrl = `http://127.0.0.1:${server.address().port}`
t.after(async () => {
await new Promise(resolve => server.close(resolve))
await rm(container, { recursive: true, force: true })
})
const response = await fetch(`${baseUrl}/health`)
assert.equal(response.status, 503)
assert.deepEqual(await response.json(), { status: 'unavailable' })
})
test('creates and retrieves an immutable opaque backup across server restarts', async t => {
const api = await startApi()
const fixture = backupFixture()
t.after(async () => {
if (api.server.listening) await new Promise(resolve => api.server.close(resolve))
await rm(api.rootDir, { recursive: true, force: true })
})
const created = await request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json', origin: allowedOrigin },
body: JSON.stringify(fixture.payload)
})
assert.equal(created.status, 201)
assert.deepEqual(await created.json(), { created: true })
await new Promise(resolve => api.server.close(resolve))
api.server = createBackupServer({ rootDir: api.rootDir, allowedOrigins: [allowedOrigin] })
await new Promise((resolve, reject) => {
api.server.once('error', reject)
api.server.listen(0, '127.0.0.1', resolve)
})
api.baseUrl = `http://127.0.0.1:${api.server.address().port}`
const retrieved = await request(api, '/v1/backups/restore', {
method: 'POST',
headers: { 'content-type': 'application/json', origin: allowedOrigin },
body: JSON.stringify({ id: fixture.payload.id })
})
assert.equal(retrieved.status, 200)
assert.deepEqual(await retrieved.json(), { blob: fixture.payload.blob })
const duplicate = await request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json', origin: allowedOrigin },
body: JSON.stringify({ ...fixture.payload, blob: randomBytes(96).toString('base64url') })
})
assert.equal(duplicate.status, 409)
const unchanged = await request(api, '/v1/backups/restore', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: fixture.payload.id })
})
assert.deepEqual(await unchanged.json(), { blob: fixture.payload.blob })
})
test('validates IDs, verifiers, content type, JSON and opaque blob encoding', async t => {
const api = await startApi()
t.after(() => api.close())
const fixture = backupFixture()
const invalidPayloads = [
{ ...fixture.payload, id: 'short' },
{ ...fixture.payload, blob: 'not+base64url' },
{ ...fixture.payload, deleteVerifier: 'short' },
{ id: fixture.payload.id, blob: fixture.payload.blob },
{ ...fixture.payload, extra: true }
]
for (const payload of invalidPayloads) {
const response = await request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload)
})
assert.equal(response.status, 400)
}
const malformed = await request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{'
})
assert.equal(malformed.status, 400)
const wrongType = await request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'text/plain' },
body: JSON.stringify(fixture.payload)
})
assert.equal(wrongType.status, 415)
})
test('accepts a 256 KiB decoded blob and rejects one byte more', async t => {
const api = await startApi()
t.after(() => api.close())
const fixture = backupFixture()
const accepted = await request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ ...fixture.payload, blob: randomBytes(262_144).toString('base64url') })
})
const oversizedFixture = backupFixture()
const rejected = await request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ ...oversizedFixture.payload, blob: randomBytes(262_145).toString('base64url') })
})
assert.equal(accepted.status, 201)
assert.equal(rejected.status, 413)
})
test('responds before an oversized request body finishes streaming', async t => {
const api = await startApi()
t.after(() => api.close())
const url = new URL('/v1/backups', api.baseUrl)
const client = createHttpRequest(url, {
method: 'POST',
headers: { 'content-type': 'application/json' }
})
t.after(() => client.destroy())
const responsePromise = new Promise((resolve, reject) => {
client.once('response', resolve)
client.once('error', reject)
})
client.write('A'.repeat(393_217))
const response = await Promise.race([
responsePromise,
new Promise((_, reject) => setTimeout(() => reject(new Error('Server did not reject streaming body')), 1_000))
])
assert.equal(response.statusCode, 413)
response.resume()
})
test('deletes only with the matching client secret and uses constant not-found responses', async t => {
const api = await startApi()
t.after(() => api.close())
const fixture = backupFixture()
await request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(fixture.payload)
})
const missing = await request(api, '/v1/backups/restore', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: randomBytes(16).toString('base64url') })
})
const unauthorized = await request(api, '/v1/backups/delete', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: fixture.payload.id })
})
const wrongSecret = await request(api, '/v1/backups/delete', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: fixture.payload.id, deleteSecret: randomBytes(32).toString('base64url') })
})
assert.equal(missing.status, 404)
assert.equal(unauthorized.status, 404)
assert.equal(wrongSecret.status, 404)
const missingBody = await missing.text()
const unauthorizedBody = await unauthorized.text()
const wrongSecretBody = await wrongSecret.text()
assert.equal(missingBody, unauthorizedBody)
assert.equal(unauthorizedBody, wrongSecretBody)
assert.equal(wrongSecretBody, '{"error":"not_found"}')
const deleted = await request(api, '/v1/backups/delete', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: fixture.payload.id, deleteSecret: fixture.deleteSecret })
})
assert.equal(deleted.status, 204)
assert.equal((await readdir(api.rootDir)).length, 0)
const afterDelete = await request(api, '/v1/backups/restore', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: fixture.payload.id })
})
assert.equal(afterDelete.status, 404)
})
test('allows only configured browser origins and supports preflight', async t => {
const api = await startApi()
t.after(() => api.close())
const allowed = await request(api, '/health', { headers: { origin: allowedOrigin } })
assert.equal(allowed.headers.get('access-control-allow-origin'), allowedOrigin)
assert.equal(allowed.headers.get('vary'), 'Origin')
const denied = await request(api, '/health', { headers: { origin: 'https://attacker.example' } })
assert.equal(denied.status, 403)
assert.equal(denied.headers.get('access-control-allow-origin'), null)
const preflight = await request(api, '/v1/backups', {
method: 'OPTIONS',
headers: {
origin: allowedOrigin,
'access-control-request-method': 'POST',
'access-control-request-headers': 'content-type'
}
})
assert.equal(preflight.status, 204)
assert.equal(preflight.headers.get('access-control-allow-origin'), allowedOrigin)
assert.match(preflight.headers.get('access-control-allow-methods'), /POST/)
})
test('rate limits backup routes without limiting health checks', async t => {
const api = await startApi({ trustedProxy: true, rateLimit: { max: 2, windowMs: 60_000 } })
t.after(() => api.close())
const restore = (id, address) => request(api, '/v1/backups/restore', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-forwarded-for': address },
body: JSON.stringify({ id })
})
const first = await restore(randomBytes(16).toString('base64url'), '198.51.100.1')
const independent = await restore(randomBytes(16).toString('base64url'), '198.51.100.2')
const second = await restore(randomBytes(16).toString('base64url'), '198.51.100.1')
const limited = await restore(randomBytes(16).toString('base64url'), '198.51.100.1')
const health = await request(api, '/health')
assert.equal(first.status, 404)
assert.equal(independent.status, 404)
assert.equal(second.status, 404)
assert.equal(limited.status, 429)
assert.equal(limited.headers.get('retry-after'), '60')
assert.equal(health.status, 200)
})
test('enforces an atomic global storage capacity without blocking existing restores', async t => {
const api = await startApi({ maxStorageBytes: 420 })
const secondServer = createBackupServer({ rootDir: api.rootDir, allowedOrigins: [allowedOrigin], maxStorageBytes: 420 })
await new Promise((resolve, reject) => {
secondServer.once('error', reject)
secondServer.listen(0, '127.0.0.1', resolve)
})
const secondBaseUrl = `http://127.0.0.1:${secondServer.address().port}`
t.after(() => api.close())
t.after(() => new Promise(resolve => secondServer.close(resolve)))
const first = backupFixture()
const second = backupFixture()
const create = (fixture, baseUrl = api.baseUrl) => fetch(`${baseUrl}/v1/backups`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(fixture.payload)
})
const results = await Promise.all([create(first), create(second, secondBaseUrl)])
assert.deepEqual(results.map(response => response.status).sort(), [201, 507])
const stored = results[0].status === 201 ? first : second
const restored = await request(api, '/v1/backups/restore', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: stored.payload.id })
})
assert.equal(restored.status, 200)
const storedFiles = await readdir(api.rootDir)
assert.equal(storedFiles.length, 1)
assert.match(storedFiles[0], /^[A-Za-z0-9_-]{22}\.json$/)
const deleted = await request(api, '/v1/backups/delete', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: stored.payload.id, deleteSecret: stored.deleteSecret })
})
assert.equal(deleted.status, 204)
const blocked = stored === first ? second : first
assert.equal((await create(blocked)).status, 201)
})
test('limits anonymous uploads separately while keeping restores available', async t => {
const api = await startApi({
rateLimit: { max: 2, windowMs: 60_000 },
uploadRateLimit: { max: 1, windowMs: 60_000 }
})
t.after(() => api.close())
const first = backupFixture()
const second = backupFixture()
const create = fixture => request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(fixture.payload)
})
assert.equal((await create(first)).status, 201)
assert.equal((await create(second)).status, 429)
const restored = await request(api, '/v1/backups/restore', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: first.payload.id })
})
assert.equal(restored.status, 200)
})
test('never takes over an old storage lock that may still have an active owner', async t => {
const api = await startApi()
t.after(() => api.close())
const lockPath = join(api.rootDir, '.storage.lock')
const release = await lockfile.lock(api.rootDir, {
realpath: false,
lockfilePath: lockPath,
stale: 30_000,
update: 10_000
})
t.after(() => release().catch(() => {}))
const fixture = backupFixture()
const pending = request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(fixture.payload)
})
const early = await Promise.race([
pending.then(() => 'responded'),
new Promise(resolve => setTimeout(() => resolve('waiting'), 100))
])
assert.equal(early, 'waiting')
await release()
assert.equal((await pending).status, 201)
})
test('recovers a storage lock left by a terminated process', async t => {
const api = await startApi()
t.after(() => api.close())
const lockPath = join(api.rootDir, '.storage.lock')
await mkdir(lockPath)
const old = new Date(Date.now() - 60_000)
await utimes(lockPath, old, old)
const fixture = backupFixture()
const pending = request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(fixture.payload)
})
const early = await Promise.race([
pending.then(response => response.status),
new Promise(resolve => setTimeout(() => resolve('timeout'), 500))
])
await pending
assert.equal(early, 201)
assert.equal((await readdir(api.rootDir)).some(name => name.includes('.stale.')), false)
})
test('revalidates delete authorization inside the storage lock', async t => {
const api = await startApi()
t.after(() => api.close())
const original = backupFixture()
const replacement = backupFixture()
replacement.payload.id = original.payload.id
await request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(original.payload)
})
const lockPath = join(api.rootDir, '.storage.lock')
const release = await lockfile.lock(api.rootDir, {
realpath: false,
lockfilePath: lockPath,
stale: 30_000,
update: 10_000
})
t.after(() => release().catch(() => {}))
const pendingDelete = request(api, '/v1/backups/delete', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: original.payload.id, deleteSecret: original.deleteSecret })
})
await new Promise(resolve => setTimeout(resolve, 50))
await writeFile(join(api.rootDir, `${original.payload.id}.json`), JSON.stringify({
version: 1,
blob: replacement.payload.blob,
deleteVerifier: replacement.payload.deleteVerifier,
createdAt: new Date().toISOString()
}))
await release()
assert.equal((await pendingDelete).status, 404)
const restored = await request(api, '/v1/backups/restore', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: original.payload.id })
})
assert.equal(restored.status, 200)
assert.deepEqual(await restored.json(), { blob: replacement.payload.blob })
})
test('never accepts backup IDs in URLs', async t => {
const api = await startApi()
t.after(() => api.close())
const id = randomBytes(16).toString('base64url')
const read = await request(api, `/v1/backups/${id}`)
const remove = await request(api, `/v1/backups/${id}`, { method: 'DELETE' })
const query = await request(api, `/v1/backups?backup=${id}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(backupFixture().payload)
})
assert.equal(read.status, 404)
assert.equal(remove.status, 404)
assert.equal(query.status, 404)
})
test('stored records contain no delete secret and operational logs contain no IDs or blobs', async t => {
const messages = []
const api = await startApi({ logger: message => messages.push(String(message)) })
t.after(() => api.close())
const fixture = backupFixture()
await request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(fixture.payload)
})
const files = await readdir(api.rootDir)
assert.equal(files.length, 1)
const stored = await readFile(join(api.rootDir, files[0]), 'utf8')
assert.equal(stored.includes(fixture.deleteSecret), false)
assert.equal(messages.some(message => message.includes(fixture.payload.id)), false)
assert.equal(messages.some(message => message.includes(fixture.payload.blob)), false)
})