mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
* fix(measurements): close issue #109 + sync body composition across UI/PDF/thresholds 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) <noreply@anthropic.com> * docs: align OpenAPI spec + migration comment with body-composition reality - 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) <noreply@anthropic.com> * fix(security): close 3 audit CRITICALs (Withings header / idempotency Bearer / GlitchTip URL strip) 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) <noreply@anthropic.com> * feat(measurements): add OXYGEN_SATURATION (SpO2) as first-class measurement type — v1.3.3 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<ThresholdMetric>` 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) <noreply@anthropic.com> * fix(analytics): truthfulness pass on medical citations + clamp override orangeMax 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) <noreply@anthropic.com> * fix(security): Bearer-scope wildcard handling + GDPR Art.17 account-delete erasure 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * fix(measurements): close enum drift cousins — derive 5 type-arrays from canonical enum + extend 2 contract enums 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) <noreply@anthropic.com> * fix(security): close 6 V3-audit HIGH findings (CSP, web-push SSRF, IP geo, AI provider leak, import rate-limit) - 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) <noreply@anthropic.com> * fix(security,gdpr): trusted-proxy IP, audit-log retention, idempotency cachable filter, Bearer mock tightening 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) <noreply@anthropic.com> * fix(security): encrypt moodLog webhook secret at rest with AES-256-GCM (V3 audit STILL-V2-C-2) 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) <noreply@anthropic.com> * docs: sync README + AGENTS + CLAUDE + CHANGELOG + OpenAPI for v1.3.3 (V3 audit doc-drift) 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) <noreply@anthropic.com> * fix(audit-review): close 4 HIGH + 2 MEDIUM findings from multi-agent review pass 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:<userId>`, 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
23 KiB
23 KiB
Changelog
[1.3.3] — 2026-05-08
Added
- Pulse oximetry as a first-class measurement type (
OXYGEN_SATURATION). Closes the SpO2 part of #109. Migration0024_oxygen_saturationextends theMeasurementTypeenum. 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 declaredOXYGEN_SATURATIONfrom 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.tsxthat drifted from the v1.3 server enum; extracted tomeasurement-list-meta.tswith 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_WATERandBONE_MASS— severity logic was returningnominalfor any value because no defaults existed.
Changed
- OpenAPI
MeasurementTypeenum 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-Secretheader 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
defaultUserIdResolvernow supports Bearer tokens. Cookie sessions tried first, then Bearer-token viahashTokenlookup. Without the Bearer fallback, every iOS / external-ingest retry was hitting the handler again and creating duplicate measurements (audit C-4 — the exact use casewithIdempotencywas built for). - GlitchTip URL stripping —
reportToGlitchtipnow 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_metricsunit comment lied — claimedTOTAL_BODY_WATER: percent of body weight (%)while every other surface (validators, Withings client, doctor PDF) treated it askg. 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 withpermissions:["medication:ingest"]could DELETE the user account. Spec now requirespermissions:["*"]or the explicit required permission. - Account-deletion completeness (CRITICAL — V3-2 / GDPR Art. 17).
Cascades through
Feedback+AuditLogrows 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.comconnect-srcnow gated to/settings/ai/**(was a global blanket on every page, including/auth/login→ DOM-XSS exfil channel). - Web-Push subscription endpoint SSRF guard —
endpointnow requires HTTPS + passesisPublicUrl()(wasz.url()only). Side-fix:isPublicUrl()no longer falsely classifies DNS labels starting withfc/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). Existingip-api.complaintext HTTP path leaked auth-event IP + timestamp on every login (GDPR Art. 32 + Art. 44). Operators can override viaIP_GEO_LOOKUP_URL(HTTPS only) or disable entirely withIP_GEO_LOOKUP_DISABLED=1. /api/ai/testno 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/importrate-limit added — 5 imports/hour/user. Was unlimited (bulk-injection vector).- Trusted-proxy XFF semantics —
getClientIp()now readsX-Forwarded-Forright-to-left with a configurableTRUST_PROXY_HOPS(default 1, matches typical single-proxy self-host). Closes XFF rotation bypass of per-IP rate-limits. - Audit-log retention job —
audit_logsrows older thanAUDIT_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.findUniquecalls are now asserted to usewhere: { tokenHash: <hashed> }, 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/importare now derived frommeasurementTypeEnum.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 deppdf-parse.
[1.3.2] — 2026-04-28
Fixed
- Glucose tiles on the dashboard rendered the raw i18n key
targets.glucoseFastinginstead of the translated label (closes #108). Bothmessages/en.jsonandmessages/de.jsonhad two top-leveltargetsblocks;JSON.parsesilently keeps the last occurrence, and that block was missing the four glucose labels. The duplicate is now collapsed into a single block. A duplicatebugreport.bugTitlePlaceholdershadowed insidebugreportwas cleaned up too. Two further keys (dashboard.sleep,dashboard.steps) were missing from both locales and were falling back to hard-coded English; both are now translated. - New i18n locale-integrity test
(
src/lib/__tests__/i18n-locale-integrity.test.ts) fails the build on duplicate keys at any nesting depth and on key drift betweenenandde— closes the structural gap that let the duplicatetargetsblock ship in the first place.
Changed
- Screenshot upload removed from the bug-report form (also part
of #108). The form previously accepted an image attachment that
was stored in the local DB but never reached the published GitHub
issue — GitHub does not accept inline base64 data URIs in issue
bodies and offers no public API to attach images to an issue
programmatically. Rather than ship misleading
"a screenshot was attached" placeholder text in the resulting
issue, the upload UI is now gone and the placeholder note is no
longer added when promoting feedback. The
screenshotBase64column and the admin-side preview of previously-submitted screenshots are unchanged — existing reports keep their attachments locally. We plan to revisit a real screenshot pipeline in a future release.
[1.3.1] — 2026-04-27
Fixed
- Compose env-var validation no longer breaks Coolify-style deploys.
docker-compose.ymlpreviously used${VAR:?required}shell-parameter syntax for the four secrets andPOSTGRES_PASSWORD. Some hosting platforms (Coolify in particular) parse compose files eagerly and store the fallback error string ("POSTGRES_PASSWORD is required") as the literal env-var value whenPOSTGRES_PASSWORDwas unset, which then collided withDATABASE_URLand broke the running app withP1000: Authentication failed. Compose now uses plain${VAR}interpolation; validation moved intodocker-entrypoint.sh, which fails fast with a clear stderr message listing the unset variables.
Notes
If you upgraded an existing Compose stack from 1.2.x → 1.3.0 and hit
the POSTGRES_PASSWORD is required literal-as-value bug, set
POSTGRES_PASSWORD in your environment to whatever your existing
Postgres data volume was originally initialised with (likely
healthlog if you started from a pre-1.2.1 release), then redeploy.
Postgres only honours POSTGRES_PASSWORD on first volume init — the
existing user keeps the original password regardless of env changes.
[1.3.0] — 2026-04-27
Added — Body composition + targeted hardening
- Total body water and bone mass as measurement types (closes #89). New
enum values
TOTAL_BODY_WATERandBONE_MASS, both stored canonically in kilograms (matches Withings hydration/bone-mass measures and Health Connect'sTotalBodyWaterRecord/BoneMassRecord). Migration is purely additive (ALTER TYPE ... ADD VALUE) — safe to apply against any 1.2.x database without downtime. - Withings sync picks both up automatically. The Withings client now
maps measure type
77(hydration / water mass) and88(bone mass). Anyone with a Withings Body+ scale and an active connection will see the new metrics flowing in on the next sync without any extra config. - Doctor-report PDF includes both new types in the vital-signs table when data exists, with locale-aware labels in English and German.
- Dashboard widgets registered for both (default-invisible — opt in via Settings → Dashboard layout).
Security
- SSRF guard hardened (
isPublicUrl). The previous implementation usedparseIntwith permissive prefix checks likeh.startsWith("10.")which let010.0.0.1slip through — and worse, the WHATWG URL parser silently normalises010.0.0.1to8.0.0.1(octal interpretation), a real bypass on naive checks. The new guard adds a pre-URL leading-zero check on the raw input, a strict IPv4 parser, and proper IPv6 bracket / loopback / link-local handling. Now blocks127.0.0.0/8,0.0.0.0/8,10.0.0.0/8,169.254.0.0/16,172.16.0.0/12,192.168.0.0/16,100.64.0.0/10(CGNAT),::1,fe80::/10,fc00::/7. Comprehensive regression tests included. - GitHub PAT redacted from logged error bodies. When a feedback
escalation to GitHub fails, the response body was being passed
verbatim into
getEvent()?.addWarning(...)— flowing to Loki. The body is now stripped of the configured token before logging. - Per-user threshold writes are rate-limited (30 writes / 5 min) to make audit-log enumeration unattractive. Audit-logging itself was already there.
Performance
- N+1 in
/api/insights/comprehensivefixed. The medication-compliance loop hit Postgres once per active medication. It now batches into a singlemedicationId IN (...)query and groups in memory. Latency improvement scales linearly with the user's active medication count.
Reliability
- pg-boss draining on SIGTERM/SIGINT.
docker stop, Coolify redeploys, and Kubernetes pod terminations now triggerboss.stop({ graceful: true, timeout: 30s })so in-flight handlers finish instead of being killed. Previously, pending handlers could be lost or replayed on restart. - CI now blocks on TypeScript strict checks.
continue-on-error: trueremoved for typecheck. Tests already were blocking. ESLint stays non-blocking for now — the existingsettings/page.tsxmonolith has long-standingreact-hooks/set-state-in-effectviolations whose proper fix lives in the 1.4.0 settings-split refactor; a blocking lint here would just gate every PR until that refactor lands. Required cleaning up twoanytypes inapi-handler.ts(justified inline — Next.js variadic handler signature constrained byPromise<Response>return).
Polish
- Bottom-nav touch targets sized to WCAG 2.5.5 minimum (44×44 CSS px). Visual icon stays at 20 px so the design doesn't shift.
- Phase-config dialog marks the decorative coloured dot
aria-hiddenbecause the redundant text label already conveys the phase to screen readers. No more meaninglessimagenode announcements. - Admin-status labels for "Web Push" and "Bug Report" now go through
t()(newadmin.integrationWebPush/admin.integrationBugReportkeys in en + de). They were the last hard-coded English strings on the admin page.
What's not in this release (tracked for later)
- Onboarding redesign (dashboard-first empty-state flow + persistent
Getting Started checklist) and the typed
apiClientwrapper that underpins it are tracked for a focused 1.4.0 cycle. The 1.2.1 patch already closed the acute symptoms of #87 (silent-failure toast + default schedule), so the redesign is now a proper UI investment rather than a bug-fix. - Withings sync is mapping both new measures, but a dedicated Bearer-auth ingest endpoint for external pipelines (n8n + Health Connect, requested in #89) ships in 1.4.0 alongside the API-token flow.
[1.2.1] — 2026-04-27
Fixed
- Onboarding: Medications added during onboarding are now actually persisted (closes #87). The wizard previously sent an empty
schedules: []array, the server-side validation rejected it with a 422, the client never checkedresponse.ok, and the user was redirected to the dashboard as if everything had worked. Onboarding now wraps each step intry/catch, surfaces failures via toast, and attaches a default reminder window (08:00–09:00 daily) so the medication actually persists. A hint under the medication list explains the default. - Docker setup (closes #88):
docker-compose.ymlnow usesports: "3000:3000"(wasexpose: "3000", which made the app unreachable from the host).POSTGRES_PASSWORDis a single env var that both the Postgres service andDATABASE_URLinterpolate, so they cannot drift apart..env.examplenow points at the in-container hostnamedb:5432(waslocalhost:5432, which never resolves inside the app container).
- Documentation:
package.jsonsynced to 1.2.0 (was lagging on 1.1.0).CLAUDE.mdandAGENTS.mdcorrected to 23 models (theFeedbackmodel added in v1.2 was missing from the count).README.mdQuick Start gives a realistic time estimate, generates the four secrets in one block straight into.env, and points reverse-proxy users at the docs.
Added — Tooling & Supply Chain
- Pre-built multi-arch images on GHCR:
.github/workflows/docker-publish.ymlnow buildslinux/amd64+linux/arm64images on every push tomainand on everyv*tag, publishing toghcr.io/mbombeck/healthlog. Self-hosters no longer need a build toolchain —docker compose pull && docker compose up -dis enough. The bundleddocker-compose.ymlreferences the published image with abuild:block as fallback for contributors. - Supply-chain attestations: each published image carries a SLSA build provenance statement and a Software Bill of Materials.
SECURITY.mddocuments how to verify them and how to pin a specific version. - Documentation single source of truth:
getting-started/installation.mdxis now the canonical setup guide (mirrors the bundleddocker-compose.yml);self-hosting/docker.mdxslimmed to image internals + ops notes only. The landing page's Quick Start terminal block now includes the secrets-generation step (was missing).
Notes
This is a patch release that closes the install/onboarding friction reported in #87 and #88. The bigger user-facing changes (additional measurement types like total body water and bone mass per #89, full onboarding redesign, typed API client) are tracked for 1.3.0.
[1.2.0] — 2026-04-18
Added — Personalization, Glucose & Multi-Provider AI
- Per-user custom thresholds: Override the computed default ranges (BP, BMI, glucose, pulse) with values from your clinician. Audit-logged with previous/new values and timestamps. Doctor Report PDF flags custom ranges and prints both your target and the standard guideline value.
- Blood glucose tracking: New metric with
fasting,postprandial,random, andbedtimecontexts. Display unit switch between mg/dL and mmol/L (lossless conversion). Context-aware classification per ADA 2024 / DGIM. Per-context charting on dashboard and Doctor Report PDF. - Dashboard customization: Show/hide and drag-to-reorder every dashboard widget. Per-user preference, reset-to-defaults button. Layout persists across the same user on the same device.
- Built-in feedback system: New in-app Send Feedback flow (Bug / Feature / Question / Other) with anonymized system info attachment. Stored in HealthLog's own database — no GitHub config required. Optional
Escalate to GitHubbutton for admins who configure a PAT. - Multi-provider AI insights: Provider abstraction extended with Anthropic Claude and local OpenAI-compatible endpoints (Ollama, LM Studio, vLLM, LiteLLM) alongside OpenAI. Per-user provider selection. Local endpoints keep all health data on your network.
- Locale-aware UI polish (English-first): Numbers, dates, glucose units, BP, weight, and BMI all formatted via
useFormatters()from the active locale. Doctor Report PDF and AI insight prompts now respect locale end-to-end (no hand-rolledIntl.*with fixed locales).
Changed
- Reference range computation extracted into a dedicated
src/lib/health/thresholds.tsmodule with computed defaults and override resolution. - AI provider routing reworked to dispatch by
providerfield on the user record; OpenAI remains the default for legacy users. - Dashboard route renders widgets from
UserDashboardLayoutmodel when present, otherwise falls back to the default order. - Doctor Report PDF: locale-aware headers, glucose section, custom-range badges.
Security
- GitHub PAT for feedback escalation stored AES-256-GCM encrypted in the database (never as env var).
- Local AI endpoint URLs validated against SSRF (no localhost/RFC1918 unless explicitly allowed by admin).
- Custom threshold writes rate-limited and audit-logged with IP.
[1.1.0] — 2026-04-06
Added — AI Insights Overhaul
- ChatGPT Proxy Integration: Insights now run through a local openai-oauth proxy using your ChatGPT subscription — no separate API billing required
- Admin AI Fallback: Admins can configure a global API key (OpenAI/OpenRouter) as fallback for users without their own connection
- Provider Abstraction: New
src/lib/ai/module with pluggable providers (Codex OAuth, Admin Key, None) and automatic failover - Medical Insight Prompts: 7 specialized prompts based on ESC/ESH 2023, WHO, DGE, and DEGAM guidelines
- Blood Pressure: ESC/ESH classification, morning risk ladder (J-HOP), pulse pressure, seasonal variation
- Weight: 5%/10% milestone recognition, plateau detection, body composition divergence
- Pulse: Fitness interpretation ladder, 80-100 bpm elevated-risk band, rate-pressure product
- BMI: Age-adjusted DEGAM classification for 65+
- Medication Compliance: Chronotherapy hints, mood-adherence risk prediction, 90-day tracking
- General Status: Cross-domain synthesis with cardiovascular risk stratification
- Mood: Bidirectional correlations with vitals and adherence
- Enriched Feature Extraction:
- Sleep duration and activity steps (previously ignored)
- Rate-Pressure Product (pulse × systolic BP, myocardial demand indicator)
- Body composition divergence flag (weight stable + body fat rising)
- Mood-adherence risk predictor
- Seasonal BP variation (winter vs summer, requires >180 days data)
- BP standard deviation (sdSys30/sdDia30) as variability risk marker
- Pulse pressure (arterial stiffness marker)
- 5 cross-metric Pearson correlations (weight↔BP, pulse↔BP, mood↔pulse, mood↔BP, mood↔weight)
- 90-day averages and all-time statistics for all metrics
- Historical comparison (current 7d vs previous 30d baseline)
- New UI Components:
InsightStatusCard: Compact per-metric status card with classification indicator and fade-in animationInsightAdvisorCard: Premium structured card with findings, correlations, recommendations (ready for integration)
- OAuth Routes:
/api/auth/codex/authorize,/callback,/disconnectfor ChatGPT connection - Admin AI Settings:
/api/admin/ai-settingsfor global API key management
Changed
- Insight prompts now use personal advisor tone ("dein Blutdruck") with positive-first pattern
- Reasoning scaffold in system prompt (What changed? → Why? → What to do?)
- Conditional correlation instructions (only mention when |r| > 0.4)
- InsightResult schema enriched with
insightType,primaryRecommendation,classificationLabel - BP target calculation now uses paired readings (both sys AND dia must be in range simultaneously)
- Medication streak tracking extended from 7-day to 30-day window
- CSP updated to allow
chatgpt.comfor OAuth flow
Security
- Rate limiting on all OAuth and admin endpoints
- PKCE (S256) + state parameter for OAuth CSRF protection
- Encrypted token storage at rest (AES-256-GCM)
- Error messages truncated to prevent upstream response body leaks
- Admin key preview shows last 4 chars of decrypted key (not ciphertext prefix)
prefers-reduced-motionsupport for insight card animations
Removed
openaiKeyEncryptedfield from User model (replaced by provider abstraction)- Direct OpenAI API calls from insight generators (now routed through provider)
- Legacy API key input in settings UI (replaced by ChatGPT connect button)