mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
acd2f29da2a08fb86b7a69d0eb99ca29dc51f2a0
15
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
4d3a02cc1a |
feat(security): add second-factor schema and encrypt free-text health notes at rest
Lay the account-security and data-sovereignty substrate for v1.23. No user-facing behaviour yet — schema, crypto registry, redaction, and the note-encryption data path only. Schema + migrations: - 0199 — User totp_secret_encrypted / totp_confirmed_at / mfa_enforced / passkey_upgrade_nudge_dismissed; Session mfa_verified_at / last_active_at; Passkey last_used_at. All additive, nullable / defaulted. - 0200 — mfa_challenges (single-use step-up ticket; only the ticket hash is stored, so no session or token is ever minted before the second factor), mfa_recovery_codes (Argon2id at rest), webauthn_mfa_credentials (second-factor keys, kept separate from passkeys, which remain the passwordless-primary set). - 0201 — mood_entries.note_encrypted + measurements.notes_encrypted (BYTEA), matching the existing free-text encrypted-note convention. Free-text health notes: - New writes encrypt the mood / measurement note into the *_encrypted column and null the legacy plaintext column; reads prefer the ciphertext and fall back to plaintext only for not-yet-migrated rows, via a single read/write helper. - A boot-time job migrates existing plaintext rows in an idempotent, transactional, fail-closed pass (encrypt, then null the plaintext, per row in one transaction; safe to re-run; a row with content is never left both-null). - The legacy plaintext columns are kept this release and dropped in a follow-up once the backfill reports zero remaining rows — the same boundary the key-rotation playbook uses before dropping a legacy key. Crypto coverage: - Register the three new encrypted columns and wire them into the key-rotation script so a rotation never orphans them. - Extend the observability and log redaction denylists so TOTP secrets, MFA / recovery / backup codes, and export passphrases never reach a wide event. |
||
|
|
1f4ba5939d |
style: apply prettier across the tree
One-shot prettier pass so the new format:check gate starts from a clean baseline. Formatting only — no behaviour change. Generator-owned and vendored artifacts stay untouched via .prettierignore. |
||
|
|
fbdcb31202 |
feat(import): add CSV measurement import with per-row status and dry-run
A self-hoster migrating from a spreadsheet or a meter export overwhelmingly has CSV, which neither the JSON nor the Apple Health import covers. Add `POST /api/import/csv` reusing the existing write loop and rollup re-fold. The documented header is `type,value,unit,measuredAt[,glucoseContext,notes, externalId]`, order-independent. measuredAt must carry an explicit ISO-8601 offset — a row without one is skipped rather than guessed — and is bounded by the shared entry-instant validator. Glucose accepts mmol/L (converted to canonical mg/dL) and weight accepts lb (converted to kg); any other non-canonical unit is skipped, never mis-stored. An optional externalId column makes a re-upload idempotent; without it a re-upload duplicates. The response carries a per-row status envelope so a 2000-row file with three bad rows imports 1997 and names the three. `?dryRun=1` validates and previews the outcome without writing. The parse, validation, and unit conversion live in a pure module so they are unit-tested without a database. Settings -> Export & Import gains a third CSV card mirroring the JSON and Apple Health cards, with upload, paste, preview, and a downloadable example. The new route is documented in the OpenAPI contract. |
||
|
|
82e2d7dfb1 |
fix(import): bound JSON measurement timestamps and dedup on externalId
The generic JSON import validated measurement `measuredAt` with a bare `z.string().datetime()` — no ceiling — so a crafted payload could forward-date a reading, the gap the single-POST plausibility bound already closed. Route the measurement and mood instant fields through the shared `validateEntryInstant` (no future beyond a 5-minute skew, no instant before 1900). Measurement rows were also always created with a NULL externalId, which is distinct in the `(userId, type, source, externalId)` unique key, so a re-import duplicated the whole set. Give measurements the same optional externalId upsert mood already has: present -> idempotent upsert, absent -> first-write-wins create. |
||
|
|
3b480b9885 |
perf(i18n,shell): ship one locale and stop blocking the first paint
The client bundle carries only the active locale — resolved on the server and handed to the provider with no hydration flash, halving the dashboard route to under a megabyte parsed (288 KiB gzip). The auth shell renders the app chrome immediately instead of a spinner while the session resolves in parallel, the widgets request folds into the snapshot it duplicated, and insight pills prefetch their sub-pages — a first visit to a sub-page lands in about ninety milliseconds instead of nearly a second. |
||
|
|
65677c2112 |
fix(api): bound every JSON body read
Raising the middleware body cap to 512 MB for archive imports removed the implicit ceiling that protected JSON routes, so any bulk endpoint would buffer an arbitrarily large body before validation ran. Every JSON body read under /api now carries an explicit byte cap sized to its legitimate payload (64 KB for single-record routes up to 16 MB for the measurement import), webhooks cap before signature work, and oversized bodies return 413. |
||
|
|
9c4e4de927 |
fix(db): live-row partial unique for intake re-take, mood external-id dedup key, perf indexes
Migration 0121 replaces the MedicationIntakeEvent unique constraint with a partial unique index `WHERE deleted_at IS NULL`. A slot the user previously deleted (tombstoned) no longer occupies the unique slot, so a re-take re-creates the row cleanly instead of P2002-ing against the tombstone and 500-ing. The migration dedups any pre-existing duplicate live rows first (keep newest, tombstone the rest) so the unique build can't fail on real data. Measurement and Workout deliberately stay full uniques: Measurement's compound-key writes use prisma.upsert, which Prisma 7 compiles to native INSERT ... ON CONFLICT and Postgres cannot arbiter against a partial unique (and consolidate-daily-mean intentionally resurrects a tombstoned canonical row, which needs the full unique); Workout has no deleted_at column. The rationale is documented in the migration header and the schema comments. Migration 0122 adds MoodEntry.externalId plus a NULL-distinct `@@unique([userId, source, externalId])`. The moodLog webhook, the pull sync, and the JSON import now carry the source's stable id into externalId and upsert on the new key when present, so a re-import is idempotent even when moodLoggedAt re-rounds or re-zones. Native/MANUAL entries keep their NULL externalId and stay on the legacy (userId, date, moodLoggedAt) path. Migration 0123 adds AuditLog(userId, action, createdAt desc) and MoodEntry(userId, moodLoggedAt) composite indexes for the status-cache and mood-insights read paths. Also corrects the stale "v1.6.0 drops this column" note on MedicationSchedule.daysOfWeek (the column is still dual-written and read by live paths, so it is retained) and documents the UTC-only timezone storage invariant in a schema header comment. Integration tests cover the intake re-take after delete (no resurrection / no 500) and mood re-import idempotency (with and without an external id) on real Postgres. (cherry picked from commit 9a6723f469bdec063a99cd08df132e07ec3c95a5) |
||
|
|
1114cf7e63 |
chore(release): v1.4.40 — comprehensive Critical+High architecture closure plus iOS PB30 enablement
Eleven touch-disjoint waves landed on develop plus a six-axis QA reconcile round before tagging. See CHANGELOG.md for the user-facing prose; this commit message documents the wave + report inventory for release-management traceability. Waves shipped (each with a phase report under .planning/): - phase-W-POOL-v1440-report.md — p-limit(4) wrapper around the analytics 15-way fan-out + pg.Pool max raised 10 -> 20. Closes the v1.4.39 empirical-trace finding #1 (Prisma pool starvation). - phase-W-DELETED-v1440-report.md — 11 reader-tier helpers + 3 integration contracts close audit Critical Finding #3 (soft-delete invisibility) across dashboard, analytics, summaries, rollup tiers, and the Coach snapshot path. - phase-W-INSIGHTS-v1440-report.md — mood-rollup swap on the three /api/insights/{features,targets,cards} routes plus the compliance-rollup hook gap closure for bulk-projection mints; the post-QA reconcile patch closes the six remaining measurement.findMany sites in the cluster that still leaked tombstoned rows. - phase-W-INFRA-v1440-report.md — rollup helper umbrella consolidation under src/lib/rollups/* with zero orphan re-exports; lint, typecheck, knip, and 93/93 rollup tests green post-restructure. - phase-W-RSC-v1440-report.md — per-tile Suspense boundaries plus the queryKey factory-bypass walker test (extended to comparison/ in the reconcile pass). - phase-W-PRIVACY-v1440-report.md — bilingual /privacy page with 17 paired-section tests pinning the 9 SB-3 disclosure requirements. - phase-W-AASA-v1440-report.md — /.well-known/apple-app-site-association endpoint pinned to S8WDX4W5KX.dev.healthlog.app with app-ID parity assertions guarding against split rotation. - phase-W-APNS-NOTIFY-v1440-report.md — MEDICATION_REMINDER time-sensitive APNs payload (priority 10 + interruption-level) scoped via a parameterised test that pins all 6 other event-types do not bypass Focus. AP-2 .p8 install gates real delivery. - phase-W-CONSENT-v1440-report.md — AI consent receipts CRUD with append-only invariant pinned in test; revoke-then-regrant leaves both rows; idempotent DELETE returns 200 + receipt: null. - phase-W-WMY-WIRE-v1440-report.md — three previously-dead WMY readers (week/month/year) wired through summaries-slice and health-score-fast-path with a linear-composability parity test pinning the cross-consumer routing contract. - phase-W-GHOSTS-v1440-report.md — ~1 177 lines of dead code removed across 9 atomic commits; startOfUtcDay + wallClockInTz consolidation eliminates a sibling-drift risk; knip whitelist trimmed to three files pending follow-up. QA reconcile patches applied per the v1.4.40 QA round (six reports under .planning/round-v1440-QA-*-findings.md): - senior C1 — dashboard-suspense-boundaries regex aligned with the post-W-INFRA Thread 2 userTimezone-local lift. - senior H1 — soft-delete filter added to the six remaining insights measurement.findMany sites. - specialist H1 — Promise.allSettled around bulk-projection compliance recompute on both /api/medications/intake and /api/dashboard/summary. - ux H1 — compare-toggle migrated to queryKeys.dashboardWidgets factory; walker test extended to src/components/comparison/. - security M1 — consent artefact 64 KB cap enforced via Buffer.byteLength (UTF-8 bytes, not UTF-16 code units). Quality gates green at tag: pnpm typecheck, pnpm lint, pnpm knip --include files,dependencies,binaries,unlisted, pnpm test --run (4726 passing / 1 skipped). Tests 4525 -> 4726 unit + 1 skipped over the v1.4.39 -> v1.4.40 span. User-facing prose: see CHANGELOG.md. |
||
|
|
4122c79d66 |
chore(release): v1.4.39.1 — rollup tier catches Withings + import + admin-restore write paths
Hotfix on top of v1.4.39. Three measurement write paths (Withings sync, `/api/import`, admin backup restore) were bypassing the persistent rollup tier's write hook, so the dashboard chart's `source=rollup` fast-path painted "Noch nicht genug Daten" on the 30 / 90-point range for accounts whose recent data only came through those paths. The 7-day window kept rendering because the chart's raw fetch never consults the rollup tier. Closes the read-coverage gap by wiring `recomputeBucketsForMeasurement` into every remaining ingest path and re-anchors the boot-time backfill discovery to per-day coverage so already-deployed instances re-converge on the next worker boot. The legacy `sum_value IS NULL` branch stays on the same UNION so the v1.4.39 column-backfill continues working. Also re-anchors one date-bound test in the medications-intake route suite so the green CI run no longer depends on the wall clock. Tests 4647 → 4648 passing; one long-standing skip unchanged. |
||
|
|
d84922cdc3 |
chore(release): v1.4.39 — mood, medication-compliance, and cumulative-sum rollup tiers
Squashed merge of the v1.4.39 wave work from develop. The release extends the v1.4.38 fast-path posture to two more endpoints that still walked the source table on every cold mount and folds a cumulative `sum_value` column into the existing rollup tier so step / flight / distance / daylight / active-energy sparklines no longer re-derive their daily totals in Node. Raw measurements / mood_entries / medication_intake_events tables are byte-unchanged. Three new tiers (mood_entry_rollups, medication_compliance_rollups, measurement_rollups.sum_value) are derived caches that self-heal via boot-time backfill on first reach. Migrations 0070, 0071, 0072 are additive only. The sum_value column add is catalog-only DDL on PostgreSQL 11+ — no table rewrite, writes resume in milliseconds. No environment-variable change. No API contract break. No iOS contract change. Expected wins (live perf-verify rides the post-deploy window): - /api/mood/analytics cold 12.7 s -> ~200 ms - /api/medications/intake?scope=compliance cold 3.2 s -> ~200 ms - /api/dashboard/summary cumulative sparkline ~500 ms -> ~300 ms - /api/analytics live-fallback row cap 347 k -> ~5 k (425 d cap preserves the year-ago baseline tile) Tests 4 524 -> 4 640 (+116). Typecheck clean. Lint clean. See CHANGELOG.md for the full v1.4.39 entry. |
||
|
|
affede31a4 |
v1.4.34 — apple-health import + reliability + web-freeze
The final functional web release before the iOS native client lands.
Headline work: a streaming Apple Health `export.zip` importer for
prospective iOS-migrating accounts, a server-side aggregation cache
that collapses the three hottest dashboard reads onto in-process LRU
slots with single-flight coalescing and per-user invalidation, a
broader compliance classifier with a dedicated `early` bucket so
ahead-of-window doses count as compliant, the Coach launch surface
hoisted onto every authed page with a dashboard-hero CTA, and a
trimmer Settings shelf that folds Sources into Targets. The release
also lights up the `cache.<name>.outcome` annotation on the active
wide event so production logs carry the cache hit-ratio signal
without leaking userIds.
### Added
- **Server-side aggregation cache.** A new `ServerCache<T>` primitive
(`src/lib/cache/server-cache.ts`) extends the v1.4.33 Coach snapshot
LRU shape into a reusable per-route layer with TTL expiry,
capacity-bounded LRU eviction, single-flight read coalescing, and
per-instance hit / miss / eviction / stampede counters. Wires the
three hottest dashboard reads — `/api/analytics` (slim + default),
`/api/gamification/achievements`, `/api/medications/intake?scope=compliance`
— and bolts per-user invalidation onto every measurement / mood /
medication / workout / dashboard-widget write endpoint so the next
read paints fresh data instead of waiting out the TTL. Every cache
hit / miss surfaces on the active wide event as
`cache.<name>.outcome` + `cache.<name>.key_hash` so production logs
carry the hit-ratio signal without leaking userIds.
- **Apple Health `export.zip` import.** New endpoint
`POST /api/import/apple-health-export` (synchronous multipart
upload, asynchronous ingest via a dedicated pg-boss
`apple-health-import` worker, per-`MeasurementType` ingestion
stats). Streams the upload straight to disk so a multi-gigabyte
export never lands in V8 heap, hashes the bytes inline for
content-based idempotency, and unpacks `apple_health_export/export.xml`
with a hand-rolled ZIP central-directory walker that handles Zip64
for archives past the 4 GB barrier. The parser folds every
`<Record>`, `<Workout>`, `<Correlation>`, and `<ClinicalRecord>`
into the existing `Measurement` and `Workout` row shapes — spot
rows keyed by `HKMetadataKeyExternalUUID` (or a deterministic
`sample:<sha256>` fallback), cumulative HK types collapsed into
one `stats:<HKType>:<YYYY-MM-DD>` row per user-local day to match
the iOS daily-aggregation convention. Live per-stage progress
surfaces through `GET /api/import/apple-health-export/{jobId}/status`.
An admin variant at `POST /api/admin/import-apple-health-export`
imports on behalf of a target user (cookie-only `requireAdmin()`
gate; Bearer tokens never elevate).
- **`ImportJob` schema model + migration.** New Prisma model captures
the per-upload state machine (`queued | unpacking | parsing |
upserting | done | failed`), the content-hash for idempotency
short-circuits, and the per-`MeasurementType` ingestion counters
the status route surfaces back to the client.
- **`lastSeenByType` on `/api/analytics`.** Both the slim
(`?slice=summaries`) and default slices return a per-type
`{ lastSeenAt, daysAgo }` map. The dashboard trend tiles wire 12
mounts to a new `tileStaleDays()` helper so an "X days / weeks /
months ago" hint paints under any per-metric tile whose last
sample crosses the 7-day floor. Six locales gained additive
week / month plural keys; Polish uses the genitive-plural form
that covers every non-1 count the bucket math can produce.
- **Dashboard "Ask the coach" hero CTA.** A new pill next to the
existing "Hinzufügen" / "Add" launches the Coach drawer directly
from the dashboard hero. The `<CoachLaunchProvider>` hoisted from
the insights layout to the auth shell so the drawer is reachable
from every authed route; the floating action button stays scoped
to `/insights/**` so it cannot distract on the dashboard.
- **Shared achievements query hook.** `useAchievementsQuery()`
centralises the three previous consumers — recent-achievements
card, the `/achievements` mother page, and the unlock notifier —
onto one TanStack queryKey + cache slot so dashboard cold mount
fires the endpoint once instead of twice.
- **Typed authed Cache-Control presets.** New
`src/lib/http/cache-headers.ts` module exports
`NO_STORE_BUT_BFCACHE = "private, max-age=0, must-revalidate"`,
`SHORT_LIVED_PUBLIC`, and an `applyAuthedHeaders()` helper. The
presets land at the framework level via a `next.config.ts`
`headers()` rule that stamps the bfcache-friendly directive on
every authed HTML response, restoring Chromium bfcache eligibility
for in-app back / forward navigation.
- **`scripts/print-bundle-report.mjs`.** Restores the at-a-glance
"top client chunks by parsed size" signal Turbopack dropped from
`next build`; reads `.next/analyze/client.json` (written by
`@next/bundle-analyzer` when `pnpm analyze` runs with `ANALYZE=1`)
and prints a sorted table plus totals.
### Changed
- **Settings sidebar: "Sources" folded into "Targets & Sources".**
Per-metric threshold ranges and per-metric source priority now
share one `/settings/thresholds` page so the same metric's
threshold and device-source preference sit together instead of
one sidebar entry away. `/settings/sources` keeps a
`permanentRedirect` so external bookmarks and docs links follow
through unchanged. Section count: 11 → 10.
- **Insights tab strip: vital pills collapse under a "Vitals"
parent.** Five vital pills (HRV, Resting HR, Oxygen, Body
Temperature, Active Energy) hide behind one parent pill that
opens a popover sub-list. Parent-pill active state mirrors the
URL so spatial orientation survives the collapse; each sub-page
keeps its own URL and bookmark resolves unchanged. Strip
footprint: 14 → 10 entries when every metric has data.
- **Coolify env-var audit.** `mcp__coolify-apps01__env_vars`
inspection captured the section-1 / section-2 duplicates that
have accumulated under apps-01 since v1.3.1. Audit pinned at
`.planning/round-v1434-iwa-coolify-env-audit.md`; no env-var
deletes performed (operator action).
### Fixed
- **Compliance classifier: early intakes count as compliant.** The
classifier picked up a dedicated `early` bucket (window-start
minus three hours through window-start), the `on_time` post-window
tolerance widened from one hour to three hours, and the `late`
band sits between the new `on_time` ceiling and the configurable
`lateMinutes` knob. Heatmap consumers route the new bucket
through the compliant path; the dedicated `early` counter rides
on `DailyComplianceEntry` for downstream consumers that want to
distinguish ahead-of-window from on-window intakes.
- **Compliance heatmap fallback retired.** The v1.4.33 defensive
`looksClassifierBug` fallthrough is gone now that the underlying
classifier widening removes the every-dose-very-late mode the
fallback covered.
### Performance
- **`/api/gamification/achievements` consumer collapse.** Two of
three previous consumers shared the same TanStack queryKey
literal; the third carried a per-user discriminator so the cache
treated it as a fresh slot. Dashboard cold mount fired the
endpoint twice. Collapsing onto the shared hook trims that to a
single request; with the new server-side cache slot warm, the
duplicate-mount worst case rides single-flight coalescing.
- **GHCR build fires once per release tag.** The
`docker-publish.yml` workflow lost its `push.branches: [main]`
trigger; the `:latest` raw-tag enable rule moved onto the tag
ref so each release produces exactly one multi-arch build that
refreshes both the semver tags and the `:latest` alias.
- **NFT-trace warnings silenced.** `next.config.ts` gained an
`outputFileTracingExcludes` block that narrows the Turbopack
tracer away from the `MAXMIND_LICENSE_KEY` env-access path so
the trace-report no longer warns on every dependent route file.
### Refactor
- **Dashboard analytics consumers.** The two cards that previously
decided their own freshness copy now lean on the typed
`tileStaleDays()` helper and the additive `lastSeenByType` field
on the analytics response. Mood and BD-Zielbereich tiles keep the
default `null` because they have no underlying per-type freshness
signal.
### Accessibility
- **Dashboard hero CTA hits the WCAG 2.5.5 touch-target floor.**
The new "Ask the coach" pill carries `min-h-11` on mobile and
the matching `sm:min-h-9` desktop floor so the touch target
stays above 44 px on the Pixel 5 boundary.
- **Insights tab-strip Vitals parent reads correctly to assistive
tech.** Parent pill carries `aria-current="page"` whenever the
current URL matches one of its child vital sub-pages, plus a
`data-slot="insights-tab-strip-group"` hook for visual-regression
testing.
### Internal
- **Two e2e flake windows tightened.** `onboarding-flicker` swapped
its 12-sample 50 ms poll loop for a single Playwright
auto-retrying `toBeHidden({ timeout: 700 })` assertion so the
1-2 ms race window between the analytics-pending shell and
`useAuth().user` resolving collapses to a single retry slot.
`mobile-viewport` dropped `nav a[href]` from the touch-target
sweep (the bottom-nav owns its own WCAG spec) and gated the
44-px floor on `matchMedia('(min-width: 640px)').matches === false`
so the Pixel 5 viewport never tripped into the `sm:` tier during
WebKit render commits.
- **Server-cache observability.** Every `cached()` invocation passes
an `annotate` callback that lands two keys on the wide event:
`cache.<name>.outcome` ∈ `{ hit | miss | stampede }` and
`cache.<name>.key_hash` (non-reversible djb2 32-bit hash). Ops
can grep `cache.analytics.outcome` over any time window to
compute the hit ratio per deployment.
- **AASA followups verified.** The v1.4.33 `/.well-known/apple-app-site-association`
handler serves a direct 200 on every fronting origin; Apple's
CDN has ingested matching bodies for all three domains.
### Web freeze
v1.4.34 marks the last functional release of the web codebase before
the iOS native client launches. Subsequent v1.4.x tags carry security
fixes, dependency bumps, and hotfix-only corrections. New feature work
is paused until the iOS app clears Apple review, at which point a
v1.5.0 version-bump-only release tags the milestone. The Prisma
schema head comment and `.planning/v15-strategic-plan.md` §5
decision-log row pin the freeze trigger in-tree.
|
||
|
|
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. |
||
|
|
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> |
||
|
|
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> |
||
|
|
38309932f3 |
Initial release v1.0.0
HealthLog — personal health-tracking PWA with weight, blood pressure, pulse, mood tracking, medication compliance, Withings integration, doctor report PDF export, and Dracula-themed UI. |