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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-11 12:31:37 +02:00
1 parent cefc8c15c1
commit f65d25b0d0
22 files changed
+469 -66

No files matched your search

+20
View File
@@ -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
+5 -2
View File
@@ -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.