fix(measurements): validate single-POST source against the writable allowlist

WRITABLE_MEASUREMENT_SOURCES was a dead export whose docblock claimed it
gated the single-entry POST, but the schema only refined out COMPUTED —
WITHINGS and IMPORT stayed client-writable, so a client could attribute a row
to a source the server owns (the Withings webhook / CSV importer) and pollute
the per-source canonical picker. Wire createMeasurementSchema.source to a Zod
enum built from the now-correct {MANUAL, APPLE_HEALTH} allowlist; the enum
subsumes the old COMPUTED refine. Regenerate the OpenAPI contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-02 23:40:24 +02:00
1 parent dabdfdf68e
commit f170198a83
3 files changed
+57 -37

No files matched your search

-3
View File
@@ -555,10 +555,7 @@ paths:
type: string
enum:
- MANUAL
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
glucoseContext:
type: string
enum:
@@ -287,34 +287,49 @@ describe("measurement validation", () => {
expect(validateMeasurementRange("RECOVERY_SCORE", 101)).not.toBeNull();
});
it("rejects the server-owned COMPUTED source on a client write", () => {
const parsed = createMeasurementSchema.safeParse({
type: "RECOVERY_SCORE",
value: 72,
measuredAt: "2026-06-02T12:00:00Z",
source: "COMPUTED",
});
expect(parsed.success).toBe(false);
if (!parsed.success) {
const issue = parsed.error.issues.find((i) =>
i.path.includes("source"),
);
expect(issue, "expected a source-path rejection").toBeDefined();
expect(issue!.message).toMatch(/server-owned/i);
it("rejects every non-writable source on a client write", () => {
// v1.10.0 QA — the single-POST validates `source` against the
// client-writable subset {MANUAL, APPLE_HEALTH}. COMPUTED (server
// scores), WITHINGS (the Withings webhook), and IMPORT (the CSV
// importer) are all server-owned and must be rejected so a client
// cannot forge a row attributed to a source it does not own.
for (const source of ["COMPUTED", "WITHINGS", "IMPORT"]) {
const parsed = createMeasurementSchema.safeParse({
type: "WEIGHT",
value: 80,
measuredAt: "2026-06-02T12:00:00Z",
source,
});
expect(parsed.success, `expected ${source} to be rejected`).toBe(false);
if (!parsed.success) {
const issue = parsed.error.issues.find((i) =>
i.path.includes("source"),
);
expect(issue, `expected a source-path rejection for ${source}`).toBeDefined();
}
}
});
it("still accepts a client-writable source for a score-typed manual row", () => {
// The COMPUTED source is the only one this refine blocks; a MANUAL
// write of any type stays valid (the source guard is orthogonal to
// whether a UI ever offers manual score entry).
it("accepts the client-writable sources MANUAL and APPLE_HEALTH", () => {
for (const source of ["MANUAL", "APPLE_HEALTH"]) {
const parsed = createMeasurementSchema.safeParse({
type: "WEIGHT",
value: 80,
measuredAt: "2026-06-02T12:00:00Z",
source,
});
expect(parsed.success, `expected ${source} to be accepted`).toBe(true);
}
});
it("defaults source to MANUAL when omitted", () => {
const parsed = createMeasurementSchema.safeParse({
type: "RECOVERY_SCORE",
value: 72,
type: "WEIGHT",
value: 80,
measuredAt: "2026-06-02T12:00:00Z",
source: "MANUAL",
});
expect(parsed.success).toBe(true);
if (parsed.success) expect(parsed.data.source).toBe("MANUAL");
});
});
});
+21 -13
View File
@@ -147,11 +147,24 @@ export const measurementSourceEnum = z.enum([
*/
export const WRITABLE_MEASUREMENT_SOURCES = [
"MANUAL",
"WITHINGS",
"IMPORT",
"APPLE_HEALTH",
] as const;
/**
* v1.10.0 QA — the Zod enum the single-entry POST validates `source` against.
* Built from `WRITABLE_MEASUREMENT_SOURCES` so the allowlist has one source of
* truth. A client may only attribute a row it actually owns: `MANUAL` (a
* hand-entered reading) or `APPLE_HEALTH` (the HealthKit batch's per-row
* source). `WITHINGS` is owned by the Withings webhook, `IMPORT` by the CSV
* importer, and `COMPUTED` by the nightly score engines — letting a client
* forge any of those would pollute the per-source canonical picker with rows
* the server never produced. The batch route mirrors the same exclusion with
* its own narrower `{APPLE_HEALTH, MANUAL}` `batchSourceEnum`.
*/
export const writableMeasurementSourceEnum = z.enum(
WRITABLE_MEASUREMENT_SOURCES,
);
const unitMap: Record<string, string> = {
WEIGHT: "kg",
BLOOD_PRESSURE_SYS: "mmHg",
@@ -428,7 +441,11 @@ export const createMeasurementSchema = z
value: z.number(),
measuredAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
notes: z.string().max(MEASUREMENT_NOTES_MAX_LENGTH).optional(),
source: measurementSourceEnum.optional().default("MANUAL"),
// v1.10.0 QA — validate against the client-writable subset, not the full
// `MeasurementSource` enum. A client may attribute only `MANUAL` or
// `APPLE_HEALTH`; `WITHINGS` / `IMPORT` / `COMPUTED` are server-owned and
// forging them would pollute the per-source canonical picker.
source: writableMeasurementSourceEnum.optional().default("MANUAL"),
// Only applies when type === BLOOD_GLUCOSE. Mirrored by a CHECK
// constraint in Postgres (see migration 0021).
glucoseContext: glucoseContextEnum.optional(),
@@ -453,16 +470,7 @@ export const createMeasurementSchema = z
"Blood glucose measurements require a context (fasting/postprandial/random/bedtime); other types must not set one.",
path: ["glucoseContext"],
},
)
// v1.10.0 — computed scores (WX-C). The `COMPUTED` source is server-owned:
// a nightly engine mints those rows, a client never does. Reject it here
// so the single-entry POST cannot forge a server-derived row (the batch
// route enforces the same exclusion through its `{APPLE_HEALTH, MANUAL}`
// allowlist).
.refine((data) => data.source !== "COMPUTED", {
message: "The COMPUTED source is server-owned and cannot be set by a client.",
path: ["source"],
});
);
export const updateMeasurementSchema = z.object({
value: z.number().min(0).max(500000).optional(),