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>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 authored and GitHub committed 2026-05-08 01:05:56 +02:00
1 parent b76d579717
commit 98a57eb786
62 files changed
+2172 -196

No files matched your search

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