Marc-André BombeckandClaude Opus 4.7 98a57eb786 audit(v1.3.3): SpO2 + body-composition + 14 CRITICAL/HIGH security/correctness fixes (#121)
* 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>
2026-05-08 01:05:56 +02:00
2026-03-10 20:14:33 +01:00
2026-03-10 20:14:33 +01:00
2026-03-10 20:14:33 +01:00
2026-03-10 20:14:33 +01:00
2026-03-10 20:14:33 +01:00
2026-03-10 20:14:33 +01:00
2026-03-10 20:14:33 +01:00
2026-03-10 20:14:33 +01:00
2026-03-10 20:14:33 +01:00
2026-03-10 20:14:33 +01:00
2026-03-10 20:14:33 +01:00
2026-03-10 20:14:33 +01:00
2026-03-10 20:14:33 +01:00
2026-03-10 20:14:33 +01:00

HealthLog Logo

HealthLog

Your health data belongs to you. Track it on your own terms.

License: AGPL-3.0 TypeScript Next.js 16 Self-Hosted PRs Welcome

A self-hosted, privacy-first health tracking PWA.
Weight, blood pressure, mood, medications, AI insights -- all under your control.

Website · Live Demo · Documentation


Why HealthLog?

Most health apps lock your data behind proprietary clouds, push subscriptions, and sell your metrics to advertisers. HealthLog takes a different approach: your data stays on your server, encrypted at rest, accessible only to you.


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, 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.

Customizable Dashboard -- Show, hide, and drag-to-reorder every widget. Per-user layout with reset-to-defaults.

Mood Logging -- 5-point scale with tags, notes, and trend analytics. Syncs automatically from moodLog.app via webhook.

Medication Compliance -- Flexible scheduling with time windows, recurrence patterns, intake logging (take/skip/snooze), and compliance heatmaps. External API for iOS Shortcuts integration.

Withings Integration -- OAuth2 device sync for scales, blood pressure monitors, and activity trackers with automatic deduplication.

Multi-Provider AI Insights -- Pick OpenAI, Anthropic Claude, or any local OpenAI-compatible endpoint (Ollama, LM Studio, vLLM). BYOK or admin-shared key. Cached daily. Local endpoints keep all data on your network.

Doctor Report PDF Export -- Generate professional medical reports client-side. Locale-aware (English/German), with vital sign summaries, BP/BMI/glucose classification, compliance rates, custom-threshold badges, and optional AI analysis.

Built-in Feedback -- Send bug reports and feature requests from inside the app. Stored in your HealthLog database — no GitHub config required. Optional GitHub escalation for admins.

PWA with Offline Support -- Installable on iOS and Android. Service worker with intelligent caching strategies for reliable offline access.

Multi-Channel Notifications -- Telegram (with inline action buttons), ntfy (self-hostable), and Web Push. Medication reminders with late/missed escalation.

Gamification -- 38+ persistent achievements across intake streaks, compliance milestones, and healthy metric streaks.

Internationalization -- English (default) and German UI with 1500+ translation keys, guarded by a CI integrity test that fails the build on duplicate keys or drift between locales. Numbers, dates, units, and AI prompts all locale-aware via useFormatters(). Browser-based detection with per-user override.


Quick Start

Plan ~5 minutes for a working install. The bundled docker-compose.yml pulls a pre-built multi-arch image (linux/amd64 + linux/arm64) from GitHub Container Registry; no build step required for self-hosters. Contributors who want to test local changes can docker compose up --build.

git clone https://github.com/MBombeck/HealthLog.git
cd HealthLog
cp .env.example .env

Generate the four required secrets and paste them into .env:

echo "POSTGRES_PASSWORD=$(openssl rand -base64 24)" >> .env
echo "SESSION_SECRET=$(openssl rand -hex 32)"       >> .env
echo "ENCRYPTION_KEY=$(openssl rand -hex 32)"       >> .env
echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)"   >> .env

