diff --git a/src/lib/validations/__tests__/workout.test.ts b/src/lib/validations/__tests__/workout.test.ts index c8aa98ada..98d8b1a7b 100644 --- a/src/lib/validations/__tests__/workout.test.ts +++ b/src/lib/validations/__tests__/workout.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from "vitest"; import { + createBatchWorkoutSchema, createWorkoutSchema, geoJsonLineStringSchema, + MAX_ROUTE_POINTS, + MAX_WORKOUTS_PER_BATCH, workoutRouteSamplesSchema, workoutSportTypeEnum, type CreateWorkoutInput, @@ -167,4 +170,107 @@ describe("createWorkoutSchema", () => { createWorkoutSchema.parse({ ...minimalRun, maxHeartRate: 5 }), ).toThrow(); }); + + it("rejects a route geometry that exceeds the per-route point cap", () => { + // One past the cap — schema MUST reject. The cap exists so a single + // pathological workout can't blow the 5 MB request-body ceiling on + // its own; a desync between the schema cap and the route route's + // expected-size accounting would silently degrade ingest. + const coords: [number, number][] = []; + for (let i = 0; i < MAX_ROUTE_POINTS + 1; i++) { + coords.push([11 + i * 1e-6, 49 + i * 1e-6]); + } + expect(() => + createWorkoutSchema.parse({ + ...minimalRun, + route: { + geometry: { type: "LineString", coordinates: coords }, + }, + }), + ).toThrow(/20000-point cap/); + }); + + it("rejects a route whose sampleTimestamps length mismatches coordinates", () => { + // Cross-field invariant: per-sample HR / speed must line up against + // the parallel coordinate index, so a desynced pair silently + // poisons downstream analytics. Hard-fail at parse time so the iOS + // mapper surfaces the problem immediately. + expect(() => + createWorkoutSchema.parse({ + ...minimalRun, + route: { + geometry: { + type: "LineString", + coordinates: [ + [11.077, 49.452], + [11.078, 49.453], + [11.079, 49.454], + ], + }, + sampleTimestamps: [ + { t: "2026-05-14T06:30:00.000Z" }, + { t: "2026-05-14T06:30:05.000Z" }, + // missing third entry — desync the parser MUST catch + ], + }, + }), + ).toThrow(/sampleTimestamps length must match/); + }); + + it("accepts a route when sampleTimestamps length equals coordinates length", () => { + const parsed = createWorkoutSchema.parse({ + ...minimalRun, + route: { + geometry: { + type: "LineString", + coordinates: [ + [11.077, 49.452], + [11.078, 49.453], + ], + }, + sampleTimestamps: [ + { t: "2026-05-14T06:30:00.000Z", speedMs: 3.2, hr: 142 }, + { t: "2026-05-14T06:30:05.000Z", speedMs: 3.3, hr: 144 }, + ], + }, + }); + expect(parsed.route?.sampleTimestamps).toHaveLength(2); + }); +}); + +describe("createBatchWorkoutSchema", () => { + const minimalRun = (id: string) => ({ + sportType: "running", + startedAt: "2026-05-14T06:30:00.000Z", + endedAt: "2026-05-14T07:15:00.000Z", + externalId: id, + }); + + it("accepts a single-workout batch", () => { + const parsed = createBatchWorkoutSchema.parse({ + workouts: [minimalRun("hk-uuid-001")], + }); + expect(parsed.workouts).toHaveLength(1); + }); + + it("accepts a batch at the cap (100 workouts)", () => { + const workouts = Array.from( + { length: MAX_WORKOUTS_PER_BATCH }, + (_, i) => minimalRun(`hk-uuid-${i}`), + ); + const parsed = createBatchWorkoutSchema.parse({ workouts }); + expect(parsed.workouts).toHaveLength(MAX_WORKOUTS_PER_BATCH); + }); + + it("rejects a batch above the cap", () => { + const workouts = Array.from( + { length: MAX_WORKOUTS_PER_BATCH + 1 }, + (_, i) => minimalRun(`hk-uuid-${i}`), + ); + expect(() => createBatchWorkoutSchema.parse({ workouts })).toThrow(); + }); + + it("rejects an empty batch", () => { + expect(() => createBatchWorkoutSchema.parse({ workouts: [] })).toThrow(); + }); }); diff --git a/src/lib/validations/workout.ts b/src/lib/validations/workout.ts index cd5061d2c..15a440d97 100644 --- a/src/lib/validations/workout.ts +++ b/src/lib/validations/workout.ts @@ -43,6 +43,16 @@ export const workoutSportTypeEnum = z.enum([ export type WorkoutSportType = z.infer; +/** + * Upper bound on points per route LineString. A 24h ultra at 1Hz lands + * at 86 400 points; 20 000 covers any normal run / ride / hike while + * still rejecting pathological payloads (an attacker shipping a 5 MB + * route per workout * 100 workouts/batch * unbounded retries). The + * route geometry is the largest tail in the batch payload — keeping it + * bounded keeps the total request body bounded with it. + */ +export const MAX_ROUTE_POINTS = 20_000; + /** * GeoJSON LineString — the WorkoutRoute.geometry shape. RFC 7946 §3.1.4 * with one HealthLog-specific allowance: the altitude component on @@ -57,7 +67,11 @@ export const geoJsonLineStringSchema = z.object({ .tuple([z.number().min(-180).max(180), z.number().min(-90).max(90)]) .rest(z.number()), ) - .min(2, "LineString requires at least 2 points"), + .min(2, "LineString requires at least 2 points") + .max( + MAX_ROUTE_POINTS, + `LineString exceeds the ${MAX_ROUTE_POINTS}-point cap`, + ), }); export type GeoJsonLineString = z.infer; @@ -78,33 +92,77 @@ export const workoutRouteSamplesSchema = z.array( export type WorkoutRouteSamples = z.infer; /** - * Workout insert payload — the shape `POST /api/measurements/batch?kind=workout` - * will accept once that endpoint lands (v1.5). Defined here so the iOS - * Swift session can generate its DTO against the locked contract and - * the v1.4.26 XML import worker has the same target. + * Workout insert payload — the shape `POST /api/workouts/batch` accepts. + * Defined here so the iOS Swift session can generate its DTO against the + * locked contract and the v1.4.26 XML import worker has the same target. + * + * Cross-field invariants enforced via `.superRefine()`: + * - When `route.sampleTimestamps` is present, its length MUST equal + * `route.geometry.coordinates.length`. A desynced pair silently + * degrades downstream analytics (per-sample HR / speed would line + * up against the wrong coordinate), so we hard-fail at parse time. */ -export const createWorkoutSchema = z.object({ - sportType: workoutSportTypeEnum, - startedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)), - endedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)), - totalEnergyKcal: z.number().min(0).max(50000).optional(), - totalDistanceM: z.number().min(0).max(1_000_000).optional(), - avgHeartRate: z.number().int().min(20).max(300).optional(), - maxHeartRate: z.number().int().min(20).max(300).optional(), - minHeartRate: z.number().int().min(20).max(300).optional(), - 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"), - externalId: z.string().max(128).optional(), - externalSourceVersion: z.string().max(64).optional(), - metadata: z.record(z.string(), z.unknown()).optional(), - route: z - .object({ - geometry: geoJsonLineStringSchema, - sampleTimestamps: workoutRouteSamplesSchema.optional(), - }) - .optional(), -}); +export const createWorkoutSchema = z + .object({ + sportType: workoutSportTypeEnum, + startedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)), + endedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)), + totalEnergyKcal: z.number().min(0).max(50000).optional(), + totalDistanceM: z.number().min(0).max(1_000_000).optional(), + avgHeartRate: z.number().int().min(20).max(300).optional(), + maxHeartRate: z.number().int().min(20).max(300).optional(), + minHeartRate: z.number().int().min(20).max(300).optional(), + 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"), + externalId: z.string().max(128).optional(), + externalSourceVersion: z.string().max(64).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + route: z + .object({ + geometry: geoJsonLineStringSchema, + sampleTimestamps: workoutRouteSamplesSchema.optional(), + }) + .optional(), + }) + .superRefine((value, ctx) => { + const samples = value.route?.sampleTimestamps; + const coords = value.route?.geometry.coordinates; + if (samples && coords && samples.length !== coords.length) { + ctx.addIssue({ + code: "custom", + path: ["route", "sampleTimestamps"], + message: + "sampleTimestamps length must match geometry.coordinates length", + }); + } + }); export type CreateWorkoutInput = z.infer; + +/** + * Upper bound on workouts per batch ingest call. Workouts are heavier + * than measurements (each may carry a 20 000-point route geometry), so + * the cap is an order of magnitude tighter than the 500-entry + * measurements batch. A cold-start iOS HealthKit backfill is "every + * workout I've ever recorded" — 100 covers a healthy multi-year history + * and rejects pathological cases without forcing pagination on the + * happy path. + */ +export const MAX_WORKOUTS_PER_BATCH = 100; + +/** + * Batch ingest payload — `{ workouts: [createWorkoutSchema, ...] }`. + * Mirrors `createBatchMeasurementSchema` shape so the iOS sync engine + * can re-use the same retry / cursor plumbing. Each entry is a typed + * workout; an optional nested `route` carries the GeoJSON LineString. + */ +export const createBatchWorkoutSchema = z.object({ + workouts: z + .array(createWorkoutSchema) + .min(1) + .max(MAX_WORKOUTS_PER_BATCH), +}); + +export type CreateBatchWorkoutInput = z.infer;