Merge branch 'fix/large-account-jobs' into release/v1.39.1

This commit is contained in:
Marc-André Bombeck committed 2026-09-25 06:49:16 +02:00
commit d81518b5bf
86 files changed
+5867 -898

No files matched your search

@@ -14,6 +14,8 @@
* at-rest contract: the ciphertext stays in the DB, the plaintext is only
* materialised inside the request handler and streamed to the admin.
*/
import { Readable } from "node:stream";
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
@@ -22,8 +24,13 @@ import { auditLog } from "@/lib/auth/audit";
import {
BACKUP_UNDECRYPTABLE_CODE,
BACKUP_UNDECRYPTABLE_ERROR,
unpackBackupBlob,
openBackupBlob,
} from "@/lib/export/backup-blob";
import {
readStreamedBackup,
type BackupSource,
type StreamedBackup,
} from "@/lib/export/streamed-backup";
import { annotate } from "@/lib/logging/context";
import { parseBackupPayload } from "@/lib/validations/backup";
@@ -54,9 +61,11 @@ export const GET = apiHandler(
throw new HttpError(404, "Backup not found");
}
let plaintext: string;
// Opened and streamed, never unpacked into one string: a large record's
// JSON is longer than any string V8 can hold (#1031).
let source: BackupSource;
try {
plaintext = unpackBackupBlob(backup.data);
source = openBackupBlob(backup.data);
} catch (err) {
// A rotated or dropped key, or a stored copy that is no longer the one
// that was written. Both are bad stored input rather than a fault in
@@ -80,8 +89,10 @@ export const GET = apiHandler(
// pathological case where the worker wrote something the upload
// route would later reject — better to fail loudly here than to ship
// a junk file that breaks an admin's restore plan.
let streamed: StreamedBackup;
try {
parseBackupPayload(plaintext);
streamed = await readStreamedBackup(source);
parseBackupPayload(streamed.raw);
} catch (err) {
await auditLog("admin.backups.download.failed", {
userId: admin.id,
@@ -107,22 +118,26 @@ export const GET = apiHandler(
ownerId: backup.userId,
ownerUsername: backup.user.username,
type: backup.type,
sizeBytes: Buffer.byteLength(plaintext, "utf8"),
sizeBytes: streamed.bytes,
},
});
// Returning the plaintext as-is keeps the response a faithful copy of
// Returning the stored bytes as-is keeps the response a faithful copy of
// what the worker wrote (formatting, key order). The file is the
// canonical artefact admins will store / re-upload.
return new NextResponse(plaintext, {
status: 200,
headers: {
"content-type": "application/json; charset=utf-8",
"content-disposition": `attachment; filename="${filename}"`,
// Backups contain sensitive health data — keep them out of any
// shared cache (CDN, browser disk cache).
"cache-control": "no-store, max-age=0",
return new NextResponse(
Readable.toWeb(Readable.from(source())) as ReadableStream<Uint8Array>,
{
status: 200,
headers: {
"content-type": "application/json; charset=utf-8",
"content-disposition": `attachment; filename="${filename}"`,
// Backups contain sensitive health data — keep them out of any
// shared cache (CDN, browser disk cache).
"cache-control": "no-store, max-age=0",
"content-length": String(streamed.bytes),
},
},
});
);
},
);
@@ -47,6 +47,9 @@ vi.mock("@/lib/validations/backup", () => ({
parseBackupPayload: vi.fn(),
isCompatibleSchemaVersion: vi.fn(() => true),
summarizeBackup: vi.fn(() => ({})),
// The streamed reader checks measurements one at a time; this file has none.
BACKUP_SCHEMA_VERSION: "2",
backupMeasurementSchema: { safeParse: vi.fn() },
}));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
+117 -57
View File
@@ -26,8 +26,17 @@ import { auditLog } from "@/lib/auth/audit";
import {
BACKUP_UNDECRYPTABLE_CODE,
BACKUP_UNDECRYPTABLE_ERROR,
unpackBackupBlob,
openBackupBlob,
} from "@/lib/export/backup-blob";
import {
insertMeasurementRows,
type MeasurementInsertRow,
} from "@/lib/export/measurement-bulk-insert";
import {
readStreamedBackup,
type BackupSource,
type StreamedBackup,
} from "@/lib/export/streamed-backup";
import { encryptNote } from "@/lib/crypto/note-cipher";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { encryptContextToBytes } from "@/lib/labs/biomarker-store";
@@ -38,6 +47,7 @@ import {
parseBackupPayload,
isCompatibleSchemaVersion,
summarizeBackup,
type BackupMeasurement,
type BackupSummary,
} from "@/lib/validations/backup";
import { recomputeUserMoodRollups } from "@/lib/rollups/mood-rollups";
@@ -137,6 +147,19 @@ interface RestoreResponse {
};
}
/**
* How long the restore transaction may run. Two minutes was the whole budget
* when a record was a few hundred thousand rows; deleting and rewriting 1.25
* million measurements in one transaction takes longer than that (#1031). On
* the test host the whole restore of that account, reading the file twice
* included, took 94 s. The budget grows by a second per 5 000 measurements,
* which gives that account six minutes, about four times what it needed, and
* never drops below the old two minutes.
*/
function restoreTransactionTimeoutMs(measurementCount: number): number {
return 120_000 + Math.ceil(measurementCount / 5);
}
function decodeEncryptedBytes(encoded: string): Uint8Array<ArrayBuffer> {
const decoded = Buffer.from(encoded, "base64");
const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength));
@@ -198,9 +221,12 @@ const handler = apiHandler(
throw new HttpError(404, "Backup not found");
}
let plaintext: string;
// Opened, not unpacked: the JSON of a large record is longer than any
// string V8 can hold (#1031), so it is read as a stream below. Opening
// authenticates the whole ciphertext first, as unpacking did.
let source: BackupSource;
try {
plaintext = unpackBackupBlob(backup.data);
source = openBackupBlob(backup.data);
} catch (err) {
await auditLog("admin.backups.restore.failed", {
userId: admin.id,
@@ -224,10 +250,16 @@ const handler = apiHandler(
// Parsed once and kept, because the schema's per-section `.default([])`
// erases the difference between a section that is absent and one that is
// empty — and that difference is what the completeness check below reads.
//
// The measurements are the exception: `readStreamedBackup` checks each one
// against the element schema as it goes and keeps only their count, and
// the transaction reads them a second time, in batches, as it writes them.
let raw: unknown;
let payload;
let streamed: StreamedBackup;
try {
raw = JSON.parse(plaintext);
streamed = await readStreamedBackup(source);
raw = streamed.raw;
payload = parseBackupPayload(raw);
} catch (err) {
await auditLog("admin.backups.restore.failed", {
@@ -548,7 +580,7 @@ const handler = apiHandler(
}
const toRestoredMeasurementData = (
measurement: (typeof payload.measurements)[number],
measurement: BackupMeasurement,
) => ({
type: measurement.type,
value: measurement.value,
@@ -600,55 +632,80 @@ const handler = apiHandler(
: {}),
});
const stableRows = payload.measurements.flatMap((measurement) =>
measurement.id
? [
{
id: measurement.id,
userId: ownerId,
...toRestoredMeasurementData(measurement),
},
]
: [],
);
const measurementBatchSize = 1_000;
for (
let offset = 0;
offset < stableRows.length;
offset += measurementBatchSize
) {
await tx.measurement.createMany({
data: stableRows.slice(offset, offset + measurementBatchSize),
});
}
// v1 payloads did not require stable ids. Preserve their historical
// natural-key reconciliation without routing canonical v2 rows
// through it.
for (const measurement of payload.measurements) {
if (measurement.id) continue;
const restoredData = toRestoredMeasurementData(measurement);
const existing = await tx.measurement.findFirst({
where: {
userId: ownerId,
type: measurement.type,
source: restoredData.source,
measuredAt: restoredData.measuredAt,
sleepStage: restoredData.sleepStage,
},
select: { id: true },
});
if (existing) {
await tx.measurement.update({
where: { id: existing.id, userId: ownerId },
data: restoredData,
});
} else {
await tx.measurement.create({
data: { userId: ownerId, ...restoredData },
});
// The measurements, a batch at a time from a second read of the
// file, so the restore never holds more than one batch of them.
//
// Two later sections point INTO the measurements (a record's
// `sourceMeasurementId`, an ECG strip's `measurementId`) and have
// to know which of those ids this transaction actually wrote. They
// used to be handed every written id; they only ever ask about the
// ones they reference, so only those are collected.
const referencedMeasurementIds = new Set<string>();
for (const record of payload.personalRecords) {
if (record.sourceMeasurementId) {
referencedMeasurementIds.add(record.sourceMeasurementId);
}
}
for (const recording of payload.ecgRecordings) {
if (recording.measurementId) {
referencedMeasurementIds.add(recording.measurementId);
}
}
const writtenReferencedIds = new Set<string>();
const measurementBatchSize = 1_000;
await streamed.forEachMeasurementBatch(
measurementBatchSize,
async (batch) => {
const stableRows = batch.flatMap(
(measurement): MeasurementInsertRow[] =>
measurement.id
? [
{
id: measurement.id,
userId: ownerId,
...toRestoredMeasurementData(measurement),
},
]
: [],
);
// One statement per batch, not `createMany`: see
// `insertMeasurementRows` for what the latter cost here.
await insertMeasurementRows(tx, stableRows);
for (const row of stableRows) {
if (referencedMeasurementIds.has(row.id)) {
writtenReferencedIds.add(row.id);
}
}
// v1 payloads did not require stable ids. Preserve their
// historical natural-key reconciliation without routing
// canonical v2 rows through it.
for (const measurement of batch) {
if (measurement.id) continue;
const restoredData = toRestoredMeasurementData(measurement);
const existing = await tx.measurement.findFirst({
where: {
userId: ownerId,
type: measurement.type,
source: restoredData.source,
measuredAt: restoredData.measuredAt,
sleepStage: restoredData.sleepStage,
},
select: { id: true },
});
if (existing) {
await tx.measurement.update({
where: { id: existing.id, userId: ownerId },
data: restoredData,
});
} else {
await tx.measurement.create({
data: { userId: ownerId, ...restoredData },
});
}
}
},
);
const medByName = new Map<string, string>();
const restoredMedicationIds = new Set<string>();
@@ -1932,7 +1989,7 @@ const handler = apiHandler(
tx,
ownerId,
payload,
new Set(stableRows.map((row) => row.id)),
writtenReferencedIds,
skips,
);
@@ -1949,7 +2006,7 @@ const handler = apiHandler(
tx,
ownerId,
payload,
new Set(stableRows.map((row) => row.id)),
writtenReferencedIds,
skips,
);
@@ -2024,7 +2081,7 @@ const handler = apiHandler(
},
{
maxWait: 10_000,
timeout: 120_000,
timeout: restoreTransactionTimeoutMs(streamed.measurementCount),
},
);
} catch (err) {
@@ -2071,7 +2128,7 @@ const handler = apiHandler(
// outside the transaction (the 5-year fold would otherwise hold a
// long write lock) and is best-effort so a populator hiccup never
// undoes the restore. The boot-time backfill is the safety net.
if (payload.measurements.length > 0) {
if (streamed.measurementCount > 0) {
try {
await recomputeUserRollups(ownerId);
} catch (err) {
@@ -2131,7 +2188,10 @@ const handler = apiHandler(
}
}
const summary = summarizeBackup(payload);
const summary = {
...summarizeBackup(payload),
measurements: streamed.measurementCount,
};
await auditLog("admin.backups.restore", {
userId: admin.id,
@@ -44,6 +44,11 @@ vi.mock("@/lib/validations/backup", () => ({
parseBackupPayload: (...a: unknown[]) => parseMock(...a),
summarizeBackup: (...a: unknown[]) => summarizeMock(...a),
isCompatibleSchemaVersion: vi.fn(() => true),
// The measurements are read as a stream and counted one by one.
BACKUP_SCHEMA_VERSION: "2",
backupMeasurementSchema: {
safeParse: (value: unknown) => ({ success: true, data: value }),
},
}));
import { GET } from "../route";
@@ -62,9 +67,14 @@ beforeEach(() => {
createdAt: new Date("2026-08-01T00:00:00Z"),
user: { id: "u1", username: "self-hoster" },
});
decryptMock.mockReturnValue("{}");
// Twelve measurements in the file: the preview counts them from the stream.
decryptMock.mockReturnValue(
JSON.stringify({
measurements: Array.from({ length: 12 }, (_, i) => ({ id: `m${i}` })),
}),
);
parseMock.mockReturnValue({ schemaVersion: 3 });
summarizeMock.mockReturnValue({ measurements: 12, moodEntries: 3 });
summarizeMock.mockReturnValue({ measurements: 0, moodEntries: 3 });
});
describe("GET /api/admin/backups/[id]/summary", () => {
@@ -19,8 +19,12 @@ import { apiError, apiSuccess } from "@/lib/api-response";
import {
BACKUP_UNDECRYPTABLE_CODE,
BACKUP_UNDECRYPTABLE_ERROR,
unpackBackupBlob,
openBackupBlob,
} from "@/lib/export/backup-blob";
import {
readStreamedBackup,
type BackupSource,
} from "@/lib/export/streamed-backup";
import { annotate } from "@/lib/logging/context";
import {
isCompatibleSchemaVersion,
@@ -46,9 +50,11 @@ export const GET = apiHandler(
throw new HttpError(404, "Backup not found");
}
let plaintext: string;
// Opened and read as a stream: a large record's JSON is longer than any
// string V8 can hold (#1031).
let source: BackupSource;
try {
plaintext = unpackBackupBlob(backup.data);
source = openBackupBlob(backup.data);
} catch {
// Same refusal the restore and the download give, for the same reason:
// a copy this instance cannot open is bad stored input, and the preview
@@ -59,8 +65,11 @@ export const GET = apiHandler(
}
let payload;
let measurementCount: number;
try {
payload = parseBackupPayload(plaintext);
const streamed = await readStreamedBackup(source);
measurementCount = streamed.measurementCount;
payload = parseBackupPayload(streamed.raw);
} catch {
return apiError("Backup payload failed schema validation", 422);
}
@@ -72,7 +81,10 @@ export const GET = apiHandler(
);
}
const summary = summarizeBackup(payload);
const summary = {
...summarizeBackup(payload),
measurements: measurementCount,
};
annotate({
action: { name: "admin.backups.summary" },
+272 -133
View File
@@ -1,17 +1,25 @@
/**
* POST /api/admin/backups/upload — admin-only ingest of a backup file.
*
* Accepts a `multipart/form-data` body with a single `file` field that
* carries a JSON backup matching `backupPayloadSchema`. The route:
* Accepts the backup file either as the raw request body (JSON, or JSON
* compressed with gzip) or, for a small file, as a `multipart/form-data`
* body with a single `file` field. The route:
*
* 1. Authenticates the caller as an admin (cookie session only).
* 2. Reads the file (size capped — see `MAX_UPLOAD_BYTES`).
* 3. Parses and validates the JSON against the canonical schema.
* 4. Rejects files written by a future schema version (incompatible).
* 5. Encrypts the plaintext and inserts a new `DataBackup` row of
* type `MANUAL_UPLOAD_<unix-ms>` so multiple uploads coexist with
* 5. Encrypts the file as it arrives and inserts a new `DataBackup` row
* of type `MANUAL_UPLOAD_<unix-ms>` so multiple uploads coexist with
* the rolling `WEEKLY_AUTO` snapshot AND with each other.
*
* The file is read as a stream and never held whole (#1031): an export of an
* account with 1.25 million measurements is a few hundred megabytes of JSON,
* which neither fits a 1 GB container beside the running app nor, in the
* disaster-recovery shape, fits in one JavaScript string at all. It is
* checked as it goes and stored in the same pass; a file that fails any check
* rolls the store back, so nothing of it remains.
*
* Crucially, this route does NOT execute a restore. It only stores the
* file. The separate `POST /api/admin/backups/[id]/restore` endpoint
* (criterion 3) actually replaces user data — keeping the two phases
@@ -20,46 +28,120 @@
*
* Phase B1 / criterion 2 of the v1.4.15 backup-completeness work.
*/
import { Readable } from "node:stream";
import { createGunzip } from "node:zlib";
import { NextRequest } from "next/server";
import { ZodError } from "zod/v4";
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 { packBackupBlob } from "@/lib/export/backup-blob";
import { BackupJsonError, scanBackupJson } from "@/lib/export/backup-json-scan";
import { storeBackupBlob } from "@/lib/export/store-backup-blob";
import { StreamedBackupInvalidError } from "@/lib/export/streamed-backup";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import {
BACKUP_SCHEMA_VERSION,
backupMeasurementSchema,
backupPayloadSchema,
isCompatibleSchemaVersion,
summarizeBackup,
type BackupPayload,
type BackupSummary,
} from "@/lib/validations/backup";
export const dynamic = "force-dynamic";
/**
* Cap on uploaded file size. The largest production backups today are
* ~2 MB; 10 MB leaves headroom while keeping a malicious admin client
* from blowing memory by streaming a 4 GB file. Enforced by reading the
* `Content-Length` header AND the actual buffered size after decoding —
* a missing/wrong content-length doesn't bypass the limit.
* Cap on a file sent as a multipart form. The form parser holds the whole
* file in memory, so this path stays for small files only; anything larger
* is sent as the raw request body, which is read as a stream.
*/
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
/**
* Cap on a file sent as the raw request body, compressed or not. The same
* ceiling the app sets for any request body (`middlewareClientMaxBodySize` in
* `next.config.ts`), beyond which the body would arrive truncated. A
* disaster-recovery file of 1.25 million measurements is 662 MB as plain
* JSON and 64 MB compressed, so large files go up compressed.
*/
const MAX_RAW_UPLOAD_BYTES = 512 * 1024 * 1024;
interface UploadResponse {
id: string;
valid: true;
summary: BackupSummary;
}
/** A file the route refuses: the answer, and what the audit row records. */
class UploadRefused extends Error {
constructor(
readonly status: number,
readonly answer: string,
readonly details: Record<string, unknown>,
readonly meta?: Record<string, unknown>,
) {
super(answer);
this.name = "UploadRefused";
}
}
/** Bytes → bytes, gunzipped when the file starts with the gzip magic. */
async function* decodedBytes(
source: AsyncIterable<Uint8Array>,
limit: number,
): AsyncGenerator<Uint8Array> {
let seen = 0;
async function* counted() {
for await (const chunk of source) {
seen += chunk.byteLength;
if (seen > limit) {
throw new UploadRefused(
413,
`Upload exceeds ${Math.round(limit / 1024 / 1024)} MB limit`,
{ reason: "file_size_exceeded", size: seen },
);
}
yield chunk;
}
}
const iterator = counted()[Symbol.asyncIterator]();
const first = await iterator.next();
if (first.done) return;
const rest = (async function* () {
yield first.value;
for (;;) {
const next = await iterator.next();
if (next.done) return;
yield next.value;
}
})();
const head = first.value;
if (head.byteLength >= 2 && head[0] === 0x1f && head[1] === 0x8b) {
const gunzip = Readable.from(rest).pipe(createGunzip());
try {
for await (const chunk of gunzip) yield chunk as Buffer;
} catch (err) {
if (err instanceof UploadRefused) throw err;
throw new UploadRefused(422, "Uploaded file is not valid gzip", {
reason: "invalid_gzip",
});
}
return;
}
yield* rest;
}
export const POST = apiHandler(async (request: NextRequest) => {
const { user: admin } = await requireAdmin();
annotate({ action: { name: "admin.backups.upload" } });
const ipAddress = getClientIp(request);
// Rate-limit identical to the manual-run endpoint. An admin uploading
// a 10 MB blob 60× per minute would just be wasteful — three is plenty
// for a deliberate restore-prep workflow.
// Rate-limit identical to the manual-run endpoint. Three is plenty for a
// deliberate restore-prep workflow.
const rl = await checkRateLimit(
`admin-backups-upload:${admin.id}`,
3,
@@ -69,120 +151,56 @@ export const POST = apiHandler(async (request: NextRequest) => {
throw new HttpError(429, "Too many backup uploads");
}
// Cheap pre-flight on the declared content length. The actual buffer
// size check below is the hard limit; this just rejects obvious abuse
// before allocating the FormData parser.
const denied = (reason: string, extra: Record<string, unknown> = {}) =>
auditLog("admin.backups.upload.denied", {
userId: admin.id,
ipAddress,
details: { reason, ...extra },
});
const contentType = request.headers.get("content-type") ?? "";
const multipart = contentType.startsWith("multipart/form-data");
const limit = multipart ? MAX_UPLOAD_BYTES : MAX_RAW_UPLOAD_BYTES;
// Cheap pre-flight on the declared content length. The byte count while
// reading is the hard limit; this just rejects obvious abuse early.
const contentLength = Number(request.headers.get("content-length") ?? 0);
if (contentLength > MAX_UPLOAD_BYTES) {
await auditLog("admin.backups.upload.denied", {
userId: admin.id,
ipAddress: getClientIp(request),
details: { reason: "content_length_exceeded", contentLength },
});
return apiError("Upload exceeds 10 MB limit", 413);
}
let formData: FormData;
try {
formData = await request.formData();
} catch (err) {
if (contentLength > limit) {
await denied("content_length_exceeded", { contentLength });
return apiError(
`Invalid multipart body: ${err instanceof Error ? err.message : "unknown"}`,
400,
`Upload exceeds ${Math.round(limit / 1024 / 1024)} MB limit`,
413,
);
}
const file = formData.get("file");
if (!(file instanceof File)) {
return apiError("Field 'file' must be a file", 422);
}
if (file.size > MAX_UPLOAD_BYTES) {
await auditLog("admin.backups.upload.denied", {
userId: admin.id,
ipAddress: getClientIp(request),
details: { reason: "file_size_exceeded", size: file.size },
});
return apiError("Upload exceeds 10 MB limit", 413);
}
let plaintext: string;
try {
plaintext = await file.text();
} catch {
return apiError("Failed to read uploaded file", 400);
}
let parsedJson: unknown;
try {
parsedJson = JSON.parse(plaintext);
} catch (err) {
await auditLog("admin.backups.upload.denied", {
userId: admin.id,
ipAddress: getClientIp(request),
details: {
reason: "invalid_json",
message: err instanceof Error ? err.message : "parse_failed",
},
});
return apiError("Uploaded file is not valid JSON", 422);
}
let payload;
try {
payload = backupPayloadSchema.parse(parsedJson);
} catch (err) {
const issues =
err instanceof ZodError
? err.issues.slice(0, 10).map((i) => ({
path: i.path.join("."),
message: i.message,
}))
: [];
await auditLog("admin.backups.upload.denied", {
userId: admin.id,
ipAddress: getClientIp(request),
details: { reason: "schema_invalid", issues },
});
return apiError("Backup payload failed schema validation", 422, {
issues,
});
}
if (!isCompatibleSchemaVersion(payload.schemaVersion)) {
await auditLog("admin.backups.upload.denied", {
userId: admin.id,
ipAddress: getClientIp(request),
details: {
reason: "incompatible_schema_version",
schemaVersion: payload.schemaVersion,
},
});
return apiError(
`Backup schema version '${payload.schemaVersion}' is not supported by this server`,
422,
let source: AsyncIterable<Uint8Array>;
if (multipart) {
let formData: FormData;
try {
formData = await request.formData();
} catch (err) {
return apiError(
`Invalid multipart body: ${err instanceof Error ? err.message : "unknown"}`,
400,
);
}
const file = formData.get("file");
if (!(file instanceof File)) {
return apiError("Field 'file' must be a file", 422);
}
if (file.size > MAX_UPLOAD_BYTES) {
await denied("file_size_exceeded", { size: file.size });
return apiError("Upload exceeds 10 MB limit", 413);
}
source = Readable.fromWeb(
file.stream() as Parameters<typeof Readable.fromWeb>[0],
);
}
// Make sure the userId on the file points at a user that exists in
// this DB. Restore would fail later anyway, but failing here gives
// the admin a precise error before any side-effect.
const owner = await prisma.user.findUnique({
where: { id: payload.userId },
select: { id: true, username: true },
});
if (!owner) {
await auditLog("admin.backups.upload.denied", {
userId: admin.id,
ipAddress: getClientIp(request),
details: {
reason: "owner_not_found",
ownerId: payload.userId,
},
});
return apiError(
`Backup is for user '${payload.userId}' which does not exist in this DB`,
422,
} else {
if (!request.body) {
return apiError("The backup file is missing from the request body", 422);
}
source = Readable.fromWeb(
request.body as Parameters<typeof Readable.fromWeb>[0],
);
}
@@ -190,25 +208,146 @@ 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 = packBackupBlob(JSON.stringify(payload));
let payload: BackupPayload | undefined;
let measurementCount = 0;
let owner: { id: string; username: string } | null = null;
const created = await prisma.dataBackup.create({
data: {
userId: owner.id,
type: uploadType,
data: encrypted,
},
});
let created: { id: string };
try {
created = await storeBackupBlob(
prisma,
() => ({ userId: owner!.id, type: uploadType }),
async (write) => {
// One pass: every chunk is stored as it is checked, and a check that
// fails throws, which rolls the store back.
async function* storedAsRead() {
for await (const chunk of decodedBytes(source, limit)) {
await write(Buffer.from(chunk));
yield chunk;
}
}
let firstWithoutId: number | null = null;
let scanned;
try {
scanned = await scanBackupJson(storedAsRead(), {
streamKeys: new Set(["measurements"]),
onElement: (_key, element, index) => {
const parsed = backupMeasurementSchema.safeParse(element);
if (!parsed.success) {
const issue = parsed.error.issues[0];
throw new StreamedBackupInvalidError(
["measurements", index, ...(issue?.path ?? [])].join("."),
issue?.message ?? "invalid measurement",
);
}
if (!parsed.data.id && firstWithoutId === null) {
firstWithoutId = index;
}
},
});
} catch (err) {
if (err instanceof BackupJsonError) {
throw new UploadRefused(422, "Uploaded file is not valid JSON", {
reason: "invalid_json",
message: err.message,
});
}
if (err instanceof StreamedBackupInvalidError) {
const issues = [{ path: err.path, message: err.message }];
throw new UploadRefused(
422,
"Backup payload failed schema validation",
{ reason: "schema_invalid", issues },
{ issues },
);
}
throw err;
}
measurementCount = scanned.streamedCounts.measurements ?? 0;
const summary = summarizeBackup(payload);
try {
payload = backupPayloadSchema.parse(scanned.document);
} catch (err) {
const issues =
err instanceof ZodError
? err.issues.slice(0, 10).map((i) => ({
path: i.path.join("."),
message: i.message,
}))
: [];
throw new UploadRefused(
422,
"Backup payload failed schema validation",
{ reason: "schema_invalid", issues },
{ issues },
);
}
if (
payload.schemaVersion === BACKUP_SCHEMA_VERSION &&
firstWithoutId !== null
) {
const issues = [
{
path: `measurements.${firstWithoutId}.id`,
message: "Canonical v2 measurements require a stable id",
},
];
throw new UploadRefused(
422,
"Backup payload failed schema validation",
{ reason: "schema_invalid", issues },
{ issues },
);
}
if (!isCompatibleSchemaVersion(payload.schemaVersion)) {
throw new UploadRefused(
422,
`Backup schema version '${payload.schemaVersion}' is not supported by this server`,
{
reason: "incompatible_schema_version",
schemaVersion: payload.schemaVersion,
},
);
}
// Make sure the userId on the file points at a user that exists in
// this DB. Restore would fail later anyway, but failing here gives
// the admin a precise error before any side-effect.
owner = await prisma.user.findUnique({
where: { id: payload.userId },
select: { id: true, username: true },
});
if (!owner) {
throw new UploadRefused(
422,
`Backup is for user '${payload.userId}' which does not exist in this DB`,
{ reason: "owner_not_found", ownerId: payload.userId },
);
}
},
);
} catch (err) {
if (err instanceof UploadRefused) {
await denied(err.details.reason as string, err.details);
return apiError(err.answer, err.status, err.meta);
}
throw err;
}
const summary: BackupSummary = {
...summarizeBackup(payload!),
measurements: measurementCount,
};
const storedOwner = owner as { id: string; username: string } | null;
await auditLog("admin.backups.upload", {
userId: admin.id,
ipAddress: getClientIp(request),
ipAddress,
details: {
backupId: created.id,
ownerId: owner.id,
ownerUsername: owner.username,
ownerId: storedOwner?.id,
ownerUsername: storedOwner?.username,
type: uploadType,
schemaVersion: summary.schemaVersion,
counts: {
@@ -64,6 +64,29 @@ vi.mock("@/lib/db", () => ({
documentConditionLink: { findMany: vi.fn() },
extractedFact: { findMany: vi.fn() },
ecgRecording: { findMany: vi.fn() },
onboardingRecord: { findUnique: vi.fn().mockResolvedValue(null) },
environmentTravelLocation: { findMany: vi.fn().mockResolvedValue([]) },
environmentContext: { findMany: vi.fn().mockResolvedValue([]) },
personalRecord: { findMany: vi.fn().mockResolvedValue([]) },
coachReminder: { findMany: vi.fn().mockResolvedValue([]) },
coachPlan: { findMany: vi.fn().mockResolvedValue([]) },
coachFact: { findMany: vi.fn().mockResolvedValue([]) },
coachConversation: { findMany: vi.fn().mockResolvedValue([]) },
measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) },
measurementReminder: { findMany: vi.fn().mockResolvedValue([]) },
vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) },
encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) },
encounterDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
encounter: { findMany: vi.fn().mockResolvedValue([]) },
practitioner: { findMany: vi.fn().mockResolvedValue([]) },
healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) },
intradayCumulativeProfile: { findMany: vi.fn().mockResolvedValue([]) },
correlationPattern: { findMany: vi.fn().mockResolvedValue([]) },
healthProfileFactRevision: { findMany: vi.fn().mockResolvedValue([]) },
customMetric: { findMany: vi.fn().mockResolvedValue([]) },
cycleSymptom: { findMany: vi.fn().mockResolvedValue([]) },
},
}));
@@ -177,6 +200,7 @@ beforeEach(() => {
);
vi.mocked(prisma.extractedFact.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.ecgRecording.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.userAchievement.findMany).mockResolvedValue([] as never);
});
describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => {
@@ -192,7 +216,13 @@ describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => {
it("/api/export/full-backup scopes the measurement read to deletedAt: null", async () => {
const { GET } = await import("../full-backup/route");
await GET(mkReq("http://localhost/api/export/full-backup"));
// The file is written into the response as it is produced, so the reads
// happen while the body is consumed. The mocked client does not cover
// every section the builder reads, so the body errors part-way (as the
// whole response did before it streamed); the reads asserted here have
// been issued by then.
const res = await GET(mkReq("http://localhost/api/export/full-backup"));
await res.text();
expect(prisma.measurement.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ deletedAt: null }),
@@ -202,7 +232,10 @@ describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => {
it("/api/export/full-backup scopes every v1.28 records domain to userId + deletedAt: null", async () => {
const { GET } = await import("../full-backup/route");
await GET(mkReq("http://localhost/api/export/full-backup"));
// The file is written into the response as it is produced, so the reads
// happen while the body is consumed.
const res = await GET(mkReq("http://localhost/api/export/full-backup"));
await res.text();
expect(prisma.labResult.findMany).toHaveBeenCalledWith(
expect.objectContaining({
+77 -27
View File
@@ -42,13 +42,44 @@ import {
safeJson,
} from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
import { randomBytes } from "node:crypto";
import { createReadStream } from "node:fs";
import { readdir, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { FullBackupCounts } from "@/lib/export/full-backup-payload";
import { streamFullBackupJson } from "@/lib/export/full-backup-stream";
import {
encryptArchive,
encryptArchiveToFile,
MIN_EXPORT_PASSPHRASE_LENGTH,
type SpooledArchive,
} from "@/lib/export/passphrase-archive";
import { streamToResponseBody } from "@/lib/export/response-stream";
import { NextRequest, NextResponse } from "next/server";
const SPOOL_PREFIX = "healthlog-export-";
/**
* Remove spool files an earlier export left behind: a process that died
* mid-export never reached its cleanup. An hour is far longer than any export
* takes, so nothing still being written or sent is touched.
*/
async function removeStaleSpools(): Promise<void> {
try {
const dir = tmpdir();
const cutoff = Date.now() - 60 * 60 * 1000;
for (const name of await readdir(dir)) {
if (!name.startsWith(SPOOL_PREFIX)) continue;
const path = join(dir, name);
const info = await stat(path).catch(() => null);
if (info && info.mtimeMs < cutoff) await rm(path, { force: true });
}
} catch {
// Housekeeping only; an export must not fail over it.
}
}
const encryptedExportSchema = z
.object({
passphrase: z
@@ -89,10 +120,31 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
const { passphrase } = parsed.data;
const { payload, counts } = await buildFullBackupPayload(prisma, user.id);
// Encrypt the JSON bytes. The passphrase goes no further than the KDF.
const archive = await encryptArchive(JSON.stringify(payload), passphrase);
// Sealed as it is produced and spooled to a temporary file, then sent: the
// format puts the tag in front of the ciphertext, so the archive cannot go
// out before its last byte is encrypted, and holding it in memory is what
// took the app down on an account of 1.25 million measurements (#1031). Only
// ciphertext touches the disk, and the file is removed once it is sent or
// the client leaves.
let counts: FullBackupCounts | undefined;
await removeStaleSpools();
const bodyPath = join(
tmpdir(),
`${SPOOL_PREFIX}${randomBytes(12).toString("hex")}.hlx.part`,
);
let archive: SpooledArchive;
try {
archive = await encryptArchiveToFile(
async (write) => {
counts = await streamFullBackupJson(prisma, user.id, write);
},
passphrase,
bodyPath,
);
} catch (err) {
await rm(bodyPath, { force: true });
throw err;
}
await auditLog("user.export.encrypted", {
userId: user.id,
@@ -102,38 +154,36 @@ export const POST = apiHandler(async (request: NextRequest) => {
annotate({
meta: {
export_measurements_count: counts.measurements,
export_medications_count: counts.medications,
export_intake_count: counts.intakeEvents,
export_medication_side_effect_count: counts.medicationSideEffects,
export_mood_count: counts.moodEntries,
export_cycle_count: counts.cycles,
export_cycle_day_log_count: counts.cycleDayLogs,
export_lab_result_count: counts.labResults,
export_biomarker_count: counts.biomarkers,
export_illness_episode_count: counts.illnessEpisodes,
export_illness_day_log_count: counts.illnessDayLogs,
export_allergy_count: counts.allergies,
export_family_history_count: counts.familyHistory,
export_workout_count: counts.workouts,
export_document_count: counts.documents,
export_nutrient_day_count: counts.nutrientDays,
export_health_profile_count: counts.healthProfile,
export_custom_metric_count: counts.customMetrics,
export_custom_metric_entry_count: counts.customMetricEntries,
export_intraday_profile_count: counts.intradayProfiles,
export_measurements_count: counts?.measurements,
export_medications_count: counts?.medications,
export_intake_count: counts?.intakeEvents,
export_mood_count: counts?.moodEntries,
export_archive_bytes: archive.byteLength,
},
});
const archiveBody = streamToResponseBody(
async (write) => {
await write(archive.prefix);
for await (const chunk of createReadStream(archive.bodyPath)) {
await write(chunk as Buffer);
}
},
{
onComplete: () => rm(archive.bodyPath, { force: true }),
onError: () => rm(archive.bodyPath, { force: true }),
},
);
const stamp = new Date().toISOString().slice(0, 10);
// Return the raw binary archive (NOT the apiSuccess envelope). The file is a
// self-contained `.hlx` archive openable with the user's passphrase via
// scripts/decrypt-export.ts.
return new NextResponse(new Uint8Array(archive), {
return new NextResponse(archiveBody, {
status: 200,
headers: {
"Content-Type": "application/octet-stream",
"Content-Length": String(archive.byteLength),
"Content-Disposition": `attachment; filename="healthlog-backup-${user.id}-${stamp}.hlx"`,
"Cache-Control": "no-store",
},
+35 -37
View File
@@ -35,7 +35,9 @@ import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { apiError, getClientIp } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
import type { FullBackupCounts } from "@/lib/export/full-backup-payload";
import { streamFullBackupJson } from "@/lib/export/full-backup-stream";
import { streamToResponseBody } from "@/lib/export/response-stream";
import { NextRequest, NextResponse } from "next/server";
export const GET = apiHandler(async (request: NextRequest) => {
@@ -47,48 +49,44 @@ export const GET = apiHandler(async (request: NextRequest) => {
return apiError("Maximum 10 exports per hour", 429);
}
// v1.23 — the payload builder is shared with the passphrase-encrypted
// export route so both emit the byte-for-byte same shape (the measurement/
// medication/mood/cycle domains are restore-compatible; the newer records
// domains are export-only for now — see the module doc comment above).
const { payload, counts } = await buildFullBackupPayload(prisma, user.id);
await auditLog("user.export.full-backup", {
userId: user.id,
ipAddress: getClientIp(request),
details: { counts },
});
annotate({
meta: {
export_measurements_count: counts.measurements,
export_medications_count: counts.medications,
export_intake_count: counts.intakeEvents,
export_medication_side_effect_count: counts.medicationSideEffects,
export_mood_count: counts.moodEntries,
export_cycle_count: counts.cycles,
export_cycle_day_log_count: counts.cycleDayLogs,
export_lab_result_count: counts.labResults,
export_biomarker_count: counts.biomarkers,
export_illness_episode_count: counts.illnessEpisodes,
export_illness_day_log_count: counts.illnessDayLogs,
export_allergy_count: counts.allergies,
export_family_history_count: counts.familyHistory,
export_workout_count: counts.workouts,
export_document_count: counts.documents,
export_nutrient_day_count: counts.nutrientDays,
export_health_profile_count: counts.healthProfile,
export_custom_metric_count: counts.customMetrics,
export_custom_metric_entry_count: counts.customMetricEntries,
export_intraday_profile_count: counts.intradayProfiles,
// Written into the response as it is produced (`streamFullBackupJson`), not
// built and stringified first: for an account of 1.25 million measurements
// the built payload and its JSON string did not fit a 1 GB container, and
// the export took the whole app down (#1031). The file is byte for byte
// what `JSON.stringify` of the payload builder's object gives; the passphrase
// export beside this route writes the same bytes, encrypted.
//
// The counts are only known once the last row has gone out, so the audit
// row is written then. A file that stops early is audited as a failure.
const ipAddress = getClientIp(request);
let counts: FullBackupCounts | undefined;
const body = streamToResponseBody(
async (write) => {
counts = await streamFullBackupJson(prisma, user.id, write);
},
});
{
onComplete: () =>
auditLog("user.export.full-backup", {
userId: user.id,
ipAddress,
details: { counts },
}),
onError: (err) =>
auditLog("user.export.full-backup.failed", {
userId: user.id,
ipAddress,
details: {
reason: err instanceof Error ? err.message : String(err),
},
}),
},
);
const stamp = new Date().toISOString().slice(0, 10);
// Stream the JSON directly (NOT wrapped in the apiSuccess envelope) so
// the file is a self-contained backup — admin upload + restore expect
// the raw payload, not `{ data: { ... } }`.
return new NextResponse(JSON.stringify(payload), {
return new NextResponse(body, {
status: 200,
headers: {
"Content-Type": "application/json; charset=utf-8",