From 9aa0a3597a9327739f8a3cf1edb9b06f4ab38fbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 1 Jul 2026 23:45:08 +0200 Subject: [PATCH] fix(api): constrain client-writable sources and batch timestamps Narrow the workout create schema's source to the client-writable set (MANUAL, APPLE_HEALTH), matching the measurement write surfaces, so a client cannot forge a server-owned source into the cross-source picker. Apply the single-entry plausibility bound to the measurement batch timestamps so a backdated or future-dated row can no longer slip past the batch path. Regenerate the OpenAPI contract. --- docs/api/openapi.yaml | 10 ---------- src/app/api/measurements/batch/route.ts | 20 +++++++++++++++++-- src/lib/validations/__tests__/workout.test.ts | 12 +++++++++++ src/lib/validations/workout.ts | 10 ++++++++-- 4 files changed, 38 insertions(+), 14 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 73ee36bbd..e553979c5 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2664,17 +2664,7 @@ paths: type: string enum: - MANUAL - - WITHINGS - - IMPORT - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP externalId: type: string maxLength: 128 diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 3748b429c..aac262593 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -60,6 +60,7 @@ 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"; @@ -115,8 +116,23 @@ const batchEntrySchema = z.object({ hkIdentifier: z.string().min(1).max(120), value: z.number().finite(), unit: z.string().min(1).max(60), - startDate: z.iso.datetime({ offset: true }), - endDate: z.iso.datetime({ offset: true }), + // 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", + }), 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/lib/validations/__tests__/workout.test.ts b/src/lib/validations/__tests__/workout.test.ts index dd7904542..5ca5e9812 100644 --- a/src/lib/validations/__tests__/workout.test.ts +++ b/src/lib/validations/__tests__/workout.test.ts @@ -217,6 +217,18 @@ describe("createWorkoutSchema", () => { ).toThrow(); }); + it("rejects a server-owned source a client cannot forge", () => { + // The client-writable set is exactly {MANUAL, APPLE_HEALTH}. WHOOP / + // FITBIT rows are minted server-side by the sync workers; letting a + // client attribute a row to them would pollute the per-source canonical + // workout picker with rows the integration never produced. + for (const forged of ["WHOOP", "FITBIT", "WITHINGS", "COMPUTED"]) { + expect(() => + createWorkoutSchema.parse({ ...minimalRun, source: forged }), + ).toThrow(); + } + }); + it("rejects a max heart rate below the resting floor", () => { expect(() => createWorkoutSchema.parse({ ...minimalRun, maxHeartRate: 5 }), diff --git a/src/lib/validations/workout.ts b/src/lib/validations/workout.ts index 2aa0636bd..dfd25584e 100644 --- a/src/lib/validations/workout.ts +++ b/src/lib/validations/workout.ts @@ -1,5 +1,5 @@ import { z } from "zod/v4"; -import { measurementSourceEnum } from "./measurement"; +import { writableMeasurementSourceEnum } from "./measurement"; /** * Workout sport-type union. @@ -166,7 +166,13 @@ export const createWorkoutSchema = z stepCount: z.number().int().min(0).max(200_000).optional(), elevationM: z.number().min(-500).max(10_000).optional(), pauseDurationSec: z.number().int().min(0).max(86_400).optional(), - source: measurementSourceEnum.optional().default("MANUAL"), + // Narrowed to the client-writable subset ({MANUAL, APPLE_HEALTH}) — the + // same allowlist the measurement writes use (`WRITABLE_MEASUREMENT_SOURCES` + // / `batchSourceEnum`). Server-owned sources (WHOOP, FITBIT) write directly + // via `prisma.workout.upsert` with a hardcoded `source`, never through this + // schema, so a client cannot forge a row attributed to an integration it + // does not own. + source: writableMeasurementSourceEnum.optional().default("MANUAL"), externalId: z.string().max(128).optional(), externalSourceVersion: z.string().max(64).optional(), metadata: z.record(z.string(), z.unknown()).optional(),