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/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, ); 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", 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({