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:
Marc-André Bombeck committed 2026-09-24 12:23:00 +02:00
1 parent f700a7c0f1
commit f28153c832
8 files changed
+100 -13

No files matched your search

+2 -1
View File
@@ -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 /
+7 -1
View File
@@ -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) => {