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(); + } + }); +});