From a080cd9a0e40a8b0ebb5e0ef81a539353fa85389 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 8 May 2026 16:34:30 +0200 Subject: [PATCH] =?UTF-8?q?chore(release):=20v1.4.1=20=E2=80=94=20hardenin?= =?UTF-8?q?g=20+=20per-section=20admin=20+=20CI=20safety=20nets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CHANGELOG entry covers the three buckets of change between 1.4.0 and 1.4.1: security hardening from the audit pass (moodLog SSRF closed + centralised error redaction), citation-accuracy fixes (ESH 2023 label, Saint-Maurice attribution, steps source), and the CI safety nets (executable Postgres testcontainers + Playwright/axe-core foundation), plus the structural per-section admin extraction and the in-tree docs sync. The deferred items from the audit pass land in docs/ops/v141-followup-issues.md so they're discoverable for the v1.4.2 cycle without re-running the audit. package.json bumped 1.4.0 → 1.4.1. No DB migration. No env-var change required. No API contract change. Co-Authored-By: Marc-André Bombeck --- CHANGELOG.md | 112 ++++++++++++- docs/ops/v141-followup-issues.md | 260 +++++++++++++++++++++++++++++++ package.json | 2 +- 3 files changed, 370 insertions(+), 4 deletions(-) create mode 100644 docs/ops/v141-followup-issues.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 31586c6..ba3a38c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,111 @@ # Changelog +## [1.4.1] — 2026-05-08 + +### Security + +- **moodLog integration no longer accepts internal-network URLs.** A + user could previously save `http://169.254.169.254/` (cloud-metadata) + or any RFC1918 address as their moodLog instance; the daily sync + worker would then fetch from that target with the user's API key in + the Authorization header. The credentials write path now refuses + non-public hosts, the sync worker re-checks the URL at the actual + fetch site (so legacy rows stored before the guard are also + refused), and the fetch is now `redirect: "manual"` so a public + host cannot 302 to an internal target with the bearer on the + redirect hop. +- **Error reports never echo bearer tokens, Telegram bot tokens, or + query-string secrets.** `WideEventBuilder.setError()` and the + Glitchtip incident path now run every error message and stack + trace through a central `redactSecrets()` filter that scrubs + `Bearer …`, Telegram `bot:` URLs, and `?secret=`, + `?code=`, `?token=`, `?api_key=` query strings. The substitution + is generic `[REDACTED]` so partial entropy is never revealed. + +### Fixed — Citation accuracy + +- **Blood-pressure classification now cites ESH 2023.** The dashboard + tile, the doctor-report PDF, and the inline analytics comments + used to label the band as "ESC/ESH 2018". The numbers haven't + changed (the 2023 ESH update kept the 2018 thresholds), but the + joint authoring did — ESC withdrew from the 2023 document, so the + correct citation is "ESH 2023" alone. +- **Steps target source label is `Saint-Maurice JAMA 2020`** instead + of `WHO`. Every other surface in the app (AI prompts, inline + comments, drift tests) already enforced this attribution; the + insights/targets surface was the last "WHO" label in the tree. + WHO publishes physical-activity _time_, not a step quota. +- **Saint-Maurice "mortality plateau 8000–12000" attribution + softened.** The original JAMA 2020 paper reports continued + dose-response benefit (HR 0.49 at 8k, HR 0.35 at 12k) — not a + plateau. The plateau-shaped finding belongs to Paluch 2022 + _Lancet Public Health_ (PMID 35247352), not Saint-Maurice. The + inline comments and AI prompts now say "continued dose-response + benefit through ~12,000 steps/day" instead. + +### Added — CI safety nets + +- **Postgres-backed integration test suite is now executable.** The + testcontainers infrastructure shipped in 1.4.0; this release wires + the per-test boilerplate through vitest's `globalSetup` so all + four files share one container. `pnpm test:integration` runs ten + tests (rate-limit race, idempotency replay-attack contract, GDPR + Article-17 cascade delete, session create / read / expire) against + a real Postgres in under four seconds. CI runs the suite on every + PR. +- **Playwright + axe-core E2E foundation.** A new `pnpm e2e` runs + five public-surface specs (version endpoint, proxy auth-redirect, + login form autofill hints, DE/EN locale switch, axe-core + accessibility gate) against the production build in CI. Authenticated + flow specs (quick-entry, doctor-report, settings round-trip, + test-buttons, onboarding) ride a follow-up release because they + need a seeded test user; the foundation makes adding them a + one-PR step. + +### Changed — Admin internals + +- **Admin page is now per-section components.** The status-card grid + shipped in 1.4.0 sat on top of a 2,700-line monolith; that monolith + is now 14 focused files in `src/components/admin/` with a 77-line + `src/app/admin/page.tsx` shell that mounts them. Every section + keeps the same DOM, ids, query keys, and i18n keys — no + user-visible change. + +### Fixed + +- **Final ESLint error is gone.** The medications page's "API + endpoint" dialog ran its initial-load fetch through a `useCallback` + paired with `useEffect` and triggered the strict + `react-hooks/set-state-in-effect` rule. Refactored to TanStack + Query — same network calls, no effect, lint count is now zero on + `main`. + +### Documentation + +- **Repo-internal docs synced for v1.4.** README adds the + Multi-tenant ready and Test connection buttons feature blocks, the + API reference table includes the eleven new v1.4 endpoints, and + the model count is corrected to 26 (RefreshToken). AGENTS.md and + CLAUDE.md reflect the per-route `/settings/[section]` layout and + the per-section admin layout. `docs/api/openapi.yaml` documents + the new endpoints (version, refresh, refresh/revoke, + status-overview, backup/test, the five test-connection probes). + `docs/migration/v1.3-to-v1.4.md` corrects the now-wrong "no + migrations" claim and adds full env-var sections for the + worker/web split, encryption-key versioning, and off-host backup + target. + +### Notes + +- No database migration in 1.4.1. +- No environment-variable change required to upgrade. +- No API contract change — every route added in 1.4.0 is still + there; no shapes or status codes flipped. +- The audit pass that drove this release identified five medium + security items and three P0 performance items that warrant + deeper architectural work; those are tracked in + `docs/ops/v141-followup-issues.md` and ride a future release. + ## [1.4.0] — 2026-05-08 ### Added — Foundation, safer ranges, and a faster dashboard @@ -41,7 +147,7 @@ now cites "ESH 2023" with the published source URL. Numbers unchanged. - **"WHO ≥ 8 000 steps/day" hallucination fully removed.** WHO - publishes activity *time* (150–300 min/wk moderate), not a step + publishes activity _time_ (150–300 min/wk moderate), not a step quota. The v1.3.3 fix only landed in `effective-range.ts`; four AI prompt strings and the `getStepsRange()` helper carried the old wording forward. Saint-Maurice et al., JAMA 2020 (mortality plateau @@ -50,7 +156,7 @@ to AASM 2015. - **Body-fat ACE bands corrected and three-way drift resolved.** The classifier used `essential = 6 (M) / 14 (F)` as the floor — but - that's actually ACE's *Athletes* lower bound. Readings below were + that's actually ACE's _Athletes_ lower bound. Readings below were mislabelled "Essential" instead of "Below essential" (a danger band). Six-band classifier now mirrors the ACE table, and the three sites that had three different green-band numbers @@ -221,7 +327,7 @@ via `prisma db push` to dev/prod). Any environment built strictly from `prisma/migrations/` (CI testcontainers, brand-new self-host installs) is now consistent. Migration is `ADD COLUMN IF NOT - EXISTS`, so it's a clean add on a fresh database and a safe no-op +EXISTS`, so it's a clean add on a fresh database and a safe no-op against any environment that was already kept in sync. ### Notes diff --git a/docs/ops/v141-followup-issues.md b/docs/ops/v141-followup-issues.md new file mode 100644 index 0000000..ae3f570 --- /dev/null +++ b/docs/ops/v141-followup-issues.md @@ -0,0 +1,260 @@ +# v1.4.x follow-up issues + +Items identified by the v1.4.1 audit pass that warrant deeper work +than a hardening release should land. Each one carries enough context +that a future contributor can pick it up without re-running the audit. + +## Security — deferred + +### S-FOLLOW-1 — Idempotency-Key concurrent duplicate side-effects (HIGH) + +Audit reference: `~/infra/reports/v14-security-audit.md` H1. + +The `withIdempotency()` wrapper currently does +`findCached → handler → persistCached`. Two requests with the same +`Idempotency-Key` arriving in parallel both miss the SELECT, both +execute the handler (duplicate measurements / intake events), and +one INSERT loses on the unique constraint silently. The CLAUDE.md +contract ("retries with the same Idempotency-Key … replay the +original response — no second side-effect") is therefore violated +whenever a flaky mobile client double-sends. + +**Fix shape**: acquire the slot before the handler runs. +`INSERT … ON CONFLICT DO NOTHING RETURNING id` reserving the +`(userId, key, method, path)` row with a sentinel +`responseStatus = 0`, then UPDATE on completion; concurrent losers +SELECT-loop with backoff for the row to be filled. Or wrap the +whole request in a Postgres advisory lock keyed by hash(userId, key) +via `pg_advisory_xact_lock`. + +Why deferred: needs a careful integration test against the new +testcontainers suite (two concurrent inserts → exactly one handler +invocation) and a public-API contract check that the +sentinel-status row never bleeds into a 2xx replay. Fits a v1.4.2 +PR scope on its own. + +--- + +### S-FOLLOW-2 — Encryption key fallback NODE_ENV gate (MEDIUM) + +Audit reference: `~/infra/reports/v14-security-audit.md` M1. + +`src/lib/crypto.ts:61-74` only requires the 64-hex production key +when `process.env.NODE_ENV === "production"`. A fresh Docker run +that forgets to set `NODE_ENV=production` (raw `node …` invocations, +some operator setups) silently accepts a 32-character key and pads +it deterministically with a SHA-256 of itself, halving the entropy +and making the resulting key recoverable from any leaked partial. + +**Fix shape**: invert the gate to `NODE_ENV !== "development" && +NODE_ENV !== "test"` — fail-closed by default — or require an +explicit `ALLOW_WEAK_DEV_KEYS=1` opt-in. + +Why deferred: needs a migration note for self-hosters whose `.env` +files lack `NODE_ENV` (default Coolify deploys do set it; bespoke +setups may not). Worth a release note + .env.example call-out. + +--- + +### S-FOLLOW-3 — Refresh-token reuse-detection serialisation (MEDIUM) + +Audit reference: `~/infra/reports/v14-security-audit.md` M2. + +`src/lib/auth/refresh-token.ts` reuse-detection is a `findUnique` +followed by a non-transactional `findMany + updateMany`. Concurrent +first-time refreshes can briefly have both clients holding +unrevoked access tokens before the loser's are revoked, and a +parallel reuse attempt doubles the audit-log noise. + +**Fix shape**: wrap the rotation in a single `prisma.$transaction` +with `Serializable` isolation, OR reorder to "claim first, mint +second": `prisma.refreshToken.update({ where: { id, usedAt: null } })` +fails atomically if the row was already consumed; only after the +update succeeds, call `issueAccessAndRefresh`. + +Why deferred: needs an integration test that fires N parallel +refreshes for the same token and asserts exactly one new pair is +issued. The testcontainers suite is the right home; v1.4.2 PR. + +--- + +### S-FOLLOW-4 — moodLog webhook HMAC lookup column (MEDIUM) + +Audit reference: `~/infra/reports/v14-security-audit.md` M3. + +`src/app/api/integrations/moodlog/webhook/route.ts` performs an +O(n) AES-GCM decrypt sweep across all enabled users for every +webhook hit. Side-channels: timing oracle for ordering, DoS +amplification (30/min IP rate-limit × n decrypts). + +**Fix shape**: add a `moodLogWebhookSecretLookupHash` column on +`User` (HMAC-SHA-256 keyed by `API_TOKEN_HMAC_KEY`, populated +alongside the encrypted secret on every write). Webhook does a +single indexed `findUnique` on the lookup hash, bypassing the +candidate iteration. The encryption stays as defence-in-depth. + +Why deferred: schema migration required. Backfill needs a one-off +script that decrypts every existing row and writes the lookup +hash. Owners need to approve the migration window. v1.4.2 or +v1.5. + +--- + +### S-FOLLOW-5 — moodLog `readMoodLogSecret` legacy fallback (MEDIUM) + +Audit reference: `~/infra/reports/v14-security-audit.md` M4. + +If `decrypt()` throws inside `src/lib/moodlog-secret.ts:27-37`, the +function returns the stored value as-is. Intent: legacy plaintext +rows keep working. Side effect: an attacker with read access to +the DB obtains the ciphertext blob and can submit it as the literal +webhook secret. + +**Fix shape**: detect "looks like a v1.4 envelope" +(`/^[A-Za-z0-9_-]{1,32}\..+$/`) and refuse the legacy fallback for +envelope-shaped values. Log a metric so the rotation-on-write +contract can be verified to drain. + +Why deferred: needs a small data-audit run to confirm no production +row has a non-envelope value before the strict check goes live. + +--- + +### S-FOLLOW-6 — Restore script writes decrypted JSON with default permissions (MEDIUM) + +Audit reference: `~/infra/reports/v14-security-audit.md` M5. + +`scripts/restore-backup.ts:73-122` writes the decrypted JSON via +`writeFileSync(out, plaintext, "utf8")` with default mode (0644 +typical). The file contains decrypted PHI for the restored user. + +**Fix shape**: +`writeFileSync(out, plaintext, { encoding: "utf8", mode: 0o600 })`. +Optional: warn if `out` is in a world-readable directory. + +Why deferred: tiny one-line fix; bundling with the next ops-script +update keeps the PR coherent. + +--- + +## Performance — deferred + +### P-FOLLOW-1 — Recharts top-level import on `/insights` (P0) + +Audit reference: `~/infra/reports/v14-performance-audit.md` P0. + +`src/app/insights/page.tsx:42-50` static-imports +`{ ScatterChart, Scatter, XAxis, … } from "recharts"` even though +`HealthChart` and `MoodChart` 8 lines above are already wrapped in +`dynamic(…, { ssr: false })`. The static import pulls the whole +recharts bundle into the route's first JS payload, defeating the +dynamic split. + +**Fix shape**: extract the BP-mood scatter into +`src/components/charts/bp-mood-scatter.tsx` and import it via +`dynamic(…, { ssr: false })` like the others. + +Why deferred: trivial mechanical change but needs a Lighthouse +before/after to quantify the bundle-size win for the v1.4.2 +release notes. + +--- + +### P-FOLLOW-2 — `/api/insights/targets` unbounded glucose history scan (P0) + +Audit reference: `~/infra/reports/v14-performance-audit.md` P0. + +`src/app/api/insights/targets/route.ts:600-604` pulls every glucose +measurement the user ever logged (no `gte: thirtyDaysAgo`) and +filters in JS. Diabetic users with multi-year history will +materialise tens of thousands of rows on every targets fetch. + +**Fix shape**: split into "latest per context" +(`distinct: ["glucoseContext"], orderBy: { measuredAt: "desc" }`) +and a `measuredAt: { gte: thirtyDaysAgo }` window query, mirroring +the pattern at lines 102-121 for `latestEverByType`. + +Why deferred: needs a per-context test asserting the new query +returns the same payload shape. Pairs nicely with the MoodEntry +index migration (P-FOLLOW-3). + +--- + +### P-FOLLOW-3 — `MoodEntry @@index` is on `(userId, date)` not `(userId, moodLoggedAt)` (P0) + +Audit reference: `~/infra/reports/v14-performance-audit.md` P0. + +`prisma/schema.prisma:438` declares `@@index([userId, date])`, but +active queries sort/filter by `moodLoggedAt`: + +- `src/app/api/insights/targets/route.ts:495` +- `src/app/api/insights/comprehensive/route.ts:52` +- `src/app/api/export/route.ts:87` + +The unique constraint `@@unique([userId, date, moodLoggedAt])` is +not selectable by Postgres for windowed `moodLoggedAt`-range reads. + +**Fix shape**: add `@@index([userId, moodLoggedAt])` (do NOT drop +the existing index — the unique still serves the moodlog webhook +upsert path). New migration `0026_moodentry_moodloggedat_index`. + +Why deferred: schema migration. Owner approval for the migration +window. + +--- + +### P-FOLLOW-4 to -7 — P1 / P2 perf items + +- N+1 count() per medication on admin reminder-check + (`src/app/api/admin/notifications/reminder-check/route.ts:84`). + Replace with `groupBy` on `medicationIntakeEvent`. +- `/api/analytics` 8-parallel full-history scan + (`src/app/api/analytics/route.ts:20-40`). Single `findMany` with + `type: { in: types }` and JS-side group. +- `/api/import` sequential `prisma.measurement.create` per row + (`src/app/api/import/route.ts:77-96`). Replace with + `createMany({ skipDuplicates: true })`. +- `pairByTimestamp` not used in `/api/analytics` BP correlation + (`src/app/api/analytics/route.ts:80-86`). Replace the quadratic + loop with the helper from `@/lib/analytics/correlations`. + +--- + +## Test-quality polish (5 minor items) + +Audit reference: `~/infra/reports/v14-test-theatre-audit.md`. + +- `src/app/api/gamification/__tests__/ios-format.test.ts` L111-112: + upgrade `toBeDefined()` to shape predicates. +- `src/lib/analytics/__tests__/classifications.test.ts` L169-170: + add `toMatch(/60/)` body-content assertion to compliance-alert tests. +- `src/lib/__tests__/idempotency.test.ts` L145: upgrade bare + `toHaveBeenCalled()` to `toHaveBeenCalledWith({ where: …key… })`. +- `src/app/api/telegram/webhook/__tests__/route.test.ts` L457: same + shape — upgrade or drop. + +--- + +## i18n hardcoded strings + +Audit reference: `~/infra/reports/v14-i18n-drift-audit.md`. + +35+ hardcoded strings in `src/components/settings/ai-section.tsx` +need to be moved into `messages/{en,de}.json` keys. The settings +section was extracted in #143 with strings inline as a known +follow-up; this is the cleanup PR. + +Why deferred: low blast-radius (only the AI provider settings page +shows English to a German user; the dashboard, dialogs, and +critical UX paths all flow through `t(…)`). v1.4.x or v1.5. + +--- + +## Notes + +These are the items the v1.4.1 audit identified that the hardening +release deliberately did NOT take. The audit also found 0 hallucinated +medical claims, 0 i18n key-parity gaps, and the test theatre rate +across the entire codebase was low (5 minor patterns). The codebase +is in healthy shape; this list is the punch-list for the next +proactive iteration, not a backlog of bugs we know about and ignore. diff --git a/package.json b/package.json index db790cf..c71b17f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.4.0", + "version": "1.4.1", "private": true, "packageManager": "pnpm@10.31.0", "scripts": {