feat(validation): bound entry timestamps with a shared plausibility guard

Only medication intake bounded its capture instant — a hand-rolled POST or a
faulty import could backdate a measurement to epoch zero or forward-date a
mood entry into next year, and both sailed through validation.

Extract the intake bound into a shared `validateEntryInstant` helper: reject
any instant more than a five-minute clock-skew tolerance in the future, and
reject anything before a 1900 far-past floor. Callers can clamp the past
window tighter — medication intake keeps its five-year floor — and override
the past-bound message to preserve an established contract.

Apply it to the measurement create/edit schema (`measuredAt`), the mood
schema (`moodLoggedAt`), and the cycle period + day-log (`loggedAt`);
medication intake now delegates to the same helper. The capture forms gain a
matching `max` on the datetime picker so a future instant can't be selected
in the UI either. The wire shape is unchanged, so the OpenAPI contract holds.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 13:51:51 +02:00
1 parent e9138bb4f6
commit 3e7aa49eb0
12 files changed
+323 -23

No files matched your search

@@ -472,6 +472,10 @@ export function MeasurementForm({
id="measuredAt"
value={measuredAt}
onChange={(e) => setMeasuredAt(e.target.value)}
// v1.17 W1b — mirror the server-side plausibility bound: the
// picker cannot select a future instant. Matches the
// `validateEntryInstant` refine on `measuredAt`.
max={getDefaultMeasuredAtValue()}
required
aria-required="true"
aria-invalid={!!error || undefined}
@@ -1273,6 +1273,8 @@ export function MeasurementList({
id="edit-measuredAt"
value={editMeasuredAt}
onChange={(e) => setEditMeasuredAt(e.target.value)}
// v1.17 W1b — match the server bound: no future instant.
max={toDateTimeLocalValue(new Date().toISOString())}
required
aria-required="true"
aria-invalid={!!editError || undefined}
+2
View File
@@ -328,6 +328,8 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
id="mood-logged-at"
value={moodLoggedAt}
onChange={(e) => setMoodLoggedAt(e.target.value)}
// v1.17 W1b — match the server bound: no future instant.
max={getDefaultMoodLoggedAtValue()}
required
aria-required="true"
aria-invalid={!!error || undefined}
+2
View File
@@ -842,6 +842,8 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
id="edit-mood-logged-at"
value={editMoodLoggedAt}
onChange={(e) => setEditMoodLoggedAt(e.target.value)}
// v1.17 W1b — match the server bound: no future instant.
max={toDateTimeLocalValue(new Date().toISOString())}
required
/>
</div>
@@ -0,0 +1,90 @@
import { describe, it, expect } from "vitest";
import { z } from "zod/v4";
import {
isPlausibleEntryInstant,
validateEntryInstant,
ENTRY_INSTANT_CLOCK_SKEW_MS,
ENTRY_INSTANT_FAR_PAST,
} from "../entry-instant";
const NOW = Date.parse("2026-06-14T12:00:00.000Z");
describe("isPlausibleEntryInstant", () => {
it("accepts a recent past instant", () => {
expect(
isPlausibleEntryInstant(new Date(NOW - 60_000), { now: NOW }),
).toBe(true);
});
it("accepts an instant inside the clock-skew tolerance", () => {
expect(
isPlausibleEntryInstant(
new Date(NOW + ENTRY_INSTANT_CLOCK_SKEW_MS - 1),
{ now: NOW },
),
).toBe(true);
});
it("rejects a future instant beyond the skew tolerance", () => {
expect(
isPlausibleEntryInstant(
new Date(NOW + ENTRY_INSTANT_CLOCK_SKEW_MS + 60_000),
{ now: NOW },
),
).toBe(false);
});
it("rejects an instant before the 1900 far-past floor", () => {
expect(
isPlausibleEntryInstant(
new Date(ENTRY_INSTANT_FAR_PAST.getTime() - 1),
{ now: NOW },
),
).toBe(false);
});
it("accepts a deeply-backdated instant when no maxAge is set", () => {
expect(
isPlausibleEntryInstant(new Date("1950-03-04T00:00:00.000Z"), {
now: NOW,
}),
).toBe(true);
});
it("honours a tighter maxAge window", () => {
const fiveYears = 5 * 365 * 24 * 60 * 60 * 1000;
expect(
isPlausibleEntryInstant(new Date(NOW - fiveYears + 60_000), {
now: NOW,
maxAgeMs: fiveYears,
}),
).toBe(true);
expect(
isPlausibleEntryInstant(new Date(NOW - fiveYears - 60_000), {
now: NOW,
maxAgeMs: fiveYears,
}),
).toBe(false);
});
});
describe("validateEntryInstant — Zod integration", () => {
const schema = validateEntryInstant(
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
);
it("rejects a future-dated instant", () => {
const future = new Date(Date.now() + 60 * 60 * 1000).toISOString();
expect(schema.safeParse(future).success).toBe(false);
});
it("accepts a sane backdated instant", () => {
const past = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
expect(schema.safeParse(past).success).toBe(true);
});
it("rejects an instant before 1900", () => {
expect(schema.safeParse("1899-12-31T00:00:00.000Z").success).toBe(false);
});
});
@@ -132,6 +132,39 @@ describe("measurement validation", () => {
expect(parsed.success).toBe(false);
});
// v1.17 W1b — shared `validateEntryInstant` plausibility bound.
it("rejects a future-dated measurement", () => {
const future = new Date(Date.now() + 60 * 60 * 1000).toISOString();
const parsed = createMeasurementSchema.safeParse({
...validBase,
type: "WEIGHT",
value: 80,
measuredAt: future,
});
expect(parsed.success).toBe(false);
});
it("accepts a sane backdated measurement", () => {
const past = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
const parsed = createMeasurementSchema.safeParse({
...validBase,
type: "WEIGHT",
value: 80,
measuredAt: past,
});
expect(parsed.success).toBe(true);
});
it("rejects a measurement dated before 1900", () => {
const parsed = createMeasurementSchema.safeParse({
...validBase,
type: "WEIGHT",
value: 80,
measuredAt: "1899-06-01T00:00:00.000Z",
});
expect(parsed.success).toBe(false);
});
// v1.4.25 W10 reconcile (code-review M4): `deviceType` was a
// batch-only field; the single-entry schema silently dropped it.
// Now it parses through so the column is populated whether the
@@ -44,6 +44,31 @@ describe("createMoodEntrySchema — note + structured tagKeys (v1.8.5)", () => {
expect(r.success).toBe(true);
if (r.success) expect(r.data.tagKeys).toBeNull();
});
// v1.17 W1b — shared `validateEntryInstant` plausibility bound.
it("rejects a future-dated mood entry", () => {
const future = new Date(Date.now() + 60 * 60 * 1000).toISOString();
expect(
createMoodEntrySchema.safeParse({ ...base, moodLoggedAt: future })
.success,
).toBe(false);
});
it("accepts a sane backdated mood entry", () => {
const past = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
expect(
createMoodEntrySchema.safeParse({ ...base, moodLoggedAt: past }).success,
).toBe(true);
});
it("rejects a mood entry dated before 1900", () => {
expect(
createMoodEntrySchema.safeParse({
...base,
moodLoggedAt: "1899-12-31T00:00:00.000Z",
}).success,
).toBe(false);
});
});
describe("moodLogCredentialsSchema SSRF guard", () => {
+18 -3
View File
@@ -10,6 +10,21 @@
* instants are ISO-8601 with offset.
*/
import { z } from "zod/v4";
import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant";
/**
* v1.17 W1b — `loggedAt` instant on the cycle wire stays a string (the iOS
* contract reads it back verbatim), so the plausibility bound is applied as
* a `.refine` over the parsed instant rather than the date-typed
* `validateEntryInstant` wrapper. Same `isPlausibleEntryInstant` predicate
* the measurement / mood / medication-intake paths use: no future instant
* beyond the 5-min skew, no instant before 1900.
*/
const boundedLoggedAt = z.iso
.datetime({ offset: true })
.refine((s) => isPlausibleEntryInstant(new Date(s)), {
message: "loggedAt must be a plausible instant (not future, not pre-1900)",
});
/* ── enums (mirror the Prisma enums) ─────────────────────────────── */
@@ -105,7 +120,7 @@ export const cycleDayLogInputSchema = z.object({
.max(40)
.optional(),
note: z.string().max(500).optional(),
loggedAt: z.iso.datetime({ offset: true }),
loggedAt: boundedLoggedAt,
source: cycleSourceEnum.optional().default("MANUAL"),
externalId: z.string().min(1).max(120).optional(),
});
@@ -134,7 +149,7 @@ export const cycleDayLogPatchSchema = z.object({
.max(40)
.optional(),
note: z.string().max(500).nullable().optional(),
loggedAt: z.iso.datetime({ offset: true }).optional(),
loggedAt: boundedLoggedAt.optional(),
});
export const MAX_CYCLE_BULK_ENTRIES = 500;
@@ -149,7 +164,7 @@ export const cyclePeriodSchema = z.object({
action: z.enum(["start", "end"]),
date: dateString,
externalId: z.string().min(1).max(120).optional(),
loggedAt: z.iso.datetime({ offset: true }),
loggedAt: boundedLoggedAt,
});
/* ── calendar + history queries ──────────────────────────────────── */
+106
View File
@@ -0,0 +1,106 @@
/**
* Shared entry-instant plausibility bound (v1.17 W1b).
*
* Medication intake (v1.15.19 / v1.16.9) already rejects a `takenAt` that
* lies in the future or implausibly far in the past, so the retro-add
* dialog and a hand-rolled API call cannot insert a row a decade ahead or
* a century behind. The data-portability audit found the other capture
* paths — `Measurement.measuredAt` and `MoodEntry.moodLoggedAt` — accepted
* arbitrary instants, future dates included.
*
* This module hoists the intake bound into one reusable validator every
* timestamped capture path applies:
*
* - **No future instants** beyond a small clock-skew tolerance
* (`ENTRY_INSTANT_CLOCK_SKEW_MS`, +5 min). A client clock running a
* few minutes fast is tolerated; a genuinely future-dated entry is a
* bug or an import error and is rejected.
* - **A sane past floor.** Entries before `ENTRY_INSTANT_FAR_PAST` (the
* start of 1900) are rejected — no real-world reading predates it, and
* the floor catches epoch-zero / corrupted-import timestamps. Callers
* that want a tighter window (medication intake keeps its five-year
* floor) pass `maxAgeMs` to clamp the past bound nearer to now.
*
* Pure helpers + a Zod `superRefine` factory so the same bound can sit on
* any schema's instant field with a stable per-field error path.
*/
import type { z } from "zod/v4";
/** Tolerated future skew for a client clock running fast (5 minutes). */
export const ENTRY_INSTANT_CLOCK_SKEW_MS = 5 * 60 * 1000;
/**
* Absolute far-past floor: 1900-01-01T00:00:00Z. No genuine health reading
* predates it; the floor rejects epoch-zero / corrupted-import instants.
*/
export const ENTRY_INSTANT_FAR_PAST = new Date("1900-01-01T00:00:00.000Z");
export interface EntryInstantBounds {
/**
* Optional tighter past window in milliseconds. When set, the past floor
* is `max(ENTRY_INSTANT_FAR_PAST, now - maxAgeMs)` — medication intake
* passes its five-year window here so a stale backdated dose is rejected
* well before the 1900 floor.
*/
maxAgeMs?: number;
/** Clock anchor; defaults to `Date.now()`. Injectable for tests. */
now?: number;
/**
* Optional override for the past-bound violation message. Lets a caller
* keep an established wording (e.g. medication intake's "within the last
* 5 years") so existing contracts / tests stay stable.
*/
pastMessage?: string;
}
/**
* `true` when `instant` is a plausible capture time: not in the future
* beyond the skew tolerance, and not before the (optionally clamped) past
* floor. Shared by the Zod refinements and any imperative caller.
*/
export function isPlausibleEntryInstant(
instant: Date,
bounds: EntryInstantBounds = {},
): boolean {
const now = bounds.now ?? Date.now();
const t = instant.getTime();
if (Number.isNaN(t)) return false;
if (t > now + ENTRY_INSTANT_CLOCK_SKEW_MS) return false;
const floorMs =
bounds.maxAgeMs !== undefined
? Math.max(ENTRY_INSTANT_FAR_PAST.getTime(), now - bounds.maxAgeMs)
: ENTRY_INSTANT_FAR_PAST.getTime();
if (t < floorMs) return false;
return true;
}
/**
* Attach the entry-instant bound to a `z.date()`-typed field via two
* `.refine` calls so each violation carries its own message. Use on any
* transformed ISO field after `.transform((s) => new Date(s))`.
*
* Re-reads `Date.now()` inside the refinements (not at schema-build time)
* so a long-lived schema instance always validates against the current
* clock.
*/
export function validateEntryInstant<T extends z.ZodType<Date>>(
schema: T,
bounds: Omit<EntryInstantBounds, "now"> = {},
): T {
const pastMessage =
bounds.pastMessage ??
(bounds.maxAgeMs !== undefined
? "Timestamp is too far in the past"
: "Timestamp must not predate 1900");
return schema
.refine(
(d) =>
d.getTime() <= Date.now() + ENTRY_INSTANT_CLOCK_SKEW_MS,
{ message: "Timestamp must not be in the future" },
)
.refine(
(d) =>
isPlausibleEntryInstant(d, { ...bounds, now: Date.now() }),
{ message: pastMessage },
) as T;
}
+13 -5
View File
@@ -1,4 +1,5 @@
import { z } from "zod/v4";
import { validateEntryInstant } from "./entry-instant";
export const measurementTypeEnum = z.enum([
"WEIGHT",
@@ -499,7 +500,13 @@ export const createMeasurementSchema = z
.object({
type: measurementTypeEnum,
value: z.number(),
measuredAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
// v1.17 W1b — plausibility bound (shared `validateEntryInstant`): no
// future instants beyond a 5-min clock-skew tolerance, no instant before
// 1900. Closes the data-portability gap where a hand-rolled POST could
// backdate or forward-date a reading arbitrarily.
measuredAt: validateEntryInstant(
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
),
notes: z.string().max(MEASUREMENT_NOTES_MAX_LENGTH).optional(),
// v1.10.0 QA — validate against the client-writable subset, not the full
// `MeasurementSource` enum. A client may attribute only `MANUAL` or
@@ -534,10 +541,11 @@ export const createMeasurementSchema = z
export const updateMeasurementSchema = z.object({
value: z.number().min(0).max(500000).optional(),
measuredAt: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional(),
// v1.17 W1b — same plausibility bound on the edit path; an edit cannot
// forward-date a reading into the future nor before 1900.
measuredAt: validateEntryInstant(
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
).optional(),
notes: z
.string()
.max(
+17 -10
View File
@@ -2,6 +2,7 @@ import { z } from "zod/v4";
import { SCHEDULE_TYPES } from "@/lib/medications/scheduling/recurrence";
import { INJECTION_SITE_KEYS } from "@/lib/medications/injection-sites";
import { validateEntryInstant } from "@/lib/validations/entry-instant";
/**
* v1.8.5 — the eight injection-site enum values, mirrored from the
@@ -206,16 +207,22 @@ function hhmmToMinutes(hhmm: string): number {
export const TAKEN_AT_CLOCK_SKEW_MS = 5 * 60 * 1000;
export const TAKEN_AT_MAX_AGE_MS = 5 * 365 * 24 * 60 * 60 * 1000;
/** ISO `takenAt` → `Date` with the shared plausibility bounds applied. */
export const boundedTakenAtSchema = z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.refine((d) => d.getTime() <= Date.now() + TAKEN_AT_CLOCK_SKEW_MS, {
message: "takenAt must not be in the future",
})
.refine((d) => d.getTime() >= Date.now() - TAKEN_AT_MAX_AGE_MS, {
message: "takenAt must be within the last 5 years",
});
/**
* ISO `takenAt` → `Date` with the plausibility bounds applied.
*
* v1.17 W1b — delegates to the shared `validateEntryInstant` helper (the
* same bound now guarding `measuredAt` + `moodLoggedAt`), passing the
* five-year window so a stale backdated dose is rejected well before the
* 1900 floor. The 5-min clock-skew tolerance is the shared default.
*/
export const boundedTakenAtSchema = validateEntryInstant(
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
{
maxAgeMs: TAKEN_AT_MAX_AGE_MS,
// Preserve the established wording so the iOS contract + tests stay stable.
pastMessage: "takenAt must be within the last 5 years",
},
);
export const scheduleSchema = z
.object({
+11 -5
View File
@@ -1,5 +1,6 @@
import { z } from "zod/v4";
import { isPublicUrl } from "@/lib/validations/notifications";
import { validateEntryInstant } from "@/lib/validations/entry-instant";
/**
* @deprecated The standalone moodLog integration is superseded by native
@@ -135,7 +136,12 @@ export const createMoodEntrySchema = z.object({
// `tags: ["note:<text>"]` workaround. Capped at 500 chars so the
// Coach evidence shelf renders cleanly without truncating chips.
note: z.string().max(500).optional(),
moodLoggedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
// v1.17 W1b — plausibility bound (shared `validateEntryInstant`): no
// future instants beyond a 5-min clock-skew tolerance, no instant before
// 1900. Mirrors the measurement + medication-intake bound.
moodLoggedAt: validateEntryInstant(
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
),
source: moodSourceEnum.optional().default("MANUAL"),
// v1.12.1 — optional source-stable id (e.g. an iOS SwiftData row UUID).
// When present, the create upserts on `(userId, source, externalId)`
@@ -157,10 +163,10 @@ export const updateMoodEntrySchema = z.object({
// `null` clears every rated link; omit to leave them untouched.
ratedFactors: ratedFactors.nullable().optional(),
note: z.string().max(500).nullable().optional(),
moodLoggedAt: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional(),
// v1.17 W1b — same plausibility bound on the edit path.
moodLoggedAt: validateEntryInstant(
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
).optional(),
});
export const listMoodEntriesSchema = z.object({