From f65d25b0d0c8a21d530a2d08125c7b5890f4ce1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 12:31:37 +0200 Subject: [PATCH] fix(db): read the clock as UTC in raw SQL and prove it on a Berlin database Every raw-SQL clock read against a timestamp column without a zone now writes NOW() AT TIME ZONE 'UTC', so passkey and login challenges, rate limits, provider pauses, coach usage stamps, tombstone and measurement windows no longer pass through the session time zone even where the per-connection pin cannot apply. The regression-window bounds stay naive UTC timestamps instead of being turned into timestamptz. A structural guard keeps bare NOW() / CURRENT_TIMESTAMP out of raw SQL outside a short list of timestamptz targets. The integration test sets the database default to Europe/Berlin and drives challenge claims, session expiry, the native sign-in handoff freshness check and the rate-limit window through the app's client, plus the challenge claim through an unpinned client. Self-hosting docs describe the pin for an existing or shared Postgres. --- docs/self-hosting/getting-started.md | 20 ++ docs/self-hosting/scaling.md | 7 +- prisma/schema.prisma | 3 +- src/__tests__/sql-now-utc-guard.test.ts | 97 +++++++ src/app/api/measurements/series/route.ts | 2 +- src/lib/ai/coach/budget.ts | 8 +- src/lib/ai/provider-health-ledger.ts | 14 +- .../__tests__/summaries-slice.test.ts | 12 +- src/lib/analytics/source-rank-sql.ts | 4 +- src/lib/analytics/summaries-slice.ts | 29 +- src/lib/auth/passkey.ts | 6 +- src/lib/db.ts | 28 +- src/lib/export/measurement-bulk-insert.ts | 2 +- src/lib/insights/comprehensive-aggregator.ts | 29 +- src/lib/jobs/__tests__/boss-instance.test.ts | 2 + src/lib/jobs/data-arrival.ts | 2 +- src/lib/jobs/reminder/cleanup-handlers.ts | 2 +- src/lib/jobs/step-consolidation-repair.ts | 2 +- .../repair-apple-health-distance.ts | 2 +- src/lib/notifications/delivery-identity.ts | 4 +- src/lib/rate-limit.ts | 8 +- tests/integration/session-time-zone.test.ts | 252 ++++++++++++++++++ 22 files changed, 469 insertions(+), 66 deletions(-) create mode 100644 src/__tests__/sql-now-utc-guard.test.ts create mode 100644 tests/integration/session-time-zone.test.ts diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index c55033afd..9967227e6 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -77,6 +77,26 @@ process (`pnpm dev` against a host Postgres) or a **custom compose** that does not derive it — in that case uncomment and set `DATABASE_URL` explicitly in `.env` (see the commented example there). +### Using an existing or shared Postgres + +HealthLog can live in a database on a Postgres instance you already run. +It stores every time as UTC and pins each connection it opens to UTC +(`TimeZone=UTC`, sent with the libpq `options` startup parameter), so the +server's or the database's own `timezone` setting does not matter: an +instance set to `Europe/Berlin` works the same as one set to UTC. The app +checks the setting at startup and logs a warning naming the zone if a +connection still reports something else. + +Behind PgBouncer with `DATABASE_SESSION_OPTIONS_DISABLED=1` (see +[Scaling](./scaling.md#postgres-connection-pool-sizing)) the app pins the +zone with `SET TIME ZONE 'UTC'` on each new connection instead; PgBouncer +keeps that setting per client in transaction pooling too. If the startup +warning still appears, set the database default as well: + +```sql +ALTER DATABASE healthlog SET timezone TO 'UTC'; +``` + ## 3. Bring the stack up ```bash diff --git a/docs/self-hosting/scaling.md b/docs/self-hosting/scaling.md index 2512ecad9..c39d30b5a 100644 --- a/docs/self-hosting/scaling.md +++ b/docs/self-hosting/scaling.md @@ -188,13 +188,16 @@ already`. Either raise `max_connections` on the Postgres side, lower `DATABASE_POOL_MAX`, or front the database with PgBouncer in transaction-pooling mode. -The app sets its statement timeout and `work_mem` on every connection -through the libpq `options` startup parameter. PgBouncer refuses that +The app sets its statement timeout, `work_mem` and `TimeZone=UTC` on every +connection through the libpq `options` startup parameter. PgBouncer refuses that parameter (`unsupported startup parameter: options`) unless it is listed in `ignore_startup_parameters`. Either list it there, or set `DATABASE_SESSION_OPTIONS_DISABLED=1` so the app sends no session settings; set the timeout and `work_mem` on the database role instead (`ALTER ROLE healthlog SET work_mem = '16MB'`) if you still want them. +The time zone is the exception: with the options off the app pins it with +`SET TIME ZONE 'UTC'` on each new connection, which PgBouncer tracks per +client, and warns at startup if a connection still reports another zone. The measurement maintenance job (Admin, System status) then applies its own settings with `SET` after connecting. Its concurrent index rebuild needs a session, so run it through a session-mode pool or a direct connection. diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 99eb867c2..8cce98cc9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -11,7 +11,8 @@ // round-trip the same instant. The invariant every reader, writer, and // raw-SQL query MUST hold is: // -// * The application server and the Postgres session run in UTC. +// * The application server and the Postgres session run in UTC (every +// connection pins TimeZone=UTC, see src/lib/db-session.ts). // * Wall-clock / calendar-local anchoring lives in an explicit `tz` // string column (e.g. `MoodEntry.tz`) or the user's `timezone`, NOT // in the column type. diff --git a/src/__tests__/sql-now-utc-guard.test.ts b/src/__tests__/sql-now-utc-guard.test.ts new file mode 100644 index 000000000..8846c7622 --- /dev/null +++ b/src/__tests__/sql-now-utc-guard.test.ts @@ -0,0 +1,97 @@ +/** + * Raw SQL never reads the clock through the session time zone. + * + * Stored times are UTC in `timestamp` columns without a zone. Postgres + * compares such a column with `NOW()` / `CURRENT_TIMESTAMP` / + * `clock_timestamp()` by converting through the session time zone, and + * assigns one of them to such a column the same way, so on a database whose + * default is not UTC a fresh passkey challenge read as already expired. Every + * connection is pinned to UTC (`@/lib/db-session`), and on top of that every + * raw-SQL clock read writes `NOW() AT TIME ZONE 'UTC'`, which yields the naive + * UTC timestamp whatever the session says. + * + * The few bare reads left are listed below with the reason they are safe: + * each feeds a `timestamptz` column or returns the instant to JavaScript. + */ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; +import { describe, expect, it } from "vitest"; + +const ROOT = join(__dirname, "..", ".."); +const SRC = join(ROOT, "src"); + +/** `file: reason` for every bare clock read that is deliberately left. */ +const ALLOWED: Record = { + "src/lib/rollups/mood-rollups.ts": "computed_at is timestamptz", + "src/lib/rollups/medication-compliance-rollups.ts": + "computed_at is timestamptz", + "src/lib/jobs/job-failures.ts": "pg-boss tables are timestamptz", + "src/lib/jobs/reminder/registrar-shared.ts": "pg-boss tables are timestamptz", + "src/lib/db-compat.ts": "DDL default on a timestamptz column", +}; + +const SQL_CLOCK = + /(?]|=)\s*now\(\)\s*(?:$|[,)]|[-+]\s*(?:make_interval|INTERVAL))|\bnow\(\)\s*[-+]\s*(?:make_interval|INTERVAL)/; +const ANCHORED = + /(?:NOW\(\)|CURRENT_TIMESTAMP|clock_timestamp\(\)) AT TIME ZONE 'UTC'/g; + +function walk(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + const full = join(dir, name); + if (name === "generated" || name === "__tests__") continue; + if (statSync(full).isDirectory()) walk(full, out); + else if (/\.tsx?$/.test(name) && !/\.test\.tsx?$/.test(name)) + out.push(full); + } + return out; +} + +function offenders(): string[] { + const found: string[] = []; + for (const file of walk(SRC)) { + const rel = relative(ROOT, file); + if (ALLOWED[rel]) continue; + readFileSync(file, "utf8") + .split("\n") + .forEach((line, index) => { + if (/^\s*(\/\/|\*|\/\*|--)/.test(line)) return; + if (SQL_CLOCK.test(line.replace(ANCHORED, ""))) { + found.push(`${rel}:${index + 1}: ${line.trim()}`); + } + }); + } + return found; +} + +describe("raw SQL clock reads are anchored to UTC", () => { + it("finds the anchored form across the swept files", () => { + // The matcher must see something, or a broken walk would pass. + const passkey = readFileSync(join(SRC, "lib/auth/passkey.ts"), "utf8"); + expect(passkey.match(ANCHORED)?.length).toBeGreaterThanOrEqual(3); + expect(SQL_CLOCK.test("AND expires_at > NOW()")).toBe(true); + expect(SQL_CLOCK.test("updated_at = now()")).toBe(true); + expect(SQL_CLOCK.test("const started = now();")).toBe(false); + expect(SQL_CLOCK.test("elapsedMs: () => now() - started,")).toBe(false); + expect(SQL_CLOCK.test("const ends = now() + perCallMs;")).toBe(false); + expect( + SQL_CLOCK.test("deleted_at >= now() - make_interval(days => 1)"), + ).toBe(true); + expect(SQL_CLOCK.test("Date.now()")).toBe(false); + }); + + it("leaves no bare NOW() / CURRENT_TIMESTAMP outside the allowlist", () => { + expect(offenders()).toEqual([]); + }); + + it("keeps the allowlist honest: every entry still has a bare read", () => { + for (const rel of Object.keys(ALLOWED)) { + const lines = readFileSync(join(ROOT, rel), "utf8") + .replace(ANCHORED, "") + .split("\n"); + expect( + lines.some((line) => SQL_CLOCK.test(line)), + rel, + ).toBe(true); + } + }); +}); diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts index 17049bf4c..a60dd7992 100644 --- a/src/app/api/measurements/series/route.ts +++ b/src/app/api/measurements/series/route.ts @@ -378,7 +378,7 @@ export const GET = apiHandler(async (request: NextRequest) => { AND "type" = 'BLOOD_PRESSURE_SYS'::"measurement_type" AND "deleted_at" IS NULL AND "value" >= $3 - AND "measured_at" >= date_trunc('day', $2::timestamptz) + AND "measured_at" >= date_trunc('day', $2::timestamptz AT TIME ZONE 'UTC') ORDER BY date_trunc('day', "measured_at"), (${rank}), "source"::text ) c ON c.d = date_trunc('day', m."measured_at") diff --git a/src/lib/ai/coach/budget.ts b/src/lib/ai/coach/budget.ts index 88e1e0097..f1d387e0a 100644 --- a/src/lib/ai/coach/budget.ts +++ b/src/lib/ai/coach/budget.ts @@ -321,12 +321,12 @@ export async function reserveBudget( { total_tokens: number; operator_tokens: number }[] >` INSERT INTO coach_usage (id, user_id, date_key, total_tokens, operator_tokens, message_count, created_at, updated_at) - VALUES (gen_random_uuid()::text, ${userId}, ${dateKey}, ${reserved}, ${operatorReserved}, ${messageBump}, NOW(), NOW()) + VALUES (gen_random_uuid()::text, ${userId}, ${dateKey}, ${reserved}, ${operatorReserved}, ${messageBump}, (NOW() AT TIME ZONE 'UTC'), (NOW() AT TIME ZONE 'UTC')) ON CONFLICT (user_id, date_key) DO UPDATE SET total_tokens = coach_usage.total_tokens + ${reserved}, operator_tokens = coach_usage.operator_tokens + ${operatorReserved}, message_count = coach_usage.message_count + ${messageBump}, - updated_at = NOW() + updated_at = (NOW() AT TIME ZONE 'UTC') RETURNING total_tokens, operator_tokens `; const totalAfter = Number(rows[0]?.total_tokens ?? reserved); @@ -453,7 +453,7 @@ export async function reconcileSpend( UPDATE coach_usage SET total_tokens = GREATEST(0, total_tokens + ${delta}), operator_tokens = GREATEST(0, operator_tokens + ${operatorDelta}), - updated_at = NOW() + updated_at = (NOW() AT TIME ZONE 'UTC') WHERE user_id = ${userId} AND date_key = ${dateKey} `; } @@ -472,7 +472,7 @@ async function refundReservation( SET total_tokens = GREATEST(0, total_tokens - ${reserved}), operator_tokens = GREATEST(0, operator_tokens - ${operatorReserved}), message_count = GREATEST(0, message_count - ${messageBump}), - updated_at = NOW() + updated_at = (NOW() AT TIME ZONE 'UTC') WHERE user_id = ${userId} AND date_key = ${dateKey} `; } diff --git a/src/lib/ai/provider-health-ledger.ts b/src/lib/ai/provider-health-ledger.ts index 64c49388c..1113fe373 100644 --- a/src/lib/ai/provider-health-ledger.ts +++ b/src/lib/ai/provider-health-ledger.ts @@ -203,14 +203,14 @@ export const postgresProviderHealthLedger: ProviderHealthLedger = { next_retry_at, updated_at) VALUES (gen_random_uuid()::text, ${userId}, ${providerType}, 'ok', NULL, - 0, NOW(), NULL, NULL, NOW()) + 0, (NOW() AT TIME ZONE 'UTC'), NULL, NULL, (NOW() AT TIME ZONE 'UTC')) ON CONFLICT (user_id, provider_type) DO UPDATE SET last_result = 'ok', last_status = NULL, consecutive_failures = 0, - last_ok_at = NOW(), + last_ok_at = (NOW() AT TIME ZONE 'UTC'), next_retry_at = NULL, - updated_at = NOW() + updated_at = (NOW() AT TIME ZONE 'UTC') `; } catch { // Fire-and-forget — a failed write never blocks the hot path. @@ -240,13 +240,13 @@ export const postgresProviderHealthLedger: ProviderHealthLedger = { next_retry_at, updated_at) VALUES (gen_random_uuid()::text, ${userId}, ${providerType}, ${result}, - ${status}, 1, NULL, NOW(), NOW() + ${interval}::interval, NOW()) + ${status}, 1, NULL, (NOW() AT TIME ZONE 'UTC'), (NOW() AT TIME ZONE 'UTC') + ${interval}::interval, (NOW() AT TIME ZONE 'UTC')) ON CONFLICT (user_id, provider_type) DO UPDATE SET last_result = ${result}, last_status = ${status}, consecutive_failures = provider_health.consecutive_failures + 1, - last_failure_at = NOW(), - next_retry_at = NOW() + CASE + last_failure_at = (NOW() AT TIME ZONE 'UTC'), + next_retry_at = (NOW() AT TIME ZONE 'UTC') + CASE WHEN ${result} = 'hard_failed' AND provider_health.consecutive_failures + 1 >= ${pauseFrom}::int THEN LEAST( @@ -258,7 +258,7 @@ export const postgresProviderHealthLedger: ProviderHealthLedger = { ) ELSE ${interval}::interval END, - updated_at = NOW() + updated_at = (NOW() AT TIME ZONE 'UTC') `; } catch { // Fire-and-forget. diff --git a/src/lib/analytics/__tests__/summaries-slice.test.ts b/src/lib/analytics/__tests__/summaries-slice.test.ts index b7e3551df..a503f58e0 100644 --- a/src/lib/analytics/__tests__/summaries-slice.test.ts +++ b/src/lib/analytics/__tests__/summaries-slice.test.ts @@ -662,13 +662,17 @@ describe("computeSummariesSlice", () => { // left with slope90 in v1.37.19). for (const days of ["7 days", "30 days"]) { expect(sql).toContain( - `(date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '${days}') AT TIME ZONE 'UTC'`, + `m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '${days}')`, ); } const anchored = sql.match( - /date_trunc\('day', NOW\(\) AT TIME ZONE 'UTC'\) - INTERVAL '(?:7|30) days'\) AT TIME ZONE 'UTC'/g, + /date_trunc\('day', NOW\(\) AT TIME ZONE 'UTC'\) - INTERVAL '(?:7|30) days'\)/g, ); expect(anchored?.length).toBe(4); + // The bound stays a naive UTC timestamp like measured_at: converting + // it to timestamptz would route the comparison through the session + // time zone. + expect(sql).not.toMatch(/INTERVAL '(?:7|30) days'\) AT TIME ZONE/); // The regression windows must NOT fall back to the bare wall-clock // `NOW() - INTERVAL 'N days'` bound the warm path never uses for the @@ -676,7 +680,9 @@ describe("computeSummariesSlice", () => { // REGR_SLOPE onward) so the avg7/avg30 windows above — which DO stay // wall-clock by design — don't trip the assertion. const slopeBlock = sql.slice(sql.indexOf("REGR_SLOPE")); - expect(slopeBlock).not.toMatch(/WHERE m\."measured_at" >= NOW\(\) -/); + expect(slopeBlock).not.toMatch( + /WHERE m\."measured_at" >= \(?NOW\(\)( AT TIME ZONE 'UTC'\))? -/, + ); }); }); }); diff --git a/src/lib/analytics/source-rank-sql.ts b/src/lib/analytics/source-rank-sql.ts index c53485983..a5f1940b2 100644 --- a/src/lib/analytics/source-rank-sql.ts +++ b/src/lib/analytics/source-rank-sql.ts @@ -134,10 +134,10 @@ export function canonicalMeasurementsCte( onlyType?: MeasurementType, ): string { const sinceInner = sinceInterval - ? `AND "measured_at" >= NOW() - INTERVAL '${assertInterval(sinceInterval)}'` + ? `AND "measured_at" >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '${assertInterval(sinceInterval)}'` : ""; const sinceOuter = sinceInterval - ? `AND mm."measured_at" >= NOW() - INTERVAL '${assertInterval(sinceInterval)}'` + ? `AND mm."measured_at" >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '${assertInterval(sinceInterval)}'` : ""; // A single-type reader (a series for one metric) scopes both halves to that // type, so the pick scans one metric's rows instead of every row the user diff --git a/src/lib/analytics/summaries-slice.ts b/src/lib/analytics/summaries-slice.ts index 92b2c1ff0..9766410a9 100644 --- a/src/lib/analytics/summaries-slice.ts +++ b/src/lib/analytics/summaries-slice.ts @@ -174,10 +174,14 @@ function windowedMean(filter: string): string { filter, }); } -const AVG7_SQL = windowedMean(`m."measured_at" >= NOW() - INTERVAL '7 days'`); -const AVG30_SQL = windowedMean(`m."measured_at" >= NOW() - INTERVAL '30 days'`); +const AVG7_SQL = windowedMean( + `m."measured_at" >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '7 days'`, +); +const AVG30_SQL = windowedMean( + `m."measured_at" >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days'`, +); const AVG30_LAST_MONTH_SQL = windowedMean( - `m."measured_at" >= NOW() - INTERVAL '60 days' AND m."measured_at" < NOW() - INTERVAL '30 days'`, + `m."measured_at" >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '60 days' AND m."measured_at" < (NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days'`, ); interface NarrowAggregateRow { @@ -566,7 +570,7 @@ async function computeFromRollups(userId: string): Promise { PERCENTILE_CONT(0.5) WITHIN GROUP ( ORDER BY m."value" ) FILTER ( - WHERE m."measured_at" >= NOW() - INTERVAL '90 days' + WHERE m."measured_at" >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '90 days' )::double precision AS median FROM ${dayWeightedRows("cm", SESSION_DAY_FRAME)} m GROUP BY m."type" @@ -1041,17 +1045,16 @@ async function computeFromLiveAggregate( PERCENTILE_CONT(0.5) WITHIN GROUP ( ORDER BY m."value" ) FILTER ( - WHERE m."measured_at" >= NOW() - INTERVAL '90 days' + WHERE m."measured_at" >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '90 days' )::double precision AS median, -- A2-M2 — anchor the regression windows on the UTC-midnight day -- boundary so this cold-fallback path returns the SAME boundary-day -- membership the warm computeFromRollups composes via -- startOfUtcDay(now - N days) + composeWindowedRegression. The -- expression date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL - -- 'N days' is the SQL twin of startOfUtcDay; the trailing AT TIME - -- ZONE 'UTC' re-materialises the naive truncation as a UTC instant - -- so the measured_at (timestamptz) comparison lands on the same wall - -- the rollup bucketStart sits on. The wall-clock NOW() - INTERVAL + -- 'N days' is the SQL twin of startOfUtcDay. It stays a naive UTC + -- timestamp, like measured_at itself, so the comparison never + -- passes through the session time zone. The wall-clock NOW() - INTERVAL -- bound let the boundary day flip in/out of the slope window across -- a warm-to-cold coverage transition (cache-dependent slope on the -- same request); the avg7/30/median windows above stay wall-clock @@ -1060,25 +1063,25 @@ async function computeFromLiveAggregate( m."value", EXTRACT(EPOCH FROM m."measured_at") / 86400.0 ) FILTER ( - WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '7 days') AT TIME ZONE 'UTC' + WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '7 days') )::double precision AS slope7, REGR_R2( m."value", EXTRACT(EPOCH FROM m."measured_at") / 86400.0 ) FILTER ( - WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '7 days') AT TIME ZONE 'UTC' + WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '7 days') )::double precision AS r2_7, REGR_SLOPE( m."value", EXTRACT(EPOCH FROM m."measured_at") / 86400.0 ) FILTER ( - WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days') AT TIME ZONE 'UTC' + WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days') )::double precision AS slope30, REGR_R2( m."value", EXTRACT(EPOCH FROM m."measured_at") / 86400.0 ) FILTER ( - WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days') AT TIME ZONE 'UTC' + WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days') )::double precision AS r2_30 FROM ${dayWeightedRows("cm", SESSION_DAY_FRAME)} m GROUP BY m."type" diff --git a/src/lib/auth/passkey.ts b/src/lib/auth/passkey.ts index d2847ad82..cc7b7a5a3 100644 --- a/src/lib/auth/passkey.ts +++ b/src/lib/auth/passkey.ts @@ -186,7 +186,7 @@ export async function consumeRegistrationChallenge(input: { WHERE id = ${input.challengeId} AND user_id = ${input.userId} AND type = ${expectedType} - AND expires_at > NOW() + AND expires_at > (NOW() AT TIME ZONE 'UTC') RETURNING challenge, created_at `; @@ -225,7 +225,7 @@ export async function claimAuthChallenge(input: { WHERE id = ${input.challengeId} AND type = ${input.type} AND user_id = ${input.userId} - AND expires_at > NOW() + AND expires_at > (NOW() AT TIME ZONE 'UTC') RETURNING challenge, user_id ` : await prisma.$queryRaw< @@ -234,7 +234,7 @@ export async function claimAuthChallenge(input: { DELETE FROM auth_challenges WHERE id = ${input.challengeId} AND type = ${input.type} - AND expires_at > NOW() + AND expires_at > (NOW() AT TIME ZONE 'UTC') RETURNING challenge, user_id `; if (rows.length !== 1) return null; diff --git a/src/lib/db.ts b/src/lib/db.ts index be64fb5b7..9cfff9d20 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -1,6 +1,6 @@ import { Prisma, PrismaClient } from "@/generated/prisma/client"; import { PrismaPg } from "@prisma/adapter-pg"; -import { Pool } from "pg"; +import { Pool, type PoolConfig } from "pg"; import { pinTimeZoneOnConnect, sessionOptionsDisabled, @@ -144,16 +144,32 @@ export function buildSessionOptions(): string | undefined { return `${timeouts}-c work_mem=${getWorkMem()} ${TIME_ZONE_OPTION}`; } -function createPrismaClient() { - const pool = new Pool({ +export function prismaPoolConfig(): PoolConfig { + return { connectionString: process.env.DATABASE_URL!, max: getPrismaPoolMax(), connectionTimeoutMillis: getPoolConnectionTimeoutMs(), options: buildSessionOptions(), - }); + }; +} + +/** + * The adapter for the process-wide client. The startup options carry the + * UTC pin by default, so the adapter builds its own pool from the config. + * With the options off (a pooler) the pool is built here instead, to pin the + * zone by statement on connect; the adapter then reuses that one pool for + * its whole life, a reconnect included. + */ +export function createPrismaAdapter(): PrismaPg { + const config = prismaPoolConfig(); + if (!sessionOptionsDisabled()) return new PrismaPg(config); + const pool = new Pool(config); pinTimeZoneOnConnect(pool); - const adapter = new PrismaPg(pool, { disposeExternalPool: true }); - return new PrismaClient({ adapter }); + return new PrismaPg(pool); +} + +function createPrismaClient() { + return new PrismaClient({ adapter: createPrismaAdapter() }); } // One client per process, in production too. A production build gives every diff --git a/src/lib/export/measurement-bulk-insert.ts b/src/lib/export/measurement-bulk-insert.ts index e056817a0..adbb848ae 100644 --- a/src/lib/export/measurement-bulk-insert.ts +++ b/src/lib/export/measurement-bulk-insert.ts @@ -123,7 +123,7 @@ function selectExpression( const cast = `${ref}::timestamp(3)`; // Prisma fills both on create when the row does not carry them. return field === "createdAt" || field === "updatedAt" - ? `COALESCE(${cast}, CURRENT_TIMESTAMP)` + ? `COALESCE(${cast}, CURRENT_TIMESTAMP AT TIME ZONE 'UTC')` : cast; } if (kind === "bytea") return `decode(${ref}, 'hex')`; diff --git a/src/lib/insights/comprehensive-aggregator.ts b/src/lib/insights/comprehensive-aggregator.ts index 8d562abd0..63f22ec82 100644 --- a/src/lib/insights/comprehensive-aggregator.ts +++ b/src/lib/insights/comprehensive-aggregator.ts @@ -145,10 +145,14 @@ function windowedMean(filter: string): string { filter, }); } -const AVG7_SQL = windowedMean(`m."measured_at" >= NOW() - INTERVAL '7 days'`); -const AVG30_SQL = windowedMean(`m."measured_at" >= NOW() - INTERVAL '30 days'`); +const AVG7_SQL = windowedMean( + `m."measured_at" >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '7 days'`, +); +const AVG30_SQL = windowedMean( + `m."measured_at" >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days'`, +); const AVG30_LAST_MONTH_SQL = windowedMean( - `m."measured_at" >= NOW() - INTERVAL '60 days' AND m."measured_at" < NOW() - INTERVAL '30 days'`, + `m."measured_at" >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '60 days' AND m."measured_at" < (NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days'`, ); /** @@ -488,7 +492,7 @@ async function buildFromRollups( m."measured_at" AS measured_at FROM measurements m WHERE m."user_id" = $1 - AND m."measured_at" >= NOW() - INTERVAL '90 days' + AND m."measured_at" >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '90 days' AND m."deleted_at" IS NULL ORDER BY m."type", date_trunc('day', m."measured_at") DESC, (${rankM}), m."measured_at" DESC `, @@ -718,10 +722,9 @@ async function buildFromLiveAggregate( -- composes from (startOfUtcDay(now - N days) + -- composeWindowedRegression). The expression -- date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL 'N days' - -- is the SQL twin of startOfUtcDay; the trailing AT TIME ZONE 'UTC' - -- re-materialises the naive truncation as a UTC instant so the - -- measured_at (timestamptz) comparison is on the same wall the - -- rollup bucketStart sits on. Without this anchor an account that + -- is the SQL twin of startOfUtcDay. It stays a naive UTC timestamp, + -- like measured_at itself, so the comparison never passes through + -- the session time zone. Without this anchor an account that -- crosses a coverage boundary (warm to cold) saw the boundary day -- flip in/out of the slope window, giving a cache-dependent answer -- to the same request. @@ -729,25 +732,25 @@ async function buildFromLiveAggregate( m."value", EXTRACT(EPOCH FROM m."measured_at") / 86400.0 ) FILTER ( - WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '7 days') AT TIME ZONE 'UTC' + WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '7 days') )::double precision AS slope7, REGR_R2( m."value", EXTRACT(EPOCH FROM m."measured_at") / 86400.0 ) FILTER ( - WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '7 days') AT TIME ZONE 'UTC' + WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '7 days') )::double precision AS r2_7, REGR_SLOPE( m."value", EXTRACT(EPOCH FROM m."measured_at") / 86400.0 ) FILTER ( - WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days') AT TIME ZONE 'UTC' + WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days') )::double precision AS slope30, REGR_R2( m."value", EXTRACT(EPOCH FROM m."measured_at") / 86400.0 ) FILTER ( - WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days') AT TIME ZONE 'UTC' + WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days') )::double precision AS r2_30 FROM ${dayWeightedRows("cm", SESSION_DAY_FRAME)} m JOIN window_stats ws ON ws."type" = m."type" @@ -765,7 +768,7 @@ async function buildFromLiveAggregate( m."measured_at" AS measured_at FROM measurements m WHERE m."user_id" = $1 - AND m."measured_at" >= NOW() - INTERVAL '90 days' + AND m."measured_at" >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '90 days' AND m."deleted_at" IS NULL ORDER BY m."type", date_trunc('day', m."measured_at") DESC, (${rankM}), m."measured_at" DESC `, diff --git a/src/lib/jobs/__tests__/boss-instance.test.ts b/src/lib/jobs/__tests__/boss-instance.test.ts index 08b70ed83..6b1950999 100644 --- a/src/lib/jobs/__tests__/boss-instance.test.ts +++ b/src/lib/jobs/__tests__/boss-instance.test.ts @@ -70,6 +70,7 @@ describe("web-process pg-boss producer", () => { connectionString: "postgres://healthlog@test/db", max: 2, connectionTimeoutMillis: 20_000, + options: "-c TimeZone=UTC", migrate: false, schedule: false, supervise: false, @@ -111,6 +112,7 @@ describe("worker pg-boss pool", () => { connectionString: "postgres://healthlog@test/db", max: 2, connectionTimeoutMillis: 20_000, + options: "-c TimeZone=UTC", }); }); }); diff --git a/src/lib/jobs/data-arrival.ts b/src/lib/jobs/data-arrival.ts index 3e048ee61..499689940 100644 --- a/src/lib/jobs/data-arrival.ts +++ b/src/lib/jobs/data-arrival.ts @@ -147,7 +147,7 @@ async function claimReaction( ), operator_tokens = GREATEST(0, operator_tokens - ${operatorReserved}), message_count = GREATEST(0, message_count - 1), - updated_at = NOW() + updated_at = (NOW() AT TIME ZONE 'UTC') WHERE user_id = ${arrival.userId} AND date_key = ${current.generationBudgetDateKey} `; diff --git a/src/lib/jobs/reminder/cleanup-handlers.ts b/src/lib/jobs/reminder/cleanup-handlers.ts index 0c6927965..e15318865 100644 --- a/src/lib/jobs/reminder/cleanup-handlers.ts +++ b/src/lib/jobs/reminder/cleanup-handlers.ts @@ -294,7 +294,7 @@ export async function handleRateLimitCleanup( const p = getWorkerPrisma(); try { const result = await p.$executeRaw` - DELETE FROM rate_limits WHERE reset_at < NOW() + DELETE FROM rate_limits WHERE reset_at < (NOW() AT TIME ZONE 'UTC') `; evt.addMeta("rate_limit_cleanup_deleted", result); return jobDone({ deleted: result }); diff --git a/src/lib/jobs/step-consolidation-repair.ts b/src/lib/jobs/step-consolidation-repair.ts index 09c8a3bc3..0c1108a9e 100644 --- a/src/lib/jobs/step-consolidation-repair.ts +++ b/src/lib/jobs/step-consolidation-repair.ts @@ -274,7 +274,7 @@ export async function enqueueBootTimeStepConsolidationRepair( FROM measurements t WHERE t."type" = 'ACTIVITY_STEPS' AND t."deleted_at" IS NOT NULL - AND t."deleted_at" >= now() - make_interval(days => ${TOMBSTONE_RETENTION_DAYS}) + AND t."deleted_at" >= (NOW() AT TIME ZONE 'UTC') - make_interval(days => ${TOMBSTONE_RETENTION_DAYS}) AND t."source" IN ('GOOGLE_HEALTH', 'FITBIT') AND t."external_id" LIKE 'stats:%' AND NOT EXISTS ( diff --git a/src/lib/measurements/repair-apple-health-distance.ts b/src/lib/measurements/repair-apple-health-distance.ts index 19cc9364c..9b0178cbf 100644 --- a/src/lib/measurements/repair-apple-health-distance.ts +++ b/src/lib/measurements/repair-apple-health-distance.ts @@ -326,7 +326,7 @@ export async function applyAppleHealthDistanceRepair( SET value = value * ${factor}, unit = 'm', sync_version = sync_version + 1, - updated_at = NOW() + updated_at = (NOW() AT TIME ZONE 'UTC') WHERE id = ANY(${chunk})`; } await auditLog(APPLE_HEALTH_DISTANCE_REPAIR_ACTION, { diff --git a/src/lib/notifications/delivery-identity.ts b/src/lib/notifications/delivery-identity.ts index 97c9710f0..c68b9ea35 100644 --- a/src/lib/notifications/delivery-identity.ts +++ b/src/lib/notifications/delivery-identity.ts @@ -166,7 +166,7 @@ export async function claimManagedGuardianEgressAuthorization( ${delivery.recipientUserId}, ${channel}, ${eventType}, - clock_timestamp() + (clock_timestamp() AT TIME ZONE 'UTC') WHERE EXISTS ( SELECT 1 FROM "account_grants" @@ -177,7 +177,7 @@ export async function claimManagedGuardianEgressAuthorization( AND "revoked_at" IS NULL AND ( "expires_at" IS NULL - OR "expires_at" > clock_timestamp() + OR "expires_at" > (clock_timestamp() AT TIME ZONE 'UTC') ) ) RETURNING "id" diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 6914c167c..3d764b8e8 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -31,14 +31,14 @@ export async function checkRateLimit( const rows = await prisma.$queryRaw<{ count: number; reset_at: Date }[]>` INSERT INTO rate_limits (key, count, reset_at) - VALUES (${key}, 1, NOW() + ${windowInterval}::interval) + VALUES (${key}, 1, (NOW() AT TIME ZONE 'UTC') + ${windowInterval}::interval) ON CONFLICT (key) DO UPDATE SET count = CASE - WHEN rate_limits.reset_at < NOW() THEN 1 + WHEN rate_limits.reset_at < (NOW() AT TIME ZONE 'UTC') THEN 1 ELSE rate_limits.count + 1 END, reset_at = CASE - WHEN rate_limits.reset_at < NOW() THEN NOW() + ${windowInterval}::interval + WHEN rate_limits.reset_at < (NOW() AT TIME ZONE 'UTC') THEN (NOW() AT TIME ZONE 'UTC') + ${windowInterval}::interval ELSE rate_limits.reset_at END RETURNING count, reset_at @@ -80,7 +80,7 @@ export async function refundRateLimit(key: string): Promise { await prisma.$executeRaw` UPDATE rate_limits SET count = GREATEST(count - 1, 0) - WHERE key = ${key} AND reset_at >= NOW() + WHERE key = ${key} AND reset_at >= (NOW() AT TIME ZONE 'UTC') `; } diff --git a/tests/integration/session-time-zone.test.ts b/tests/integration/session-time-zone.test.ts new file mode 100644 index 000000000..3743de7e8 --- /dev/null +++ b/tests/integration/session-time-zone.test.ts @@ -0,0 +1,252 @@ +/** + * HealthLog works on a database whose default time zone is not UTC. + * + * Stored times are UTC in `timestamp` columns without a zone; Postgres reads + * `now()` through the session time zone. On a shared instance set to + * Europe/Berlin a fresh passkey challenge used to be expired before the + * prompt closed. This file sets the test database's default to Europe/Berlin + * (every connection opened afterwards starts in it) and drives the expiry + * paths through the application's own client, which pins UTC per + * connection. A second block takes the pin away and shows that the raw-SQL + * expiry checks hold on their own. + */ +import { Client } from "pg"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import { PrismaClient } from "@/generated/prisma/client"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const ZONE = "Europe/Berlin"; +const USER_ID = "user-session-time-zone"; + +let databaseName = ""; + +async function rawClient(): Promise { + // No startup options: the connection takes the database default. + const client = new Client({ connectionString: process.env.DATABASE_URL }); + await client.connect(); + return client; +} + +async function setDatabaseZone(zone: string | null): Promise { + const client = await rawClient(); + try { + if (!databaseName) { + const { rows } = await client.query<{ name: string }>( + "SELECT current_database() AS name", + ); + databaseName = rows[0].name; + } + await client.query( + zone + ? `ALTER DATABASE "${databaseName}" SET timezone TO '${zone}'` + : `ALTER DATABASE "${databaseName}" RESET timezone`, + ); + } finally { + await client.end(); + } +} + +/** A Prisma client WITHOUT the pin: its sessions run in Europe/Berlin. */ +function unpinnedClient(): PrismaClient { + return new PrismaClient({ + adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL! }), + }); +} + +async function challenge( + db: PrismaClient, + type: string, + expiresInMs = 60_000, +): Promise { + const row = await db.authChallenge.create({ + data: { + userId: USER_ID, + challenge: `material-${type}`, + type, + expiresAt: new Date(Date.now() + expiresInMs), + }, + }); + return row.id; +} + +beforeAll(async () => { + // Before any pooled connection of this file opens. + await setDatabaseZone(ZONE); +}); + +afterAll(async () => { + // The container is shared with every other file of the run. + await setDatabaseZone(null); +}); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + await getPrismaClient().user.create({ + data: { id: USER_ID, username: "tz-user", email: "tz@example.test" }, + }); +}); + +describe("on a database whose default zone is Europe/Berlin", () => { + it("a connection without the pin really runs in Europe/Berlin", async () => { + const client = await rawClient(); + try { + const { rows } = await client.query<{ TimeZone: string }>( + "SHOW TimeZone", + ); + expect(rows[0].TimeZone).toBe(ZONE); + } finally { + await client.end(); + } + }); + + it("the application's pool runs in UTC and the boot check says so", async () => { + const { runBootTimeZoneCheck } = await import("@/lib/boot/time-zone-check"); + await expect(runBootTimeZoneCheck(getPrismaClient())).resolves.toEqual({ + state: "utc", + timeZone: "UTC", + }); + const unpinned = unpinnedClient(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + await expect(runBootTimeZoneCheck(unpinned)).resolves.toEqual({ + state: "mismatch", + timeZone: ZONE, + }); + expect(warn).toHaveBeenCalledTimes(1); + } finally { + warn.mockRestore(); + await unpinned.$disconnect(); + } + }); + + it("a fresh passkey registration challenge is accepted", async () => { + const { consumeRegistrationChallenge } = await import("@/lib/auth/passkey"); + const { hashToken } = await import("@/lib/auth/hmac"); + const sessionId = "session-tz"; + const id = await challenge( + getPrismaClient(), + `passkey-registration:v1:${hashToken(sessionId)}`, + ); + const claim = await consumeRegistrationChallenge({ + challengeId: id, + userId: USER_ID, + sessionId, + }); + expect(claim.ok).toBe(true); + if (claim.ok) { + // Stamped by the column default: UTC, not Berlin wall time. + expect(Math.abs(claim.createdAt.getTime() - Date.now())).toBeLessThan( + 60_000, + ); + } + }); + + it("a fresh login challenge is claimed, an expired one is not", async () => { + const { claimAuthChallenge } = await import("@/lib/auth/passkey"); + const fresh = await challenge(getPrismaClient(), "authentication"); + await expect( + claimAuthChallenge({ challengeId: fresh, type: "authentication" }), + ).resolves.not.toBeNull(); + const stale = await challenge(getPrismaClient(), "authentication", -1_000); + await expect( + claimAuthChallenge({ challengeId: stale, type: "authentication" }), + ).resolves.toBeNull(); + }); + + it("a session is valid until it expires and is stamped in UTC", async () => { + const { validateSessionFromCookieValue } = + await import("@/lib/auth/session"); + const { hashToken } = await import("@/lib/auth/hmac"); + const live = await getPrismaClient().session.create({ + data: { + userId: USER_ID, + tokenHash: hashToken("hls_live-secret"), + expiresAt: new Date(Date.now() + 60 * 60_000), + }, + }); + await getPrismaClient().session.create({ + data: { + userId: USER_ID, + tokenHash: hashToken("hls_expired-secret"), + expiresAt: new Date(Date.now() - 60_000), + }, + }); + expect(Math.abs(live.createdAt.getTime() - Date.now())).toBeLessThan( + 60_000, + ); + await expect( + validateSessionFromCookieValue("hls_live-secret"), + ).resolves.not.toBeNull(); + await expect( + validateSessionFromCookieValue("hls_expired-secret"), + ).resolves.toBeNull(); + }); + + it("the native sign-in handoff sees a session begun inside it as fresh", async () => { + // The handoff link is good only for a session created after it began: + // `session.createdAt >= startedAt`, with `startedAt` read from the + // database clock and `createdAt` stamped by the column default. + const { nativeHandoffDbNow } = + await import("@/lib/auth/native-web-handoff"); + const startedAt = await nativeHandoffDbNow(); + // The driver adapter reads a `timestamptz` as if its offset were +00:00, + // so on a Berlin session this instant came back two hours late. + expect(Math.abs(startedAt.getTime() - Date.now())).toBeLessThan(60_000); + const created = await getPrismaClient().$queryRaw<{ created_at: Date }[]>` + INSERT INTO sessions (id, user_id, token_hash, expires_at) + VALUES ('session-handoff', ${USER_ID}, 'handoff-hash', + (NOW() AT TIME ZONE 'UTC') + INTERVAL '1 hour') + RETURNING created_at + `; + const createdAt = created[0].created_at; + expect(createdAt.getTime()).toBeGreaterThanOrEqual(startedAt.getTime()); + expect(createdAt.getTime() - startedAt.getTime()).toBeLessThan(60_000); + }); + + it("a rate-limit window resets one window from now", async () => { + const { checkRateLimit } = await import("@/lib/rate-limit"); + const before = Date.now(); + const result = await checkRateLimit("tz:window", 5, 60_000); + expect(result.allowed).toBe(true); + expect(result.resetAt - before).toBeGreaterThan(55_000); + expect(result.resetAt - before).toBeLessThan(65_000); + }); +}); + +describe("without the per-connection pin", () => { + it("the raw-SQL challenge expiry still holds on a Berlin session", async () => { + const unpinned = unpinnedClient(); + try { + const [{ TimeZone }] = + await unpinned.$queryRawUnsafe<{ TimeZone: string }[]>("SHOW TimeZone"); + expect(TimeZone).toBe(ZONE); + vi.resetModules(); + vi.doMock("@/lib/db", async (importOriginal) => ({ + ...(await importOriginal()), + prisma: unpinned, + })); + const { claimAuthChallenge } = await import("@/lib/auth/passkey"); + const fresh = await challenge(unpinned, "authentication"); + await expect( + claimAuthChallenge({ challengeId: fresh, type: "authentication" }), + ).resolves.not.toBeNull(); + const stale = await challenge(unpinned, "authentication", -1_000); + await expect( + claimAuthChallenge({ challengeId: stale, type: "authentication" }), + ).resolves.toBeNull(); + } finally { + vi.doUnmock("@/lib/db"); + await unpinned.$disconnect(); + } + }); +});