diff --git a/CHANGELOG.md b/CHANGELOG.md index b6fc9d1cb..60eab6f09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## [Unreleased] +## [1.30.8] — 2026-07-18 + +Hardening for the new heart-rate upload validation. + +- **A malformed bucket timestamp is rejected, not silently misfiled.** A bucket whose time rolled over (a 24:00, an impossible day) is now refused rather than stored under a non-standard key, where it would have failed to merge with a re-post and could land on the wrong day. +- **The per-window low/high is sanity-checked before it counts.** A window's low and high are kept only when they are in a plausible range and sit either side of the average; a glitched reading no longer skews the day's high/low band. The average is always kept. + +No migration. No breaking changes. + ## [1.30.7] — 2026-07-18 Groundwork for a tidier heart-rate history. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6e239e93b..a9ae1a496 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.30.7 + version: 1.30.8 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 9005d5e30..eacf041a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.30.7", + "version": "1.30.8", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index b9fb25a34..0ebc4d602 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.30.7"; + /* @sw-version-fallback */ "v1.30.8"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts b/src/app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts index 336f9c64d..79f28b182 100644 --- a/src/app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts +++ b/src/app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts @@ -1,12 +1,14 @@ /** - * v1.19.0 (iOS #34) — go-forward aggregated heart-rate wire contract on - * `POST /api/measurements/batch`. + * v1.30.7/v1.30.8 (iOS #34) — go-forward aggregated 10-min heart-rate wire + * contract on `POST /api/measurements/batch`. * * Asserts: - * - a fresh 10-min HR bucket inserts; + * - a fresh 10-min HR bucket inserts, with its per-bucket min/max spread; * - a re-post of the same bucket OVERWRITES (status `updated`, not a * duplicate row); - * - a malformed aggregated HR bucket externalId is `skipped`; + * - a malformed / off-grid aggregated HR bucket externalId is `skipped`; + * - v1.30.8: an out-of-range or mis-ordered spread is dropped to null while + * the trustworthy average survives; * - the per-sample uuid HR path is unaffected (immutable duplicate); * - the existing per-day step `stats:` overwrite path is unaffected. */ @@ -172,6 +174,39 @@ describe("POST /api/measurements/batch — aggregated HR wire contract (iOS #34) expect(rows[0].valueMax).toBe(96); }); + it.each([ + // out-of-plausible-range spread (a sensor glitch / spurious discreteMax) + { valueMin: 58, valueMax: 99999 }, + { valueMin: -9999, valueMax: 96 }, + // mis-ordered: valueMin above the average, or valueMax below it + { valueMin: 80, valueMax: 96 }, + { valueMin: 58, valueMax: 70 }, + ])( + "v1.30.8 — drops an out-of-range or mis-ordered spread to null but keeps the average %o", + async (spread) => { + vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 1 }); + const res = await POST( + makeRequest({ entries: [hrBucketEntry(HR_BUCKET_ID, 72, spread)] }), + ); + expect(res.status).toBe(200); + const createArg = vi.mocked(prisma.measurement.createMany).mock + .calls[0][0]; + const rows = ( + createArg as { + data: { + value: number; + valueMin: number | null; + valueMax: number | null; + }[]; + } + ).data; + // The trustworthy average survives; the invalid spread is dropped. + expect(rows[0].value).toBe(72); + expect(rows[0].valueMin).toBeNull(); + expect(rows[0].valueMax).toBeNull(); + }, + ); + it("overwrites min/max alongside the average on a re-post", async () => { vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { type: "PULSE", source: "APPLE_HEALTH", externalId: HR_BUCKET_ID }, diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 33cfe5c94..1bdce805b 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -318,15 +318,25 @@ async function postBatch(request: NextRequest): Promise { // per-day cumulative `stats:` total, or a manual entry never carries a // spread, so we pin them to null there even if a client mis-sends the // fields — `value` stays the single source of truth for those rows. + // + // v1.30.8 — validate the spread the same way `value` is guarded (above): + // it now drives the DAY min/max band + the intraday envelope, so an + // out-of-range or mis-ordered spread (a spurious `discreteMin` of 0, a + // sensor glitch) would poison the aggregate exactly like a bogus `value` + // would. Persist the spread ONLY when both extremes are present, in range, + // and correctly ordered around `value`; otherwise drop to null and keep the + // trustworthy average. Partial or invalid spread → avg-only, still valid. const isHrBucket = isAggregatedBucketExternalId(entry.externalId); - const valueMin = - isHrBucket && typeof mapped.valueMin === "number" - ? mapped.valueMin - : null; - const valueMax = - isHrBucket && typeof mapped.valueMax === "number" - ? mapped.valueMax - : null; + const spreadValid = + isHrBucket && + typeof mapped.valueMin === "number" && + typeof mapped.valueMax === "number" && + validateMeasurementRange(mapped.type, mapped.valueMin) === null && + validateMeasurementRange(mapped.type, mapped.valueMax) === null && + mapped.valueMin <= mapped.value && + mapped.value <= mapped.valueMax; + const valueMin = spreadValid ? mapped.valueMin : null; + const valueMax = spreadValid ? mapped.valueMax : null; prepared.push({ index, diff --git a/src/lib/measurements/__tests__/aggregated-hr-bucket.test.ts b/src/lib/measurements/__tests__/aggregated-hr-bucket.test.ts index 576e9b453..e07539fcf 100644 --- a/src/lib/measurements/__tests__/aggregated-hr-bucket.test.ts +++ b/src/lib/measurements/__tests__/aggregated-hr-bucket.test.ts @@ -90,6 +90,19 @@ describe("isAggregatedBucketExternalId", () => { ])("returns false for a non-bucket externalId %s", (id) => { expect(isAggregatedBucketExternalId(id as string | null)).toBe(false); }); + + it.each([ + // v1.30.8 — these PARSE via V8's silent rollover but are NOT canonical, so + // they'd key a row the canonical (toISOString) client never emits → an + // un-mergeable duplicate + wrong-day placement. Must be rejected. + "stats:HKQuantityTypeIdentifierHeartRate:2026-07-18T24:00:00.000Z", // → 07-19T00 + "stats:HKQuantityTypeIdentifierHeartRate:2026-04-31T14:30:00.000Z", // → 05-01 + "stats:HKQuantityTypeIdentifierHeartRate:2026-02-29T14:30:00.000Z", // non-leap → 03-01 + "stats:HKQuantityTypeIdentifierHeartRate:2026-12-31T24:00:00.000Z", // → 2027-01-01 + ])("rejects a non-canonical rollover instant %s", (id) => { + expect(isAggregatedBucketExternalId(id)).toBe(false); + expect(parseAggregatedBucketStart(id)).toBeNull(); + }); }); describe("targetsAggregatedBucket", () => { diff --git a/src/lib/measurements/apple-health-mapping.ts b/src/lib/measurements/apple-health-mapping.ts index 74d182ae6..e665b18bd 100644 --- a/src/lib/measurements/apple-health-mapping.ts +++ b/src/lib/measurements/apple-health-mapping.ts @@ -897,7 +897,14 @@ export function isAggregatedBucketExternalId( const suffix = externalId.slice(prefix.length); if (!AGGREGATED_BUCKET_SUFFIX_RE.test(suffix)) return false; const parsed = new Date(suffix); - return !Number.isNaN(parsed.getTime()); + if (Number.isNaN(parsed.getTime())) return false; + // Reject a non-canonical instant that PARSES via V8's silent rollover + // (`T24:00`, `2026-04-31`, a non-leap `02-29`) — it would key a row under + // a literal string the canonical client (`heartRateBucketExternalId`, which + // mints via `toISOString()`) never emits, so the two would never collapse + // on the overwrite key. Round-trip equality pins the suffix to the one + // canonical form. + return parsed.toISOString() === suffix; } return false; } @@ -937,7 +944,10 @@ export function parseAggregatedBucketStart( const suffix = externalId.slice(prefix.length); if (!AGGREGATED_BUCKET_SUFFIX_RE.test(suffix)) return null; const parsed = new Date(suffix); - return Number.isNaN(parsed.getTime()) ? null : parsed; + if (Number.isNaN(parsed.getTime())) return null; + // Canonical-form gate (mirrors `isAggregatedBucketExternalId`): a rolled-over + // instant would place the bucket on the wrong local day. + return parsed.toISOString() === suffix ? parsed : null; } return null; }