Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
"colMin": "Min",
"colMax": "Max",
"colN": "n",
"bpClassificationTitle": "Blutdruck — ESC/ESH-Klassifikation (2018)",
"bpClassificationTitle": "Blutdruck — ESH-Klassifikation (2023)",
"avgBp": "Durchschnittlicher Blutdruck",
"classification": "Klassifikation",
"bmiTitle": "Body-Mass-Index (BMI)",
Expand Down Expand Up @@ -634,7 +634,7 @@
"inTargetRange": "im Zielbereich",
"bpTarget": "Ziel: {range}",
"noDobSet": "Geburtsdatum nicht hinterlegt.",
"avg30dEsc": "30T-Durchschnitt · ESC/ESH 2018",
"avg30dEsc": "30T-Durchschnitt · ESH 2023",
"whoClassification": "WHO-Klassifikation",
"correlations": "Korrelationen",
"weightVsBp": "Gewicht vs. Blutdruck (Systolisch)",
Expand Down
4 changes: 2 additions & 2 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
"colMin": "Min",
"colMax": "Max",
"colN": "n",
"bpClassificationTitle": "Blood pressure — ESC/ESH classification (2018)",
"bpClassificationTitle": "Blood pressure — ESH classification (2023)",
"avgBp": "Average blood pressure",
"classification": "Classification",
"bmiTitle": "Body Mass Index (BMI)",
Expand Down Expand Up @@ -634,7 +634,7 @@
"inTargetRange": "in target range",
"bpTarget": "Target: {range}",
"noDobSet": "Date of birth not set.",
"avg30dEsc": "30d average · ESC/ESH 2018",
"avg30dEsc": "30d average · ESH 2023",
"whoClassification": "WHO Classification",
"correlations": "Correlations",
"weightVsBp": "Weight vs. Blood Pressure (Systolic)",
Expand Down
8 changes: 7 additions & 1 deletion src/app/api/insights/targets/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,13 @@ export const GET = apiHandler(async () => {
unit: "steps",
range: stepsRange,
classification: stepsClassification,
source: "WHO",
// WHO publishes activity *time* (150–300 min/wk moderate),
// not a step quota. The closest peer-reviewed dose-response
// for the 8 000–15 000 band is Saint-Maurice JAMA 2020. The
// AI prompts at src/lib/insights/prompts/{base-system,
// general-status}.ts already enforce this attribution; this
// surface label was the last "WHO" mislabel in the codebase.
source: "Saint-Maurice JAMA 2020",
});

// 8. Medication Compliance (average across active medications)
Expand Down
2 changes: 1 addition & 1 deletion src/lib/ai/prompts/base-system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ ADVANCED METRICS:
- moodAdherenceRisk: mood ≤ 2.5 over 7 days and falling = adherence drop within the next 5 days likely. Address proactively.
- seasonalVariation: winter-summer delta of systolic BP. > 5 mmHg is physiologically normal. Reassure the user — this is not a deterioration.
- sleep: target ≥ 7h/night (AASM 2015 Adult Sleep Duration Consensus). < 6h: risk factor for hypertension and weight gain.
- activity: ≥ 8,000 steps/day (Saint-Maurice et al., JAMA 2020 — mortality plateau 8,000–12,000). Note: WHO publishes activity *time* (150–300 min/week moderate), NOT a step quota — do not cite "WHO" as the source of a step number.
- activity: ≥ 8,000 steps/day (Saint-Maurice et al., JAMA 2020 — continued dose-response benefit through ~12,000 steps/day, not a plateau). Note: WHO publishes activity *time* (150–300 min/week moderate), NOT a step quota — do not cite "WHO" as the source of a step number.

