mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
86d80d2c04
commit
9aa0a3597a
4 files changed
+38
-14
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user