Files
HealthLog/src/lib/analytics/classifications.ts
T
Marc-André Bombeck 70353882eb fix(security): close moodLog SSRF + central error redaction; fix citation drift (#148)
The v1.4.1 audit pass turned up one CRITICAL, two HIGH, and four
medical-citation drift findings. This commit addresses everything
that's safe to land in a hardening release; deeper architectural
fixes (idempotency-race transactional rewrite, encryption-key
fallback gate, refresh-token reuse-detection serialisation, moodLog
secret HMAC lookup column) are tracked for v1.4.2 in
docs/ops/v141-followup-issues.md.

Security
--------

CRITICAL — moodLog SSRF
  src/lib/validations/moodlog.ts now refines the credentials URL
  with isPublicUrl() so an authenticated user cannot store
  http://169.254.169.254/ (cloud metadata) or RFC1918 hosts.
  src/lib/moodlog/sync.ts re-checks isPublicUrl(baseUrl) at the
  fetch site (so legacy rows stored before the credential guard are
  also refused) and switches to redirect: "manual" + 3xx → fail so a
  public host cannot 302 the request to an internal target with the
  user's apiKey on the redirect hop. New unit test
  src/lib/validations/__tests__/moodlog.test.ts asserts the guard
  rejects 169.254.x.x, 10.x, 172.16.x.x, 192.168.x.x, 127.0.0.1, and
  localhost.

HIGH — error-message secret leakage
  New src/lib/logging/redact.ts redacts Bearer tokens (incl. our
  hlk_/hlr_), Telegram bot URLs (bot<digits>:<token>), and
  query-string secrets (?secret=, ?code=, ?token=, ?api_key=). The
  redactor is called from WideEventBuilder.setError() — every error
  reaching Loki is scrubbed once, centrally — and from the
  reportToGlitchtip() call in api-handler.ts so the Glitchtip
  incident UI is also protected. New unit suite covers the four
  patterns plus over-redaction (Bearer's \\S+ greedily consumes
  anything up to the next whitespace, which is the safer side of
  the trade-off).

Truthfulness — citation drift fixes (audit found 0 hallucinations,
4 drift items)
  - messages/{en,de}.json bpClassificationTitle and avg30dEsc:
    "ESC/ESH 2018" → "ESH 2023". User-visible on the dashboard and
    doctor-report PDF. The numeric thresholds are unchanged from
    2018; the 2023 update is ESH-only since ESC withdrew from
    joint authoring.
  - src/lib/analytics/classifications.ts: section header,
    getBpTargetsByAge() docblock, and inline comment updated to
    cite ESH 2023 with the same "numerically unchanged" caveat.
  - src/app/api/insights/targets/route.ts:375: source label "WHO"
    → "Saint-Maurice JAMA 2020". The AI-prompt anti-pattern guard
    explicitly forbids citing WHO for a step number; this surface
    was the last "WHO" mislabel in the codebase.
  - Saint-Maurice "mortality plateau 8000-12000" attribution
    softened to "continued dose-response benefit through ~12,000
    steps/day, not a plateau" in src/lib/medical-citations.ts,
    src/lib/analytics/effective-range.ts,
    src/lib/analytics/classifications.ts, and both AI prompts. The
    original JAMA 2020 paper shows continuing benefit (HR 0.49 at
    8k vs 4k, HR 0.35 at 12k); the plateau-shaped finding belongs
    to Paluch 2022 Lancet PH (PMID 35247352), not Saint-Maurice.

Quality gates
  pnpm typecheck         clean
  pnpm test              669 / 669 (was 658; +11 from new suites)
  pnpm test:integration  10 / 10 (untouched)
  pnpm lint              0 errors
2026-05-08 16:30:37 +02:00

477 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Health metric classifications and rule-based alerts.
* WHO BMI categories, ESC/ESH BP classification, and smart alerts.
*/
// ── BMI Classification (WHO) ────────────────────────────
export interface BmiClassification {
category: string;
color: string;
severity: "normal" | "warning" | "danger";
}
export function classifyBMI(bmi: number): BmiClassification {
if (bmi < 18.5) {
return { category: "Underweight", color: "#f1fa8c", severity: "warning" };
}
if (bmi < 25) {
return { category: "Normal", color: "#50fa7b", severity: "normal" };
}
if (bmi < 30) {
return {
category: "Overweight",
color: "#ffb86c",
severity: "warning",
};
}
if (bmi < 35) {
return {
category: "Obesity Grade I",
color: "#ff79c6",
severity: "danger",
};
}
if (bmi < 40) {
return {
category: "Obesity Grade II",
color: "#ff5555",
severity: "danger",
};
}
return {
category: "Obesity Grade III",
color: "#ff5555",
severity: "danger",
};
}
// ── BP Classification (ESH 2023) ────────────────────────
export interface BpClassification {
category: string;
color: string;
severity: "normal" | "elevated" | "warning" | "danger";
}
export function classifyBP(sys: number, dia: number): BpClassification {
if (sys < 120 && dia < 80) {
return { category: "Optimal", color: "#50fa7b", severity: "normal" };
}
if (sys < 130 && dia < 85) {
return { category: "Normal", color: "#8be9fd", severity: "normal" };
}
if (sys < 140 && dia < 90) {
return {
category: "High-normal",
color: "#f1fa8c",
severity: "elevated",
};
}
if (sys < 160 && dia < 100) {
return {
category: "Hypertension Grade 1",
color: "#ffb86c",
severity: "warning",
};
}
if (sys < 180 && dia < 110) {
return {
category: "Hypertension Grade 2",
color: "#ff79c6",
severity: "danger",
};
}
return {
category: "Hypertension Grade 3",
color: "#ff5555",
severity: "danger",
};
}
// ── Pulse Classification (AHA) ──────────────────────────
export interface PulseClassification {
category: string;
color: string;
severity: "info" | "normal" | "warning" | "danger";
}
export function classifyPulse(bpm: number): PulseClassification {
if (bpm < 60) {
return { category: "Bradycardia", color: "#8be9fd", severity: "info" };
}
if (bpm <= 100) {
return { category: "Normal", color: "#50fa7b", severity: "normal" };
}
if (bpm <= 120) {
return { category: "Elevated", color: "#ffb86c", severity: "warning" };
}
return { category: "Tachycardia", color: "#ff5555", severity: "danger" };
}
// ── Sleep Duration Classification (AASM/SRS) ───────────
export interface SleepDurationClassification {
category: string;
color: string;
severity: "normal" | "info" | "warning";
}
export function classifySleepDuration(
hours: number,
): SleepDurationClassification {
if (hours < 6) {
return {
category: "Far too short",
color: "#ff5555",
severity: "warning",
};
}
if (hours < 7) {
return { category: "Too short", color: "#ffb86c", severity: "warning" };
}
if (hours <= 9) {
return { category: "On target", color: "#50fa7b", severity: "normal" };
}
if (hours <= 10) {
return { category: "Slightly long", color: "#8be9fd", severity: "info" };
}
return {
category: "Far too long",
color: "#ff79c6",
severity: "warning",
};
}
// ── Body Fat Classification (ACE) ───────────────────────
export interface BodyFatClassification {
category: string;
color: string;
severity: "normal" | "warning" | "danger";
}
/**
* Classify a body-fat percentage according to the ACE (American Council
* on Exercise) standard table. Source:
* https://www.acefitness.org/resources/everyone/blog/112/what-are-the-guidelines-for-percentage-of-body-fat-loss/
*
* v1.3.3 used `essential = 6 (M)` / `14 (F)` for the lower boundary,
* which actually matches ACE's *Athletes* lower bound — readings below
* that bound were mislabelled "Essential" instead of either "Below
* essential" (a danger floor) or "Essential" proper (the 2-5 / 10-13
* band, which is athletic-extreme but not pathological). v1.4 splits
* that range into a danger band below the ACE essential floor and the
* actual essential band as a warning.
*
* Cross-checked against:
* - Heyward V & Wagner D, "Applied Body Composition Assessment" 2nd
* ed (ACE's underlying source).
* - Gallagher D et al., "Healthy percentage body fat ranges: an
* approach for developing guidelines based on body mass index."
* Am J Clin Nutr. 2000.
* https://academic.oup.com/ajcn/article/72/3/694/4729363
* - NIH NHLBI Practical Guide on the Identification, Evaluation, and
* Treatment of Overweight and Obesity in Adults (2000) — does NOT
* publish percent-fat bands; flagged here as a hallucination
* avoidance reminder.
*/
export function classifyBodyFat(
pct: number,
gender: "MALE" | "FEMALE" | null,
age?: number,
): BodyFatClassification {
void age;
// ACE bands per gender. Order: dangerFloor, essentialMax, athleteMax,
// fitnessMax, acceptableMax. Above acceptableMax = obese.
let dangerFloor: number;
let essentialMax: number;
let athleteMax: number;
let fitnessMax: number;
let acceptableMax: number;
if (gender === "MALE") {
// ACE: essential 2-5, athletes 6-13, fitness 14-17, acceptable 18-24.
dangerFloor = 2;
essentialMax = 5;
athleteMax = 13;
fitnessMax = 17;
acceptableMax = 24;
} else if (gender === "FEMALE") {
// ACE: essential 10-13, athletes 14-20, fitness 21-24, acceptable 25-31.
dangerFloor = 10;
essentialMax = 13;
athleteMax = 20;
fitnessMax = 24;
acceptableMax = 31;
} else {
// Gender-neutral fallback: midpoint of M/F bands.
dangerFloor = 6;
essentialMax = 9;
athleteMax = 16.5;
fitnessMax = 20.5;
acceptableMax = 27.5;
}
if (pct < dangerFloor) {
// Below ACE essential floor — clinically concerning (immune /
// hormonal / cardiac risk in chronic depletion).
return {
category: "Below essential",
color: "#ff5555",
severity: "danger",
};
}
if (pct <= essentialMax) {
return { category: "Essential", color: "#f1fa8c", severity: "warning" };
}
if (pct <= athleteMax) {
return { category: "Athletic", color: "#50fa7b", severity: "normal" };
}
if (pct <= fitnessMax) {
return { category: "Fitness", color: "#50fa7b", severity: "normal" };
}
if (pct <= acceptableMax) {
return { category: "Acceptable", color: "#ffb86c", severity: "warning" };
}
return { category: "Obese", color: "#ff5555", severity: "danger" };
}
// ── Activity Steps Classification ───────────────────────
//
// Cohort source: Saint-Maurice PF, et al. "Association of daily step
// count and step intensity with mortality among US adults." JAMA. 2020.
// https://jamanetwork.com/journals/jama/fullarticle/2763292
// Mortality benefit accumulates strongly through ~12 000 steps/day
// (HR 0.49 at 8k vs 4k, HR 0.35 at 12k — continued dose-response,
// not a plateau). WHO publishes physical-activity TIME (150–300
// min/wk moderate) — NOT a step quota; do not cite "WHO ≥ 8 000 steps".
export interface StepsClassification {
category: string;
color: string;
severity: "normal" | "info" | "warning";
}
export function classifySteps(steps: number): StepsClassification {
if (steps < 3000) {
return {
category: "Very low",
color: "#ff5555",
severity: "warning",
};
}
if (steps < 5000) {
return { category: "Low active", color: "#ffb86c", severity: "warning" };
}
if (steps < 7500) {
return {
category: "Moderately active",
color: "#f1fa8c",
severity: "info",
};
}
if (steps < 10000) {
return { category: "Active", color: "#50fa7b", severity: "normal" };
}
return { category: "Very active", color: "#50fa7b", severity: "normal" };
}
/**
* Activity-steps target range. Single source of truth: aligned with
* `effective-range.ts` (Saint-Maurice JAMA 2020 — continued mortality
* dose-response through ~12 000 steps/day). The v1.3.3 audit flagged a drift where this helper
* returned {7 000, 10 000} while `effective-range.ts` returned
* {8 000, 15 000}; both surfaces showed different "green" bands to the
* same user. They now agree.
*/
export function getStepsRange(): { min: number; max: number } {
return { min: 8000, max: 15000 };
}
// ── Target Range Helpers ────────────────────────────────
/**
* Healthy weight range based on BMI 18.5–24.9 (WHO).
*/
export function getWeightRange(heightCm: number): { min: number; max: number } {
const heightM = heightCm / 100;
const heightSq = heightM * heightM;
return {
min: Math.round(18.5 * heightSq * 10) / 10,
max: Math.round(24.9 * heightSq * 10) / 10,
};
}
/**
* Normal resting pulse range (AHA).
*/
export function getPulseRange(): { min: number; max: number } {
return { min: 60, max: 100 };
}
/**
* Recommended nightly sleep duration for adults (AASM/SRS).
*/
export function getSleepDurationRange(): { min: number; max: number } {
return { min: 7, max: 9 };
}
/**
* BP target ranges based on ESH 2023 guidelines (numerically
* unchanged from the joint ESC/ESH 2018 document; the 2023 update
* is ESH-only since ESC withdrew from joint authoring).
*/
export function getBpTargetsByAge(
age: number,
gender?: "MALE" | "FEMALE" | null,
): { sysLow: number; sysHigh: number; diaLow: number; diaHigh: number } {
void gender;
if (age < 65) {
return { sysLow: 120, sysHigh: 129, diaLow: 70, diaHigh: 79 };
}
// 65+ (both 65–79 and ≥80 have the same targets per ESH 2023)
return { sysLow: 130, sysHigh: 139, diaLow: 70, diaHigh: 79 };
}
// ── Health Alerts ────────────────────────────────────────
export interface HealthAlert {
level: "success" | "info" | "warning" | "danger";
title: string;
message: string;
}
export interface AlertInput {
bmi?: number | null;
bpAvgSys?: number | null;
bpAvgDia?: number | null;
bpPctInTarget?: number | null;
weightSlope30?: number | null;
pulseAvg30?: number | null;
pulseAnomalyCount?: number;
medications?: Array<{
name: string;
compliance7: number;
compliance30: number;
}>;
}
export function generateAlerts(input: AlertInput): HealthAlert[] {
const alerts: HealthAlert[] = [];
// BMI alerts
if (input.bmi != null) {
if (input.bmi < 18.5) {
alerts.push({
level: "warning",
title: "BMI below normal range",
message: `Your BMI of ${input.bmi} is below the normal range (18.5–24.9).`,
});
} else if (input.bmi >= 25 && input.bmi < 30) {
alerts.push({
level: "info",
title: "BMI in overweight range",
message: `Your BMI of ${input.bmi} is in the overweight range. Regular exercise and a balanced diet can help.`,
});
} else if (input.bmi >= 30) {
alerts.push({
level: "warning",
title: "BMI in obesity range",
message: `Your BMI of ${input.bmi} is in the obesity range.`,
});
} else {
alerts.push({
level: "success",
title: "BMI in normal range",
message: `Your BMI of ${input.bmi} is in the healthy range.`,
});
}
}
// BP alerts
if (input.bpAvgSys != null && input.bpAvgDia != null) {
const bpClass = classifyBP(input.bpAvgSys, input.bpAvgDia);
if (bpClass.severity === "danger") {
alerts.push({
level: "danger",
title: "Blood pressure significantly elevated",
message: `Your average blood pressure (${input.bpAvgSys}/${input.bpAvgDia}) is classified as "${bpClass.category}".`,
});
} else if (bpClass.severity === "warning") {
alerts.push({
level: "warning",
title: "Blood pressure slightly elevated",
message: `Your average blood pressure (${input.bpAvgSys}/${input.bpAvgDia}) is in the "${bpClass.category}" range.`,
});
}
}
// BP target adherence
if (input.bpPctInTarget != null) {
if (input.bpPctInTarget >= 80) {
alerts.push({
level: "success",
title: "Good blood pressure target adherence",
message: `${input.bpPctInTarget}% of your readings are within the target range.`,
});
} else if (input.bpPctInTarget < 50) {
alerts.push({
level: "warning",
title: "Blood pressure often outside target range",
message: `Only ${input.bpPctInTarget}% of your readings are within the target range.`,
});
}
}
// Weight trend
if (input.weightSlope30 != null) {
const weeklyChange = input.weightSlope30 * 7;
if (weeklyChange > 0.5) {
alerts.push({
level: "info",
title: "Weight increasing",
message: `Your weight is increasing by approx. ${weeklyChange.toFixed(1)} kg per week over the last 30 days.`,
});
} else if (weeklyChange < -0.5) {
alerts.push({
level: "info",
title: "Weight decreasing",
message: `Your weight is decreasing by approx. ${Math.abs(weeklyChange).toFixed(1)} kg per week over the last 30 days.`,
});
}
}
// Pulse anomalies
if (input.pulseAnomalyCount != null && input.pulseAnomalyCount > 2) {
alerts.push({
level: "warning",
title: "Pulse outliers detected",
message: `${input.pulseAnomalyCount} unusual pulse readings in the last 30 days.`,
});
}
// Medication compliance
if (input.medications) {
for (const med of input.medications) {
if (med.compliance7 < 80) {
alerts.push({
level: "warning",
title: `Low compliance: ${med.name}`,
message: `Your 7-day compliance for ${med.name} is only ${med.compliance7}%. Regular intake is important.`,
});
} else if (med.compliance7 >= 95) {
alerts.push({
level: "success",
title: `Excellent compliance: ${med.name}`,
message: `${med.compliance7}% compliance over the last 7 days. Keep it up!`,
});
}
}
}
return alerts;
}