HISTORICAL COMPARISON:
- Compare current 7-day values against the previous 30-day average.
Expand Down
2 changes: 1 addition & 1 deletion src/lib/ai/prompts/general-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const GENERAL_SECTION_EN = `DOMAIN — OVERALL ASSESSMENT:
- Use historicalComparison to anchor current changes against the established baseline.
- If age and sex are known, apply age- and sex-specific risk assessment.
- Sleep: When sleep data is present, fold sleep quality into the overall picture. < 6h/night = risk factor for hypertension and weight gain.
- Activity: When activity data is present, evaluate step count (≥ 8,000/day — Saint-Maurice et al., JAMA 2020; mortality plateau 8,000–12,000). Note: WHO publishes activity *time* (min/week), NOT a step quota — do not phrase the target as "WHO ≥ 8,000 steps". Tie back to pulse and weight trends.
- Activity: When activity data is present, evaluate step count (≥ 8,000/day — Saint-Maurice et al., JAMA 2020; benefit accumulates through ~12,000 steps/day, not a plateau). Note: WHO publishes activity *time* (min/week), NOT a step quota — do not phrase the target as "WHO ≥ 8,000 steps". Tie back to pulse and weight trends.
- Rate-pressure product: When ratePressureProduct is present, include it as a cardiac-load indicator. > 12,000 = elevated myocardial oxygen demand.
- Body-composition divergence: If bodyCompositionDivergence.flag = true, treat as an early sign of sarcopenic obesity.
- Seasonal variation: When seasonalVariation is present, contextualise seasonal BP swings and reassure where appropriate.
Expand Down
19 changes: 11 additions & 8 deletions src/lib/analytics/classifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export function classifyBMI(bmi: number): BmiClassification {
};
}

// ── BP Classification (ESC/ESH 2018) ────────────────────
// ── BP Classification (ESH 2023) ────────────────────────

export interface BpClassification {
category: string;
Expand Down Expand Up @@ -244,9 +244,10 @@ export function classifyBodyFat(
// 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 plateaus 8 000–12 000 steps/day. WHO publishes
// physical-activity TIME (150–300 min/wk moderate) — NOT a step
// quota; do not cite "WHO ≥ 8 000 steps".
// 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;
Expand Down Expand Up @@ -280,8 +281,8 @@ export function classifySteps(steps: number): StepsClassification {

/**
* Activity-steps target range. Single source of truth: aligned with
* `effective-range.ts` (Saint-Maurice JAMA 2020 — mortality plateau
* 8 000–12 000). The v1.3.3 audit flagged a drift where this helper
* `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.
Expand Down Expand Up @@ -319,7 +320,9 @@ export function getSleepDurationRange(): { min: number; max: number } {
}

