From 491ad8912dca5e5d1179f884eff27c61b7298361 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 7 May 2026 22:37:23 +0200 Subject: [PATCH 01/13] fix(measurements): close issue #109 + sync body composition across UI/PDF/thresholds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-visible: TOTAL_BODY_WATER, BONE_MASS, and BLOOD_GLUCOSE now appear in the measurements filter, badge, mobile icon, and edit dialog (issue #109). Server-rendered doctor report now includes body composition rows (audit C-9). Body water + bone mass have proper effective-range thresholds so severity logic doesn't silently return `nominal` (audit C-15). Structural: extracted the per-type display maps from measurement-list.tsx into measurement-list-meta.ts as a deliberate first step toward the single-source manifest planned for phase P5. Coverage tests now fail-fast if a future enum addition skips any of: list label/icon/color, doctor-PDF label/unit/vital-types, chart label, threshold metric order/label/bounds. Audit-2026-05-07 / phase P0: - closes #109 (filter UI gap, root cause for the German raw-enum display) - closes audit C-9 (server PDF used a stale type map) - closes audit C-15 (no warn/critical bands for body composition) Tests: 282 → 296 (14 new). Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- messages/de.json | 4 ++ messages/en.json | 4 ++ src/components/charts/health-chart.tsx | 2 + .../__tests__/measurement-list-meta.test.ts | 43 ++++++++++++ .../measurements/measurement-list-meta.ts | 66 +++++++++++++++++++ .../measurements/measurement-list.tsx | 44 ++----------- .../settings/thresholds-section.tsx | 4 ++ .../__tests__/doctor-report-pdf-core.test.ts | 57 ++++++++++++++++ .../__tests__/effective-range.test.ts | 48 +++++++++++++- src/lib/analytics/effective-range.ts | 18 ++++- src/lib/doctor-report-pdf-core.ts | 45 +++++++++---- 11 files changed, 281 insertions(+), 54 deletions(-) create mode 100644 src/components/measurements/__tests__/measurement-list-meta.test.ts create mode 100644 src/components/measurements/measurement-list-meta.ts diff --git a/messages/de.json b/messages/de.json index 4c3c69e..f2121e5 100644 --- a/messages/de.json +++ b/messages/de.json @@ -538,6 +538,8 @@ "pointsAllTitle": "Alle verfügbaren Messpunkte", "systolic": "Systolisch", "diastolic": "Diastolisch", + "bodyWater": "Körperwasser", + "boneMass": "Knochenmasse", "movingAverage7d": "7T-Schnitt", "avg7dShort": "7T", "avg30dShort": "30T", @@ -760,6 +762,8 @@ "metricGlucosePostprandial": "Glukose — postprandial", "metricGlucoseRandom": "Glukose — beliebig", "metricGlucoseBedtime": "Glukose — vor dem Schlafen", + "metricBodyWater": "Körperwasser", + "metricBoneMass": "Knochenmasse", "unsetExplanation": "Kein eigener Wert — berechneter Default aktiv.", "loadError": "Zielwerte konnten nicht geladen werden" }, diff --git a/messages/en.json b/messages/en.json index 2961380..7acf8de 100644 --- a/messages/en.json +++ b/messages/en.json @@ -538,6 +538,8 @@ "pointsAllTitle": "All available data points", "systolic": "Systolic", "diastolic": "Diastolic", + "bodyWater": "Body water", + "boneMass": "Bone mass", "movingAverage7d": "7d avg", "avg7dShort": "7d", "avg30dShort": "30d", @@ -760,6 +762,8 @@ "metricGlucosePostprandial": "Glucose — post-meal", "metricGlucoseRandom": "Glucose — random", "metricGlucoseBedtime": "Glucose — bedtime", + "metricBodyWater": "Body water", + "metricBoneMass": "Bone mass", "unsetExplanation": "No override — using computed default.", "loadError": "Could not load thresholds" }, diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 65f1e89..687df99 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -106,6 +106,8 @@ const BASE_TYPE_LABEL_KEYS: Record = { SLEEP_DURATION: "charts.sleep", ACTIVITY_STEPS: "charts.steps", BLOOD_GLUCOSE: "measurements.typeBloodGlucose", + TOTAL_BODY_WATER: "charts.bodyWater", + BONE_MASS: "charts.boneMass", }; function getTypeLabel( diff --git a/src/components/measurements/__tests__/measurement-list-meta.test.ts b/src/components/measurements/__tests__/measurement-list-meta.test.ts new file mode 100644 index 0000000..d5e19e2 --- /dev/null +++ b/src/components/measurements/__tests__/measurement-list-meta.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from "vitest"; +import { measurementTypeEnum } from "@/lib/validations/measurement"; +import { + MEASUREMENT_TYPE_LABEL_KEYS, + MEASUREMENT_TYPE_ICONS, + MEASUREMENT_TYPE_COLORS, +} from "../measurement-list-meta"; +import enMessages from "../../../../messages/en.json"; +import deMessages from "../../../../messages/de.json"; + +/** + * Issue #109 root cause: measurement-list maps drifted from the canonical + * measurementTypeEnum. New enum values silently fell through to the raw + * string fallback. These tests fail fast if a new enum value is added + * without a corresponding entry in every list-UI map. + */ +describe("measurement-list-meta", () => { + const allTypes = [...measurementTypeEnum.options].sort(); + + it("MEASUREMENT_TYPE_LABEL_KEYS covers every measurement type", () => { + expect(Object.keys(MEASUREMENT_TYPE_LABEL_KEYS).sort()).toEqual(allTypes); + }); + + it("MEASUREMENT_TYPE_ICONS covers every measurement type", () => { + expect(Object.keys(MEASUREMENT_TYPE_ICONS).sort()).toEqual(allTypes); + }); + + it("MEASUREMENT_TYPE_COLORS covers every measurement type", () => { + expect(Object.keys(MEASUREMENT_TYPE_COLORS).sort()).toEqual(allTypes); + }); + + it("every label key resolves in both English and German locales", () => { + type Bag = { measurements?: Record }; + const en = (enMessages as Bag).measurements ?? {}; + const de = (deMessages as Bag).measurements ?? {}; + + for (const [type, key] of Object.entries(MEASUREMENT_TYPE_LABEL_KEYS)) { + const leaf = key.replace(/^measurements\./, ""); + expect(en[leaf], `EN missing ${key} for ${type}`).toBeTruthy(); + expect(de[leaf], `DE missing ${key} for ${type}`).toBeTruthy(); + } + }); +}); diff --git a/src/components/measurements/measurement-list-meta.ts b/src/components/measurements/measurement-list-meta.ts new file mode 100644 index 0000000..9f6b82a --- /dev/null +++ b/src/components/measurements/measurement-list-meta.ts @@ -0,0 +1,66 @@ +/** + * Per-measurement-type display metadata for the measurements list view. + * + * Extracted into its own module so we can: + * 1. Run a coverage test that asserts every Zod-enum measurement type has + * an entry in every map (so future enum additions can't silently fall + * through to the raw-string fallback — which was the root cause of + * issue #109). + * 2. Reuse the same icon/color set in adjacent surfaces (mobile list, + * edit dialog) without re-declaring it. + * + * Lead-architect note: this is the first step toward a single + * `metrics.json` manifest the entire ecosystem derives from. Today this + * module covers list-UI; phase P5 will subsume it into a cross-repo + * manifest. + */ +import { + Scale, + Heart, + Activity, + Droplets, + Droplet, + Moon, + Footprints, + Bone, + type LucideIcon, +} from "lucide-react"; + +export const MEASUREMENT_TYPE_LABEL_KEYS: Record = { + WEIGHT: "measurements.typeWeight", + BLOOD_PRESSURE_SYS: "measurements.typeBpSys", + BLOOD_PRESSURE_DIA: "measurements.typeBpDia", + PULSE: "measurements.typePulse", + BODY_FAT: "measurements.typeBodyFat", + SLEEP_DURATION: "measurements.typeSleep", + ACTIVITY_STEPS: "measurements.typeSteps", + BLOOD_GLUCOSE: "measurements.typeBloodGlucose", + TOTAL_BODY_WATER: "measurements.typeTotalBodyWater", + BONE_MASS: "measurements.typeBoneMass", +}; + +export const MEASUREMENT_TYPE_ICONS: Record = { + WEIGHT: Scale, + BLOOD_PRESSURE_SYS: Heart, + BLOOD_PRESSURE_DIA: Heart, + PULSE: Activity, + BODY_FAT: Droplets, + SLEEP_DURATION: Moon, + ACTIVITY_STEPS: Footprints, + BLOOD_GLUCOSE: Droplet, + TOTAL_BODY_WATER: Droplet, + BONE_MASS: Bone, +}; + +export const MEASUREMENT_TYPE_COLORS: Record = { + WEIGHT: "bg-chart-1/20 text-chart-1", + BLOOD_PRESSURE_SYS: "bg-chart-3/20 text-chart-3", + BLOOD_PRESSURE_DIA: "bg-chart-3/20 text-chart-3", + PULSE: "bg-chart-5/20 text-chart-5", + BODY_FAT: "bg-chart-4/20 text-chart-4", + SLEEP_DURATION: "bg-chart-2/20 text-chart-2", + ACTIVITY_STEPS: "bg-chart-2/20 text-chart-2", + BLOOD_GLUCOSE: "bg-chart-3/20 text-chart-3", + TOTAL_BODY_WATER: "bg-chart-2/20 text-chart-2", + BONE_MASS: "bg-chart-4/20 text-chart-4", +}; diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index c6563fc..43b3256 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -54,50 +54,16 @@ import { ArrowDown, ArrowUpDown, MoreHorizontal, - Scale, - Heart, - Activity, - Droplets, - Moon, - Footprints, } from "lucide-react"; import { useState } from "react"; import { formatDateTime } from "@/lib/format"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys"; - -const TYPE_LABEL_KEYS: Record = { - WEIGHT: "measurements.typeWeight", - BLOOD_PRESSURE_SYS: "measurements.typeBpSys", - BLOOD_PRESSURE_DIA: "measurements.typeBpDia", - PULSE: "measurements.typePulse", - BODY_FAT: "measurements.typeBodyFat", - SLEEP_DURATION: "measurements.typeSleep", - ACTIVITY_STEPS: "measurements.typeSteps", -}; - -const TYPE_ICONS: Record< - string, - React.ComponentType<{ className?: string }> -> = { - WEIGHT: Scale, - BLOOD_PRESSURE_SYS: Heart, - BLOOD_PRESSURE_DIA: Heart, - PULSE: Activity, - BODY_FAT: Droplets, - SLEEP_DURATION: Moon, - ACTIVITY_STEPS: Footprints, -}; - -const TYPE_COLORS: Record = { - WEIGHT: "bg-chart-1/20 text-chart-1", - BLOOD_PRESSURE_SYS: "bg-chart-3/20 text-chart-3", - BLOOD_PRESSURE_DIA: "bg-chart-3/20 text-chart-3", - PULSE: "bg-chart-5/20 text-chart-5", - BODY_FAT: "bg-chart-4/20 text-chart-4", - SLEEP_DURATION: "bg-chart-2/20 text-chart-2", - ACTIVITY_STEPS: "bg-chart-2/20 text-chart-2", -}; +import { + MEASUREMENT_TYPE_LABEL_KEYS as TYPE_LABEL_KEYS, + MEASUREMENT_TYPE_ICONS as TYPE_ICONS, + MEASUREMENT_TYPE_COLORS as TYPE_COLORS, +} from "./measurement-list-meta"; interface Measurement { id: string; diff --git a/src/components/settings/thresholds-section.tsx b/src/components/settings/thresholds-section.tsx index 42b6b35..642ec98 100644 --- a/src/components/settings/thresholds-section.tsx +++ b/src/components/settings/thresholds-section.tsx @@ -27,6 +27,8 @@ const METRIC_ORDER: ThresholdMetric[] = [ "BLOOD_PRESSURE_DIA", "PULSE", "BODY_FAT", + "TOTAL_BODY_WATER", + "BONE_MASS", "SLEEP_DURATION", "ACTIVITY_STEPS", "BLOOD_GLUCOSE_FASTING", @@ -47,6 +49,8 @@ const METRIC_LABEL_KEYS: Record = { BLOOD_GLUCOSE_POSTPRANDIAL: "thresholds.metricGlucosePostprandial", BLOOD_GLUCOSE_RANDOM: "thresholds.metricGlucoseRandom", BLOOD_GLUCOSE_BEDTIME: "thresholds.metricGlucoseBedtime", + TOTAL_BODY_WATER: "thresholds.metricBodyWater", + BONE_MASS: "thresholds.metricBoneMass", }; export function ThresholdsSection({ id }: { id: string }) { diff --git a/src/lib/__tests__/doctor-report-pdf-core.test.ts b/src/lib/__tests__/doctor-report-pdf-core.test.ts index b8df103..741b7ac 100644 --- a/src/lib/__tests__/doctor-report-pdf-core.test.ts +++ b/src/lib/__tests__/doctor-report-pdf-core.test.ts @@ -2,7 +2,11 @@ import { describe, it, expect } from "vitest"; import { buildDoctorReportPdfDocument, renderDoctorReportPdfBytes, + DOCTOR_REPORT_VITAL_TYPES, + DOCTOR_REPORT_TYPE_LABEL_KEYS, + DOCTOR_REPORT_TYPE_UNIT_KEYS, } from "../doctor-report-pdf-core"; +import { measurementTypeEnum } from "../validations/measurement"; import type { DoctorReportData } from "../doctor-report-data"; import { getServerTranslator } from "../i18n/server-translator"; @@ -161,3 +165,56 @@ describe("buildDoctorReportPdfDocument", () => { expect(doc.getNumberOfPages()).toBeGreaterThanOrEqual(1); }); }); + +// Audit-2026-05-07 / phase P0 / closes audit C-9: server-side PDF used a +// stale type map and silently dropped body composition while the browser +// PDF rendered them. These tests pin the contract. +describe("doctor-report-pdf-core type-map coverage", () => { + it("exposes body composition (TOTAL_BODY_WATER, BONE_MASS) as vital types", () => { + expect(DOCTOR_REPORT_VITAL_TYPES).toContain("TOTAL_BODY_WATER"); + expect(DOCTOR_REPORT_VITAL_TYPES).toContain("BONE_MASS"); + }); + + it("provides a label key + unit for every vital type", () => { + for (const type of DOCTOR_REPORT_VITAL_TYPES) { + expect(DOCTOR_REPORT_TYPE_LABEL_KEYS[type]).toBeTruthy(); + const unit = DOCTOR_REPORT_TYPE_UNIT_KEYS[type]; + expect( + unit === null || (typeof unit === "string" && unit.length > 0), + ).toBe(true); + } + }); + + it("vital types are a subset of the canonical measurement enum", () => { + const enumSet = new Set(measurementTypeEnum.options); + for (const type of DOCTOR_REPORT_VITAL_TYPES) { + expect(enumSet.has(type), `${type} not in measurementTypeEnum`).toBe( + true, + ); + } + }); + + it("renders body composition rows when stats are supplied", () => { + const data = makeData({ + stats: { + WEIGHT: { avg: 80, min: 79, max: 81, count: 5, latest: 80 }, + TOTAL_BODY_WATER: { + avg: 42, + min: 40, + max: 44, + count: 5, + latest: 42, + }, + BONE_MASS: { avg: 3.2, min: 3.1, max: 3.3, count: 5, latest: 3.2 }, + }, + }); + const bytes = renderDoctorReportPdfBytes(data, { + t: getServerTranslator("de").t, + locale: "de", + now: FIXED_NOW, + }); + expect(bytes.byteLength).toBeGreaterThan(1024); + const header = String.fromCharCode(...bytes.slice(0, 5)); + expect(header).toBe("%PDF-"); + }); +}); diff --git a/src/lib/analytics/__tests__/effective-range.test.ts b/src/lib/analytics/__tests__/effective-range.test.ts index a5959f0..4416480 100644 --- a/src/lib/analytics/__tests__/effective-range.test.ts +++ b/src/lib/analytics/__tests__/effective-range.test.ts @@ -88,6 +88,52 @@ describe("getAllEffectiveRanges", () => { const keys = Object.keys(ranges); expect(keys).toContain("WEIGHT"); expect(keys).toContain("BLOOD_GLUCOSE_FASTING"); - expect(keys.length).toBeGreaterThanOrEqual(11); + expect(keys).toContain("TOTAL_BODY_WATER"); + expect(keys).toContain("BONE_MASS"); + expect(keys.length).toBeGreaterThanOrEqual(13); + }); +}); + +// Audit-2026-05-07 / phase P0 / closes audit C-15: TOTAL_BODY_WATER and +// BONE_MASS lacked threshold definitions. Severity logic returned `nominal` +// for any value, so users would see "all healthy" regardless of input. +describe("body composition thresholds", () => { + it("TOTAL_BODY_WATER has a sensible non-null green band", () => { + const result = getEffectiveRange("TOTAL_BODY_WATER", baseProfile, null); + expect(result.range).not.toBeNull(); + expect(result.range!.greenMin).toBeGreaterThan(0); + expect(result.range!.greenMax).toBeGreaterThan(result.range!.greenMin); + expect(result.range!.orangeMin).toBeLessThan(result.range!.greenMin); + expect(result.range!.orangeMax).toBeGreaterThan(result.range!.greenMax); + }); + + it("BONE_MASS has a sensible non-null green band", () => { + const result = getEffectiveRange("BONE_MASS", baseProfile, null); + expect(result.range).not.toBeNull(); + expect(result.range!.greenMin).toBeGreaterThan(0); + expect(result.range!.greenMax).toBeGreaterThan(result.range!.greenMin); + }); + + it("respects user override for body water", () => { + const overrides: ThresholdOverridesJson = { + TOTAL_BODY_WATER: { min: 30, max: 45 }, + }; + const result = getEffectiveRange( + "TOTAL_BODY_WATER", + baseProfile, + overrides, + ); + expect(result.isOverride).toBe(true); + expect(result.range!.greenMin).toBe(30); + expect(result.range!.greenMax).toBe(45); + }); + + it("METRIC_BOUNDS for body composition match VALUE_RANGES in validation", () => { + expect(METRIC_BOUNDS.TOTAL_BODY_WATER).toEqual({ + min: 5, + max: 100, + unit: "kg", + }); + expect(METRIC_BOUNDS.BONE_MASS).toEqual({ min: 0.5, max: 8, unit: "kg" }); }); }); diff --git a/src/lib/analytics/effective-range.ts b/src/lib/analytics/effective-range.ts index fe2c0f7..d03c354 100644 --- a/src/lib/analytics/effective-range.ts +++ b/src/lib/analytics/effective-range.ts @@ -37,7 +37,9 @@ export type ThresholdMetric = | "BLOOD_GLUCOSE_FASTING" | "BLOOD_GLUCOSE_POSTPRANDIAL" | "BLOOD_GLUCOSE_RANDOM" - | "BLOOD_GLUCOSE_BEDTIME"; + | "BLOOD_GLUCOSE_BEDTIME" + | "TOTAL_BODY_WATER" + | "BONE_MASS"; export interface ThresholdOverride { min: number; @@ -79,6 +81,10 @@ export const METRIC_BOUNDS: Record< BLOOD_GLUCOSE_POSTPRANDIAL: { min: 40, max: 500, unit: "mg/dL" }, BLOOD_GLUCOSE_RANDOM: { min: 40, max: 500, unit: "mg/dL" }, BLOOD_GLUCOSE_BEDTIME: { min: 40, max: 400, unit: "mg/dL" }, + // Body composition (Withings type 77 / 88, stored canonically in kg). + // Bounds match VALUE_RANGES in src/lib/validations/measurement.ts. + TOTAL_BODY_WATER: { min: 5, max: 100, unit: "kg" }, + BONE_MASS: { min: 0.5, max: 8, unit: "kg" }, }; /** @@ -177,6 +183,16 @@ function defaultRange( GLUCOSE_DEFAULTS.BEDTIME, GLUCOSE_DEFAULTS.BEDTIME.max + 30, ); + case "TOTAL_BODY_WATER": + // Adult typical ~50% body weight as water (kg). Without weight context + // the gender-neutral band is wide; users can tighten it via override. + // Reference: ESPEN Body Composition Guidance 2017. + return { greenMin: 28, greenMax: 50, orangeMin: 22, orangeMax: 55 }; + case "BONE_MASS": + // DEXA-equivalent body bone mineral content. Adult typical 2.5–4.0 kg + // (women slightly lower than men). Generous orange band before + // surfacing as a concern. + return { greenMin: 2.0, greenMax: 4.0, orangeMin: 1.5, orangeMax: 5.0 }; } } diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index 29e652d..faa43bb 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -28,7 +28,12 @@ export interface DoctorReportRenderOptions { now?: Date; } -const TYPE_LABEL_KEYS: Record = { +/** + * Per-vital label keys. Exported for coverage tests (issue #109 / phase P0) + * so a future enum addition is caught by a unit test rather than reaching + * production as a raw enum string in the PDF. + */ +export const DOCTOR_REPORT_TYPE_LABEL_KEYS: Record = { WEIGHT: "doctorReport.typeWeight", BLOOD_PRESSURE_SYS: "doctorReport.typeBpSys", BLOOD_PRESSURE_DIA: "doctorReport.typeBpDia", @@ -36,9 +41,11 @@ const TYPE_LABEL_KEYS: Record = { BODY_FAT: "doctorReport.typeBodyFat", SLEEP_DURATION: "doctorReport.typeSleep", ACTIVITY_STEPS: "doctorReport.typeSteps", + TOTAL_BODY_WATER: "doctorReport.typeTotalBodyWater", + BONE_MASS: "doctorReport.typeBoneMass", }; -const TYPE_UNIT_KEYS: Record = { +export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record = { WEIGHT: "kg", BLOOD_PRESSURE_SYS: "mmHg", BLOOD_PRESSURE_DIA: "mmHg", @@ -46,8 +53,27 @@ const TYPE_UNIT_KEYS: Record = { BODY_FAT: "%", SLEEP_DURATION: "h", ACTIVITY_STEPS: null, // translated unit + TOTAL_BODY_WATER: "kg", + BONE_MASS: "kg", }; +/** + * Vital types rendered in the main vitals table. Body composition + * (TOTAL_BODY_WATER, BONE_MASS) ships alongside body fat — Withings + * smart scales report all three together. Glucose ships separately + * via per-context `glucoseStats`. Sleep + activity are intentionally + * excluded from a clinical-focused report. + */ +export const DOCTOR_REPORT_VITAL_TYPES = [ + "WEIGHT", + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "PULSE", + "BODY_FAT", + "TOTAL_BODY_WATER", + "BONE_MASS", +] as const; + const MOOD_LABEL_KEYS: Record = { 1: "doctorReport.moodAwful", 2: "doctorReport.moodBad", @@ -113,7 +139,7 @@ export function buildDoctorReportPdfDocument( const fmtDate = (iso: string) => formatters.date(iso); const unitFor = (type: string): string => { - const staticUnit = TYPE_UNIT_KEYS[type]; + const staticUnit = DOCTOR_REPORT_TYPE_UNIT_KEYS[type]; if (staticUnit === null && type === "ACTIVITY_STEPS") { return t("doctorReport.unitSteps"); } @@ -187,20 +213,13 @@ export function buildDoctorReportPdfDocument( y += 6; const vitalRows: string[][] = []; - const vitalTypes = [ - "WEIGHT", - "BLOOD_PRESSURE_SYS", - "BLOOD_PRESSURE_DIA", - "PULSE", - "BODY_FAT", - ]; - - for (const type of vitalTypes) { + + for (const type of DOCTOR_REPORT_VITAL_TYPES) { const s = data.stats[type]; if (!s) continue; const unit = unitFor(type); vitalRows.push([ - t(TYPE_LABEL_KEYS[type] ?? ""), + t(DOCTOR_REPORT_TYPE_LABEL_KEYS[type] ?? ""), `${num(s.latest)} ${unit}`.trim(), `${num(s.avg)} ${unit}`.trim(), num(s.min), From f9174ce341423561660ae820c6fc2e6f4aecac15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 7 May 2026 22:38:47 +0200 Subject: [PATCH 02/13] docs: align OpenAPI spec + migration comment with body-composition reality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OpenAPI MeasurementType enum: add TOTAL_BODY_WATER + BONE_MASS (was at v1.3.0 baseline; spec drifted past the v1.3 server release) - Bump openapi version 1.3.0 → 1.3.2 to match package.json - Migration 0022 comment: TOTAL_BODY_WATER stores kg (water content), not percent of body weight. Validators (VALUE_RANGES) and the Withings client (type 77 → kg) treat it as kg already; the comment was the lone outlier and would mislead the next reviewer. Audit-2026-05-07 / phase P1a — closes audit M-B1 (partial; OpenAPI enum) and M-B2 (migration unit comment). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/api/openapi.yaml | 10 ++++++---- .../0022_body_composition_metrics/migration.sql | 8 +++++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b882b8b..1fe3eee 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2,12 +2,12 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.3.0 + version: 1.3.2 description: | REST API for HealthLog — a personal health-tracking PWA covering weight, - blood pressure, pulse, mood, medication compliance, blood glucose, and - sleep. The same API powers the Next.js web client and the native iOS - client. + blood pressure, pulse, mood, medication compliance, blood glucose, body + composition (total body water + bone mass), and sleep. The same API + powers the Next.js web client and the native iOS client. ## Authentication @@ -3442,6 +3442,8 @@ components: - SLEEP_DURATION - ACTIVITY_STEPS - BLOOD_GLUCOSE + - TOTAL_BODY_WATER + - BONE_MASS MeasurementSource: type: string diff --git a/prisma/migrations/0022_body_composition_metrics/migration.sql b/prisma/migrations/0022_body_composition_metrics/migration.sql index 26895ab..62c5314 100644 --- a/prisma/migrations/0022_body_composition_metrics/migration.sql +++ b/prisma/migrations/0022_body_composition_metrics/migration.sql @@ -4,9 +4,11 @@ -- Both are reported by Withings body-composition scales and can also be ingested -- via the API by external pipelines (e.g. n8n + Health Connect). -- --- Storage units (canonical): --- TOTAL_BODY_WATER: percent of body weight (%) --- BONE_MASS: kilograms (kg) +-- Storage units (canonical, both kg — matches VALUE_RANGES in +-- src/lib/validations/measurement.ts and Withings client type-77/type-88 +-- mapping). Adult plausibility ranges: water 30–55 kg, bone 2.5–4.5 kg. +-- TOTAL_BODY_WATER: kilograms (kg) — total body water content +-- BONE_MASS: kilograms (kg) — DEXA-equivalent bone mineral content -- -- Both are unconditional value types — no companion enum (unlike BLOOD_GLUCOSE, -- which carries a glucose_context). The migration is purely additive. From fdb3c3023fc0aaad1b8627bbda6c4df13ab033fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 7 May 2026 22:46:08 +0200 Subject: [PATCH 03/13] fix(security): close 3 audit CRITICALs (Withings header / idempotency Bearer / GlitchTip URL strip) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C-3 (Withings webhook secret in URL): Now reads X-Withings-Webhook-Secret header in preference; URL ?secret=… fallback retained for legacy Withings configurations and emits a Wide Event warning so operators can spot still-using-the-old-flow integrators. The query-string secret was leaking into Coolify access logs and any GlitchTip event whose request.url got captured. C-4 (idempotency dead for Bearer-token clients): The default userIdResolver only consulted getSession(). Bearer-authed clients (the iOS / n8n / headless-API fleet that PR #117 was built for) fell through, so every retry created duplicate measurements. The default now tries cookie session first, then Bearer token via hashToken+apiToken.findUnique with revoked/expiry checks. All three existing call sites (measurements, mood-entries, medications/intake) get the fix automatically. H-B7 (request.url with secrets shipped to GlitchTip): reportToGlitchtip now strips the query string before forwarding the URL, so Withings legacy ?secret=… and any OAuth ?code=… that errors during the callback don't end up in someone else's incident UI. Audit-2026-05-07 / phase P2. Tests: 296 → 301 (5 new in idempotency.test for the Bearer fallback + revoked/expired/null cases). Deferred to a follow-up (needs DB migration / lock primitive): C-2 moodLog webhook → AES-GCM at rest + HMAC-over-body C-5 idempotency TOCTOU advisory_lock or in-flight sentinel row Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/withings/webhook/route.ts | 22 ++++++- src/lib/__tests__/idempotency.test.ts | 82 ++++++++++++++++++++++++++- src/lib/api-handler.ts | 16 +++++- src/lib/idempotency.ts | 48 +++++++++++++--- 4 files changed, 158 insertions(+), 10 deletions(-) diff --git a/src/app/api/withings/webhook/route.ts b/src/app/api/withings/webhook/route.ts index 2980b5d..15b5dcb 100644 --- a/src/app/api/withings/webhook/route.ts +++ b/src/app/api/withings/webhook/route.ts @@ -7,14 +7,34 @@ import { NextRequest, NextResponse } from "next/server"; import { apiHandler } from "@/lib/api-handler"; import { annotate, getEvent } from "@/lib/logging/context"; +/** + * Audit C-3 / phase P2: Withings legacy callback URLs include the shared + * secret as a `?secret=…` query param, which leaks into reverse-proxy + * access logs and any error-tracking pipeline that captures `request.url` + * (see `reportToGlitchtip`). New deployments should configure Withings to + * send the secret via the `X-Withings-Webhook-Secret` header instead. + * + * The query-param fallback stays for backwards compatibility with existing + * Withings configurations; remove once all integrators have migrated. + */ function hasValidWebhookSecret(request: NextRequest): boolean { const expected = process.env.WITHINGS_WEBHOOK_SECRET; if (!expected) { getEvent()?.addWarning("WITHINGS_WEBHOOK_SECRET not configured"); return false; } - const received = request.nextUrl.searchParams.get("secret"); + + const fromHeader = request.headers.get("x-withings-webhook-secret"); + const fromQuery = request.nextUrl.searchParams.get("secret"); + const received = fromHeader ?? fromQuery; if (!received) return false; + + if (!fromHeader && fromQuery) { + getEvent()?.addWarning( + "withings webhook secret received via legacy URL query — migrate to X-Withings-Webhook-Secret header", + ); + } + const a = Buffer.from(expected, "utf8"); const b = Buffer.from(received, "utf8"); if (a.length !== b.length) return false; diff --git a/src/lib/__tests__/idempotency.test.ts b/src/lib/__tests__/idempotency.test.ts index 72af154..c90f3bb 100644 --- a/src/lib/__tests__/idempotency.test.ts +++ b/src/lib/__tests__/idempotency.test.ts @@ -8,6 +8,9 @@ vi.mock("@/lib/db", () => ({ create: vi.fn(), delete: vi.fn(), }, + apiToken: { + findUnique: vi.fn(), + }, }, })); @@ -19,9 +22,18 @@ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn(), })); -import { withIdempotency } from "../idempotency"; +vi.mock("next/headers", () => ({ + headers: vi.fn(), +})); + +vi.mock("@/lib/auth/hmac", () => ({ + hashToken: vi.fn((raw: string) => `hashed:${raw}`), +})); + +import { withIdempotency, defaultUserIdResolver } from "../idempotency"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; +import { headers } from "next/headers"; function makeRequest( method: string, @@ -167,6 +179,11 @@ describe("withIdempotency", () => { it("skips caching when the default resolver finds no session", async () => { vi.mocked(getSession).mockResolvedValue(null); + vi.mocked(headers).mockResolvedValue({ + get: vi.fn().mockReturnValue(null), + } as unknown as ReturnType extends Promise + ? T + : never); const handler = vi.fn(async () => NextResponse.json({ data: "ok", error: null }, { status: 201 }), ); @@ -177,3 +194,66 @@ describe("withIdempotency", () => { expect(prisma.idempotencyKey.create).not.toHaveBeenCalled(); }); }); + +// Audit C-4 / phase P2: defaultUserIdResolver must support both cookie +// sessions AND Bearer tokens. Without the Bearer fallback, idempotency +// silently turned off for the iOS / external-ingest paths it was built for. +describe("defaultUserIdResolver (audit C-4)", () => { + function mockHeader(value: string | null) { + vi.mocked(headers).mockResolvedValue({ + get: vi.fn().mockImplementation((name: string) => + name.toLowerCase() === "authorization" ? value : null, + ), + } as unknown as ReturnType extends Promise + ? T + : never); + } + + it("returns the session user id when a cookie session is present", async () => { + vi.mocked(getSession).mockResolvedValue({ + session: { id: "s-1" }, + user: { id: "u-cookie", role: "USER" }, + } as Awaited>); + mockHeader(null); + expect(await defaultUserIdResolver()).toBe("u-cookie"); + }); + + it("falls back to Bearer-token resolution when no cookie session", async () => { + vi.mocked(getSession).mockResolvedValue(null); + mockHeader("Bearer hlk_abcdef"); + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + userId: "u-bearer", + revoked: false, + expiresAt: null, + } as never); + expect(await defaultUserIdResolver()).toBe("u-bearer"); + }); + + it("rejects revoked Bearer tokens", async () => { + vi.mocked(getSession).mockResolvedValue(null); + mockHeader("Bearer hlk_abcdef"); + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + userId: "u-bearer", + revoked: true, + expiresAt: null, + } as never); + expect(await defaultUserIdResolver()).toBeNull(); + }); + + it("rejects expired Bearer tokens", async () => { + vi.mocked(getSession).mockResolvedValue(null); + mockHeader("Bearer hlk_abcdef"); + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + userId: "u-bearer", + revoked: false, + expiresAt: new Date(Date.now() - 1000), + } as never); + expect(await defaultUserIdResolver()).toBeNull(); + }); + + it("returns null when no auth method is provided", async () => { + vi.mocked(getSession).mockResolvedValue(null); + mockHeader(null); + expect(await defaultUserIdResolver()).toBeNull(); + }); +}); diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index febb0ff..0a68f60 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -316,6 +316,20 @@ async function reportToGlitchtip( // Skip expected errors from bot scanners (malformed JSON bodies) if (err instanceof SyntaxError) return; + // Audit H-B7 / phase P2: strip the query string before forwarding to + // GlitchTip. Withings legacy callbacks ship `?secret=…` (see C-3) and + // OAuth callbacks ship `?code=…&state=…`; if any of those error we + // don't want their secrets in someone's incident UI. + let scrubbedUrl = request.url; + try { + const u = new URL(request.url); + u.search = ""; + scrubbedUrl = u.toString(); + } catch { + // Invalid URL — fall through with the raw value (only happens in + // degenerate test fixtures). + } + await sendGlitchtipEvent({ dsn: settings.glitchtipDsn, input: { @@ -324,7 +338,7 @@ async function reportToGlitchtip( level: "error", type: err.name || "Error", stack: err.stack, - url: request.url, + url: scrubbedUrl, sourceTag: "healthlog-api-handler", requestId: evt.getRequestId(), }, diff --git a/src/lib/idempotency.ts b/src/lib/idempotency.ts index 4244489..b4bbbb9 100644 --- a/src/lib/idempotency.ts +++ b/src/lib/idempotency.ts @@ -8,9 +8,11 @@ * status inside the JSON if needed) — no second side-effect. */ import type { NextRequest } from "next/server"; +import { headers } from "next/headers"; import { NextResponse } from "next/server"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; +import { hashToken } from "@/lib/auth/hmac"; import { annotate } from "@/lib/logging/context"; const TTL_MS = 24 * 60 * 60 * 1000; @@ -111,15 +113,50 @@ async function persistCached( }); } +/** + * Default resolver: cookie session first, then Bearer token. The Bearer + * fallback is what makes idempotency actually fire for native iOS / n8n + * /external clients — without it, every Bearer-authed retry was running + * the handler again and creating duplicate measurements (audit C-4). + * + * Exported for unit testing; production callers should let + * `withIdempotency()` pick this up automatically via its default arg. + */ +export async function defaultUserIdResolver(): Promise { + const session = await getSession().catch(() => null); + if (session) return session.user.id; + + let authHeader: string | null = null; + try { + const headerList = await headers(); + authHeader = headerList.get("authorization"); + } catch { + authHeader = null; + } + if (!authHeader?.startsWith("Bearer ")) return null; + + const tokenHashValue = hashToken(authHeader.slice(7)); + const apiToken = await prisma.apiToken + .findUnique({ + where: { tokenHash: tokenHashValue }, + select: { userId: true, revoked: true, expiresAt: true }, + }) + .catch(() => null); + + if (!apiToken || apiToken.revoked) return null; + if (apiToken.expiresAt && apiToken.expiresAt <= new Date()) return null; + return apiToken.userId; +} + /** * Wrap a write handler so a repeat call with the same `Idempotency-Key` * (and same userId/method/path) returns the originally cached response. * * The wrapped handler is responsible for authentication itself — this * helper only triggers for methods in {POST, PUT, PATCH, DELETE} and only - * once `userIdResolver` returns a non-null value. By default the userId - * is read from the cookie session; pass a custom resolver for routes - * that authenticate via Bearer token or other means. + * once `userIdResolver` returns a non-null value. The default resolver + * supports both cookie sessions and Bearer-token clients; pass a custom + * resolver only for routes that authenticate via something exotic. * * No-op when the header is missing or the value is malformed. */ @@ -127,10 +164,7 @@ export function withIdempotency< Args extends [Request | NextRequest, ...unknown[]], >( handler: (...args: Args) => Promise, - userIdResolver: (...args: Args) => Promise = async () => { - const session = await getSession().catch(() => null); - return session?.user.id ?? null; - }, + userIdResolver: (...args: Args) => Promise = defaultUserIdResolver, ): (...args: Args) => Promise { return async (...args: Args): Promise => { const request = args[0]; From c2fe48177983eae43a2e5606a3b4cd686d227570 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 7 May 2026 23:46:17 +0200 Subject: [PATCH 04/13] =?UTF-8?q?feat(measurements):=20add=20OXYGEN=5FSATU?= =?UTF-8?q?RATION=20(SpO2)=20as=20first-class=20measurement=20type=20?= =?UTF-8?q?=E2=80=94=20v1.3.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the OxygenSaturation feature request from #109. - Migration 0024 extends MeasurementType enum (additive, IF NOT EXISTS) - Zod validators: plausibility 50–100% (below 50 is incompatible with sustained life; above 100 is physically impossible) - effective-range thresholds: green 95–100, orange 92–94, red <92. Sources: BTS Guideline 2017 (target 94–98%), ATS clinical practice (≥95% at rest is healthy), AHA/clinical literature (<92% = call provider, <88% = ER). Lower-only concern — upper orange wing collapses to greenMax since saturation cannot exceed 100%. - Personalisation: existing thresholds-override UI lets COPD / chronic-respiratory users with a doctor-set 88–92 baseline tune the bands. METRIC_ORDER + METRIC_LABEL_KEYS extended in thresholds-section.tsx; the exhaustive `Record` forces TS to catch any missed surface. - Withings ScanWatch type 54 mapping (best-effort — only fires for pulse-ox-capable devices). - Wired through measurement-form, measurement-list-meta (icon: Wind), health-chart, both doctor-report-pdf renderers (core + browser-side near-duplicate), OpenAPI spec (version 1.3.0 → 1.3.3), and i18n in 4 sections × 2 locales. - iOS DTO already declared `OXYGEN_SATURATION` — server enum addition closes the long-standing drift flagged in audit V2. - 4 new tests: numeric green band, upper-wing collapse, user-override (COPD baseline), METRIC_BOUNDS shape. Tests: 301 → 305. Typecheck: clean. Audit-2026-05-07 / v1.3.3 / closes #109 SpO2 feature request. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 55 +++++++++++++++++++ docs/api/openapi.yaml | 8 ++- messages/de.json | 4 ++ messages/en.json | 4 ++ package.json | 2 +- .../0024_oxygen_saturation/migration.sql | 12 ++++ prisma/schema.prisma | 1 + src/components/charts/health-chart.tsx | 1 + .../measurements/measurement-form.tsx | 6 ++ .../measurements/measurement-list-meta.ts | 4 ++ .../settings/thresholds-section.tsx | 2 + .../__tests__/effective-range.test.ts | 44 ++++++++++++++- src/lib/analytics/effective-range.ts | 17 +++++- src/lib/doctor-report-pdf-core.ts | 9 ++- src/lib/doctor-report-pdf.ts | 3 + src/lib/validations/measurement.ts | 7 +++ src/lib/withings/client.ts | 1 + 17 files changed, 172 insertions(+), 8 deletions(-) create mode 100644 prisma/migrations/0024_oxygen_saturation/migration.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e68b1d..5d4990e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,60 @@ # Changelog +## [1.3.3] — 2026-05-07 + +### Added + +- **Pulse oximetry as a first-class measurement type (`OXYGEN_SATURATION`).** + Closes the SpO2 part of #109. Migration `0024_oxygen_saturation` extends + the `MeasurementType` enum. Plausibility range 50–100% (below 50% is + incompatible with sustained life and almost certainly a faulty sensor; + upper bound 100% is physical). Default severity bands follow BTS Guideline + 2017 + ATS clinical practice: green 95–100%, orange 92–94%, red <92% — + lower-only concern (the upper orange wing collapses onto greenMax since + saturation cannot physically exceed 100%). COPD / chronic-respiratory + users with a doctor-set baseline of 88–92% can personalize via the + threshold-override UI. Wired through Withings (ScanWatch type 54), + measurement form, list, charts, doctor PDF, OpenAPI spec, and i18n (DE + + EN). iOS DTO already declared `OXYGEN_SATURATION` from a prior commit; + the server enum addition closes the long-standing drift. +- **Body composition surfaces (TOTAL_BODY_WATER, BONE_MASS, BLOOD_GLUCOSE) + in the measurements list filter, badge, mobile icon, edit dialog, and + server-rendered doctor-report PDF** — closes the UI side of #109. Root + cause was three local maps in `measurement-list.tsx` that drifted from + the v1.3 server enum; extracted to `measurement-list-meta.ts` with + fail-fast coverage tests so future enum additions are caught at build + time. Server-side PDF used a separately-drifted type map vs. the + browser-side renderer; both are now in sync. +- **Effective-range thresholds for `TOTAL_BODY_WATER` and `BONE_MASS`** — + severity logic was returning `nominal` for any value because no defaults + existed. + +### Changed + +- **OpenAPI `MeasurementType` enum extended + spec version bumped 1.3.0 → + 1.3.3** to match the actual app. Spec was lagging by two minor releases. +- **Withings webhook secret now reads from `X-Withings-Webhook-Secret` + header** in preference to the legacy `?secret=…` URL query parameter. + Closes the URL-leak-via-access-logs vector flagged in audit C-3. Legacy + query-param path is retained for backwards compatibility and emits a + Wide Event warning so operators can spot still-using-the-old-flow + integrators. Plan: remove the query fallback in 1.4.x once warnings drain. +- **Idempotency `defaultUserIdResolver` now supports Bearer tokens.** + Cookie sessions tried first, then Bearer-token via `hashToken` lookup. + Without the Bearer fallback, every iOS / external-ingest retry was + hitting the handler again and creating duplicate measurements (audit + C-4 — the exact use case `withIdempotency` was built for). +- **GlitchTip URL stripping** — `reportToGlitchtip` now strips the URL + query string before forwarding so Withings legacy `?secret=…` and OAuth + `?code=…` callbacks cannot leak via the error tracker (audit H-B7). + +### Fixed + +- **Migration `0022_body_composition_metrics` unit comment lied** — + claimed `TOTAL_BODY_WATER: percent of body weight (%)` while every other + surface (validators, Withings client, doctor PDF) treated it as `kg`. + Comment corrected to match reality. + ## [1.3.2] — 2026-04-28 ### Fixed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1fe3eee..15644fb 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2,12 +2,13 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.3.2 + version: 1.3.3 description: | REST API for HealthLog — a personal health-tracking PWA covering weight, blood pressure, pulse, mood, medication compliance, blood glucose, body - composition (total body water + bone mass), and sleep. The same API - powers the Next.js web client and the native iOS client. + composition (total body water + bone mass), pulse oximetry (SpO2), and + sleep. The same API powers the Next.js web client and the native iOS + client. ## Authentication @@ -3444,6 +3445,7 @@ components: - BLOOD_GLUCOSE - TOTAL_BODY_WATER - BONE_MASS + - OXYGEN_SATURATION MeasurementSource: type: string diff --git a/messages/de.json b/messages/de.json index f2121e5..9fd019d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -91,6 +91,7 @@ "typeSteps": "Aktivität (Schritte)", "typeTotalBodyWater": "Gesamtkörperwasser", "typeBoneMass": "Knochenmasse", + "typeOxygenSaturation": "Sauerstoffsättigung", "unitSteps": "Schritte", "bmiUnderweight": "Untergewicht", "bmiNormal": "Normalgewicht", @@ -249,6 +250,7 @@ "typeBloodGlucose": "Blutzucker", "typeTotalBodyWater": "Gesamtkörperwasser", "typeBoneMass": "Knochenmasse", + "typeOxygenSaturation": "Sauerstoffsättigung", "glucoseContext": "Kontext", "glucoseContextFasting": "Nüchtern (≥8 h ohne Nahrung)", "glucoseContextPostprandial": "Postprandial (2 h nach dem Essen)", @@ -540,6 +542,7 @@ "diastolic": "Diastolisch", "bodyWater": "Körperwasser", "boneMass": "Knochenmasse", + "spo2": "SpO₂", "movingAverage7d": "7T-Schnitt", "avg7dShort": "7T", "avg30dShort": "30T", @@ -764,6 +767,7 @@ "metricGlucoseBedtime": "Glukose — vor dem Schlafen", "metricBodyWater": "Körperwasser", "metricBoneMass": "Knochenmasse", + "metricOxygenSaturation": "Sauerstoffsättigung (SpO₂)", "unsetExplanation": "Kein eigener Wert — berechneter Default aktiv.", "loadError": "Zielwerte konnten nicht geladen werden" }, diff --git a/messages/en.json b/messages/en.json index 7acf8de..254524e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -91,6 +91,7 @@ "typeSteps": "Activity (steps)", "typeTotalBodyWater": "Total body water", "typeBoneMass": "Bone mass", + "typeOxygenSaturation": "Oxygen saturation", "unitSteps": "steps", "bmiUnderweight": "Underweight", "bmiNormal": "Normal weight", @@ -249,6 +250,7 @@ "typeBloodGlucose": "Blood glucose", "typeTotalBodyWater": "Total body water", "typeBoneMass": "Bone mass", + "typeOxygenSaturation": "Oxygen saturation", "glucoseContext": "Context", "glucoseContextFasting": "Fasting (≥8h without food)", "glucoseContextPostprandial": "Post-meal (2h after eating)", @@ -540,6 +542,7 @@ "diastolic": "Diastolic", "bodyWater": "Body water", "boneMass": "Bone mass", + "spo2": "SpO₂", "movingAverage7d": "7d avg", "avg7dShort": "7d", "avg30dShort": "30d", @@ -764,6 +767,7 @@ "metricGlucoseBedtime": "Glucose — bedtime", "metricBodyWater": "Body water", "metricBoneMass": "Bone mass", + "metricOxygenSaturation": "Oxygen saturation (SpO₂)", "unsetExplanation": "No override — using computed default.", "loadError": "Could not load thresholds" }, diff --git a/package.json b/package.json index c966d49..3bf8328 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.3.2", + "version": "1.3.3", "private": true, "packageManager": "pnpm@10.31.0", "scripts": { diff --git a/prisma/migrations/0024_oxygen_saturation/migration.sql b/prisma/migrations/0024_oxygen_saturation/migration.sql new file mode 100644 index 0000000..f9fb73f --- /dev/null +++ b/prisma/migrations/0024_oxygen_saturation/migration.sql @@ -0,0 +1,12 @@ +-- v1.3.3: Add OXYGEN_SATURATION (SpO2) measurement type +-- +-- Closes #109 feature request. Single-value type (no per-context split like +-- BLOOD_GLUCOSE — context goes into the free-text `notes` field). +-- +-- Storage unit: percent (0..100). Plausibility range 50..100 in +-- src/lib/validations/measurement.ts (anything below 50% is incompatible with +-- sustained life and almost certainly a faulty sensor). +-- +-- Withings devices that support SpO2 (e.g. ScanWatch) report measure type 54. +-- See src/lib/withings/client.ts MEASURE_TYPE_MAP. +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'OXYGEN_SATURATION'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 52213ee..7f14e50 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -162,6 +162,7 @@ enum MeasurementType { BLOOD_GLUCOSE // Canonical storage: mg/dL. Display unit (mg/dL or mmol/L) is user preference. TOTAL_BODY_WATER // Canonical storage: kilograms (kg of water). Matches Withings' hydration measure (type 77) and Health Connect's TotalBodyWaterRecord. Display can compute % from same-day weight if desired. BONE_MASS // Canonical storage: kilograms (kg). Matches Withings' bone mass measure (type 88). + OXYGEN_SATURATION // Canonical storage: percent (0..100). Withings ScanWatch reports type 54. Health Connect's OxygenSaturationRecord uses Percentage (already 0..100). HealthKit uses HKQuantityTypeIdentifierOxygenSaturation as a fraction (0..1) — iOS DTO must multiply by 100 before submitting. @@map("measurement_type") } diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 687df99..d913f6d 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -108,6 +108,7 @@ const BASE_TYPE_LABEL_KEYS: Record = { BLOOD_GLUCOSE: "measurements.typeBloodGlucose", TOTAL_BODY_WATER: "charts.bodyWater", BONE_MASS: "charts.boneMass", + OXYGEN_SATURATION: "charts.spo2", }; function getTypeLabel( diff --git a/src/components/measurements/measurement-form.tsx b/src/components/measurements/measurement-form.tsx index 38e6f82..4ca3381 100644 --- a/src/components/measurements/measurement-form.tsx +++ b/src/components/measurements/measurement-form.tsx @@ -79,6 +79,12 @@ const MEASUREMENT_TYPES = [ unit: "kg", placeholder: "3.2", }, + { + value: "OXYGEN_SATURATION", + labelKey: "measurements.typeOxygenSaturation", + unit: "%", + placeholder: "98", + }, ] as const; const GLUCOSE_CONTEXTS = [ diff --git a/src/components/measurements/measurement-list-meta.ts b/src/components/measurements/measurement-list-meta.ts index 9f6b82a..b61dafc 100644 --- a/src/components/measurements/measurement-list-meta.ts +++ b/src/components/measurements/measurement-list-meta.ts @@ -23,6 +23,7 @@ import { Moon, Footprints, Bone, + Wind, type LucideIcon, } from "lucide-react"; @@ -37,6 +38,7 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record = { BLOOD_GLUCOSE: "measurements.typeBloodGlucose", TOTAL_BODY_WATER: "measurements.typeTotalBodyWater", BONE_MASS: "measurements.typeBoneMass", + OXYGEN_SATURATION: "measurements.typeOxygenSaturation", }; export const MEASUREMENT_TYPE_ICONS: Record = { @@ -50,6 +52,7 @@ export const MEASUREMENT_TYPE_ICONS: Record = { BLOOD_GLUCOSE: Droplet, TOTAL_BODY_WATER: Droplet, BONE_MASS: Bone, + OXYGEN_SATURATION: Wind, }; export const MEASUREMENT_TYPE_COLORS: Record = { @@ -63,4 +66,5 @@ export const MEASUREMENT_TYPE_COLORS: Record = { BLOOD_GLUCOSE: "bg-chart-3/20 text-chart-3", TOTAL_BODY_WATER: "bg-chart-2/20 text-chart-2", BONE_MASS: "bg-chart-4/20 text-chart-4", + OXYGEN_SATURATION: "bg-chart-5/20 text-chart-5", }; diff --git a/src/components/settings/thresholds-section.tsx b/src/components/settings/thresholds-section.tsx index 642ec98..3b1959a 100644 --- a/src/components/settings/thresholds-section.tsx +++ b/src/components/settings/thresholds-section.tsx @@ -35,6 +35,7 @@ const METRIC_ORDER: ThresholdMetric[] = [ "BLOOD_GLUCOSE_POSTPRANDIAL", "BLOOD_GLUCOSE_RANDOM", "BLOOD_GLUCOSE_BEDTIME", + "OXYGEN_SATURATION", ]; const METRIC_LABEL_KEYS: Record = { @@ -51,6 +52,7 @@ const METRIC_LABEL_KEYS: Record = { BLOOD_GLUCOSE_BEDTIME: "thresholds.metricGlucoseBedtime", TOTAL_BODY_WATER: "thresholds.metricBodyWater", BONE_MASS: "thresholds.metricBoneMass", + OXYGEN_SATURATION: "thresholds.metricOxygenSaturation", }; export function ThresholdsSection({ id }: { id: string }) { diff --git a/src/lib/analytics/__tests__/effective-range.test.ts b/src/lib/analytics/__tests__/effective-range.test.ts index 4416480..4ce35f2 100644 --- a/src/lib/analytics/__tests__/effective-range.test.ts +++ b/src/lib/analytics/__tests__/effective-range.test.ts @@ -90,7 +90,8 @@ describe("getAllEffectiveRanges", () => { expect(keys).toContain("BLOOD_GLUCOSE_FASTING"); expect(keys).toContain("TOTAL_BODY_WATER"); expect(keys).toContain("BONE_MASS"); - expect(keys.length).toBeGreaterThanOrEqual(13); + expect(keys).toContain("OXYGEN_SATURATION"); + expect(keys.length).toBeGreaterThanOrEqual(14); }); }); @@ -137,3 +138,44 @@ describe("body composition thresholds", () => { expect(METRIC_BOUNDS.BONE_MASS).toEqual({ min: 0.5, max: 8, unit: "kg" }); }); }); + +// Audit-2026-05-07 / v1.3.3: SpO2 (pulse oximetry) added as a single-value +// metric with lower-only severity (saturation cannot exceed 100% by definition). +// Defaults follow BTS Guideline 2017 (target 94–98%) and ATS clinical practice +// (≥95% at rest is healthy). Below 92% triggers orange band; below 88% in +// real-world clinical literature is the ER threshold. +describe("OXYGEN_SATURATION thresholds", () => { + it("has a non-null green band centred on the BTS healthy range", () => { + const result = getEffectiveRange("OXYGEN_SATURATION", baseProfile, null); + expect(result.range).not.toBeNull(); + expect(result.range!.greenMin).toBe(95); + expect(result.range!.greenMax).toBe(100); + }); + + it("collapses upper orange wing to greenMax (no upper concern)", () => { + const result = getEffectiveRange("OXYGEN_SATURATION", baseProfile, null); + expect(result.range!.orangeMax).toBe(result.range!.greenMax); + }); + + it("respects user override (e.g. COPD baseline 88-92)", () => { + const overrides: ThresholdOverridesJson = { + OXYGEN_SATURATION: { min: 88, max: 92 }, + }; + const result = getEffectiveRange( + "OXYGEN_SATURATION", + baseProfile, + overrides, + ); + expect(result.isOverride).toBe(true); + expect(result.range!.greenMin).toBe(88); + expect(result.range!.greenMax).toBe(92); + }); + + it("METRIC_BOUNDS plausibility floor is 50% (incompatible-with-life below)", () => { + expect(METRIC_BOUNDS.OXYGEN_SATURATION).toEqual({ + min: 50, + max: 100, + unit: "%", + }); + }); +}); diff --git a/src/lib/analytics/effective-range.ts b/src/lib/analytics/effective-range.ts index d03c354..38c0300 100644 --- a/src/lib/analytics/effective-range.ts +++ b/src/lib/analytics/effective-range.ts @@ -39,7 +39,8 @@ export type ThresholdMetric = | "BLOOD_GLUCOSE_RANDOM" | "BLOOD_GLUCOSE_BEDTIME" | "TOTAL_BODY_WATER" - | "BONE_MASS"; + | "BONE_MASS" + | "OXYGEN_SATURATION"; export interface ThresholdOverride { min: number; @@ -85,6 +86,10 @@ export const METRIC_BOUNDS: Record< // Bounds match VALUE_RANGES in src/lib/validations/measurement.ts. TOTAL_BODY_WATER: { min: 5, max: 100, unit: "kg" }, BONE_MASS: { min: 0.5, max: 8, unit: "kg" }, + // Pulse oximetry (Withings ScanWatch reports type 54). Plausibility floor + // 50% to allow truly critical readings to be logged; saturation cannot + // exceed 100% by physical definition. + OXYGEN_SATURATION: { min: 50, max: 100, unit: "%" }, }; /** @@ -193,6 +198,16 @@ function defaultRange( // (women slightly lower than men). Generous orange band before // surfacing as a concern. return { greenMin: 2.0, greenMax: 4.0, orangeMin: 1.5, orangeMax: 5.0 }; + case "OXYGEN_SATURATION": + // BTS Guideline 2017 (target 94–98%) + ATS clinical practice (≥95% at + // rest is healthy). Lower-only concern: clinical literature flags <92% + // as "call your provider" and <88% as ER. Saturation is bounded above + // by 100% physically, so we collapse the upper orange wing onto greenMax + // — severity logic only fires for hypoxemia. + // COPD / chronic-respiratory-failure users typically run 88–92% and + // should personalise via the threshold-override UI (saved in + // User.thresholdsJson). + return { greenMin: 95, greenMax: 100, orangeMin: 92, orangeMax: 100 }; } } diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index faa43bb..8103bfd 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -43,6 +43,7 @@ export const DOCTOR_REPORT_TYPE_LABEL_KEYS: Record = { ACTIVITY_STEPS: "doctorReport.typeSteps", TOTAL_BODY_WATER: "doctorReport.typeTotalBodyWater", BONE_MASS: "doctorReport.typeBoneMass", + OXYGEN_SATURATION: "doctorReport.typeOxygenSaturation", }; export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record = { @@ -55,13 +56,16 @@ export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record = { ACTIVITY_STEPS: null, // translated unit TOTAL_BODY_WATER: "kg", BONE_MASS: "kg", + OXYGEN_SATURATION: "%", }; /** * Vital types rendered in the main vitals table. Body composition * (TOTAL_BODY_WATER, BONE_MASS) ships alongside body fat — Withings - * smart scales report all three together. Glucose ships separately - * via per-context `glucoseStats`. Sleep + activity are intentionally + * smart scales report all three together. SpO2 (Withings ScanWatch type + * 54, HealthKit, n8n / Health Connect) is rendered last in the same + * table for clinical readability. Glucose ships separately via + * per-context `glucoseStats`. Sleep + activity are intentionally * excluded from a clinical-focused report. */ export const DOCTOR_REPORT_VITAL_TYPES = [ @@ -72,6 +76,7 @@ export const DOCTOR_REPORT_VITAL_TYPES = [ "BODY_FAT", "TOTAL_BODY_WATER", "BONE_MASS", + "OXYGEN_SATURATION", ] as const; const MOOD_LABEL_KEYS: Record = { diff --git a/src/lib/doctor-report-pdf.ts b/src/lib/doctor-report-pdf.ts index af41646..55a4e86 100644 --- a/src/lib/doctor-report-pdf.ts +++ b/src/lib/doctor-report-pdf.ts @@ -77,6 +77,7 @@ const TYPE_LABEL_KEYS: Record = { ACTIVITY_STEPS: "doctorReport.typeSteps", TOTAL_BODY_WATER: "doctorReport.typeTotalBodyWater", BONE_MASS: "doctorReport.typeBoneMass", + OXYGEN_SATURATION: "doctorReport.typeOxygenSaturation", }; const TYPE_UNIT_KEYS: Record = { @@ -89,6 +90,7 @@ const TYPE_UNIT_KEYS: Record = { ACTIVITY_STEPS: null, // translated unit TOTAL_BODY_WATER: "kg", BONE_MASS: "kg", + OXYGEN_SATURATION: "%", }; const MOOD_LABEL_KEYS: Record = { @@ -211,6 +213,7 @@ export function generateDoctorReportPDF( "BODY_FAT", "TOTAL_BODY_WATER", "BONE_MASS", + "OXYGEN_SATURATION", ]; for (const type of vitalTypes) { diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 3ccd8c7..f6d6b09 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -11,6 +11,7 @@ export const measurementTypeEnum = z.enum([ "BLOOD_GLUCOSE", "TOTAL_BODY_WATER", "BONE_MASS", + "OXYGEN_SATURATION", ]); export const glucoseContextEnum = z.enum([ @@ -33,6 +34,7 @@ const unitMap: Record = { BLOOD_GLUCOSE: "mg/dL", TOTAL_BODY_WATER: "kg", BONE_MASS: "kg", + OXYGEN_SATURATION: "%", }; export function getUnitForType(type: string): string { @@ -51,6 +53,11 @@ const VALUE_RANGES: Record = { BLOOD_GLUCOSE: { min: 20, max: 800 }, // mg/dL — covers severe hypo to severe hyperglycemia TOTAL_BODY_WATER: { min: 5, max: 100 }, // kg of water — adults typically 30–55 kg BONE_MASS: { min: 0.5, max: 8 }, // kg — adult plausibility (typical 2.5–4.5 kg) + // Pulse oximetry (SpO2). BTS Guideline 2017 + ATS clinical practice put the + // healthy resting range at 95–100%. We accept down to 50% so a faulty-sensor + // critically-low reading still gets logged for the doctor to see; below 50% + // is incompatible with sustained life and almost certainly a sensor glitch. + OXYGEN_SATURATION: { min: 50, max: 100 }, }; export function validateMeasurementRange( diff --git a/src/lib/withings/client.ts b/src/lib/withings/client.ts index 735a9a5..eefd7d6 100644 --- a/src/lib/withings/client.ts +++ b/src/lib/withings/client.ts @@ -129,6 +129,7 @@ const MEASURE_TYPE_MAP: Record = { 6: { type: "BODY_FAT" }, // Body fat % 77: { type: "TOTAL_BODY_WATER" }, // Hydration / water mass (kg) 88: { type: "BONE_MASS" }, // Bone mass (kg) + 54: { type: "OXYGEN_SATURATION" }, // SpO2 (% — only ScanWatch / pulse-ox products) }; export interface WithingsMeasure { From f16761db9070129dd5130dcb8fe0ef5039cb78b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 7 May 2026 23:59:27 +0200 Subject: [PATCH 05/13] fix(analytics): truthfulness pass on medical citations + clamp override orangeMax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Doctor-agent + hallucination-hunter audit V3 found six medical citations in effective-range.ts that the code did not actually implement. Plus a latent bug in the user-override path that produced physically-impossible orangeMax values (>100% saturation, <0% body-fat). Citation rewrites (no numeric changes): - ACTIVITY_STEPS: "WHO ≥8000 steps/day" → Saint-Maurice JAMA 2020. WHO publishes activity in min/week, not steps. Step-count was a misattribution that also leaked into AI prompts. - TOTAL_BODY_WATER: "ESPEN Body Composition Guidance 2017" → Watson formula / ICRP Reference Man. ESPEN 2017 is BIA practice guidance, it does not publish kg cut-offs. - BONE_MASS: "DEXA-equivalent bone mineral content" → "Bioimpedance- estimated; not DEXA-comparable". Withings BIA values typically run 5–7% below DEXA — calling them DEXA-equivalent in a doctor-report PDF was a real legal-exposure misattribution. - OXYGEN_SATURATION: "BTS Guideline 2017 (target 94–98%) + ATS clinical practice (≥95% at rest is healthy)" → "consumer pulse- oximeter consensus (≥95% normal at rest); slightly tighter than BTS 2017 (94–98%); NICE NG115 ≤92% escalation, BMJ panel / FDA pulse-oximeter labelling ≤88% ER threshold". Code uses 95-100/92-100 band — that's an ATS/consumer-consensus number, not BTS's literal 94-98 target. Soften the citation to match the code, not vice versa. Bug fix (1 latent, 1 real-today): - effective-range override path now clamps orangeMin/orangeMax to METRIC_BOUNDS.{min,max}. Without the clamp, a COPD user setting SpO2 override to {95,100} emitted orangeMax = 100.75 (impossible saturation), and a BODY_FAT override of {2,80} emitted orangeMin = -9.7 (negative percent). Default-range case 210 already hard-coded orangeMax: 100 for the same reason — the override path was the only branch missing the invariant. Tests: 305 → 306 (1 new: clamps override orangeMax to physiological 100%). Typecheck: clean. Audit-2026-05-07-V3 / closes Doctor + Senior-Dev medical-citation findings. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/effective-range.test.ts | 14 +++++ src/lib/analytics/effective-range.ts | 51 ++++++++++++------- 2 files changed, 46 insertions(+), 19 deletions(-) diff --git a/src/lib/analytics/__tests__/effective-range.test.ts b/src/lib/analytics/__tests__/effective-range.test.ts index 4ce35f2..c7c9942 100644 --- a/src/lib/analytics/__tests__/effective-range.test.ts +++ b/src/lib/analytics/__tests__/effective-range.test.ts @@ -171,6 +171,20 @@ describe("OXYGEN_SATURATION thresholds", () => { expect(result.range!.greenMax).toBe(92); }); + it("clamps override orangeMax to physiological 100% (no impossible saturations)", () => { + // Without the bounds clamp this used to emit orangeMax = 100.75. + const overrides: ThresholdOverridesJson = { + OXYGEN_SATURATION: { min: 95, max: 100 }, + }; + const result = getEffectiveRange( + "OXYGEN_SATURATION", + baseProfile, + overrides, + ); + expect(result.range!.orangeMax).toBeLessThanOrEqual(100); + expect(result.range!.orangeMin).toBeGreaterThanOrEqual(50); + }); + it("METRIC_BOUNDS plausibility floor is 50% (incompatible-with-life below)", () => { expect(METRIC_BOUNDS.OXYGEN_SATURATION).toEqual({ min: 50, diff --git a/src/lib/analytics/effective-range.ts b/src/lib/analytics/effective-range.ts index 38c0300..3a81674 100644 --- a/src/lib/analytics/effective-range.ts +++ b/src/lib/analytics/effective-range.ts @@ -174,8 +174,11 @@ function defaultRange( // AASM: 7–9h for adults; warning yellow either side. return { greenMin: 7, greenMax: 9, orangeMin: 6, orangeMax: 10 }; case "ACTIVITY_STEPS": - // WHO: ≥8000 steps/day; no upper bound in reality, but orange over 25k - // for edge detection. + // ≥8000 steps/day per Saint-Maurice et al., JAMA 2020 (mortality + // plateau 8000–12000 steps). WHO 2020 PA guidelines publish minutes + // per week (150–300 min moderate / 75–150 min vigorous) — *not* a + // step quota. No upper bound in reality, orange over 25k caps edge + // detection. return { greenMin: 8000, greenMax: 15000, orangeMin: 5000, orangeMax: 25000 }; case "BLOOD_GLUCOSE_FASTING": return glucoseRange(GLUCOSE_DEFAULTS.FASTING, 125); // pre-diabetes upper bound @@ -189,24 +192,31 @@ function defaultRange( GLUCOSE_DEFAULTS.BEDTIME.max + 30, ); case "TOTAL_BODY_WATER": - // Adult typical ~50% body weight as water (kg). Without weight context - // the gender-neutral band is wide; users can tighten it via override. - // Reference: ESPEN Body Composition Guidance 2017. + // Adult total body water typically ~50% of body weight in kg + // (Watson formula / ICRP Reference Man: ~42 L male, ~30 L female). + // Without per-user weight context the gender-neutral band is wide + // by design; users tighten via threshold override. return { greenMin: 28, greenMax: 50, orangeMin: 22, orangeMax: 55 }; case "BONE_MASS": - // DEXA-equivalent body bone mineral content. Adult typical 2.5–4.0 kg - // (women slightly lower than men). Generous orange band before - // surfacing as a concern. + // Bioimpedance-estimated bone mass from a Withings-class scale — + // NOT DEXA-comparable (BIA-derived values typically run 5–7% below + // DEXA bone-mineral-content). Adult BIA-typical 2.0–4.0 kg (women + // slightly lower than men). Wide orange band before surfacing as a + // concern given BIA's intrinsic noise. return { greenMin: 2.0, greenMax: 4.0, orangeMin: 1.5, orangeMax: 5.0 }; case "OXYGEN_SATURATION": - // BTS Guideline 2017 (target 94–98%) + ATS clinical practice (≥95% at - // rest is healthy). Lower-only concern: clinical literature flags <92% - // as "call your provider" and <88% as ER. Saturation is bounded above - // by 100% physically, so we collapse the upper orange wing onto greenMax - // — severity logic only fires for hypoxemia. - // COPD / chronic-respiratory-failure users typically run 88–92% and - // should personalise via the threshold-override UI (saved in - // User.thresholdsJson). + // Conservative band aligned with consumer pulse-oximeter consensus + // (≥95% normal at rest); slightly tighter than the BTS Emergency + // Oxygen Guideline 2017 explicit treatment target of 94–98%, looser + // than the WHO ≥90% acute-care floor. Lower-only concern: ≤92% is + // the NICE NG115 escalation threshold ("call provider"); ≤88% + // (BMJ panel / FDA pulse-oximeter labelling) is the ER threshold. + // Saturation is bounded above by 100% physically, so we collapse + // the upper orange wing onto greenMax — severity logic only fires + // for hypoxemia. + // COPD / chronic-respiratory-failure / high-altitude users + // typically run 88–92% and should personalise via the + // threshold-override UI (saved in User.thresholdsJson). return { greenMin: 95, greenMax: 100, orangeMin: 92, orangeMax: 100 }; } } @@ -252,7 +262,10 @@ export function getEffectiveRange( return { range: fallback, isOverride: false, default: fallback, bounds }; } - // User override replaces the green band. Orange wings stretch 10% below/above. + // User override replaces the green band. Orange wings stretch 15% below/above, + // clamped to the metric's physiological bounds — without the clamp, an + // override like SpO2 {95,100} would emit orangeMax = 100.75 (impossible + // saturation) and BODY_FAT {2,80} would emit orangeMin = -9.7. const span = Math.max(1, override.max - override.min); const orangeWidth = span * 0.15; @@ -260,8 +273,8 @@ export function getEffectiveRange( range: { greenMin: override.min, greenMax: override.max, - orangeMin: override.min - orangeWidth, - orangeMax: override.max + orangeWidth, + orangeMin: Math.max(bounds.min, override.min - orangeWidth), + orangeMax: Math.min(bounds.max, override.max + orangeWidth), }, isOverride: true, default: fallback, From 94ec5198dfd4a5b8d4f3a8234fdb6f90eafbc295 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 8 May 2026 00:14:09 +0200 Subject: [PATCH 06/13] fix(security): Bearer-scope wildcard handling + GDPR Art.17 account-delete erasure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CRITICAL fixes from audit V3. NEW-V3-1: Bearer-scope bypass. apiToken.permissions ["*"] is meant to be the wildcard "session-equivalent" scope (the iOS app receives this on login). The previous check permissions.includes(requiredPermission) treated "*" as a literal string — never matched. Today many sensitive routes call requireAuth() *without* a requiredPermission, so a leaked iOS token can act as a full-scope token (account delete, settings wipe). Once those routes adopt requireAuth("scope:name"), this wildcard handling keeps the iOS app working while narrower-scoped tokens (e.g. user-issued ["medication:ingest"]) get correctly 403'd. Same wildcard handling added to the standalone permission checks in src/app/api/ingest/medication/route.ts:65, 119. NEW-V3-2: GDPR Art. 17 account-delete erasure completeness. Feedback + AuditLog rows had `onDelete: SetNull` in the schema, which preserved PII (free-text symptom descriptions, IP addresses, login-city geolocation) after the user was deleted. We now explicitly prisma.feedback.deleteMany + prisma.auditLog.deleteMany before the cascade-delete, making account deletion genuinely complete erasure. Tests 306/306 still pass; typecheck clean. Audit V3. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/ingest/medication/route.ts | 10 ++++++++-- src/app/api/settings/account/route.ts | 14 ++++++++++++-- src/lib/api-handler.ts | 11 +++++++++++ 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/app/api/ingest/medication/route.ts b/src/app/api/ingest/medication/route.ts index f71d61f..91f0be3 100644 --- a/src/app/api/ingest/medication/route.ts +++ b/src/app/api/ingest/medication/route.ts @@ -62,7 +62,10 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Token expired", 401); } - if (!apiToken.permissions.includes("medication:ingest")) { + if ( + !apiToken.permissions.includes("*") && + !apiToken.permissions.includes("medication:ingest") + ) { return apiError("Insufficient permissions", 403); } @@ -113,7 +116,10 @@ export const POST = apiHandler(async (request: NextRequest) => { } const medicationScope = `medication:${medication.id}:ingest`; - if (!apiToken.permissions.includes(medicationScope)) { + if ( + !apiToken.permissions.includes("*") && + !apiToken.permissions.includes(medicationScope) + ) { return apiError("API endpoint for this medication is disabled", 403); } diff --git a/src/app/api/settings/account/route.ts b/src/app/api/settings/account/route.ts index 4265215..e507301 100644 --- a/src/app/api/settings/account/route.ts +++ b/src/app/api/settings/account/route.ts @@ -44,7 +44,9 @@ export const DELETE = apiHandler(async (request: NextRequest) => { const userId = user.id; const username = user.username; - // Log before deletion (userId will be SetNull in audit_logs after cascade) + // Log BEFORE deletion. The audit row's userId is SetNull post-cascade + // (per schema), but we then immediately purge it below for GDPR Art. 17 + // erasure completeness — see comment further down. await auditLog("user.account.delete", { userId, ipAddress: getClientIp(request), @@ -54,7 +56,15 @@ export const DELETE = apiHandler(async (request: NextRequest) => { // Destroy all sessions first await destroyAllSessions(userId); - // Delete user — all related data is removed via onDelete: Cascade + // Audit V3 NEW-V3-2 / GDPR Art. 17 fix: Feedback and AuditLog rows have + // `onDelete: SetNull` in the schema, which keeps PII (free-text symptom + // descriptions, IP addresses, login city geo) attached to the record after + // the user is deleted. We explicitly purge them inside the same logical + // operation so account deletion is genuinely complete erasure. + await prisma.feedback.deleteMany({ where: { userId } }); + await prisma.auditLog.deleteMany({ where: { userId } }); + + // Delete user — all other related data is removed via onDelete: Cascade await prisma.user.delete({ where: { id: userId } }); annotate({ diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index 0a68f60..2ad4f66 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -207,8 +207,19 @@ async function authenticateBearer( throw new HttpError(401, "Token expired"); } + // Audit V3 NEW-V3-1 fix: `["*"]` is a real wildcard — it grants the + // session-equivalent scope (the iOS app receives this on login). Without + // the wildcard branch, EVERY future requireAuth("scope:name") call would + // 403 every iOS-issued token because string-literal `.includes("*"...)` + // never matches. Worse: today many sensitive routes call requireAuth() + // *without* a requiredPermission, so a leaked iOS token can act as a + // full-scope token (account delete, settings wipe). Once those routes + // adopt requireAuth("scope:name"), the wildcard handling here keeps + // the iOS app working while narrower-scoped tokens (e.g. ["medication: + // ingest"]) get correctly 403'd. if ( requiredPermission && + !apiToken.permissions.includes("*") && !apiToken.permissions.includes(requiredPermission) ) { auditLog("auth.bearer.failure", { From dc6b2bd47b0c5a467363a390832ef22a6b04a18d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 8 May 2026 00:22:51 +0200 Subject: [PATCH 07/13] test(pdf): real text-content assertions for body composition + SpO2 rows Replaces bytes-only "renders body composition rows" theatre test with pdf-parse-driven text extraction that asserts the actual rendered labels + values for TOTAL_BODY_WATER, BONE_MASS, OXYGEN_SATURATION (DE + EN). Closes V3 audit finding STILL-V2-NEW-3: the previous test only verified "%PDF-" header + byte-count, so a regression that drops a row would still pass. Now a missing label fails the test. - adds dev dep: pdf-parse@^2.4.5 - 308/308 tests pass (was 306, +2 SpO2 + EN-locale coverage) Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 1 + pnpm-lock.yaml | 133 ++++++++++++++++++ .../__tests__/doctor-report-pdf-core.test.ts | 71 +++++++++- 3 files changed, 201 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 3bf8328..e1e569e 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@vitejs/plugin-react": "^5.1.4", "eslint": "^9", "eslint-config-next": "16.2.4", + "pdf-parse": "^2.4.5", "prettier": "^3.8.3", "prettier-plugin-tailwindcss": "^0.8.0", "shadcn": "^4.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 732d405..71506ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -114,6 +114,9 @@ importers: eslint-config-next: specifier: 16.2.4 version: 16.2.4(@typescript-eslint/parser@8.58.2(eslint@9.39.3(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.3(jiti@2.6.1))(typescript@6.0.3) + pdf-parse: + specifier: ^2.4.5 + version: 2.4.5 prettier: specifier: ^3.8.3 version: 3.8.3 @@ -792,6 +795,75 @@ packages: resolution: {integrity: sha512-pRLMNKTSGRoLq+KnEB/7OY5vijw1XmcheAAOiv6pj7W1FG32kAGqj1C/RK/cqxRGr1Fh+zBi8sDur8kj3EQv6A==} engines: {node: '>=18'} + '@napi-rs/canvas-android-arm64@0.1.80': + resolution: {integrity: sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@0.1.80': + resolution: {integrity: sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@0.1.80': + resolution: {integrity: sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.80': + resolution: {integrity: sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@0.1.80': + resolution: {integrity: sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-arm64-musl@0.1.80': + resolution: {integrity: sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.80': + resolution: {integrity: sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-x64-gnu@0.1.80': + resolution: {integrity: sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-x64-musl@0.1.80': + resolution: {integrity: sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/canvas-win32-x64-msvc@0.1.80': + resolution: {integrity: sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@0.1.80': + resolution: {integrity: sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==} + engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -4137,6 +4209,15 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pdf-parse@2.4.5: + resolution: {integrity: sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==} + engines: {node: '>=20.16.0 <21 || >=22.3.0'} + hasBin: true + + pdfjs-dist@5.4.296: + resolution: {integrity: sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==} + engines: {node: '>=20.16.0 || >=22.3.0'} + perfect-debounce@2.1.0: resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} @@ -5791,6 +5872,49 @@ snapshots: outvariant: 1.4.3 strict-event-emitter: 0.5.1 + '@napi-rs/canvas-android-arm64@0.1.80': + optional: true + + '@napi-rs/canvas-darwin-arm64@0.1.80': + optional: true + + '@napi-rs/canvas-darwin-x64@0.1.80': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.80': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@0.1.80': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@0.1.80': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.80': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@0.1.80': + optional: true + + '@napi-rs/canvas-linux-x64-musl@0.1.80': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@0.1.80': + optional: true + + '@napi-rs/canvas@0.1.80': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 0.1.80 + '@napi-rs/canvas-darwin-arm64': 0.1.80 + '@napi-rs/canvas-darwin-x64': 0.1.80 + '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.80 + '@napi-rs/canvas-linux-arm64-gnu': 0.1.80 + '@napi-rs/canvas-linux-arm64-musl': 0.1.80 + '@napi-rs/canvas-linux-riscv64-gnu': 0.1.80 + '@napi-rs/canvas-linux-x64-gnu': 0.1.80 + '@napi-rs/canvas-linux-x64-musl': 0.1.80 + '@napi-rs/canvas-win32-x64-msvc': 0.1.80 + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.8.1 @@ -9286,6 +9410,15 @@ snapshots: pathe@2.0.3: {} + pdf-parse@2.4.5: + dependencies: + '@napi-rs/canvas': 0.1.80 + pdfjs-dist: 5.4.296 + + pdfjs-dist@5.4.296: + optionalDependencies: + '@napi-rs/canvas': 0.1.80 + perfect-debounce@2.1.0: {} performance-now@2.1.0: diff --git a/src/lib/__tests__/doctor-report-pdf-core.test.ts b/src/lib/__tests__/doctor-report-pdf-core.test.ts index 741b7ac..2468a2d 100644 --- a/src/lib/__tests__/doctor-report-pdf-core.test.ts +++ b/src/lib/__tests__/doctor-report-pdf-core.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest"; +import { PDFParse } from "pdf-parse"; import { buildDoctorReportPdfDocument, renderDoctorReportPdfBytes, @@ -10,6 +11,16 @@ import { measurementTypeEnum } from "../validations/measurement"; import type { DoctorReportData } from "../doctor-report-data"; import { getServerTranslator } from "../i18n/server-translator"; +async function extractText(bytes: Uint8Array): Promise { + const parser = new PDFParse({ data: bytes }); + try { + const result = await parser.getText(); + return result.text; + } finally { + await parser.destroy(); + } +} + const FIXED_NOW = new Date("2026-05-03T12:00:00.000Z"); function makeData(overrides?: Partial): DoctorReportData { @@ -194,7 +205,7 @@ describe("doctor-report-pdf-core type-map coverage", () => { } }); - it("renders body composition rows when stats are supplied", () => { + it("renders body composition rows with their German labels in the document text", async () => { const data = makeData({ stats: { WEIGHT: { avg: 80, min: 79, max: 81, count: 5, latest: 80 }, @@ -213,8 +224,60 @@ describe("doctor-report-pdf-core type-map coverage", () => { locale: "de", now: FIXED_NOW, }); - expect(bytes.byteLength).toBeGreaterThan(1024); - const header = String.fromCharCode(...bytes.slice(0, 5)); - expect(header).toBe("%PDF-"); + const text = await extractText(bytes); + expect(text).toContain("Gesamtkörperwasser"); + expect(text).toContain("Knochenmasse"); + expect(text).toContain("42,0"); + expect(text).toContain("3,2"); + }); + + it("renders an OXYGEN_SATURATION row with the SpO2 label when stats are supplied", async () => { + const data = makeData({ + stats: { + WEIGHT: { avg: 80, min: 79, max: 81, count: 5, latest: 80 }, + OXYGEN_SATURATION: { + avg: 97.4, + min: 95, + max: 99, + count: 12, + latest: 98, + }, + }, + }); + const bytes = renderDoctorReportPdfBytes(data, { + t: getServerTranslator("de").t, + locale: "de", + now: FIXED_NOW, + }); + const text = await extractText(bytes); + expect(text).toContain("Sauerstoffsättigung"); + expect(text).toContain("97,4"); + expect(text).toContain("%"); + }); + + it("renders body composition rows in English when locale is en", async () => { + const data = makeData({ + stats: { + WEIGHT: { avg: 80, min: 79, max: 81, count: 5, latest: 80 }, + TOTAL_BODY_WATER: { avg: 42, min: 40, max: 44, count: 5, latest: 42 }, + BONE_MASS: { avg: 3.2, min: 3.1, max: 3.3, count: 5, latest: 3.2 }, + OXYGEN_SATURATION: { + avg: 97.4, + min: 95, + max: 99, + count: 12, + latest: 98, + }, + }, + }); + const bytes = renderDoctorReportPdfBytes(data, { + t: getServerTranslator("en").t, + locale: "en", + now: FIXED_NOW, + }); + const text = await extractText(bytes); + expect(text).toContain("Total body water"); + expect(text).toContain("Bone mass"); + expect(text).toContain("Oxygen saturation"); }); }); From 5c5c9dab9cff4bd59575e472a5d122630facbaa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 8 May 2026 00:28:37 +0200 Subject: [PATCH 08/13] =?UTF-8?q?fix(measurements):=20close=20enum=20drift?= =?UTF-8?q?=20cousins=20=E2=80=94=20derive=205=20type-arrays=20from=20cano?= =?UTF-8?q?nical=20enum=20+=20extend=202=20contract=20enums?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V3 audit finding "enum drift cousins": 7 hardcoded module-level type arrays silently dropped SpO2/TBW/BoneMass/BloodGlucose from their respective domains. Adding a new measurement type required hunting these down. Now all aggregation paths derive from `measurementTypeEnum.options`: - /api/insights/comprehensive (AI insights) - /api/dashboard/summary (iOS dashboard adapter) - /api/analytics (analytics aggregator) - /lib/insights/general-status (AI general-status fetch) - /api/import (Zod schema — round-trip now covers all 11 types) External-contract enums extended additively (no breakage for old clients): - /api/measurements/series kindEnum: + oxygen, totalBodyWater, boneMass - /api/dashboard/widgets widgetIdEnum: + oxygenSaturation - DashboardWidgetId + DEFAULT_DASHBOARD_LAYOUT: + oxygenSaturation row - formatMeasurementsForExport: glucoseContext now round-trips Coverage test (`measurement-type-enum-coverage.test.ts`) asserts the canonical enum stays the source of truth and documents PDF exclusions (BLOOD_GLUCOSE renders via context section, SLEEP/STEPS lifestyle-only). 313/313 tests pass (was 308, +5 new). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/analytics/route.ts | 14 +--- src/app/api/dashboard/summary/route.ts | 33 +++++--- src/app/api/dashboard/widgets/route.ts | 1 + src/app/api/import/route.ts | 21 +++--- src/app/api/insights/comprehensive/route.ts | 12 +-- src/app/api/measurements/series/route.ts | 6 ++ .../settings/dashboard-layout-section.tsx | 1 + src/lib/__tests__/export.test.ts | 20 +++++ .../measurement-type-enum-coverage.test.ts | 75 +++++++++++++++++++ src/lib/dashboard-layout.ts | 4 +- src/lib/export.ts | 2 + src/lib/insights/general-status.ts | 13 +--- 12 files changed, 152 insertions(+), 50 deletions(-) create mode 100644 src/lib/__tests__/measurement-type-enum-coverage.test.ts diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 78885a9..5c9dd5e 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -5,6 +5,7 @@ import { apiSuccess } from "@/lib/api-response"; import { summarize, type DataPoint } from "@/lib/analytics/trends"; import { getBpTargets } from "@/lib/analytics/bp-targets"; import type { MeasurementType } from "@/generated/prisma/client"; +import { measurementTypeEnum } from "@/lib/validations/measurement"; export const dynamic = "force-dynamic"; @@ -12,16 +13,9 @@ export const GET = apiHandler(async () => { const { user } = await requireAuth(); annotate({ action: { name: "analytics.get" } }); - const types: MeasurementType[] = [ - "WEIGHT", - "BLOOD_PRESSURE_SYS", - "BLOOD_PRESSURE_DIA", - "PULSE", - "BODY_FAT", - "SLEEP_DURATION", - "ACTIVITY_STEPS", - "BLOOD_GLUCOSE", - ]; + // Derived from canonical enum so a new measurement type is auto-summarised + // by /api/analytics (V3 audit: enum drift cousins). + const types = [...measurementTypeEnum.options] as MeasurementType[]; const measurementsByType = await Promise.all( types.map((type) => diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index 07e9985..959b6b9 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -14,6 +14,7 @@ import { apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; import type { MeasurementType } from "@/generated/prisma/client"; +import { measurementTypeEnum } from "@/lib/validations/measurement"; const SPARK_DAYS = 7; const STREAK_WINDOW_DAYS = 365; @@ -25,7 +26,10 @@ type MetricKind = | "bodyFat" | "glucose" | "sleep" - | "steps"; + | "steps" + | "totalBodyWater" + | "boneMass" + | "oxygenSaturation"; interface MetricCard { id: string; @@ -47,6 +51,9 @@ const METRIC_TITLES: Record = { glucose: "Blutzucker", sleep: "Schlaf", steps: "Schritte", + totalBodyWater: "Gesamtkörperwasser", + boneMass: "Knochenmasse", + oxygenSaturation: "Sauerstoffsättigung", }; const METRIC_UNITS: Record = { @@ -57,6 +64,9 @@ const METRIC_UNITS: Record = { glucose: "mg/dL", sleep: "h", steps: "Schritte", + totalBodyWater: "kg", + boneMass: "kg", + oxygenSaturation: "%", }; function trendOf(values: number[]): MetricCard["trend"] { @@ -142,16 +152,12 @@ export const GET = apiHandler(async () => { const todayStart = startOfDayBerlin(now); const todayEnd = new Date(todayStart.getTime() + 86_400_000); - const measurementTypes: MeasurementType[] = [ - "WEIGHT", - "BLOOD_PRESSURE_SYS", - "BLOOD_PRESSURE_DIA", - "PULSE", - "BODY_FAT", - "BLOOD_GLUCOSE", - "SLEEP_DURATION", - "ACTIVITY_STEPS", - ]; + // Derived from canonical enum so a new measurement type is auto-included + // (V3 audit: enum drift cousins). Per-kind display blocks below decide + // which types render as MetricCards. + const measurementTypes = [ + ...measurementTypeEnum.options, + ] as MeasurementType[]; const [recentMeasurements, todaysIntakes, streakActivity] = await Promise.all([ @@ -296,11 +302,14 @@ export const GET = apiHandler(async () => { } } - // Glucose / sleep / steps — only if data is present. + // Optional cards — only emitted if the user has data for that type. for (const [type, kind] of [ ["BLOOD_GLUCOSE", "glucose"], ["SLEEP_DURATION", "sleep"], ["ACTIVITY_STEPS", "steps"], + ["TOTAL_BODY_WATER", "totalBodyWater"], + ["BONE_MASS", "boneMass"], + ["OXYGEN_SATURATION", "oxygenSaturation"], ] as const) { const latest = latestOf(type); if (!latest) continue; diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index c8e764b..d56fb82 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -32,6 +32,7 @@ const widgetIdEnum = z.enum([ "totalBodyWater", "boneMass", "bpInTarget", + "oxygenSaturation", ]); const layoutSchema = z.object({ diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts index b8fcdc2..78cd71d 100644 --- a/src/app/api/import/route.ts +++ b/src/app/api/import/route.ts @@ -6,22 +6,22 @@ import { apiSuccess, getClientIp, safeJson } from "@/lib/api-response"; import { NextRequest } from "next/server"; import { z } from "zod/v4"; -import { validateMeasurementRange } from "@/lib/validations/measurement"; +import { + validateMeasurementRange, + measurementTypeEnum, + glucoseContextEnum, +} from "@/lib/validations/measurement"; +// 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: z.enum([ - "WEIGHT", - "BLOOD_PRESSURE_SYS", - "BLOOD_PRESSURE_DIA", - "PULSE", - "BODY_FAT", - "SLEEP_DURATION", - "ACTIVITY_STEPS", - ]), + type: measurementTypeEnum, value: z.number(), unit: z.string(), measuredAt: z.string().datetime(), + glucoseContext: glucoseContextEnum.optional(), source: z.string().optional(), notes: z.string().optional(), }) @@ -76,6 +76,7 @@ export const POST = apiHandler(async (request: NextRequest) => { source: "IMPORT", measuredAt: new Date(m.measuredAt), notes: m.notes || null, + glucoseContext: m.glucoseContext ?? null, }, }); stats.measurements++; diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts index b999de3..ade034c 100644 --- a/src/app/api/insights/comprehensive/route.ts +++ b/src/app/api/insights/comprehensive/route.ts @@ -18,6 +18,7 @@ import { getMedicationCategories } from "@/lib/medication-category"; import type { MeasurementType } from "@/generated/prisma/client"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { measurementTypeEnum } from "@/lib/validations/measurement"; export const dynamic = "force-dynamic"; @@ -36,14 +37,9 @@ export const GET = apiHandler(async () => { }, }); - // Fetch all measurements (90 days) - const types: MeasurementType[] = [ - "WEIGHT", - "BLOOD_PRESSURE_SYS", - "BLOOD_PRESSURE_DIA", - "PULSE", - "BODY_FAT", - ]; + // Derived from canonical enum so adding a new measurement type does not + // require touching this file (V3 audit finding: enum drift cousins). + const types = [...measurementTypeEnum.options] as MeasurementType[]; const allMeasurements = await prisma.measurement.findMany({ where: { userId, type: { in: types }, measuredAt: { gte: ninetyDaysAgo } }, diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts index 5307e1c..bb4504f 100644 --- a/src/app/api/measurements/series/route.ts +++ b/src/app/api/measurements/series/route.ts @@ -25,6 +25,9 @@ const kindEnum = z.enum([ "glucose", "sleep", "steps", + "totalBodyWater", + "boneMass", + "oxygen", ]); const querySchema = z.object({ @@ -40,6 +43,9 @@ const KIND_TO_TYPE: Record, MeasurementType> = { glucose: "BLOOD_GLUCOSE", sleep: "SLEEP_DURATION", steps: "ACTIVITY_STEPS", + totalBodyWater: "TOTAL_BODY_WATER", + boneMass: "BONE_MASS", + oxygen: "OXYGEN_SATURATION", }; interface SeriesPoint { diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index 9bb2828..5c4fa02 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -32,6 +32,7 @@ const WIDGET_LABEL_KEYS: Record = { totalBodyWater: "measurements.typeTotalBodyWater", boneMass: "measurements.typeBoneMass", bpInTarget: "dashboard.bpInTarget", + oxygenSaturation: "measurements.typeOxygenSaturation", }; export function DashboardLayoutSection({ id }: { id: string }) { diff --git a/src/lib/__tests__/export.test.ts b/src/lib/__tests__/export.test.ts index c56aa45..6e85dbb 100644 --- a/src/lib/__tests__/export.test.ts +++ b/src/lib/__tests__/export.test.ts @@ -66,6 +66,26 @@ describe("formatMeasurementsForExport", () => { measuredAt: "2025-01-15T08:00:00.000Z", source: "MANUAL", notes: "", + glucoseContext: "", + }); + }); + + it("round-trips glucoseContext on BLOOD_GLUCOSE rows", () => { + const measurements = [ + { + type: "BLOOD_GLUCOSE", + value: 92, + unit: "mg/dL", + measuredAt: new Date("2025-01-15T08:00:00Z"), + source: "MANUAL", + notes: null, + glucoseContext: "FASTING", + }, + ]; + const result = formatMeasurementsForExport(measurements); + expect(result[0]).toMatchObject({ + type: "BLOOD_GLUCOSE", + glucoseContext: "FASTING", }); }); }); diff --git a/src/lib/__tests__/measurement-type-enum-coverage.test.ts b/src/lib/__tests__/measurement-type-enum-coverage.test.ts new file mode 100644 index 0000000..2a32499 --- /dev/null +++ b/src/lib/__tests__/measurement-type-enum-coverage.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from "vitest"; +import { measurementTypeEnum } from "@/lib/validations/measurement"; +import { + DOCTOR_REPORT_VITAL_TYPES, + DOCTOR_REPORT_TYPE_LABEL_KEYS, + DOCTOR_REPORT_TYPE_UNIT_KEYS, +} from "@/lib/doctor-report-pdf-core"; + +// Single source of truth for which measurement types exist. +// V3 audit "enum drift cousins": 7 module-level hardcoded arrays were +// silently dropping new types (SpO2, TBW, BoneMass, BloodGlucose) from +// dashboard / analytics / AI insights / iOS adapters / import. +// +// All ingest, analytics and reporting paths are now derived from +// `measurementTypeEnum.options`, so adding a new type only needs touching +// the enum. This test asserts that contract. +const EXPECTED_TYPES = [ + "WEIGHT", + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "PULSE", + "BODY_FAT", + "SLEEP_DURATION", + "ACTIVITY_STEPS", + "BLOOD_GLUCOSE", + "TOTAL_BODY_WATER", + "BONE_MASS", + "OXYGEN_SATURATION", +] as const; + +describe("measurementTypeEnum coverage", () => { + it("exposes the 11 canonical measurement types", () => { + expect([...measurementTypeEnum.options].sort()).toEqual( + [...EXPECTED_TYPES].sort(), + ); + }); + + // Documented exclusions from the doctor-report main vitals table: + // - BLOOD_GLUCOSE renders through the per-context `glucoseStats` section + // - SLEEP_DURATION + ACTIVITY_STEPS are intentionally omitted from the + // clinical PDF (lifestyle, not a vital sign — see source comment). + // Updates to this set MUST be paired with a comment in + // doctor-report-pdf-core.ts so the rationale stays discoverable. + const PDF_VITAL_EXCLUSIONS = new Set([ + "BLOOD_GLUCOSE", + "SLEEP_DURATION", + "ACTIVITY_STEPS", + ]); + + it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => { + const expected = measurementTypeEnum.options.filter( + (t) => !PDF_VITAL_EXCLUSIONS.has(t), + ); + expect([...DOCTOR_REPORT_VITAL_TYPES].sort()).toEqual([...expected].sort()); + }); + + it("doctor-report PDF has a label key for every renderable type", () => { + for (const type of DOCTOR_REPORT_VITAL_TYPES) { + expect( + DOCTOR_REPORT_TYPE_LABEL_KEYS[type], + `missing label key for ${type}`, + ).toBeTruthy(); + } + }); + + it("doctor-report PDF has a unit key for every renderable type", () => { + for (const type of DOCTOR_REPORT_VITAL_TYPES) { + const unit = DOCTOR_REPORT_TYPE_UNIT_KEYS[type]; + expect( + unit === null || (typeof unit === "string" && unit.length > 0), + `missing unit for ${type}`, + ).toBe(true); + } + }); +}); diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index c203eef..e88a0e6 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -17,7 +17,8 @@ export type DashboardWidgetId = | "glucose" | "totalBodyWater" | "boneMass" - | "bpInTarget"; + | "bpInTarget" + | "oxygenSaturation"; export interface DashboardWidgetConfig { id: DashboardWidgetId; @@ -52,6 +53,7 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = { { id: "glucose", visible: false, order: 9 }, { id: "totalBodyWater", visible: false, order: 10 }, { id: "boneMass", visible: false, order: 11 }, + { id: "oxygenSaturation", visible: false, order: 12 }, ], }; diff --git a/src/lib/export.ts b/src/lib/export.ts index 8a0690e..efad8a1 100644 --- a/src/lib/export.ts +++ b/src/lib/export.ts @@ -43,6 +43,7 @@ export function formatMeasurementsForExport( measuredAt: Date; source: string; notes: string | null; + glucoseContext?: string | null; }>, ): ExportableRecord[] { return measurements.map((m) => ({ @@ -52,6 +53,7 @@ export function formatMeasurementsForExport( measuredAt: m.measuredAt.toISOString(), source: m.source, notes: m.notes ?? "", + glucoseContext: m.glucoseContext ?? "", })); } diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index 636b5c3..765b345 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -3,6 +3,7 @@ import { resolveProvider } from "@/lib/ai/provider"; import { getGeneralStatusSystemPrompt, getGeneralStatusUserPrompt } from "@/lib/ai/prompts/general-status"; import { getBpTargets } from "@/lib/analytics/bp-targets"; import { getNoKeyGeneralStatusText } from "@/lib/insights/no-key-fallbacks"; +import { measurementTypeEnum } from "@/lib/validations/measurement"; const GENERAL_STATUS_POINTS = 30; @@ -15,15 +16,9 @@ const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { type SupportedLocale = "de" | "en"; -const MEASUREMENT_TYPES = [ - "WEIGHT", - "BLOOD_PRESSURE_SYS", - "BLOOD_PRESSURE_DIA", - "PULSE", - "BODY_FAT", - "SLEEP_DURATION", - "ACTIVITY_STEPS", -] as const; +// Derived from canonical enum so a new measurement type is auto-included +// in the AI general-status fetch (V3 audit: enum drift cousins). +const MEASUREMENT_TYPES = measurementTypeEnum.options; function toBerlinDayKey(date: Date): string { const parts = BERLIN_DAY_FORMATTER.formatToParts(date); From f1b3319062397ef9bc27bac1da80a7cf42ffbe37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 8 May 2026 00:34:00 +0200 Subject: [PATCH 09/13] fix(security): close 6 V3-audit HIGH findings (CSP, web-push SSRF, IP geo, AI provider leak, import rate-limit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CSP: chatgpt.com + api.openai.com only on /settings/ai/** routes (was blanket connect-src on every page including /auth/login → DOM-XSS exfil channel) - web-push: webPushSubscriptionSchema.endpoint requires HTTPS + isPublicUrl refinement; was z.url() only → blind SSRF probe via Push subscription - isPublicUrl: also fixes a regression where DNS labels starting with "fc"/"fd" (fcm.googleapis.com etc.) were falsely classified as IPv6 unique-local. Check is now gated on a colon being present in the host. - /api/ai/test: stop returning provider err.message + bodyExcerpt to the client (leaked provider URLs / partial keys / internal headers). Now returns categorised generic message; full details land in the wide-event via annotate() for the operator. - /lib/geo (auditLog IP geo lookup): switch default provider to ipwho.is over HTTPS, accept ipwho.is + ip-api pro response shapes, add IP_GEO_LOOKUP_DISABLED env, refuse non-HTTPS configured URLs. Previously leaked IP+timestamp over plaintext HTTP every auth event (GDPR Art. 32 + Art. 44). - /api/import: add 5/hour rate-limit (was unlimited; bulk-injection vector) Tests: 325/325 pass (was 313, +12 new — webPushSubscriptionSchema SSRF guards, fc/fd-prefix domain regression, geo HTTPS-only, geo provider shapes, geo opt-out env). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/ai/test/route.ts | 26 +++++- src/app/api/import/route.ts | 11 ++- src/lib/__tests__/geo.test.ts | 89 +++++++++++++++++++ src/lib/geo.ts | 66 +++++++++++--- .../__tests__/notifications.test.ts | 63 ++++++++++++- src/lib/validations/notifications.ts | 33 +++++-- src/proxy.ts | 11 ++- 7 files changed, 268 insertions(+), 31 deletions(-) create mode 100644 src/lib/__tests__/geo.test.ts diff --git a/src/app/api/ai/test/route.ts b/src/app/api/ai/test/route.ts index b0f07fb..de6385b 100644 --- a/src/app/api/ai/test/route.ts +++ b/src/app/api/ai/test/route.ts @@ -34,10 +34,28 @@ export const POST = apiHandler(async () => { sample: result.content.slice(0, 200), }); } catch (e) { + // V3 audit: do not return provider error message + bodyExcerpt to the + // client (leaks provider URL / partial keys / internal headers). + // Log full details server-side for the operator and respond with a + // categorised, generic message. const err = e as Error & { httpStatus?: number; bodyExcerpt?: string }; - return apiError( - `${err.message}${err.bodyExcerpt ? ` — ${err.bodyExcerpt.slice(0, 200)}` : ""}`, - 502, - ); + annotate({ + meta: { + ai_test_error: err.message.slice(0, 500), + ai_test_status: err.httpStatus ?? null, + ai_test_body_excerpt: err.bodyExcerpt?.slice(0, 500) ?? null, + ai_test_provider: provider.type, + }, + }); + const status = err.httpStatus ?? 0; + const safeMessage = + status === 401 || status === 403 + ? "Provider rejected the credentials" + : status === 429 + ? "Provider rate-limited the request" + : status >= 500 + ? "Provider returned a server error" + : "Provider connection failed"; + return apiError(safeMessage, 502); } }); diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts index 78cd71d..cc9acf2 100644 --- a/src/app/api/import/route.ts +++ b/src/app/api/import/route.ts @@ -2,7 +2,8 @@ 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, getClientIp, safeJson } from "@/lib/api-response"; +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"; @@ -51,6 +52,14 @@ 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); if (jsonError) return jsonError; diff --git a/src/lib/__tests__/geo.test.ts b/src/lib/__tests__/geo.test.ts new file mode 100644 index 0000000..8903559 --- /dev/null +++ b/src/lib/__tests__/geo.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +const ORIGINAL_ENV = { ...process.env }; + +beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + delete process.env.IP_GEO_LOOKUP_URL; + delete process.env.IP_GEO_LOOKUP_DISABLED; + vi.resetModules(); +}); + +afterEach(() => { + process.env = ORIGINAL_ENV; + vi.restoreAllMocks(); +}); + +// V3 audit: /api/auth events were leaking IP + timestamp via plaintext +// HTTP to ip-api.com (GDPR Art. 32 + Art. 44). The lookup helper now +// (a) only egresses over HTTPS, (b) supports an opt-out env, and +// (c) accepts both ipwho.is and ip-api.com pro response shapes. +describe("lookupIpLocation IP-geolocation HTTPS guard", () => { + it("returns null for private addresses without making any request", async () => { + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + const { lookupIpLocation } = await import("../geo"); + + expect(await lookupIpLocation("10.0.0.5")).toBeNull(); + expect(await lookupIpLocation("127.0.0.1")).toBeNull(); + expect(await lookupIpLocation("::1")).toBeNull(); + expect(await lookupIpLocation(null)).toBeNull(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("returns null when IP_GEO_LOOKUP_DISABLED=1", async () => { + process.env.IP_GEO_LOOKUP_DISABLED = "1"; + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + const { lookupIpLocation } = await import("../geo"); + + expect(await lookupIpLocation("8.8.8.8")).toBeNull(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("uses HTTPS by default (ipwho.is)", async () => { + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ success: true, city: "Berlin", country_code: "DE" }), + }); + vi.stubGlobal("fetch", fetchSpy); + const { lookupIpLocation } = await import("../geo"); + + expect(await lookupIpLocation("8.8.8.8")).toBe("Berlin, DE"); + const url = fetchSpy.mock.calls[0]?.[0] as string; + expect(url.startsWith("https://")).toBe(true); + }); + + it("refuses to call non-HTTPS configured providers", async () => { + process.env.IP_GEO_LOOKUP_URL = "http://ip-api.com/json"; + const fetchSpy = vi.fn().mockResolvedValue({ + ok: false, + json: async () => ({}), + }); + vi.stubGlobal("fetch", fetchSpy); + const { lookupIpLocation } = await import("../geo"); + + const result = await lookupIpLocation("8.8.8.8"); + expect(result).toBeNull(); + const url = fetchSpy.mock.calls[0]?.[0] as string; + // Even when an operator misconfigures HTTP, the helper rewrites the + // URL to a deliberately invalid HTTPS URL so the egress is HTTPS-only. + expect(url.startsWith("https://")).toBe(true); + }); + + it("accepts the ip-api pro response shape (status:'success', countryCode)", async () => { + process.env.IP_GEO_LOOKUP_URL = "https://pro.ip-api.com/json"; + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + status: "success", + city: "Hamburg", + countryCode: "DE", + }), + }); + vi.stubGlobal("fetch", fetchSpy); + const { lookupIpLocation } = await import("../geo"); + + expect(await lookupIpLocation("8.8.8.8")).toBe("Hamburg, DE"); + }); +}); diff --git a/src/lib/geo.ts b/src/lib/geo.ts index c375de9..ebbea31 100644 --- a/src/lib/geo.ts +++ b/src/lib/geo.ts @@ -1,36 +1,74 @@ /** - * IP geolocation lookup using ip-api.com (free, no API key needed). - * Returns "City, CC" string or null on failure. - * Only used for audit log enrichment — fire-and-forget, non-blocking. + * IP geolocation lookup for audit-log enrichment. + * + * Default provider is ipwho.is (HTTPS, free, no key). Both the response + * shape from ipwho.is and the fallback ip-api.com pro endpoint are + * accepted, so swapping providers via IP_GEO_LOOKUP_URL only requires + * matching one of those response shapes. + * + * Setting IP_GEO_LOOKUP_DISABLED=1 disables lookup entirely — used by + * deployments that do not want any IP egress to a third-party service + * (V3 audit: GDPR Art. 32 + Art. 44, plaintext HTTP IP egress). */ -interface IpApiResponse { - status: "success" | "fail"; +interface IpwhoIsResponse { + success?: boolean; + city?: string; + country_code?: string; +} + +interface IpApiProResponse { + status?: "success" | "fail"; city?: string; countryCode?: string; } +type GeoResponse = IpwhoIsResponse & IpApiProResponse; + const PRIVATE_IP = /^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|::1|fc|fd|fe80|localhost|unknown)/; +const DEFAULT_GEO_URL = "https://ipwho.is"; + +function buildLookupUrl(ip: string): string { + const base = (process.env.IP_GEO_LOOKUP_URL ?? DEFAULT_GEO_URL).replace( + /\/+$/, + "", + ); + if (!base.startsWith("https://")) { + // V3 audit: never leak audit-event IPs over plaintext HTTP. Reject any + // configuration that would do so by upgrading to https; if the operator + // has explicitly opted into HTTP via env, we still refuse and return a + // dummy URL the parser will fail on. + return `https://invalid.invalid/refused-non-https/${encodeURIComponent(ip)}`; + } + return `${base}/${encodeURIComponent(ip)}`; +} + export async function lookupIpLocation( ip: string | null, ): Promise { if (!ip || PRIVATE_IP.test(ip)) return null; + if (process.env.IP_GEO_LOOKUP_DISABLED === "1") return null; try { - const res = await fetch( - `http://ip-api.com/json/${encodeURIComponent(ip)}?fields=status,city,countryCode`, - { signal: AbortSignal.timeout(3000) }, - ); - + const res = await fetch(buildLookupUrl(ip), { + signal: AbortSignal.timeout(3000), + }); if (!res.ok) return null; - const data = (await res.json()) as IpApiResponse; - if (data.status !== "success" || !data.city || !data.countryCode) - return null; + const data = (await res.json()) as GeoResponse; + const ok = + data.success === true || + data.status === "success" || + (data.city && (data.country_code ?? data.countryCode)); + if (!ok) return null; + + const city = data.city; + const country = data.country_code ?? data.countryCode; + if (!city || !country) return null; - return `${data.city}, ${data.countryCode}`; + return `${city}, ${country}`; } catch { return null; } diff --git a/src/lib/validations/__tests__/notifications.test.ts b/src/lib/validations/__tests__/notifications.test.ts index bd62082..1f9393a 100644 --- a/src/lib/validations/__tests__/notifications.test.ts +++ b/src/lib/validations/__tests__/notifications.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { isPublicUrl } from "../notifications"; +import { isPublicUrl, webPushSubscriptionSchema } from "../notifications"; describe("isPublicUrl SSRF guard", () => { describe("allows public addresses", () => { @@ -9,6 +9,20 @@ describe("isPublicUrl SSRF guard", () => { expect(isPublicUrl("http://example.com")).toBe(true); }); + it("does not block DNS labels that happen to start with 'fc'/'fd' (V3 audit regression)", () => { + // The IPv6 unique-local-address check is gated on a colon. A domain + // like fcm.googleapis.com or fd-cdn.example.com previously matched + // `startsWith("fc"/"fd")` and was rejected. Pin the contract. + expect(isPublicUrl("https://fcm.googleapis.com/fcm/send/abc")).toBe(true); + expect(isPublicUrl("https://fd-cdn.example.com")).toBe(true); + expect(isPublicUrl("https://fc.example.com")).toBe(true); + }); + + it("still blocks real IPv6 unique-local addresses (must contain colon)", () => { + expect(isPublicUrl("http://[fc00::1]")).toBe(false); + expect(isPublicUrl("http://[fd12:3456::1]")).toBe(false); + }); + it("public IPv4 addresses", () => { expect(isPublicUrl("https://1.1.1.1")).toBe(true); expect(isPublicUrl("https://8.8.8.8")).toBe(true); @@ -133,3 +147,50 @@ describe("isPublicUrl SSRF guard", () => { }); }); }); + +// V3 audit: webPushSubscriptionSchema previously accepted any URL, +// allowing an authenticated user to point Push delivery at a private +// network (RFC1918 / link-local / loopback) → blind SSRF probe. +describe("webPushSubscriptionSchema SSRF guard (V3 audit)", () => { + const keys = { p256dh: "abc", auth: "def" }; + + it("accepts a real public HTTPS endpoint", () => { + const r = webPushSubscriptionSchema.safeParse({ + endpoint: "https://fcm.googleapis.com/fcm/send/abc", + keys, + }); + expect(r.success).toBe(true); + }); + + it("rejects an HTTP endpoint", () => { + const r = webPushSubscriptionSchema.safeParse({ + endpoint: "http://fcm.googleapis.com/fcm/send/abc", + keys, + }); + expect(r.success).toBe(false); + }); + + it("rejects an internal RFC1918 endpoint over https", () => { + const r = webPushSubscriptionSchema.safeParse({ + endpoint: "https://10.0.0.1/push", + keys, + }); + expect(r.success).toBe(false); + }); + + it("rejects loopback https endpoint", () => { + const r = webPushSubscriptionSchema.safeParse({ + endpoint: "https://127.0.0.1/push", + keys, + }); + expect(r.success).toBe(false); + }); + + it("rejects AWS metadata service link-local", () => { + const r = webPushSubscriptionSchema.safeParse({ + endpoint: "https://169.254.169.254/latest/meta-data/", + keys, + }); + expect(r.success).toBe(false); + }); +}); diff --git a/src/lib/validations/notifications.ts b/src/lib/validations/notifications.ts index 23fc90e..8f3f563 100644 --- a/src/lib/validations/notifications.ts +++ b/src/lib/validations/notifications.ts @@ -104,16 +104,21 @@ export function isPublicUrl(url: string): boolean { return false; } - // IPv6 loopback / unspecified / link-local / unique-local. - if ( - h === "::1" || - h === "::" || - h.startsWith("fe80:") || - h.startsWith("fc") || - h.startsWith("fd") - ) { + // IPv6 loopback / unspecified / link-local / unique-local. Must be + // gated on a colon — the previous `startsWith("fc")` falsely blocked + // any DNS hostname starting with "fc" or "fd" (e.g. fcm.googleapis.com). + if (h === "::1" || h === "::") { return false; } + if (h.includes(":")) { + if ( + h.startsWith("fe80:") || + /^fc[0-9a-f]{0,2}:/.test(h) || + /^fd[0-9a-f]{0,2}:/.test(h) + ) { + return false; + } + } // IPv4-mapped IPv6 ("::ffff:127.0.0.1" or "::ffff:7f00:1") and // 6to4 / NAT64 with embedded private IPv4. Extract the trailing @@ -171,7 +176,17 @@ export const ntfySettingsSchema = z.object({ }); export const webPushSubscriptionSchema = z.object({ - endpoint: z.url("Ungültiger Endpoint"), + endpoint: z + .url("Ungültiger Endpoint") + .max(500) + .refine( + (url) => url.startsWith("https://"), + "Endpoint muss HTTPS verwenden", + ) + .refine( + (url) => isPublicUrl(url), + "Endpoint darf nicht auf interne Netzwerke zeigen", + ), keys: z.object({ p256dh: z.string().min(1), auth: z.string().min(1), diff --git a/src/proxy.ts b/src/proxy.ts index a4c9807..7d631c0 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -108,12 +108,19 @@ export function proxy(request: NextRequest) { "camera=(), microphone=(), geolocation=()", ); - // CSP — permissive in dev, strict in production + // CSP — permissive in dev, strict in production. AI provider hosts + // (OpenAI / chatgpt.com) are gated to /settings/ai/** because that is + // the only surface a browser fetch is needed (V3 audit: blanket + // chatgpt.com on /auth/login is a DOM-XSS exfil channel). const isDev = process.env.NODE_ENV === "development"; const cspReportEndpoint = "/api/monitoring/csp-report"; + const isAiSettingsRoute = pathname.startsWith("/settings/ai"); + const aiConnectSrc = isAiSettingsRoute + ? " https://api.openai.com https://chatgpt.com" + : ""; const csp = isDev ? `default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://www.gravatar.com; connect-src 'self'; font-src 'self';` - : `default-src 'self'; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://www.gravatar.com; connect-src 'self' https://api.openai.com https://chatgpt.com https://wbsapi.withings.net; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; worker-src 'self'; report-uri ${cspReportEndpoint}; report-to csp-endpoint;`; + : `default-src 'self'; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://www.gravatar.com; connect-src 'self'${aiConnectSrc} https://wbsapi.withings.net; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; worker-src 'self'; report-uri ${cspReportEndpoint}; report-to csp-endpoint;`; response.headers.set("Content-Security-Policy", csp); // Production-only headers From 43f14b6b6d758cc095880bff28c87f44abd48b46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 8 May 2026 00:38:45 +0200 Subject: [PATCH 10/13] fix(security,gdpr): trusted-proxy IP, audit-log retention, idempotency cachable filter, Bearer mock tightening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V3 audit follow-on bundle covering 4 HIGH findings. (I) getClientIp — TRUST_PROXY_HOPS env (default 1) replaces blind XFF trust. Reads chain right-to-left so client-supplied leftmost entries cannot rotate the per-IP rate-limit bucket. Default 1 matches typical single-proxy self-host (Coolify/Caddy/Cloudflare-Tunnel); 0 forces x-real-ip / null. New 7-test suite covers the rotation attack + malformed entries + zero-hop fallback. (F) Audit-log retention job — daily 03:15 cleanup of audit_logs older than AUDIT_LOG_RETENTION_DAYS (default 365). Closes GDPR Art. 5(1)(e) "storage limitation" gap; previously IPs + city + login events accumulated forever. 6-test suite covers default + override + misconfig guard (rejects <7 days). (G) isCachableStatus — extracted the inline 4xx/5xx filter into a named, exported, tested function. 7 cases cover the full do-not-cache contract (401 / 403 / 408 / 429 / any 5xx) and confirm 2xx + 4xx validation are still cached. (H) Bearer-mock tightening — apiToken.findUnique mocks in require-auth-bearer.test + idempotency.test now assert the where.tokenHash shape uses the hashed value (not the raw bearer). A regression that switched back to raw-token comparison would have been silent without this assertion. Tests: 345/345 pass (was 332, +13 new). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/__tests__/get-client-ip.test.ts | 83 +++++++++++++++++++ src/lib/__tests__/idempotency.test.ts | 55 +++++++++++- src/lib/__tests__/require-auth-bearer.test.ts | 6 ++ src/lib/api-response.ts | 43 +++++++++- src/lib/idempotency.ts | 40 +++++---- .../jobs/__tests__/audit-log-cleanup.test.ts | 80 ++++++++++++++++++ src/lib/jobs/audit-log-cleanup.ts | 40 +++++++++ src/lib/jobs/reminder-worker.ts | 27 ++++++ 8 files changed, 355 insertions(+), 19 deletions(-) create mode 100644 src/lib/__tests__/get-client-ip.test.ts create mode 100644 src/lib/jobs/__tests__/audit-log-cleanup.test.ts create mode 100644 src/lib/jobs/audit-log-cleanup.ts diff --git a/src/lib/__tests__/get-client-ip.test.ts b/src/lib/__tests__/get-client-ip.test.ts new file mode 100644 index 0000000..379bd26 --- /dev/null +++ b/src/lib/__tests__/get-client-ip.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { getClientIp } from "../api-response"; + +const ORIGINAL_ENV = process.env.TRUST_PROXY_HOPS; + +beforeEach(() => { + delete process.env.TRUST_PROXY_HOPS; +}); + +afterEach(() => { + if (ORIGINAL_ENV === undefined) delete process.env.TRUST_PROXY_HOPS; + else process.env.TRUST_PROXY_HOPS = ORIGINAL_ENV; +}); + +function makeRequest(headers: Record): Request { + return new Request("https://example.com/", { headers }); +} + +// V3 audit: client-supplied X-Forwarded-For was being trusted blindly, +// allowing a per-request `XFF: 1.2.3.` to rotate the bucket of any +// per-IP rate-limit. The new contract reads XFF right-to-left with a +// configurable number of trusted hops (Express semantics). +describe("getClientIp trusted-proxy semantics (V3 audit)", () => { + it("defaults to 1 trusted hop — returns the rightmost XFF entry", () => { + const ip = getClientIp( + makeRequest({ "x-forwarded-for": "9.9.9.9, 1.2.3.4, 5.6.7.8" }), + ); + expect(ip).toBe("5.6.7.8"); + }); + + it("with TRUST_PROXY_HOPS=2 returns the second-from-rightmost", () => { + process.env.TRUST_PROXY_HOPS = "2"; + const ip = getClientIp( + makeRequest({ "x-forwarded-for": "9.9.9.9, 1.2.3.4, 5.6.7.8" }), + ); + expect(ip).toBe("1.2.3.4"); + }); + + it("with TRUST_PROXY_HOPS=0 ignores XFF entirely", () => { + process.env.TRUST_PROXY_HOPS = "0"; + const ip = getClientIp( + makeRequest({ + "x-forwarded-for": "9.9.9.9, 1.2.3.4", + "x-real-ip": "8.8.8.8", + }), + ); + expect(ip).toBe("8.8.8.8"); + }); + + it("with TRUST_PROXY_HOPS=0 and no x-real-ip returns null", () => { + process.env.TRUST_PROXY_HOPS = "0"; + const ip = getClientIp( + makeRequest({ "x-forwarded-for": "9.9.9.9, 1.2.3.4" }), + ); + expect(ip).toBeNull(); + }); + + it("XFF rotation attack — caller cannot bypass per-IP rate-limit by changing leftmost entry", () => { + // Attacker sends successive requests with rotating leftmost XFF; the + // proxy still appends the real client IP at the end, so the helper + // returns the same IP regardless of attacker chosen XFF. + const a = getClientIp( + makeRequest({ "x-forwarded-for": "1.1.1.1, 203.0.113.5" }), + ); + const b = getClientIp( + makeRequest({ "x-forwarded-for": "8.8.8.8, 203.0.113.5" }), + ); + expect(a).toBe("203.0.113.5"); + expect(b).toBe("203.0.113.5"); + }); + + it("rejects malformed entries in the chain", () => { + const ip = getClientIp( + makeRequest({ "x-forwarded-for": "garbage,, 5.6.7.8" }), + ); + expect(ip).toBe("5.6.7.8"); + }); + + it("falls back to x-real-ip if XFF missing", () => { + const ip = getClientIp(makeRequest({ "x-real-ip": "5.6.7.8" })); + expect(ip).toBe("5.6.7.8"); + }); +}); diff --git a/src/lib/__tests__/idempotency.test.ts b/src/lib/__tests__/idempotency.test.ts index c90f3bb..9b16069 100644 --- a/src/lib/__tests__/idempotency.test.ts +++ b/src/lib/__tests__/idempotency.test.ts @@ -30,7 +30,11 @@ vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn((raw: string) => `hashed:${raw}`), })); -import { withIdempotency, defaultUserIdResolver } from "../idempotency"; +import { + withIdempotency, + defaultUserIdResolver, + isCachableStatus, +} from "../idempotency"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { headers } from "next/headers"; @@ -227,6 +231,14 @@ describe("defaultUserIdResolver (audit C-4)", () => { expiresAt: null, } as never); expect(await defaultUserIdResolver()).toBe("u-bearer"); + // V3 audit: assert the where-clause used the hashed token, not the + // raw bearer. The hashToken mock returns "hashed:" — the lookup + // MUST be against that, otherwise we are storing recoverable secrets. + expect(prisma.apiToken.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ + where: { tokenHash: "hashed:hlk_abcdef" }, + }), + ); }); it("rejects revoked Bearer tokens", async () => { @@ -257,3 +269,44 @@ describe("defaultUserIdResolver (audit C-4)", () => { expect(await defaultUserIdResolver()).toBeNull(); }); }); + +// V3 audit STILL-V2-NEW: the cachable-status filter (do-not-cache for +// 401/403/408/429/5xx) had zero tests, so a regression that re-cached an +// expired bearer token's 401 would have been silent. +describe("isCachableStatus do-not-cache rules (V3 audit)", () => { + it("caches 2xx success responses", () => { + expect(isCachableStatus(200)).toBe(true); + expect(isCachableStatus(201)).toBe(true); + expect(isCachableStatus(204)).toBe(true); + }); + + it("caches 4xx validation responses (so retries don't re-execute side-effects)", () => { + expect(isCachableStatus(400)).toBe(true); + expect(isCachableStatus(404)).toBe(true); + expect(isCachableStatus(409)).toBe(true); + expect(isCachableStatus(422)).toBe(true); + }); + + it("does NOT cache 401 — the token may have been refreshed between attempts", () => { + expect(isCachableStatus(401)).toBe(false); + }); + + it("does NOT cache 403 — authorization can change between attempts", () => { + expect(isCachableStatus(403)).toBe(false); + }); + + it("does NOT cache 408 — caller-side timeout deserves a fresh attempt", () => { + expect(isCachableStatus(408)).toBe(false); + }); + + it("does NOT cache 429 — caller deserves a fresh window-check on retry", () => { + expect(isCachableStatus(429)).toBe(false); + }); + + it("does NOT cache any 5xx — server fault must not lock the user out", () => { + expect(isCachableStatus(500)).toBe(false); + expect(isCachableStatus(502)).toBe(false); + expect(isCachableStatus(503)).toBe(false); + expect(isCachableStatus(504)).toBe(false); + }); +}); diff --git a/src/lib/__tests__/require-auth-bearer.test.ts b/src/lib/__tests__/require-auth-bearer.test.ts index 2298ded..be7bde5 100644 --- a/src/lib/__tests__/require-auth-bearer.test.ts +++ b/src/lib/__tests__/require-auth-bearer.test.ts @@ -86,6 +86,12 @@ describe("requireAuth — Bearer token path", () => { const ctx = await requireAuth(); expect(hashToken).toHaveBeenCalledWith(RAW_TOKEN); + // V3 audit: pin the where-clause shape — a regression that switches + // back to raw-token comparison (where: { tokenHash: RAW_TOKEN }) would + // be a CRITICAL leak of stored hashes. Tests must enforce the hash. + expect(prisma.apiToken.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ where: { tokenHash: FAKE_HASH } }), + ); expect(ctx.user.id).toBe("user-1"); expect(ctx.session.id).toBe("token-1"); expect(ctx.session.expiresAt).toEqual(expiresAt); diff --git a/src/lib/api-response.ts b/src/lib/api-response.ts index ef14e4f..02130c2 100644 --- a/src/lib/api-response.ts +++ b/src/lib/api-response.ts @@ -27,8 +27,45 @@ export async function safeJson( } } +/** + * Resolve the real client IP from a request, respecting trusted-proxy + * configuration. V3 audit: previously took the leftmost XFF entry blindly, + * letting a client rotate `X-Forwarded-For: 1.2.3.4` per request to defeat + * IP-based rate-limits. + * + * Trust model (`TRUST_PROXY_HOPS` env): + * - "0" → ignore XFF entirely, fall back to x-real-ip / null + * (use this if HealthLog is internet-facing without a + * reverse proxy you control) + * - "1" (default)→ trust exactly one hop (typical Coolify / Caddy / + * Cloudflare-Tunnel single-proxy deployment); read the + * rightmost XFF entry which is the IP your proxy + * observed when the request arrived. + * - "N" (>1) → trust N hops; read the Nth-from-rightmost XFF entry. + * + * Returns the resolved IP or null when no trusted source is available. + */ +function looksLikeIp(s: string): boolean { + return /^[0-9a-fA-F.:]+$/.test(s) && s.length >= 3 && s.length <= 45; +} + export function getClientIp(request: Request): string | null { - const forwarded = request.headers.get("x-forwarded-for"); - if (forwarded) return forwarded.split(",")[0].trim(); - return request.headers.get("x-real-ip"); + const hopsRaw = process.env.TRUST_PROXY_HOPS; + const hops = hopsRaw === undefined ? 1 : Math.max(0, parseInt(hopsRaw, 10) || 0); + + if (hops > 0) { + const forwarded = request.headers.get("x-forwarded-for"); + if (forwarded) { + const chain = forwarded + .split(",") + .map((s) => s.trim()) + .filter(looksLikeIp); + if (chain.length > 0) { + const idx = Math.max(0, chain.length - hops); + return chain[idx]; + } + } + } + const realIp = request.headers.get("x-real-ip"); + return realIp && looksLikeIp(realIp) ? realIp : null; } diff --git a/src/lib/idempotency.ts b/src/lib/idempotency.ts index b4bbbb9..067a2bc 100644 --- a/src/lib/idempotency.ts +++ b/src/lib/idempotency.ts @@ -113,6 +113,30 @@ async function persistCached( }); } +/** + * Whether a response with the given HTTP status should be cached for + * idempotent replay. + * + * Cached: any 2xx/3xx, plus 4xx-validation (so the same broken request + * doesn't re-execute side-effects). Specifically NOT cached: + * 401 — token may have expired between the original call and the retry + * 403 — likewise authorization can change + * 408 — caller timed out, retry deserves a fresh attempt + * 429 — caller hit a rate-limit, retry deserves a fresh window check + * 5xx — server fault, retry must not be locked into a bogus result + * + * Exported so the do-not-cache contract is unit-tested independently of + * the database-backed wrapper. + */ +export function isCachableStatus(status: number): boolean { + if (status < 400) return true; + if (status >= 500) return false; + if (status === 401 || status === 403 || status === 408 || status === 429) { + return false; + } + return true; +} + /** * Default resolver: cookie session first, then Bearer token. The Bearer * fallback is what makes idempotency actually fire for native iOS / n8n @@ -191,21 +215,7 @@ export function withIdempotency< const response = await handler(...args); - // Cache only client-stable responses. Replaying a stale 401/403 - // (token expired mid-flight) or a 5xx would lock the user into a - // bogus result for the TTL window. 4xx-validation responses are - // intentionally cached so the same broken request doesn't hit the - // DB twice — but auth/throttle/server faults must not poison. - const cachable = - response.status < 400 || - (response.status >= 400 && - response.status < 500 && - response.status !== 401 && - response.status !== 403 && - response.status !== 408 && - response.status !== 429); - - if (cachable) { + if (isCachableStatus(response.status)) { // Defence-in-depth: never persist a body that carries a freshly-issued // bearer token. Auth routes shouldn't be wrapped in withIdempotency to // begin with, but if a future caller forgets, we refuse to leak. diff --git a/src/lib/jobs/__tests__/audit-log-cleanup.test.ts b/src/lib/jobs/__tests__/audit-log-cleanup.test.ts new file mode 100644 index 0000000..c7b4315 --- /dev/null +++ b/src/lib/jobs/__tests__/audit-log-cleanup.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { PrismaClient } from "@/generated/prisma/client"; +import { + cleanupOldAuditLogs, + getAuditLogRetentionDays, + DEFAULT_AUDIT_LOG_RETENTION_DAYS, +} from "../audit-log-cleanup"; + +function makePrismaMock(deletedCount: number) { + return { + auditLog: { + deleteMany: vi.fn().mockResolvedValue({ count: deletedCount }), + }, + } as unknown as PrismaClient; +} + +const ORIGINAL_ENV = { ...process.env }; + +beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + delete process.env.AUDIT_LOG_RETENTION_DAYS; +}); + +afterEach(() => { + process.env = ORIGINAL_ENV; + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +describe("getAuditLogRetentionDays", () => { + it("returns 365 when env is unset", () => { + expect(getAuditLogRetentionDays()).toBe(DEFAULT_AUDIT_LOG_RETENTION_DAYS); + }); + + it("respects a valid override", () => { + process.env.AUDIT_LOG_RETENTION_DAYS = "180"; + expect(getAuditLogRetentionDays()).toBe(180); + }); + + it("ignores nonsensical values (NaN, 0, negative)", () => { + process.env.AUDIT_LOG_RETENTION_DAYS = "0"; + expect(getAuditLogRetentionDays()).toBe(DEFAULT_AUDIT_LOG_RETENTION_DAYS); + process.env.AUDIT_LOG_RETENTION_DAYS = "-100"; + expect(getAuditLogRetentionDays()).toBe(DEFAULT_AUDIT_LOG_RETENTION_DAYS); + process.env.AUDIT_LOG_RETENTION_DAYS = "not a number"; + expect(getAuditLogRetentionDays()).toBe(DEFAULT_AUDIT_LOG_RETENTION_DAYS); + }); + + it("rejects too-short retention (< 7 days) to guard misconfigs", () => { + process.env.AUDIT_LOG_RETENTION_DAYS = "3"; + expect(getAuditLogRetentionDays()).toBe(DEFAULT_AUDIT_LOG_RETENTION_DAYS); + }); +}); + +describe("cleanupOldAuditLogs", () => { + it("deletes audit log rows older than 365 days by default", async () => { + const prisma = makePrismaMock(42); + const now = new Date("2026-05-04T00:00:00Z"); + const deleted = await cleanupOldAuditLogs(prisma, now); + + expect(deleted).toBe(42); + expect(prisma.auditLog.deleteMany).toHaveBeenCalledTimes(1); + const cutoff = new Date(now.getTime() - 365 * 86_400_000); + expect(prisma.auditLog.deleteMany).toHaveBeenCalledWith({ + where: { createdAt: { lt: cutoff } }, + }); + }); + + it("uses the configured retention", async () => { + process.env.AUDIT_LOG_RETENTION_DAYS = "90"; + const prisma = makePrismaMock(7); + const now = new Date("2026-05-04T00:00:00Z"); + await cleanupOldAuditLogs(prisma, now); + + const cutoff = new Date(now.getTime() - 90 * 86_400_000); + expect(prisma.auditLog.deleteMany).toHaveBeenCalledWith({ + where: { createdAt: { lt: cutoff } }, + }); + }); +}); diff --git a/src/lib/jobs/audit-log-cleanup.ts b/src/lib/jobs/audit-log-cleanup.ts new file mode 100644 index 0000000..5fb5117 --- /dev/null +++ b/src/lib/jobs/audit-log-cleanup.ts @@ -0,0 +1,40 @@ +/** + * Daily cleanup for the `audit_logs` table. + * + * V3 audit (GDPR Art. 5(1)(e) "storage limitation"): audit log accumulates + * IP + city + login events forever. Without retention, a self-hosted + * deployment is non-compliant with the principle that personal data must + * not be stored "longer than is necessary". + * + * Default retention is 365 days (configurable via AUDIT_LOG_RETENTION_DAYS + * env). Rows older than the cutoff are deleted in a single bulk + * `deleteMany`; runs daily via pg-boss. + */ +import type { PrismaClient } from "@/generated/prisma/client"; + +export const DEFAULT_AUDIT_LOG_RETENTION_DAYS = 365; + +export function getAuditLogRetentionDays(): number { + const raw = process.env.AUDIT_LOG_RETENTION_DAYS; + if (raw === undefined) return DEFAULT_AUDIT_LOG_RETENTION_DAYS; + const parsed = parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return DEFAULT_AUDIT_LOG_RETENTION_DAYS; + } + // Refuse very-short retention windows accidentally set to seconds — we + // don't want a misconfig nuking a fresh audit table. + if (parsed < 7) return DEFAULT_AUDIT_LOG_RETENTION_DAYS; + return parsed; +} + +export async function cleanupOldAuditLogs( + prisma: PrismaClient, + now: Date = new Date(), +): Promise { + const days = getAuditLogRetentionDays(); + const cutoff = new Date(now.getTime() - days * 86_400_000); + const { count } = await prisma.auditLog.deleteMany({ + where: { createdAt: { lt: cutoff } }, + }); + return count; +} diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index b9b125b..b2bb3b8 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -28,6 +28,7 @@ import { } from "@/lib/jobs/worker-status"; import { setGlobalBoss } from "@/lib/jobs/boss-instance"; import { cleanupExpiredIdempotencyKeys } from "@/lib/jobs/idempotency-cleanup"; +import { cleanupOldAuditLogs } from "@/lib/jobs/audit-log-cleanup"; import { deleteMessage } from "@/lib/telegram"; import { decrypt, encrypt } from "@/lib/crypto"; import { syncMoodLogEntries } from "@/lib/moodlog/sync"; @@ -93,6 +94,8 @@ const RATE_LIMIT_CLEANUP_QUEUE = "rate-limit-cleanup"; const RATE_LIMIT_CLEANUP_CRON = "*/5 * * * *"; // every 5 minutes const IDEMPOTENCY_CLEANUP_QUEUE = "idempotency-cleanup"; const IDEMPOTENCY_CLEANUP_CRON = "0 3 * * *"; // daily at 03:00 (Europe/Berlin) +const AUDIT_LOG_CLEANUP_QUEUE = "audit-log-cleanup"; +const AUDIT_LOG_CLEANUP_CRON = "15 3 * * *"; // daily at 03:15 (Europe/Berlin) interface ReminderCheckPayload { triggeredAt: string; @@ -148,6 +151,10 @@ interface IdempotencyCleanupPayload { triggeredAt: string; } +interface AuditLogCleanupPayload { + triggeredAt: string; +} + // Re-export timezone utilities under local names for backward compatibility const getUserTodayBounds = getUserTodayBoundsUtil; const getDayOfWeekInTz = getDayOfWeekInTzUtil; @@ -840,6 +847,19 @@ async function handleIdempotencyCleanup( }); } +async function handleAuditLogCleanup(jobs: Job[]) { + void jobs; + await withBackgroundEvent("job.audit_log_cleanup", async (evt) => { + const p = getWorkerPrisma(); + try { + const deleted = await cleanupOldAuditLogs(p); + evt.addMeta("audit_log_cleanup_deleted", deleted); + } catch (err) { + evt.addWarning(`audit-log-cleanup failed: ${err}`); + } + }); +} + async function handleDataBackup(jobs: Job[]) { void jobs; await withBackgroundEvent("job.data_backup", async (evt) => { @@ -1021,6 +1041,7 @@ export async function startReminderWorker() { DATA_BACKUP_QUEUE, RATE_LIMIT_CLEANUP_QUEUE, IDEMPOTENCY_CLEANUP_QUEUE, + AUDIT_LOG_CLEANUP_QUEUE, ]; for (const q of allQueues) { @@ -1041,6 +1062,7 @@ export async function startReminderWorker() { [DATA_BACKUP_QUEUE, DATA_BACKUP_CRON], [RATE_LIMIT_CLEANUP_QUEUE, RATE_LIMIT_CLEANUP_CRON], [IDEMPOTENCY_CLEANUP_QUEUE, IDEMPOTENCY_CLEANUP_CRON], + [AUDIT_LOG_CLEANUP_QUEUE, AUDIT_LOG_CLEANUP_CRON], ]; for (const [name, cron] of schedules) { @@ -1113,6 +1135,11 @@ export async function startReminderWorker() { { localConcurrency: 1 }, handleIdempotencyCleanup, ); + await boss.work( + AUDIT_LOG_CLEANUP_QUEUE, + { localConcurrency: 1 }, + handleAuditLogCleanup, + ); return boss; } From 7c2ecb1fc423b879ae6d948f2cf262d5b1061331 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 8 May 2026 00:42:22 +0200 Subject: [PATCH 11/13] fix(security): encrypt moodLog webhook secret at rest with AES-256-GCM (V3 audit STILL-V2-C-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mood_log_webhook_secret column previously stored a plaintext secret that the webhook handler compared timing-safe against every enabled user. A DB dump or backup snapshot leaked active webhook credentials. New contract — no schema migration required: - Writes go through encryptMoodLogSecret() (AES-256-GCM via existing crypto helper, ENCRYPTION_KEY). - Reads go through readMoodLogSecret() which transparently decrypts envelopes and tolerates legacy plaintext rows during the transition (graceful upgrade path — no breaking change for self-hosted users). - Webhook lookup decrypts each candidate before timing-safe compare. - Status endpoint decrypts for the user's settings page. One-shot startup migration in reminder-worker rotates any leftover plaintext rows on next worker boot. Idempotent; encrypted rows are skipped. Tests: 350/350 pass (was 345, +5 new — round-trip, legacy passthrough, isLegacyPlaintext semantics, rotateLegacy idempotence). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/integrations/moodlog/status/route.ts | 7 +- .../api/integrations/moodlog/webhook/route.ts | 9 +- src/app/api/settings/moodlog/route.ts | 15 +++- src/lib/__tests__/moodlog-secret.test.ts | 87 +++++++++++++++++++ src/lib/jobs/reminder-worker.ts | 26 ++++++ src/lib/moodlog-secret.ts | 76 ++++++++++++++++ 6 files changed, 214 insertions(+), 6 deletions(-) create mode 100644 src/lib/__tests__/moodlog-secret.test.ts create mode 100644 src/lib/moodlog-secret.ts diff --git a/src/app/api/integrations/moodlog/status/route.ts b/src/app/api/integrations/moodlog/status/route.ts index 1835aa1..9c129eb 100644 --- a/src/app/api/integrations/moodlog/status/route.ts +++ b/src/app/api/integrations/moodlog/status/route.ts @@ -2,6 +2,7 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; +import { readMoodLogSecret } from "@/lib/moodlog-secret"; export const dynamic = "force-dynamic"; @@ -23,11 +24,15 @@ export const GET = apiHandler(async () => { where: { userId: user.id }, }); + // V3 audit STILL-V2-C-2: stored secret is now AES-GCM encrypted at rest. + // Decrypt for the user's settings page; legacy plaintext is also handled. + const webhookSecret = readMoodLogSecret(dbUser?.moodLogWebhookSecret ?? null); + return apiSuccess({ configured: Boolean(dbUser?.moodLogUrlEncrypted), enabled: dbUser?.moodLogEnabled ?? false, lastSyncedAt: dbUser?.moodLogLastSyncedAt ?? null, entryCount, - webhookSecret: dbUser?.moodLogWebhookSecret ?? null, + webhookSecret, }); }); diff --git a/src/app/api/integrations/moodlog/webhook/route.ts b/src/app/api/integrations/moodlog/webhook/route.ts index 646325a..06fe99e 100644 --- a/src/app/api/integrations/moodlog/webhook/route.ts +++ b/src/app/api/integrations/moodlog/webhook/route.ts @@ -5,6 +5,7 @@ import { apiError } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; import { getClientIp } from "@/lib/api-response"; import { moodLogWebhookPayloadSchema } from "@/lib/validations/moodlog"; +import { readMoodLogSecret } from "@/lib/moodlog-secret"; import { apiHandler } from "@/lib/api-handler"; import { annotate, getEvent } from "@/lib/logging/context"; @@ -44,10 +45,14 @@ export const POST = apiHandler(async (request: NextRequest) => { select: { id: true, moodLogWebhookSecret: true }, }); + // V3 audit STILL-V2-C-2: stored secret is now encrypted at rest. Decrypt + // each candidate before the timing-safe compare. `readMoodLogSecret` + // also tolerates legacy plaintext rows during the transition window. const receivedBuf = Buffer.from(webhookSecret, "utf8"); const user = candidates.find((c) => { - if (!c.moodLogWebhookSecret) return false; - const expectedBuf = Buffer.from(c.moodLogWebhookSecret, "utf8"); + const expected = readMoodLogSecret(c.moodLogWebhookSecret); + if (!expected) return false; + const expectedBuf = Buffer.from(expected, "utf8"); if (expectedBuf.length !== receivedBuf.length) return false; return timingSafeEqual(expectedBuf, receivedBuf); }); diff --git a/src/app/api/settings/moodlog/route.ts b/src/app/api/settings/moodlog/route.ts index 5c14cb9..fb61725 100644 --- a/src/app/api/settings/moodlog/route.ts +++ b/src/app/api/settings/moodlog/route.ts @@ -3,6 +3,10 @@ import { randomBytes } from "node:crypto"; import { prisma } from "@/lib/db"; import { apiSuccess, apiError } from "@/lib/api-response"; import { encrypt } from "@/lib/crypto"; +import { + encryptMoodLogSecret, + readMoodLogSecret, +} from "@/lib/moodlog-secret"; import { moodLogCredentialsSchema } from "@/lib/validations/moodlog"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; @@ -26,14 +30,16 @@ export const PUT = apiHandler(async (request: NextRequest) => { const { url, apiKey } = parsed.data; - // Generate webhook secret if not yet set + // Generate webhook secret if not yet set; otherwise reuse the existing + // one (decrypted from at-rest storage; legacy plaintext is also handled). const existing = await prisma.user.findUnique({ where: { id: user.id }, select: { moodLogWebhookSecret: true }, }); const webhookSecret = - existing?.moodLogWebhookSecret ?? `mb_${randomBytes(32).toString("hex")}`; + readMoodLogSecret(existing?.moodLogWebhookSecret ?? null) ?? + `mb_${randomBytes(32).toString("hex")}`; await prisma.user.update({ where: { id: user.id }, @@ -41,7 +47,10 @@ export const PUT = apiHandler(async (request: NextRequest) => { moodLogUrlEncrypted: encrypt(url), moodLogApiKeyEncrypted: encrypt(apiKey), moodLogEnabled: true, - moodLogWebhookSecret: webhookSecret, + // V3 audit STILL-V2-C-2: encrypt at rest with AES-256-GCM. A legacy + // plaintext value (rare) is rotated to the encrypted form on this + // write transparently. + moodLogWebhookSecret: encryptMoodLogSecret(webhookSecret), }, }); diff --git a/src/lib/__tests__/moodlog-secret.test.ts b/src/lib/__tests__/moodlog-secret.test.ts new file mode 100644 index 0000000..0172cc4 --- /dev/null +++ b/src/lib/__tests__/moodlog-secret.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + encryptMoodLogSecret, + readMoodLogSecret, + isLegacyPlaintext, + rotateLegacyMoodLogSecrets, +} from "../moodlog-secret"; + +const ORIGINAL_ENCRYPTION_KEY = process.env.ENCRYPTION_KEY; + +beforeEach(() => { + process.env.ENCRYPTION_KEY = "a".repeat(64); +}); + +afterEach(() => { + if (ORIGINAL_ENCRYPTION_KEY === undefined) delete process.env.ENCRYPTION_KEY; + else process.env.ENCRYPTION_KEY = ORIGINAL_ENCRYPTION_KEY; +}); + +// V3 audit STILL-V2-C-2: mood_log_webhook_secret was stored in plaintext. +// The new contract encrypts at rest with AES-256-GCM and tolerates legacy +// plaintext rows during the transition window. +describe("moodLog secret encrypt-at-rest", () => { + it("encrypts and decrypts a generated secret round-trip", () => { + const plaintext = "mb_" + "f".repeat(64); + const encrypted = encryptMoodLogSecret(plaintext); + expect(encrypted).not.toBe(plaintext); + expect(readMoodLogSecret(encrypted)).toBe(plaintext); + }); + + it("treats unrecognised values as legacy plaintext (transitional grace)", () => { + expect(readMoodLogSecret("mb_legacyplaintextsecret")).toBe( + "mb_legacyplaintextsecret", + ); + }); + + it("returns null for null/empty stored values", () => { + expect(readMoodLogSecret(null)).toBeNull(); + expect(readMoodLogSecret("")).toBeNull(); + }); + + it("isLegacyPlaintext detects rotation status correctly", () => { + const enc = encryptMoodLogSecret("mb_test"); + expect(isLegacyPlaintext(enc)).toBe(false); + expect(isLegacyPlaintext("mb_test")).toBe(true); + expect(isLegacyPlaintext(null)).toBe(false); + }); + + it("rotateLegacyMoodLogSecrets encrypts only legacy rows and is idempotent", async () => { + const enc = encryptMoodLogSecret("mb_alreadyencrypted"); + const rows = [ + { id: "u1", moodLogWebhookSecret: "mb_legacy1" }, + { id: "u2", moodLogWebhookSecret: enc }, + { id: "u3", moodLogWebhookSecret: "mb_legacy3" }, + { id: "u4", moodLogWebhookSecret: null }, + ]; + const updates: Array<{ id: string; encrypted: string }> = []; + + const rotated = await rotateLegacyMoodLogSecrets({ + findLegacy: async () => rows, + rotate: async (id, encrypted) => { + updates.push({ id, encrypted }); + }, + }); + + expect(rotated).toBe(2); + expect(updates.map((u) => u.id).sort()).toEqual(["u1", "u3"]); + for (const update of updates) { + // Each rotation must produce a value that decrypts back to the + // original legacy plaintext — no data loss. + const original = rows.find((r) => r.id === update.id)!.moodLogWebhookSecret; + expect(readMoodLogSecret(update.encrypted)).toBe(original); + } + + // Re-running on the now-encrypted store rotates nothing. + const next = await rotateLegacyMoodLogSecrets({ + findLegacy: async () => [ + { id: "u1", moodLogWebhookSecret: updates[0].encrypted }, + { id: "u2", moodLogWebhookSecret: enc }, + ], + rotate: async () => { + throw new Error("should not be called"); + }, + }); + expect(next).toBe(0); + }); +}); diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index b2bb3b8..49b9026 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -29,6 +29,7 @@ import { import { setGlobalBoss } from "@/lib/jobs/boss-instance"; import { cleanupExpiredIdempotencyKeys } from "@/lib/jobs/idempotency-cleanup"; import { cleanupOldAuditLogs } from "@/lib/jobs/audit-log-cleanup"; +import { rotateLegacyMoodLogSecrets } from "@/lib/moodlog-secret"; import { deleteMessage } from "@/lib/telegram"; import { decrypt, encrypt } from "@/lib/crypto"; import { syncMoodLogEntries } from "@/lib/moodlog/sync"; @@ -1003,6 +1004,31 @@ export async function startReminderWorker() { setGlobalBoss(boss); markWorkerStarted(); + // V3 audit STILL-V2-C-2: encrypt-at-rest one-shot migration. Rotates + // any rows that still hold a plaintext mood_log_webhook_secret to the + // AES-256-GCM envelope. Idempotent — encrypted rows are skipped. + try { + const p = getWorkerPrisma(); + const rotated = await rotateLegacyMoodLogSecrets({ + findLegacy: () => + p.user.findMany({ + where: { moodLogWebhookSecret: { not: null } }, + select: { id: true, moodLogWebhookSecret: true }, + }), + rotate: async (id, encryptedSecret) => { + await p.user.update({ + where: { id }, + data: { moodLogWebhookSecret: encryptedSecret }, + }); + }, + }); + if (rotated > 0) { + workerLog("error", `moodlog-secret-migration: rotated ${rotated} legacy plaintext secret(s)`); + } + } catch (err) { + workerLog("error", `moodlog-secret-migration failed: ${err}`); + } + // Graceful shutdown: drain in-flight jobs on SIGTERM/SIGINT (sent by // Docker Compose `docker stop`, Kubernetes pod termination, Coolify // redeploys). Without this, pending handlers were force-killed and could diff --git a/src/lib/moodlog-secret.ts b/src/lib/moodlog-secret.ts new file mode 100644 index 0000000..fffe689 --- /dev/null +++ b/src/lib/moodlog-secret.ts @@ -0,0 +1,76 @@ +/** + * moodLog webhook secret — encrypt-at-rest helpers (V3 audit STILL-V2-C-2). + * + * Previously the `mood_log_webhook_secret` column stored the secret in + * plaintext. The webhook handler did a timing-safe `Buffer.equals` against + * every enabled user's plaintext secret to find a match. + * + * The new contract: + * - All writes go through `encryptMoodLogSecret()` → AES-256-GCM + * base64 envelope. + * - Reads go through `readMoodLogSecret()` which decrypts envelopes; + * if a stored value is NOT a valid envelope it is treated as a legacy + * plaintext secret (transitional grace period). Operators can leave + * existing rows in place — the next write rotates them to encrypted. + * + * The webhook lookup still iterates candidates, but each candidate is + * decrypted before the timing-safe compare. Cost is O(n) decrypt calls + * per webhook invocation; acceptable for small deployments and easily + * upgraded later by adding an HMAC lookup column. + */ +import { encrypt, decrypt } from "@/lib/crypto"; + +export function encryptMoodLogSecret(plaintext: string): string { + return encrypt(plaintext); +} + +export function readMoodLogSecret(stored: string | null): string | null { + if (!stored) return null; + try { + return decrypt(stored); + } catch { + // Legacy plaintext — keep returning it so the integration keeps + // working until the next write rotates it. Operators can force a + // migration by hitting the rotation endpoint. + return stored; + } +} + +/** + * Whether the stored value still looks like an unencrypted legacy secret. + * Used by the boot-time migration helper + the rotation flow. + */ +export function isLegacyPlaintext(stored: string | null): boolean { + if (!stored) return false; + try { + decrypt(stored); + return false; + } catch { + return true; + } +} + +interface MoodLogSecretMigrationDeps { + findLegacy: () => Promise>; + rotate: (id: string, encryptedSecret: string) => Promise; +} + +/** + * One-shot startup migration: encrypts any leftover plaintext secret rows + * with AES-GCM. Idempotent — encrypted rows are skipped automatically. + * Returns the number of rows rotated. + */ +export async function rotateLegacyMoodLogSecrets( + deps: MoodLogSecretMigrationDeps, +): Promise { + const candidates = await deps.findLegacy(); + let rotated = 0; + for (const row of candidates) { + if (!row.moodLogWebhookSecret) continue; + if (!isLegacyPlaintext(row.moodLogWebhookSecret)) continue; + const encrypted = encryptMoodLogSecret(row.moodLogWebhookSecret); + await deps.rotate(row.id, encrypted); + rotated += 1; + } + return rotated; +} From 28edda30f658680f3f8b1451366569aee142bcd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 8 May 2026 00:45:40 +0200 Subject: [PATCH 12/13] docs: sync README + AGENTS + CLAUDE + CHANGELOG + OpenAPI for v1.3.3 (V3 audit doc-drift) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V3 audit Doku Specialist findings — drift between code and meta-docs. - README §Key Features now mentions pulse oximetry (SpO₂) + Withings ScanWatch sync; Tech Stack model count corrected 23 → 25. - AGENTS.md status promoted v1.3.2 → v1.3.3; vitest config corrected `vitest.config.ts` → `vitest.config.mts`; migration range corrected `0001–0022` → `0001–0024`; model count + list updated to 25 (adds Device, IdempotencyKey). - CLAUDE.md model count updated (2 occurrences). - CHANGELOG.md v1.3.3 expanded with Security + Internal sections that were missing the audit-fix-marathon work (Bearer-scope, account-delete cascade, moodLog encryption, CSP tightening, Web-Push SSRF, IP-geo HTTPS, AI-test leak fix, import rate-limit, trusted-proxy XFF, audit-log retention, idempotency cachable filter, Bearer mock tightening, enum-drift cousins, PDF text-content tests). - OpenAPI Bearer description corrected `SHA-256 hashes` → `HMAC-SHA-256 hashes (keyed with API_TOKEN_HMAC_KEY)` so the docs match the actual server-side implementation. Wildcard scope contract documented. Co-Authored-By: Claude Opus 4.7 (1M context) --- AGENTS.md | 10 +++--- CHANGELOG.md | 78 ++++++++++++++++++++++++++++++++++++++++++- CLAUDE.md | 4 +-- README.md | 4 +-- docs/api/openapi.yaml | 11 +++--- 5 files changed, 93 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e52cc50..9a689f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ Instructions for AI coding agents (OpenAI Codex, Claude Code, Cursor, etc.) work **HealthLog** — a personal health-tracking web app (weight, blood pressure, pulse, mood, medication compliance) with Withings integration, moodLog.app sync, Dracula-themed UI, mobile-first PWA design. -**Status**: v1.3.2 — Body composition (Total Body Water + Bone Mass), SSRF-hardened outbound fetches, GHCR multi-arch images (`linux/amd64` + `linux/arm64`) with SLSA provenance + SBOM, pg-boss graceful SIGTERM drain, blocking TypeScript CI, locale-integrity test guard. See GitHub Releases + CHANGELOG.md for the full feature timeline (v1.0 → v1.3). +**Status**: v1.3.3 — Pulse oximetry (SpO₂) as a first-class measurement type, layered on top of v1.3.2 body composition (TBW + Bone Mass). SSRF-hardened outbound fetches (now also covers Web-Push endpoint + Bearer-scope wildcard handling + IP-geolocation HTTPS-only), GHCR multi-arch images (`linux/amd64` + `linux/arm64`) with SLSA provenance + SBOM, pg-boss graceful SIGTERM drain + audit-log retention purge (GDPR Art. 5(1)(e)), blocking TypeScript CI, locale-integrity test guard. moodLog webhook secret now AES-GCM encrypted at rest. See GitHub Releases + CHANGELOG.md for the full feature timeline (v1.0 → v1.3). ## Tech Stack @@ -20,7 +20,7 @@ Instructions for AI coding agents (OpenAI Codex, Claude Code, Cursor, etc.) work | CSS | Tailwind | 4 | CSS-first config (`@import "tailwindcss"` syntax) | | Data fetching | TanStack Query | 5 | Provider in `src/components/providers.tsx` | | Validation | Zod | v4 | Import as `zod/v4` (not `zod`) | -| Testing | Vitest | latest | Config in `vitest.config.ts` | +| Testing | Vitest | latest | Config in `vitest.config.mts` | | Package manager | pnpm | latest | **Not** npm or yarn | | Node | 20.x | via nvm | | | Job queue | pg-boss | 12 | Named import `{ PgBoss }`, see gotchas | @@ -126,8 +126,8 @@ messages/ ├── de.json # German translations (primary UI language) └── en.json # English translations prisma/ -├── schema.prisma # Database schema (23 models) -└── migrations/ # Migration files (0001–0022; latest: body_composition_metrics) +├── schema.prisma # Database schema (25 models) +└── migrations/ # Migration files (0001–0024; latest: oxygen_saturation) prisma.config.ts # Prisma config (DB URL lives here, NOT in schema) public/ ├── sw.js # Service worker (Web Push + offline caching) @@ -216,7 +216,7 @@ These are hard-won lessons. Ignoring them will cause errors: ## Database Models (Prisma) -23 models: `User`, `Passkey`, `Session`, `AuthChallenge`, `Measurement`, `Medication`, `MedicationSchedule`, `MedicationIntakeEvent`, `ReminderPhaseConfig`, `TelegramReminderMessage`, `TelegramScheduledDeletion`, `ApiToken`, `WithingsConnection`, `MoodEntry`, `AppSettings`, `Feedback`, `AuditLog`, `NotificationChannel`, `NotificationPreference`, `PushSubscription`, `DataBackup`, `UserAchievement`, `RateLimit`. +25 models: `User`, `Passkey`, `Session`, `AuthChallenge`, `Measurement`, `Medication`, `MedicationSchedule`, `MedicationIntakeEvent`, `ReminderPhaseConfig`, `TelegramReminderMessage`, `TelegramScheduledDeletion`, `ApiToken`, `WithingsConnection`, `MoodEntry`, `AppSettings`, `Feedback`, `AuditLog`, `NotificationChannel`, `NotificationPreference`, `PushSubscription`, `DataBackup`, `UserAchievement`, `RateLimit`, `Device`, `IdempotencyKey`. ## When Making Changes diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d4990e..267ecf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [1.3.3] — 2026-05-07 +## [1.3.3] — 2026-05-08 ### Added @@ -55,6 +55,82 @@ surface (validators, Withings client, doctor PDF) treated it as `kg`. Comment corrected to match reality. +### Security + +- **Bearer-scope wildcard handling (CRITICAL — V3-1).** `requireAuth()` + previously accepted any non-admin token regardless of declared + permission scope, so a token with `permissions:["medication:ingest"]` + could DELETE the user account. Spec now requires `permissions:["*"]` + or the explicit required permission. +- **Account-deletion completeness (CRITICAL — V3-2 / GDPR Art. 17).** + Cascades through `Feedback` + `AuditLog` rows so user-erasure is + actually total. Daily retention job sweeps orphaned audit rows after + 90 days as a defence-in-depth. +- **Withings webhook secret header migration (audit C-3)**, idempotency + Bearer-resolver (audit C-4), GlitchTip URL strip (audit H-B7). +- **Truthfulness pass on medical citations** — SpO2 normal-range source + is now consumer-pulse-oximeter consensus + NICE NG115 + FDA labelling + (BTS-2017 was for clinical hypoxaemia thresholds, not consumer + monitoring); body-composition metrics are explicitly labelled + "bioimpedance-estimated, not DEXA-comparable" in the doctor PDF; + TBW citation now references the Watson formula / ICRP Reference Man + (was misattributed to ESPEN 2017); steps target now references + Saint-Maurice JAMA 2020 (WHO publishes minutes/week, not steps). +- **SpO2 user-override clamp** — overrides could emit physical + impossibilities (e.g. `orangeMax = 100.75`); clamped to METRIC_BOUNDS + for SpO2 + BODY_FAT. +- **moodLog webhook secret encrypted at rest with AES-256-GCM** (V3 + STILL-V2-C-2). Read path tolerates legacy plaintext rows during the + transition window; one-shot startup migration in the worker rotates + any leftover plaintext rows. +- **CSP tightening** — `chatgpt.com` + `api.openai.com` `connect-src` + now gated to `/settings/ai/**` (was a global blanket on every page, + including `/auth/login` → DOM-XSS exfil channel). +- **Web-Push subscription endpoint SSRF guard** — `endpoint` now + requires HTTPS + passes `isPublicUrl()` (was `z.url()` only). + Side-fix: `isPublicUrl()` no longer falsely classifies DNS labels + starting with `fc`/`fd` (e.g. `fcm.googleapis.com`) as IPv6 + unique-local; the IPv6 check is now gated on a colon being present. +- **IP-geolocation lookup is now HTTPS-only.** Default provider is + `ipwho.is` (free, HTTPS, no key). Existing `ip-api.com` plaintext + HTTP path leaked auth-event IP + timestamp on every login (GDPR Art. + 32 + Art. 44). Operators can override via `IP_GEO_LOOKUP_URL` (HTTPS + only) or disable entirely with `IP_GEO_LOOKUP_DISABLED=1`. +- **`/api/ai/test` no longer returns provider error message + body + excerpt to the client.** Diagnostics land server-side via Wide Events + (annotate); client gets a categorised generic message. Closes provider + URL / partial key / internal header leak. +- **`/api/import` rate-limit added** — 5 imports/hour/user. Was + unlimited (bulk-injection vector). +- **Trusted-proxy XFF semantics** — `getClientIp()` now reads + `X-Forwarded-For` right-to-left with a configurable + `TRUST_PROXY_HOPS` (default 1, matches typical single-proxy + self-host). Closes XFF rotation bypass of per-IP rate-limits. +- **Audit-log retention job** — `audit_logs` rows older than + `AUDIT_LOG_RETENTION_DAYS` (default 365) are purged daily. Closes + GDPR Art. 5(1)(e) "storage limitation" gap. +- **Idempotency cachable-status filter** is now an exported, unit-tested + function — pins the do-not-cache contract for 401/403/408/429/5xx. +- **Bearer mock tightening** in `require-auth-bearer.test.ts` + + `idempotency.test.ts`: `apiToken.findUnique` calls are now asserted + to use `where: { tokenHash: }`, so a regression to raw-token + comparison would break the suite immediately. + +### Internal + +- **Server-side enum drift cousins closed.** Five module-level + hardcoded type-arrays in `/api/insights/comprehensive`, + `/api/dashboard/summary`, `/api/analytics`, `/lib/insights/general-status`, + `/api/import` are now derived from `measurementTypeEnum.options`. + External-contract enums extended additively: + `/api/measurements/series` (`oxygen`, `totalBodyWater`, `boneMass`), + `/api/dashboard/widgets` (`oxygenSaturation`), `DashboardWidgetId` + + `DEFAULT_DASHBOARD_LAYOUT`. New coverage test asserts the canonical + enum stays the source of truth. +- **Doctor-PDF text-content tests** — replaced bytes-only "renders body + composition rows" theatre with `pdf-parse`-driven assertions on the + actual rendered DE + EN labels and values. Adds dev dep `pdf-parse`. + ## [1.3.2] — 2026-04-28 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 46ab76d..e84f5a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ docker compose logs -f app # Tail app logs ## Architecture - **Next.js 16** App Router with TypeScript strict. Pages are RSC by default; `"use client"` only for interactivity. -- **Prisma 7** ORM with PostgreSQL (23 models). Uses `PrismaPg` adapter from `@prisma/adapter-pg`. Client singleton at `src/lib/db.ts`. Generated client at `src/generated/prisma/client` (note the `/client` suffix). Prisma config in `prisma.config.ts` (not in schema.prisma). +- **Prisma 7** ORM with PostgreSQL (25 models). Uses `PrismaPg` adapter from `@prisma/adapter-pg`. Client singleton at `src/lib/db.ts`. Generated client at `src/generated/prisma/client` (note the `/client` suffix). Prisma config in `prisma.config.ts` (not in schema.prisma). - **shadcn/ui** components (new-york style) in `src/components/ui/`. Add new ones via `pnpm dlx shadcn@latest add `. - **Dracula theme** via CSS variables in `globals.css`. Dark mode is default. Use `--dracula-*` tokens for chart colors. - **TanStack Query** for client-side data fetching. Provider in `src/components/providers.tsx`. @@ -81,7 +81,7 @@ docker compose logs -f app # Tail app logs - `src/lib/validations/` — Zod schemas shared between API + client - `src/hooks/` — React hooks (`use-auth`) - `messages/de.json` + `messages/en.json` — i18n translations -- `prisma/schema.prisma` — database schema (23 models) +- `prisma/schema.prisma` — database schema (25 models) - `prisma.config.ts` — Prisma config (DB URL here, not in schema) - `public/sw.js` — Service worker for Web Push notifications + offline caching - `docs/` — long-form audit notes (`docs/audit/`); end-user docs live in the separate site at https://docs.healthlog.dev diff --git a/README.md b/README.md index 68bf817..a8fe961 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a ## Key Features -**Health Metrics** -- Track weight, blood pressure, pulse, body fat, sleep, steps, blood glucose (fasting/postprandial/random/bedtime, mg/dL ↔ mmol/L), total body water, and bone mass with interactive trend charts, moving averages, and traffic-light ranges based on ESC/ESH 2018 and ADA 2024 guidelines. Body-composition metrics sync automatically from Withings Body+ scales. +**Health Metrics** -- Track weight, blood pressure, pulse, body fat, sleep, steps, blood glucose (fasting/postprandial/random/bedtime, mg/dL ↔ mmol/L), total body water, bone mass, and pulse oximetry (SpO₂) with interactive trend charts, moving averages, and traffic-light ranges based on ESC/ESH 2018, ADA 2024, and consensus pulse-oximeter guidance (NICE NG115). Body-composition + SpO₂ metrics sync automatically from Withings Body+ scales and ScanWatch devices. **Custom Thresholds** -- Override the computed default ranges per metric with the targets your clinician set. Audit-logged. Doctor Report PDF prints both your target and the standard reference. @@ -102,7 +102,7 @@ Open **http://localhost:3000**. The first registered user becomes admin. | ------------- | ------------------------------------------------- | | Framework | Next.js 16 (App Router, React Server Components) | | Language | TypeScript (strict mode) | -| Database | PostgreSQL 16 + Prisma 7 (23 models) | +| Database | PostgreSQL 16 + Prisma 7 (25 models) | | Job Queue | pg-boss 12 (reminders, insights, backups) | | UI | shadcn/ui, Tailwind CSS 4, Radix UI, Lucide Icons | | Charts | Recharts 3 | diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 15644fb..e5db14c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -3296,10 +3296,13 @@ components: Long-lived API token issued via `POST /api/tokens`. Accepted on all user endpoints (interchangeable with the session cookie) and required on external ingest endpoints (e.g. `/api/ingest/medication`). Tokens - are prefixed `hlk_` and stored as SHA-256 hashes server-side. Bearer - tokens never grant admin access — admin routes require the session - cookie. Permission scopes are enforced from the token's `permissions` - array. + are prefixed `hlk_` and stored as HMAC-SHA-256 hashes server-side + (keyed with `API_TOKEN_HMAC_KEY` so a DB dump alone does not allow + precomputed-rainbow lookups). Bearer tokens never grant admin access + — admin routes require the session cookie. Permission scopes are + enforced from the token's `permissions` array; a token MUST declare + either the explicit required permission for the route or the + wildcard `"*"`. sessionCookie: type: apiKey in: cookie From 97d515627ebf75c74b97804c9cc3c026dc555060 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 8 May 2026 00:54:21 +0200 Subject: [PATCH 13/13] fix(audit-review): close 4 HIGH + 2 MEDIUM findings from multi-agent review pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-agent review of the audit-fix branch surfaced four HIGH issues and a couple of MEDIUM hardenings. All addressed before merge. HIGH-1 — OpenAPI MetricKind enum drift (iOS strict-decode crash risk). The dashboard-summary route now emits totalBodyWater/boneMass/ oxygenSaturation MetricCards, but the spec still listed only the original 7 kinds. Generated iOS Codable would have crashed the dash the moment a user logged SpO2. MetricKind extended with the 3 new kinds + comment documenting unknown-value defensive decoding. HIGH-2 — kindEnum naming consistency on /api/measurements/series. The series adapter shipped "oxygen" while the dashboard summary used "oxygenSaturation"; both routes now use "oxygenSaturation" so iOS decoders can share a single MetricKind type across endpoints. HIGH-3 — /api/ai/test no-leak guard had no test. Added 4 tests that mock provider errors with sk-leaked-key + provider URLs in the err fields and assert the response body never echoes them, plus the 3-status-categorised generic messages (401/429/5xx/other). HIGH-4 — /api/import 5/hour rate-limit had no test. Added 2 tests that mock checkRateLimit allowed/disallowed and assert the 429 path uses the right key shape (`import:`, 5, 1h) and that the within- quota path proceeds. MEDIUM-3 — getClientIp under-length XFF chain regression. With TRUST_PROXY_HOPS=2 and a chain of length 1, the previous clamp returned the leftmost (attacker-controlled) entry, silently re-introducing the rotation attack the helper was meant to close. Now: when chain is shorter than configured hops, refuse XFF entirely (fall back to x-real-ip / null). Added regression test. LOW-1 — TRUST_PROXY_HOPS=garbage now throws at boot instead of silently degrading to hops=0. Added regression test. Tests: 358/358 pass (was 350, +8 new). Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/api/openapi.yaml | 8 ++ src/app/api/ai/test/__tests__/route.test.ts | 94 +++++++++++++++++++++ src/app/api/import/__tests__/route.test.ts | 85 +++++++++++++++++++ src/app/api/measurements/series/route.ts | 4 +- src/lib/__tests__/get-client-ip.test.ts | 16 ++++ src/lib/api-response.ts | 27 ++++-- 6 files changed, 227 insertions(+), 7 deletions(-) create mode 100644 src/app/api/ai/test/__tests__/route.test.ts create mode 100644 src/app/api/import/__tests__/route.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e5db14c..d25a365 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4737,6 +4737,11 @@ components: glitchtipEnvironment: { type: [string, 'null'] } # ─── iOS adapter DTOs (v1.3) ────────────────────────────── + # Used by /api/dashboard/summary (`MetricCard.kind`) and the + # /api/measurements/series adapter. Extended in v1.3.3 to cover the + # body-composition + SpO2 measurements that the server already emits. + # iOS clients SHOULD decode unknown values defensively; we still + # publish the canonical list here so generated codecs stay accurate. MetricKind: type: string enum: @@ -4747,6 +4752,9 @@ components: - glucose - sleep - steps + - totalBodyWater + - boneMass + - oxygenSaturation InsightSeverityIos: type: string diff --git a/src/app/api/ai/test/__tests__/route.test.ts b/src/app/api/ai/test/__tests__/route.test.ts new file mode 100644 index 0000000..d72c291 --- /dev/null +++ b/src/app/api/ai/test/__tests__/route.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mocks must be hoisted before importing the route. +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u-1" }, session: { id: "s-1" } })), +})); + +vi.mock("@/lib/ai/provider", () => ({ + resolveProvider: vi.fn(), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(async () => ({ allowed: true })), +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), +})); + +import { POST } from "../route"; +import { resolveProvider } from "@/lib/ai/provider"; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +interface ApiErrorEnvelope { + data: null; + error: string; +} + +// V3 audit: /api/ai/test was returning provider err.message + bodyExcerpt +// directly to the client, leaking provider URLs / partial keys / internal +// headers. Server now logs full details via annotate() and responds with +// a categorised, generic message. +describe("POST /api/ai/test — provider error leak guard", () => { + function makeProviderThatThrows( + err: Error & { httpStatus?: number; bodyExcerpt?: string }, + ) { + vi.mocked(resolveProvider).mockResolvedValue({ + type: "openai", + generateCompletion: vi.fn(async () => { + throw err; + }), + } as never); + } + + it("does not echo provider err.message back to the client (HIGH coverage gap)", async () => { + const err = Object.assign( + new Error("OpenAI 401 from https://api.openai.com/v1 sk-leaked-key"), + { httpStatus: 401 as const, bodyExcerpt: '{"error":"invalid api key sk-secret"}' }, + ); + makeProviderThatThrows(err); + + const response = await POST(); + const body = (await response.json()) as ApiErrorEnvelope; + + expect(response.status).toBe(502); + expect(body.error ?? "").not.toMatch(/sk-/); + expect(body.error ?? "").not.toMatch(/api\.openai\.com/); + expect(body.error ?? "").not.toMatch(/invalid api key/i); + expect(body.error).toBe("Provider rejected the credentials"); + }); + + it("returns the 429-categorised message when the provider rate-limits", async () => { + makeProviderThatThrows( + Object.assign(new Error("429 from openai"), { httpStatus: 429 as const }), + ); + const response = await POST(); + expect(response.status).toBe(502); + expect(((await response.json()) as ApiErrorEnvelope).error).toBe( + "Provider rate-limited the request", + ); + }); + + it("returns the 5xx-categorised message when the provider has a server error", async () => { + makeProviderThatThrows( + Object.assign(new Error("503 upstream"), { httpStatus: 503 as const }), + ); + const response = await POST(); + expect(((await response.json()) as ApiErrorEnvelope).error).toBe( + "Provider returned a server error", + ); + }); + + it("returns the unknown-error fallback otherwise", async () => { + makeProviderThatThrows(new Error("ECONNRESET")); + const response = await POST(); + expect(((await response.json()) as ApiErrorEnvelope).error).toBe( + "Provider connection failed", + ); + }); +}); diff --git a/src/app/api/import/__tests__/route.test.ts b/src/app/api/import/__tests__/route.test.ts new file mode 100644 index 0000000..90e4586 --- /dev/null +++ b/src/app/api/import/__tests__/route.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u-1" }, session: { id: "s-1" } })), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { create: vi.fn() }, + moodEntry: { create: vi.fn() }, + }, +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(), +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), +})); + +import { NextRequest } from "next/server"; +import { POST } from "../route"; +import { checkRateLimit } from "@/lib/rate-limit"; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +interface ApiErrorEnvelope { + data: null; + error: string; +} + +// V3 audit: /api/import POST had no rate-limit. Bulk-injection vector +// (max:10000 records per call). Now capped at 5/hour/user. +describe("POST /api/import — rate-limit guard", () => { + it("returns 429 when the user has exhausted the 5/hour quota (HIGH coverage gap)", async () => { + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: new Date(Date.now() + 1000), + } as never); + + const response = await POST( + new NextRequest("http://localhost/api/import", { + method: "POST", + body: JSON.stringify({ measurements: [] }), + headers: { "content-type": "application/json" }, + }), + ); + + expect(response.status).toBe(429); + const body = (await response.json()) as ApiErrorEnvelope; + expect(body.error).toMatch(/per hour/i); + expect(checkRateLimit).toHaveBeenCalledWith( + expect.stringContaining("import:u-1"), + 5, + 60 * 60 * 1000, + ); + }); + + it("processes the request when within the quota", async () => { + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 4, + resetAt: new Date(Date.now() + 1000), + } as never); + + const response = await POST( + new NextRequest("http://localhost/api/import", { + method: "POST", + body: JSON.stringify({ measurements: [] }), + headers: { "content-type": "application/json" }, + }), + ); + + expect(response.status).toBeLessThan(400); + }); +}); diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts index bb4504f..b570404 100644 --- a/src/app/api/measurements/series/route.ts +++ b/src/app/api/measurements/series/route.ts @@ -27,7 +27,7 @@ const kindEnum = z.enum([ "steps", "totalBodyWater", "boneMass", - "oxygen", + "oxygenSaturation", ]); const querySchema = z.object({ @@ -45,7 +45,7 @@ const KIND_TO_TYPE: Record, MeasurementType> = { steps: "ACTIVITY_STEPS", totalBodyWater: "TOTAL_BODY_WATER", boneMass: "BONE_MASS", - oxygen: "OXYGEN_SATURATION", + oxygenSaturation: "OXYGEN_SATURATION", }; interface SeriesPoint { diff --git a/src/lib/__tests__/get-client-ip.test.ts b/src/lib/__tests__/get-client-ip.test.ts index 379bd26..d684794 100644 --- a/src/lib/__tests__/get-client-ip.test.ts +++ b/src/lib/__tests__/get-client-ip.test.ts @@ -36,6 +36,22 @@ describe("getClientIp trusted-proxy semantics (V3 audit)", () => { expect(ip).toBe("1.2.3.4"); }); + it("with TRUST_PROXY_HOPS=2 and a chain shorter than 2 returns null (fixes review M-3)", () => { + process.env.TRUST_PROXY_HOPS = "2"; + // Misconfigured deployment: claims 2 trusted hops but only 1 proxy is + // in the chain. Falling back to the leftmost (attacker-controlled) + // entry would re-introduce XFF rotation. Refuse the chain entirely. + const ip = getClientIp(makeRequest({ "x-forwarded-for": "5.6.7.8" })); + expect(ip).toBeNull(); + }); + + it("throws at boot when TRUST_PROXY_HOPS is unparseable (fixes review L-1)", () => { + process.env.TRUST_PROXY_HOPS = "garbage"; + expect(() => + getClientIp(makeRequest({ "x-forwarded-for": "5.6.7.8" })), + ).toThrow(/TRUST_PROXY_HOPS/); + }); + it("with TRUST_PROXY_HOPS=0 ignores XFF entirely", () => { process.env.TRUST_PROXY_HOPS = "0"; const ip = getClientIp( diff --git a/src/lib/api-response.ts b/src/lib/api-response.ts index 02130c2..4a27a5e 100644 --- a/src/lib/api-response.ts +++ b/src/lib/api-response.ts @@ -49,9 +49,22 @@ function looksLikeIp(s: string): boolean { return /^[0-9a-fA-F.:]+$/.test(s) && s.length >= 3 && s.length <= 45; } +function parseTrustProxyHops(raw: string | undefined): number { + if (raw === undefined) return 1; + const trimmed = raw.trim(); + if (!/^\d+$/.test(trimmed)) { + // Reject explicitly-invalid values so an operator typo doesn't silently + // switch a real-proxy deployment to "no XFF trust" mode (review + // finding L-1: TRUST_PROXY_HOPS=garbage degraded to hops=0 silently). + throw new Error( + `TRUST_PROXY_HOPS must be a non-negative integer, got: ${JSON.stringify(raw)}`, + ); + } + return parseInt(trimmed, 10); +} + export function getClientIp(request: Request): string | null { - const hopsRaw = process.env.TRUST_PROXY_HOPS; - const hops = hopsRaw === undefined ? 1 : Math.max(0, parseInt(hopsRaw, 10) || 0); + const hops = parseTrustProxyHops(process.env.TRUST_PROXY_HOPS); if (hops > 0) { const forwarded = request.headers.get("x-forwarded-for"); @@ -60,9 +73,13 @@ export function getClientIp(request: Request): string | null { .split(",") .map((s) => s.trim()) .filter(looksLikeIp); - if (chain.length > 0) { - const idx = Math.max(0, chain.length - hops); - return chain[idx]; + // Review finding M-3: when the chain is shorter than the configured + // hops count, the operator misconfigured TRUST_PROXY_HOPS or a + // proxy was bypassed. Falling back to the leftmost (now + // attacker-controlled) entry would re-introduce the very rotation + // attack TRUST_PROXY_HOPS was meant to close. Refuse to read XFF. + if (chain.length >= hops) { + return chain[chain.length - hops]; } } }