Then bring the stack up:

docker compose up -d

Open http://localhost:3000. The first registered user becomes admin.

Behind a reverse proxy (Caddy / Traefik / Nginx) for TLS, set NEXT_PUBLIC_APP_URL and APP_URL to your public URL in .env before starting. See Self-Hosting → Reverse Proxy for examples.


Tech Stack

Layer Technology
Framework Next.js 16 (App Router, React Server Components)
Language TypeScript (strict mode)
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
Data Fetching TanStack Query 5
Forms React Hook Form 7 + Zod 4
Auth SimpleWebAuthn 13, Argon2id
Notifications Telegram Bot API, ntfy, Web Push (VAPID)
PDF jsPDF (client-side generation)
Testing Vitest 4
Deployment Docker (multi-stage Alpine)

Security and Privacy

HealthLog is designed for people who take data ownership seriously.

  • Self-hosted -- Your data never leaves your server. No telemetry, no third-party tracking.
  • AES-256-GCM encryption -- All stored secrets (OAuth tokens, API keys, VAPID keys) are encrypted at rest.
  • Passkey authentication -- WebAuthn as primary auth with password fallback (Argon2id + zxcvbn strength validation).
  • Server-side sessions -- PostgreSQL-backed with 30-day sliding expiry, HttpOnly/SameSite=Strict cookies.
  • Security headers -- CSP with nonces, HSTS, X-Frame-Options DENY, Permissions-Policy, Referrer-Policy.
  • Rate limiting -- Sliding window on auth and API endpoints.
  • HMAC-SHA256 API tokens -- Bearer tokens are hashed before storage.
  • Audit logging -- All sensitive operations tracked with IP addresses.

Environment Variables

Required

Variable Description
POSTGRES_PASSWORD Password for the bundled Postgres service (Docker Compose)
DATABASE_URL PostgreSQL connection string (uses POSTGRES_PASSWORD above)
SESSION_SECRET 64-char hex string for session signing
ENCRYPTION_KEY 64-char hex string for AES-256-GCM
API_TOKEN_HMAC_KEY 64-char hex string for API token hashing

Optional

