Files
HealthLog/src/app/api/import/route.ts
T
Marc-André Bombeck 4d3a02cc1a feat(security): add second-factor schema and encrypt free-text health notes at rest
Lay the account-security and data-sovereignty substrate for v1.23. No
user-facing behaviour yet — schema, crypto registry, redaction, and the
note-encryption data path only.

Schema + migrations:
- 0199 — User totp_secret_encrypted / totp_confirmed_at / mfa_enforced /
  passkey_upgrade_nudge_dismissed; Session mfa_verified_at / last_active_at;
  Passkey last_used_at. All additive, nullable / defaulted.
- 0200 — mfa_challenges (single-use step-up ticket; only the ticket hash is
  stored, so no session or token is ever minted before the second factor),
  mfa_recovery_codes (Argon2id at rest), webauthn_mfa_credentials (second-factor
  keys, kept separate from passkeys, which remain the passwordless-primary set).
- 0201 — mood_entries.note_encrypted + measurements.notes_encrypted (BYTEA),
  matching the existing free-text encrypted-note convention.

Free-text health notes:
- New writes encrypt the mood / measurement note into the *_encrypted column and
  null the legacy plaintext column; reads prefer the ciphertext and fall back to
  plaintext only for not-yet-migrated rows, via a single read/write helper.
- A boot-time job migrates existing plaintext rows in an idempotent,
  transactional, fail-closed pass (encrypt, then null the plaintext, per row in
  one transaction; safe to re-run; a row with content is never left both-null).
- The legacy plaintext columns are kept this release and dropped in a follow-up
  once the backfill reports zero remaining rows — the same boundary the
  key-rotation playbook uses before dropping a legacy key.

Crypto coverage:
- Register the three new encrypted columns and wire them into the key-rotation
  script so a rotation never orphans them.
- Extend the observability and log redaction denylists so TOTP secrets, MFA /
  recovery / backup codes, and export passphrases never reach a wide event.
2026-06-27 19:33:10 +02:00

300 lines
10 KiB
TypeScript

