diff --git a/src/components/measurements/measurement-form.tsx b/src/components/measurements/measurement-form.tsx index eda1e6eb2..6afe01da2 100644 --- a/src/components/measurements/measurement-form.tsx +++ b/src/components/measurements/measurement-form.tsx @@ -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} diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index aa0ec6794..2d9c2d34e 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -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} diff --git a/src/components/mood/mood-form.tsx b/src/components/mood/mood-form.tsx index 3373b754d..aa0fd6001 100644 --- a/src/components/mood/mood-form.tsx +++ b/src/components/mood/mood-form.tsx @@ -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} diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index 39d303c7d..12e93967f 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -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 /> diff --git a/src/lib/validations/__tests__/entry-instant.test.ts b/src/lib/validations/__tests__/entry-instant.test.ts new file mode 100644 index 000000000..9d0132d94 --- /dev/null +++ b/src/lib/validations/__tests__/entry-instant.test.ts @@ -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); + }); +}); diff --git a/src/lib/validations/__tests__/measurement.test.ts b/src/lib/validations/__tests__/measurement.test.ts index 403fcf3b2..983d019ba 100644 --- a/src/lib/validations/__tests__/measurement.test.ts +++ b/src/lib/validations/__tests__/measurement.test.ts @@ -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 diff --git a/src/lib/validations/__tests__/moodlog.test.ts b/src/lib/validations/__tests__/moodlog.test.ts index 06ed3d078..0f619fc25 100644 --- a/src/lib/validations/__tests__/moodlog.test.ts +++ b/src/lib/validations/__tests__/moodlog.test.ts @@ -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", () => { diff --git a/src/lib/validations/cycle.ts b/src/lib/validations/cycle.ts index 09a78ff9e..6b3353cc1 100644 --- a/src/lib/validations/cycle.ts +++ b/src/lib/validations/cycle.ts @@ -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 ──────────────────────────────────── */ diff --git a/src/lib/validations/entry-instant.ts b/src/lib/validations/entry-instant.ts new file mode 100644 index 000000000..aa641aee9 --- /dev/null +++ b/src/lib/validations/entry-instant.ts @@ -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>( + schema: T, + bounds: Omit = {}, +): 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; +} diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index c2de3706f..041e6259f 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -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( diff --git a/src/lib/validations/medication.ts b/src/lib/validations/medication.ts index 78a6011a4..cce5059d0 100644 --- a/src/lib/validations/medication.ts +++ b/src/lib/validations/medication.ts @@ -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({ diff --git a/src/lib/validations/moodlog.ts b/src/lib/validations/moodlog.ts index d093101f3..afb7a1073 100644 --- a/src/lib/validations/moodlog.ts +++ b/src/lib/validations/moodlog.ts @@ -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:"]` 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({