Files
HealthLog/docs/ops/v141-followup-issues.md
T
Marc-André BombeckandClaude Opus 4.7 a1cf9bcb37 docs: scrub PII + internal jargon + German leaks from user-facing artifacts
Retroactive cleanup of CHANGELOG.md and docs/audit/v1.4.X-summary.md
files. Removes maintainer's real name, specific health figures tied to
the live tenant, internal release-process jargon, and stray German
strings in English-only release notes. Pre-release PII-grep gate added
to the v1.4.20 release pipeline. No git history rewrite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:14:28 +02:00

261 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 repo-doc
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.