mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
b76d579717
commit
98a57eb786
62 files changed
+2172
-196
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Generated
+133
@@ -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';
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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++;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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: "%",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 } },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user