Merge fix/backup-timeout

# Conflicts:
#	CHANGELOG.md
#	tests/integration/backup-round-trip.test.ts
This commit is contained in:
Marc-André Bombeck committed 2026-09-03 03:23:41 +02:00
commit b2a8edf65f
34 files changed
+1046 -62

No files matched your search

@@ -19,7 +19,7 @@ import { prisma } from "@/lib/db";
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
import { apiError, getClientIp } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { decrypt } from "@/lib/crypto";
import { unpackBackupBlob } from "@/lib/export/backup-blob";
import { annotate } from "@/lib/logging/context";
import { parseBackupPayload } from "@/lib/validations/backup";
@@ -52,7 +52,7 @@ export const GET = apiHandler(
let plaintext: string;
try {
plaintext = decrypt(backup.data);
plaintext = unpackBackupBlob(backup.data);
} catch (err) {
// Decryption failure is rare but real — a rotated/missing key, or a
// corrupted blob from a partially-failed write. Surface it as a 500
@@ -19,7 +19,7 @@ import { prisma, toJson } from "@/lib/db";
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { decrypt } from "@/lib/crypto";
import { unpackBackupBlob } from "@/lib/export/backup-blob";
import { encryptNote } from "@/lib/crypto/note-cipher";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { encryptContextToBytes } from "@/lib/labs/biomarker-store";
@@ -180,7 +180,7 @@ const handler = apiHandler(
let plaintext: string;
try {
plaintext = decrypt(backup.data);
plaintext = unpackBackupBlob(backup.data);
} catch (err) {
await auditLog("admin.backups.restore.failed", {
userId: admin.id,
@@ -16,7 +16,7 @@ import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { decrypt } from "@/lib/crypto";
import { unpackBackupBlob } from "@/lib/export/backup-blob";
import { annotate } from "@/lib/logging/context";
import {
isCompatibleSchemaVersion,
@@ -44,7 +44,7 @@ export const GET = apiHandler(
let plaintext: string;
try {
plaintext = decrypt(backup.data);
plaintext = unpackBackupBlob(backup.data);
} catch {
return apiError("Failed to decrypt backup payload", 500);
}
+49 -14
View File
@@ -5,11 +5,20 @@
* shipped — only metadata (id, userId, username, type, size in bytes,
* createdAt). The encrypted blob remains server-side; admins can trigger
* a re-snapshot but not download another user's payload from the UI.
*
* Alongside the rows, the response says whether the SCHEDULE is still alive:
* how old the newest scheduled copy is and how the last scheduled run ended.
* Without those two the page cannot tell a working backup from one that
* stopped six weeks ago — every row it lists has a perfectly ordinary
* timestamp either way.
*/
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { DATA_BACKUP_QUEUE } from "@/lib/jobs/data-backup-policy";
import { readLastQueueRun } from "@/lib/jobs/job-failures";
import { summariseBackupSchedule } from "@/lib/jobs/backup-schedule-status";
// v1.4.41 W-ORG — `BackupRow` / `BackupsList` moved to `src/types/backups.ts`
// so callers (in particular `components/admin/backups-section.tsx`) don't
// have to reach across the component → route-handler layer boundary.
@@ -17,31 +26,57 @@ import type { BackupRow, BackupsList } from "@/types/backups";
export const dynamic = "force-dynamic";
/** The type the weekly worker writes; anything else was uploaded by hand. */
const SCHEDULED_BACKUP_TYPE = "WEEKLY_AUTO";
interface BackupMetaRow {
id: string;
user_id: string;
username: string;
type: string;
size_bytes: number;
created_at: Date;
}
export const GET = apiHandler(async () => {
await requireAdmin();
annotate({ action: { name: "admin.backups.list" } });
// Pull metadata only — `data` would balloon the response and reveal
// ciphertext we don't need on the listing page.
const backups = await prisma.dataBackup.findMany({
include: { user: { select: { id: true, username: true } } },
orderBy: { createdAt: "desc" },
});
// Metadata only, and the size read as `octet_length` in the database rather
// than by pulling every blob into this process to measure it. The listing
// needs a number, not the ciphertext, and on an instance with large records
// fetching them all was its own way of running out of memory. Base64 is
// ASCII, so the column's byte length is exactly what the wire reports.
const rows = await prisma.$queryRaw<BackupMetaRow[]>`
SELECT b.id, b.user_id, u.username, b.type,
octet_length(b.data) AS size_bytes,
b.created_at
FROM data_backups b
JOIN users u ON u.id = b.user_id
ORDER BY b.created_at DESC
`;
const rows: BackupRow[] = backups.map((b) => ({
id: b.id,
userId: b.userId,
username: b.user.username,
type: b.type,
sizeBytes: Buffer.byteLength(b.data, "utf8"),
createdAt: b.createdAt.toISOString(),
const list: BackupRow[] = rows.map((row) => ({
id: row.id,
userId: row.user_id,
username: row.username,
type: row.type,
sizeBytes: Number(row.size_bytes),
createdAt: row.created_at.toISOString(),
}));
const payload: BackupsList = {
rows,
rows: list,
// Matches the retention window the backup-prune job enforces so the
// backups page states the same number the worker acts on.
retentionDays: 90,
schedule: summariseBackupSchedule({
scheduledCreatedAt: rows
.filter((row) => row.type === SCHEDULED_BACKUP_TYPE)
.map((row) => row.created_at),
lastRun: await readLastQueueRun(DATA_BACKUP_QUEUE),
now: new Date(),
}),
};
return apiSuccess(payload);
+12 -5
View File
@@ -15,11 +15,13 @@ import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { checkRateLimit } from "@/lib/rate-limit";
import {
DATA_BACKUP_QUEUE,
DATA_BACKUP_SEND_OPTIONS,
} from "@/lib/jobs/data-backup-policy";
export const dynamic = "force-dynamic";
const DATA_BACKUP_QUEUE = "data-backup";
export const POST = apiHandler(async (request: NextRequest) => {
const { user: admin } = await requireAdmin();
annotate({ action: { name: "admin.backups.run" } });
@@ -57,9 +59,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
throw new HttpError(503, "Background worker is not running");
}
const jobId = await boss.send(DATA_BACKUP_QUEUE, {
triggeredAt: new Date().toISOString(),
});
// Same expiration window as the weekly cron. An ad-hoc snapshot runs the
// identical pass over the identical record, so it must not silently keep the
// 15-minute queue default the cron was rescued from.
const jobId = await boss.send(
DATA_BACKUP_QUEUE,
{ triggeredAt: new Date().toISOString() },
DATA_BACKUP_SEND_OPTIONS,
);
annotate({ meta: { job_id: jobId ?? null } });
+2 -2
View File
@@ -26,7 +26,7 @@ import { prisma } from "@/lib/db";
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { encrypt } from "@/lib/crypto";
import { packBackupBlob } from "@/lib/export/backup-blob";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import {
@@ -190,7 +190,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
// intact so multiple uploads can coexist for the same user without
// overwriting each other or the rolling WEEKLY_AUTO snapshot.
const uploadType = `MANUAL_UPLOAD_${Date.now()}`;
const encrypted = encrypt(JSON.stringify(payload));
const encrypted = packBackupBlob(JSON.stringify(payload));
const created = await prisma.dataBackup.create({
data: {