diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c5d2e1821..2c0cf6fc7 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -26952,7 +26952,7 @@ components: - ko today: description: "Optional override of the reference date used to resolve relative phrases (\"tomorrow\", \"next Monday\"). - Format: `YYYY-MM-DD`. Defaults to the server's UTC day." + Format: `YYYY-MM-DD`. Defaults to the account's own day in its time zone." type: string pattern: ^\d{4}-\d{2}-\d{2}$ required: 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/e2e/coach-open-at-end.spec.ts b/e2e/coach-open-at-end.spec.ts index ef3ef4fd1..a86f0426c 100644 --- a/e2e/coach-open-at-end.spec.ts +++ b/e2e/coach-open-at-end.spec.ts @@ -185,12 +185,26 @@ test.describe("Coach: a saved conversation opens at its end", () => { waitUntil: "domcontentloaded", }); - const thread = page.locator('[data-slot="coach-message-thread"]'); - await expect(thread).toBeVisible({ timeout: 15_000 }); + // The empty state carries the same `coach-message-thread` slot, so the + // thread being visible says nothing about the conversation having + // arrived; the height used to be read from the empty state on a slow + // run. Wait for the frame only a loaded thread renders, and for every + // stored answer in it. + await expect( + page.locator('[data-slot="coach-message-thread-frame"]'), + ).toBeVisible({ timeout: 15_000 }); + await expect( + page.locator('[data-slot="coach-assistant-turn"]'), + ).toHaveCount(8, { timeout: 15_000 }); + const thread = page.locator( + '[data-slot="coach-message-thread-frame"] [data-slot="coach-message-thread"]', + ); // The thread scrolls at all: otherwise "at the end" proves nothing. - expect( - await thread.evaluate((el) => el.scrollHeight > el.clientHeight + 200), - ).toBe(true); + await expect + .poll(() => + thread.evaluate((el) => el.scrollHeight > el.clientHeight + 200), + ) + .toBe(true); const chart = page.locator( '[data-slot="coach-result-chart"][data-ref="r1"]', diff --git a/e2e/day-view.spec.ts b/e2e/day-view.spec.ts index 3b99bacd5..0aae88187 100644 --- a/e2e/day-view.spec.ts +++ b/e2e/day-view.spec.ts @@ -275,6 +275,25 @@ test.describe("the day view", () => { ), ) .toBe(true); + // On a phone the calendar is a sheet over the day's own sheet. Focus + // arrives before Radix has handed it the Escape key: in between, the day + // underneath still holds it. The handover shows as the day's layer + // turning inert (`pointer-events: none`) once the calendar is the top + // layer; wait for that, not for a fixed time. The docked column is no + // dialog and has no such handover. + if (!desktop) + await expect + .poll(() => + panel(page).evaluate( + (el) => + new Promise((resolve) => + requestAnimationFrame(() => + setTimeout(() => resolve(getComputedStyle(el).pointerEvents)), + ), + ), + ), + ) + .toBe("none"); await page.keyboard.press("Escape"); await expect(picker).toHaveCount(0); await expect(panel(page)).toBeVisible(); diff --git a/e2e/documents.spec.ts b/e2e/documents.spec.ts index 8e5a8f76d..a186b6996 100644 --- a/e2e/documents.spec.ts +++ b/e2e/documents.spec.ts @@ -232,6 +232,45 @@ test.describe("document vault", () => { await expect(sheet.locator("iframe")).toBeVisible({ timeout: 5_000 }); }); + test("a PDF opens in a new browser tab from the fetched bytes", async ({ + page, + }) => { + // Headless Chromium has no PDF viewer and turns a PDF tab into a + // download, so the tab itself is not observable here. Record what the + // control hands to `window.open` instead, and read the bytes behind it. + await page.addInitScript(() => { + const calls: unknown[][] = []; + (window as unknown as { __opened: unknown[][] }).__opened = calls; + window.open = (...args: unknown[]) => { + calls.push(args); + return null; + }; + }); + await page.goto(`/documents?episode=${KNIE_EPISODE_ID}&view=compact&q=MRT`); + await openButton(page, "MRT Knie").click(); + const sheet = page.getByRole("dialog", { name: "MRT Knie" }); + await expect(sheet.locator("iframe")).toBeVisible({ timeout: 5_000 }); + const open = sheet.locator('[data-slot="document-open-in-tab"]'); + // Enabled once the preview's bytes are in hand. + await expect(open).toBeEnabled(); + if (process.env.E2E_SHOT_DIR) { + await page.screenshot({ + path: `${process.env.E2E_SHOT_DIR}/open-in-tab-${test.info().project.name}.png`, + }); + } + await open.click(); + const opened = await page.evaluate( + () => (window as unknown as { __opened: string[][] }).__opened, + ); + // The object URL the preview already shows, not the serve route: a bare + // navigation would carry no record-session assertion. + const previewSrc = await sheet.locator("iframe").getAttribute("src"); + expect(previewSrc).toMatch(/^blob:/); + expect(opened).toEqual([[previewSrc, "_blank", "noopener"]]); + // The sheet stays where it was. + await expect(sheet).toBeVisible(); + }); + test("doctor flow B: the condition filter reaches the MRT report", async ({ page, }) => { diff --git a/messages/de.json b/messages/de.json index 42e4ed414..3ddbf9f68 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2844,7 +2844,7 @@ "coach": { "refusalOutOfScope": "Ich kann nur bei den Gesundheitsdaten in deinem Log helfen: Messwerte, Medikamente, Stimmung, Trends. Fragen außerhalb dieses Themas beantworte ich nicht.", "refusalInjection": "In deiner Nachricht stehen Anweisungen, die meine Vorgaben überschreiben sollen. Ich bin der HealthLog-Coach und fasse ausschließlich deine eigenen Gesundheitsdaten zusammen. Formuliere deine Frage bitte in diesem Rahmen neu.", - "errorBudget": "Du hast das heutige Coach-Limit erreicht. Das Budget setzt sich um Mitternacht (UTC) zurück.", + "errorBudget": "Du hast das heutige Coach-Limit erreicht. Das Budget setzt sich um Mitternacht in deiner Zeitzone zurück.", "errorProvider": "Der Coach konnte gerade keinen Auswertungs-Anbieter erreichen. Bitte versuche es in einem Moment erneut.", "errorDemo": "In der Demo antwortet der Coach nicht.", "errorNoProvider": "Der Coach liest deine erfassten Gesundheitsdaten und beantwortet Fragen dazu (keine medizinische Beratung). Dafür braucht HealthLog einen KI-Anbieter. Verbinde deinen eigenen Schlüssel oder ein lokales Modell.", @@ -3000,7 +3000,7 @@ "settingsSaved": "Gespeichert", "settingsCancel": "Abbrechen", "dailyLimitTitle": "Heute-Limit erreicht", - "dailyLimitBody": "Tageslimit erreicht. Das Budget setzt sich um Mitternacht (UTC) zurück.", + "dailyLimitBody": "Tageslimit erreicht. Das Budget setzt sich um Mitternacht in deiner Zeitzone zurück.", "providerRateLimitTitle": "Provider rate-limited", "providerRateLimitBody": "Provider ist temporär überlastet; Reset in ~5 min.", "cluster": { @@ -10879,6 +10879,7 @@ "saveError": "Die Änderung konnte nicht gespeichert werden.", "deleteError": "Das Dokument konnte nicht gelöscht werden.", "download": "Herunterladen", + "openInNewTab": "In neuem Tab öffnen", "delete": "Löschen", "titleLabel": "Titel", "titlePlaceholder": "Titel hinzufügen", diff --git a/messages/en.json b/messages/en.json index e275f8e44..22c59c452 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2844,7 +2844,7 @@ "coach": { "refusalOutOfScope": "I can only help with the health metrics in your log: measurements, medications, mood, trends. I cannot answer questions outside that scope.", "refusalInjection": "I noticed wording that tries to override my instructions. I am the HealthLog Coach and only summarise your own health data. Please rephrase your question in those terms.", - "errorBudget": "You've reached today's Coach usage limit. The budget refreshes at midnight UTC.", + "errorBudget": "You've reached today's Coach usage limit. The budget refreshes at midnight in your time zone.", "errorProvider": "The Coach could not reach an Insights provider just now. Please try again in a moment.", "errorDemo": "The Coach doesn't answer in the demo.", "errorNoProvider": "The Coach reads your logged health data and answers questions about it (not medical advice). HealthLog needs an AI provider for that. Connect your own key or a local model.", @@ -3000,7 +3000,7 @@ "settingsSaved": "Saved", "settingsCancel": "Cancel", "dailyLimitTitle": "Daily limit reached", - "dailyLimitBody": "Daily limit reached. The budget refreshes at midnight UTC.", + "dailyLimitBody": "Daily limit reached. The budget refreshes at midnight in your time zone.", "providerRateLimitTitle": "Provider rate-limited", "providerRateLimitBody": "Provider temporarily rate-limited; retry in ~5 min.", "cluster": { @@ -10879,6 +10879,7 @@ "saveError": "Couldn't save the change.", "deleteError": "Couldn't delete the document.", "download": "Download", + "openInNewTab": "Open in new tab", "delete": "Delete", "titleLabel": "Title", "titlePlaceholder": "Add a title", diff --git a/messages/es.json b/messages/es.json index ee467e577..6bae1f78f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2844,7 +2844,7 @@ "coach": { "refusalOutOfScope": "Solo puedo ayudarte con los datos de salud de tu registro: mediciones, medicamentos, estado de ánimo, tendencias. Preguntas fuera de ese ámbito no las respondo.", "refusalInjection": "Tu mensaje contiene instrucciones que pretenden sobrescribir mis indicaciones. Soy el coach de HealthLog y solo resumo tus propios datos de salud. Reformula tu pregunta dentro de ese marco.", - "errorBudget": "Has alcanzado el límite diario del coach. El cupo se reinicia a medianoche (UTC).", + "errorBudget": "Has alcanzado el límite diario del coach. El cupo se reinicia a medianoche en tu zona horaria.", "errorProvider": "El coach no ha podido contactar con un proveedor de análisis ahora mismo. Inténtalo en un momento.", "errorDemo": "En la demo, el Coach no responde.", "errorNoProvider": "El coach lee tus datos de salud registrados y responde preguntas sobre ellos (no es consejo médico). HealthLog necesita un proveedor de IA para ello: conecta tu propia clave o un modelo local.", @@ -3000,7 +3000,7 @@ "settingsSaved": "Guardado", "settingsCancel": "Cancelar", "dailyLimitTitle": "Límite diario alcanzado", - "dailyLimitBody": "Límite diario alcanzado. El cupo se reinicia a medianoche (UTC).", + "dailyLimitBody": "Límite diario alcanzado. El cupo se reinicia a medianoche en tu zona horaria.", "providerRateLimitTitle": "Proveedor con límite de tasa", "providerRateLimitBody": "El proveedor está temporalmente saturado; reinicio en ~5 min.", "cluster": { @@ -10879,6 +10879,7 @@ "saveError": "No se pudo guardar el cambio.", "deleteError": "No se pudo eliminar el documento.", "download": "Descargar", + "openInNewTab": "Abrir en una pestaña nueva", "delete": "Eliminar", "titleLabel": "Título", "titlePlaceholder": "Añadir un título", diff --git a/messages/fr.json b/messages/fr.json index 0b07d8272..ebec3595d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2844,7 +2844,7 @@ "coach": { "refusalOutOfScope": "Je ne peux aider que pour les données de santé de ton journal : mesures, médicaments, humeur, tendances. Les questions hors de ce périmètre ne sont pas traitées.", "refusalInjection": "Ton message contient des instructions qui cherchent à remplacer mes consignes. Je suis le coach HealthLog et je ne résume que tes propres données de santé. Reformule ta question dans ce cadre.", - "errorBudget": "Tu as atteint la limite quotidienne du coach. Le quota se réinitialise à minuit (UTC).", + "errorBudget": "Tu as atteint la limite quotidienne du coach. Le quota se réinitialise à minuit dans ton fuseau horaire.", "errorProvider": "Le coach n’a pas pu joindre un fournisseur d’analyse maintenant. Réessaie dans un instant.", "errorDemo": "Dans la démo, le Coach ne répond pas.", "errorNoProvider": "Le coach lit tes données de santé enregistrées et répond aux questions à leur sujet (pas un avis médical). HealthLog a besoin d'un fournisseur d'IA pour cela : connecte ta propre clé ou un modèle local.", @@ -3000,7 +3000,7 @@ "settingsSaved": "Enregistré", "settingsCancel": "Annuler", "dailyLimitTitle": "Limite quotidienne atteinte", - "dailyLimitBody": "Limite quotidienne atteinte. Le quota se réinitialise à minuit (UTC).", + "dailyLimitBody": "Limite quotidienne atteinte. Le quota se réinitialise à minuit dans ton fuseau horaire.", "providerRateLimitTitle": "Fournisseur limité en débit", "providerRateLimitBody": "Le fournisseur est temporairement saturé ; réinitialisation dans ~5 min.", "cluster": { @@ -10879,6 +10879,7 @@ "saveError": "Impossible d'enregistrer la modification.", "deleteError": "Impossible de supprimer le document.", "download": "Télécharger", + "openInNewTab": "Ouvrir dans un nouvel onglet", "delete": "Supprimer", "titleLabel": "Titre", "titlePlaceholder": "Ajouter un titre", diff --git a/messages/it.json b/messages/it.json index 8de19030c..992c4b835 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2844,7 +2844,7 @@ "coach": { "refusalOutOfScope": "Posso aiutarti solo con i dati di salute del tuo log: misurazioni, farmaci, umore, trend. Domande fuori da questo ambito non le rispondo.", "refusalInjection": "Nel tuo messaggio ci sono istruzioni che cercano di sovrascrivere le mie indicazioni. Sono il coach di HealthLog e riassumo solo i tuoi dati di salute: riformula la domanda in questo perimetro.", - "errorBudget": "Hai raggiunto il limite giornaliero del coach. Il budget si reimposta a mezzanotte (UTC).", + "errorBudget": "Hai raggiunto il limite giornaliero del coach. Il budget si reimposta a mezzanotte nel tuo fuso orario.", "errorProvider": "Il coach non è riuscito a contattare un provider di analisi ora. Riprova fra poco.", "errorDemo": "Nella demo il Coach non risponde.", "errorNoProvider": "Il coach legge i tuoi dati di salute registrati e risponde a domande su di essi (non è consulenza medica). HealthLog ha bisogno di un provider IA per questo: collega la tua chiave o un modello locale.", @@ -3000,7 +3000,7 @@ "settingsSaved": "Salvato", "settingsCancel": "Annulla", "dailyLimitTitle": "Limite giornaliero raggiunto", - "dailyLimitBody": "Limite giornaliero raggiunto. Il budget si reimposta a mezzanotte (UTC).", + "dailyLimitBody": "Limite giornaliero raggiunto. Il budget si reimposta a mezzanotte nel tuo fuso orario.", "providerRateLimitTitle": "Provider con rate-limit", "providerRateLimitBody": "Il provider è temporaneamente sovraccarico; reset in ~5 min.", "cluster": { @@ -10879,6 +10879,7 @@ "saveError": "Impossibile salvare la modifica.", "deleteError": "Impossibile eliminare il documento.", "download": "Scarica", + "openInNewTab": "Apri in una nuova scheda", "delete": "Elimina", "titleLabel": "Titolo", "titlePlaceholder": "Aggiungi un titolo", diff --git a/messages/ko.json b/messages/ko.json index a4fc6b89f..a813b4978 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -2844,7 +2844,7 @@ "coach": { "refusalOutOfScope": "기록에 있는 건강 지표(측정값, 약, 기분, 추이)만 도와드릴 수 있어요. 그 밖의 질문에는 답할 수 없어요.", "refusalInjection": "제 지시를 덮어쓰려는 표현이 보였어요. 저는 HealthLog 코치이고 회원님의 건강 데이터만 정리해 드려요. 그 범위로 질문을 다시 적어 주세요.", - "errorBudget": "오늘 코치 사용 한도에 도달했어요. 사용량은 UTC 자정에 초기화돼요.", + "errorBudget": "오늘 코치 사용 한도에 도달했어요. 사용량은 내 시간대의 자정에 초기화돼요.", "errorProvider": "지금은 코치가 인사이트 제공자에 연결하지 못했어요. 잠시 후 다시 시도해 주세요.", "errorDemo": "데모에서는 코치가 답하지 않아요.", "errorNoProvider": "코치는 기록된 건강 데이터를 읽고 그에 대한 질문에 답해요(의학적 조언은 아니에요). 그러려면 HealthLog에 AI 제공자가 필요해요. 직접 발급한 키나 로컬 모델을 연결해 주세요.", @@ -3000,7 +3000,7 @@ "settingsSaved": "저장됨", "settingsCancel": "취소", "dailyLimitTitle": "하루 한도 도달", - "dailyLimitBody": "하루 한도에 도달했어요. 사용량은 UTC 자정에 초기화돼요.", + "dailyLimitBody": "하루 한도에 도달했어요. 사용량은 내 시간대의 자정에 초기화돼요.", "providerRateLimitTitle": "제공자 요청 제한", "providerRateLimitBody": "제공자가 일시적으로 요청을 제한하고 있어요. 5분쯤 뒤에 다시 시도해 주세요.", "cluster": { @@ -10879,6 +10879,7 @@ "saveError": "변경 내용을 저장하지 못했어요.", "deleteError": "문서를 삭제하지 못했어요.", "download": "다운로드", + "openInNewTab": "새 탭에서 열기", "delete": "삭제", "titleLabel": "제목", "titlePlaceholder": "제목 입력", diff --git a/messages/pl.json b/messages/pl.json index 9c55a4cc9..9950e6411 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2844,7 +2844,7 @@ "coach": { "refusalOutOfScope": "Mogę pomóc tylko w sprawach danych zdrowotnych z twojego logu: pomiarów, leków, nastroju, trendów. Pytań spoza tego zakresu nie podejmuję.", "refusalInjection": "Twoja wiadomość zawiera instrukcje, które próbują nadpisać moje wytyczne. Jestem coachem HealthLog i podsumowuję wyłącznie twoje własne dane zdrowotne. Przeformułuj pytanie w tych ramach.", - "errorBudget": "Osiągnięto dzienny limit coacha. Limit resetuje się o północy (UTC).", + "errorBudget": "Osiągnięto dzienny limit coacha. Limit resetuje się o północy w Twojej strefie czasowej.", "errorProvider": "Coach nie mógł teraz skontaktować się z dostawcą analizy. Spróbuj za chwilę.", "errorDemo": "W wersji demo Coach nie odpowiada.", "errorNoProvider": "Coach czyta Twoje zapisane dane zdrowotne i odpowiada na pytania o nie (to nie porada medyczna). HealthLog potrzebuje do tego dostawcy AI: połącz własny klucz lub model lokalny.", @@ -3000,7 +3000,7 @@ "settingsSaved": "Zapisano", "settingsCancel": "Anuluj", "dailyLimitTitle": "Osiągnięto dzienny limit", - "dailyLimitBody": "Osiągnięto dzienny limit. Limit resetuje się o północy (UTC).", + "dailyLimitBody": "Osiągnięto dzienny limit. Limit resetuje się o północy w Twojej strefie czasowej.", "providerRateLimitTitle": "Dostawca z ograniczeniem", "providerRateLimitBody": "Dostawca jest tymczasowo przeciążony; reset za ~5 min.", "cluster": { @@ -10879,6 +10879,7 @@ "saveError": "Nie udało się zapisać zmiany.", "deleteError": "Nie udało się usunąć dokumentu.", "download": "Pobierz", + "openInNewTab": "Otwórz w nowej karcie", "delete": "Usuń", "titleLabel": "Tytuł", "titlePlaceholder": "Dodaj tytuł", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9741a7925..fc8910d6d 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/admin/provider-health/route.ts b/src/app/api/admin/provider-health/route.ts index 199d99923..bfe9eeffd 100644 --- a/src/app/api/admin/provider-health/route.ts +++ b/src/app/api/admin/provider-health/route.ts @@ -73,11 +73,13 @@ interface ProviderHealthSummary { const CENTRAL_TYPES = [...OPERATOR_HELD_PROVIDER_TYPES]; export const GET = apiHandler(async () => { - await requireAdmin(); + const { user } = await requireAdmin(); annotate({ action: { name: "admin.provider-health.get" } }); - // The UTC day the ledger buckets by — the same key every reservation writes. - const dateKey = buildDateKey(); + // The ledger buckets each account by its own local day; the operator's + // "today" is the day in the operator's own zone, which on a single-zone + // instance is everyone's. + const dateKey = await buildDateKey(user.id); const groups = await prisma.providerHealth.groupBy({ // `lastStatus` joins the grouping key rather than an aggregate: the diff --git a/src/app/api/ai/test/route.ts b/src/app/api/ai/test/route.ts index eb8166511..1a1e25402 100644 --- a/src/app/api/ai/test/route.ts +++ b/src/app/api/ai/test/route.ts @@ -145,7 +145,7 @@ export const POST = apiHandler(async (request: NextRequest) => { // `admin-key` is the operator's own credential and draws the operator // ceiling; an override names the caller's own key, and every other resolved // type is the user's own egress, so those draw the user-plan ceiling. - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(user.id); const reservation = await reserveBudget( user.id, AI_TEST_MAX_TOKENS, diff --git a/src/app/api/documents/inbound/[id]/extract/route.ts b/src/app/api/documents/inbound/[id]/extract/route.ts index 1fc8338c5..e011909a8 100644 --- a/src/app/api/documents/inbound/[id]/extract/route.ts +++ b/src/app/api/documents/inbound/[id]/extract/route.ts @@ -183,7 +183,7 @@ async function reserveFor( providerType: ProviderChainType, tokens: number, ): Promise { - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(userId); const reservation = await reserveBudget( userId, tokens, diff --git a/src/app/api/documents/inbound/[id]/index/route.ts b/src/app/api/documents/inbound/[id]/index/route.ts index 7c01083fc..514c266ba 100644 --- a/src/app/api/documents/inbound/[id]/index/route.ts +++ b/src/app/api/documents/inbound/[id]/index/route.ts @@ -200,7 +200,7 @@ async function handleVisionIndex( const rl = await checkDocumentAiRateLimit(userId); if (!rl.allowed) return documentAiRateLimited(rl); - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(userId); const reservation = await reserveBudget( userId, DOCUMENT_INDEX_RESERVE_TOKENS, diff --git a/src/app/api/documents/inbound/[id]/original/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/original/__tests__/route.test.ts index c759ae581..dea3c6a7c 100644 --- a/src/app/api/documents/inbound/[id]/original/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/original/__tests__/route.test.ts @@ -240,6 +240,10 @@ describe("serving-posture header matrix (Class A inline vs Class B attachment)", ["image/heic", "photo.heic"], ["application/xml", "export.xml"], ["application/json", "export.json"], + // Upload refuses markup outright; a row that somehow carries one must + // still never render in-origin, where its script would run. + ["image/svg+xml", "drawing.svg"], + ["text/html", "page.html"], ])( "Class B %s → attachment + octet-stream, never inline", async (mime, name) => { diff --git a/src/app/api/documents/inbound/[id]/suggest/route.ts b/src/app/api/documents/inbound/[id]/suggest/route.ts index 28ac50c64..da9d78fcc 100644 --- a/src/app/api/documents/inbound/[id]/suggest/route.ts +++ b/src/app/api/documents/inbound/[id]/suggest/route.ts @@ -122,7 +122,7 @@ async function reserveFor( userId: string, providerType: ProviderChainType, ): Promise { - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(userId); const reservation = await reserveBudget( userId, AI_BUDGETS.documentAssist.maxTokens, diff --git a/src/app/api/documents/inbound/[id]/summary/route.ts b/src/app/api/documents/inbound/[id]/summary/route.ts index 9c2c7c484..c5e293890 100644 --- a/src/app/api/documents/inbound/[id]/summary/route.ts +++ b/src/app/api/documents/inbound/[id]/summary/route.ts @@ -216,7 +216,7 @@ async function reserveFor( ask: SummaryRequest, providerType: ProviderChainType, ): Promise { - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(ask.userId); const reservation = await reserveBudget( ask.userId, budgetFor(ask.mode), diff --git a/src/app/api/labs/ocr/extract/route.ts b/src/app/api/labs/ocr/extract/route.ts index 94a76ee03..a4e21e9d2 100644 --- a/src/app/api/labs/ocr/extract/route.ts +++ b/src/app/api/labs/ocr/extract/route.ts @@ -157,7 +157,7 @@ async function handleTextExtract( // v1.21.0 (F1) — the operator-cost cap applies only when the picked provider // egresses on the operator's own key; a BYOK / Codex / local pick runs on the // user's own plan and gets the generous user-plan ceiling. - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(userId); const reservation = await reserveBudget( userId, OCR_RESERVE_TOKENS.text, @@ -218,7 +218,7 @@ async function handleVisionExtract( // v1.21.0 (F1) — the operator-cost cap applies only when the picked vision // provider egresses on the operator's own key; a BYOK / Codex pick runs on // the user's own plan and gets the generous user-plan ceiling. - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(userId); const reservation = await reserveBudget( userId, OCR_RESERVE_TOKENS.vision, 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/app/api/medications/extract/route.ts b/src/app/api/medications/extract/route.ts index c8bee510e..aaf949bfb 100644 --- a/src/app/api/medications/extract/route.ts +++ b/src/app/api/medications/extract/route.ts @@ -225,7 +225,10 @@ async function handleExtract(request: NextRequest): Promise { chain.map((entry) => entry.providerType), ); - const today = todayOverride ?? buildDateKey(); + // The account's own today: "tomorrow" in a description means the + // person's tomorrow, and the same key is the budget day below. + const accountToday = await buildDateKey(userId); + const today = todayOverride ?? accountToday; const { systemPrompt, userPrompt } = buildMedicationExtractionPrompt({ text, today, @@ -239,7 +242,7 @@ async function handleExtract(request: NextRequest): Promise { // paying for their own key by the operator's ceiling. `resolveDailyCap` // charges the operator ceiling only when the chain's primary is the // operator's own credential; every other chain gets the user-plan ceiling. - const dateKey = buildDateKey(); + const dateKey = accountToday; const estimatedTokens = MAX_OUTPUT_TOKENS + Math.ceil((systemPrompt.length + userPrompt.length) / 4); diff --git a/src/app/api/oura/callback/__tests__/route.test.ts b/src/app/api/oura/callback/__tests__/route.test.ts index 07cb60d32..efaf97c1d 100644 --- a/src/app/api/oura/callback/__tests__/route.test.ts +++ b/src/app/api/oura/callback/__tests__/route.test.ts @@ -41,7 +41,12 @@ const { vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: bossMock })); -vi.mock("@/lib/oura/client", () => ({ exchangeCode: exchangeMock })); +vi.mock("@/lib/oura/client", async (importOriginal) => ({ + isAcceptedOuraIssuer: ( + await importOriginal() + ).isAcceptedOuraIssuer, + exchangeCode: exchangeMock, +})); vi.mock("@/lib/oura/credentials", () => ({ getOuraClientCredentials: getCredsMock, })); @@ -65,10 +70,12 @@ function makeReq(opts: { code?: string; state?: string; cookie?: string; + iss?: string; }): NextRequest { const params = new URLSearchParams(); if (opts.code) params.set("code", opts.code); if (opts.state) params.set("state", opts.state); + if (opts.iss) params.set("iss", opts.iss); return { url: `https://app.example/api/oura/callback?${params}`, cookies: { @@ -127,10 +134,11 @@ describe("GET /api/oura/callback", () => { const res = await GET( makeReq({ code: "auth-code", state: "S", cookie: "S" }), ); - expect(exchangeMock).toHaveBeenCalledWith("auth-code", { - clientId: "c", - clientSecret: "s", - }); + expect(exchangeMock).toHaveBeenCalledWith( + "auth-code", + { clientId: "c", clientSecret: "s" }, + null, + ); const data = userUpdate.mock.calls[0]![0].data; expect(data.ouraAccessTokenEncrypted).toBe("enc:at"); expect(data.ouraRefreshTokenEncrypted).toBe("enc:rt"); @@ -139,6 +147,34 @@ describe("GET /api/oura/callback", () => { }); }); +// Oura's current authorization server names itself on the callback +// (RFC 9207); the issuer travels on to pick the token endpoint. +describe("GET /api/oura/callback — issuer", () => { + it("passes Oura's issuer on to the code exchange", async () => { + const iss = "https://moi.ouraring.com/oauth/v2/ext/oauth-anonymous"; + const res = await GET(makeReq({ code: "c", state: "S", cookie: "S", iss })); + expect(exchangeMock).toHaveBeenCalledWith( + "c", + { clientId: "c", clientSecret: "s" }, + iss, + ); + expect(res.headers.get("location")).toContain("oura=connected"); + }); + + it("refuses a callback that names a foreign authorization server", async () => { + const res = await GET( + makeReq({ + code: "c", + state: "S", + cookie: "S", + iss: "https://evil.example/oauth", + }), + ); + expect(res.headers.get("location")).toContain("reason=issuer"); + expect(exchangeMock).not.toHaveBeenCalled(); + }); +}); + /** * v1.32.28 — connecting starts a sync straight away. The poll handler has * accepted a single-user payload since it was written; this callback is the diff --git a/src/app/api/oura/callback/route.ts b/src/app/api/oura/callback/route.ts index 553dd9c64..89f4284ba 100644 --- a/src/app/api/oura/callback/route.ts +++ b/src/app/api/oura/callback/route.ts @@ -4,7 +4,7 @@ import { getSession } from "@/lib/auth/session"; import { annotate, getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { encrypt } from "@/lib/crypto"; -import { exchangeCode } from "@/lib/oura/client"; +import { exchangeCode, isAcceptedOuraIssuer } from "@/lib/oura/client"; import { getOuraClientCredentials } from "@/lib/oura/credentials"; import { oauthStateCookieName, @@ -67,11 +67,20 @@ export const GET = apiHandler(async (request: NextRequest) => { if (!code) return ERR("nocode"); + // RFC 9207: an authorization server that names itself must be Oura's. The + // issuer also tells which token endpoint the code belongs to. Older apps + // send none, which is fine. + const iss = searchParams.get("iss"); + if (!isAcceptedOuraIssuer(iss)) { + annotate({ meta: { reason: "issuer" } }); + return ERR("issuer"); + } + try { const creds = await getOuraClientCredentials(userId); if (!creds) return ERR("nocreds"); - const tokens = await exchangeCode(code, creds); + const tokens = await exchangeCode(code, creds, iss); await prisma.user.update({ where: { id: userId }, diff --git a/src/components/day/day-layer.tsx b/src/components/day/day-layer.tsx index d0cfaec05..8c75f2ea3 100644 --- a/src/components/day/day-layer.tsx +++ b/src/components/day/day-layer.tsx @@ -717,6 +717,18 @@ function DaySheet({ event.preventDefault(); returnFocus(); }} + onEscapeKeyDown={(event) => { + // A dialog opened from inside the day (the date's calendar) is + // portaled out of the panel. For a moment after it opens, until + // Radix has re-rendered both layers, the panel still holds the + // Escape listener: an Escape aimed at the calendar then closed the + // whole day. Escape from inside another dialog is never the day's. + const owner = + event.target instanceof Element + ? event.target.closest('[role="dialog"]') + : null; + if (owner && owner !== panelRef.current) event.preventDefault(); + }} className={cn( // Quicker than the primitive's 500 ms, which read as lag on a // layer opened from a tap; no slide at all under reduced motion. diff --git a/src/components/documents/__tests__/document-detail-download.test.ts b/src/components/documents/__tests__/document-detail-download.test.ts index e3d331e5e..b59f64e1a 100644 --- a/src/components/documents/__tests__/document-detail-download.test.ts +++ b/src/components/documents/__tests__/document-detail-download.test.ts @@ -81,3 +81,41 @@ describe("the detail sheet's original-document transport", () => { ); }); }); + +/** + * "Open in new tab" hands the browser the types it renders itself, so a long + * report gets the browser's zoom and search. It must open the object URL the + * sheet already holds (a bare navigation to the route carries no record-session + * assertion and is refused once the session has been inside a shared record), + * and it must never appear for a type outside the inline serving class. + */ +describe("the detail sheet's open-in-new-tab control", () => { + const control = SHEET.slice( + SHEET.indexOf("function DocumentOpenInTabButton("), + SHEET.indexOf("/** Inline preview for Class A documents"), + ); + + it("exists", () => { + expect(control.length).toBeGreaterThan(0); + expect(SHEET).toMatch(/ { + expect(SHEET).toMatch( + /doc\.servingClass === "inline" \? \(\s*\n\s* { + expect(control).toContain('window.open(readyUrl, "_blank", "noopener")'); + expect(control).not.toContain("/api/documents"); + }); + + it("is disabled until the bytes are in hand, and when they failed", () => { + expect(control).toMatch(/disabled=\{readyUrl === null \|\| unavailable\}/); + expect(SHEET).toMatch( + / { + if (readyUrl) window.open(readyUrl, "_blank", "noopener"); + }} + > + + + {t("documents.detail.openInNewTab")} + + + ); +} + /** Inline preview for Class A documents; skeleton until the blob paints. */ function InlinePreview({ src, @@ -759,8 +797,8 @@ export function DocumentDetailSheet({ doc ? ( // Delete sits bottom-LEFT, quieted to a text button: the // bottom-right slot reads as the primary/confirm action, and a - // destructive control there is a footgun. Share + Download — the - // safe, expected actions — keep the trailing edge. On phones their + // destructive control there is a footgun. Share, open in a new tab and + // Download, the safe, expected actions, keep the trailing edge. On phones their // labels collapse to icon-only (aria-labelled) so three controls // never overflow the sheet at 360 px; the labels return at `sm+`.
@@ -824,6 +862,12 @@ export function DocumentDetailSheet({ )} + {doc.servingClass === "inline" ? ( + + ) : null} ", () => { />, ); expect(html).toContain( - "Daily limit reached. The budget refreshes at midnight UTC.", + "Daily limit reached. The budget refreshes at midnight in your time zone.", ); // v1.4.33 IW7 — fallback copy rewritten ("AI provider" -> "Insights // provider"). Both old and new strings should be absent here when diff --git a/src/components/insights/coach-panel/chat-bubble.tsx b/src/components/insights/coach-panel/chat-bubble.tsx index 9a712d453..dbd8738b2 100644 --- a/src/components/insights/coach-panel/chat-bubble.tsx +++ b/src/components/insights/coach-panel/chat-bubble.tsx @@ -560,7 +560,7 @@ function ChatBubbleImpl({ // v1.4.25 W5 — map server-emitted error codes to specific Coach // i18n keys. Distinct user-quota and provider-rate-limit copy so the - // user understands daily-cap (resets at UTC midnight) vs. transient + // user understands daily-cap (resets at their local midnight) vs. transient // provider load (retry in ~5 min). Codes that have no dedicated // translation fall back to the generic provider-unavailable copy. const errorKey = errorCode ? errorCodeToI18nKey(errorCode) : null; diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 9a3e2cb25..78981c5e6 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -28,6 +28,17 @@ export async function register() { console.error("[instrumentation] Encryption key check crashed:", err); } + // Every connection is pinned to UTC; say clearly if one is not. + try { + const [{ runBootTimeZoneCheck }, { prisma }] = await Promise.all([ + import("@/lib/boot/time-zone-check"), + import("@/lib/db"), + ]); + await runBootTimeZoneCheck(prisma); + } catch { + // A diagnostic must never break boot. + } + // Every Node process watches its own event loop — web-only // containers included; a stall report from the process serving // requests is the whole point. diff --git a/src/lib/__tests__/db.test.ts b/src/lib/__tests__/db.test.ts index 5cf7c93e6..9ca117bee 100644 --- a/src/lib/__tests__/db.test.ts +++ b/src/lib/__tests__/db.test.ts @@ -7,6 +7,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { buildSessionOptions, getConnectionBudget, + pinTimeZoneOnConnect, + SET_SESSION_TIME_ZONE, + timeZoneSessionOptions, getPgBossPoolMax, getPoolConnectionTimeoutMs, getPrismaPoolMax, @@ -135,14 +138,14 @@ describe("buildSessionOptions", () => { it("keeps work_mem when the timeout is disabled", () => { process.env.DATABASE_STATEMENT_TIMEOUT_MS = "0"; delete process.env.DATABASE_WORK_MEM; - expect(buildSessionOptions()).toBe("-c work_mem=16MB"); + expect(buildSessionOptions()).toBe("-c work_mem=16MB -c TimeZone=UTC"); }); it("sets the default work_mem beside the timeouts", () => { delete process.env.DATABASE_STATEMENT_TIMEOUT_MS; delete process.env.DATABASE_WORK_MEM; expect(buildSessionOptions()).toBe( - "-c statement_timeout=60000 -c idle_in_transaction_session_timeout=60000 -c work_mem=16MB", + "-c statement_timeout=60000 -c idle_in_transaction_session_timeout=60000 -c work_mem=16MB -c TimeZone=UTC", ); }); @@ -166,6 +169,38 @@ describe("buildSessionOptions", () => { }); }); +// Every session runs in UTC whatever the database default says: the stored +// `timestamp` values are UTC and `now()` reads through the session zone. +describe("session time zone pin", () => { + afterEach(() => { + delete process.env.DATABASE_SESSION_OPTIONS_DISABLED; + }); + + it("rides the startup options of every pool", () => { + expect(buildSessionOptions()).toContain("-c TimeZone=UTC"); + expect(timeZoneSessionOptions()).toBe("-c TimeZone=UTC"); + }); + + it("adds no connect hook while the startup options carry the pin", () => { + const on = vi.fn(); + pinTimeZoneOnConnect({ on } as never); + expect(on).not.toHaveBeenCalled(); + }); + + it("pins by statement on connect when the startup options are off", async () => { + process.env.DATABASE_SESSION_OPTIONS_DISABLED = "true"; + expect(timeZoneSessionOptions()).toBeUndefined(); + const on = vi.fn(); + pinTimeZoneOnConnect({ on } as never); + expect(on).toHaveBeenCalledWith("connect", expect.any(Function)); + const query = vi.fn().mockResolvedValue(undefined); + const hook = on.mock.calls[0][1] as (client: unknown) => void; + hook({ query }); + expect(query).toHaveBeenCalledWith(SET_SESSION_TIME_ZONE); + expect(SET_SESSION_TIME_ZONE).toBe("SET TIME ZONE 'UTC'"); + }); +}); + describe("process-wide Prisma client", () => { const globalSlot = globalThis as unknown as { prisma?: unknown }; diff --git a/src/lib/ai/coach/__tests__/budget.test.ts b/src/lib/ai/coach/__tests__/budget.test.ts index 3757daa61..aea8edaa7 100644 --- a/src/lib/ai/coach/__tests__/budget.test.ts +++ b/src/lib/ai/coach/__tests__/budget.test.ts @@ -22,16 +22,45 @@ vi.mock("@/lib/db", () => ({ }, })); +const { zoneOf } = vi.hoisted(() => ({ + zoneOf: vi.fn(async (userId: string) => + userId === "berlin" + ? "Europe/Berlin" + : userId === "new-york" + ? "America/New_York" + : "UTC", + ), +})); +vi.mock("@/lib/tz/resolver", () => ({ resolveUserTimezone: zoneOf })); + +/** + * The budget day turns at the account's own midnight, the boundary the daily + * briefing and the other per-day generations use. On the UTC day, the first + * generations of a new local day east of UTC were counted against the old + * day's exhausted ceiling. + */ describe("buildDateKey", () => { - it("formats UTC YYYY-MM-DD", () => { - // 2026-05-10T22:30Z is UTC May 10 — Berlin would be May 11 already - const key = buildDateKey(new Date("2026-05-10T22:30:00.000Z")); - expect(key).toBe("2026-05-10"); + it("is the account's local day, not the UTC day", async () => { + // 22:30 UTC on May 10 is already 00:30 on May 11 in Berlin (CEST). + const at = new Date("2026-05-10T22:30:00.000Z"); + await expect(buildDateKey("berlin", at)).resolves.toBe("2026-05-11"); + await expect(buildDateKey("utc", at)).resolves.toBe("2026-05-10"); + expect(zoneOf).toHaveBeenCalledWith("berlin"); }); - it("rolls forward at UTC midnight", () => { - const key = buildDateKey(new Date("2026-05-11T00:01:00.000Z")); - expect(key).toBe("2026-05-11"); + it("turns at local midnight on either side of it", async () => { + // Berlin: 23:59 local on May 10, then 00:01 local on May 11. + await expect( + buildDateKey("berlin", new Date("2026-05-10T21:59:00.000Z")), + ).resolves.toBe("2026-05-10"); + await expect( + buildDateKey("berlin", new Date("2026-05-10T22:01:00.000Z")), + ).resolves.toBe("2026-05-11"); + // West of UTC the local day ends after the UTC one: 02:00 UTC on May 11 + // is still the evening of May 10 in New York. + await expect( + buildDateKey("new-york", new Date("2026-05-11T02:00:00.000Z")), + ).resolves.toBe("2026-05-10"); }); }); diff --git a/src/lib/ai/coach/budget.ts b/src/lib/ai/coach/budget.ts index 88e1e0097..2849190b2 100644 --- a/src/lib/ai/coach/budget.ts +++ b/src/lib/ai/coach/budget.ts @@ -9,12 +9,13 @@ * us add per-day analytics ("avg replies per active user") later * without further migrations. * - * `dateKey` is a UTC `YYYY-MM-DD` string. UTC, not Europe/Berlin: the - * job runner that may seed off-hour tasks (planned v1.5) needs a - * boundary that does not jump on DST. Display layers can format with - * the user's timezone. + * `dateKey` is a `YYYY-MM-DD` string: the calendar day in the account's own + * time zone (`buildDateKey`), the same boundary as the daily briefing and + * every other per-day surface. */ import { prisma } from "@/lib/db"; +import { userDayKey } from "@/lib/tz/format"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; import type { ProviderChainType } from "@/lib/ai/provider-chain"; /** @@ -215,8 +216,9 @@ export interface DailySpend { export async function readDailySpend( userId: string, - dateKey: string = buildDateKey(), + dateKey?: string, ): Promise { + dateKey ??= await buildDateKey(userId); const row = await prisma.coachUsage.findUnique({ where: { userId_dateKey: { userId, dateKey } }, select: { totalTokens: true, operatorTokens: true }, @@ -228,15 +230,24 @@ export async function readDailySpend( } /** - * Build the UTC day-key for a given clock. Defaults to "now". + * The budget day of one account at `at` (default now): the calendar day in + * the account's own time zone. * - * UTC choice: the budget guards spend against the operator's LLM - * bill — the bill cycles at the provider's UTC midnight, so aligning - * the local meter to the same boundary keeps reasoning trivial. + * Up to v1.42.1 this was the UTC day. The daily briefing and the other + * per-day generations run on the account's local day, so in a zone east of + * UTC the first ones of a new local day still counted against the old UTC + * day, whose ceiling the evening had used up: a burst of refusals right + * after local midnight. The day now turns where the person's day turns. + * + * A caller resolves the key once per request or job and hands the same key + * to the reservation and the reconcile, so both land on one row even when + * the call spans midnight. */ -export function buildDateKey(at: Date = new Date()): string { - // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the spend meter follows the provider's UTC billing day (see the doc comment) - return at.toISOString().slice(0, 10); +export async function buildDateKey( + userId: string, + at: Date = new Date(), +): Promise { + return userDayKey(at, await resolveUserTimezone(userId)); } /** @@ -321,12 +332,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 +464,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 +483,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/coach/self-context-questions.ts b/src/lib/ai/coach/self-context-questions.ts index 86d352c7d..ac0959832 100644 --- a/src/lib/ai/coach/self-context-questions.ts +++ b/src/lib/ai/coach/self-context-questions.ts @@ -240,7 +240,7 @@ export async function deriveClarifyingQuestions( // serialises them on the row's unique key instead. The cap follows the // chain's cost owner, so a user on their own plan is not held to the // operator-cost ceiling. - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(userId); const reservation = await reserveBudget( userId, QUESTIONS_MAX_TOKENS, diff --git a/src/lib/ai/coach/turn/budget.ts b/src/lib/ai/coach/turn/budget.ts index 85a78cc69..603a08019 100644 --- a/src/lib/ai/coach/turn/budget.ts +++ b/src/lib/ai/coach/turn/budget.ts @@ -68,7 +68,7 @@ export async function reserveTurnBudget(args: { // egresses through the operator's own key or account; a chain on the // person's own plan or key gets the generous user-plan ceiling. const dailyCap = resolveDailyCap(chain); - const reqDateKey = buildDateKey(); + const reqDateKey = await buildDateKey(userId); const firstRound = toolMode ? Math.max(1, Math.floor(args.firstRound ?? AI_BUDGETS.coach.maxTokens)) : AI_BUDGETS.coach.maxTokens; 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/boot/__tests__/time-zone-check.test.ts b/src/lib/boot/__tests__/time-zone-check.test.ts new file mode 100644 index 000000000..bce14204c --- /dev/null +++ b/src/lib/boot/__tests__/time-zone-check.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + checkSessionTimeZone, + isUtcTimeZone, + runBootTimeZoneCheck, +} from "../time-zone-check"; + +function clientAnswering(rows: unknown) { + return { $queryRawUnsafe: vi.fn().mockResolvedValue(rows) }; +} + +describe("boot time zone check", () => { + afterEach(() => vi.restoreAllMocks()); + + it("accepts the spellings Postgres reports for UTC", () => { + for (const name of ["UTC", "Etc/UTC", "utc", "UCT", "Zulu"]) { + expect(isUtcTimeZone(name)).toBe(true); + } + for (const name of ["Europe/Berlin", "GMT+1", "America/New_York"]) { + expect(isUtcTimeZone(name)).toBe(false); + } + }); + + it("stays quiet on a UTC session", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const client = clientAnswering([{ TimeZone: "UTC" }]); + expect(await runBootTimeZoneCheck(client)).toEqual({ + state: "utc", + timeZone: "UTC", + }); + expect(client.$queryRawUnsafe).toHaveBeenCalledWith("SHOW TimeZone"); + expect(warn).not.toHaveBeenCalled(); + }); + + it("warns, naming the zone and the fix, on any other zone", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const outcome = await runBootTimeZoneCheck( + clientAnswering([{ TimeZone: "Europe/Berlin" }]), + ); + expect(outcome).toEqual({ state: "mismatch", timeZone: "Europe/Berlin" }); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain('"Europe/Berlin"'); + expect(warn.mock.calls[0][0]).toContain("SET timezone TO 'UTC'"); + }); + + it("never throws when the database cannot answer", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const client = { + $queryRawUnsafe: vi.fn().mockRejectedValue(new Error("down")), + }; + expect(await checkSessionTimeZone(client)).toEqual({ + state: "error", + message: "down", + }); + await runBootTimeZoneCheck(client); + expect(warn.mock.calls[0][0]).toContain("skipped: down"); + }); +}); diff --git a/src/lib/boot/time-zone-check.ts b/src/lib/boot/time-zone-check.ts new file mode 100644 index 000000000..45a23ba60 --- /dev/null +++ b/src/lib/boot/time-zone-check.ts @@ -0,0 +1,75 @@ +/** + * Boot check: does a pooled connection really run in UTC? + * + * HealthLog pins `TimeZone=UTC` on every connection it opens (see + * `@/lib/db-session`). This reads the setting back on one pooled connection + * and says so in the log when it is anything else: a pooler that drops the + * startup options, a role or proxy that overrides the setting. The expiry + * checks of passkey challenges, sessions and one-time links read the stored + * UTC timestamps through that zone, so a mismatch shows up as "expired" + * straight away. The check never throws and never stops the boot. + */ + +const UTC_NAMES = /^(etc\/)?(utc|uct|universal|zulu)$/i; + +export interface TimeZoneCheckClient { + $queryRawUnsafe(query: string): Promise; +} + +export type TimeZoneCheckOutcome = + | { state: "utc"; timeZone: string } + | { state: "mismatch"; timeZone: string } + | { state: "error"; message: string }; + +export function isUtcTimeZone(name: string): boolean { + return UTC_NAMES.test(name.trim()); +} + +export async function checkSessionTimeZone( + client: TimeZoneCheckClient, +): Promise { + try { + const rows = + await client.$queryRawUnsafe>>( + "SHOW TimeZone", + ); + const value = rows[0] ? Object.values(rows[0])[0] : undefined; + const timeZone = typeof value === "string" ? value : String(value); + return isUtcTimeZone(timeZone) + ? { state: "utc", timeZone } + : { state: "mismatch", timeZone }; + } catch (err) { + return { + state: "error", + message: err instanceof Error ? err.message : String(err), + }; + } +} + +export function timeZoneMismatchMessage(timeZone: string): string { + return ( + `[boot] The database session runs in time zone "${timeZone}", not UTC. ` + + "HealthLog stores times as UTC and pins every connection to UTC, but " + + "something between the app and the database overrode it. Expiry checks " + + "(passkey challenges, sessions, one-time links) will be off by the zone " + + "offset. Fix: ALTER DATABASE SET timezone TO 'UTC', and if a " + + "connection pooler drops the startup options, set " + + "DATABASE_SESSION_OPTIONS_DISABLED=true so the app pins the zone by " + + "statement instead." + ); +} + +/** Run the check and log the outcome. Resolves the outcome for tests. */ +export async function runBootTimeZoneCheck( + client: TimeZoneCheckClient, +): Promise { + const outcome = await checkSessionTimeZone(client); + if (outcome.state === "mismatch") { + console.warn(timeZoneMismatchMessage(outcome.timeZone)); + } else if (outcome.state === "error") { + console.warn( + `[boot] Database time zone check skipped: ${outcome.message.slice(0, 300)}`, + ); + } + return outcome; +} diff --git a/src/lib/db-session.ts b/src/lib/db-session.ts new file mode 100644 index 000000000..38415033a --- /dev/null +++ b/src/lib/db-session.ts @@ -0,0 +1,75 @@ +/** + * Session settings shared by every Postgres connection HealthLog opens: the + * Prisma pool, pg-boss's pool and the dedicated lock and maintenance clients. + * Kept apart from `db.ts` so a side connection can use them without loading + * the Prisma client. + */ +import type { Pool, PoolClient } from "pg"; + +import { trackBackgroundTask } from "@/lib/logging/background-tasks"; + +/** + * Whether the app may send the libpq `options` startup parameter at all. + * + * PgBouncer refuses a connection that carries a startup parameter it does not + * know (`unsupported startup parameter: options`) unless the operator lists + * it in `ignore_startup_parameters`. Up to v1.41 such a host could set the + * statement timeout to 0, which dropped the whole string; v1.42 always sends + * `work_mem`, so that no longer worked. `DATABASE_SESSION_OPTIONS_DISABLED` + * (1, true or yes) sends none of the session settings; the pooler or the + * database's own defaults then apply. + */ +export function sessionOptionsDisabled(): boolean { + const raw = process.env.DATABASE_SESSION_OPTIONS_DISABLED?.trim(); + return raw !== undefined && /^(1|true|yes)$/i.test(raw); +} + +/** + * The session time zone every connection HealthLog opens runs in. + * + * Almost every `DateTime` column is `timestamp(3)` WITHOUT time zone and + * holds UTC. Postgres converts between that type and `now()` / + * `CURRENT_TIMESTAMP` / `timestamptz` through the session time zone, and the + * driver sends a `Date` parameter as an offset-free UTC string that a + * `timestamptz` column reads in the session time zone. On a database whose + * default is anything but UTC (a shared instance set to Europe/Berlin, say) + * every expiry check then ran hours off, a passkey challenge expired before + * the prompt closed, and `timestamptz` writes landed shifted. Pinning the + * session removes the dependence for every query at once, whatever the + * database or role default says. + */ +export const SESSION_TIME_ZONE = "UTC"; + +export const TIME_ZONE_OPTION = `-c TimeZone=${SESSION_TIME_ZONE}`; + +/** + * The statement that pins the time zone where the startup `options` cannot + * be sent (`DATABASE_SESSION_OPTIONS_DISABLED`, a connection pooler). + * PgBouncer tracks `TimeZone` per client and replays it onto whichever server + * connection serves the next transaction, so a `SET` on connect holds in + * transaction pooling too. + */ +export const SET_SESSION_TIME_ZONE = `SET TIME ZONE '${SESSION_TIME_ZONE}'`; + +/** The startup option alone, for the side connections (pg-boss, locks). */ +export function timeZoneSessionOptions(): string | undefined { + return sessionOptionsDisabled() ? undefined : TIME_ZONE_OPTION; +} + +/** + * Pin the time zone on every new connection of `pool` by statement, when the + * startup options are off. pg queues the statement ahead of the first query + * the new client is handed out for, so no query runs before it. + */ +export function pinTimeZoneOnConnect(pool: Pool): void { + if (!sessionOptionsDisabled()) return; + pool.on("connect", (client: PoolClient) => { + trackBackgroundTask( + client.query(SET_SESSION_TIME_ZONE).catch((err: unknown) => { + // The boot check reports a session that is not in UTC; this names + // the cause when the statement itself failed. + console.error("[db] Could not pin the session time zone:", err); + }), + ); + }); +} diff --git a/src/lib/db.ts b/src/lib/db.ts index bfa815ae1..9cfff9d20 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -1,5 +1,19 @@ import { Prisma, PrismaClient } from "@/generated/prisma/client"; import { PrismaPg } from "@prisma/adapter-pg"; +import { Pool, type PoolConfig } from "pg"; +import { + pinTimeZoneOnConnect, + sessionOptionsDisabled, + TIME_ZONE_OPTION, +} from "@/lib/db-session"; + +export { + pinTimeZoneOnConnect, + SESSION_TIME_ZONE, + SET_SESSION_TIME_ZONE, + sessionOptionsDisabled, + timeZoneSessionOptions, +} from "@/lib/db-session"; const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined; @@ -108,22 +122,6 @@ export function getWorkMem(): string { return raw && WORK_MEM_SHAPE.test(raw) ? raw : DEFAULT_WORK_MEM; } -/** - * Whether the app may send the libpq `options` startup parameter at all. - * - * PgBouncer refuses a connection that carries a startup parameter it does not - * know (`unsupported startup parameter: options`) unless the operator lists - * it in `ignore_startup_parameters`. Up to v1.41 such a host could set the - * statement timeout to 0, which dropped the whole string; v1.42 always sends - * `work_mem`, so that no longer worked. `DATABASE_SESSION_OPTIONS_DISABLED` - * (1, true or yes) sends none of the session settings; the pooler or the - * database's own defaults then apply. - */ -export function sessionOptionsDisabled(): boolean { - const raw = process.env.DATABASE_SESSION_OPTIONS_DISABLED?.trim(); - return raw !== undefined && /^(1|true|yes)$/i.test(raw); -} - /** * Build the libpq `options` startup string applying the session settings. * Passed straight through `PrismaPg` to the underlying `pg.Pool`, which @@ -143,17 +141,35 @@ export function buildSessionOptions(): string | undefined { timeoutMs > 0 ? `-c statement_timeout=${timeoutMs} -c idle_in_transaction_session_timeout=${timeoutMs} ` : ""; - return `${timeouts}-c work_mem=${getWorkMem()}`; + return `${timeouts}-c work_mem=${getWorkMem()} ${TIME_ZONE_OPTION}`; } -function createPrismaClient() { - const adapter = new PrismaPg({ +export function prismaPoolConfig(): PoolConfig { + return { connectionString: process.env.DATABASE_URL!, max: getPrismaPoolMax(), connectionTimeoutMillis: getPoolConnectionTimeoutMs(), options: buildSessionOptions(), - }); - return new PrismaClient({ adapter }); + }; +} + +/** + * 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); + 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/documents/auto-stage-labs.ts b/src/lib/documents/auto-stage-labs.ts index ef9133ee8..fc694a56e 100644 --- a/src/lib/documents/auto-stage-labs.ts +++ b/src/lib/documents/auto-stage-labs.ts @@ -290,7 +290,7 @@ export async function maybeAutoStageLabFacts( // ceiling under the cost owner the auto-index resolved. // v1.38.19 — auto-staging runs off the upload, with no one waiting // on it: a background surface, so half the day's ceiling. - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(userId); const reservation = await reserveBudget( userId, AI_BUDGETS.ocrExtractText.maxTokens, diff --git a/src/lib/documents/fenced-chat.ts b/src/lib/documents/fenced-chat.ts index 1af3eb30a..90a834650 100644 --- a/src/lib/documents/fenced-chat.ts +++ b/src/lib/documents/fenced-chat.ts @@ -312,7 +312,7 @@ export async function streamFencedReply( await appendMessage({ conversationId, role: "user", content: message }); // ── Budget reservation (atomic, before the provider call) ── - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(userId); const reservation = await reserveBudget( userId, AI_BUDGETS.documentChat.maxTokens, diff --git a/src/lib/documents/index-document.ts b/src/lib/documents/index-document.ts index 9674b2e1d..80c3ff25d 100644 --- a/src/lib/documents/index-document.ts +++ b/src/lib/documents/index-document.ts @@ -242,7 +242,7 @@ async function tryProviderIndex( return { outcome: null, note: null }; } - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(userId); const reservation = await reserveBudget( userId, AI_BUDGETS.documentTranscribe.maxTokens, 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/briefing-provider.ts b/src/lib/insights/briefing-provider.ts index fb2686927..422ec7fbc 100644 --- a/src/lib/insights/briefing-provider.ts +++ b/src/lib/insights/briefing-provider.ts @@ -93,7 +93,7 @@ export interface RunBriefingCompletionArgs { * a pinned seed would reproduce the phrasing the re-roll exists to vary. */ seed?: number; - /** UTC day-key override; defaults to today. Tests pin it. */ + /** Budget day-key override; defaults to the account's today. Tests pin it. */ dateKey?: string; /** * v1.41 — `daily_briefing` on the calls that may reason (the generation and @@ -122,7 +122,7 @@ export interface RunBriefingCompletionArgs { export async function runBriefingCompletion( args: RunBriefingCompletionArgs, ): Promise { - const dateKey = args.dateKey ?? buildDateKey(); + const dateKey = args.dateKey ?? (await buildDateKey(args.userId)); // v1.41 — one resolution per call: the operator's switch and cap, the payer // from the chain's primary. `undefined` is today's wire. 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/insights/status-provider.ts b/src/lib/insights/status-provider.ts index d098fe15c..6646c9771 100644 --- a/src/lib/insights/status-provider.ts +++ b/src/lib/insights/status-provider.ts @@ -250,7 +250,7 @@ export async function runStatusCompletion( // credential (`admin-openai` / `admin-codex`). A self-hoster on their own key // or a local model is measured against the generous user-plan ceiling, so // their own hardware/plan is never rationed by the operator's bill. - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(userId); // Thinking is billed as output, so a reasoning call reserves its thinking // budget too; the reconcile below settles on the reported count. const estimatedTokens = 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/__tests__/google-health-backfill.test.ts b/src/lib/jobs/__tests__/google-health-backfill.test.ts index 2773abef9..8afde8dd0 100644 --- a/src/lib/jobs/__tests__/google-health-backfill.test.ts +++ b/src/lib/jobs/__tests__/google-health-backfill.test.ts @@ -7,24 +7,37 @@ */ import { beforeEach, describe, expect, it, vi } from "vitest"; -const { prismaMock, syncUserGoogleHealthMock, isParkedMock } = vi.hoisted( - () => ({ - prismaMock: { - googleHealthConnection: { - findMany: vi.fn(), - findUnique: vi.fn(async () => null), - update: vi.fn(), - }, +const { + prismaMock, + syncUserGoogleHealthMock, + isParkedMock, + bossSend, + resumeState, +} = vi.hoisted(() => ({ + prismaMock: { + googleHealthConnection: { + findMany: vi.fn(), + findUnique: vi.fn( + async (): Promise<{ + backfillProgress: unknown; + backfillCompletedAt: Date | null; + } | null> => null, + ), + update: vi.fn(), }, - syncUserGoogleHealthMock: vi.fn(), - isParkedMock: vi.fn(async () => false), - }), -); + }, + syncUserGoogleHealthMock: vi.fn(), + isParkedMock: vi.fn(async () => false), + bossSend: vi.fn(), + // Collections the mocked walk "finishes" in this attempt, added to the + // resume set the way `runGoogleHealthCollection` adds them. + resumeState: { finishes: [] as string[], doneBefore: [] as string[] }, +})); vi.mock("@/lib/db", () => ({ prisma: prismaMock })); vi.mock("@/lib/jobs/boss-instance", () => ({ - getGlobalBoss: () => ({ send: vi.fn() }), + getGlobalBoss: () => ({ send: bossSend }), })); vi.mock("@/lib/logging/context", () => ({ @@ -40,12 +53,16 @@ vi.mock("@/lib/safe-fetch", async (importOriginal) => { vi.mock("@/lib/google-health/sync-core", () => ({ GOOGLE_HEALTH_INTEGRATION_KEY: "google-health", - readGoogleHealthBackfillDone: () => new Set(), + readGoogleHealthBackfillDone: () => new Set(resumeState.doneBefore), runWithGoogleHealthBackfillResume: async ( _userId: string, - _done: Set, + done: Set, fn: () => Promise, - ) => ({ result: await fn(), skipped: [] }), + ) => { + const result = await fn(); + for (const key of resumeState.finishes) done.add(key); + return { result, skipped: [] }; + }, })); vi.mock("@/lib/google-health/sync", () => ({ isGoogleHealthParked: isParkedMock, @@ -63,6 +80,10 @@ beforeEach(() => { vi.clearAllMocks(); isParkedMock.mockResolvedValue(false); prismaMock.googleHealthConnection.update.mockResolvedValue({}); + prismaMock.googleHealthConnection.findUnique.mockResolvedValue(null); + bossSend.mockResolvedValue("job-next"); + resumeState.finishes = []; + resumeState.doneBefore = []; }); describe("runGoogleHealthBackfillForUser — verdict-gated marker", () => { @@ -150,3 +171,92 @@ describe("runGoogleHealthBackfillForUser — verdict-gated marker", () => { expect(prismaMock.googleHealthConnection.update).not.toHaveBeenCalled(); }); }); + +/** + * A long history walks in slices. A slice that finished more collections than + * it started with is progressing, not failing: it queues the next slice and + * ends quietly, without the error line or the failure count. Only a slice that + * finished nothing new is stuck and keeps throwing. + */ +describe("runGoogleHealthBackfillForUser — slices", () => { + it("continues a progressing slice instead of failing it", async () => { + resumeState.doneBefore = ["steps"]; + resumeState.finishes = ["sleep", "weight"]; + syncUserGoogleHealthMock.mockResolvedValue({ imported: 9, failed: true }); + + await expect( + runGoogleHealthBackfillForUser("u1", undefined, 2), + ).resolves.toEqual({ + imported: 9, + continued: true, + }); + + expect(bossSend).toHaveBeenCalledTimes(1); + const [queue, payload, options] = bossSend.mock.calls[0]!; + expect(queue).toBe("integration-backfill-admission"); + expect(payload).toMatchObject({ + kind: "google-health-backfill", + data: { userId: "u1", slice: 3 }, + }); + // Its own key, so the exclusive policy does not swallow it while this + // slice is still active. + expect(options).toMatchObject({ + singletonKey: "google-health-backfill|u1|slice-3", + startAfter: expect.any(Number), + }); + // Not complete: no stamp. + expect(prismaMock.googleHealthConnection.update).not.toHaveBeenCalled(); + }); + + it("still throws for a slice that finished nothing new", async () => { + resumeState.doneBefore = ["steps"]; + resumeState.finishes = []; + syncUserGoogleHealthMock.mockResolvedValue({ imported: 3, failed: true }); + + await expect(runGoogleHealthBackfillForUser("u1")).rejects.toThrow( + /incomplete/, + ); + expect(bossSend).not.toHaveBeenCalled(); + }); + + it("throws rather than drop the work when the next slice cannot be queued", async () => { + resumeState.finishes = ["sleep"]; + bossSend.mockResolvedValue(null); + syncUserGoogleHealthMock.mockResolvedValue({ imported: 1, failed: true }); + + await expect(runGoogleHealthBackfillForUser("u1")).rejects.toThrow( + /incomplete/, + ); + }); + + it("stamps the marker on the slice that finishes the history", async () => { + resumeState.doneBefore = ["steps", "sleep"]; + resumeState.finishes = ["weight"]; + syncUserGoogleHealthMock.mockResolvedValue({ imported: 4, failed: false }); + + await expect( + runGoogleHealthBackfillForUser("u1", undefined, 5), + ).resolves.toEqual({ + imported: 4, + }); + expect(bossSend).not.toHaveBeenCalled(); + expect( + prismaMock.googleHealthConnection.update.mock.calls[0]![0].data + .backfillCompletedAt, + ).toBeInstanceOf(Date); + }); + + it("walks nothing again once an earlier slice completed the import", async () => { + prismaMock.googleHealthConnection.findUnique.mockResolvedValue({ + backfillProgress: null, + backfillCompletedAt: new Date(), + }); + + await expect( + runGoogleHealthBackfillForUser("u1", undefined, 4), + ).resolves.toEqual({ + imported: 0, + }); + expect(syncUserGoogleHealthMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/jobs/__tests__/measurement-maintenance-session.test.ts b/src/lib/jobs/__tests__/measurement-maintenance-session.test.ts index 93ba20002..213c9c5ed 100644 --- a/src/lib/jobs/__tests__/measurement-maintenance-session.test.ts +++ b/src/lib/jobs/__tests__/measurement-maintenance-session.test.ts @@ -26,6 +26,7 @@ describe("maintenance session settings behind a pooler", () => { "SET maintenance_work_mem = '128MB'", "SET max_parallel_maintenance_workers = '0'", "SET lock_timeout = '300000'", + "SET TimeZone = 'UTC'", ]); }); it("keeps maintenance in one process so it needs no /dev/shm segment", () => { diff --git a/src/lib/jobs/boss-instance.ts b/src/lib/jobs/boss-instance.ts index b807477d5..438e85818 100644 --- a/src/lib/jobs/boss-instance.ts +++ b/src/lib/jobs/boss-instance.ts @@ -8,6 +8,7 @@ import { PgBoss } from "pg-boss"; import { getPgBossPoolMax, getPoolConnectionTimeoutMs } from "@/lib/db"; +import { timeZoneSessionOptions } from "@/lib/db-session"; const BOSS_KEY = "__healthlog_pgboss__" as const; const BOSS_START_KEY = "__healthlog_pgboss_start__" as const; @@ -76,6 +77,9 @@ function databaseOptions(connectionString: string) { connectionString, max: getPgBossPoolMax(), connectionTimeoutMillis: getPoolConnectionTimeoutMs(), + // pg-boss keeps its own tables in `timestamptz`, so its queue math does + // not depend on the zone; the pin keeps every HealthLog session alike. + options: timeZoneSessionOptions(), } as const; } diff --git a/src/lib/jobs/coach-nudge-ai.ts b/src/lib/jobs/coach-nudge-ai.ts index 3df90feeb..4ffb0e27e 100644 --- a/src/lib/jobs/coach-nudge-ai.ts +++ b/src/lib/jobs/coach-nudge-ai.ts @@ -211,7 +211,7 @@ export const composeNudgeWithAI: ComposeNudgeWithAI = async (params) => { const maxTokens = budget.maxTokens ?? 160; // v1.38.19 — a background surface: half the day's ceiling. const dailyCap = resolveDailyCapFor("job", chain); - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(params.userId); // Per-user budget gate (atomic reserve; refunds itself on refusal). const reservation = await reserveBudget( 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/document-content-index-backfill.ts b/src/lib/jobs/document-content-index-backfill.ts index 357619313..f355cb7d3 100644 --- a/src/lib/jobs/document-content-index-backfill.ts +++ b/src/lib/jobs/document-content-index-backfill.ts @@ -268,7 +268,7 @@ export async function runContentIndexBackfillForUser( break outer; } - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(userId); const reservation = await reserveBudget( userId, AI_BUDGETS.documentTranscribe.maxTokens, diff --git a/src/lib/jobs/document-summary.ts b/src/lib/jobs/document-summary.ts index 02f495b81..de889748f 100644 --- a/src/lib/jobs/document-summary.ts +++ b/src/lib/jobs/document-summary.ts @@ -223,7 +223,7 @@ export async function runDocumentSummaryJob( return; } - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(userId); const reservation = await reserveBudget( userId, AI_BUDGETS.documentSummary.maxTokens, diff --git a/src/lib/jobs/google-health-backfill.ts b/src/lib/jobs/google-health-backfill.ts index 03d428e5e..bb3e66b42 100644 --- a/src/lib/jobs/google-health-backfill.ts +++ b/src/lib/jobs/google-health-backfill.ts @@ -15,7 +15,11 @@ import { Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; -import { integrationBackfillSourceOptions } from "@/lib/jobs/integration-backfill-admission"; +import { + enqueueIntegrationBackfillAdmission, + INTEGRATION_BACKFILL_CONTINUATION_DELAY_SECONDS, + integrationBackfillSourceOptions, +} from "@/lib/jobs/integration-backfill-admission"; import { readGoogleHealthBackfillDone, runWithGoogleHealthBackfillResume, @@ -68,6 +72,14 @@ export interface GoogleHealthBackfillPayload { * (`runWithGoogleHealthBackfillResume`). The note is cleared once the stamp * lands, and on every reconnect. * + * Runs in slices: an attempt that ends before the whole history is in but + * finished at least one more collection than it started with made progress. + * It is not a failure: it sends the next slice (`slice + 1`) and returns + * `continued`, without the error line or the queue's failure count. Only an + * attempt that finished nothing new throws, because that one is stuck: the + * pg-boss retry and, after it, the next boot take it from there. Progress is + * bounded by the number of collections, so the chain of slices is too. + * * Never beside another run of the same account: the sync takes the account's * lock, waiting a bounded time for an hourly or manual run to finish. If the * lock does not come free the attempt throws without doing anything, and the @@ -76,7 +88,8 @@ export interface GoogleHealthBackfillPayload { export async function runGoogleHealthBackfillForUser( userId: string, shouldStop: () => boolean = () => false, -): Promise<{ imported: number }> { + slice: number = 0, +): Promise<{ imported: number; continued?: boolean }> { if (await isGoogleHealthParked(userId)) { annotate({ action: { @@ -89,9 +102,21 @@ export async function runGoogleHealthBackfillForUser( const connection = await prisma.googleHealthConnection.findUnique({ where: { userId }, - select: { backfillProgress: true }, + select: { backfillProgress: true, backfillCompletedAt: true }, }); + if (connection?.backfillCompletedAt) { + // A slice queued behind the one that finished the import, or a boot + // delivery that raced it: the history is in, nothing to walk again. + annotate({ + action: { + name: "google_health.backfill.already_complete", + details: { slice }, + }, + }); + return { imported: 0 }; + } const done = readGoogleHealthBackfillDone(connection?.backfillProgress); + const doneBefore = done.size; const { result: { imported, failed, busy }, @@ -115,9 +140,46 @@ export async function runGoogleHealthBackfillForUser( } if (failed) { - // Surface through the pg-boss retry path (retryLimit 3, backoff). If the - // failure parked the connection at error_reauth meanwhile, the next - // attempt takes the reauth return above instead of failing again. + // `done` is the resume set this attempt added to as it finished + // collections; a larger set than it started with is progress. + const finished = done.size - doneBefore; + if (finished > 0) { + const boss = getGlobalBoss(); + const sent = boss + ? await enqueueIntegrationBackfillAdmission( + boss, + { + kind: "google-health-backfill", + data: { + userId, + enqueuedAt: new Date().toISOString(), + slice: slice + 1, + }, + }, + INTEGRATION_BACKFILL_CONTINUATION_DELAY_SECONDS, + ) + : null; + if (sent) { + annotate({ + action: { + name: "google_health.backfill.continued", + details: { + imported, + slice, + collectionsFinished: finished, + collectionsDone: done.size, + }, + }, + }); + return { imported, continued: true }; + } + // No follow-up could be queued; fall through to the retry path so the + // work is not dropped. + } + // No collection finished in this attempt: the backfill is stuck, not + // slow. Surface through the pg-boss retry path (retryLimit 3, backoff). + // If the failure parked the connection at error_reauth meanwhile, the + // next attempt takes the reauth return above instead of failing again. throw new Error( `google-health backfill incomplete for user ${userId} — marker not stamped`, ); diff --git a/src/lib/jobs/integration-backfill-admission.ts b/src/lib/jobs/integration-backfill-admission.ts index 2e528ca55..382a65374 100644 --- a/src/lib/jobs/integration-backfill-admission.ts +++ b/src/lib/jobs/integration-backfill-admission.ts @@ -28,6 +28,14 @@ export type IntegrationBackfillData = { userId: string; enqueuedAt: string; provider?: "WHOOP" | "WITHINGS"; + /** + * The continuation number of an import that runs in slices: absent (0) for + * the job discovery or a connect sends, 1, 2, ... for each follow-up a + * slice that made progress sent for the rest. Part of the job's singleton + * key, so the follow-up is not swallowed by the `exclusive` policy while the + * slice that sends it is still active; never part of the import identity. + */ + slice?: number; }; export interface IntegrationBackfillAdmissionPayload { @@ -88,7 +96,12 @@ export function integrationBackfillSourceOptions( }; } -export function integrationBackfillAdmissionSingletonKey( +/** + * The import a job belongs to: one kind for one account (and provider). The + * import lock keys on it, so no two slices or deliveries of one import ever + * run at once. + */ +export function integrationBackfillIdentity( payload: IntegrationBackfillAdmissionPayload, ): string { if (payload.kind === "sleep-timeline-backfill") { @@ -100,17 +113,29 @@ export function integrationBackfillAdmissionSingletonKey( return `${payload.kind}|${payload.data.userId}`; } +/** The job's singleton key: the import identity, plus its slice number. */ +export function integrationBackfillAdmissionSingletonKey( + payload: IntegrationBackfillAdmissionPayload, +): string { + const identity = integrationBackfillIdentity(payload); + const slice = payload.data.slice ?? 0; + return slice > 0 ? `${identity}|slice-${slice}` : identity; +} + export async function enqueueIntegrationBackfillAdmission( boss: BossSender, payload: IntegrationBackfillAdmissionPayload, + startAfterSeconds: number = 0, ): Promise { - return boss.send( - INTEGRATION_BACKFILL_ADMISSION_QUEUE, - payload, - integrationBackfillAdmissionSendOptions(payload), - ); + return boss.send(INTEGRATION_BACKFILL_ADMISSION_QUEUE, payload, { + ...integrationBackfillAdmissionSendOptions(payload), + ...(startAfterSeconds > 0 ? { startAfter: startAfterSeconds } : {}), + }); } +/** Breathing room between one slice of an import and the next. */ +export const INTEGRATION_BACKFILL_CONTINUATION_DELAY_SECONDS = 60; + /** * The send options of one admission job. The options ride on the send, not on * `createQueue`, whose `ON CONFLICT DO NOTHING` leaves an existing queue's diff --git a/src/lib/jobs/integration-backfill-drain.ts b/src/lib/jobs/integration-backfill-drain.ts index 76f91c4d6..68d7f7174 100644 --- a/src/lib/jobs/integration-backfill-drain.ts +++ b/src/lib/jobs/integration-backfill-drain.ts @@ -25,7 +25,7 @@ import { runStravaBackfillForUser } from "@/lib/jobs/strava-backfill"; import { runSleepTimelineBackfillForUser } from "@/lib/jobs/sleep-timeline-backfill"; import { runLabBiomarkerBackfillForUser } from "@/lib/jobs/lab-biomarker-backfill"; import { - integrationBackfillAdmissionSingletonKey, + integrationBackfillIdentity, type IntegrationBackfillAdmissionPayload, } from "@/lib/jobs/integration-backfill-admission"; import { withIntegrationBackfillLock } from "@/lib/jobs/integration-backfill-lock"; @@ -36,6 +36,8 @@ import { withBackgroundEvent } from "@/lib/logging/background"; interface RunCounts { imported: number; + /** Imports that ended a slice with progress and sent the next one. */ + continued: number; removed: number; deleted: number; markers: number; @@ -66,14 +68,16 @@ async function runAdmitted( return; } case "google-health-backfill": { - const { imported } = await runGoogleHealthBackfillForUser( + const { imported, continued } = await runGoogleHealthBackfillForUser( userId, shouldStop, + data.slice ?? 0, ); totals.imported += imported; + if (continued) totals.continued += 1; workerLog( "info", - `[google-health-backfill] user=${userId} imported=${imported}`, + `[google-health-backfill] user=${userId} imported=${imported}${continued ? " continued" : ""}`, ); return; } @@ -152,6 +156,7 @@ export async function drainIntegrationBackfillAdmission( const shouldStop = jobBudget(jobs); const totals: RunCounts = { imported: 0, + continued: 0, removed: 0, deleted: 0, markers: 0, @@ -159,7 +164,7 @@ export async function drainIntegrationBackfillAdmission( }; let alreadyRunning = 0; for (const job of jobs) { - const identity = integrationBackfillAdmissionSingletonKey(job.data); + const identity = integrationBackfillIdentity(job.data); const run = await withIntegrationBackfillLock(identity, () => runAdmitted(job.data, shouldStop, totals), ); @@ -183,6 +188,7 @@ export async function drainIntegrationBackfillAdmission( jobs: jobs.length, already_running: alreadyRunning, imported: totals.imported, + continued: totals.continued, removed: totals.removed, deleted: totals.deleted, markers: totals.markers, diff --git a/src/lib/jobs/job-lock.ts b/src/lib/jobs/job-lock.ts index 711fe471d..eb85441d7 100644 --- a/src/lib/jobs/job-lock.ts +++ b/src/lib/jobs/job-lock.ts @@ -19,6 +19,7 @@ * One extra connection per running locked job. */ import { Client } from "pg"; +import { timeZoneSessionOptions } from "@/lib/db-session"; import { caughtAs } from "@/lib/logging/signal"; /** What a guarded run came to. */ @@ -50,7 +51,11 @@ export async function withJobLock( connectionString: string | undefined = process.env.DATABASE_URL, options: JobLockOptions = {}, ): Promise> { - const client = new Client({ connectionString, keepAlive: true }); + const client = new Client({ + connectionString, + keepAlive: true, + options: timeZoneSessionOptions(), + }); // An idle client whose server goes away emits `error`; unhandled, that // takes the whole worker down. The run itself notices the database is // gone through its own queries. diff --git a/src/lib/jobs/measurement-maintenance.ts b/src/lib/jobs/measurement-maintenance.ts index 72a7c6a03..34eed50d9 100644 --- a/src/lib/jobs/measurement-maintenance.ts +++ b/src/lib/jobs/measurement-maintenance.ts @@ -77,7 +77,7 @@ export interface MeasurementMaintenancePayload { * whatever its container settings. */ export const MAINTENANCE_SESSION_OPTIONS = - "-c statement_timeout=0 -c idle_in_transaction_session_timeout=0 -c maintenance_work_mem=128MB -c max_parallel_maintenance_workers=0 -c lock_timeout=300000"; + "-c statement_timeout=0 -c idle_in_transaction_session_timeout=0 -c maintenance_work_mem=128MB -c max_parallel_maintenance_workers=0 -c lock_timeout=300000 -c TimeZone=UTC"; /** * The same settings as `SET` statements, for a host behind a connection diff --git a/src/lib/jobs/reaction-line.ts b/src/lib/jobs/reaction-line.ts index a30e48d3b..1051aae19 100644 --- a/src/lib/jobs/reaction-line.ts +++ b/src/lib/jobs/reaction-line.ts @@ -592,7 +592,7 @@ export async function runReactionLine( generationClaimId, job.userId, revision, - buildDateKey(), + await buildDateKey(job.userId), // v1.38.19 — a background surface: half the day's ceiling. resolveDailyCapFor("job", chain), resolveCostOwner(chain), 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/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts index 83d8de485..8f6bce5d3 100644 --- a/src/lib/openapi/routes/medications/schemas.ts +++ b/src/lib/openapi/routes/medications/schemas.ts @@ -1036,7 +1036,7 @@ export const medicationExtractRequest = z .regex(/^\d{4}-\d{2}-\d{2}$/) .optional() .describe( - 'Optional override of the reference date used to resolve relative phrases ("tomorrow", "next Monday"). Format: `YYYY-MM-DD`. Defaults to the server\'s UTC day.', + 'Optional override of the reference date used to resolve relative phrases ("tomorrow", "next Monday"). Format: `YYYY-MM-DD`. Defaults to the account\'s own day in its time zone.', ), }) .meta({ diff --git a/src/lib/oura/__tests__/client.test.ts b/src/lib/oura/__tests__/client.test.ts index 1fead0330..d34407fe9 100644 --- a/src/lib/oura/__tests__/client.test.ts +++ b/src/lib/oura/__tests__/client.test.ts @@ -1,7 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + OURA_OAUTH_LEGACY_TOKEN_URL, OURA_OAUTH_SCOPE, + OURA_OAUTH_TOKEN_URL, RESILIENCE_LEVELS, derivePeriodDaysFromCyclePhases, exchangeCode, @@ -12,6 +14,7 @@ import { fetchVo2Max, getAuthorizationUrl, getOuraCredentials, + isAcceptedOuraIssuer, mapCardiovascularAge, mapDailyActivity, mapDailySleep, @@ -132,6 +135,133 @@ describe("exchangeCode / refreshAccessToken", () => { }); }); +/** + * Oura moved its authorization server: apps from the current developer + * portal get codes from moi.ouraring.com, which the documented + * api.ouraring.com token endpoint refuses with 400 invalid_request. A code + * works once, so the live endpoint is always asked first. + */ +describe("token endpoint order", () => { + const MOI_ISS = "https://moi.ouraring.com/oauth/v2/ext/oauth-anonymous"; + const OK = { + status: 200, + body: { access_token: "a", refresh_token: "r", expires_in: 86400 }, + }; + const urls = (mock: ReturnType) => + mock.mock.calls.map(([url]) => url); + + it("exchanges a code at the live endpoint, sending the secret in the body", async () => { + const fetchMock = installFetchMock([OK]); + await exchangeCode("code", CREDS, MOI_ISS); + expect(OURA_OAUTH_TOKEN_URL).toBe( + "https://moi.ouraring.com/oauth/v2/ext/oauth-token", + ); + expect(urls(fetchMock)).toEqual([OURA_OAUTH_TOKEN_URL]); + const body = String(fetchMock.mock.calls[0]![1]!.body); + expect(body).toContain("grant_type=authorization_code"); + expect(body).toContain("client_id=cid"); + expect(body).toContain("client_secret=csecret"); + }); + + it("never hands a code from the current server to the legacy endpoint", async () => { + const fetchMock = installFetchMock([ + { status: 400, body: { error: "invalid_request" } }, + OK, + ]); + await expect(exchangeCode("code", CREDS, MOI_ISS)).rejects.toMatchObject({ + upstreamError: "invalid_request", + }); + expect(urls(fetchMock)).toEqual([OURA_OAUTH_TOKEN_URL]); + expect(urls(fetchMock)).not.toContain(OURA_OAUTH_LEGACY_TOKEN_URL); + }); + + it("asks the legacy endpoint only after the live one refused an older app's code", async () => { + const fetchMock = installFetchMock([ + { status: 401, body: { error: "invalid_client" } }, + OK, + ]); + const t = await exchangeCode("code", CREDS); + expect(t.access_token).toBe("a"); + expect(urls(fetchMock)).toEqual([ + OURA_OAUTH_TOKEN_URL, + OURA_OAUTH_LEGACY_TOKEN_URL, + ]); + }); + + it("does not try the legacy endpoint once the live one accepted the code", async () => { + const fetchMock = installFetchMock([OK]); + await exchangeCode("code", CREDS); + expect(urls(fetchMock)).toEqual([OURA_OAUTH_TOKEN_URL]); + }); + + it("refreshes at the live endpoint first", async () => { + const fetchMock = installFetchMock([OK]); + await refreshAccessToken("r1", CREDS); + expect(urls(fetchMock)).toEqual([OURA_OAUTH_TOKEN_URL]); + }); + + it.each([ + [400, { error: "invalid_request" }], + [404, null], + ])( + "falls back to the legacy endpoint on a %i from the live one", + async (status, body) => { + const fetchMock = installFetchMock([{ status, body }, OK]); + const t = await refreshAccessToken("r1", CREDS); + expect(t.refresh_token).toBe("r"); + expect(urls(fetchMock)).toEqual([ + OURA_OAUTH_TOKEN_URL, + OURA_OAUTH_LEGACY_TOKEN_URL, + ]); + }, + ); + + it("reports the live endpoint's refusal when both refuse", async () => { + installFetchMock([ + { status: 400, body: { error: "invalid_grant" } }, + { status: 400, body: { error: "invalid_request" } }, + ]); + await expect(refreshAccessToken("dead", CREDS)).rejects.toMatchObject({ + upstreamError: "invalid_grant", + }); + }); + + it("does not fall back on a server error or a rate limit", async () => { + for (const status of [429, 503]) { + const fetchMock = installFetchMock([{ status }, OK]); + await expect(refreshAccessToken("r1", CREDS)).rejects.toBeInstanceOf( + OuraApiError, + ); + expect(urls(fetchMock)).toEqual([OURA_OAUTH_TOKEN_URL]); + vi.unstubAllGlobals(); + } + }); +}); + +describe("isAcceptedOuraIssuer", () => { + it("accepts no issuer and Oura's own servers", () => { + expect(isAcceptedOuraIssuer(null)).toBe(true); + expect( + isAcceptedOuraIssuer( + "https://moi.ouraring.com/oauth/v2/ext/oauth-anonymous", + ), + ).toBe(true); + expect(isAcceptedOuraIssuer("https://cloud.ouraring.com")).toBe(true); + }); + + it("refuses anything else", () => { + for (const iss of [ + "https://evil.example/oauth", + "http://moi.ouraring.com/oauth", + "https://moi.ouraring.com.evil.example/", + "not a url", + "", + ]) { + expect(isAcceptedOuraIssuer(iss), iss).toBe(false); + } + }); +}); + describe("fetchReadiness pagination", () => { it("walks next_token across pages", async () => { installFetchMock([ diff --git a/src/lib/oura/client.ts b/src/lib/oura/client.ts index 0460c315f..073c6a858 100644 --- a/src/lib/oura/client.ts +++ b/src/lib/oura/client.ts @@ -10,7 +10,9 @@ * * Oura specifics: standard authorization-code OAuth WITH refresh tokens (the * sync layer refreshes on 401, persisting BOTH rotated tokens). The token - * endpoint takes client_id/secret in the body. Collections are read by + * endpoint takes client_id/secret in the body (client_secret_post) and lives + * on moi.ouraring.com, with the documented api.ouraring.com endpoint as a + * fallback for older apps. Collections are read by * `start_date` / `end_date` (YYYY-MM-DD) and page via `next_token`. */ import { getEvent } from "@/lib/logging/context"; @@ -22,7 +24,63 @@ import { envOr, envValue } from "@/lib/env"; const OURA_API_BASE = "https://api.ouraring.com"; const OURA_OAUTH_AUTH_URL = "https://cloud.ouraring.com/oauth/authorize"; -const OURA_OAUTH_TOKEN_URL = "https://api.ouraring.com/oauth/token"; + +/** + * Oura's live token endpoint. Apps created in the current developer portal + * get their codes from this authorization server (the callback carries + * `iss=https://moi.ouraring.com/oauth/v2/ext/oauth-anonymous`), and the + * documented `api.ouraring.com/oauth/token` answers those codes with + * `400 invalid_request`. Older apps are served here too. + */ +export const OURA_OAUTH_TOKEN_URL = + "https://moi.ouraring.com/oauth/v2/ext/oauth-token"; + +/** + * The documented endpoint, kept only as a fallback for the older apps the + * live endpoint turns away. Never tried first for a code: an authorization + * code works once (RFC 6749 4.1.2), so a rejected first attempt would burn + * it before the right endpoint saw it. + */ +export const OURA_OAUTH_LEGACY_TOKEN_URL = + "https://api.ouraring.com/oauth/token"; + +/** The origin of Oura's current authorization server. */ +const OURA_MOI_ORIGIN = "https://moi.ouraring.com"; + +/** + * Authorization-server origins an `iss` callback parameter may name + * (RFC 9207). Anything else is a mix-up and refused. + */ +const OURA_ISSUER_ORIGINS = new Set([ + OURA_MOI_ORIGIN, + "https://cloud.ouraring.com", + "https://api.ouraring.com", +]); + +/** + * Check the `iss` the authorization server sent with the callback. Absent is + * fine (older apps never send one); present, it must name one of Oura's own + * authorization servers. + */ +export function isAcceptedOuraIssuer(iss: string | null): boolean { + if (iss === null) return true; + try { + const url = new URL(iss); + return url.protocol === "https:" && OURA_ISSUER_ORIGINS.has(url.origin); + } catch { + return false; + } +} + +/** True when the code was issued by the current authorization server. */ +function issuedByMoi(iss: string | null | undefined): boolean { + if (!iss) return false; + try { + return new URL(iss).origin === OURA_MOI_ORIGIN; + } catch { + return false; + } +} export interface OuraCredentials { clientId: string; @@ -76,12 +134,18 @@ export interface OuraTokenResponse { scope?: string; } -async function postToken( +interface TokenAttempt { + status: number; + json: unknown; +} + +async function requestToken( + url: string, params: URLSearchParams, verb: string, -): Promise { +): Promise { const start = performance.now(); - const res = await safeFetch(OURA_OAUTH_TOKEN_URL, { + const res = await safeFetch(url, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", @@ -89,7 +153,7 @@ async function postToken( }, body: params.toString(), }); - const json = await res.json().catch(() => null); + const json: unknown = await res.json().catch(() => null); const verdict = classifyOuraResponse(res.status); getEvent()?.addExternalCall({ service: "oura", @@ -98,33 +162,91 @@ async function postToken( status: res.status, error: verdict.classification === "success" ? undefined : verdict.reason, }); + return { status: res.status, json }; +} + +function upstreamError(json: unknown): string | undefined { + if (json === null || typeof json !== "object") return undefined; + const error = (json as { error?: unknown }).error; + return typeof error === "string" ? error : undefined; +} + +/** + * Whether the legacy endpoint is worth asking after the live one refused: + * a 400, 401 or 404. The live endpoint may not know an older app's client, + * code or refresh token at all, and its refusal does not tell "unknown here" + * apart from "bad grant" reliably, so every such refusal gets the one retry. + * Server errors and rate limits do not: they say nothing about the grant. + */ +const FALLBACK_STATUSES = new Set([400, 401, 404]); + +function belongsElsewhere(attempt: TokenAttempt): boolean { + return FALLBACK_STATUSES.has(attempt.status); +} + +function tokenResult(attempt: TokenAttempt, verb: string): OuraTokenResponse { + const verdict = classifyOuraResponse(attempt.status); if (verdict.classification !== "success") { throw new OuraApiError({ verb, classification: verdict.classification, httpStatus: verdict.httpStatus, reason: verdict.reason, - upstreamError: typeof json?.error === "string" ? json.error : undefined, + upstreamError: upstreamError(attempt.json), }); } // A 2xx with an empty or non-JSON body (captive portal, gateway 200, 204) // leaves `json` null. Casting it straight to the token type handed callers a // null they then dereferenced, surfacing an unclassified TypeError instead of // a handled integration error. Narrow explicitly and classify as transient. - if (json === null || typeof json !== "object") { + if (attempt.json === null || typeof attempt.json !== "object") { throw new OuraApiError({ verb, classification: "transient", - httpStatus: res.status, + httpStatus: attempt.status, reason: "empty_token_body", }); } - return json as OuraTokenResponse; + return attempt.json as OuraTokenResponse; } +/** + * Ask the live endpoint, then the legacy one when `mayFallBack` allows it and + * the live endpoint refused with 400, 401 or 404. The legacy endpoint is + * never asked first. + */ +async function postToken( + params: URLSearchParams, + verb: string, + mayFallBack: boolean, +): Promise { + const live = await requestToken(OURA_OAUTH_TOKEN_URL, params, verb); + if (mayFallBack && belongsElsewhere(live)) { + const legacy = await requestToken( + OURA_OAUTH_LEGACY_TOKEN_URL, + params, + `${verb}.legacy`, + ); + if (legacy.status >= 200 && legacy.status < 300) { + return tokenResult(legacy, verb); + } + // Neither endpoint took it. The live endpoint's answer is the one that + // describes the grant for every current app, so report that one. + } + return tokenResult(live, verb); +} + +/** + * Exchange an authorization code. `iss` is the callback's issuer parameter: + * a code from the current authorization server goes to the live endpoint + * only, since a second attempt elsewhere could never succeed. Without an + * issuer (an older app) the legacy endpoint is the fallback after the live + * one has refused the code; the live endpoint is always asked first. + */ export async function exchangeCode( code: string, creds: OuraCredentials, + iss?: string | null, ): Promise { return postToken( new URLSearchParams({ @@ -135,9 +257,15 @@ export async function exchangeCode( client_secret: creds.clientSecret, }), "exchangeCode", + !issuedByMoi(iss), ); } +/** + * Refresh an access token: live endpoint first, the legacy endpoint when the + * live one refuses it (a refresh token of an older app). + * A refused refresh does not consume the token, so the retry is safe. + */ export async function refreshAccessToken( refreshToken: string, creds: OuraCredentials, @@ -150,6 +278,7 @@ export async function refreshAccessToken( client_secret: creds.clientSecret, }), "refreshAccessToken", + true, ); } 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/coach-budget-reservation.test.ts b/tests/integration/coach-budget-reservation.test.ts index 2b4f28443..0ddc28a1d 100644 --- a/tests/integration/coach-budget-reservation.test.ts +++ b/tests/integration/coach-budget-reservation.test.ts @@ -9,7 +9,7 @@ * prove both contracts against a real Postgres; a mocked client could not * detect a missing atomic increment. */ -import { beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getPrismaClient, truncateAllTables } from "./setup"; @@ -180,3 +180,64 @@ describe("reserveBudget (real Postgres)", () => { expect(row?.totalTokens).toBeGreaterThan(0); }); }); + +/** + * The budget day turns at the account's own midnight, the boundary the daily + * briefing uses. On the UTC day a Berlin account's first generations after + * local midnight still counted against the previous day's spent ceiling. + */ +describe("the budget day is the account's local day", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("opens a fresh day at local midnight while the UTC day is still running", async () => { + const { buildDateKey, reserveBudget } = + await import("@/lib/ai/coach/budget"); + const user = await getPrismaClient().user.create({ + data: { + username: "budget-berlin", + email: "budget-berlin@example.test", + role: "USER", + timezone: "Europe/Berlin", + }, + }); + // 22:30 UTC on 2026-06-19 is 00:30 on 2026-06-20 in Berlin (CEST). + vi.useFakeTimers({ + toFake: ["Date"], + now: new Date("2026-06-19T22:30:00.000Z"), + }); + + // The evening used the whole ceiling, booked to the day that just ended. + await getPrismaClient().coachUsage.create({ + data: { + userId: user.id, + dateKey: "2026-06-19", + totalTokens: 1000, + operatorTokens: 1000, + messageCount: 3, + }, + }); + + const dateKey = await buildDateKey(user.id); + expect(dateKey).toBe("2026-06-20"); + const first = await reserveBudget( + user.id, + 200, + dateKey, + 1000, + "operator", + "job", + ); + expect(first.allowed).toBe(true); + const rows = await getPrismaClient().coachUsage.findMany({ + where: { userId: user.id }, + orderBy: { dateKey: "asc" }, + select: { dateKey: true, totalTokens: true }, + }); + expect(rows).toEqual([ + { dateKey: "2026-06-19", totalTokens: 1000 }, + { dateKey: "2026-06-20", totalTokens: 200 }, + ]); + }); +}); diff --git a/tests/integration/coach-chat.test.ts b/tests/integration/coach-chat.test.ts index 9ae2ae8d9..bafa99d28 100644 --- a/tests/integration/coach-chat.test.ts +++ b/tests/integration/coach-chat.test.ts @@ -230,7 +230,9 @@ describe("POST /api/insights/chat — integration", () => { // the reservation is refunded so the ledger stays at the cap. const { userId } = await seedUserWithSession(); const prisma = getPrismaClient(); - const dateKey = new Date().toISOString().slice(0, 10); + // The account's own day, the key the route reserves under. + const { buildDateKey } = await import("@/lib/ai/coach/budget"); + const dateKey = await buildDateKey(userId); // v1.21.0 — the daily cap is now provider-aware: the seeded user resolves // to a user-egress chain, so the applicable ceiling is USER_PLAN_CAP. Seed // the spend at that ceiling so the reservation trips the gate. (Seeding the diff --git a/tests/integration/document-ai-runs.test.ts b/tests/integration/document-ai-runs.test.ts index 2b82d6575..ffa9dbba5 100644 --- a/tests/integration/document-ai-runs.test.ts +++ b/tests/integration/document-ai-runs.test.ts @@ -483,7 +483,7 @@ describe("the reaper", () => { const { readDailySpend, buildDateKey } = await import("@/lib/ai/coach/budget"); expect( - (await readDailySpend(user.id, buildDateKey())).total, + (await readDailySpend(user.id, await buildDateKey(user.id))).total, ).toBeGreaterThan(0); const { reapAiRuns } = await import("@/lib/documents/ai-runs/store"); @@ -493,7 +493,9 @@ describe("the reaper", () => { timedOut: 0, deleted: 0, }); - expect((await readDailySpend(user.id, buildDateKey())).total).toBe(0); + expect( + (await readDailySpend(user.id, await buildDateKey(user.id))).total, + ).toBe(0); const run = await getPrismaClient().documentAiRun.findUniqueOrThrow({ where: { id: runId }, diff --git a/tests/integration/google-health-backfill-rate-limit.test.ts b/tests/integration/google-health-backfill-rate-limit.test.ts index 6c2d3e04a..2cf7bd9a1 100644 --- a/tests/integration/google-health-backfill-rate-limit.test.ts +++ b/tests/integration/google-health-backfill-rate-limit.test.ts @@ -168,6 +168,20 @@ vi.mock("@/lib/safe-fetch", async (importOriginal) => { }; }); +/** The follow-up slices a progressing backfill queues, recorded. */ +const sent = vi.hoisted(() => ({ + jobs: [] as Array<{ queue: string; data: unknown; options: unknown }>, +})); +vi.mock("@/lib/jobs/boss-instance", async (importOriginal) => ({ + ...(await importOriginal()), + getGlobalBoss: () => ({ + send: async (queue: string, data: unknown, options: unknown) => { + sent.jobs.push({ queue, data, options }); + return `job-${sent.jobs.length}`; + }, + }), +})); + vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); @@ -212,6 +226,7 @@ beforeEach(async () => { fake.requestsByPath = new Map(); fake.status429 = 0; fake.status401 = 0; + sent.jobs = []; await seed(); }, 120_000); @@ -353,9 +368,19 @@ describe("Google Health backfill under rate limits and token expiry (#1194)", () fake.requestsByPath.get("heart-rate/dataPoints") ?? 0; return heartRatePagesSeen >= 3; }; + // It finished four collections, so it is progressing, not failing: it + // ends quietly and queues the next slice of the import. await expect( runGoogleHealthBackfillForUser(USER, stopAfterThreeHeartRatePages), - ).rejects.toThrow(/incomplete/); + ).resolves.toMatchObject({ continued: true }); + expect(sent.jobs).toHaveLength(1); + expect(sent.jobs[0]).toMatchObject({ + queue: "integration-backfill-admission", + data: { + kind: "google-health-backfill", + data: { userId: USER, slice: 1 }, + }, + }); const afterFirst = await getPrismaClient().googleHealthConnection.findUnique({ @@ -373,9 +398,10 @@ describe("Google Health backfill under rate limits and token expiry (#1194)", () ); expect(done).not.toContain("fetchHeartRate"); - // Second attempt: the finished collections are not read again. + // Second attempt, the queued slice: the finished collections are not + // read again. fake.requestsByPath = new Map(); - await runGoogleHealthBackfillForUser(USER); + await runGoogleHealthBackfillForUser(USER, undefined, 1); expect(fake.requestsByPath.get("exercise/dataPoints")).toBeUndefined(); expect(fake.requestsByPath.get("sleep/dataPoints")).toBeUndefined(); @@ -392,6 +418,29 @@ describe("Google Health backfill under rate limits and token expiry (#1194)", () expect(afterSecond?.backfillProgress).toBeNull(); }); + it("keeps failing a backfill whose slice finished nothing new", async () => { + const { runGoogleHealthBackfillForUser } = + await import("@/lib/jobs/google-health-backfill"); + + // A first slice finishes everything but the heart-rate stream. + const stopAtThirdPage = () => + (fake.requestsByPath.get("heart-rate/dataPoints") ?? 0) >= 3; + await runGoogleHealthBackfillForUser(USER, stopAtThirdPage); + expect(sent.jobs).toHaveLength(1); + + // The next slice cannot get through the heart-rate stream either: no + // collection finishes, so this one is stuck and says so. + fake.requestsByPath = new Map(); + await expect( + runGoogleHealthBackfillForUser(USER, stopAtThirdPage, 1), + ).rejects.toThrow(/incomplete/); + expect(sent.jobs).toHaveLength(1); + const row = await getPrismaClient().googleHealthConnection.findUnique({ + where: { userId: USER }, + }); + expect(row?.backfillCompletedAt).toBeNull(); + }); + it("never runs the hourly poll or a manual sync beside a running backfill", async () => { fake.holdAtPage = 2; fake.held = new Promise((resolve) => { diff --git a/tests/integration/measurement-rollups.test.ts b/tests/integration/measurement-rollups.test.ts index 6e6613f1a..ba21112fb 100644 --- a/tests/integration/measurement-rollups.test.ts +++ b/tests/integration/measurement-rollups.test.ts @@ -35,6 +35,7 @@ import { } from "@/lib/rollups/measurement-rollups"; import { aggregateBuckets } from "@/lib/rollups/measurement-read"; import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator"; +import { windowWeighting } from "@/lib/measurements/day-statistic"; vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), @@ -65,6 +66,81 @@ afterEach(() => { vi.restoreAllMocks(); }); +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * The instant the summary fixtures count back from: noon UTC yesterday. + * + * They used to count back from `Date.now()`, with each reading a few more + * minutes earlier than the last. Run between midnight and about 01:15 UTC, + * the earliest of those minutes slid across midnight into the day before, + * so a day gained a reading in another hour, and for a type whose day is a + * mean of hourly means (pulse) the composed mean moved off the plain AVG the + * tests compared it with: the suite went red for an hour every night. Noon + * leaves every offset inside its own day, so the layout is the same at any + * time the suite runs. + */ +function fixtureAnchor(): number { + const now = new Date(); + return ( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) - + DAY_MS + + 12 * 60 * 60 * 1000 + ); +} + +/** + * Live SQL over the raw rows for the summary's count / min / max / mean, + * with the mean each type documents: a plain AVG over readings, or, for a + * type whose day is a mean of hourly means (`windowWeighting(type) === + * "day"`), the mean of those per-day values. + */ +async function liveSummaryRows(userId: string, since: Date) { + const prisma = getPrismaClient(); + const rows = await prisma.$queryRaw< + Array<{ + type: string; + count: bigint; + min_value: number; + max_value: number; + mean_value: number; + day_mean_value: number; + }> + >` + WITH hourly AS ( + SELECT m."type", date_trunc('day', m."measured_at") AS day, + AVG(m."value") AS v + FROM measurements m + WHERE m."user_id" = ${userId} AND m."measured_at" >= ${since} + GROUP BY m."type", date_trunc('day', m."measured_at"), + date_trunc('hour', m."measured_at") + ), + daily AS ( + SELECT "type", day, AVG(v) AS v FROM hourly GROUP BY "type", day + ), + by_day AS ( + SELECT "type", AVG(v)::double precision AS day_mean_value + FROM daily GROUP BY "type" + ) + SELECT + m."type"::text AS type, + COUNT(*) AS count, + MIN(m."value")::double precision AS min_value, + MAX(m."value")::double precision AS max_value, + AVG(m."value")::double precision AS mean_value, + d.day_mean_value AS day_mean_value + FROM measurements m + JOIN by_day d ON d."type" = m."type" + WHERE m."user_id" = ${userId} AND m."measured_at" >= ${since} + GROUP BY m."type", d.day_mean_value + `; + return rows.map((row) => ({ + ...row, + expectedMean: + windowWeighting(row.type) === "day" ? row.day_mean_value : row.mean_value, + })); +} + describe("measurement rollups — integration", () => { it("writes a DAY rollup row that matches the inserted measurements", async () => { const prisma = getPrismaClient(); @@ -298,8 +374,8 @@ describe("measurement rollups — integration", () => { // type families exercise the per-type composition path. The // values are deliberately fractional so the round2 contract gets // a workout. - const now = Date.now(); - const dayMs = 24 * 60 * 60 * 1000; + const now = fixtureAnchor(); + const dayMs = DAY_MS; const seedRows: Array<{ userId: string; type: "WEIGHT" | "PULSE"; @@ -336,26 +412,7 @@ describe("measurement rollups — integration", () => { // the source of truth — the read-swap must produce the same // count/min/max/mean byte-for-byte. const ninetyDaysAgo = new Date(Date.now() - 90 * dayMs); - const live = await prisma.$queryRaw< - Array<{ - type: string; - count: bigint; - min_value: number; - max_value: number; - mean_value: number; - }> - >` - SELECT - m."type"::text AS type, - COUNT(*) AS count, - MIN(m."value")::double precision AS min_value, - MAX(m."value")::double precision AS max_value, - AVG(m."value")::double precision AS mean_value - FROM measurements m - WHERE m."user_id" = ${user.id} - AND m."measured_at" >= ${ninetyDaysAgo} - GROUP BY m."type" - `; + const live = await liveSummaryRows(user.id, ninetyDaysAgo); const round2 = (n: number) => Math.round(n * 100) / 100; @@ -366,9 +423,10 @@ describe("measurement rollups — integration", () => { expect(summary.count).toBe(Number(liveRow.count)); expect(summary.min).toBe(round2(liveRow.min_value)); expect(summary.max).toBe(round2(liveRow.max_value)); - // The composed weighted mean equals AVG(value) over the same - // rows; both run through `round2`. - expect(summary.mean).toBe(round2(liveRow.mean_value)); + // The composed mean equals the type's documented mean over the same + // rows (AVG, or the mean of hourly-mean days); both run through + // `round2`. + expect(summary.mean).toBe(round2(liveRow.expectedMean)); } }); @@ -741,8 +799,8 @@ describe("measurement rollups — integration", () => { // 60 readings over 30 days, two types, deliberately spread so // anomalies + slopes + windowed avgs all have signal. - const now = Date.now(); - const dayMs = 24 * 60 * 60 * 1000; + const now = fixtureAnchor(); + const dayMs = DAY_MS; const seedRows: Array<{ userId: string; type: "WEIGHT" | "PULSE"; @@ -777,26 +835,7 @@ describe("measurement rollups — integration", () => { // Parallel live aggregation over the same 90-day window using the // exact heavy-aggregate shape the cold-mount fallback runs. const ninetyDaysAgo = new Date(now - 90 * dayMs); - const live = await prisma.$queryRaw< - Array<{ - type: string; - count: bigint; - min_value: number; - max_value: number; - mean_value: number; - }> - >` - SELECT - m."type"::text AS type, - COUNT(*) AS count, - MIN(m."value")::double precision AS min_value, - MAX(m."value")::double precision AS max_value, - AVG(m."value")::double precision AS mean_value - FROM measurements m - WHERE m."user_id" = ${user.id} - AND m."measured_at" >= ${ninetyDaysAgo} - GROUP BY m."type" - `; + const live = await liveSummaryRows(user.id, ninetyDaysAgo); const aggregate = await buildComprehensiveAggregate(user.id); @@ -811,7 +850,7 @@ describe("measurement rollups — integration", () => { expect(summary.count).toBe(Number(liveRow.count)); expect(summary.min).toBe(round2(liveRow.min_value)); expect(summary.max).toBe(round2(liveRow.max_value)); - expect(summary.mean).toBe(round2(liveRow.mean_value)); + expect(summary.mean).toBe(round2(liveRow.expectedMean)); // Narrow aggregate fills the non-composable windowed columns. // Slope tuples and avg7/avg30 are sourced from live SQL even on // the rollup-fresh path so the values are present (not null) as diff --git a/tests/integration/rate-limit-429-headers.test.ts b/tests/integration/rate-limit-429-headers.test.ts index 87800caef..db4608027 100644 --- a/tests/integration/rate-limit-429-headers.test.ts +++ b/tests/integration/rate-limit-429-headers.test.ts @@ -185,7 +185,7 @@ describe("429 rate-limit headers (real Postgres)", () => { await getPrismaClient().coachUsage.create({ data: { userId: TEST_USER_ID, - dateKey: buildDateKey(), + dateKey: await buildDateKey(TEST_USER_ID), totalTokens: USER_PLAN_CAP, messageCount: 1, }, 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(); + } + }); +});