mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
de5b3159997bf0853390cc156d41cebdbc53a0bb
53
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
41596d1cb8 |
docs: comments name what the code does, not how it was built
Thirty-one files carried internal build vocabulary and anchors pointing at notes that are not in the repository: a reader of `checklist.ts` in a year would find its rationale keyed to a research id they cannot open, inside a comment that also names an internal build stage. Two test titles carried the same anchors. The version tags and the whole of the reasoning stay; only the vocabulary and the dangling references go, with the few sentences that were built around an anchor rewritten to stand on their own. |
||
|
|
0bdbf009f6 |
fix(integrations): a provider with no history is "unknown"
The ledger's synthetic "no row" snapshot returned `state: "connected"`, so the field's default meant the opposite of its name: every account that had never connected a provider published `connected` on the status envelope. Settings survived it by never reading `state`; the setup flow's connect step did read it, and told brand-new accounts their wearable was connected. The snapshot now reads `unknown`. `syncHealth.verdict` is unchanged and stays the only liveness truth; the managed-record projection maps `unknown` onto `disconnected` the way it already maps a missing connection. |
||
|
|
b6e34d1264 |
Close five honesty gaps on the integration surface
Each of these made the server report something that was not so, and each now
has a test that fails without the fix.
The Withings credential DELETE wrote no audit row and left the ledger at
whatever state it last held. Fitbit, Google Health and WHOOP all park theirs,
and three of them say in their own comments that they do it for parity with a
list that never included Withings. A client reading `state` after the teardown
was told `connected` about a connection that no longer existed.
Oura's and Strava's credential DELETEs audited only when an access token
happened to be present, so removing a stored pair that had never completed
OAuth left no trail at all. Polar's twin has always done both unconditionally;
they match it now. The old Oura test asserted the skip, which is why the gap
survived a rewrite — that expectation is inverted here, with the reason next to
it.
The Withings and WHOOP manual syncs had no rate limiter, while `fullSync` walks
full history against a per-user provider budget. The Fitbit route's own comment
records that it was the outlier until it got one; these two were never brought
along. Both now carry the same 5/60s baseline and 1/hour full-sync buckets as
their siblings.
Every sync route parsed its body inside a catch commented "no body provided",
which was true for one of the three ways to reach it. An absent body still
means an incremental run — that is the documented shape and the web cards send
it. A body that is present and unparseable is now 400, and one that fails the
schema is 422: `{"fullSync": "true"}` used to be read as false and answered 200,
so a client that asked for full history and mistyped the value was told the run
it never requested had succeeded. Unknown keys are still ignored. The four
copies collapse into one helper.
The consolidated integration status swallowed a failed freshness query into an
empty array, which is exactly what a record with nothing in it also produces —
so the signal built to say "this metric has gone quiet" answered "nothing has
gone quiet" when it had failed to look. `metricFreshnessDegraded` separates the
two, as one flag rather than one per entry, because it is a single query for all
eight providers.
The contract follows the behaviour in the same commit.
|
||
|
|
a3561b4600 |
Tell a first-run what Apple Health is actually doing
Refs #778. Two honesty gaps from the first-run report: the web onboarding presented Apple Health like a web connector although the connection only completes in the iPhone app, and the initial backfill gave no sign of progress, so a working first sync looked broken. The onboarding source step now says plainly that Apple Health is set up in the iPhone app after onboarding, without dead-ending the flow. The integration card gains a server-authoritative progress summary — rows accepted, oldest reading reached, and an honest flowing/waiting line derived from the last accepted batch. Nothing the server does not know is shown: no percentage, no ETA, no invented throttle state; a quiet stretch reads as waiting for the phone, not as an error. |
||
|
|
4e95df9e8d |
feat(integrations): tell the Apple Health card how the last sync arrived
The batch route has accepted a `syncTrigger` since v1.32.8 and put it on the wide event, where no human reads it. Half of what was promised got built: the value is written and appears in three places in the codebase, none of which is a screen anyone looks at. Persist it on the account, alongside the instant of the most recent batch that arrived without the app being opened, and publish both from `GET /api/integrations/healthkit`. The card now names how the last sync arrived and when data last came in on its own. `healthKitLastSyncedAt` alone reads identically whether the phone delivers by itself or only while someone has the app open, which is why "is background delivery working" could not be settled from either side. A batch that declares no trigger writes null rather than leaving the previous one standing — the field describes this sync, and an older client's silence is not evidence about it. A foreground batch leaves the background timestamp alone; overwriting it would erase the only answer the card has. Also here: WHOOP, Fitbit and Google Health join the workout-freshness table. All three write workouts, and without an entry their cards could report every measurement type fresh while the workout leg had been silent for weeks. And the status pill drops a generic `aria-label` that replaced its own accessible name, so a screen reader announced the same words for a healthy connection as for a broken one. |
||
|
|
06935256b0 |
feat(integrations): let the ledger hold every failing leg at once
`failing_leg` was one slot, so two legs of the same provider failing in overlapping windows overwrote each other. The row remembered whichever failed last, and that leg recovering then cleared an error the other one was still causing — the ledger forgot a live failure and painted the card green over it. The slot becomes a set. A failure adds its leg, a success removes its own, and the error state, the per-kind buckets and the streak anchor clear only once the set is empty. The array is ordered oldest failure first, so the last element is the leg that wrote the message `last_error` holds; when that leg recovers while others are still failing, the message is cleared rather than left describing a leg that is now healthy. An unattributed failure adds nothing and removes nothing. A single-leg provider therefore behaves exactly as before, and a multi-leg provider no longer loses what its other legs already reported. The column never reached a client — it is absent from the status serialiser and from the OpenAPI contract — so it is replaced rather than shimmed. |
||
|
|
4e3b029c38 |
test(integrations): pin the escalation ladder, leg-scoped clearing, and what the card says
Mutation-checked against the production paths they cover: the escalation verdict, the leg guard in `recordSyncSuccess`, the streak anchor the verdict dates itself by, the status envelope threading it, and the pill's relative timestamp. Also give the four legacy per-provider status routes the verdict the consolidated envelope already carries. They answered `connected: true` plus a `state` string and nothing else, so a client reading one of them saw a live connection over a pipe that had delivered nothing for weeks. |
||
|
|
527dd6c1fa |
fix(integrations): escalate a long transient failure streak, and scope a success to its own leg
A sync failure the classifier bucketed as `transient` had no exit. The park test read `kind === "persistent"` and the persistent streak anchor was only ever stamped by a persistent failure, so a streak could run for a thousand attempts with the persistent counter at zero, the state frozen at `error_transient`, and no ladder it could climb. `transient` is a hypothesis about retryability, and the classifier defaults every timeout, 429, 5xx and unrecognised response to it precisely because the wire cannot tell a reboot from a permanent break. That default is right for one attempt and wrong for a thousand: an unbroken streak retried hourly for a full day has refuted it by experiment. Record it as `persistent` from there, which feeds the park ladder that already exists — 48 h of unbroken failure end to end before an integration stops attempting. The counters could not accumulate anyway. `IntegrationStatus` is keyed (user, integration) while Withings runs four legs on four crons and WHOOP four resources on four more, and a success cleared the error, every bucket, the streak anchor and the state whatever leg had recorded them. The ECG tick at :41 wiped what the sleep tick recorded at :15 — including a reauth park — so the strike ladder was reset several times an hour and the card painted a green "connected · 12 minutes ago" over a data class that had stopped arriving. A success now clears only what it is entitled to clear. Two columns carry it: `failing_since_at` dates the streak, `failing_leg` names its owner. The migration backfills the anchor for rows already in an error state so a connection broken for weeks escalates on its next attempt rather than restarting its clock. |
||
|
|
d0b45e36b3 |
feat(integrations): remove the moodLog integration
Mood has been a first-class module here for a long time, with entries,
structured tags, rated factors and notes. The bridge to the external moodLog
service had nothing left to add, and its Settings card was withdrawn in
|
||
|
|
10593a11a8 |
test(integrations): cover the verdict ladder and the coverage guarantee
The load-bearing one is the panel-coverage test: render the panel with an envelope carrying every integration key and require each configured one to surface as a card or as a fallback row. Drop the fallback row and it goes red, which is the whole point — the previous shape of this bug was a provider on the wire that no screen rendered, and no card-shaped test could have caught it. Alongside it: the ladder table with the month-silent fixture that resolves to stalled, the 24-hour and 7-day boundaries asserted against literals rather than the constants they check, the per-metric stale flag firing only under a healthy provider, and a structural guard that the verdict-to-pill mapping is defined in exactly one module. The card suites migrate with the semantics: a transient failure now expects the warning pill everywhere, and the Apple Health suite trades its bespoke badge states for pill assertions. The e2e mocks gain the verdict shape, the nightscout entry, and an assertion that the card-less row is on the page. |
||
|
|
de47bfa5ba |
feat(integrations): resolve sync health on the server
The ledger records what the last attempt did. It has no way to say what is no longer being attempted: a row frozen at `error_transient` for a month reads exactly like one retrying every hour, because nothing anywhere read the age of `lastAttemptAt`. `sync-verdict.ts` derives the missing reading as a pure read-side projection: eight verdicts down one ladder, no writer, no cron, nothing that can desynchronise from the row it reads. `stalled` is the new one, and it deliberately outranks `failing` — an integration nobody is calling is not failing, it is not running. Every entry on `/api/integrations/status` carries the verdict, computed inline from data the route already holds. That placement is the point: a card-level verdict misses a card-less provider by construction, which is how a dead sync stayed invisible. Nightscout joins the envelope so its card can stop fetching its own status. The freshness module gains the workout leg (Strava writes no measurements at all) and a source-parameterised core so the Apple Health route can reuse it. Its per-metric `stale` flag fires only while the provider itself reads healthy: that combination is the dead-pipe signature, and when the whole connection has stopped every metric ages together anyway. MCP reports the verdict beside `connected`, which only ever meant "not explicitly disconnected". |
||
|
|
1fca6c1d25 |
fix(mood): key mood rollups by the local day, not the UTC day
The persistent mood-rollup writer bucketed on the UTC truncation of `mood_logged_at` and ignored the canonical per-row `MoodEntry.date` label. Every coverage-miss fallback keys on `entry.date`, so the rollup tier disagreed with the fallback and with the day the user logged the mood: a late-evening or after-midnight local mood landed on the wrong calendar day, and the label shifted the moment the boot backfill flipped a user from the live fallback onto the rollup tier. Key every bucket off the stored `date` label instead — DAY, WEEK, MONTH and YEAR — with `bucket_start` set to that label's UTC-midnight instant. The rollup is now byte-identical to the live `entry.date` fallback and timezone-independent by construction: DST correctness stays where it already lives, in `moodDateKey` at write time. No consumer changes, no schema change; every rollup reader already formats `bucket_start` as a UTC day label. Migration 0269 empties `mood_entry_rollups` so the boot-time full-backfill worker re-mints every user's buckets under the corrected keying; the worker now deletes-then-refolds so it is self-cleaning against any row left by an older instance. While the table is empty, reads fall back to the live `date`-keyed walk, which was already correct, so there is no window where a wrong day is shown. |
||
|
|
62eb431d66 |
release: v1.32.0 — hardening and scalability (#580)
* release: prepare v1.32.0 * test: stabilize water capture re-entry |
||
|
|
a6d4bb414c | fix(moodlog): acknowledge durable source writes | ||
|
|
4591899657 | fix(release): harden v1.31 arrival contracts | ||
|
|
9c63d3ce20 |
chore(release): v1.28.25 — every integration held to the same standard
Platform-wide hardening: the failure classes found live this week hunted down across every integration. Fitbit carried the same tombstone wedge fixed for Google — probe now matches soft-deleted rows and the update resurrects them; the comment describing a partial unique index that never existed is corrected. Sleep segment ids are stable across re-scoring for Withings (session+segment-start), WHOOP/Polar (one segment per stage, no positional index) and Oura (run-start keyed), each with a session-scoped sweep that soft-deletes rows a re-score orphaned — so legacy and affected nights heal as they are re-read. One platform rule for deleted rows: source-owned readings resurrect on re-import (Withings, WHOOP, Oura, Polar, Nightscout, mood webhook, CSV re-imports, Apple stats day-totals); Apple sample-grain deletions stick per the paired-client contract, and the mood bulk route's tombstone match is now a true no-op reported as duplicate. Import counts reconcile against actual createMany counts. Google lifecycle: a dead token registers on the cycle's hard-fail ledger, so an expired connection parks instead of stamping success hourly; backfill and sleep-repair markers stamp only after a clean pass (pg-boss retries are now real); failed measurement writes hold the watermark; no-op updates are skipped; mapping.md tells the current truth. Scale: doctor-report reads only consumed columns and enabled sections; exports and backups keyset-paginate; achievements tally via SQL buckets; series charts day-bucket dense kinds beyond 90 days; BP pairing is a linear two-pointer; distinct-type scans run server-side; the graded-series cold fallback is bounded; the fitbit defer tracker loops instead of spreading. |
||
|
|
1212ed91d5 |
chore(release): v1.28.21 — sleep history heals itself; plateau context; sync diagnostics
A one-time background pass re-reads each connected account's Google Health sleep history so nights stored twice before the sleep fix collapse to their true total (new sleep_repaired_at marker, migration 0238; watermark-safe, enqueued once at boot per un-repaired connection). The sleep night read reports when two writers disagree about the same night — the served total is unchanged, the disagreement rides along as an annotation (sourceDiscrepancy on the night DTO) so clients can mark the number instead of silently picking a side. A GLP-1 medication whose weight has plateaued on the current dose says so next to the estimated drug level (efficacy tab + insights) — a factual note from the existing detector, association only, verb-guard covered. Google Health diagnostics: the sync log now separates a daily-totals read that returned nothing from one the importer could not parse (googleHealth.rollup.emptyResponse vs droppedAll), and the connection test's structure probe reports the raw response envelope key names when a rollup type parses to zero — a per-account naming drift becomes visible from one report. |
||
|
|
75eb0637e6 |
feat(strava): add per-user BYO-OAuth workout source + Garmin indirect-path docs
Add Strava as a per-user BYO-OAuth workout source, mirroring the shipped WHOOP/Oura/Polar pattern. Strava caps every new app at single-player mode, so each self-hoster registers their own app and pastes the client id/secret into Settings (falling back to a shared env app). Strava is a workout source only — it exposes no daily metrics and no training-depth analytics are derived. - Schema (migration 0233): per-user encrypted client id/secret + access/refresh tokens, plaintext athlete id, incremental cursor, and backfill marker on `User`; `STRAVA` added to `MeasurementSource`. - Token model follows Oura's reactive-refresh-on-401 with a compare-and-set on the stored refresh ciphertext, because Strava rotates its refresh token on every refresh. - Client over safeFetch: authorization-code + refresh, activity list + budget- aware per-activity detail (for calories/HR), honouring the 200/15min cap; each activity maps to one `Workout` row. Activity name/description stay in metadata as data, never surfaced or interpolated. - Dedup reuses the existing source-agnostic two-layer picker: `STRAVA` slots into the workout ladder below the device-native wearables and above MANUAL/IMPORT, so an Apple-Watch + Strava twin collapses to one canonical row. - Poll-cohort hourly sync (:17) + self-converging boot backfill, wired into the integration-sync registrar. Webhook deferred. - OAuth routes (connect/callback/credentials/disconnect/status/test) with signed state, cross-account re-auth rejection, and a surgical `www.strava.com` connect-src gated to the Strava surfaces. - Settings card + consolidated status envelope entry; env + compose whitelist; i18n across all six locales. - Garmin: no direct connector is possible (business-partner-only). Add docs/integrations/garmin.md documenting the Apple Health / Google Health Connect path and Garmin's withheld proprietary scores, cross-linked from both platform docs, plus a non-connector info note in the Connections panel. Regenerate docs/api/openapi.yaml for the new `STRAVA` MeasurementSource value. |
||
|
|
4c158f6abc |
feat(integrations): per-metric-type last-value freshness signal
Freshness was tracked only per-integration, so a provider returning a healthy 200 with an empty body for ONE metric type recorded no failure and the whole integration stamped green — a metric dead since launch was indistinguishable from a healthy-idle one. Compute the last-value timestamp per (source, type) straight from the Measurement table (no migration) and surface it per integration on the status endpoint, so the Settings card can flag a silently-dead metric the green pill hides. The card UI badge + per-cadence staleness classification are the deferred remainder. |
||
|
|
849607e7df |
fix(google-health): reconcile the daily-summary filter and two leaf fields with the v4 reference
Daily HRV and resting heart rate returned HTTP 200 with zero imports on a live Fitbit-band account. Full reconciliation against the v4 reference (data-types index, DataPoint schema, list/reconcile/dailyRollUp pages) surfaces three defects: - The daily-summary `.date` filter prefix is the docs' own contradiction: the data-types index's filter-parameter column says snake_case (`daily_heart_rate_variability`), while the list reference's only worked daily example is `dailyHeartRateVariability.date < "2024-08-15"` — the camelCase payload key. Live, the snake form is accepted (200) but matches nothing. Send the worked-example camelCase form first and re-walk once with snake_case if the very first page 400s; annotate the accepted style (`googleHealth.dateFilter.style`) and surface it through the structure probe as `requestShape` so a live account settles the grammar. - `daily-respiratory-rate` reads `breathsPerMinute` (a plain number); the previously assumed `dailyRespiratoryRateBpm` leaf does not exist, so RESPIRATORY_RATE never parsed a single row. - `height` carries `heightMillimeters` (int64 string), not `heightMeters`; the heightCm profile seed never fired. Convert mm -> cm. Also cover the documented Exercise.ExerciseType catalogue in the sport map (MOUNTAIN_BIKE, TRAIL_RUN, TREADMILL_WALK, STATIONARY_BIKE, SWIMMING_POOL, FREE_WEIGHTS, CROSSFIT, TABATA_WORKOUT, ...) — the enum's multi-word values previously fell through to "other". Tests pin the documented field names per fixture, the camel-first/snake- fallback walk (including no-fallback for sample shapes and unfiltered backfills), and the extended sport map. mapping.md records the filter contradiction and the corrected leaves. |
||
|
|
3653402d4a |
feat(google-health): report the accepted rollup shape from the structure probe
The structure probe (POST /api/integrations/google-health/test with probe:"structure") already exercises one 7-day dailyRollUp per cumulative type; it now also reports which documented request shape Google accepted (days90 standard chunking vs days14 conservative fallback) as requestShape, next to the field-name skeleton. A self-hoster can settle the range-constraint question from the UI without server logs; no value, timestamp, or identifier is added to the output. |
||
|
|
f92e9fd840 |
feat(google-health): surface the API error body and add a structure probe
- On a non-2xx data-point response, extract the AIP-193 error envelope
into a redacted STATUS: message detail (bearer tokens stripped, URL
query strings dropped, 200-char cap) and thread it into the thrown
GoogleHealthApiError plus the external-call annotation, so a
field-grammar 400 is diagnosable from operator logs. The OAuth token
endpoint keeps its flat invalid_grant handling.
- POST /api/integrations/google-health/test with {"probe":"structure"}
now fetches one recent page/window per data type and returns only the
payload STRUCTURE (field names + typeof leaves, never values) plus the
per-type error verdict — paste-safe diagnostics for payload-shape
drift. Same auth and rate limit as the plain connection check.
|
||
|
|
0d3067837e |
fix(google-health): surface the connection status and add the resume and test routes
Wire the settings card to the live connection so it reflects reality after a connect. - Emit a google-health entry on the consolidated integration-status envelope (configured, connected, last sync, backfill, and the needs-reconnect flag). Without it the card read an undefined status and showed a permanently disconnected connection even after a successful OAuth grant, hiding the sync, disconnect, and reconnect controls. - Add the resume and test-connection routes the card calls, mirroring the classic Fitbit ones. - Use the shared refresh icon on the reconnect action. - Drop the experimental-scope opt-in from the runbook and describe ECG and irregular-rhythm as a planned addition rather than a current toggle. |
||
|
|
6c27e053b7 |
test: route the global fetch stub onto the undici pinned engine
The requirePublicHost egress paths now dispatch through undici's own fetch, so the existing global fetch stubs in the AI, GeoIP, moodlog, send, and ntfy suites no longer intercept them. Delegate undici's fetch back to the installed global stub so each suite keeps asserting its request shape and canned responses without exercising the network. |
||
|
|
e748a91a36 |
feat(fitbit): wire PKCE through connect/callback and fix the test probe
Connect mints the PKCE pair, stashes the verifier on the OAuth-state row, and sends the S256 challenge to the authorize endpoint. Callback reads the verifier back and presents it on the token exchange, refusing cleanly when it is absent. The connection-test probe hits the classic /1/user/-/profile.json endpoint. |
||
|
|
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. |
||
|
|
9430e89889 |
feat(integrations): unify OAuth returns and the status envelope
The WHOOP and Fitbit OAuth callbacks redirect back with `?whoop=connected|error` / `?fitbit=connected|error`, but the settings page only read that param for Withings, Polar and Oura — a user returning from a WHOOP or Fitbit round-trip landed on a silently unchanged page with no toast. Extend the generic return handler to all four OAuth providers, add the matching connected/failed/error i18n keys across every locale, and invalidate both the per-card key and the consolidated envelope on a successful return. Polar and Oura already write the shared IntegrationKey ledger but each fired its own `/api/<provider>/status` round-trip from the card, so the page issued one consolidated request plus three per-card ones. Fold Polar and Oura into `/api/integrations/status` and let the cards read a passed view-model instead of fetching, retiring the extra round-trips. The per-provider status routes stay for the iOS and test callers. |
||
|
|
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. |
||
|
|
53e87ba345 |
perf: consolidate integration status query, add mood-insights SWR, defer below-fold charts
Settings → Integrations fired five status round-trips on every mount — the consolidated /api/integrations/status alongside the four per-provider /api/<provider>/status queries. Extend the consolidated endpoint to carry every field the four cards render (Withings activity scope, WHOOP/Fitbit backfill state, moodLog webhook secret + entry count) and drive all four cards off the single envelope. The legacy per-provider routes stay for the iOS/test callers; the web cards no longer hit them. Mood-insights cache was a plain 60 s TTL that was hard-evicted on every mood write, so an active logger re-paid the multi-second cold compute on every entry. Add stale-while-revalidate to ServerCache (wrapSwr / cachedSwr + a markStaleByPrefix that collapses the fresh TTL without dropping the value), give the moodInsights bucket a 10-minute stale window, route the read through cachedSwr, and switch the mood-write invalidation from a hard evict to a mark-stale so the prior aggregate serves immediately while a single background recompute warms a fresh one. Defer the eager Recharts on the mood-insights surface. The trajectory forecast card pulled Recharts into the initial chunk of every trajectory-eligible metric page though the main chart is already deferred; split its fan band into trajectory-fan.tsx behind next/dynamic. The three below-fold mood mini-charts (distribution / weekday / time-of-day) were static imports under an already-dynamic hero line chart; defer them with next/dynamic. Each loader paints a skeleton sized to the chart's own band so the chunk arrives without a layout shift; the charts stay Recharts and visually identical. (cherry picked from commit 2b627893ab6b4d7bc457fc18e6e5585b048190e1) |
||
|
|
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) |
||
|
|
cfb97593a3 |
feat(insights): add tag-by-metric crosstab and deprecate the moodLog webhook
Extend the mood-relations surface from mood to a health metric. For each structured mood tag present on enough days, compare a metric's value on tag-present vs tag-absent days: active energy on workout days (same day), sleep length on nights tagged sleep (same day), and next-day recovery after a tag like alcohol or food (D to D+1 lag). The comparison reuses the existing statistics engine wholesale — the Welch two-sample t-test, the per-group day floors, the confidence band, and the Benjamini-Hochberg FDR control already used by the influence and discovery boards — so no new statistics code lands. Only pairs that clear p < 0.05 and the FDR control surface, ranked by adjusted q then absolute delta and capped at eight rows. The result rides on GET /api/mood/insights as a new additive `tagMetricCrosstab` field and renders as a confidence-gated, observational card on the mood insights page, framed as an association in your own data rather than a cause. The endpoint is server-rendered and not in the OpenAPI contract, so no contract change is required. Deprecate the standalone moodLog integration. Mood is now tracked fully inside HealthLog through entries, structured tags, and rated factors, so the external webhook and reverse-sync bridge no longer add anything. The webhook route, the push and pull helpers, and the validation schemas carry an @deprecated note pointing at the native path; the surface stays functional this release and is slated for removal in a future major. The settings card shows a deprecation hint, localised across all six locales. |
||
|
|
f44dccb828 |
feat(fitbit): add the Google Health settings card with connection test and resume
Give the Google Health (Fitbit & Pixel) integration the same settings-card
parity WHOOP and Withings already have. It is a BYO-key provider — the user
pastes their own Google OAuth client id/secret, then connects — so the card
mirrors the WHOOP flow rather than the server-brokered Withings one.
- New POST /api/integrations/fitbit/test does a live, rate-limited
(5/60s per user) probe against the Google Health profile endpoint via
the refreshed token from getValidToken, returning { ok, latencyMs,
lastSyncedAt } and the same error-code union WHOOP uses. The Bearer
token and upstream host are scrubbed before any annotation.
- New POST /api/integrations/fitbit/resume clears a parked integration
via resumeIntegrationFromPark, rate-limited to match the test route.
- FitbitCard renders the credentials form, OAuth connect, sync now /
sync all data / test connection / disconnect action row, the parked
resume banner, and a backfill-in-progress line. Status reads from the
existing /api/fitbit/status; the pill/error/parked state comes off the
cross-integration envelope, which now carries a fitbit entry.
- Card strings added under settings.fitbit* across all six locales.
|
||
|
|
6f378c8584 |
feat(whoop): add connection test and resume-from-park parity with Withings
Mirror the Withings integration actions on the WHOOP card. A new POST /api/integrations/whoop/test runs a live, rate-limited probe against the WHOOP basic-profile endpoint, refreshing the token via getValidToken and classifying HTTP/transport failures into the same errorCode taxonomy the Withings test route uses. A new POST /api/integrations/whoop/resume clears a parked integration via resumeIntegrationFromPark for the whoop provider. The settings WhoopCard gains the shared Test-connection button, the parked-state resume banner, and switches the full-sync icon to RefreshCw for visual parity. Sync-now and sync-all are unchanged. |
||
|
|
008160dc72 |
feat(whoop): settings surface — status, disconnect, credentials, sync routes + connect card
Fill the WHOOP route set beside the OAuth connect/callback: status reports connection + token expiry + backfill progress, disconnect revokes the stored tokens and parks the integration, credentials saves/clears the per-user BYO client id/secret encrypted at rest, sync triggers a manual incremental or full pull. Add a WHOOP card to Settings -> Integrations mirroring the Withings card (BYO-keys form, connect/disconnect, status pill, last-sync, backfill notice) and surface whoop rows in the combined integrations-status envelope so the pill reflects real failure state. WHOOP lives under the existing integrations section -- no new slug. |
||
|
|
50e0a65228 |
chore(release): v1.7.0
v1.7.0 — health-record export (PDF + FHIR), flexible medication schedules, first-paint dashboard snapshot, full HealthKit coverage, offline sync delta feed, Coach data clustering. See CHANGELOG. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
b43b57f657 |
feat(security): pin resolved IP on outbound fetches to defeat DNS rebinding
Closes issue 217. The isPublicUrl input-time guard catches every RFC1918 / CGNAT / loopback / link-local alt-notation, but a low-TTL DNS record can flip between accept and dispatch. The new pinnedPublicDispatcher resolves the hostname literally inside undici.Agent's connect hook, refuses any address isPublicIp would reject, and pins the connection to the first valid public address. The five outbound paths that accept a user-supplied host route through the dispatcher via safeFetch's requirePublicHost flag — moodlog sync, moodlog reverse-push, the moodlog credentials probe, the local AI client (already gated in commit 1), and the ntfy sender. The flag now buys both the input-time check and the connect-time pin in one call. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
425503e095 |
feat(security): safeFetch wrapper with manual redirect and timeout defaults
Closes issue 218. Flips the convention from "remember to opt in to redirect: manual" to "remember to opt out". Every outbound fetch that doesn't pin a hard-coded host now routes through safeFetch and inherits AbortSignal.timeout(15_000) so a tar-pit upstream can no longer pin a worker indefinitely. The local AI client also gates on requirePublicHost: true since its host is user-supplied; operators running self-hosted Ollama / LM Studio on an RFC1918 address keep their escape hatch via ALLOW_LOCAL_AI_PRIVATE_HOSTS=true (also enforced at write-time in /api/user/ai-provider). 14 outbound call sites migrated. The four that already pinned manual redirect (the historic templates) keep their semantics; the wrapper preserves an explicit init.redirect. Adds undici as an explicit dependency so the connect-time pin landing alongside this can rely on the documented API rather than a transitive hop. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
f92e644154 |
chore(release): v1.4.49 — audit-backlog closure + server reminder suppression + diagnostic endpoint + analytics cold-path fix
Bundles the deferred v1.4.47 Mediums + Lows with the v1.4.48-deferred items into a single release. Headline: server-side suppression of MEDICATION_REMINDER APNs for iOS clients that own their local reminders (closes GH #206), a push_attempts table backing /api/admin/notifications/diagnostic, and a sweep of v1.4.48 QA forward-findings. Performance (the user-visible win): - /api/analytics cold-fallback path splits the heavy single-statement aggregate into two parallel queries. The linearly composable columns (count / min / max / mean) keep the full-partition scan; the windowed 7 / 30 / 90-day avg / slope / r² columns take a 90-day outer cap so the planner does an index range scan on (user_id, type, measured_at) instead of a full-partition sequential scan. Output is bit-identical; cold slim slice drops from multi-second to sub-second on tenants with large measurement partitions. Server-side notifications: - GET + PATCH /api/auth/me/notification-prefs with deep-merge semantics. Initial category medication.clientManaged: boolean (default false). Cron medicationReminderJob skips dose-due APNs when the flag is true, emitting a medication_reminder.suppressed_client_managed wide-event annotation per skip. - push_attempts table backs the diagnostic endpoint. Every APNS / Web-Push / Telegram / NTFY sender writes a fire-and-forget row. Daily 03:35 Europe/Berlin cleanup prunes rows older than 90 days. - /api/admin/notifications/diagnostic — admin-only endpoint surfacing device APNs token presence (masked), channel config presence, and recent push attempts. Registered in OpenAPI. - Per-channel APNs "Send test" button now fires with eventType: "MEDICATION_REMINDER" so the dispatcher's time-sensitive + priority-10 branch runs. A successful self-test now exercises the exact path production reminders take; an unsuccessful one points the user at iOS Settings → Time Sensitive Notifications rather than appearing to be a server-side bug. Withings reliability: - OAuth callback consumes the nonce atomically (delete-first with P2025 catch for the replay branch). The previous read-then-delete pattern allowed a race between two concurrent callbacks. - ?reason=state redirect differentiated into csrf1 / replay / expired / cross_user so operators can tell mismatch types apart without DB shell access. - connect endpoint rate-limited to 10/60s/user; row-create failures redirect to a consistent error surface. Observability hardening: - redactSensitiveFields(body) denylist helper covers password / token / secret / apiKey / authorization / csrfState / nonce, recursive, case-insensitive. Caught: /api/devices invalid_format issues were echoing apnsToken values verbatim into the audit log. - sanitiseZodIssues(issues, { stripValuesFromMessage: true }) overload applied to 14 audit-log sites. Migrations: 0079 (notification_prefs), 0080 (push_attempts). Tests: 4815 → 5272 unit + integration. Workout-batch integration suite that had been red on main since v1.4.45 (three release commits shipped through it) now green again — updated to match v1.4.42 cross-source dedup semantics. Resolves PR #207. |
||
|
|
66a7f8b6fa |
chore(release): v1.4.47 — audit-backlog closure + drag-to-reorder + Coach disable + OAuth nonce ledger
Eight changes: W0 idempotency test pin, W1 legacy column drop, W2 Textarea primitive, W3 Coach disable toggle, W4 dnd-kit drag-to-reorder, W5 onboarding 24h gate, W6 Withings OAuth nonce ledger, W7+W8 legacy_form_total drop + Coach onLine pre-check. 6-axis QA reconciled. Migrations 0076/0077/0078 required pre-image. Tests 5093 → ~5170. |
||
|
|
6c7fc7dc84 |
chore(release): v1.4.45 — analytics 9 s perf fix + audit-marathon closure + v1.4.44 hotfix merged
Audit-marathon close: 14 waves + W10 QA + reconcile. Perf fix (analytics 9 s -> 2-3 s), Zod multi-issue rollout (41 routes), Withings parked-state automation, mobile-UI WCAG 2.5.5 close, QoL i18n + offline banner + account delete, security PII drop + replay-injection scan. Migration 0075 required. Tests 4815 -> 5076. |
||
|
|
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. |
||
|
|
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. |
||
|
|
f5962497d6 |
v1.4.34.4 — audit-driven hotfix bundle (security, UX, code-quality, docs)
Consolidated hotfix landing the findings from a six-axis audit
covering mobile security, UX + accessibility, code quality + docs,
README + discoverability, web performance, and mobile-deep responsive
behaviour. None of the changes break the public API or the existing
data shape; every fix is additive and SAFE under the v1.4.34.x web
freeze. Full audit reports live in `.planning/round-audit-*.md`.
Security
- Password rotation now wipes API tokens + refresh tokens in the
same `$transaction` as session revocation. A stolen iOS Bearer no
longer survives a user-initiated password change or an admin reset.
- `/.well-known/` proxy bypass tightened to an explicit-allowlist
`Set`. Stale `/api/auth/codex/callback` PUBLIC_PATHS entry dropped.
- HSTS gains `preload`. Withings host removed from the global CSP
and gated to the integration's own routes.
- `getClientIp` warns once per process on `TRUST_PROXY_HOPS`/XFF
mismatch + exposes a tagged helper for tighter universal buckets.
- Service worker rejects off-origin notification-click URLs. Offline
fallback body switched to a locale-neutral icon. Manifest gains
`scope`, `id`, `display_override`.
UX + accessibility
- i18n leaks closed on Dialog/Sheet close-X, ChartSkeleton's loading
announcement, and the notifications-section breadcrumb.
- Focus rings on bottom-nav, the More overflow button, and the
mobile top-bar user menu.
- Tap-target floor: Input / NativeSelect / Select / DateTimeInput
switch to `h-11 sm:h-10`.
- Admin chip-strip auto-scroll-into-view (matches v1.4.33 settings).
- List-page subtitles visible on mobile.
- Insights empty-state CTA vocabulary normalised so the link opens
the right measurement form (was silently failing for
BODY_TEMPERATURE / BLOOD_GLUCOSE / PULSE).
Mobile-deep
- `viewport.themeColor` reacts to light/dark mode.
- TopBar + Coach FAB reserve safe-area-inset-top / -bottom for
notched iPhones.
- Sonner toaster switches to `theme="system"`.
Code quality
- `SESSION_SECRET` dropped — the env var was documented as required
but never read by code. Three secrets, not four. Self-hosters
upgrading can delete the existing line.
- `toJson<T>()` helper consolidates seven Prisma JSON-cast sites.
- `db-compat.ts` gains a header docstring naming it the
schema-bootstrap path.
- Passkey response types adopted from `@simplewebauthn/server`;
`any` + eslint-disable pragmas drop out.
- Zero-TODO CI gate added at `.github/workflows/no-todo-markers.yml`.
Documentation + discoverability
- README rework: How-it-compares matrix, Apple Health + AI Coach
promoted to standalone Key-Features bullets, status block, latest
release + GHCR badges, OpenAPI pointer, three-secret Quick Start.
- openGraph + twitter cards now carry `images` and a
`summary_large_image` Twitter card.
- GitHub repo metadata: description tightened from 290 to 156
chars + keyword-front, `homepageUrl` flipped from a personal
tenant to the demo site, six generic / PII-adjacent topics
swapped for six high-intent ones.
- Five new user-facing docs: `docs/self-hosting/getting-started.md`,
`docs/self-hosting/reverse-proxy.md`,
`docs/integrations/apple-health.md`,
`docs/integrations/withings.md`,
`docs/integrations/ai-providers.md`.
- `docs/README.md` clarifies the docs tree as internal playbooks
vs the user-facing `docs.healthlog.dev` site.
|
||
|
|
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. |
||
|
|
4db72a8c2e |
feat(integrations): connection-state + sync-error UI in Settings → Integrations
v1.4.15 Phase B2, criterion 1. Until this release the Settings →
Integrations card showed "Connected / Last sync 23 min ago" and
nothing else. A user whose Withings refresh token had revoked saw a
green badge; a user whose moodLog instance was down saw the same
green badge with a slightly older timestamp. The signal was useless.
This commit adds:
- New `IntegrationStatus` table (`integration_statuses` migration
`0029`). One row per (user, integration) capturing `state`
(`connected | error_transient | error_reauth | disconnected`),
`lastSuccessAt`, `lastAttemptAt`, encrypted `lastError`,
`consecutiveFailures` counter, `alertedAt` window-guard.
- `src/lib/integrations/status.ts` — the only writers
(`recordSyncSuccess`, `recordSyncFailure`, `markReauthRequired`,
`markReconnected`, `markDisconnected`) plus a read-only
`getIntegrationStatus()` for the API. The error column is
AES-256-GCM encrypted at rest because Withings/moodLog 401s can
echo URL/apiKey fragments — we can't ship that into a future
backup tarball.
- `GET /api/integrations/status` — single roundtrip returns both
Withings and moodLog snapshots plus the global threshold (so the
UI's "{n}/{threshold}" string is server-sourced and rotates with
the env var).
- Settings → Integrations cards both gain an
`IntegrationStatusBanner` row above the credentials form: state
badge ("Connected" / "Reconnect required" / "Error" /
"Disconnected"), `{n}/{threshold} consecutive failures` chip,
last-success / last-attempt times, and a destructive-toned
last-error line. The banner is suppressed entirely when the
integration is fresh-connected with no history (no false alarms).
Tests: +20 unit (status helper threshold + alert-window state machine,
banner rendering across all four states) + 8 integration
(`tests/integration/integration-status.test.ts`) confirming
encrypted-at-rest, audit-log row shape, alert idempotency window,
and CASCADE-delete on User. 116 files / 883 unit pass, 11 integration
files / 39 pass.
Encryption uses the existing `src/lib/crypto.ts` AES-256-GCM helper —
no new key material. The `consecutive_failures` counter and
`alerted_at` window are wired to the dispatcher in the next commit
(`feat(integrations): admin Telegram alert on persistent sync
failure`).
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> |
||
|
|
6e74612efc |
test(coverage): real-behaviour tests for audit + webhook routes (~80% line coverage) (#142)
Adds vitest unit tests covering the four zero-coverage webhook / monitoring surfaces external systems hit. Each test mocks at the module boundary (per existing project pattern in src/app/api/auth/login/ __tests__/native-token.test.ts) and asserts on actual call arguments — no test theatre. Per-file line coverage: - src/lib/auth/audit.ts 100.00% - src/app/api/withings/webhook/route.ts 98.11% - src/app/api/integrations/moodlog/webhook/route.ts 95.23% - src/app/api/telegram/webhook/route.ts 95.12% Test count: +61 (494 -> 555). |
||
|
|
4a6be429ca |
feat(api): test endpoints for Withings, moodLog, Web Push, Glitchtip, Umami (#134)
* feat(api): test endpoints for Withings, moodLog, Web Push, Glitchtip, Umami
Five POST /test endpoints modelled on /api/ai/test (apiHandler +
requireAuth/requireAdmin + checkRateLimit 5/60s + annotate() +
sanitised error mapping):
- /api/integrations/withings/test — pings wbsapi.withings.net /measure
with the decrypted user access token
- /api/integrations/moodlog/test — HEAD against the user's decrypted
moodLog URL (5 s timeout, isPublicUrl
SSRF guard)
- /api/notifications/web-push/test — sends "Push test" via web-push to
every PushSubscription, returns only
endpoint host (never full URL)
- /api/monitoring/glitchtip/test — admin-only; sends a stub event via
sendGlitchtipEvent() using the saved
AppSettings.glitchtipDsn
- /api/monitoring/umami/test — admin-only; HTTPS-only GET against
AppSettings.umamiScriptUrl, checks
body for "umami" marker
Each route has a vitest suite covering: happy path, rate-limit denial,
missing config (422), and upstream-401 sanitisation (no sk-/Bearer/full
URL leak).
Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
* fix(integration-tests): address review findings — singleton key, redirect SSRF, token refresh, scoped push
Multi-agent review caught a regression set on the five integration test
endpoints. Fixes here address every CRITICAL + HIGH finding:
- AppSettings is keyed on `id: "singleton"` everywhere else; the Glitchtip
and Umami test routes were querying `id: "default"` and would silently
return 422 in production. Tests now assert the where argument so the
typo can't sneak back.
- moodLog and Umami fetch with `redirect: "manual"` to neutralise the
redirect-follow SSRF (a public host could 302 to 169.254.169.254 or
RFC1918 ranges); 3xx is now treated as a configuration error.
- Withings test goes through the existing `getValidToken()` helper, so an
expired access token refreshes the same way the real sync does instead
of reporting "credentials rejected" until the next scheduled sync.
- Glitchtip DSN is validated as HTTPS + public host before any outbound
call, and the dispatch is wrapped in a 5 s `Promise.race` deadline.
- Withings dispatch picks up an `AbortController` 5 s deadline; the
Withings API-level `status` field (HTTP 200 + `status !== 0`) is now
categorised separately from the HTTP status (e.g. `status: 401` →
credentials_rejected, `status: 601` → rate_limited).
- moodLog test does an authenticated GET against the actual sync
endpoint instead of a bare HEAD on the configured URL — a HEAD on the
bare URL would return 200 from a generic landing page even when the
API key was invalid.
- Web Push test sends to the most recent subscription only (was: every
registered subscription, so a user with three devices got three buzzes
per click); tests assert findFirst+orderBy so this can't regress.
- Web Push response shape is the same shape on success and upstream
failure (errors land in the meta envelope, not in the visible
response body) — the prior dead-code in the 401 sanitisation test is
gone.
- Every error response now carries an `errorCode` in `meta` (e.g.
`credentials_rejected`, `rate_limited`, `dsn_host_not_public`) so the
upcoming UI can localise via `t("settings.testConnection.errors." + code)`
instead of rendering the server's English fallback.
- Outbound error annotations strip `Bearer <token>` and full URLs
before they reach Wide Events / Loki — push providers in particular
embed the subscription endpoint (which is the routing secret) in
410/404 error messages.
- Umami response read is capped at 256 KB so a hostile public host
cannot stream gigabytes through us; the marker check is tightened to
tracker shapes (`window.umami`, `umami.track`, `"umami"`) so a 404
page that says "Powered by Umami" no longer false-positives.
- `apiError` now optionally carries a `meta` object so error responses
can include the `errorCode` without breaking the existing
`{ data: null, error: <string> }` envelope.
477 tests green.
---------
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> |