mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
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.
300 lines
10 KiB
TypeScript
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);
|
|
});
|