import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import {
validateMeasurementRange,
measurementTypeEnum,
glucoseContextEnum,
} from "@/lib/validations/measurement";
import { validateEntryInstant } from "@/lib/validations/entry-instant";
import { encryptNote } from "@/lib/crypto/note-cipher";
import { recomputeUserMoodRollups } from "@/lib/rollups/mood-rollups";
import {
collapseToTypeDayKeys,
recomputeBucketsForMeasurement,
} from "@/lib/rollups/measurement-rollups";
import type { MeasurementType } from "@/generated/prisma/client";
// Derived from canonical enum so round-trip export → import covers every
// type. Previous hardcoded subset silently dropped 4 of 11 types
// (V3 audit: enum drift cousins).
const measurementSchema = z
.object({
type: measurementTypeEnum,
value: z.number(),
unit: z.string(),
// v1.17.1 — share the canonical entry-instant bound used by the single
// POST + edit paths (v1.17 W1b). Previously a bare `z.string().datetime()`
// with no ceiling, so a crafted JSON import could forward-date a reading.
// `validateEntryInstant` rejects any future instant beyond a 5-min skew
// tolerance and any instant before 1900; the field transforms to a `Date`.
measuredAt: validateEntryInstant(
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
),
glucoseContext: glucoseContextEnum.optional(),
source: z.string().optional(),
notes: z.string().optional(),
// v1.17.1 — optional source-stable id. When present, the import upserts on
// `(userId, type, source=IMPORT, externalId)` so a re-import of the same
// export is idempotent rather than minting a duplicate set — mirrors the
// mood path. Absent → first-write-wins create with a NULL externalId
// (distinct in the unique key), which DOES duplicate on re-import.
externalId: z.string().min(1).max(120).optional(),
})
.refine(
(data) => validateMeasurementRange(data.type, data.value) === null,
"Wert ausserhalb des plausiblen Bereichs",
);
const moodEntrySchema = z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
mood: z.enum(["SUPER_GUT", "GUT", "OKAY", "SCHLECHT", "LAUSIG"]),
score: z.number().int().min(1).max(5),
tags: z.string().optional(),
// v1.17.1 — same entry-instant bound as measurements; an imported mood
// log cannot be forward-dated. Optional + transforms to a `Date` when set.
loggedAt: validateEntryInstant(
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
).optional(),
// v1.12.1 — optional source-stable id (e.g. a Daylio row id). When
// present, the import upserts on `(userId, source, externalId)` so a
// re-import of the same export is idempotent rather than minting a
// duplicate; absent → the legacy first-write-wins create path.
externalId: z.string().min(1).max(120).optional(),
});
const importSchema = z.object({
measurements: z.array(measurementSchema).max(10000).optional(),
moodEntries: z.array(moodEntrySchema).max(10000).optional(),
});
/**
* Import user data (JSON format matching export structure).
* Deduplicates via unique constraints — existing records are skipped.
*/
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "import.upload" } });
// V3 audit: bulk-injection vector unchecked. 5/hour matches the export
// limit (10/hour) but is tighter because import writes have a higher
// blast radius (DB writes vs. read-only export).
const rl = await checkRateLimit(`import:${user.id}`, 5, 60 * 60 * 1000);
if (!rl.allowed) {
return apiError("Maximum 5 imports per hour", 429);
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024 * 1024,
});
if (jsonError) return jsonError;
const parsed = importSchema.safeParse(body);
if (!parsed.success) {
return apiError(
parsed.error.issues[0]?.message ?? "Invalid import payload",
422,
);
}
const userId = user.id;
const data = parsed.data;
const stats = { measurements: 0, moodEntries: 0, skipped: 0 };
// Import measurements
// v1.4.39.1 — track each (type, measuredAt) we wrote so we can re-
// fold the persistent rollup tier at the end. Pre-fix, the import
// path bypassed the rollup write hook entirely, so a CSV / JSON
// restore left the user's `source=rollup` chart paths under-counting
// the imported days until the next worker boot re-ran the backfill.
const touchedMeasurements: Array<{
type: MeasurementType;
measuredAt: Date;
}> = [];
if (data.measurements?.length) {
for (const m of data.measurements) {
try {
// `measuredAt` is already a `Date` (validateEntryInstant transform).
const measuredAt = m.measuredAt;
if (m.externalId) {
// v1.17.1 — idempotent re-import keyed on the source-stable id, so
// re-uploading the same export updates in place rather than minting
// a duplicate. Mirrors the mood path. The unique key already exists
// (`userId_type_source_externalId`, schema.prisma:942).
await prisma.measurement.upsert({
where: {
userId_type_source_externalId: {
userId,
type: m.type,
source: "IMPORT",
externalId: m.externalId,
},
},
update: {
value: m.value,
unit: m.unit,
measuredAt,
notes: null,
notesEncrypted: encryptNote(m.notes || null),
glucoseContext: m.glucoseContext ?? null,
},
create: {
userId,
type: m.type,
value: m.value,
unit: m.unit,
source: "IMPORT",
externalId: m.externalId,
measuredAt,
notes: null,
notesEncrypted: encryptNote(m.notes || null),
glucoseContext: m.glucoseContext ?? null,
},
});
} else {
await prisma.measurement.create({
data: {
userId,
type: m.type,
value: m.value,
unit: m.unit,
source: "IMPORT",
measuredAt,
notes: null,
notesEncrypted: encryptNote(m.notes || null),
glucoseContext: m.glucoseContext ?? null,
},
});
}
touchedMeasurements.push({
type: m.type as MeasurementType,
measuredAt,
});
stats.measurements++;
} catch {
// Unique constraint violation — skip duplicate
stats.skipped++;
}
}
}
// Import mood entries
if (data.moodEntries?.length) {
for (const e of data.moodEntries) {
try {
const loggedAt = e.loggedAt ?? new Date();
if (e.externalId) {
// v1.12.1 — idempotent re-import keyed on the source-stable id.
// A second import of the same export updates the row in place
// rather than skipping it as a duplicate, so an upstream edit
// (re-scored mood, added tag) is reflected.
await prisma.moodEntry.upsert({
where: {
userId_source_externalId: {
userId,
source: "IMPORT",
externalId: e.externalId,
},
},
update: {
date: e.date,
mood: e.mood,
score: e.score,
tags: e.tags || null,
moodLoggedAt: loggedAt,
},
create: {
userId,
date: e.date,
mood: e.mood,
score: e.score,
tags: e.tags || null,
source: "IMPORT",
externalId: e.externalId,
moodLoggedAt: loggedAt,
},
});
} else {
await prisma.moodEntry.create({
data: {
userId,
date: e.date,
mood: e.mood,
score: e.score,
tags: e.tags || null,
source: "IMPORT",
moodLoggedAt: loggedAt,
},
});
}
stats.moodEntries++;
} catch {
// Unique constraint violation — skip duplicate
stats.skipped++;
}
}
}
// v1.4.39 W-MOOD — one bounded re-fold per import is cheaper than
// firing N per-row hooks. The mood rollup tier is keyed on the
// user + day; the fold materialises every touched day from the
// entries we just wrote. Best-effort: an importer hiccup must not
// surface as a 5xx, the rollup is a cache tier.
if (stats.moodEntries > 0) {
try {
await recomputeUserMoodRollups(userId, { granularities: ["DAY"] });
} catch (err) {
annotate({
meta: {
mood_rollup_import_failed: true,
mood_rollup_import_error:
err instanceof Error ? err.message : String(err),
},
});
}
}
// v1.4.39.1 — refresh the persistent measurement rollup table for
// every distinct (type, day) the import touched. Collapsed so a
// 10 000-row CSV restore pays at most ~N (type, day) recomputes
// rather than 10 000 per-row hooks. Best-effort: a populator hiccup
// never fails the importer.
if (touchedMeasurements.length > 0) {
try {
const keys = collapseToTypeDayKeys(touchedMeasurements);
for (const k of keys) {
await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt);
}
} catch (err) {
annotate({
meta: {
measurement_rollup_import_failed: true,
measurement_rollup_import_error:
err instanceof Error ? err.message : String(err),
},
});
}
}
await auditLog("import.upload", {
userId,
ipAddress: getClientIp(request),
details: stats,
});
annotate({ meta: { import_stats: stats } });
return apiSuccess(stats);
});