Variable Description
NEXT_PUBLIC_APP_URL Public-facing URL (default: http://localhost:3000)
WITHINGS_CLIENT_ID Withings OAuth2 client ID
WITHINGS_CLIENT_SECRET Withings OAuth2 client secret
WITHINGS_REDIRECT_URI OAuth callback URL
WITHINGS_WEBHOOK_SECRET Webhook URL hardening secret
TELEGRAM_WEBHOOK_SECRET Telegram bot webhook secret

Telegram bot token, ntfy settings, Web Push VAPID keys, Umami, and GlitchTip URLs are configured in the Admin Panel and stored encrypted in the database.


Architecture

src/
├── app/                    # Next.js App Router pages & API routes
│   ├── api/                # REST API endpoints (100+ route files)
│   ├── admin/              # Admin panel
│   ├── auth/               # Login, register, passkey enrolment
│   ├── medications/        # Medication management
│   ├── measurements/       # Health metric entry
│   ├── mood/               # Mood log
│   ├── insights/           # AI-powered analytics
│   ├── charts/             # Long-form charts
│   ├── achievements/       # Gamification page
│   ├── targets/            # Custom thresholds dashboard
│   ├── notifications/      # Notification preferences matrix
│   ├── bugreport/          # Built-in feedback / bug report
│   ├── onboarding/         # 4-step guided setup
│   └── settings/           # User preferences (8 top-level sections)
├── components/
│   ├── ui/                 # shadcn/ui primitives
│   ├── layout/             # Shell (sidebar, topbar, bottom nav)
│   ├── medications/        # Medication cards, forms, timeline
│   ├── measurements/       # Measurement form, list
│   ├── mood/               # Mood form, mood list
│   ├── charts/             # Recharts wrappers
│   ├── insights/           # AI insight status / advisor cards
│   ├── gamification/       # Achievement cards, progress
│   ├── monitoring/         # Umami / GlitchTip bootstrap
│   └── settings/           # Settings-page section components
├── lib/
│   ├── auth/               # Session, audit, passkey logic
│   ├── notifications/      # Dispatcher + channel senders
│   ├── jobs/               # pg-boss worker (reminders, insights, backups)
│   ├── analytics/          # Trend calculations, compliance, correlations
│   ├── ai/                 # Multi-provider client (OpenAI, Anthropic, local)
│   ├── insights/           # Insight pipeline + medical prompts
│   ├── gamification/       # Achievement definitions
│   ├── feedback/           # Built-in feedback + GitHub escalation
│   ├── moodlog/            # moodLog.app webhook + sync
│   ├── monitoring/         # Umami / GlitchTip server-side hooks
│   ├── withings/           # OAuth client, sync service
│   ├── logging/            # Wide Events: builder, context, transports
│   ├── i18n/               # Translations context & config
│   ├── validations/        # Shared Zod schemas
│   ├── api-handler.ts      # apiHandler wrapper, requireAuth/requireAdmin
│   ├── api-response.ts     # { data, error } envelope helpers
│   ├── crypto.ts           # AES-256-GCM encrypt/decrypt
│   ├── rate-limit.ts       # Sliding-window rate limiter
│   ├── db.ts               # Prisma singleton
│   └── doctor-report-pdf.ts # Client-side PDF generation
├── hooks/                  # React hooks
└── generated/prisma/       # Generated Prisma client

Key Patterns

  • RSC by default -- "use client" only for interactive components
  • API envelope -- All responses follow { data, error } shape via apiSuccess() / apiError() in src/lib/api-response.ts
  • apiHandler wrapper -- Every API route wraps its handler in apiHandler() (src/lib/api-handler.ts) for consistent error handling, Wide-Event structured logging, and x-request-id propagation
  • Encrypted secrets -- Withings tokens, API keys, VAPID keys, notification credentials
  • Timezone-aware -- Europe/Berlin for display, UTC in database
  • Route protection -- proxy.ts (Next.js 16's renamed middleware) checks session cookie, redirects unauthenticated requests
  • Client-side PDF -- Doctor reports generated in browser via jsPDF

API Reference

All mutations require authentication via session cookie. External ingest uses Bearer tokens.

Health Data
Method Endpoint Description
GET /api/measurements List measurements (paginated, filterable)
POST /api/measurements Create measurement
DELETE /api/measurements/:id Delete measurement
GET /api/analytics Trend summaries (7d/30d)
GET /api/export Export as CSV or JSON
POST /api/import Import from JSON
POST /api/doctor-report Aggregated data for PDF
Mood
Method Endpoint Description
GET /api/mood-entries List mood entries
POST /api/mood-entries Create mood entry
DELETE /api/mood-entries/:id Delete mood entry
GET /api/mood/analytics Mood trend analytics
POST /api/integrations/moodlog/webhook moodLog.app webhook
Medications
Method Endpoint Description
GET /api/medications List all medications
POST /api/medications Create medication
PUT /api/medications/:id Update medication
DELETE /api/medications/:id Delete medication
POST /api/medications/:id/intake Log intake event
GET /api/medications/:id/compliance Compliance stats
POST /api/ingest/medication External intake (Bearer)
Auth and Integrations
Method Endpoint Description
POST /api/auth/register Create account
POST /api/auth/login Password login
POST /api/auth/logout Destroy session
GET /api/auth/me Current user profile + Gravatar URL
POST /api/auth/password Change password
PATCH /api/auth/profile Update profile fields
POST /api/auth/passkey/* WebAuthn flows (4 sub-routes)
GET /api/auth/passkeys List enrolled passkeys
GET /api/auth/codex/authorize ChatGPT (codex) OAuth start
GET /api/withings/connect Initiate Withings OAuth
POST /api/withings/sync Trigger manual Withings sync
POST /api/withings/webhook Withings notification webhook
POST /api/insights/generate Regenerate AI insights
GET /api/insights/comprehensive Aggregated insight payload
GET /api/gamification/achievements Achievement progress
GET /api/health Docker health check
Personalization (Thresholds + Dashboard)
Method Endpoint Description
GET /api/user/thresholds Read per-user threshold overrides
PUT /api/user/thresholds Upsert thresholds (rate-limited, audit-logged)
GET /api/insights/targets Effective ranges (defaults + overrides merged)
GET /api/dashboard/widgets Read dashboard layout
PUT /api/dashboard/widgets Persist dashboard layout (show/hide/reorder)
POST /api/onboarding/complete Mark onboarding finished
Feedback + API Tokens
Method Endpoint Description
POST /api/feedback Submit in-app feedback
GET /api/bugreport/status Check published GitHub issue state
GET /api/tokens List own API tokens
POST /api/tokens Mint new API token (Bearer, hashed)
DELETE /api/tokens/:id Revoke API token
Notifications
Method Endpoint Description
GET /api/notifications/preferences Read per-channel × per-event matrix
PUT /api/notifications/preferences Update preferences
GET /api/notifications/vapid VAPID public key for Web Push
POST /api/notifications/web-push Register Web Push subscription
POST /api/telegram/webhook Telegram bot inline-button callback
Admin (admin role required)
Method Endpoint Description
GET /api/admin/status System + integration status
GET /api/admin/users List users
POST /api/admin/users/:id/reset-password Force password reset
GET /api/admin/feedback All feedback / bug reports
POST /api/admin/feedback/:id/github Escalate feedback to GitHub issue
GET /api/admin/audit-log Audit-log viewer
GET /api/admin/ai-settings Read shared AI provider config
PUT /api/admin/ai-settings Update shared AI provider config
GET /api/admin/tokens All issued API tokens
POST /api/admin/notifications/test Send test notification
GET /api/admin/data Data backups + counts

Integrations

Integration Setup Purpose
Withings Env vars Auto-sync weight, BP, and activity
Telegram Admin Panel Medication reminders with inline buttons
ntfy User Settings Self-hosted push notifications
Web Push Admin Panel Browser-native VAPID notifications
OpenAI User Settings AI health insights (BYOK)
moodLog.app User Settings Mood tracking sync
Umami Admin Panel Privacy-friendly analytics
GlitchTip Admin Panel Sentry-compatible error tracking

Local Development

# Prerequisites: Node.js 20+, pnpm, PostgreSQL

cp .env.example .env
pnpm install
pnpm db:generate
pnpm db:migrate
pnpm dev

Scripts

pnpm dev              # Development server
pnpm build            # Production build
pnpm lint             # ESLint
pnpm typecheck        # TypeScript strict check
pnpm test             # Vitest
pnpm format           # Prettier

pnpm db:generate      # Generate Prisma client
pnpm db:migrate       # Create & apply migration
pnpm db:migrate:deploy # Apply migrations (production)
pnpm db:studio        # Prisma Studio GUI

Deployment

The included docker-compose.yml runs the app and PostgreSQL. The entrypoint automatically waits for the database, runs pending migrations, and starts the server.

The app listens on port 3000. Place it behind Nginx, Caddy, or Traefik for TLS termination. Works out of the box with Coolify.


Contributing

Contributions are welcome. See CONTRIBUTING.md for guidelines.

  • Code style: pnpm format && pnpm lint
  • Type safety: pnpm typecheck must pass
  • Tests: pnpm test
  • UI language: English by default, German selectable per user. Code, comments, and commits: English.

License

HealthLog is licensed under the GNU Affero General Public License v3.0.


healthlog.dev · Live Demo · Docs

Languages
TypeScript 98.8%
JavaScript 0.8%
Shell 0.1%
PLpgSQL 0.1%