/**
* BP target ranges based on ESC/ESH 2018 guidelines.
* 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,
Expand All @@ -329,7 +332,7 @@ export function getBpTargetsByAge(
if (age < 65) {
return { sysLow: 120, sysHigh: 129, diaLow: 70, diaHigh: 79 };
}
// 65+ (both 65–79 and ≥80 have the same targets per ESC/ESH)
// 65+ (both 65–79 and ≥80 have the same targets per ESH 2023)
return { sysLow: 130, sysHigh: 139, diaLow: 70, diaHigh: 79 };
}

Expand Down
5 changes: 3 additions & 2 deletions src/lib/analytics/effective-range.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,8 +207,9 @@ function defaultRange(
// AASM: 7–9h for adults; warning yellow either side.
return { greenMin: 7, greenMax: 9, orangeMin: 6, orangeMax: 10 };
case "ACTIVITY_STEPS":
// ≥8000 steps/day per Saint-Maurice et al., JAMA 2020 (mortality
// plateau 8000–12000 steps). WHO 2020 PA guidelines publish minutes
// ≥8000 steps/day per Saint-Maurice et al., JAMA 2020 (continued
// dose-response benefit through ~12000 steps/day, not a plateau —
// HR 0.49 at 8k vs 4k, HR 0.35 at 12k). 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.
Expand Down
15 changes: 12 additions & 3 deletions src/lib/api-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { User } from "@/generated/prisma/client";
import { WideEventBuilder } from "./logging/event-builder";
import { eventStorage, getEvent } from "./logging/context";
import { emitIfSampled } from "./logging/transports";
import { redactOptional, redactSecrets } from "./logging/redact";
import { getSession } from "./auth/session";
import { hashToken } from "./auth/hmac";
import { prisma } from "./db";
Expand Down Expand Up @@ -92,7 +93,11 @@ export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
} finally {
const status = (response as Response | undefined)?.status ?? 500;
evt.finish(status);
try { emitIfSampled(evt.toJSON()); } catch { /* logging must never crash the handler */ }
try {
emitIfSampled(evt.toJSON());
} catch {
/* logging must never crash the handler */
}
}
const nr = response as NextResponse;
nr.headers.set("x-request-id", evt.getRequestId());
Expand Down Expand Up @@ -345,10 +350,14 @@ async function reportToGlitchtip(
dsn: settings.glitchtipDsn,
input: {
environment: settings.glitchtipEnvironment || "production",
message: err.message,
// Defence in depth: even though the WideEventBuilder already
// redacts on `setError()`, the GlitchTip path imports `err`
// directly. Apply the same redaction here so a Telegram bot
// token or external Bearer cannot leak via the incident UI.
message: redactSecrets(err.message),
level: "error",
type: err.name || "Error",
stack: err.stack,
stack: redactOptional(err.stack),
url: scrubbedUrl,
sourceTag: "healthlog-api-handler",
requestId: evt.getRequestId(),
Expand Down
58 changes: 58 additions & 0 deletions src/lib/logging/__tests__/redact.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { redactSecrets, redactOptional } from "../redact";

describe("redactSecrets", () => {
it("redacts Bearer tokens", () => {
expect(redactSecrets("Authorization: Bearer hlk_abc123def456")).toBe(
"Authorization: Bearer [REDACTED]",
);
expect(redactSecrets("auth=Bearer whitespace_token")).toBe(
"auth=Bearer [REDACTED]",
);
});

it("redacts Telegram bot tokens in path-style URLs", () => {
expect(
redactSecrets(
"fetch failed: https://api.telegram.org/bot1234567890:AAEhBP-w-secret/sendMessage",
),
).toBe("fetch failed: https://api.telegram.org/bot[REDACTED]/sendMessage");
});

it("redacts query-string secrets", () => {
expect(redactSecrets("Withings webhook ?secret=abc123 failed")).toBe(
"Withings webhook ?secret=[REDACTED] failed",
);
expect(redactSecrets("OAuth ?code=xyz&state=abc")).toBe(
"OAuth ?code=[REDACTED]&state=abc",
);
expect(redactSecrets("api?api_key=secret")).toBe("api?api_key=[REDACTED]");
});

it("leaves non-secret-shaped strings alone", () => {
expect(redactSecrets("HTTP 503 from upstream")).toBe(
"HTTP 503 from upstream",
);
expect(redactSecrets("user-7 measurement saved")).toBe(
"user-7 measurement saved",
);
});

it("handles multiple secrets in one string", () => {
// Bearer matches `\S+` so it greedily consumes `hlk_x;` — that's
// safe (over-redaction is fine when the alternative is leaking).
expect(redactSecrets("Bearer hlk_x bot999:tok ?code=abc123")).toBe(
"Bearer [REDACTED] bot[REDACTED] ?code=[REDACTED]",
);
});
});

describe("redactOptional", () => {
it("returns undefined for undefined input", () => {
expect(redactOptional(undefined)).toBeUndefined();
});

it("redacts when present", () => {
expect(redactOptional("Bearer x")).toBe("Bearer [REDACTED]");
});
});
10 changes: 6 additions & 4 deletions src/lib/logging/event-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
import type { WideEvent, LogLevel, EventKind } from "./types";
import { LOG_LEVEL_PRIORITY } from "./types";
import { getDeployContext } from "./config";
import { redactOptional, redactSecrets } from "./redact";

/**
* Baut ein Wide Event Schritt fuer Schritt auf.
Expand Down Expand Up @@ -61,14 +62,14 @@ export class WideEventBuilder {
if (err instanceof Error) {
this.event.error = {
type: err.constructor.name,
message: err.message,
stack: err.stack,
message: redactSecrets(err.message),
stack: redactOptional(err.stack),
code: (err as { statusCode?: number }).statusCode,
};
} else {
this.event.error = {
type: "Unknown",
message: String(err),
message: redactSecrets(String(err)),
};
}
return this;
Expand All @@ -88,7 +89,8 @@ export class WideEventBuilder {
}

addDbQuery(durationMs: number): this {
if (!this.event.db) this.event.db = { query_count: 0, query_duration_ms: 0 };
if (!this.event.db)
this.event.db = { query_count: 0, query_duration_ms: 0 };
this.event.db.query_count++;
this.event.db.query_duration_ms += durationMs;
return this;
Expand Down
35 changes: 35 additions & 0 deletions src/lib/logging/redact.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Redact secrets from strings before they leave the process boundary
* (Loki, Glitchtip, console). Applied in `WideEventBuilder.setError()`
* and `reportToGlitchtip()` so every error reaching observability is
* scrubbed once, centrally.
*
* Patterns:
* - `Bearer <token>` — generic API tokens incl. our `hlk_` /
* `hlr_` formats and external OAuth bearers (Withings, AI providers).
* - `bot<digits>:<token>` — Telegram bot URLs embed the token in the
* path: `https://api.telegram.org/bot1234567:ABC-…/sendMessage`.
* If `fetch()` rejects with a `cause` that surfaces the URL (some
* Node runtimes), the token would land in error reports.
* - `?secret=…` and `?code=…` — query-string leaks for legacy
* Withings webhooks and OAuth callbacks (already scrubbed at the
* URL level in `reportToGlitchtip`, but error.message can carry
* the URL too).
*
* The substitution is intentionally generic ([REDACTED]) — we don't
* want partial revelation of token entropy.
*/
export function redactSecrets(input: string): string {
return input
.replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]")
.replace(/bot\d+:[A-Za-z0-9_-]+/g, "bot[REDACTED]")
.replace(
/([?&])(secret|code|token|api[_-]?key)=[^&\s]+/gi,
"$1$2=[REDACTED]",
);
}

/** Apply `redactSecrets` to a possibly-undefined string. */
export function redactOptional(s: string | undefined): string | undefined {
return s === undefined ? undefined : redactSecrets(s);
}
11 changes: 7 additions & 4 deletions src/lib/medical-citations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,17 +52,20 @@ export const CITATIONS = {
/**
* Saint-Maurice PF, et al. "Association of daily step count and step
* intensity with mortality among US adults." JAMA. 2020.
* Mortality benefit plateaus 8 000–12 000 steps/day. WHO 2020 PA
* guidelines publish minutes per week, NOT a step quota — DO NOT
* cite WHO for a step number.
* The paper reports continued dose-response benefit (HR 0.49 at 8k
* vs 4k, HR 0.35 at 12k vs 4k) — NOT a plateau. The plateau-shaped
* finding belongs to Paluch 2022 Lancet Public Health (PMID
* 35247352, age-stratified meta-analysis). WHO 2020 PA guidelines
* publish minutes per week, NOT a step quota — DO NOT cite WHO
* for a step number.
*/
STEPS_SAINT_MAURICE_2020: {
id: "steps-saint-maurice-2020",
name: "Saint-Maurice JAMA 2020",
year: 2020,
url: "https://jamanetwork.com/journals/jama/fullarticle/2763292",
caveat:
"U.S. adult cohort, observational; benefit plateaus 8–12k steps/day.",
"U.S. adult cohort, observational; benefit accumulates strongly through ~12k steps/day (continued dose-response, not a plateau).",
},

/**
Expand Down
29 changes: 28 additions & 1 deletion src/lib/moodlog/sync.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { getEvent } from "@/lib/logging/context";
import { isPublicUrl } from "@/lib/validations/notifications";

/**
* Sync mood entries from a user's moodLog instance.
Expand Down Expand Up @@ -32,6 +33,18 @@ export async function syncMoodLogEntries(
const baseUrl = decrypt(user.moodLogUrlEncrypted);
const apiKey = decrypt(user.moodLogApiKeyEncrypted);

// SSRF guard at the actual fetch site. The credential write path
// is also guarded (moodLogCredentialsSchema), but a row stored
// before that guard landed could still point at an internal IP.
// Re-checking here means the sync worker refuses internal targets
// even on legacy data, and the user's apiKey is never sent there.
if (!isPublicUrl(baseUrl)) {
getEvent()?.addWarning(
`moodLog sync refused for user ${userId}: stored URL points at non-public host`,
);
return 0;
}

// 2. Determine date range
const now = new Date();
const to = now.toISOString().slice(0, 10); // YYYY-MM-DD
Expand Down Expand Up @@ -61,6 +74,10 @@ export async function syncMoodLogEntries(
response = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${apiKey}` },
signal: controller.signal,
// SSRF defence-in-depth: do NOT follow redirects. A public
// host that 302s to an RFC1918 target would otherwise leak
// the apiKey to the internal hop.
redirect: "manual",
});
} finally {
clearTimeout(timeout);
Expand All @@ -71,8 +88,18 @@ export async function syncMoodLogEntries(
});
}

// Treat any 3xx as failure (manual redirect mode surfaces them).
if (response.status >= 300 && response.status < 400) {
getEvent()?.addWarning(
`moodLog sync refused redirect for user ${userId}: HTTP ${response.status}`,
);
return 0;
}

if (!response.ok) {
getEvent()?.addWarning(`Sync failed for user ${userId}: HTTP ${response.status}`);
getEvent()?.addWarning(
`Sync failed for user ${userId}: HTTP ${response.status}`,
);
return 0;
}

Expand Down
Loading
Loading