From 82ccac2fd0c5fe8df05209bd0e735cc4eadaa12b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 21:53:37 +0200 Subject: [PATCH 1/3] fix(ecg): let a whole sweep of recordings through the ingest limit The iPhone app posts every ECG its sweep has not yet consumed and, on a 429, stops and restarts the same sweep on its next wake. With a limit of 60 per minute a sweep of 61 or more could never finish: the same last recording was refused on every sync, so the newest ECG never arrived. The limit is 600 per minute now, enough for more than a decade of weekly strips in one sweep. Refs #1060 --- src/app/api/insights/ecg/route.ts | 13 +++++++++---- tests/integration/ecg-ingest-roundtrip.test.ts | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/app/api/insights/ecg/route.ts b/src/app/api/insights/ecg/route.ts index 1a0bc0f9d..c6d17269c 100644 --- a/src/app/api/insights/ecg/route.ts +++ b/src/app/api/insights/ecg/route.ts @@ -64,10 +64,15 @@ const MAX_SAMPLES = 32_768; // any real recording. A DoS ceiling, not a tight bound. const MAX_BODY_BYTES = 2 * 1024 * 1024; -// One recording per request, so the limit is per recording. A person records -// a handful of strips a week; 60/min still drains a full watch history in -// minutes and stops a leaked wildcard token from saturating the write path. -const INGEST_RATE_LIMIT_MAX = 60; +// One recording per request, so the limit is per recording. The iPhone app +// sends every recording its sweep has not yet consumed in one go and, on a +// 429, stops and starts the same sweep again on its next wake. A window that +// held more than the limit could therefore never finish: with 60 per minute +// the 61st recording was refused on every sync and the newest ECG never +// arrived (#1060). 600 covers more than a decade of weekly strips in one +// sweep and still stops a leaked token from saturating the write path; a +// request is at most 2 MB and usually about 100 KB. +const INGEST_RATE_LIMIT_MAX = 600; const INGEST_RATE_LIMIT_WINDOW_MS = 60 * 1000; // Only the three ECG verdicts. `RhythmClassification` in the database has six diff --git a/tests/integration/ecg-ingest-roundtrip.test.ts b/tests/integration/ecg-ingest-roundtrip.test.ts index 2f321980e..24ac2f631 100644 --- a/tests/integration/ecg-ingest-roundtrip.test.ts +++ b/tests/integration/ecg-ingest-roundtrip.test.ts @@ -205,6 +205,24 @@ describe("POST /api/insights/ecg — round trip through the real routes", () => expect(posted.data.durationSeconds).toBeCloseTo(SAMPLES.length / 512, 10); }); + it("takes a whole sweep of recordings in one go (#1060)", async () => { + // The app posts every recording its sweep has not consumed and restarts + // the sweep after a 429; a limit below the sweep's size refused the same + // last recording on every sync. Seventy distinct strips must all land. + const statuses: number[] = []; + for (let i = 0; i < 70; i += 1) { + const posted = await ingest( + ingestPayload({ + externalRecordingId: `sweep-${i}`, + recordedAt: new Date(Date.UTC(2026, 5, 1, 8, i)).toISOString(), + }), + ); + statuses.push(posted.status); + } + expect(statuses.filter((s) => s === 429)).toHaveLength(0); + expect(await getPrismaClient().ecgRecording.count()).toBe(70); + }); + it("never writes the waveform as plaintext", async () => { const posted = await ingest(ingestPayload()); const row = await getPrismaClient().ecgRecording.findUniqueOrThrow({ From 08d5acdcb5ec724bda6747e4e70cc3444de89514 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 21:53:38 +0200 Subject: [PATCH 2/3] feat(measurements): log a resting heart rate by hand The entry form offered pulse but not resting heart rate, which has its own chart and series, so a value read off a watch or taken on waking could only land among spot pulses. The form now offers it next to pulse. The server already gates the type on the recovery module (its resting_hr domain), so the form, the dashboard summary, the resting-pulse page, its trend slot and its correlation channel now follow that module too. Refs #1079 --- ...easurement-form-resting-heart-rate.test.ts | 45 +++++++++++++++++++ .../measurements/measurement-form.tsx | 8 ++++ src/lib/modules/surface.ts | 8 ++++ 3 files changed, 61 insertions(+) create mode 100644 src/components/measurements/__tests__/measurement-form-resting-heart-rate.test.ts diff --git a/src/components/measurements/__tests__/measurement-form-resting-heart-rate.test.ts b/src/components/measurements/__tests__/measurement-form-resting-heart-rate.test.ts new file mode 100644 index 000000000..5a2074a91 --- /dev/null +++ b/src/components/measurements/__tests__/measurement-form-resting-heart-rate.test.ts @@ -0,0 +1,45 @@ +/** + * The manual-entry form offers the resting heart rate (#1079). + * + * Resting heart rate has its own chart and series, separate from spot pulse. + * A value read off a watch or taken on waking had no row in the form, so it + * could only be logged as a pulse, which feeds a different chart. + */ +import { describe, it, expect } from "vitest"; + +import { VALUE_RANGES, getUnitForType } from "@/lib/validations/measurement"; +import { + MEASUREMENT_FORM_TYPE_VALUES, + MEASUREMENT_TYPES, +} from "@/components/measurements/measurement-form"; +import en from "../../../../messages/en.json"; + +describe("manual entry of the resting heart rate", () => { + it("offers it as its own type, next to pulse", () => { + expect(MEASUREMENT_FORM_TYPE_VALUES).toContain("RESTING_HEART_RATE"); + const values = MEASUREMENT_TYPES.map((t) => t.value); + expect(values.indexOf("RESTING_HEART_RATE")).toBe( + values.indexOf("PULSE") + 1, + ); + }); + + it("uses the server's unit, a labelled row and a plausible placeholder", () => { + const row = MEASUREMENT_TYPES.find((t) => t.value === "RESTING_HEART_RATE"); + expect(row).toBeDefined(); + expect(row && "unit" in row ? row.unit : undefined).toBe( + getUnitForType("RESTING_HEART_RATE"), + ); + expect( + (en as { measurements: Record }).measurements + .typeRestingHeartRate, + ).toBeTruthy(); + const range = VALUE_RANGES.RESTING_HEART_RATE; + const placeholder = Number( + row && "placeholder" in row ? row.placeholder : NaN, + ); + if (range) { + expect(placeholder).toBeGreaterThanOrEqual(range.min); + expect(placeholder).toBeLessThanOrEqual(range.max); + } + }); +}); diff --git a/src/components/measurements/measurement-form.tsx b/src/components/measurements/measurement-form.tsx index 092bc2e7a..662a6c310 100644 --- a/src/components/measurements/measurement-form.tsx +++ b/src/components/measurements/measurement-form.tsx @@ -67,6 +67,14 @@ export const MEASUREMENT_TYPES = [ unit: "bpm", placeholder: "72", }, + // The resting heart rate has its own chart and series; a value read off a + // watch or taken on waking belongs there rather than among spot pulses. + { + value: "RESTING_HEART_RATE", + labelKey: "measurements.typeRestingHeartRate", + unit: "bpm", + placeholder: "58", + }, { value: "BODY_FAT", labelKey: "measurements.typeBodyFat", diff --git a/src/lib/modules/surface.ts b/src/lib/modules/surface.ts index e0d3ba08d..efc803457 100644 --- a/src/lib/modules/surface.ts +++ b/src/lib/modules/surface.ts @@ -100,6 +100,9 @@ const STATIC_SURFACE_MODULE = { "insights-page:six-minute-walk": "recovery", "insights-page:stair-ascent-speed": "recovery", "insights-page:stair-descent-speed": "recovery", + // The resting heart rate is the recovery module's `resting_hr` domain; its + // page, trend slot, correlation channel and summary key follow it. + "insights-page:resting-pulse": "recovery", // ── Insights overview blocks ── "overview:cycle-summary": "cycle", @@ -115,6 +118,7 @@ const STATIC_SURFACE_MODULE = { // ── Trends row ── "trend:mood": "mood", "trend:sleep": "sleep", + "trend:resting_hr": "recovery", // ── Add menu ── "capture:mood": "mood", @@ -141,6 +145,9 @@ const STATIC_SURFACE_MODULE = { "summary:SLEEP_DURATION": "sleep", "summary:BLOOD_GLUCOSE": "glucose", "summary:CARDIO_RECOVERY": "recovery", + // The recovery module owns the resting_hr domain (`measurement-scope.ts`), + // so the server gates the type on it; the form and the dashboard follow. + "summary:RESTING_HEART_RATE": "recovery", "summary:SIX_MINUTE_WALK_DISTANCE": "recovery", "summary:STAIR_ASCENT_SPEED": "recovery", "summary:STAIR_DESCENT_SPEED": "recovery", @@ -189,6 +196,7 @@ const STATIC_SURFACE_MODULE = { "correlation:MOOD": "mood", "correlation:SLEEP_DURATION": "sleep", "correlation:BLOOD_GLUCOSE": "glucose", + "correlation:RESTING_HEART_RATE": "recovery", "correlation:MEDICATION_COMPLIANCE": "medications", // Symptom burden is read from the illness journal. "correlation:SYMPTOM_SEVERITY": "illness", From 4120927a94debfe81b87de58c5d6c9d12558fc35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 22:00:12 +0200 Subject: [PATCH 3/3] fix(workouts): count a bridge's batches apart from the phone's A workouts:write token and the iPhone app shared one per-account bucket of 60 batches a minute, so a bridge stuck in a retry loop could use up the allowance the phone needs to sync the same account. Scoped tokens now count in a bucket of their own, shared by all bridge tokens of the account so that minting more tokens does not multiply the allowance. Refs #1054 --- .../workouts/__tests__/batch-scope.test.ts | 36 +++++++++++++++++++ src/app/api/workouts/batch/route.ts | 8 ++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/app/api/workouts/__tests__/batch-scope.test.ts b/src/app/api/workouts/__tests__/batch-scope.test.ts index 6fe7001f2..61a063093 100644 --- a/src/app/api/workouts/__tests__/batch-scope.test.ts +++ b/src/app/api/workouts/__tests__/batch-scope.test.ts @@ -207,6 +207,42 @@ describe("POST /api/workouts/batch — which credentials it admits", () => { }); }); +describe("POST /api/workouts/batch — rate limit buckets", () => { + function lastBucket(): string { + const calls = vi.mocked(checkRateLimit).mock.calls; + return calls[calls.length - 1][0] as string; + } + + it("counts a workouts:write token apart from the phone", async () => { + armToken([WORKOUTS_WRITE_SCOPE]); + await POST(makeRequest({ workouts: [workout("s-1")] })); + const bridge = lastBucket(); + + armToken(["*"]); + await POST( + makeRequest({ workouts: [workout("s-2", { source: "APPLE_HEALTH" })] }), + ); + const phone = lastBucket(); + + expect(bridge).not.toBe(phone); + expect(bridge).toContain(USER.id); + expect(phone).toContain(USER.id); + }); + + it("keeps a session and a wildcard token in the same bucket", async () => { + armSession(); + await POST( + makeRequest({ workouts: [workout("s-1", { source: "APPLE_HEALTH" })] }), + ); + const session = lastBucket(); + armToken(["*"]); + await POST( + makeRequest({ workouts: [workout("s-2", { source: "APPLE_HEALTH" })] }), + ); + expect(lastBucket()).toBe(session); + }); +}); + describe("POST /api/workouts/batch — what a scoped caller writes", () => { it("attributes its rows to EXTERNAL", async () => { armToken([WORKOUTS_WRITE_SCOPE]); diff --git a/src/app/api/workouts/batch/route.ts b/src/app/api/workouts/batch/route.ts index 9d03b0390..8c42af76a 100644 --- a/src/app/api/workouts/batch/route.ts +++ b/src/app/api/workouts/batch/route.ts @@ -232,8 +232,14 @@ async function postBatch(request: NextRequest): Promise { // database for every other user on the host. 60 batches/min/user is // generous for healthy iOS sync and tight enough that a misbehaving // client bottoms out within a minute. + // + // A scoped `workouts:write` token is a bridge, not the phone, so it counts + // in a bucket of its own: a bridge stuck in a retry loop must not use up + // the allowance the iPhone app needs to sync the same account's workouts. + // All bridge tokens of one account share that bucket, so minting more + // tokens does not multiply the allowance. const rl = await checkRateLimit( - `workouts:batch:${user.id}`, + scoped ? `workouts:batch:bridge:${user.id}` : `workouts:batch:${user.id}`, BATCH_RATE_LIMIT_MAX, BATCH_RATE_LIMIT_WINDOW_MS, );