mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(tz): refuse bare UTC offsets as a timezone
The runtime accepts "+05:30" as a zone id, so isValidTimezone let it through, but Postgres reads a bare offset in AT TIME ZONE with the POSIX sign (east of UTC is negative), and the day-grain SQL aggregates would cut every day on the wrong side of UTC. isValidTimezone now accepts named zones only: whatever the runtime resolves to a bare offset is refused, while named zones, their aliases, UTC and the Etc/GMT family stay accepted. Every write that already validated through it refuses offsets now, and the screener and mood linked-context request schemas validate their zone too. A value stored before this change can still sit in the database, so the SQL call sites that read the stored zone directly (the dashboard summary, achievements, the doctor report, the tiered AI context) fall back rather than splice it, as the resolver and the day aggregates already did.
This commit is contained in:
1 parent
f700a7c0f1
commit
f28153c832
8 files changed
+100
-13
No files matched your search
@@ -64,6 +64,7 @@ import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { type Locale } from "@/lib/i18n/config";
|
||||
import { resolveJobLocale } from "@/lib/i18n/job-locale";
|
||||
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
import { validTimezoneOr } from "@/lib/tz/format";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { buildMedsTodayBlock } from "@/lib/dashboard/meds-today";
|
||||
import {
|
||||
@@ -321,7 +322,7 @@ export const GET = apiHandler(async () => {
|
||||
// timezone. Falls back to Europe/Berlin when the column is somehow
|
||||
// missing (defensive — the schema's NOT NULL default normally pins
|
||||
// it).
|
||||
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
|
||||
const userTz = validTimezoneOr(user.timezone, DEFAULT_TIMEZONE);
|
||||
|
||||
// v1.4.38 W-F — wrap the whole response in the analytics LRU.
|
||||
// Subsequent iOS polls inside the TTL hit memory; measurement /
|
||||
|
||||
@@ -39,6 +39,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { resolveLinkedDayContext } from "@/lib/mood/linked-context";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { isValidTimezone } from "@/lib/tz/format";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -47,7 +48,12 @@ const querySchema = z.object({
|
||||
// The zone the day is anchored to. Absent falls back to the account's own
|
||||
// display timezone; the edit dialog passes the entry's stored `tz` so a row
|
||||
// logged elsewhere keeps its own day boundaries.
|
||||
tz: z.string().min(1).max(64).optional(),
|
||||
tz: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.refine(isValidTimezone, "Invalid IANA timezone")
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
@@ -61,6 +61,7 @@ import { loadDenseMeasurementBuckets } from "./dense-buckets";
|
||||
import { summariseDenseBuckets } from "./measurement-series";
|
||||
import { emptyGlucoseClinical } from "./glucose-panel";
|
||||
import { buildAdministrationLedger, buildGlp1Block } from "./medications";
|
||||
import { validTimezoneOr } from "@/lib/tz/format";
|
||||
import {
|
||||
loadAllergies,
|
||||
loadAnamnesis,
|
||||
@@ -145,7 +146,7 @@ export async function collectDoctorReportData(
|
||||
insurerIkNumber: true,
|
||||
},
|
||||
});
|
||||
const reportTz = userProfile?.timezone ?? "Europe/Berlin";
|
||||
const reportTz = validTimezoneOr(userProfile?.timezone, "Europe/Berlin");
|
||||
|
||||
const aggregateDenseTypes = days > DENSE_REPORT_RAW_WINDOW_DAYS;
|
||||
const densePulse = aggregateDenseTypes && !excluded.includes("PULSE");
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
} from "@/lib/analytics/classifications";
|
||||
import { classifyIntakeTiming } from "@/lib/analytics/compliance";
|
||||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
|
||||
import { DEFAULT_TIMEZONE, userDayKey, validTimezoneOr } from "@/lib/tz/format";
|
||||
|
||||
export type AuthedUser = Awaited<ReturnType<typeof requireAuth>>["user"];
|
||||
|
||||
@@ -555,7 +555,7 @@ export async function buildAchievementsResult(
|
||||
// day-streak badge (login, entry, weekend, miss-free, sleep-log, on-time,
|
||||
// compliance-80) plus the hidden night-owl / early-bird / leap-day
|
||||
// triggers for every non-CET account.
|
||||
const tz = user.timezone || DEFAULT_TIMEZONE;
|
||||
const tz = validTimezoneOr(user.timezone, DEFAULT_TIMEZONE);
|
||||
|
||||
const [
|
||||
vitalsBuckets,
|
||||
|
||||
@@ -33,7 +33,7 @@ import type {
|
||||
MeasurementType,
|
||||
RollupGranularity,
|
||||
} from "@/generated/prisma/client";
|
||||
import { isNearUtc } from "@/lib/tz/format";
|
||||
import { isNearUtc, isValidTimezone } from "@/lib/tz/format";
|
||||
import {
|
||||
ensureUserRollupsFresh,
|
||||
readRollupBuckets,
|
||||
@@ -400,7 +400,12 @@ async function readBand(
|
||||
// straight to the live path keyed in their zone. The coarser bands stay
|
||||
// on the UTC-anchored rollups — a few hours of offset moves bucket
|
||||
// MEMBERSHIP at week/month/year grain by at most one edge reading.
|
||||
if (granularity === "DAY" && tz && !isNearUtc(tz, new Date(now))) {
|
||||
if (
|
||||
granularity === "DAY" &&
|
||||
tz &&
|
||||
isValidTimezone(tz) &&
|
||||
!isNearUtc(tz, new Date(now))
|
||||
) {
|
||||
return readBandLive(userId, type, granularity, from, to, tz);
|
||||
}
|
||||
// `readRollupBuckets` never throws — an empty / missing partition yields
|
||||
|
||||
@@ -55,6 +55,24 @@ describe("isValidTimezone", () => {
|
||||
it("rejects strings longer than 64 chars", () => {
|
||||
expect(isValidTimezone("A".repeat(65))).toBe(false);
|
||||
});
|
||||
|
||||
// The runtime accepts UTC offsets as zone ids, but Postgres reads a bare
|
||||
// offset in `AT TIME ZONE` with the POSIX sign (east is negative), so a day
|
||||
// cut in SQL would land on the wrong side of UTC.
|
||||
it("rejects bare UTC offsets", () => {
|
||||
expect(isValidTimezone("+05:30")).toBe(false);
|
||||
expect(isValidTimezone("-0800")).toBe(false);
|
||||
expect(isValidTimezone("+05")).toBe(false);
|
||||
expect(isValidTimezone("\u221205:00")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps accepting named zones, aliases and the Etc family", () => {
|
||||
expect(isValidTimezone("Asia/Kolkata")).toBe(true);
|
||||
expect(isValidTimezone("Asia/Calcutta")).toBe(true);
|
||||
expect(isValidTimezone("Europe/Kyiv")).toBe(true);
|
||||
expect(isValidTimezone("Etc/UTC")).toBe(true);
|
||||
expect(isValidTimezone("Etc/GMT+5")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveServerDefaultTimezone", () => {
|
||||
@@ -109,6 +127,16 @@ describe("resolveUserTimezone", () => {
|
||||
expect(await resolveUserTimezone("user-1")).toBe("Pacific/Auckland");
|
||||
});
|
||||
|
||||
it("falls back to the server default for a stored bare offset", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
timezone: "+05:30",
|
||||
} as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
defaultUserTimezone: "Asia/Tokyo",
|
||||
} as never);
|
||||
expect(await resolveUserTimezone("user-1")).toBe("Asia/Tokyo");
|
||||
});
|
||||
|
||||
it("falls back to the server default when the user row is missing", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
@@ -337,3 +365,17 @@ describe("DEFAULT_TIMEZONE", () => {
|
||||
expect(DEFAULT_TIMEZONE).toBe("Europe/Berlin");
|
||||
});
|
||||
});
|
||||
|
||||
describe("request schemas refuse a bare offset zone", () => {
|
||||
it("the screener submission", async () => {
|
||||
const { createAssessmentSchema } =
|
||||
await import("@/lib/validations/mental-health");
|
||||
const base = { instrument: "PHQ9", items: [0, 0, 0, 0, 0, 0, 0, 0, 0] };
|
||||
expect(
|
||||
createAssessmentSchema.safeParse({ ...base, tz: "Asia/Kolkata" }).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
createAssessmentSchema.safeParse({ ...base, tz: "+05:30" }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
+32
-5
@@ -48,9 +48,20 @@ const WALL_CLOCK_PARTS_OPTIONS: Omit<Intl.DateTimeFormatOptions, "timeZone"> =
|
||||
});
|
||||
|
||||
/**
|
||||
* Validate a timezone string by asking `Intl.DateTimeFormat` to use it.
|
||||
* Returns `true` if the runtime accepts the zone, `false` otherwise.
|
||||
* Cheap (microseconds) — call freely at write paths.
|
||||
* A zone id the runtime resolved to a bare UTC offset (`+05:30`, `-08:00`).
|
||||
*/
|
||||
const OFFSET_ZONE = /^[+-]\d/;
|
||||
|
||||
/**
|
||||
* Validate a timezone string: a named IANA zone (or `UTC`) the runtime
|
||||
* accepts. Returns `false` for anything else. Cheap (microseconds) — call
|
||||
* freely at write paths.
|
||||
*
|
||||
* Bare UTC offsets are refused although the runtime accepts them as zone ids.
|
||||
* The zone ends up in Postgres `AT TIME ZONE`, which reads a bare offset with
|
||||
* the POSIX sign (east of UTC is negative), so `+05:30` would cut every day
|
||||
* eleven hours off. Named zones, their aliases and the `Etc/GMT±N` family
|
||||
* mean the same thing to both.
|
||||
*/
|
||||
export function isValidTimezone(tz: string): boolean {
|
||||
if (!tz || typeof tz !== "string" || tz.length === 0 || tz.length > 64) {
|
||||
@@ -60,13 +71,29 @@ export function isValidTimezone(tz: string): boolean {
|
||||
// The memo only caches successful constructions, so probing an
|
||||
// invalid zone through it cannot poison the formatter map — and a
|
||||
// valid zone leaves its formatter warm for the real callers.
|
||||
getDateTimeFormat("en-US", tz, VALIDATION_OPTIONS);
|
||||
return true;
|
||||
const resolved = getDateTimeFormat(
|
||||
"en-US",
|
||||
tz,
|
||||
VALIDATION_OPTIONS,
|
||||
).resolvedOptions().timeZone;
|
||||
return !OFFSET_ZONE.test(resolved);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `tz` when it is a valid zone, else `fallback`. For every place a stored or
|
||||
* supplied zone reaches SQL: a value written before offsets were refused may
|
||||
* still sit in the database, and it must never be spliced.
|
||||
*/
|
||||
export function validTimezoneOr(
|
||||
tz: string | null | undefined,
|
||||
fallback: string,
|
||||
): string {
|
||||
return tz && isValidTimezone(tz) ? tz : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* The list of every IANA zone the current Node runtime accepts.
|
||||
* Returns an empty array on engines older than Node 22 / V8 12.5
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type InstrumentId,
|
||||
} from "@/lib/mental-health/instruments";
|
||||
import { assertStableExternalId } from "@/lib/validations/external-id";
|
||||
import { isValidTimezone } from "@/lib/tz/format";
|
||||
|
||||
export const assessmentInstrumentEnum = z.enum(["PHQ9", "GAD7", "WHO5", "SCI"]);
|
||||
|
||||
@@ -38,7 +39,11 @@ export const createAssessmentSchema = z
|
||||
functionalDifficulty: z.number().int().min(0).max(3).optional(),
|
||||
/** ISO instant the screener was taken; defaults to now server-side. */
|
||||
takenAt: z.string().datetime().optional(),
|
||||
tz: z.string().max(64).optional(),
|
||||
tz: z
|
||||
.string()
|
||||
.max(64)
|
||||
.refine(isValidTimezone, "Invalid IANA timezone")
|
||||
.optional(),
|
||||
/** Locale of the validated wording actually presented. */
|
||||
locale: z.string().max(16).optional(),
|
||||
/** Client provenance; defaults to WEB. */
|
||||
|
||||
Reference in new issue
Block a user