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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-01 23:45:08 +02:00
1 parent 86d80d2c04
commit 9aa0a3597a
4 files changed
+38 -14

No files matched your search

-10
View File
@@ -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
+18 -2
View File
@@ -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,
@@ -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 }),
+8 -2
View File
@@ -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(),