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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-02 00:30:19 +02:00
1 parent ba8118546d
commit 7ca2657454
4 files changed
+23 -26

No files matched your search

+2 -18
View File
@@ -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,
+6 -2
View File
@@ -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:
+6 -2
View File
@@ -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");
});
});
+9 -4
View File
@@ -181,11 +181,16 @@ export async function destroySession(): Promise<void> {
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);