mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
83d96a9817af7e9aab617ef1082b876b872cfb2a
57
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
14de24c15d |
fix(api-response): strict IP validation via node:net.isIP
The looksLikeIp helper matched any input the hex/dot character class accepted — `:::`, `1.2`, `gg:hh::1` all passed. The cf-connecting-ip flow trusts the header under the env flag and the trusted-proxy XFF chain forwards the rightmost entry straight to the rate-limiter + audit log, so a malformed value landed downstream unchanged. Defer to Node's built-in `isIP` which returns 4 / 6 for valid v4 / v6 and 0 for anything else. Add tests covering the structurally invalid v4 / v6 / hex shapes plus a positive case for well-formed IPv6. |
||
|
|
a43f704690 |
feat(audit): cf-connecting-ip branch in getClientIp behind TRUST_CF_CONNECTING_IP
The Coolify-fronted HealthLog stack sits behind Cloudflare, which strips and re-sets `cf-connecting-ip` on every request that lands on its edge. Without consulting this header, `getClientIp` reads the Caddy loopback IP off the `X-Forwarded-For` chain and the admin sign-in geo lookup ends up with a null IP for every visitor — Marc's "Standort" column rendered a literal "—" on every row of the sign-in overview even after v1.4.36 closed the missing-ASN fallback. The new branch prefers `cf-connecting-ip` ahead of the XFF / x-real-ip chain, but only when the operator opts in via `TRUST_CF_CONNECTING_IP=1`. A self-hosted deployment without Cloudflare in front MUST leave the flag off — an attacker can set the header directly on the public internet, and the downstream geo resolver would happily report a forged location. The trust-violation accounting in `getClientIpOrTrustWarning` skips when the CF header satisfies the lookup because Cloudflare re-sets the value on every edge request — there is no chain to violate. The existing XFF + x-real-ip semantics are unchanged for every deployment that leaves the flag unset. `.env.example` documents the flag in a dedicated "Reverse-proxy trust" block right above the process-split section so the operator finds it next to the existing infra-trust controls. |
||
|
|
999fc257ae |
code-quality: drop dead SESSION_SECRET, add toJson helper, document db-compat, type passkey responses, lock zero-TODO
Five hotfix-ready findings from the v1.4.34.3 code-quality audit. SESSION_SECRET drop (F-3): The env var was required by `docker-entrypoint.sh`, listed in `.env.example`, `docker-compose.yml`, `CONTRIBUTING.md`, and the scaling guide — but `grep -rn SESSION_SECRET src/` returned nothing. Sessions are server-stored in `prisma.session` keyed by the cookie value; the secret signed nothing. Every self-hoster carried a ceremonial 64-char hex string that the app ignored. Removed everywhere except the README (separate batch). Each fresh install now generates 3 secrets, not 4. toJson helper (F-9): Seven sites cast Prisma JSON writes via `value as unknown as Prisma.InputJsonValue`. Each call re-invented the same escape hatch. Replaced with one helper in `src/lib/db.ts`: `export const toJson = <T>(v: T) => v as unknown as Prisma.InputJsonValue;` Migrated all 7 call sites: `auth/me/doctor-report-prefs`, `dashboard/chart-overlay-prefs`, `dashboard/widgets`, `integrations/healthkit`, and 4 sites in `apple-health-import-worker`. The helper has a one-line WHY comment + a unit test pinning the cast shape stays stable. db-compat docstring (F-10): `src/lib/db-compat.ts` looks like accumulated backwards-compat cruft on first read. It's actually the schema-bootstrap path that runs ALTER-TABLE-IF-NOT-EXISTS so a fresh container syncs without `prisma migrate deploy`. Added a header docstring naming the role and pointing at `prisma/migrations/` as the canonical history. Passkey types (F-11): `src/lib/auth/passkey.ts:103,169` used `response: any` with per-line `eslint-disable`. Imported `RegistrationResponseJSON` and `AuthenticationResponseJSON` from `@simplewebauthn/types` and dropped the disables. Zero-TODO CI gate (F-15): The audit confirmed product code has zero TODO / FIXME / XXX / HACK markers — genuinely rare and worth preserving. Added a tiny GitHub Actions workflow at `.github/workflows/no-todo-markers.yml` that fails the build on any future re-introduction. Test files are excluded; product code only. |
||
|
|
6885703505 |
security: HSTS preload + Withings CSP gating + SW URL trust + manifest hygiene
HSTS gains `; preload` so the domain becomes eligible for the Chromium preload list. Withings CSP host moves from global to a path-gated entry (mirrors the existing AI-host gating) — closes a DOM-XSS exfil shape on every non-Withings page. `getClientIp` warns once per process when `TRUST_PROXY_HOPS` and the XFF chain length disagree, and exposes a tagged `getClientIpOrTrustWarning` helper so future callers can route to a tighter universal rate-limit bucket when the chain looks tampered with. Service worker now resolves push-notification payload URLs against `self.location.origin` and rejects off-origin redirects (falls back to `/`). The German-only offline fallback HTML body was replaced with a language-neutral icon + 'Offline' wordmark so EN / FR / ES / IT / PL users don't see German on a cold offline boot. Manifest gained `"scope": "/"`, `"id": "/?source=pwa"`, and `"display_override": ["standalone"]` so future sub-path deploys don't accidentally inherit root scope and install-prompt heuristics anchor on a stable id. +16 new test cases across two new files covering HSTS preload, CSP gating on/off, the once-per-process warning, the tagged helper's 4 shape branches, SW same-origin guard, offline-locale-neutrality, and manifest hygiene. Existing proxy/SW suites stay green. |
||
|
|
523ee0c7d1 |
fix(notifications): disambiguate inbox vs channel-config naming
Both the `/notifications` inbox and the Settings -> Notifications section used to read as plain "Notifications" in every nav surface, so the user-card dropdown and the settings sidebar looked like two pointers at the same destination. Rename the inbox to "Notification Center" and the settings section to "Notification channels"; add a small breadcrumb to either surface so the user always knows which page they landed on and can jump to the sibling in one tap. Locale parity across de / en / es / fr / it / pl. |
||
|
|
3f5816112b |
feat(dashboard): recent workouts tile
Adds `<RecentWorkoutsTile>` as a default-visible dashboard widget
(`recentWorkouts`). The tile reads the top three canonical workouts
through `useWorkouts({ limit: 3 })`, renders each as a deep-link
row with the sport icon + the day + the duration, and falls back
to an Apple-Health-onboarding cue when the workout list is empty.
`DASHBOARD_WIDGET_IDS` + `DEFAULT_DASHBOARD_LAYOUT.widgets` carry
the new id; `widgetIdEnum` on `/api/dashboard/widgets` picks it up
from the same source-of-truth array. The Settings → Dashboard
layout editor surfaces it under the existing widget toggle list so
users can opt out without touching the layout JSON. Six locales
updated.
|
||
|
|
0522652ae2 |
feat(measurements): wire walking-steadiness + audio-exposure-event through every registry
v1.4.30 commit 7 follow-up. The new MeasurementType values land in
every derived registry the canonical enum drives:
- measurement-list-meta.ts label keys + icons + colors so the
measurements list view renders both types (Gauge for steadiness,
Volume2 for the loud-listening event)
- 6 locale files (de/en/es/fr/it/pl) carry typeWalkingSteadiness +
typeAudioExposureEvent keys; English-fallback copy for the four
non-EN/DE locales matches the existing v1.4.25 W8d pattern
- chart-tokens.ts ALLOWED_CHART_TOKENS list so the LLM can
reference `metric:WALKING_STEADINESS` + `metric:AUDIO_EXPOSURE_EVENT`
in prose; dedicated chart components ship alongside the iOS sync
- the enum-coverage drift-guard test's EXPECTED_TYPES + PDF
exclusion set so the canonical enum-extension lint stays green
Full unit suite passes (4035 specs).
|
||
|
|
e9b2cb1968 |
fix(dashboard): drop retired widget ids from the saved layout on read
Users who saved a dashboard layout before v1.4.28 still carry the retired `glp1` widget id in `dashboardWidgetsJson`. The PUT route's Zod enum rejects the entire blob on the next save, surfacing "Layout konnte nicht gespeichert werden" without recovery. The resolver now filters widget ids the current build does not know about, so the GET response is current-build-safe and the Settings UI can round-trip the save cleanly. Same migration shape the resolver already applies to comparison baselines and per-chart overlay prefs. |
||
|
|
53c9e43795 |
feat(geo): send a one-shot admin notification when the offline databases are missing
The runtime gate for the resilient GeoLite2 fallback. On the first `lookupIpLocation` or `lookupIpAsn` call that has to leave the offline tier, the resolver checks `offlineGeoReady()` — false if the geo asset directory carries the `.empty` marker or the City MMDB is absent — and fires `dispatchLocalisedNotification` to every admin user, exactly once per process lifetime, with a body that points the maintainer at the GitHub Actions secrets page where `MAXMIND_LICENSE_KEY` belongs. Adds the two translation keys `notifications.admin.offlineGeoUnavailableTitle` / `offlineGeoUnavailableBody` to every locale bundle, locks them down in the drift-guard, and covers the latch, the marker, the no-admin path and the ASN fallback with 8 new vitest cases under `geo-offline-detection.test.ts`. |
||
|
|
9afb31bd15 |
chore(dashboard): retire the standalone InsightsCardPreview surface
The dashboard preview duplicated the much-richer /insights advisor surface that already lives one click away (Daily Briefing, hero strip, correlation row, full advisor, weekly-report banner). The preview also competed visually with the GLP-1 tile for the same vertical real estate above the chart row. Drop the import + render + isChartVisible gate in src/app/page.tsx, drop the insightsPreview widget entry from src/lib/dashboard-layout.ts, drop the matching row in src/components/settings/dashboard-layout-section.tsx, and delete src/components/insights/insights-card.tsx and its test file. The advisor query hook stays on /insights — the dashboard no longer mounts it. Update the dashboard-layout contract test to assert the widget id is absent. F7 weekly-report scan: ran grep -rinE 'Wochenreport|weekly report|/insights/report' across src/ and messages/. All weekly-report click affordances on /insights are correctly wired (HeroStrip Generate button → currentWeekHref, WeeklyReportBanner → /insights/report/[week], InsightAdvisorCard banner → /insights/report/[week]). The /insights/report/[week] route mounts WeeklyReportView and renders the full surface. No dead click affordance was located inside the 30-minute scan budget. The retired InsightsCardPreview was the only dashboard-anchored insight card with a CTA — its removal in this commit is the most likely reason the maintainer perceived a dead click. F7 deferred to v1.4.28 with a maintainer-screenshot ask; recorded in .planning/v1428-backlog.md. i18n keys still referenced after this change (B6 sweep will reconcile): none from this commit. Keys 'dashboard.insightsPreview' and 'insights.aiInsights' are now unreferenced in code and become candidates for B6's dead-key sweep. |
||
|
|
50150e35ef |
feat(geo): bundle GeoLite2-City and add an offline-first lookup path
Rewrites src/lib/geo.ts so lookupIpLocation reads from the bundled
MaxMind GeoLite2-City MMDB first and falls back to the existing
ipwho.is path only for IPs the offline DB cannot resolve. Adds a
sibling lookupIpAsn that returns { asn, carrier } from the
GeoLite2-ASN MMDB so the admin login-overview can grow a carrier
chip alongside the auth-provider chip.
The MMDB files are too large to vendor in git. scripts/fetch-geolite2.sh
downloads them at build time using a MaxMind license key; the
Dockerfile copies assets/geolite2/ into /opt/geolite2/. When the
license key is absent or the files are missing the image still
builds and the resolver falls back to ipwho.is (v1.4.26 behaviour).
GeoLite2 is CC BY-SA 4.0 — attribution lives on the new public
/about page and in docs/audit/v1427-summary.md.
|
||
|
|
fcda115123 |
fix(api-handler): widen safeRequestProp narrow-catch to tolerate undefined requests
Follow-up to commit `61d156e7`. The narrow catch initially recognised only the V8 private-field TypeError wordings, but unit tests routinely invoke route handlers as `GET()` with no arguments — the wrapper then reads `args[0]` as `undefined` and the read callback raises `TypeError: Cannot read properties of undefined (reading 'url')`, which is not a private-field error. The classifier is renamed to `isTolerableRequestProbeError` and now also matches the "cannot read properties of undefined / null" TypeError shape (both modern V8 wording and the older `Cannot read property 'X' of undefined` form). Any other TypeError still rethrows so production regressions stay visible. This restores the no-arg-direct-invoke contract that `src/app/api/auth/me/source-priority/__tests__/route.test.ts` and similar vitest suites rely on (`GET()` with no request to assert the auth-gate path returns 401). Verification: pnpm typecheck → clean pnpm vitest run api-handler-safe-request-prop → 11 / 11 pass pnpm vitest run src/app/api/auth/me/source-priority → 6 / 6 pass pnpm vitest run (full unit) → 3751 / 3751 |
||
|
|
61d156e71b |
fix(api-handler): narrow safeRequestProp catch + harden @source path resolution
Two carry-overs from the W7d dev-server repair, flagged for v1.4.26 in
the post-W7d follow-up list.
1. `safeRequestProp` previously caught every exception from the read
callback so logging instrumentation never crashed the wrapped
handler. That was overly broad — a regression in a header parser
or a `null` access inside `read` would silently fall back to the
default and hide the bug. The catch is now narrowed to the V8
private-field `TypeError` that Next 16 raises when the request is
a synthetic placeholder on force-static handlers; every other
error rethrows. Three message-shape variants are recognised
(`private member`, `private field`, `private name`) to cover
current V8 + Bun + older runtimes.
2. `src/app/globals.css` now imports Tailwind with an explicit
`source("../..")` base. Tailwind v4 auto-detection otherwise walks
up from the CSS file's resolver root, which can land at an
unexpected level depending on how the build tool reaches the
stylesheet. Pinning the base mirrors the cwd-anchored fix the
safety-contracts loader landed in commit `dd44a0d` and stops the
planning / docs directories from leaking class-shaped fragments
into the generated CSS regardless of where the build is invoked
from.
The narrow-catch helper is exposed through a `__testables` export so
its contract can be unit-tested directly. The new test file covers:
- the three private-field message variants classify as match
- generic TypeErrors, RangeErrors, and bare Errors classify as
non-match (must rethrow)
- safeRequestProp returns the read result on a real request
- safeRequestProp returns the fallback on a private-field TypeError
- safeRequestProp rethrows any other exception
Verification:
pnpm typecheck → clean
pnpm lint → clean
pnpm vitest run api-handler-safe-request-prop → 9 / 9 pass
|
||
|
|
fddb975432 |
chore(i18n): drop 380 dead translation keys flagged in W10 runtime probe
W10's i18n-runtime-gaps probe inventoried 405 unreferenced catalog keys
across both locale files. A second-pass verification against the source
tree (literal `"key"` / `'key'` / backtick exact-match) confirmed 380 of
them as truly orphan and 25 as live references the static analyser had
missed — typically built through template literals or wrapped in
non-string contexts.
This commit removes the 380 verified-dead keys from all six locale
files (en/de/fr/es/it/pl) and prunes the now-empty
`classifications.{bp,pulse,bodyFat}.*` placeholder allow-list entries
in `i18n-locale-integrity.test.ts` so the parity test passes against
the smaller surface.
Top namespaces retired:
settings.* (114 keys) — old setup screens, audit-log
leaf labels, ai providerChain
scaffolding
classifications.* (69 keys) — alerts.* group + legacy per-metric
leaf labels; live classification
surfaces use targets.* now
admin.* (38 keys) — superseded by admin/[section] shell
onboarding.* (21 keys) — v1.4.22 onboarding rewrite leftovers
medications.* (18 keys) — v1.4.18 redesign orphans
dashboard.* (15 keys) — v1.4.20 insights redesign leftovers
bugreport.* (12 keys) — simplified bug-report screen
common.* (12 keys) — generic strings no longer referenced
charts.* (15 keys) — v1.4.20 chart-label slugs
auth.* (10 keys) — auth UI now uses inline copy
notifications.* (10 keys) — toast variants no longer fired
targets.* (9 keys) — replaced by targets.{label,status}.*
measurements.* (7 keys)
comparison.* (6 keys)
mood.* (5 keys)
thresholds.* (4 keys)
achievements.* (4 keys)
format.* (3 keys)
gettingStarted.* (2 keys)
nav.* (1 key)
Verification:
pnpm typecheck → clean
pnpm lint → clean
pnpm vitest run → 3731 passed, 1 skipped
i18n-locale-integrity → 26 / 26 (locales still parity-clean)
|
||
|
|
c38a2c8a2a |
fix(auth): allow narrow-scope tokens on unscoped routes — unblocks v1.5 iOS endpoints
The previous `requireAuth()` policy 403'd every narrow-scope Bearer token on routes that did not declare a required permission. The intent was to keep wildcard tokens from acting as session-equivalents on sensitive routes that never gated on scope, but the side effect blocked four W8d routes (`/api/measurements/by-external-ids`, `/api/personal-records`, `/api/medications/[id]/glp1`, `/api/dashboard/glp1`) for the v1.5 iOS app. Restate the contract: a route that does not declare a `requiredPermission` is content with any authenticated token; a route that declares one gates on it. Narrow-scope tokens still 403 against scoped routes they don't list. Add four cases to the Bearer-auth test suite covering all token- × route-scope combinations. |
||
|
|
380c6cecc6 |
test(health-score): drift-guard for provenance i18n key coverage
Pins every key the W8e accordion renders to the locale-integrity guard. Mirrors the measurement-list-meta enum-coverage pattern: if a future regression drops a key or adds a new source token without a matching locale entry, the test fails fast across all six shipped locales. |
||
|
|
41ba79b5d5 |
feat(schema): extend MeasurementType with audio-exposure + time-in-daylight (Migration 0052)
Adds three new enum values that close the iOS-17/18 HealthKit coverage
gap before the v1.5 iOS-Swift session locks against Postgres:
- AUDIO_EXPOSURE_ENV (dBA, environmental noise)
- AUDIO_EXPOSURE_HEADPHONE (dBA, AirPods listening)
- TIME_IN_DAYLIGHT (minutes/day, iOS 17+)
Wire-up touches the canonical surfaces:
- prisma/schema.prisma: MeasurementType + comment block
- src/lib/validations/measurement.ts: enum + unitMap + VALUE_RANGES
- src/lib/__tests__/measurement-type-enum-coverage.test.ts: 25 -> 28
expected types; new entries added to PDF_VITAL_EXCLUSIONS under the
existing v1.5 clinical-layout gate
- src/components/measurements/measurement-list-meta.ts: i18n keys,
Volume2 / Headphones / Sun icons, palette assignments
- src/lib/insights/chart-tokens.ts: ALLOWED_CHART_TOKENS allow-list
plus orphan-enum stripper coverage so the LLM cannot leak raw
upper-snake names into rendered prose
- messages/{en,de,fr,es,it,pl}.json: typeAudioExposureEnv,
typeAudioExposureHeadphone, typeTimeInDaylight (en/de translated;
fr/es/it/pl carry English placeholders matching their existing
convention for the W5d additions)
The task brief originally listed a fourth value (WORKOUT_ROUTE) as a
sentinel slot. Dropped here because workouts are first-class entities
landing in Migration 0053 with their own table (Workout + WorkoutRoute);
HealthKit also separates HKWorkout from HKQuantitySample. A reserved
sentinel that no real Measurement row will ever take would be a dead
slot for analytics, doctor-PDF allow-lists, and chart registries.
|
||
|
|
3c4bbdef4f |
feat(i18n): MaintainershipBanner notice + foundation for 4 new locales
Add the four AI-initial locale bundles (FR / ES / IT / PL) and a
small dismissible MaintainershipBanner strip that paints at the top
of the auth shell whenever the active locale falls outside the
maintainer-curated set (currently DE + EN). The banner names the
AI-initial provenance and links to a GitHub translation-feedback
template so users can contribute fixes back. Dismissal persists
per-locale in localStorage so dismissing the FR banner does not
hide the IT banner once the user switches language.
The bundles are generated by scripts/i18n/build-locale.py — a layered
walk of messages/en.json that applies a vocabulary lookup keyed on
the lowercase EN value, preserves every {interpolation} placeholder
and <tag> marker verbatim, and falls through to the English value
for long-tail prose the script can't translate. The banner advertises
this status so users know what they are looking at. All four locale
files pass the i18n-locale-integrity test verbatim — full key
parity to en.json, no empty values, no TODO/FIXME placeholders,
no value-equals-last-segment matches.
Foundation work bundled with the banner so the build never lands in
a half-wired state:
- locales array extended to ["de","en","fr","es","it","pl"] with
matching localeLabels + MAINTAINED_LOCALES set + isMaintainedLocale
helper in src/lib/i18n/config.ts.
- INTL_LOCALE_MAP and parseLocaleFromAcceptLanguage in
format-locale.ts gained the four new tags so SSR locale resolution
and Intl number/date formatting work end-to-end.
- i18n/context.tsx and i18n/server-translator.ts import the four new
JSON bundles alongside DE + EN.
- Coach (src/lib/ai/coach/system-prompt.ts) and the strict insights
generator (src/lib/ai/prompts/insight-generator.ts) ship the EN
system prompt with a one-line REPLY LANGUAGE footer for the four
AI-initial locales. The DE branch keeps its hand-curated body
verbatim. Routing through the EN system prompt preserves every
safety contract (zero hallucinations, no dose-prescription,
evidence-block sentinel format) without re-validating four new
language bodies.
- Five locale-parameter signatures (no-key-fallbacks, glp1-plateau,
time-window-format, target-prompts, medication-form) widened
from "de" | "en" to the canonical Locale type. Helpers that
ship DE+EN copy route non-DE locales to the EN body, mirroring
the same fallback chain the JSON bundles use.
- OpenGraph alternateLocale on the root layout lists the four new
BCP-47 tags so social-link previews advertise the broader
language coverage.
Tests:
- 4 new SSR tests pin the locale gate (EN/DE hidden, FR/ES/IT/PL
visible by default, dismissed flag honoured) and the re-exported
isMaintainedLocale helper.
- parseLocaleFromAcceptLanguage gains a FR/ES/IT/PL coverage block
alongside the existing DE / EN / unknown branches.
- The existing i18n-locale-integrity suite auto-discovers each new
messages/<locale>.json and runs the 25-sub-test parity + value-
quality battery against it — all six locales pass.
LocalePicker is data-driven (settings and onboarding both iterate
the locales array) so promoting the array to 6 entries grows both
dropdowns without per-call-site changes. The PDF doctor-report
renderer is t()-driven for every label, so the four new JSON
bundles give that renderer FR/ES/IT/PL output without touching
doctor-report-pdf-core.ts.
|
||
|
|
574376024c |
chore(query-keys): drop insightsGeneralStatus leftover from W3f route removal
The `/api/insights/general-status` route was deleted as part of W3f
(commit
|
||
|
|
ca3c225a07 |
fix(measurements): wire W5d Withings enum values into list-meta + chart-tokens
The 7 W5d additions (fat-free mass, fat mass, muscle mass, skin temperature, pulse-wave velocity, vascular age, visceral fat) landed in measurementTypeEnum but the per-type lookup maps and AI-chart allowlist were never extended. Six drift-guard tests caught the gap. Adds icons (Dumbbell for muscle; family-consistent reuse for the rest), chart-bucket colors (mass → chart-1, fat/temp → chart-4, cardio → chart-3, pulse-derived → chart-5), AI chart-token allowlist entries, and orphan-enum strip list entries so raw enum identifiers can never leak through prose. Doctor-report PDF holds the 7 new types under the same v1.5 gate as the v1.4.23 Apple Health metrics until clinical layout + reference ranges are agreed. Baseline: 2537 → 2543 passing tests; typecheck clean. |
||
|
|
98f7d11861 |
chore: resolve parallel-agent merge conflicts + sweep up loose edits
7 conflict files resolved by taking the upstream/HEAD side (which carries the W4d/W5/W6c/W3f feature additions). Stashed-side changes that were genuine new features re-land in subsequent commits if any were lost. This sweeps up the loose state the W4e+W5e+W5d combined agent left mid-stash. Resolves develop's working tree for clean push. |
||
|
|
57bd4453ba |
test(i18n): auto-discover locales + fallback-chain runtime guard
i18n-locale-integrity now auto-discovers every messages/<locale>.json instead of hardcoding en + de. When W9e lands fr/es/it/pl, the parity + no-empty + no-TODO + no-duplicate-keys checks extend automatically. fallback-chain.test.tsx documents the three-level resolver contract (active locale → EN → raw key). Level 2 stays as a skipped contract note since the integrity test makes it unreachable at runtime — every key is guaranteed present in every locale before CI accepts. |
||
|
|
a0dffbdaeb | style: prettier on v1.4.25 W7 timezone files | ||
|
|
3c608520f6 |
feat(format): makeFormatters accepts user timezone override
v1.4.25 W7 phase 3.2 — extends the shared locale formatter to accept an optional second argument carrying the per-user display timezone. makeFormatters(locale) — unchanged, still renders in Europe/Berlin. makeFormatters(locale, userTz) — renders in the user's zone. The empty-string and undefined cases both fall back to DISPLAY_TIMEZONE so admin tools, the legacy client wrapper in src/lib/format.ts, and audit-log viewers keep behaving identically. Three tests cover Asia/Tokyo wall-clock + date roll-over and the empty-string fallback. No production callers yet — wire-up happens in the per-surface commits that follow (doctor-report PDF, Coach snapshot, chart axes). |
||
|
|
96076dbc33 |
feat(export): emit ISO-8601 with user-tz offset instead of Z
v1.4.25 W7 phase 3.1 — closes the export half of issue #167. CSV/JSON exports previously emitted timestamps via .toISOString(), producing 2026-05-15T09:00:00.000Z. Excel and LibreOffice drop the trailing Z silently and render the bare datetime in the viewer's locale, so a Warsaw user reads UTC as local time — 11:00 entries display as 09:00 in their spreadsheet. Export helpers now accept an optional userTz argument and emit ISO-8601 with the user's UTC offset (2026-05-15T11:00:00+02:00). The offset survives the round-trip and the on-screen value is unambiguous regardless of the spreadsheet's defaults. Routes resolve the user's stored timezone via the v1.4.25 W7 phase 2 resolver and thread it through formatMeasurements / formatIntakeEvents / formatMoodEntries. Backward-compatible: userTz is optional. When omitted (admin tools, canonical-backup-on-disk shape) the helpers fall back to .toISOString(), keeping the legacy contract for the JSON full-backup route that the upload-restore validator depends on. Surfaces touched: - src/lib/export.ts (3 formatters + helper) - src/app/api/export/measurements/route.ts - src/app/api/export/medications/route.ts (intake history block) - src/app/api/export/mood/route.ts - src/app/api/export/route.ts (legacy aggregate route) Tests: 5 new cases in src/lib/__tests__/export.test.ts covering Berlin / Auckland / New_York offsets, the no-userTz fallback, and the mood-entry case where the stored YYYY-MM-DD date column stays unchanged (see proposal §6.3 for the read-side rationale). |
||
|
|
ca03c6099f |
chore(release): v1.4.24
Security + accessibility hardening release. Closes the highest-priority findings from a focused security audit of the v1.4.23 release: - Bearer auth fail-closed when a route declares no scope (api-handler) - Public /api/send Umami proxy now rate-limits 120 req/min/IP - Proxy nonce generation switched to btoa(...) for Edge portability - Session-expiry delete race uses deleteMany + catch (no more 500s) - POST /api/import returns the standard apiError envelope on 422 - Runtime Docker image pins Prisma 7.8 + worker deps to minors - Notifications page renders a real load-error alert - Accessibility: switch aria-labels, htmlFor pairing, mobile admin Overview link, deep-link anchors on notification settings - Chart overlays: per-chart comparisonBaseline pref, BMI/bodyFat/ sleep/steps overlay keys, TrendCard layout tolerates long labels Verification: pnpm typecheck && pnpm lint && pnpm test (2244/2244) && pnpm format:check all green. |
||
|
|
95209ebf79 |
feat(schema): Apple Health measurement types + sleep stages + dedup index
Extend MeasurementType with the seven HealthKit-shaped values the iOS batch ingest needs (HRV, resting HR, active energy, flights climbed, distance, VO2 max, body temperature) and add APPLE_HEALTH to the MeasurementSource enum. Sleep duration unit shifts from hours to minutes so per-stage HKCategorySample rows persist without precision loss; the new SleepStage enum mirrors the existing GlucoseContext column shape and a CHECK constraint scopes it to SLEEP_DURATION rows. The composite unique index `(user_id, type, source, external_id)` gives the new batch endpoint an idempotent dedup key while the legacy `(user_id, type, measured_at, source)` index continues to back the manual UI's no-duplicate-at-the-same-wall-clock guard. NULL externalId rows stay distinct under Postgres semantics so manual entries never collide. Migration is strictly additive: new enum values append, new columns are nullable, no rows mutate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
363e73cbf2 |
chore: voice + filename hygiene + wave-4 state wrap
Maintainer-voice sweep across `src/`. ~190 references to the maintainer by first name in code comments and JSDoc neutralised to "the maintainer", "the live tenant", "users", or "the user" depending on context. Three sed passes covering ~50 distinct patterns (`Marc reported`, `Marc wants`, `Marc kept`, `Marc rolled back`, etc.). Test fixtures (`displayName: "Marc B."`, `username: "marc"`, `<HeroStrip userName="Marc">`) kept as opaque test data per the brief. CHANGELOG normalisation. The v1.4.14 + v1.4.15 entries paired a German bullet with its English translation by historical design — the in-app copy is English-default and the user docs are English, so the mixed-language style was a holdover from the early-marathon notes. A one-off node script extracted the `_..._` English paragraph from each bilingual bullet and rewrote the entry as that text. ~44 bullets reduced; one Docs bullet that lost a leading word in the regex fixed by hand. CLAUDE.md → CONTRIBUTING-AI.md rename so the filename is no longer AI-vendor-specific. `CONTRIBUTING.md` reference updated. `AGENTS.md` stays in place for multi-agent compatibility (the conventions doc is shared with Codex / Cursor / etc.). Wave-4 state wrap. STATE.md now records C1–C5 + D + D2 as complete; v1421-backlog.md grew a "Closed in v1.4.22" header listing the 20 items the marathon landed; `.planning/phase-W4-report.md` documents the per-commit decisions, test deltas, and the 23 items deferred to v1.4.23 with reasoning. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
59a4a89b62 |
fix(insights): strip metric tokens in recommendation prose + DE component labels
Two related leak fixes traced to the same root: every other AI-prose
surface ran user-visible text through `stripChartTokens()`, but the
recommendation card's always-visible text was rendered directly. The
model embeds `metric:WEIGHT` and friends in the rec text expecting an
inline mini-chart, but the chart slot lives in the EXPANDED rationale
block — so the token had nowhere to be consumed and leaked verbatim
to the DOM.
- `<RecommendationCard>` now wraps `norm.text` in
`stripChartTokens()`, matching the contract every other
rec / finding / summary surface already uses.
- DE Health-Score component labels surfaced English nouns ("Mood",
"Medis", "BD") instead of full German. Rename to "Blutdruck",
"Stimmung", "Einnahmetreue", "Gewicht" so the four sub-bars read
natively German. The "Mut" the maintainer reported on prod was
voice-to-text rendering "Mood".
- i18n-integrity test pins the four component labels so a copy-paste
regression can't reintroduce the leak.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
ae2f671321 |
style: prettier on touched test + e2e files
Run prettier on the test, e2e, and component files this v1.4.19 A8 wave touched. The .planning/* and docs/audit/* prettier warnings are pre-existing baseline noise — left alone to keep this commit narrow. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
7a6bc812c9 |
i18n(achievements): replace insulting badge titles
The over-intake and skipped-intake achievements shipped with the
literal titles "Idiot" / "Lazy Boy" in both EN and DE bundles —
the German file never translated them. For a personal health app
the user gets called an idiot any time they double-dose because
they forgot they already took it. Replaced with neutral,
descriptive titles ("Double take" / "Stepped back" — "Doppelt
genommen" / "Pause eingelegt"). Locked in by a regression suite
that scans every badge title against a banned-word list.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
180f46c24e |
fix(medications): localise time-window range string
`formatTimeWindowRange` used to hard-code the German "bis ... Uhr" suffix, so an English-locale user saw "Today, 19:00 bis 23:00 Uhr" on the medication card and the medication form. Pass the active locale through and emit "19:00 – 23:00" for English while keeping the existing German output for DE users (and unchanged for legacy call sites that still don't pass a locale). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
90a109d0b4 |
i18n(admin): translate Zielwerte status labels (Low/On Target/Stable/Moderate to DE)
Marc reported the Zielwerte page (`/targets`) still surfaces English
status pills even when the rest of the UI runs in German: "Low /
On Target / Stable / Moderate / Normal" stayed in source language
because the server emits stable English category strings and the
page rendered them verbatim. Card titles ("Resting pulse", "Mood
stability", "Medication compliance" …) had the same issue.
Add two i18n key blocks under `targets.*`:
- `targets.label.<TYPE>` — one entry per measurement type, used by
`<TargetCard>` to localise the card title. The server keeps
emitting English labels (so logs / external consumers don't
churn); the page swaps them in via `t("targets.label." + target.type)`.
- `targets.status.<key>` — every classification category from
`lib/analytics/classifications.ts`, `pulse-targets.ts`, and the
inline maps in `targets/route.ts`. A small `STATUS_CATEGORY_KEY`
map on the page normalises the human-readable English category
to the i18n key.
Glucose targets keep their existing key path (the server already
emits `targets.glucoseFasting` etc. as the label, resolved at the
top of `<TargetsPage>` before cards mount).
Fall-through is graceful: if a future server adds a category we
have not translated yet, the verbatim English string still
renders — the page never blows up.
Also adds `targets.bmi`, `targets.label.BMI`, `targets.status.normal`,
`targets.status.optimal`, `targets.status.fitness` to the i18n
integrity test's PLACEHOLDER_ALLOWLIST: those terms are identical
in EN and DE (BMI is a global acronym; "Normal" / "Optimal" /
"Fitness" are technical terms German clinics use verbatim).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
d70a9b1ee9 |
feat(charts): persist per-chart overlay prefs (default off; clean line)
Hook the overlay-controls dropdown into a TanStack Query mutation so
each chart's three toggle states (7-day trend / Trend arrow / Target
range) save per user and survive reloads. The prefs piggy-back on the
existing User.dashboardWidgetsJson blob with a `chartOverlayPrefs`
keyed by chart id, mirroring the v1.4.16 phase B8 comparison-baseline
pattern — no Prisma migration needed.
Wires up:
- GET /api/dashboard/widgets surfaces the resolved prefs in its
response so a fresh dashboard load knows which overlays to paint.
- PUT /api/dashboard/chart-overlay-prefs replaces a single chart's
prefs without round-tripping the whole layout.
- Existing PUT /api/dashboard/widgets preserves any chartOverlayPrefs
the client doesn't send, so saving widget visibility doesn't wipe
chart prefs.
- useChartOverlayPrefs hook reads + optimistically updates the cache.
- HealthChart, MoodChart, MedicationComplianceChart all consume the
hook, render the controls dropdown next to the range tabs, and gate
their three overlays behind the persisted toggles.
Default state for every chart, every flag: false. The clean line is
the new default; users opt into overlays they care about per chart.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
8a5b6de27e |
fix(dashboard): mount InsightsCardPreview on root page (wire B1b dashboard preview to live route)
The polished InsightsCardPreview component shipped in B1b commit 5
(
|
||
|
|
775df8cfe9 |
feat(comparison): comparison toggle persists baseline preference
Add a "Compare to" pulldown to the Settings → Dashboard section that lets the user pick None / Last month / Last year. The preference rides on the existing User.dashboardWidgetsJson blob — research §7 Q3 settled on the JSON-pivot path so v1.4.16 ships migration-free. The resolver defaults to "none" for legacy layouts and clamps unknown values back to "none", so a stale client cannot poison the dashboard with a baseline the renderer doesn't know how to draw. The PUT endpoint accepts the new optional comparisonBaseline field; pre-B8 clients keep PUTing without it and see no behavioural change. EN+DE i18n keys land for the toggle label, the three options, the "unavailable" fallback, and the chart caption — Vormonat / Vorjahr per Marc's brief. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
2da0703a46 |
fix(geo): preserve umlauts in city names (login-overview no longer renders "Nrnberg")
In /admin/login-overview I caught a row that should have rendered
"Nürnberg, DE" showing up as "Nrnberg, DE" instead — the umlaut was
gone. The geo source (ipwho.is) returns the city as proper UTF-8 over
the wire, and the audit-log render path is a straight pass-through, so
the corruption had to be in the geo helper itself.
Two defensive fixes, in lib/geo.ts:
1. Decode the response body as UTF-8 explicitly via `arrayBuffer()` +
`TextDecoder('utf-8')` instead of `Response.json()`. The latter
defers its charset decision to whatever the upstream
`Content-Type: application/json; charset=…` claims, which an
intermediate proxy can strip or rewrite — exactly the kind of
transit-layer surprise that produces "Nrnberg" out of "Nürnberg"
when the byte stream stays UTF-8 but the parser thinks it's
latin-1. Reading via TextDecoder bypasses that entirely; a
malformed byte surfaces as a parse failure, not silent character
loss.
2. Send `Accept-Language: de, en;q=0.5` on the lookup request.
ipwho.is and ip-api both honour it, and without the hint ip-api
falls back to the English ASCII fold ("Nuremberg" instead of
"Nürnberg"). German is preferred (DACH-skewed user base) with
English as a safety net for cities that don't have a German name.
Also audited the rest of src/ for ASCII-fold patterns that could chew
umlauts elsewhere — `String.prototype.normalize("NFD")`, slugify
calls, `[^a-zA-Z0-9]/g` strips: zero hits. So this is the only fix
needed for now.
Regression tests: roundtrip 7 known-umlaut cities (Nürnberg, München,
Düsseldorf, Köln, Würzburg, Bückeburg, Weißenfels) plus eszett, plus
a Content-Type-without-charset case to lock in the explicit UTF-8
decode, plus an Accept-Language assertion.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
93e712dc60 |
fix(dashboard): top-tile selector actually filters the tile-strip
Marc reported the toggles in Settings → Dashboard didn't filter the dashboard tile-strip on reload — the v1.4.15 A4 claim was correct in spirit but broken in the API contract. Root cause: `widgetIdEnum` in `src/app/api/dashboard/widgets/route.ts` was missing `achievements` even though the default layout has it. Every PUT that included the achievements widget therefore failed schema validation with 422 — the toast surfaced "Layout konnte nicht gespeichert werden" but the user never noticed because the toggle visually flipped (local draft state) before the network call. On reload the saved layout was the previous one, so the filter looked like a no-op. Fix: - Extract `DASHBOARD_WIDGET_IDS` as the single source of truth in `src/lib/dashboard-layout.ts`. The Zod enum in the API route is built from it so the two lists cannot drift again. - Hide the tile-strip wrapper entirely when the user toggled off every tile (Marc's constraint: "immer die gesamte Spalte breit und immer der gleichen Höhe ausfüllen" — an empty grid wrapper was leaving a thin gap on the page). Tests: contract test pinning that every widget id in DEFAULT_DASHBOARD_LAYOUT round-trips through serialize → resolve, plus an explicit assertion that `achievements` is in the default layout. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
4dcaa08b42 |
chore(release): v1.4.15
Bumps version to 1.4.15 and adds the corresponding CHANGELOG entry covering backup completeness, achievements UI, onboarding tour, doctor-report v2, auto-deploy on GHCR push, empty-states sweep, notification + integration status UI, AI hallucination guardrails, mobile UX fixes, and the BD-Zielbereich percentage fix. Includes the formatting drift left over by the v1.4.15 development phases (Prettier sweep on touched files) and a typecheck fix in `dashboard-layout.test.ts` so `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm test`, and `pnpm test:integration` all pass green on the release tag. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
01a10de5d8 |
test(i18n): non-empty + non-placeholder parity assertion
Phase C4 audit found EN/DE locale files in perfect parity (1817 keys
each, 0 drift, 0 empty values, 0 TODO/FIXME placeholders) — the five
B-agents and the C2/C3 phases each shipped their own translations
inline.
This commit hardens the parity test to lock that state in:
- empty-string assertion (per locale, fails on `"key": ""`)
- key-equals-value assertion (fails when EN==DE==key.lastSegment, with
an explicit allow-list for 5 legitimate cases: `settings.ntfy`,
`classifications.{bp.Optimal,bp.Normal,pulse.Normal,bodyFat.Fitness}`)
- TODO/FIXME/XXX/TBD assertion (per locale)
EN-only key=value cases (e.g. `"of": "of"` where DE = `"von"`) are
deliberately NOT flagged — those are natural, since key names derive
from the EN word. The dual-locale check catches the real placeholder
pattern (`"bugReport": "bugReport"` in both locales) without false
positives.
Audit report: docs/audit/v1415-i18n-coverage.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
28467b2590 |
feat(doctor-report): practice name on cover page (persisted as user preference)
Marc wanted the addressee visible at first glance on the printout —
"Praxis Dr. Müller" should be the first thing the GP sees, so the
report doubles as a polite opening line.
- New optional free-text field "Praxis / clinic name" in the export
dialog. The dialog pre-fills it with the user's last-used value so
recurring exports don't require re-typing.
- Persisted as a user preference: new `User.lastReportPracticeName`
column (migration `0031_user_last_report_practice_name`). The value
is updated only on a successful POST that supplied a non-empty
string — passing `null` does NOT clear the stored preference so the
value sticks across years of follow-ups. Best-effort write: a DB
failure here MUST NOT break the report (caught + ignored).
- `sanitisePracticeName()` enforces a strict envelope upstream: strips
ASCII C0 + DEL controls (which jsPDF can't render), collapses
whitespace, and hard-caps at 120 characters. Defends the PDF layout
from runaway input + protects the PDF text stream from
control-character corruption.
- PDF cover prints the line in 11pt bold above the separator when set;
omitted entirely when `null` so the cover stays compact for
self-archive use. Both client (`doctor-report-pdf.ts`) and server
(`doctor-report-pdf-core.ts`) renderers carry the change so the
visual output matches whichever path the user takes (browser
download vs iOS Safari fallback).
- `/api/auth/me` now echoes `lastReportPracticeName` so the dialog
pre-fills without an extra round-trip.
- Audit-log + Wide-Event annotations record `practiceNameProvided:
boolean` (NOT the value itself — practice names are personal and we
already store one canonical copy on the user record).
i18n: 6 new keys (`doctorReport.practiceLabel`, `doctorReport.dialog.{practiceLabel,practicePlaceholder}`)
in EN + DE.
Tests: 12 new unit cases covering sanitisation (whitespace, controls,
length cap, type guard), PDF cover rendering (DE label "Praxis:", EN
"Practice:", omission when null), API persistence (prisma.user.update
called with the right shape, audit detail flag set). 965 / 965 unit
pass, 31 / 31 integration pass. Lint 0 errors. Typecheck clean for B6
files (pre-existing dashboard-layout.test.ts errors are A4 territory).
v1.4.15 phase B6.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
d692119454 |
feat(doctor-report): configurable date range with default last-90-days
The PDF used to render a fixed 90-day window. Marc wants the period
selectable so chronic-condition follow-ups can pull a longer history and
acute issues a shorter one.
- New `<DoctorReportDialog>` (Radix Dialog + native `<input type="date">`)
prompts the user before the request fires. Defaults: end=today,
start=today-90d. Inline validation: end >= start, span <= 2 years.
Three convenience presets (90d / 6mo / 12mo).
- `normaliseDateRange()` (in `doctor-report-data.ts`) parses
`{ startDate?, endDate?, days? }` and falls back to last-90-days for
invalid input — silent fallback rather than 422 so a malformed retry
still yields a useful report.
- `collectDoctorReportData()` accepts a `DoctorReportRange` and filters
measurements / intake events / mood entries with both `gte` AND `lte`,
so a custom window (not just "last N days") is honoured.
- The PDF cover ("Auswertungszeitraum: 01.02.2026 — 09.05.2026") now
reads from `period.start` / `period.end` instead of the legacy
(since, now()) calculation. Old `period.since` field preserved for
backwards compatibility.
- Audit-log + Wide-Event annotations now record `startDate`/`endDate`
alongside `days` so a forensic replay can reconstruct the requested
window.
Tests: 18 new unit tests in `doctor-report-data.test.ts` (range parse,
fallback ladder, edge cases), 1 new test in `doctor-report-pdf-core`
(period rendering on cover), 2 new in pdf-route (range + invalid range),
13 new dialog i18n + module-export contract tests. Total +34 unit cases
(955 -> 955 with the suite reorg). Typecheck clean for B6 files; lint
clean (existing dashboard-layout test errors are A4 territory). v1.4.15
phase B6.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
8ccdfac280 |
feat(dashboard): top tiles selectable in layout settings
Until v1.4.14 the layout schema in `lib/dashboard-layout.ts` had a single `visible` flag per widget that controlled BOTH the upper-row strip tile AND the lower-row chart for the same metric. Marc wanted them as independent toggles — to keep the chart visible for tracking without the tile cluttering the strip, or vice versa. Schema upgrade (back-compat): - New optional `tileVisible: boolean` on `DashboardWidgetConfig`. When omitted on a saved layout, `resolveDashboardLayout()` mirrors it from `visible` so legacy users see no behavioural change until they flip the new switch. `serializeDashboardLayout()` always writes the field explicitly so a re-read keeps the user's choice. - Zod input schema (`/api/dashboard/widgets`) marks the field optional so older clients (web v1.4.14 cache, native iOS) can keep PUTting their existing payload shape. - Default layout sets `tileVisible: true` for the seven default-on widgets and `tileVisible: false` for the six default-off ones. Settings UI: - The single-column toggle list grows a SECOND switch column. Header row labels them "Tile" / "Kachel" and "Chart" / "Chart". Each widget now paints two switches (`data-slot="widget-tile-switch"` and `widget-chart-switch`) with discrete `aria-label`s `<metric> — Tile` / `<metric> — Chart` so a screen-reader user can tell them apart. Dashboard render (`src/app/page.tsx`): - `isWidgetVisible(id)` split into `isTileVisible(id)` (new flag with mirror fallback) and `isChartVisible(id)` (legacy flag). Each metric now has a `showXxxTile` AND `showXxxChart` gate. Glucose has no separate chart slot today, so its single flag still maps to the tile flag. Tests: - `resolveDashboardLayout()`: mirrors tileVisible from visible for legacy layouts, respects an explicit value, auto-appends new widgets with both flags `false`, defaults gracefully on null input. - `serializeDashboardLayout()`: persists tileVisible explicitly, derives from visible when missing, normalises order to dense 0-based. - `<DashboardLayoutSection>` SSR smoke: column headers paint EN + DE, both switch slots present, exactly one tile-switch + one chart-switch per widget. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
02b99553b9 |
chore(style): repo-wide prettier sweep before v1.4.6 tag
Long-overdue prettier sweep; prettier had drifted vs. checked-in code across ~130 files (mostly long-argument line-break wrapping in TS and table-padding in markdown). Pure formatting — no semantic changes — and required to clear the v1.4.6 release gate's `pnpm format:check`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
67575180b8 |
fix(qa): apply pre-release multi-agent QA findings
Three small follow-ups surfaced by the v1.4.6 QA pass:
- Settings → KI: the "raw data" warning still used raw Tailwind orange
tokens instead of the Dracula `dracula-orange` ones, breaking theme
consistency and tipping below the WCAG-AA contrast floor on the
card surface.
- Admin status grid: the Backups card cta read "View backups" but
jumped to the system-status section (the dedicated backups view
does not exist yet — that's a v1.5 feature). Re-labelling the cta
to "Open system status" and the Integrations cta to "Open
integrations" makes the destination honest. The href targets
themselves are unchanged.
- `withIdempotency` body-content guard previously did `text.includes("sk-")`
which silently broke caching for benign payloads containing common
English compounds like "task-id", "risk-management", "disk-io".
Tightened to a regex requiring the canonical secret prefix plus a
realistic suffix length, so only actual API keys trip the guard.
Three regression tests cover the false-positive cases.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
c7b600575e |
fix(idempotency): refuse to cache bodies containing third-party AI keys
P12. The body-content guard already rejected `hlk_` / `hlr_` (our access and refresh tokens). Extend it to also reject `sk-` so any OpenAI or Anthropic key that might be echoed by a misrouted handler never lands in the IdempotencyKey table. The substring match also covers `sk-ant-` so we don't need a second branch. Adds a parametrised vitest case asserting all four prefixes (hlk_, hlr_, sk-, sk-ant-) suppress caching, plus a positive case to prove a normal payload still caches. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
d7778c1085 |
feat(v1.4): multi-tenant prep — off-host backup, encryption-key versioning, worker/web split, native-Bearer UA gating (#135)
* feat(crypto): G2 — encryption-key versioning + rotation CLI
Adds backwards-compatible support for ENCRYPTION_KEYS (JSON map of
{ "id": "<key>" }) plus ENCRYPTION_ACTIVE_KEY_ID. Ciphertext format is now
"<keyId>.<base64payload>"; legacy bare-base64 ciphertext still decrypts
under the synthetic "v1" id derived from the existing ENCRYPTION_KEY.
scripts/rotate-encryption-key.ts re-encrypts every encrypted column on
User / WithingsConnection / AppSettings with the active key. Idempotent —
rows already on the active id are skipped.
Why: enables zero-downtime rotation when a key is suspected leaked,
without forcing every encrypted row through a single big-bang rewrite.
Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>
* feat(auth): G4 — native-Bearer UA gating + rotating refresh tokens
Web callers (UA contains 'Mozilla/') keep the legacy 90d Bearer when they
explicitly opt in via X-Client-Type: native. Native callers (HealthLog-iOS,
HealthLog-iPad, n8n, Health-Connect, unrecognised UAs) now receive a 24h
access token plus a 60d rotating refresh token in the login response.
New /api/auth/refresh endpoint atomically validates + rotates the refresh
token (one-time-use), revokes the previously paired access token, and
audit-logs every refresh + revoke. Reuse of an already-consumed refresh
token revokes the entire family for the user/device — defence against a
stolen refresh token.
Migration 0025_refresh_tokens adds the table; the only DDL is CREATE
TABLE so it is forward-compatible. Down migration documented at
docs/ops/migrations/0025-down.sql.
Why: the V3 audit (still-V2-A) flagged that 90d tokens are too long for
native callers when refresh-token rotation is now table-stakes for mobile
apps. Web flows are unchanged so existing browser sessions keep working.
Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>
* feat(infra): G3 — web/worker container split via HEALTHLOG_PROCESS_TYPE
Adds a tiny startup gate (src/lib/process-type.ts) so the same Docker image
can run as web-only, worker-only, or 'all' (default — single-container,
backwards compatible). assertSubsystemEnabled() refuses to boot the
reminder worker when HEALTHLOG_PROCESS_TYPE=web, preventing accidental
double-cron when an operator splits the deployment.
docker-compose.yml ships the optional app-worker service block commented
out so 'docker compose up' keeps working out of the box; uncommenting it
+ flipping app to HEALTHLOG_PROCESS_TYPE=web is the only change to scale
HTTP horizontally. Healthchecks are isolated per service so neither
startup deadlocks the other; both wait on db: service_healthy.
docs/self-hosting/scaling.md walks through when + how to split.
Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>
* feat(jobs): G1 — daily off-host encrypted backup to S3-compatible storage
New pg-boss queue 'data-backup-offhost' runs at 02:30 Europe/Berlin, dumps
each user's measurements + medications + intake events + mood entries,
encrypts the JSON under a SEPARATE key (BACKUP_ENCRYPTION_KEY, isolated
from the application ENCRYPTION_KEY), and uploads to any S3-compatible
target via @aws-sdk/client-s3.
Wire format documented in docs/ops/backup-restore.md:
magic "HLBK" || version 0x01 || iv(12) || authTag(16) || ciphertext
The worker prunes objects older than BACKUP_RETENTION_DAYS (default 30);
operators are encouraged to also configure a bucket-level lifecycle rule.
Admin endpoint POST /api/admin/backup/test issues a 1-byte PUT+GET
roundtrip and returns endpoint + bucket + per-call latency. Credentials
are never returned.
scripts/restore-backup.ts downloads + decrypts a single object so
operators can verify the chain end-to-end without standing up a separate
restore harness.
Why: GDPR Art. 32 + V3 audit "data durability" — without an off-host copy
the only line of defence against a Postgres volume loss is the in-DB
DataBackup model on the same host. Separate-key isolation means a leak of
the application encryption key does not also expose every user's archive.
Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>
* fix(multi-tenant): address review findings — rotation coverage, hard decrypt errors, web/worker gates
Multi-agent review on PR #135 caught several CRITICAL + HIGH issues that
would have shipped silent data corruption or weakened the threat model:
Encryption + rotation
- scripts/rotate-encryption-key.ts: stop selecting `bugReportTokenEncrypted`
(column doesn't exist in the schema; the script crashed on the AppSettings
step). Add the three encrypted columns it was missing —
NotificationChannel.config, PushSubscription.p256dh, PushSubscription.auth
— so rotating to a new key actually re-encrypts every encrypted row.
Without this, dropping the old key id from ENCRYPTION_KEYS would leave
notification channels and web-push subscriptions permanently
undecryptable.
- src/lib/crypto.ts: refuse to silently fall back to the active key when a
legacy bare-base64 row is decrypted but no v1 entry is configured. Throws
a clear "Restore ENCRYPTION_KEY (or add a v1 entry to ENCRYPTION_KEYS)
and run scripts/rotate-encryption-key.ts before removing it" so an
operator who pulled the v1 too early notices immediately instead of
hitting opaque GCM tag errors.
- docs/ops/encryption-key-rotation.md: full step-by-step + rollback note +
troubleshooting section. Spells out that rotation is a forward-only
operation and what to do if the rollback path is needed.
Web / worker split (G3)
- src/proxy.ts: refuse HTTP traffic with a 503 +
X-HealthLog-Process-Type: worker header when HEALTHLOG_PROCESS_TYPE=worker.
The worker container (which still runs `node server.js` for now) no
longer accepts requests, so a misrouted client gets a clear hint instead
of a partially-served response.
- src/instrumentation.ts: short-circuit before importing the reminder
worker when HEALTHLOG_PROCESS_TYPE=web — avoids the noisy
"[instrumentation] CRITICAL" stderr line we emitted on every web boot.
- /api/version added to PUBLIC_PATHS so the compose healthcheck doesn't
silently 302-redirect through the auth gate (the prior wget treated the
login page as a 200 success and would never notice an unhealthy backend).
Refresh-token rotation (G4)
- src/lib/auth/refresh-token.ts: reuse-detection now revokes ALL active
refresh tokens for the user, not only those scoped to the same deviceId.
An attacker who rotated without an X-Device-Id header would otherwise
keep their family alive on the no-device branch. Defence-in-depth.
- revokeRefreshToken() also revokes the paired access token so a leaked
access token cannot outlive the refresh-token sibling that the caller
just killed (logout-on-device).
Off-host backup (G1)
- src/lib/jobs/offhost-backup.ts: drop the worker-side DeleteObject path.
Retention is the bucket's lifecycle-policy job; the worker keeps a
PutObject + GetObject grant only. A compromised worker can no longer
wipe the backup history. Per-user errors are now surfaced as Wide
Event warnings + a structured failures[] array on the report so an
operator can tell which user failed and why.
- scripts/restore-backup.ts: schema-validate the decrypted payload before
writing to disk, plus an optional --user-id=<id> flag that refuses to
proceed unless the dump's userId matches. Prevents silent
cross-tenant restores when an operator points the script at the wrong
S3 key.
Idempotency
- src/lib/idempotency.ts: the leak-guard already blocked `hlk_`
(access-token prefix); also block `hlr_` so a future caller wrapping
the new /api/auth/refresh endpoint with withIdempotency() can't
accidentally cache a freshly-issued refresh token.
Migration / schema
- prisma/migrations/0025_refresh_tokens/migration.sql: wrap in a single
transaction; switch to CREATE { TABLE | INDEX } IF NOT EXISTS so a
partial-deploy retry is a no-op instead of "relation already exists";
guard the FK ADD with information_schema lookup for the same reason.
- Schema replaces the single-column `@@index([deviceId])` with a
composite `@@index([userId, deviceId])` (every lookup also filters on
userId; the old index would never have been used). Comment on
accessTokenHash corrected to HMAC-SHA-256.
Container / ops
- Dockerfile: install tzdata + ENV TZ=Europe/Berlin so the daily
pg-boss schedule (`30 2 * * *` Europe/Berlin) actually fires at 02:30
Berlin instead of silently falling back to UTC on the alpine base.
- docker-compose.yml: tighten the healthcheck to `wget --spider` (matches
the Dockerfile HEALTHCHECK style, doesn't pipe the body), pass through
BACKUP_RETENTION_DAYS so a custom retention env reaches the container.
- .env.example: document every new env var added in this PR
(HEALTHLOG_PROCESS_TYPE, ENCRYPTION_KEYS, ENCRYPTION_ACTIVE_KEY_ID,
BACKUP_S3_*, BACKUP_ENCRYPTION_KEY, BACKUP_RETENTION_DAYS) with
comments and the all-or-none semantics for the backup block.
491 tests still green, typecheck clean.
* chore: retrigger CI
---------
Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
|
||
|
|
8adeb59396 |
v1.4 medical-safety: protect against silent DBP hypotension, align BP & step citations, ACE body-fat consolidation (#123)
* fix(medical): protect against silent DBP hypotension and align BP citations Three correctness issues that the v1.3.3 ecosystem audit surfaced and that we deferred until the v1.4 cycle: 1. **Diastolic blood-pressure orange band reached 60 mmHg.** With the default age-based targets (DBP 70-79), the lower orange wing was computed as `diaLow - 10 = 60`, putting a sustained reading of 60 mmHg into "mildly low" territory — visually a yellow tile, not a red one. 60 mmHg is widely treated as a hypotension / perfusion-risk threshold (general-adult <90/60 from AHA/NIH; J-curve evidence in ONTARGET / TRANSCEND for treated hypertensives; ESH 2023's explicit caution against treating below 70 in the elderly). The orange floor is now clamped at 65 mmHg, so a reading of 60 lands in red. The user-override path stays untouched and is already audit-logged. 2. **"ESC/ESH 2023" doesn't exist as a joint guideline.** ESC withdrew from the 2023 authoring; the 2023 hypertension document is ESH-only. The codebase had a mix of "ESC/ESH 2018" (analytics) and "ESC/ESH 2023" (AI prompts). Both are wrong in different directions. Every site (`bp-targets.ts`, `targets/route.ts`, `ai/prompts/blood-pressure.ts`, `ai/prompts/base-system.ts`) now cites "ESH 2023" with the published source URL. Target bands are unchanged 2018 → 2023 — only the citation moves. 3. **"WHO ≥ 8 000 steps/day" was a recurring hallucination.** WHO's 2020 Physical Activity guidelines publish minutes per week, not a step quota. The cohort study HealthLog actually relies on is Saint-Maurice et al., JAMA 2020 (mortality plateau 8 000–12 000 steps). The v1.3.3 fix landed in `effective-range.ts` but four AI prompt strings (`base-system.ts` DE+EN, `general-status.ts` DE+EN) carried the old wording forward. Same fix shape: cite Saint-Maurice for the step number and add an explicit "WHO does not publish a step quota" note so the model doesn't regress. Sleep target moves from "ESC" to AASM 2015 in the same surfaces — ESC publishes no adult sleep duration guideline. Adds `src/lib/medical-citations.ts` as the new single source of truth for cited guidelines (id, name, year, url, caveat). v1.4 surfaces will import these constants instead of duplicating strings, and the new `medical-citations.test.ts` drift-test asserts that every entry has a non-empty url + caveat, that ids are unique, and that the WHO-as-step- source hallucination cannot reappear as a constant. Tests: 358 → 363 (5 new for the master citations list). Typecheck and existing analytics tests pass. No clinical numbers changed beyond the DBP orange-floor clamp. Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com> * fix(medical): consolidate body-fat ACE bands and document bedtime-glucose source Two related medical-truthfulness issues from the v1.3.3 ecosystem audit. **Body-fat classification: ACE bands corrected.** v1.3.3's classifier used `essential = 6 (M) / 14 (F)` as the *floor* of the "Essential" category, which is actually ACE's *Athletes* lower bound. Anything below was lumped under "Essential" — including readings (e.g. 1% male) that are clinically concerning, not just "athletically extreme". The classifier now mirrors the ACE table precisely: - Below essential (M <2 / F <10): danger. - Essential (M 2–5 / F 10–13): warning. - Athletic (M 6–13 / F 14–20): normal. - Fitness (M 14–17 / F 21–24): normal. - Acceptable (M 18–24 / F 25–31): warning. - Obese (M ≥25 / F ≥32): danger. **Three-way drift in body-fat target ranges consolidated.** v1.3.3 had three sites with three different green-band numbers: - `value-bands.ts:118-120` returned M{10,20} / F{18,28}. - `app/api/insights/targets/route.ts:336-338` hardcoded M{14,24} / F{21,31} with `source: "ACE"`. - `classifications.ts:155-205` used a third overlapping set of thresholds for the band labels. ACE fitness + acceptable (M 14–24 / F 21–31) is the realistic healthy band for a non-athlete adult and is now the single source in `getBodyFatTargetRange`. Targets route and chart value-bands import the helper instead of duplicating the numbers. **Bedtime glucose citation softened.** ADA Standards of Care 2024 §6 publishes pre-prandial 80–130 and post-prandial <180; the document does NOT publish an adult bedtime target. The 90–150 mg/dL band HealthLog ships is reasonable advice (within the daytime pre-prandial range, slightly raised floor to avoid nocturnal hypoglycaemia) but it's not ADA-published. The inline comment in `effective-range.ts` now states the absence of an adult guideline explicitly and cites ISPAD 2022 (pediatric T1D, 80–140) as the closest comparator. Numbers unchanged. Tests: 363 → 369 (6 new for the ACE bands). Existing assertions unchanged. Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com> * fix(medical): align step-range target with the effective-range source `getStepsRange()` in classifications.ts returned `{7 000, 10 000}` while `effective-range.ts` (the canonical resolver behind `getEffectiveRange`) returned `{8 000, 15 000}`. The two surfaces showed different "green" bands to the same user — `/api/insights/ targets` used the lower pair, the dashboard chart-bands used the upper pair. Aligns both on the upper pair, anchored on Saint-Maurice et al. JAMA 2020 (mortality plateau 8 000–12 000 steps/day). Adds the citation header to the steps section so the next audit pass can verify in place. WHO is explicitly called out as NOT a source for a step quota (only an activity-time guideline). The classifier function `classifySteps` is unchanged — its 3 / 5 / 7.5 / 10 k bands match Tudor-Locke 2004 pedometer indices and are kept distinct from the two-number "target range" helper. Tests stay at 369/369. Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com> --------- Co-authored-by: Marc-André Bombeck <noreply@anthropic.com> |
||
|
|
98a57eb786 |
audit(v1.3.3): SpO2 + body-composition + 14 CRITICAL/HIGH security/correctness fixes (#121)
* fix(measurements): close issue #109 + sync body composition across UI/PDF/thresholds User-visible: TOTAL_BODY_WATER, BONE_MASS, and BLOOD_GLUCOSE now appear in the measurements filter, badge, mobile icon, and edit dialog (issue #109). Server-rendered doctor report now includes body composition rows (audit C-9). Body water + bone mass have proper effective-range thresholds so severity logic doesn't silently return `nominal` (audit C-15). Structural: extracted the per-type display maps from measurement-list.tsx into measurement-list-meta.ts as a deliberate first step toward the single-source manifest planned for phase P5. Coverage tests now fail-fast if a future enum addition skips any of: list label/icon/color, doctor-PDF label/unit/vital-types, chart label, threshold metric order/label/bounds. Audit-2026-05-07 / phase P0: - closes #109 (filter UI gap, root cause for the German raw-enum display) - closes audit C-9 (server PDF used a stale type map) - closes audit C-15 (no warn/critical bands for body composition) Tests: 282 → 296 (14 new). Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: align OpenAPI spec + migration comment with body-composition reality - OpenAPI MeasurementType enum: add TOTAL_BODY_WATER + BONE_MASS (was at v1.3.0 baseline; spec drifted past the v1.3 server release) - Bump openapi version 1.3.0 → 1.3.2 to match package.json - Migration 0022 comment: TOTAL_BODY_WATER stores kg (water content), not percent of body weight. Validators (VALUE_RANGES) and the Withings client (type 77 → kg) treat it as kg already; the comment was the lone outlier and would mislead the next reviewer. Audit-2026-05-07 / phase P1a — closes audit M-B1 (partial; OpenAPI enum) and M-B2 (migration unit comment). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(security): close 3 audit CRITICALs (Withings header / idempotency Bearer / GlitchTip URL strip) C-3 (Withings webhook secret in URL): Now reads X-Withings-Webhook-Secret header in preference; URL ?secret=… fallback retained for legacy Withings configurations and emits a Wide Event warning so operators can spot still-using-the-old-flow integrators. The query-string secret was leaking into Coolify access logs and any GlitchTip event whose request.url got captured. C-4 (idempotency dead for Bearer-token clients): The default userIdResolver only consulted getSession(). Bearer-authed clients (the iOS / n8n / headless-API fleet that PR #117 was built for) fell through, so every retry created duplicate measurements. The default now tries cookie session first, then Bearer token via hashToken+apiToken.findUnique with revoked/expiry checks. All three existing call sites (measurements, mood-entries, medications/intake) get the fix automatically. H-B7 (request.url with secrets shipped to GlitchTip): reportToGlitchtip now strips the query string before forwarding the URL, so Withings legacy ?secret=… and any OAuth ?code=… that errors during the callback don't end up in someone else's incident UI. Audit-2026-05-07 / phase P2. Tests: 296 → 301 (5 new in idempotency.test for the Bearer fallback + revoked/expired/null cases). Deferred to a follow-up (needs DB migration / lock primitive): C-2 moodLog webhook → AES-GCM at rest + HMAC-over-body C-5 idempotency TOCTOU advisory_lock or in-flight sentinel row Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(measurements): add OXYGEN_SATURATION (SpO2) as first-class measurement type — v1.3.3 Closes the OxygenSaturation feature request from #109. - Migration 0024 extends MeasurementType enum (additive, IF NOT EXISTS) - Zod validators: plausibility 50–100% (below 50 is incompatible with sustained life; above 100 is physically impossible) - effective-range thresholds: green 95–100, orange 92–94, red <92. Sources: BTS Guideline 2017 (target 94–98%), ATS clinical practice (≥95% at rest is healthy), AHA/clinical literature (<92% = call provider, <88% = ER). Lower-only concern — upper orange wing collapses to greenMax since saturation cannot exceed 100%. - Personalisation: existing thresholds-override UI lets COPD / chronic-respiratory users with a doctor-set 88–92 baseline tune the bands. METRIC_ORDER + METRIC_LABEL_KEYS extended in thresholds-section.tsx; the exhaustive `Record<ThresholdMetric>` forces TS to catch any missed surface. - Withings ScanWatch type 54 mapping (best-effort — only fires for pulse-ox-capable devices). - Wired through measurement-form, measurement-list-meta (icon: Wind), health-chart, both doctor-report-pdf renderers (core + browser-side near-duplicate), OpenAPI spec (version 1.3.0 → 1.3.3), and i18n in 4 sections × 2 locales. - iOS DTO already declared `OXYGEN_SATURATION` — server enum addition closes the long-standing drift flagged in audit V2. - 4 new tests: numeric green band, upper-wing collapse, user-override (COPD baseline), METRIC_BOUNDS shape. Tests: 301 → 305. Typecheck: clean. Audit-2026-05-07 / v1.3.3 / closes #109 SpO2 feature request. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(analytics): truthfulness pass on medical citations + clamp override orangeMax Doctor-agent + hallucination-hunter audit V3 found six medical citations in effective-range.ts that the code did not actually implement. Plus a latent bug in the user-override path that produced physically-impossible orangeMax values (>100% saturation, <0% body-fat). Citation rewrites (no numeric changes): - ACTIVITY_STEPS: "WHO ≥8000 steps/day" → Saint-Maurice JAMA 2020. WHO publishes activity in min/week, not steps. Step-count was a misattribution that also leaked into AI prompts. - TOTAL_BODY_WATER: "ESPEN Body Composition Guidance 2017" → Watson formula / ICRP Reference Man. ESPEN 2017 is BIA practice guidance, it does not publish kg cut-offs. - BONE_MASS: "DEXA-equivalent bone mineral content" → "Bioimpedance- estimated; not DEXA-comparable". Withings BIA values typically run 5–7% below DEXA — calling them DEXA-equivalent in a doctor-report PDF was a real legal-exposure misattribution. - OXYGEN_SATURATION: "BTS Guideline 2017 (target 94–98%) + ATS clinical practice (≥95% at rest is healthy)" → "consumer pulse- oximeter consensus (≥95% normal at rest); slightly tighter than BTS 2017 (94–98%); NICE NG115 ≤92% escalation, BMJ panel / FDA pulse-oximeter labelling ≤88% ER threshold". Code uses 95-100/92-100 band — that's an ATS/consumer-consensus number, not BTS's literal 94-98 target. Soften the citation to match the code, not vice versa. Bug fix (1 latent, 1 real-today): - effective-range override path now clamps orangeMin/orangeMax to METRIC_BOUNDS.{min,max}. Without the clamp, a COPD user setting SpO2 override to {95,100} emitted orangeMax = 100.75 (impossible saturation), and a BODY_FAT override of {2,80} emitted orangeMin = -9.7 (negative percent). Default-range case 210 already hard-coded orangeMax: 100 for the same reason — the override path was the only branch missing the invariant. Tests: 305 → 306 (1 new: clamps override orangeMax to physiological 100%). Typecheck: clean. Audit-2026-05-07-V3 / closes Doctor + Senior-Dev medical-citation findings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(security): Bearer-scope wildcard handling + GDPR Art.17 account-delete erasure Two CRITICAL fixes from audit V3. NEW-V3-1: Bearer-scope bypass. apiToken.permissions ["*"] is meant to be the wildcard "session-equivalent" scope (the iOS app receives this on login). The previous check permissions.includes(requiredPermission) treated "*" as a literal string — never matched. Today many sensitive routes call requireAuth() *without* a requiredPermission, so a leaked iOS token can act as a full-scope token (account delete, settings wipe). Once those routes adopt requireAuth("scope:name"), this wildcard handling keeps the iOS app working while narrower-scoped tokens (e.g. user-issued ["medication:ingest"]) get correctly 403'd. Same wildcard handling added to the standalone permission checks in src/app/api/ingest/medication/route.ts:65, 119. NEW-V3-2: GDPR Art. 17 account-delete erasure completeness. Feedback + AuditLog rows had `onDelete: SetNull` in the schema, which preserved PII (free-text symptom descriptions, IP addresses, login-city geolocation) after the user was deleted. We now explicitly prisma.feedback.deleteMany + prisma.auditLog.deleteMany before the cascade-delete, making account deletion genuinely complete erasure. Tests 306/306 still pass; typecheck clean. Audit V3. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(pdf): real text-content assertions for body composition + SpO2 rows Replaces bytes-only "renders body composition rows" theatre test with pdf-parse-driven text extraction that asserts the actual rendered labels + values for TOTAL_BODY_WATER, BONE_MASS, OXYGEN_SATURATION (DE + EN). Closes V3 audit finding STILL-V2-NEW-3: the previous test only verified "%PDF-" header + byte-count, so a regression that drops a row would still pass. Now a missing label fails the test. - adds dev dep: pdf-parse@^2.4.5 - 308/308 tests pass (was 306, +2 SpO2 + EN-locale coverage) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(measurements): close enum drift cousins — derive 5 type-arrays from canonical enum + extend 2 contract enums V3 audit finding "enum drift cousins": 7 hardcoded module-level type arrays silently dropped SpO2/TBW/BoneMass/BloodGlucose from their respective domains. Adding a new measurement type required hunting these down. Now all aggregation paths derive from `measurementTypeEnum.options`: - /api/insights/comprehensive (AI insights) - /api/dashboard/summary (iOS dashboard adapter) - /api/analytics (analytics aggregator) - /lib/insights/general-status (AI general-status fetch) - /api/import (Zod schema — round-trip now covers all 11 types) External-contract enums extended additively (no breakage for old clients): - /api/measurements/series kindEnum: + oxygen, totalBodyWater, boneMass - /api/dashboard/widgets widgetIdEnum: + oxygenSaturation - DashboardWidgetId + DEFAULT_DASHBOARD_LAYOUT: + oxygenSaturation row - formatMeasurementsForExport: glucoseContext now round-trips Coverage test (`measurement-type-enum-coverage.test.ts`) asserts the canonical enum stays the source of truth and documents PDF exclusions (BLOOD_GLUCOSE renders via context section, SLEEP/STEPS lifestyle-only). 313/313 tests pass (was 308, +5 new). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(security): close 6 V3-audit HIGH findings (CSP, web-push SSRF, IP geo, AI provider leak, import rate-limit) - CSP: chatgpt.com + api.openai.com only on /settings/ai/** routes (was blanket connect-src on every page including /auth/login → DOM-XSS exfil channel) - web-push: webPushSubscriptionSchema.endpoint requires HTTPS + isPublicUrl refinement; was z.url() only → blind SSRF probe via Push subscription - isPublicUrl: also fixes a regression where DNS labels starting with "fc"/"fd" (fcm.googleapis.com etc.) were falsely classified as IPv6 unique-local. Check is now gated on a colon being present in the host. - /api/ai/test: stop returning provider err.message + bodyExcerpt to the client (leaked provider URLs / partial keys / internal headers). Now returns categorised generic message; full details land in the wide-event via annotate() for the operator. - /lib/geo (auditLog IP geo lookup): switch default provider to ipwho.is over HTTPS, accept ipwho.is + ip-api pro response shapes, add IP_GEO_LOOKUP_DISABLED env, refuse non-HTTPS configured URLs. Previously leaked IP+timestamp over plaintext HTTP every auth event (GDPR Art. 32 + Art. 44). - /api/import: add 5/hour rate-limit (was unlimited; bulk-injection vector) Tests: 325/325 pass (was 313, +12 new — webPushSubscriptionSchema SSRF guards, fc/fd-prefix domain regression, geo HTTPS-only, geo provider shapes, geo opt-out env). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(security,gdpr): trusted-proxy IP, audit-log retention, idempotency cachable filter, Bearer mock tightening V3 audit follow-on bundle covering 4 HIGH findings. (I) getClientIp — TRUST_PROXY_HOPS env (default 1) replaces blind XFF trust. Reads chain right-to-left so client-supplied leftmost entries cannot rotate the per-IP rate-limit bucket. Default 1 matches typical single-proxy self-host (Coolify/Caddy/Cloudflare-Tunnel); 0 forces x-real-ip / null. New 7-test suite covers the rotation attack + malformed entries + zero-hop fallback. (F) Audit-log retention job — daily 03:15 cleanup of audit_logs older than AUDIT_LOG_RETENTION_DAYS (default 365). Closes GDPR Art. 5(1)(e) "storage limitation" gap; previously IPs + city + login events accumulated forever. 6-test suite covers default + override + misconfig guard (rejects <7 days). (G) isCachableStatus — extracted the inline 4xx/5xx filter into a named, exported, tested function. 7 cases cover the full do-not-cache contract (401 / 403 / 408 / 429 / any 5xx) and confirm 2xx + 4xx validation are still cached. (H) Bearer-mock tightening — apiToken.findUnique mocks in require-auth-bearer.test + idempotency.test now assert the where.tokenHash shape uses the hashed value (not the raw bearer). A regression that switched back to raw-token comparison would have been silent without this assertion. Tests: 345/345 pass (was 332, +13 new). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(security): encrypt moodLog webhook secret at rest with AES-256-GCM (V3 audit STILL-V2-C-2) The mood_log_webhook_secret column previously stored a plaintext secret that the webhook handler compared timing-safe against every enabled user. A DB dump or backup snapshot leaked active webhook credentials. New contract — no schema migration required: - Writes go through encryptMoodLogSecret() (AES-256-GCM via existing crypto helper, ENCRYPTION_KEY). - Reads go through readMoodLogSecret() which transparently decrypts envelopes and tolerates legacy plaintext rows during the transition (graceful upgrade path — no breaking change for self-hosted users). - Webhook lookup decrypts each candidate before timing-safe compare. - Status endpoint decrypts for the user's settings page. One-shot startup migration in reminder-worker rotates any leftover plaintext rows on next worker boot. Idempotent; encrypted rows are skipped. Tests: 350/350 pass (was 345, +5 new — round-trip, legacy passthrough, isLegacyPlaintext semantics, rotateLegacy idempotence). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: sync README + AGENTS + CLAUDE + CHANGELOG + OpenAPI for v1.3.3 (V3 audit doc-drift) V3 audit Doku Specialist findings — drift between code and meta-docs. - README §Key Features now mentions pulse oximetry (SpO₂) + Withings ScanWatch sync; Tech Stack model count corrected 23 → 25. - AGENTS.md status promoted v1.3.2 → v1.3.3; vitest config corrected `vitest.config.ts` → `vitest.config.mts`; migration range corrected `0001–0022` → `0001–0024`; model count + list updated to 25 (adds Device, IdempotencyKey). - CLAUDE.md model count updated (2 occurrences). - CHANGELOG.md v1.3.3 expanded with Security + Internal sections that were missing the audit-fix-marathon work (Bearer-scope, account-delete cascade, moodLog encryption, CSP tightening, Web-Push SSRF, IP-geo HTTPS, AI-test leak fix, import rate-limit, trusted-proxy XFF, audit-log retention, idempotency cachable filter, Bearer mock tightening, enum-drift cousins, PDF text-content tests). - OpenAPI Bearer description corrected `SHA-256 hashes` → `HMAC-SHA-256 hashes (keyed with API_TOKEN_HMAC_KEY)` so the docs match the actual server-side implementation. Wildcard scope contract documented. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(audit-review): close 4 HIGH + 2 MEDIUM findings from multi-agent review pass Multi-agent review of the audit-fix branch surfaced four HIGH issues and a couple of MEDIUM hardenings. All addressed before merge. HIGH-1 — OpenAPI MetricKind enum drift (iOS strict-decode crash risk). The dashboard-summary route now emits totalBodyWater/boneMass/ oxygenSaturation MetricCards, but the spec still listed only the original 7 kinds. Generated iOS Codable would have crashed the dash the moment a user logged SpO2. MetricKind extended with the 3 new kinds + comment documenting unknown-value defensive decoding. HIGH-2 — kindEnum naming consistency on /api/measurements/series. The series adapter shipped "oxygen" while the dashboard summary used "oxygenSaturation"; both routes now use "oxygenSaturation" so iOS decoders can share a single MetricKind type across endpoints. HIGH-3 — /api/ai/test no-leak guard had no test. Added 4 tests that mock provider errors with sk-leaked-key + provider URLs in the err fields and assert the response body never echoes them, plus the 3-status-categorised generic messages (401/429/5xx/other). HIGH-4 — /api/import 5/hour rate-limit had no test. Added 2 tests that mock checkRateLimit allowed/disallowed and assert the 429 path uses the right key shape (`import:<userId>`, 5, 1h) and that the within- quota path proceeds. MEDIUM-3 — getClientIp under-length XFF chain regression. With TRUST_PROXY_HOPS=2 and a chain of length 1, the previous clamp returned the leftmost (attacker-controlled) entry, silently re-introducing the rotation attack the helper was meant to close. Now: when chain is shorter than configured hops, refuse XFF entirely (fall back to x-real-ip / null). Added regression test. LOW-1 — TRUST_PROXY_HOPS=garbage now throws at boot instead of silently degrading to hops=0. Added regression test. Tests: 358/358 pass (was 350, +8 new). Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
a8a7c19d0b |
chore(idempotency): default userIdResolver to cookie session (#118)
Drop the `resolveUserIdForIdempotency` helper that was duplicated across three PWA write routes. `withIdempotency` now reads the userId from `getSession()` by default; routes that authenticate via Bearer token can still pass a custom resolver. Net: −15 LOC across the route layer, +2 test cases for the new default. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |