From 7ca265745421eb4c7568233160ac85375430026d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 2 Jul 2026 00:30:19 +0200 Subject: [PATCH] fix(api): keep logout resilient and drop the over-strict batch time bound A transient database fault during logout must still clear the session cookie; record the delete failure on the wide event instead of throwing before the cookie is cleared, so a user can never stay authenticated after logging out. Remove the batch measurement future/past instant refine: cumulative HealthKit rows carry an end-of-day endDate that is legitimately hours ahead for the current day, and a whole-array parse would 422 the entire sync. Note the workout batch's deferred Withings path must write its source directly. --- src/app/api/measurements/batch/route.ts | 20 ++------------------ src/app/api/workouts/batch/route.ts | 8 ++++++-- src/lib/auth/__tests__/session.test.ts | 8 ++++++-- src/lib/auth/session.ts | 13 +++++++++---- 4 files changed, 23 insertions(+), 26 deletions(-) diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index aac262593..3748b429c 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -60,7 +60,6 @@ import { type MergeCandidate, } from "@/lib/measurements/cross-source-merge"; import { validateMeasurementRange } from "@/lib/validations/measurement"; -import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant"; import { deviceTypeEnum } from "@/lib/validations/source-priority"; import { checkRateLimit } from "@/lib/rate-limit"; import { enqueuePrDetection } from "@/lib/jobs/pr-detection"; @@ -116,23 +115,8 @@ const batchEntrySchema = z.object({ hkIdentifier: z.string().min(1).max(120), value: z.number().finite(), unit: z.string().min(1).max(60), - // Same plausibility bound the single-entry POST applies to `measuredAt` - // via `validateEntryInstant` (shared `entry-instant` helper): no future - // instant beyond a 5-min clock-skew tolerance, nothing before 1900. The - // batch keeps the ISO string (`mapAppleHealthEntry` parses it), so the - // bound rides on a `.refine` over the parsed instant rather than a - // transform — the wire shape is unchanged and the `stats:` overwrite - // semantics downstream are untouched. - startDate: z.iso - .datetime({ offset: true }) - .refine((s) => isPlausibleEntryInstant(new Date(s)), { - message: "startDate must not be in the future or predate 1900", - }), - endDate: z.iso - .datetime({ offset: true }) - .refine((s) => isPlausibleEntryInstant(new Date(s)), { - message: "endDate must not be in the future or predate 1900", - }), + startDate: z.iso.datetime({ offset: true }), + endDate: z.iso.datetime({ offset: true }), sleepStage: z.number().int().min(0).max(20).optional(), // v1.10.0 — categorical events (WX-B). The integer `HKCategoryValue` // codepoint for an EVENT-class category sample (irregular-rhythm, diff --git a/src/app/api/workouts/batch/route.ts b/src/app/api/workouts/batch/route.ts index a490b2e3d..05fe3c853 100644 --- a/src/app/api/workouts/batch/route.ts +++ b/src/app/api/workouts/batch/route.ts @@ -7,10 +7,14 @@ * shape locked by `createBatchWorkoutSchema` in * `src/lib/validations/workout.ts`. * 2. The Withings activity sync (W17b, deferred) — server-to-server - * ingest with `source: "WITHINGS"` and `externalId` formatted as + * ingest with `externalId` formatted as * `"${withings.id}:${withings.model_id ?? "0"}:${withings.startdate}"`. * Withings ships no route geometry, so the nested `route` is - * always absent for WITHINGS-sourced entries. + * always absent for WITHINGS-sourced entries. NOTE: the request + * schema's `source` is narrowed to the client-writable set + * (MANUAL / APPLE_HEALTH), so this server path must write its + * `WITHINGS`-sourced rows directly via `prisma.workout.upsert` + * (as the WHOOP / Fitbit syncs already do), not through this route. * * Cross-cutting concerns mirror `/api/measurements/batch` so the iOS * sync engine can re-use the same retry / cursor plumbing: diff --git a/src/lib/auth/__tests__/session.test.ts b/src/lib/auth/__tests__/session.test.ts index 57a01f035..ee27a18aa 100644 --- a/src/lib/auth/__tests__/session.test.ts +++ b/src/lib/auth/__tests__/session.test.ts @@ -91,13 +91,17 @@ describe("destroySession", () => { expect(cookieState.delete).toHaveBeenCalledWith("hl_onboarding"); }); - it("propagates a real delete failure instead of reporting a false logout", async () => { + it("clears the cookie even when the row delete fails on a transient fault", async () => { cookieState.sessionId = "sess-live"; vi.mocked(prisma.session.delete).mockRejectedValue( Object.assign(new Error("connection reset"), { code: "P1001" }) as never, ); - await expect(destroySession()).rejects.toThrow(/connection reset/); + // A non-P2025 delete failure is recorded on the wide event, not thrown, + // so logout never leaves the client authenticated with the cookie intact. + await expect(destroySession()).resolves.toBeUndefined(); + expect(cookieState.delete).toHaveBeenCalledWith("healthlog_session"); + expect(cookieState.delete).toHaveBeenCalledWith("hl_onboarding"); }); }); diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index 7dd461668..4e82e949a 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -181,11 +181,16 @@ export async function destroySession(): Promise { const sessionId = cookieStore.get(SESSION_COOKIE)?.value; if (sessionId) { // Logout is idempotent: a session row that no longer exists (P2025) is a - // no-op success — the cookie clear below still runs. Any other failure is - // a real delete error that must NOT be masked as a successful logout, so - // it propagates rather than being swallowed. + // no-op. Any other failure is a real delete error — record it on the wide + // event rather than swallowing it silently, but never block the cookie + // clear below: a transient DB fault must not leave the client logged in + // with the cookie intact. await prisma.session.delete({ where: { id: sessionId } }).catch((err) => { - if (!isP2025(err)) throw err; + if (!isP2025(err)) { + getEvent()?.addWarning( + `destroySession delete failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } }); } cookieStore.delete(SESSION_COOKIE);