Merge pull request #1255 from MBombeck/release/v1.43.9

Release v1.43.9
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-10-21 01:51:27 +02:00
commit c1fb59fe39
155 files changed
+6365 -1353

No files matched your search

+5 -3
View File
@@ -153,9 +153,11 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# Oura Cloud v2 -- optional integration (full OAuth, shared app from env)
# -----------------------------------------------------------------------------
# Register an app at https://cloud.ouraring.com/oauth/applications and set its
# redirect URI to `${NEXT_PUBLIC_APP_URL}/api/oura/callback`. Both vars together
# enable the Oura connect button. Oura uses refresh tokens; the sync refreshes
# reactively on a 401. OURA_REDIRECT_URI overrides the derived callback.
# redirect URI to `${NEXT_PUBLIC_APP_URL}/api/oura/callback`. Enable the Daily,
# Personal, SpO2, Heart health and Stress permissions on it; HealthLog asks for
# exactly those. Both vars together enable the Oura connect button. Oura uses
# refresh tokens; the sync refreshes reactively on a 401. OURA_REDIRECT_URI
# overrides the derived callback.
# OURA_CLIENT_ID=""
# OURA_CLIENT_SECRET=""
# OURA_REDIRECT_URI=""
+77
View File
@@ -1,5 +1,82 @@
# Changelog
## [1.43.9] — 2026-10-21
### Fixed
- **Oura's resilience level syncs.** Resilience sits behind Oura's stress
permission, which HealthLog did not ask for, so it was refused even with
every other permission granted. HealthLog now asks for it. When Oura refuses
a single data type for a missing permission, the Oura card names exactly
that type, the refusal no longer triggers a token refresh and never counts
toward the reconnect warning. After updating, enable Stress for your app in
Oura's developer portal and connect Oura once more. (#1204)
- **Connecting an integration survives a repeated return link.** Some
browsers open the provider's return link twice. The second visit spent the
same one-time code again, and Oura answered by revoking the connection the
first visit had just made. Every connect flow (Oura, Polar, Strava, WHOOP,
Withings, Fitbit, Google Health) now claims the return link once; a second
visit waits for the first and lands on the same page. (#1204)
- **Two syncs no longer spend the same Oura sign-in.** Syncs that renewed the
Oura sign-in at the same moment each used the one-time renewal token, which
Oura can treat as reuse. Renewals now take turns.
- **Apple Health rows show the newest reading.** A type HealthLog also writes
back to Apple Health, such as weight entered here, showed the age of the
last sample Apple Health sent while the status above it already counted the
newer entry. The row now shows whichever is newer. (#1247)
- **An expired ChatGPT sign-in waits for you.** When the sign-in is refused as
expired, the connection is marked as needing a reconnect after the first
refusal and stays parked until you sign in again, instead of being retried
on every insight run. Signing in again puts it back in front at once. Two
renewals at the same moment take turns, so a working sign-in is never
parked by mistake.
- The demo instance no longer shows the setup checklist, whose steps cannot be
done in a read-only account.
### Changed
- **Faster syncs.** Withings syncs, full syncs included, write a batch in one
pass and skip readings that did not change, so a sync that took half a
minute finishes in about a second. Every connected service refreshes its
daily charts in one pass after a sync instead of one day at a time.
- **Faster dashboard and insights.** The all-time reading counts on the
dashboard and the all-time figures for weight, blood pressure and pulse are
read from the daily summaries instead of every reading, which makes them
many times faster for accounts with a lot of watch data. The all-time
figures fall back to the readings wherever the daily summaries do not match
them. The integrations settings card answers sooner on the first visit.
- **Lighter server.** Each signed-in request loads only the small account
fields it needs, so the profile picture and stored credentials are no longer
read into memory on every call. The server loads each page's code when it
is first used rather than all of it at start, so it starts with less than
half the memory and without a pause in its first minute; a page's first
visit after a restart takes a few milliseconds longer.
- The hourly sign-in location lookup no longer retries addresses it cannot
place on every run; it tries such an address again after a week.
- Requests that probe for server actions, which this app does not use, are
turned away with a 404 and counted in one warning line instead of filling
the error log.
- The periodic memory log line breaks the heap down by area, so a large heap
can be explained from the logs.
### Security
- The `hono` dependency floor is raised to 4.13.11 (GHSA-5r4p-p66f-jhc7).
### Upgrade notes
- Three additive migrations run at start (0417 to 0419). The first start after
the upgrade refills the daily summaries once in the background; until that
finishes, the all-time figures are read from the readings as before.
- Oura: enable the Stress permission for your app in Oura's developer portal,
then connect Oura once more and leave every permission ticked.
### Thanks
@haritowa for tracing the Oura resilience refusal to the missing permission
and for timing the repeated return link, @alondin for staying with #1204
through every round, and @mills1975 for #1247.
## [1.43.8] — 2026-10-20
### Fixed
+7 -4
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.43.8
version: 1.43.9
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -77130,7 +77130,9 @@ components:
glucose while their entry is `bidirectional` or `writeOnly` and enabled): when the newest reading of the
type was entered here rather than arriving from Apple Health, or null when none was. Such readings go to the
phone and never come back as an arrival, so `stale` already counts them: the type is quiet only when both
this and `lastSeenAt` are. Show it as the server sends it; do not recompute `stale`."
are. Since v1.43.9 `lastSeenAt` on such a type is the newer of this and the newest reading from Apple
Health, because a reading entered here is in Apple Health too; show both as the server sends them and do not
recompute `stale`."
anyOf:
- type: string
format: date-time
@@ -79301,8 +79303,9 @@ components:
type: boolean
scopeReconnect:
deprecated: true
description: "Deprecated: removed in v1.44, read `health`. Oura only. Collections the stored grant does not cover: they
answered 401 while sibling collections succeeded with the same token. Not a failure and not counted toward
description: "Deprecated: removed in v1.44, read `health`. Oura only. Collections the stored grant does not cover: Oura
refused them for a missing scope (named in its 401, or inferred when sibling collections succeeded with the
same token), or the scopes Oura reported at connect leave theirs out. Not a failure and not counted toward
the reconnect threshold; one reconnect asks for the missing scopes. Empty when the grant covers everything
HealthLog reads."
type: array
+1 -1
View File
@@ -6,7 +6,7 @@ A Node process that reaches its heap limit stops with `JavaScript heap out of me
Two background wide events, no configuration needed:
- `runtime.heap.sampled` (info), every five minutes, and every minute while the heap is at or above 70 % of its limit. It goes to stdout and Loki but not to Admin > App logs, so it cannot push other events out of that page's 500-entry buffer. It carries `heap_used_mb`, `heap_total_mb`, `heap_limit_mb`, `heap_used_ratio`, `old_space_mb`, `rss_mb`, `external_mb`, `array_buffers_mb`, `active_requests`, `active_jobs`, `in_flight_dropped` and `uptime_s`.
- `runtime.heap.sampled` (info), every five minutes, and every minute while the heap is at or above 70 % of its limit. It goes to stdout and Loki but not to Admin > App logs, so it cannot push other events out of that page's 500-entry buffer. It carries `heap_used_mb`, `heap_total_mb`, `heap_limit_mb`, `heap_used_ratio`, the used size of each heap space (`new_space_mb`, `old_space_mb`, `code_space_mb`, `large_object_space_mb`, `new_large_object_space_mb`, `code_large_object_space_mb`, `trusted_space_mb`, `read_only_space_mb`, and `other_spaces_mb` for any space not named), `rss_mb`, `external_mb`, `array_buffers_mb`, `active_requests`, `active_jobs`, `in_flight_dropped` and `uptime_s`. The spaces add up to `heap_used_mb`: a large buffer or string (a profile picture held in memory, a long response body) shows in `large_object_space_mb`, not in `old_space_mb`, and memory outside the V8 heap shows in `external_mb` and `array_buffers_mb`.
- `runtime.heap.pressure` (warn), the first time the heap reaches 85 % of its limit. Besides the figures above it lists the 50 oldest requests and jobs in flight (`in_flight_oldest`: kind, method, route, age in seconds, whether it was a server action) and the routes most of them share (`in_flight_by_route`). Routes are reduced to their shape: no query string, secret path segments replaced by `[REDACTED]`, ids by `:id`, anything unusual by `:x`. It writes again only after the heap has dropped below 70 % and thirty minutes have passed. One exception: when the heap reaches 95 %, the same report is written again at once, with `pressure_ratio: 0.95`, whatever the cooldown; that tier re-arms as soon as the heap is back under 85 %. A heap that climbs to its limit shortly after an 85 % report therefore still leaves a report naming the work that pushed it over.
The heap is read every five seconds, a single call that takes microseconds. Tracking requests costs one map insert and one delete per request.
+1 -1
View File
@@ -4657,7 +4657,7 @@
"scoreCrossLink": "Den zusammengesetzten Erholungs-Score ansehen",
"emptyTitle": "Noch keine Erholungsdaten",
"empty": "Diese Seite füllt sich mit den Erholungs-, Belastungs- und Trainingslastwerten von WHOOP oder Polar und mit Ouras Resilienz-Stufe.",
"emptyOuraPermission": "Ouras Resilienz-Stufe braucht Zugriff auf Herzgesundheitsdaten, also verbinde Oura einmal neu unter Integrationen und erlaube ihn.",
"emptyOuraPermission": "Ouras Resilienz-Stufe braucht die Berechtigung für Stressdaten, also verbinde Oura einmal neu unter Integrationen und erlaube sie.",
"emptyOuraNoResilience": "Oura ist verbunden, und seine Resilienz-Stufe erscheint hier, sobald Oura eine liefert.",
"emptyAction": "Integrationen öffnen",
"block": {
+1 -1
View File
@@ -4657,7 +4657,7 @@
"scoreCrossLink": "See the composite recovery score",
"emptyTitle": "No recovery data yet",
"empty": "This page fills with the recharge, strain and load readings from WHOOP or Polar and with Oura's resilience level.",
"emptyOuraPermission": "Oura's resilience level needs heart-health access, so reconnect Oura once under Integrations and allow it.",
"emptyOuraPermission": "Oura's resilience level needs the stress permission, so reconnect Oura once under Integrations and allow it.",
"emptyOuraNoResilience": "Oura is connected, and its resilience level appears here as soon as Oura provides one.",
"emptyAction": "Open integrations",
"block": {
+1 -1
View File
@@ -4657,7 +4657,7 @@
"scoreCrossLink": "Ver la puntuación de recuperación combinada",
"emptyTitle": "Aún no hay datos de recuperación",
"empty": "Esta página se llena con los valores de recuperación, esfuerzo y carga de WHOOP o Polar y con el nivel de resiliencia de Oura.",
"emptyOuraPermission": "El nivel de resiliencia de Oura necesita acceso a los datos de salud cardíaca, así que vuelve a conectar Oura una vez en Integraciones y permítelo.",
"emptyOuraPermission": "El nivel de resiliencia de Oura necesita el permiso de datos de estrés, así que vuelve a conectar Oura una vez en Integraciones y permítelo.",
"emptyOuraNoResilience": "Oura está conectado y su nivel de resiliencia aparecerá aquí en cuanto Oura proporcione uno.",
"emptyAction": "Abrir integraciones",
"block": {
+1 -1
View File
@@ -4657,7 +4657,7 @@
"scoreCrossLink": "Voir le score de récupération composite",
"emptyTitle": "Pas encore de données de récupération",
"empty": "Cette page se remplit avec les valeurs de récupération, d'effort et de charge de WHOOP ou Polar et avec le niveau de résilience d'Oura.",
"emptyOuraPermission": "Le niveau de résilience d'Oura demande l'accès aux données de santé cardiaque : reconnectez Oura une fois dans Intégrations et autorisez-le.",
"emptyOuraPermission": "Le niveau de résilience d'Oura demande l'autorisation d'accès aux données de stress : reconnectez Oura une fois dans Intégrations et autorisez-la.",
"emptyOuraNoResilience": "Oura est connecté, et son niveau de résilience apparaîtra ici dès qu'Oura en fournira un.",
"emptyAction": "Ouvrir les intégrations",
"block": {
+1 -1
View File
@@ -4657,7 +4657,7 @@
"scoreCrossLink": "Vedi il punteggio di recupero composito",
"emptyTitle": "Ancora nessun dato di recupero",
"empty": "Questa pagina si riempie con i valori di recupero, sforzo e carico di WHOOP o Polar e con il livello di resilienza di Oura.",
"emptyOuraPermission": "Il livello di resilienza di Oura richiede l'accesso ai dati sulla salute del cuore, quindi ricollega Oura una volta in Integrazioni e consentilo.",
"emptyOuraPermission": "Il livello di resilienza di Oura richiede l'autorizzazione ai dati sullo stress, quindi ricollega Oura una volta in Integrazioni e consentila.",
"emptyOuraNoResilience": "Oura è collegato e il suo livello di resilienza apparirà qui non appena Oura ne fornirà uno.",
"emptyAction": "Apri integrazioni",
"block": {
+1 -1
View File
@@ -4657,7 +4657,7 @@
"scoreCrossLink": "종합 회복 점수 보기",
"emptyTitle": "아직 회복 데이터가 없어요",
"empty": "이 페이지에는 WHOOP 또는 Polar의 회복, 부하, 훈련 부하 값과 Oura의 회복탄력성 수준이 표시돼요.",
"emptyOuraPermission": "Oura의 회복탄력성 수준에는 심장 건강 데이터 접근 권한이 필요하니, 연동에서 Oura를 한 번 다시 연결하고 허용해 주세요.",
"emptyOuraPermission": "Oura의 회복탄력성 수준에는 스트레스 데이터 권한이 필요하니, 연동에서 Oura를 한 번 다시 연결하고 허용해 주세요.",
"emptyOuraNoResilience": "Oura가 연결되어 있으며, Oura가 회복탄력성 수준을 제공하는 즉시 여기에 표시돼요.",
"emptyAction": "통합 열기",
"block": {
+1 -1
View File
@@ -4668,7 +4668,7 @@
},
"emptyAction": "Integraties openen",
"emptyOuraNoResilience": "Oura is verbonden, en het veerkrachtniveau verschijnt hier zodra Oura er een levert.",
"emptyOuraPermission": "Het veerkrachtniveau van Oura heeft toegang tot hartgezondheid nodig, dus verbind Oura één keer opnieuw onder Integraties en sta dat toe.",
"emptyOuraPermission": "Het veerkrachtniveau van Oura heeft toestemming voor stressgegevens nodig, dus verbind Oura één keer opnieuw onder Integraties en sta die toe.",
"loadError": "Je herstelwaarden konden niet worden geladen."
},
"bodyTempDeviation": {
+1 -1
View File
@@ -4657,7 +4657,7 @@
"scoreCrossLink": "Zobacz łączny wynik regeneracji",
"emptyTitle": "Brak danych o regeneracji",
"empty": "Ta strona wypełnia się wartościami regeneracji, obciążenia i obciążenia treningowego z WHOOP lub Polar oraz poziomem odporności z Oura.",
"emptyOuraPermission": "Poziom odporności Oura wymaga dostępu do danych o zdrowiu serca, więc połącz Oura ponownie raz w Integracjach i zezwól na niego.",
"emptyOuraPermission": "Poziom odporności Oura wymaga uprawnienia do danych o stresie, więc połącz Oura ponownie raz w Integracjach i zezwól na nie.",
"emptyOuraNoResilience": "Oura jest połączona, a jej poziom odporności pojawi się tutaj, gdy tylko Oura go dostarczy.",
"emptyAction": "Otwórz integracje",
"block": {
+11
View File
@@ -329,9 +329,20 @@ const nextConfig: NextConfig = {
// their own body after authenticating (`src/lib/http/proxy-bypass-routes.ts`),
// and every other route's own cap is at or below this. Large exports still
// want a matching reverse-proxy body limit (docs/self-hosting/reverse-proxy.md).
//
// `preloadEntriesOnStart` is off. Next turns it on by default and then
// requires every route and page module (about 670 here) right after the
// server starts. That load runs on the event loop: a stall of several
// seconds in the first minute of every container, the first requests
// queued behind it, and a heap of about 430 MB before anyone has signed in,
// most of it code for routes a given instance rarely or never serves. Off,
// each module loads on its route's first request, which costs that one
// request a few tens of milliseconds; the same image measured locally
// started at 185 MB of heap instead of 428 and without the stall.
experimental: {
optimizePackageImports: ["recharts", "lucide-react"],
proxyClientMaxBodySize: "1mb",
preloadEntriesOnStart: false,
},
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.43.8",
"version": "1.43.9",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
+11 -11
View File
@@ -6,7 +6,7 @@ settings:
overrides:
js-yaml@<4.3.2: ^4.3.2
hono@<4.13.5: ^4.13.5
hono@<4.13.11: ^4.13.11
baseline-browser-mapping@<2.11.0: ^2.11.0
sharp@<0.35.4: ^0.35.4
dompurify@<3.4.16: ^3.4.16
@@ -669,7 +669,7 @@ packages:
resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==}
engines: {node: '>=18.14.1'}
peerDependencies:
hono: ^4.13.5
hono: ^4.13.11
'@humanfs/core@0.19.2':
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
@@ -4436,8 +4436,8 @@ packages:
hermes-parser@0.25.1:
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
hono@4.13.7:
resolution: {integrity: sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==}
hono@4.13.13:
resolution: {integrity: sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==}
engines: {node: '>=16.9.0'}
html-escaper@2.0.2:
@@ -7382,9 +7382,9 @@ snapshots:
'@hexagon/base64@1.1.28': {}
'@hono/node-server@1.19.17(hono@4.13.7)':
'@hono/node-server@1.19.17(hono@4.13.13)':
dependencies:
hono: 4.13.7
hono: 4.13.13
'@humanfs/core@0.19.2':
dependencies:
@@ -7581,7 +7581,7 @@ snapshots:
'@modelcontextprotocol/sdk@1.32.1(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@3.25.76)':
dependencies:
'@hono/node-server': 1.19.17(hono@4.13.7)
'@hono/node-server': 1.19.17(hono@4.13.13)
ajv: 8.20.0
ajv-formats: 3.0.1(ajv@8.20.0)
content-type: 1.0.5
@@ -7591,7 +7591,7 @@ snapshots:
eventsource-parser: 3.1.1
express: 5.2.1(supports-color@7.2.0)
express-rate-limit: 8.6.2(express@5.2.1(supports-color@7.2.0))(supports-color@7.2.0)
hono: 4.13.7
hono: 4.13.13
jose: 6.2.12
json-schema-typed: 8.0.2
pkce-challenge: 5.0.1
@@ -7605,7 +7605,7 @@ snapshots:
'@modelcontextprotocol/sdk@1.32.1(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@4.6.7)':
dependencies:
'@hono/node-server': 1.19.17(hono@4.13.7)
'@hono/node-server': 1.19.17(hono@4.13.13)
ajv: 8.20.0
ajv-formats: 3.0.1(ajv@8.20.0)
content-type: 1.0.5
@@ -7615,7 +7615,7 @@ snapshots:
eventsource-parser: 3.1.1
express: 5.2.1(supports-color@7.2.0)
express-rate-limit: 8.6.2(express@5.2.1(supports-color@7.2.0))(supports-color@7.2.0)
hono: 4.13.7
hono: 4.13.13
jose: 6.2.12
json-schema-typed: 8.0.2
pkce-challenge: 5.0.1
@@ -11247,7 +11247,7 @@ snapshots:
dependencies:
hermes-estree: 0.25.1
hono@4.13.7: {}
hono@4.13.13: {}
html-escaper@2.0.2: {}
+3 -2
View File
@@ -1,11 +1,12 @@
overrides:
# CVE-2026-84375 (js-yaml < 4.3.2, HIGH), CVE-2026-84363/84364/84365 (hono
# < 4.13.5, via @hono/node-server under the MCP SDK), CVE-2026-45819
# < 4.13.5, via @hono/node-server under the MCP SDK; GHSA-5r4p-p66f-jhc7 raised the
# floor to 4.13.11), CVE-2026-45819
# (baseline-browser-mapping < 2.11.0), GHSA-rgj7-g3m4-5g8c (sharp < 0.35.4,
# pulled by next). None is a direct dependency; the bounds move the copies
# the scanner flags and nothing else.
"js-yaml@<4.3.2": "^4.3.2"
"hono@<4.13.5": "^4.13.5"
"hono@<4.13.11": "^4.13.11"
"baseline-browser-mapping@<2.11.0": "^2.11.0"
"sharp@<0.35.4": "^0.35.4"
# GHSA-55q2-fjhq-7xh7: a crafted document gets past the sanitiser and its
@@ -0,0 +1,24 @@
-- The lowest and highest reading value per rollup bucket.
--
-- The full-history extremes the insight features carry (`allTimeMin`,
-- `allTimeMax`, `allTimeAvg` for weight, blood pressure and pulse) were read
-- from the raw `measurements` rows on every call: two passes over every
-- reading of those types the account ever recorded, the largest read on the
-- database. The DAY rollups already hold everything that fold needs except
-- the extremes of the readings themselves: `min_value` / `max_value` widen to
-- a row's own `value_min` / `value_max`, the intra-hour range an hourly
-- heart-rate statistic carries, so they are not the same figure.
--
-- Two nullable columns. Existing rows read NULL until the boot re-fold
-- (`rollup-full-backfill`, discovered by the NULL arm beside `sum_xy`)
-- refills them; new writes fill them from the same aggregate as every other
-- column. A reader treats a type with a NULL in its window as uncovered and
-- reads the raw rows, as before.
--
-- Reversibility:
-- ALTER TABLE "measurement_rollups" DROP COLUMN IF EXISTS "reading_min_value";
-- ALTER TABLE "measurement_rollups" DROP COLUMN IF EXISTS "reading_max_value";
ALTER TABLE "measurement_rollups" ADD COLUMN IF NOT EXISTS "reading_min_value" DOUBLE PRECISION;
ALTER TABLE "measurement_rollups" ADD COLUMN IF NOT EXISTS "reading_max_value" DOUBLE PRECISION;
@@ -0,0 +1,30 @@
-- The geo backfill notes when it last tried a row, and finds its candidates
-- through an index that holds only them.
--
-- The hourly backfill (`src/lib/jobs/geo-backfill.ts`) re-resolves audit rows
-- whose location or carrier is missing. An address no database will ever
-- place (a private range that reached the log through a proxy) stays missing,
-- so the same few rows were looked up again on every pass, and finding them
-- meant walking thirty days of `audit_logs` through `created_at`, filtering
-- every row that had long been resolved.
--
-- `geo_attempted_at` records the try; the backfill skips a row it tried less
-- than `GEO_BACKFILL_RETRY_DAYS` ago. NULL for every existing row: each is
-- tried once more, then waits like any other.
--
-- `audit_logs_geo_backfill_idx` is partial on the rows still missing
-- something, so it holds the backlog and nothing else, and shrinks as rows
-- resolve. Prisma cannot express the predicate, so the index lives here only
-- (noted at the model in `schema.prisma`). A plain build: it takes SHARE on
-- `audit_logs` while it reads the table once, which `migrate deploy` does at
-- boot before the app serves.
--
-- Reversibility:
-- DROP INDEX IF EXISTS "audit_logs_geo_backfill_idx";
-- ALTER TABLE "audit_logs" DROP COLUMN IF EXISTS "geo_attempted_at";
ALTER TABLE "audit_logs" ADD COLUMN IF NOT EXISTS "geo_attempted_at" TIMESTAMP(3);
CREATE INDEX IF NOT EXISTS "audit_logs_geo_backfill_idx"
ON "audit_logs" ("created_at")
WHERE "ip_address" IS NOT NULL AND ("location" IS NULL OR "carrier" IS NULL);
@@ -0,0 +1,28 @@
-- Single-use OAuth callbacks.
--
-- A browser can deliver an OAuth callback twice with the same code and state,
-- a few hundred milliseconds apart. The connect flows that sign their state
-- (Oura, Polar, Strava) kept no record of it, so both requests exchanged the
-- code; the second was refused, and Oura answers a reused code by revoking the
-- token the first exchange had just issued. A row here is the claim on one
-- callback: the first request inserts it before the exchange, a duplicate
-- finds it taken and is sent where the first one went. No user id, token or
-- code is stored; the key is a hash of the provider and the state.
--
-- Reversibility:
-- DROP TABLE IF EXISTS "oauth_callback_claims";
-- CreateTable
CREATE TABLE "oauth_callback_claims" (
"key" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expires_at" TIMESTAMP(3) NOT NULL,
"settled_at" TIMESTAMP(3),
"location" TEXT,
CONSTRAINT "oauth_callback_claims_pkey" PRIMARY KEY ("key")
);
-- CreateIndex
CREATE INDEX "oauth_callback_claims_expires_at_idx" ON "oauth_callback_claims"("expires_at");
+71 -25
View File
@@ -2253,31 +2253,31 @@ enum RollupGranularity {
}
model MeasurementRollup {
userId String @map("user_id")
type MeasurementType
granularity RollupGranularity
bucketStart DateTime @map("bucket_start") @db.Timestamptz(3)
userId String @map("user_id")
type MeasurementType
granularity RollupGranularity
bucketStart DateTime @map("bucket_start") @db.Timestamptz(3)
/// v1.11.1 — source-aware grain. Rollup rows are minted per source so the
/// read path can collapse overlapping sources (e.g. WHOOP + Apple Watch
/// resting heart rate) to the ladder-canonical reading, while cumulative
/// metrics (steps, energy) sum per source. Always non-null; migration 0115
/// purged the legacy source-blind aggregates and the boot backfill re-mints
/// per-source rows.
source MeasurementSource
count Int
mean Float
minValue Float @map("min_value")
maxValue Float @map("max_value")
sd Float?
slope Float?
r2 Float?
source MeasurementSource
count Int
mean Float
minValue Float @map("min_value")
maxValue Float @map("max_value")
sd Float?
slope Float?
r2 Float?
/// v1.4.39 W-SUM — cumulative-metric sum for the bucket. Populated
/// alongside `mean / count` for every type; cumulative read paths
/// (steps, flights, distance, daylight, active-energy) consume this
/// directly instead of re-deriving from raw measurements. Nullable
/// to keep the migration additive — the boot backfill fills NULLs
/// for existing rows; new rows always carry the sum.
sumValue Float? @map("sum_value")
sumValue Float? @map("sum_value")
/// v1.20.0 F6 — per-bucket OLS regression accumulators over the same
/// canonical-source raw rows the live REGR_* query reads. With
/// `n = count` and `Σy = mean·count` these close the additive set so a
@@ -2291,11 +2291,21 @@ model MeasurementRollup {
/// parity-matches the cross-bucket compose. Nullable to keep migration 0190
/// additive — the boot backfill fills NULLs for pre-migration rows; new
/// rows always carry them (they ride the same aggregate as mean / sum).
sumX Float? @map("sum_x")
sumXy Float? @map("sum_xy")
sumXx Float? @map("sum_xx")
sumYy Float? @map("sum_yy")
computedAt DateTime @default(now()) @map("computed_at") @db.Timestamptz(3)
sumX Float? @map("sum_x")
sumXy Float? @map("sum_xy")
sumXx Float? @map("sum_xx")
sumYy Float? @map("sum_yy")
/// The lowest and highest reading `value` of the bucket. `minValue` /
/// `maxValue` widen to a row's own range (`Measurement.valueMin` /
/// `valueMax`, the intra-hour range an hourly heart-rate statistic
/// carries); these do not, so a reader whose figure is over the readings
/// themselves (the full-history extremes in `feature-blocks.ts`) can fold
/// the DAY tier instead of the raw rows. Nullable to keep migration 0417
/// additive: the boot re-fold fills existing rows, new rows always carry
/// them, and a reader treats a type with a NULL in its range as uncovered.
readingMinValue Float? @map("reading_min_value")
readingMaxValue Float? @map("reading_max_value")
computedAt DateTime @default(now()) @map("computed_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -4539,6 +4549,34 @@ model GoogleHealthOAuthState {
@@map("google_health_oauth_states")
}
/// One row per OAuth callback the connect flows have begun to act on, keyed on
/// a hash of the provider and the `state` it returned with. Inserting it is
/// the claim: a second request carrying the same `state` (a browser that sends
/// the callback twice, a reload, a retry) finds the row taken and never
/// exchanges the code again. An authorization code works once, and Oura
/// revokes the token the first exchange issued when it sees the code reused.
///
/// The claiming request records where it sent the browser, so the duplicate
/// sends it to the same place instead of to an error page. Holds no user id,
/// token or code: `location` is the app's own result URL (`?oura=connected`,
/// `?whoop=error&reason=…`). Rows outlive the state they guard by a few
/// minutes and are swept by expiry when a callback completes and by the daily
/// OAuth-state cleanup (`src/lib/jobs/oauth-callback-claim-cleanup.ts`).
model OAuthCallbackClaim {
/// sha256(provider + NUL + state), hex.
key String @id
provider String
createdAt DateTime @default(now()) @map("created_at")
expiresAt DateTime @map("expires_at")
/// When the claiming request finished, with or without a result URL.
settledAt DateTime? @map("settled_at")
/// The redirect the claiming request answered with. Null when it threw.
location String?
@@index([expiresAt])
@@map("oauth_callback_claims")
}
// ─── Mood Entries ─────────────────────────────────────────────
model MoodEntry {
@@ -5405,18 +5443,26 @@ model AuditLog {
/// lives.
actorUserId String? @map("actor_user_id")
action String // e.g. "auth.login", "measurement.create", "export.csv"
details String? // JSON string, no sensitive data
ipAddress String? @map("ip_address")
location String? // e.g. "Berlin, DE" — resolved from IP at log time
action String // e.g. "auth.login", "measurement.create", "export.csv"
details String? // JSON string, no sensitive data
ipAddress String? @map("ip_address")
location String? // e.g. "Berlin, DE" — resolved from IP at log time
// v1.4.27 B3 — resolved at audit-creation time from the bundled
// GeoLite2-ASN MMDB. `asn` is the autonomous-system number,
// `carrier` is the organisation string (e.g. "Deutsche Telekom AG").
// Both nullable so older rows, private-IP rows, and offline-miss
// rows stay valid.
asn Int? // autonomous-system number
carrier String? // autonomous-system organisation
createdAt DateTime @default(now()) @map("created_at")
asn Int? // autonomous-system number
carrier String? // autonomous-system organisation
/// When the geo backfill (`src/lib/jobs/geo-backfill.ts`) last tried this
/// row. A row it could not complete waits `GEO_BACKFILL_RETRY_DAYS` before
/// the next try, so an address no database will ever place (a private
/// range behind a proxy) is not resolved again on every hourly pass. NULL:
/// never tried. The backfill reads its candidates through the partial index
/// `audit_logs_geo_backfill_idx` (migration 0418, raw SQL only: Prisma
/// cannot express its predicate).
geoAttemptedAt DateTime? @map("geo_attempted_at")
createdAt DateTime @default(now()) @map("created_at")
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.43.8";
/* @sw-version-fallback */ "v1.43.9";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
+28 -3
View File
@@ -90,6 +90,7 @@ import {
import { ENVIRONMENT_LOCATION_AAD } from "../src/lib/crypto/encrypted-columns";
import { sealInsightsCachedText } from "../src/lib/insights/cached-text-cipher";
import {
DEFAULT_CHART_OVERLAY_PREFS,
DEFAULT_DASHBOARD_LAYOUT,
serializeDashboardLayout,
} from "../src/lib/dashboard-layout";
@@ -497,16 +498,40 @@ async function seed() {
// One row of six tiles (weight, the two blood-pressure values, resting
// heart rate, HRV and mood) and the weight chart right below it, so the
// first screen of the public demo shows the summary, the tiles and the
// start of a chart together. Every other tile stays one switch away in
// the layout settings; its data is seeded either way. Writing the layout
// verbatim keeps the demo's home page stable across default tweaks.
// start of a chart together. Below it only the measurement charts: the
// mood and medication cards, achievements, recent workouts and check-ups
// stay one switch away in the layout settings, their data seeded either
// way. Weight, BMI and blood pressure open on 90 days, resting heart rate
// on 30 with its 7-day trend and trend line, so each chart shows a shape
// rather than a flat week. Writing the layout verbatim keeps the demo's
// home page stable across default tweaks.
const SHOWCASE_TILE_IDS = new Set(["weight", "bp", "pulse", "hrv", "mood"]);
const HIDDEN_CARD_IDS = new Set([
"mood",
"medications",
"achievements",
"recentWorkouts",
"vorsorge",
]);
const demoDashboardLayout = serializeDashboardLayout({
...DEFAULT_DASHBOARD_LAYOUT,
widgets: DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) => ({
...w,
visible: w.visible && !HIDDEN_CARD_IDS.has(w.id),
tileVisible: SHOWCASE_TILE_IDS.has(w.id),
})),
chartOverlayPrefs: {
weight: { ...DEFAULT_CHART_OVERLAY_PREFS, rangePoints: 90 },
bmi: { ...DEFAULT_CHART_OVERLAY_PREFS, rangePoints: 90 },
bp: { ...DEFAULT_CHART_OVERLAY_PREFS, rangePoints: 90 },
// The resting heart rate chart is the dashboard's `pulse` chart.
pulse: {
...DEFAULT_CHART_OVERLAY_PREFS,
rangePoints: 30,
showTrendIndicator: true,
showTrendArrow: true,
},
},
});
await client.query(
`UPDATE users SET dashboard_widgets_json = $1 WHERE id = $2`,
@@ -85,7 +85,7 @@ describe("dashboard summary reads — sparkline rollup buckets", () => {
// defeat statement reuse.
const sql = code(READS);
const casts = sql.match(/\}::"measurement_type"\[\]/g) ?? [];
expect(casts.length).toBe(2);
expect(casts.length).toBe(3);
expect(sql).not.toMatch(/\$queryRawUnsafe/);
});
});
@@ -134,6 +134,8 @@ const ALLOWED_SQL: Record<string, string> = {
"lib/rollups/measurement-coverage.ts::1":
"Selects from `unnest` of the caller's type list; `measurements` is read inside `EXISTS`, one row per type at most.",
"lib/dashboard/summary-reads.ts::1":
"Selects from `unnest` of the type list; `measurements` is read only inside COUNT scalar subqueries, before the fold window for a type the DAY rollups cover.",
"lib/dashboard/summary-reads.ts::2":
"Selects from `generate_series` of the streak window's local days; `measurements` is read inside `EXISTS`, one row per day at most.",
};
@@ -176,7 +176,9 @@ describe("measurement write tail", () => {
const missing = handRolled.filter((file) => {
const src = readFileSync(join(SRC, file), "utf8");
return !(
/recomputeBucketsForMeasurement|afterMeasurementMutation/.test(src) &&
/recomputeBucketsFor(Measurement|Keys)|afterMeasurementMutation/.test(
src,
) &&
/invalidateStatusInsightsForTypes|afterMeasurementMutation/.test(src)
);
});
@@ -0,0 +1,37 @@
/**
* HealthLog defines no Server Actions.
*
* The proxy refuses every request carrying `Next-Action` on the strength of
* that (`src/proxy.ts`): the only callers that send one are scanners. A module
* that declares `"use server"` would make the refusal break a real feature, so
* adding one has to come with revisiting it. This guard is the reminder.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { describe, expect, it } from "vitest";
const SRC = join(__dirname, "..");
function sourceFiles(dir: string): string[] {
const out: string[] = [];
for (const name of readdirSync(dir)) {
const path = join(dir, name);
if (name === "generated" || name === "__tests__") continue;
if (statSync(path).isDirectory()) out.push(...sourceFiles(path));
else if (/\.(ts|tsx)$/.test(name)) out.push(path);
}
return out;
}
describe("no Server Actions", () => {
it('has no module or function that declares "use server"', () => {
const files = sourceFiles(SRC);
expect(files.length).toBeGreaterThan(100);
const declaring = files
.filter((file) =>
/^\s*["']use server["'];?\s*$/m.test(readFileSync(file, "utf8")),
)
.map((file) => relative(SRC, file));
expect(declaring).toEqual([]);
});
});
+77
View File
@@ -0,0 +1,77 @@
/**
* A Server Action call is refused at the proxy.
*
* HealthLog defines no Server Actions, so the only requests that carry
* `Next-Action` are scanners probing for them. Next used to answer each with
* an unstructured "Failed to find Server Action" at error level; the proxy now
* refuses it with a 404 and one structured `warn` line, and a request without
* the header is untouched.
*/
import { NextRequest } from "next/server";
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
type MockInstance,
} from "vitest";
vi.mock("@/lib/process-type", () => ({ shouldRunWeb: () => true }));
import { proxy } from "../proxy";
let warn: MockInstance<(...args: unknown[]) => void>;
beforeEach(() => {
warn = vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
warn.mockRestore();
});
function probe(path: string, headers: Record<string, string> = {}) {
return new NextRequest(`https://health.example.test${path}`, {
method: "POST",
headers: { "content-type": "text/plain", ...headers },
body: "[]",
});
}
describe("proxy on a Server Action call", () => {
it("refuses it with 404 and one warn line", async () => {
const response = proxy(
probe("/", { "next-action": "7f3a9c", "user-agent": "scanner" }),
);
expect(response.status).toBe(404);
expect(await response.json()).toEqual({
data: null,
error: "Not found",
meta: { errorCode: "server_action.unsupported" },
});
expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
expect(warn).toHaveBeenCalledTimes(1);
expect(JSON.parse(String(warn.mock.calls[0]![0]))).toMatchObject({
level: "warn",
action: { name: "proxy.server_action.refused" },
http: { method: "POST", path: "/", status: 404, user_agent: "scanner" },
});
});
it("never logs a secret path segment the probe aimed at", () => {
proxy(probe("/api/withings/webhook/s3cr3t-value", { "next-action": "x" }));
const line = String(warn.mock.calls[0]![0]);
expect(line).not.toContain("s3cr3t-value");
});
it("leaves a request without the header alone", () => {
const response = proxy(probe("/api/health"));
expect(response.status).not.toBe(404);
expect(warn).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,137 @@
/**
* The caller lookups load a projection of `User`, never the whole row.
*
* Every authenticated request resolves its caller through the session lookup
* (`lib/auth/session.ts`), the Bearer resolver (`lib/auth/bearer.ts`) or, for a
* switched record, the owner load in `lib/api-handler.ts`. They used to
* `include: { user: true }` / `findUnique({ where })`, which copied the profile
* picture (megabytes), the cached briefing text and every stored credential
* into memory on each request. They now name `SESSION_USER_SELECT`.
*
* Two halves:
* 1. each of those lookups carries the projection, and no file anywhere loads
* a related user row whole with `include: { user: true }`;
* 2. the projection itself leaves out every `Bytes` column of the model and
* every secret-shaped column, derived from `schema.prisma` so a new
* column of either kind is covered without editing this file.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { walkSourceFiles } from "./helpers/source-files";
import { SESSION_USER_SELECT } from "@/lib/auth/session-user";
const SRC = join(process.cwd(), "src");
function read(rel: string): string {
return readFileSync(join(SRC, rel), "utf8");
}
function stripComments(src: string): string {
return src
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/(^|[^:])\/\/.*$/gm, "$1");
}
/** The `{ ... }` argument of every call matching `callee`, braces balanced. */
function callObjects(src: string, callee: RegExp): string[] {
const code = stripComments(src);
const out: string[] = [];
const re = new RegExp(callee.source + /\s*\(\s*\{/.source, "g");
for (let m = re.exec(code); m; m = re.exec(code)) {
const open = m.index + m[0].length - 1;
let depth = 0;
let i = open;
for (; i < code.length; i++) {
if (code[i] === "{") depth++;
else if (code[i] === "}" && --depth === 0) break;
}
out.push(code.slice(open, i + 1));
}
return out;
}
const USER_LOOKUP = /prisma\.user\.find(?:Unique|First)(?:OrThrow)?/;
const SESSION_LOOKUP = /prisma\.session\.find(?:Unique|First)(?:OrThrow)?/;
describe("caller lookups load the session projection", () => {
it("the session lookups include the user through SESSION_USER_SELECT", () => {
const calls = callObjects(
read("lib/auth/session.ts"),
SESSION_LOOKUP,
).filter((body) => /\binclude\s*:/.test(body));
expect(calls.length).toBeGreaterThanOrEqual(2);
for (const body of calls) {
expect(body).toMatch(
/include\s*:\s*\{\s*user\s*:\s*\{\s*select\s*:\s*SESSION_USER_SELECT\s*\}\s*\}/,
);
}
});
it("the Bearer resolver and the switched-owner load select SESSION_USER_SELECT", () => {
for (const rel of ["lib/auth/bearer.ts", "lib/api-handler.ts"]) {
const calls = callObjects(read(rel), USER_LOOKUP);
expect(calls.length, rel).toBeGreaterThan(0);
for (const body of calls) {
expect(body, rel).toMatch(/\bselect\s*:/);
}
expect(
calls.some((body) => /select\s*:\s*SESSION_USER_SELECT\b/.test(body)),
rel,
).toBe(true);
}
});
it("no file loads a related user row whole", () => {
const files = walkSourceFiles(SRC, { floor: 3000 })
.filter((p) => !p.startsWith("generated/"))
.filter((p) => !p.includes("__tests__"))
.filter((p) => !/\.test\.tsx?$/.test(p));
expect(files.length).toBeGreaterThan(0);
const offenders = files.filter((rel) =>
/\buser\s*:\s*true\b/.test(
stripComments(read(rel)).replace(/select\s*:\s*\{[^{}]*\}/g, ""),
),
);
expect(offenders).toEqual([]);
});
});
describe("SESSION_USER_SELECT leaves out bytes and secrets", () => {
const schema = readFileSync(
join(process.cwd(), "prisma", "schema.prisma"),
"utf8",
);
const model = schema.match(/^model User \{([\s\S]*?)^\}/m)?.[1] ?? "";
const columns = [
...model.matchAll(/^\s+(\w+)\s+([A-Z]\w*)(\??)(?:\s|$)/gm),
].map(([, name, type]) => ({ name, type }));
const selected = Object.keys(SESSION_USER_SELECT);
it("reads the User model", () => {
expect(columns.length).toBeGreaterThan(50);
expect(selected.length).toBeGreaterThan(0);
});
it("names only columns the model has", () => {
const names = new Set(columns.map((c) => c.name));
expect(selected.filter((key) => !names.has(key))).toEqual([]);
});
it("selects no Bytes column", () => {
const bytes = columns.filter((c) => c.type === "Bytes").map((c) => c.name);
expect(bytes).toContain("avatarBytes");
expect(selected.filter((key) => bytes.includes(key))).toEqual([]);
});
it("selects no credential, secret or large cached text", () => {
const secretShaped =
/Encrypted$|^passwordHash$|^totpSecret|^totpLastStep$|^telegramBotToken$|^insightsCachedText$|^environmentAqHistoryJson$/;
const secrets = columns
.map((c) => c.name)
.filter((name) => secretShaped.test(name));
expect(secrets.length).toBeGreaterThan(10);
expect(selected.filter((key) => secrets.includes(key))).toEqual([]);
});
});
@@ -0,0 +1,35 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
/**
* The setup checklist on a demo instance.
*
* The demo is one shared, read-only account carrying onboarding answers, so
* `isStillInSetup` keeps the checklist on screen for every visitor although
* none of its rows can be done there. The shell already leaves out the tour
* under `DEMO_MODE`; the dashboard leaves out the checklist the same way.
* Pinned here: every render path of the page threads the flag, and the
* client mounts the checklist only without it.
*/
const PAGE_PATH = join(process.cwd(), "src/app/page.tsx");
const CLIENT_PATH = join(process.cwd(), "src/app/page-client.tsx");
describe("dashboard setup checklist on a demo instance", () => {
it("threads DEMO_MODE into every render of the dashboard client", () => {
const src = readFileSync(PAGE_PATH, "utf8");
expect(src).toContain('process.env.DEMO_MODE === "true"');
const renders = src.match(/<DashboardPageClient\b[^>]*>/g) ?? [];
expect(renders.length).toBeGreaterThan(0);
for (const render of renders) {
expect(render).toContain("demoMode={demoMode}");
}
});
it("mounts the checklist only outside demo mode", () => {
const src = readFileSync(CLIENT_PATH, "utf8");
const mounts = src.match(/<GettingStartedChecklist\b[^>]*\/>/g) ?? [];
expect(mounts).toHaveLength(1);
expect(src).toContain("{demoMode ? null : <GettingStartedChecklist />}");
});
});
@@ -14,7 +14,11 @@ vi.mock("@/lib/db", () => ({
session: { findUnique: vi.fn() },
webauthnMfaCredential: { count: vi.fn(async () => 0) },
passkey: { count: vi.fn(async () => 0) },
user: { findFirst: vi.fn() },
user: {
findFirst: vi.fn(),
// The stored row: the password hash is not on the session user.
findUnique: vi.fn(async () => ({ passwordHash: "argon" })),
},
accountGrant: { create: vi.fn(), findFirst: vi.fn() },
},
}));
@@ -50,7 +54,6 @@ const OWNER = {
role: "USER",
username: "owner",
totpConfirmedAt: null,
passwordHash: "argon",
};
function invite(): NextRequest {
@@ -8,6 +8,7 @@ import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { getClientIp, apiSuccess, apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { forgetProviderHealth } from "@/lib/ai/provider-health-ledger";
/**
* Operator-shared central Codex — device-code poll (admin only).
@@ -91,6 +92,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
cookieStore.delete("central_codex_device");
// A new central sign-in: every account's ledger rows describe the refused
// one, and the central connection is one credential shared by all of them.
await forgetProviderHealth({ providerType: "admin-codex" });
await auditLog("admin.central-codex.connected", {
userId: user.id,
@@ -60,7 +60,10 @@ export const POST = apiHandler(
return apiError("Password required", 422);
}
const target = await prisma.user.findUnique({ where: { id } });
const target = await prisma.user.findUnique({
where: { id },
select: { locale: true, username: true },
});
if (!target) return apiError("User not found", 404);
const locale = await resolveServerLocale({
+4 -1
View File
@@ -60,7 +60,10 @@ export const PUT = apiHandler(
return returnAllZodIssues(parsed.error, 422);
}
const target = await prisma.user.findUnique({ where: { id } });
const target = await prisma.user.findUnique({
where: { id },
select: { role: true },
});
if (!target) return apiError("User not found", 404);
// Prevent removing the last admin
@@ -9,6 +9,7 @@ import { auditLog } from "@/lib/auth/audit";
import { getClientIp, apiSuccess, apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { INSIGHTS_CACHE_CLEARED } from "@/lib/insights/cached-text-cipher";
import { forgetProviderHealth } from "@/lib/ai/provider-health-ledger";
/**
* v1.4.7.1: Device-code flow polling endpoint.
@@ -88,6 +89,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
cookieStore.delete("codex_device");
// A new sign-in: whatever the ledger held against the old one no longer
// describes this credential.
await forgetProviderHealth({ userId: user.id, providerType: "codex" });
await auditLog("codex.oauth.connected", {
userId: user.id,
@@ -34,6 +34,7 @@ vi.mock("@/lib/rate-limit", () => ({
vi.mock("@/lib/auth/mfa/verify-factor", () => ({
verifyMfaFactor: vi.fn(),
loadFactorUser: vi.fn(),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
@@ -42,7 +43,7 @@ import { POST } from "../route";
import { requireMfaManagementAuth } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
import { destroyOtherSessions } from "@/lib/auth/session";
import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
import { loadFactorUser, verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
function req() {
return new Request("http://localhost/api/auth/me/mfa/disable", {
@@ -54,6 +55,12 @@ function req() {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(loadFactorUser).mockResolvedValue({
id: "u1",
totpSecretEncrypted: "enc",
totpLastStep: null,
totpConfirmedAt: new Date("2026-01-01"),
});
vi.mocked(requireMfaManagementAuth).mockResolvedValue({
transport: "cookie",
user: { id: "u1" },
+5 -6
View File
@@ -30,7 +30,7 @@ import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { destroyOtherSessions } from "@/lib/auth/session";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
import { loadFactorUser, verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
import { mfaDisableSchema } from "@/lib/validations/mfa";
export const dynamic = "force-dynamic";
@@ -72,11 +72,10 @@ export const POST = apiHandler(async (req: Request) => {
);
}
const factor = await verifyMfaFactor(
user,
parsed.data.method,
parsed.data.code,
);
const factorUser = await loadFactorUser(user.id);
const factor = factorUser
? await verifyMfaFactor(factorUser, parsed.data.method, parsed.data.code)
: { ok: false, replay: false };
if (!factor.ok) {
await auditLog("auth.mfa.failed", {
userId: user.id,
@@ -37,6 +37,7 @@ import { destroyOtherSessions } from "@/lib/auth/session";
import { decrypt } from "@/lib/crypto";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { verifyTotp } from "@/lib/auth/mfa/totp";
import { loadFactorUser } from "@/lib/auth/mfa/verify-factor";
import {
generateRecoveryCodes,
persistRecoveryCodes,
@@ -69,7 +70,9 @@ export const POST = apiHandler(async (req: Request) => {
if (user.totpConfirmedAt) {
throw new HttpError(409, "A second factor is already active");
}
if (!user.totpSecretEncrypted) {
// The pending secret and its replay floor are not on the per-request user.
const pending = await loadFactorUser(user.id);
if (!pending?.totpSecretEncrypted) {
throw new HttpError(409, "Start enrollment before confirming");
}
@@ -90,11 +93,11 @@ export const POST = apiHandler(async (req: Request) => {
// Decrypt is fail-closed — a missing/rotated key throws rather than
// confirming against junk.
const secret = decrypt(user.totpSecretEncrypted);
const secret = decrypt(pending.totpSecretEncrypted);
const result = verifyTotp(
secret,
parsed.data.code,
user.totpLastStep === null ? null : Number(user.totpLastStep),
pending.totpLastStep === null ? null : Number(pending.totpLastStep),
);
if (!result.valid) {
+10 -2
View File
@@ -162,6 +162,7 @@ export const GET = apiHandler(async () => {
onboardingRow,
ai,
coachReasoning,
identity,
] = await Promise.all([
// The actor's own row is already in hand; a switched session needs the
// record's `gender`, which is the column the cycle gate derives from.
@@ -235,6 +236,13 @@ export const GET = apiHandler(async () => {
recordId,
authority: aiAuthority,
}),
// The encrypted insurance number is not on the per-request user
// (`SESSION_USER_SELECT`); it is read here, for the one response that
// shows it.
prisma.user.findUnique({
where: { id: user.id },
select: { insuranceNumberEncrypted: true },
}),
]);
// The masking step, and the one place the record scoping is narrowed rather
@@ -286,9 +294,9 @@ export const GET = apiHandler(async () => {
// one row never 500s the whole profile fetch (the field just reads
// null and the user re-enters it).
let insuranceNumber: string | null = null;
if (user.insuranceNumberEncrypted) {
if (identity?.insuranceNumberEncrypted) {
try {
insuranceNumber = decrypt(user.insuranceNumberEncrypted);
insuranceNumber = decrypt(identity.insuranceNumberEncrypted);
} catch {
insuranceNumber = null;
}
@@ -21,6 +21,7 @@ vi.mock("@/lib/api-handler", () => ({
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
passkey: {
findUnique: vi.fn(),
update: vi.fn(),
@@ -40,7 +41,9 @@ import { DELETE, PATCH } from "../route";
import { requireAuth, requireMfaManagementAuth } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
const USER = { id: "user-1", username: "u", passwordHash: "argon2id$…" };
const USER = { id: "user-1", username: "u" };
/** The stored credential row; the session user carries no hash. */
const STORED = { passwordHash: "argon2id$…" };
const ROW = {
id: "pk-1",
@@ -75,6 +78,7 @@ const commitElevation = vi.fn().mockResolvedValue(undefined);
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.user.findUnique).mockResolvedValue(STORED as never);
vi.mocked(requireAuth).mockResolvedValue({ user: USER } as never);
commitElevation.mockClear().mockResolvedValue(undefined);
vi.mocked(requireMfaManagementAuth).mockResolvedValue({
@@ -256,9 +260,12 @@ describe("DELETE /api/auth/passkeys/{id} — the removal's gate", () => {
name: "Only one",
} as never);
vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
passwordHash: null,
} as never);
vi.mocked(requireMfaManagementAuth).mockResolvedValue({
transport: "cookie",
user: { ...USER, passwordHash: null },
user: USER,
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
commitElevation,
} as never);
+8 -4
View File
@@ -128,10 +128,14 @@ export const DELETE = apiHandler(
}
// Check: at least 1 auth method must remain
const passkeyCount = await prisma.passkey.count({
where: { userId: user.id },
});
const hasPassword = !!user.passwordHash;
const [passkeyCount, credential] = await Promise.all([
prisma.passkey.count({ where: { userId: user.id } }),
prisma.user.findUnique({
where: { id: user.id },
select: { passwordHash: true },
}),
]);
const hasPassword = !!credential?.passwordHash;
if (passkeyCount <= 1 && !hasPassword) {
return apiError(
@@ -20,7 +20,7 @@ vi.mock("@/lib/api-handler", async () => {
vi.mock("@/lib/db", () => ({
prisma: {
user: { update: vi.fn() },
user: { update: vi.fn(), findUnique: vi.fn() },
},
}));
@@ -92,7 +92,6 @@ const SESSION_OK = {
username: "tester",
email: "t@example.com",
role: "USER" as const,
passwordHash: "h",
onboardingCompletedAt: new Date(),
locale: "en",
},
@@ -109,6 +108,10 @@ function postReq(body: unknown): NextRequest {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// The hash is read off the stored row, not the session user.
vi.mocked(prisma.user.findUnique).mockResolvedValue({
passwordHash: "h",
} as never);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
// Default: account clears the step-up (no MFA enrolled, or a fresh factor).
vi.mocked(requireFreshMfaIfEnrolled).mockResolvedValue(SESSION_OK as never);
+9 -2
View File
@@ -58,11 +58,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
const { currentPassword, newPassword } = parsed.data;
if (!user.passwordHash) {
const credential = await prisma.user.findUnique({
where: { id: user.id },
select: { passwordHash: true },
});
if (!credential?.passwordHash) {
return apiError("No password set for this account", 400);
}
const currentValid = await verifyPassword(user.passwordHash, currentPassword);
const currentValid = await verifyPassword(
credential.passwordHash,
currentPassword,
);
if (!currentValid) {
await recordReproofFailure(
user.id,
@@ -17,7 +17,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {},
// The cached briefing text is read off its own row, not the session user.
prisma: { user: { findUnique: async () => null } },
}));
vi.mock("@/lib/auth/session", () => ({
getSession: vi.fn(),
@@ -71,7 +72,6 @@ const SESSION_OK = {
glucoseUnit: "mg/dL",
onboardingTourCompleted: true,
disableCoach: false,
insightsCachedText: null,
insightsCachedAt: null,
dashboardWidgetsJson: null,
},
@@ -78,6 +78,11 @@ vi.mock("@/lib/rollups/measurement-read", () => ({
// covered by `src/lib/analytics/__tests__/mood-series.test.ts`. Pinned to an
// empty series by default so the legacy assertions keep their "no mood"
// expectation, and overridden per test below.
vi.mock("@/lib/dashboard/summary-reads", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/dashboard/summary-reads")>()),
readAllTimeCounts: vi.fn(),
}));
vi.mock("@/lib/analytics/mood-series", () => ({
buildMoodDailySeries: vi.fn(async () => ({
entries: [],
@@ -106,6 +111,7 @@ import { getSession } from "@/lib/auth/session";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
import { buildMoodDailySeries } from "@/lib/analytics/mood-series";
import { userDayKey } from "@/lib/tz/format";
import { readAllTimeCounts } from "@/lib/dashboard/summary-reads";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -133,10 +139,10 @@ beforeEach(() => {
// between tests to keep cross-test isolation.
__resetAllCachesForTests();
vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never);
// v1.4.33 — the route now issues a `groupBy` for
// per-type all-time count + most-recent timestamp. Default to an
// empty aggregate so legacy tests keep their "no data" expectations.
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never);
// The per-type all-time counts (`readAllTimeCounts`). Default to none so
// legacy tests keep their "no data" expectations; the newest reading per
// type, which `lastSeenAt` carries, comes from the latest-reading read.
vi.mocked(readAllTimeCounts).mockResolvedValue([] as never);
// v1.11.4 — the sleep tile reads the raw per-stage SLEEP_DURATION rows
// via `measurement.findMany` to reconstruct the night total. Default to
// no rows so legacy tests keep their "no sleep" expectation.
@@ -249,22 +255,26 @@ describe("GET /api/dashboard/summary", () => {
}
});
it("surfaces lastSeenAt from the historical aggregate even when the 7-day window is empty", async () => {
it("surfaces lastSeenAt from the newest reading even when the 7-day window is empty", async () => {
// Power-user path — the user has logged weight for years but
// hasn't touched the app in two weeks. `groupBy` returns the
// all-time count + most-recent timestamp; the 7-day `findMany`
// returns empty. The route should still emit the weight card
// hasn't touched the app in two weeks. The all-time count and the
// newest reading come back; the sparkline is empty. The route should
// still emit the weight card
// with the historical `lastSeenAt` so the iOS client renders the
// staleness caption.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const twoWeeksAgo = new Date(Date.now() - 14 * 86_400_000);
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
vi.mocked(readAllTimeCounts).mockResolvedValue([
{
type: "WEIGHT",
_count: { _all: 312 },
_max: { measuredAt: twoWeeksAgo },
count: 312,
},
] as never);
// The newest reading, which `lastSeenAt` carries (first `$queryRaw` of
// the round: the latest-reading read).
vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([
{ type: "WEIGHT", value: 81.2, measured_at: twoWeeksAgo },
] as never);
const res = await callGet(makeReq());
const body = (await res.json()) as {
data: {
@@ -281,10 +291,9 @@ describe("GET /api/dashboard/summary", () => {
expect(weight, "weight card must be present").toBeDefined();
expect(weight?.allTimeCount).toBe(312);
expect(weight?.lastSeenAt).toBe(twoWeeksAgo.toISOString());
// No 7-day reading → latestValue stays null but updatedAt falls
// through to the historical timestamp so the iOS client can
// build the relative-age caption from a single field.
expect(weight?.latestValue).toBeNull();
// The tile shows the historical reading (REG-11) and dates it, so the
// iOS client builds the relative-age caption from one field.
expect(weight?.latestValue).toBe(81.2);
expect(weight?.updatedAt).toBe(twoWeeksAgo.toISOString());
});
@@ -299,16 +308,14 @@ describe("GET /api/dashboard/summary", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const sixtyDaysAgo = new Date(Date.now() - 60 * 86_400_000);
const sixtyOneDaysAgo = new Date(Date.now() - 61 * 86_400_000);
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
vi.mocked(readAllTimeCounts).mockResolvedValue([
{
type: "BLOOD_PRESSURE_SYS",
_count: { _all: 14 },
_max: { measuredAt: sixtyDaysAgo },
count: 14,
},
{
type: "BLOOD_PRESSURE_DIA",
_count: { _all: 14 },
_max: { measuredAt: sixtyDaysAgo },
count: 14,
},
] as never);
// Promise.all order: [latestEver, sparkline, streakDays].
@@ -406,11 +413,10 @@ describe("GET /api/dashboard/summary", () => {
// don't accidentally regress the fresh-data case.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const twoDaysAgo = new Date(Date.now() - 2 * 86_400_000);
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
vi.mocked(readAllTimeCounts).mockResolvedValue([
{
type: "WEIGHT",
_count: { _all: 200 },
_max: { measuredAt: twoDaysAgo },
count: 200,
},
] as never);
vi.mocked(prisma.$queryRaw)
@@ -457,11 +463,10 @@ describe("GET /api/dashboard/summary", () => {
// oldest bucket first.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const days = (n: number) => new Date(Date.now() - n * 86_400_000);
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
vi.mocked(readAllTimeCounts).mockResolvedValue([
{
type: "PULSE",
_count: { _all: 42 },
_max: { measuredAt: days(30) },
count: 42,
},
] as never);
vi.mocked(prisma.$queryRaw)
@@ -547,13 +552,15 @@ describe("GET /api/dashboard/summary", () => {
// hint).
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const tenDaysAgo = new Date(Date.now() - 10 * 86_400_000);
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
vi.mocked(readAllTimeCounts).mockResolvedValue([
{
type: "BLOOD_GLUCOSE",
_count: { _all: 4 },
_max: { measuredAt: tenDaysAgo },
count: 4,
},
] as never);
vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([
{ type: "BLOOD_GLUCOSE", value: 104, measured_at: tenDaysAgo },
] as never);
const res = await callGet(makeReq());
const body = (await res.json()) as {
data: {
@@ -579,11 +586,10 @@ describe("GET /api/dashboard/summary", () => {
// hours, NOT surface a single stage. Excludes IN_BED + AWAKE.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const wake = new Date("2026-06-04T06:00:00.000Z");
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
vi.mocked(readAllTimeCounts).mockResolvedValue([
{
type: "SLEEP_DURATION",
_count: { _all: 5 },
_max: { measuredAt: wake },
count: 5,
},
] as never);
// Raw per-stage rows for the night (the sleep findMany read).
@@ -654,11 +660,10 @@ describe("GET /api/dashboard/summary", () => {
// total; the additive annotation surfaces the disagreement.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const wake = new Date("2026-06-04T06:00:00.000Z");
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
vi.mocked(readAllTimeCounts).mockResolvedValue([
{
type: "SLEEP_DURATION",
_count: { _all: 4 },
_max: { measuredAt: wake },
count: 4,
},
] as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
@@ -780,11 +785,10 @@ describe("GET /api/dashboard/summary", () => {
// returned shape proves the route hands `sum_value` through
// instead of reconstructing from `mean * count`.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
vi.mocked(readAllTimeCounts).mockResolvedValue([
{
type: "ACTIVITY_STEPS",
_count: { _all: 7 },
_max: { measuredAt: new Date() },
count: 7,
},
] as never);
// First call: latest7d. Second call: sparkline. Third call:
@@ -824,11 +828,10 @@ describe("GET /api/dashboard/summary", () => {
it("falls back to mean * count when the legacy sum_value is null (v1.4.39 W-SUM)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
vi.mocked(readAllTimeCounts).mockResolvedValue([
{
type: "ACTIVITY_STEPS",
_count: { _all: 7 },
_max: { measuredAt: new Date() },
count: 7,
},
] as never);
vi.mocked(prisma.$queryRaw)
+13 -12
View File
@@ -79,6 +79,7 @@ import { buildMedsTodayBlock } from "@/lib/dashboard/meds-today";
import { computeStreak } from "@/lib/dashboard/streak";
import {
readActivityDayKeys,
readAllTimeCounts,
readLatestEver,
readSparkBuckets,
} from "@/lib/dashboard/summary-reads";
@@ -468,7 +469,7 @@ async function buildDashboardSummary(
// - sparkBuckets: ≤ SPARK_DAYS × N metric types (typically <80,
// REG-11: taken via ROW_NUMBER window, no calendar
// filter, so a 60-day-old metric still paints a chart)
// - allTimeAggregate: ≤ N metric types (unchanged)
// - allTimeAggregate: ≤ N metric types, summed from the DAY rollups
// - medsToday: today-window tally via the shared projector
// (≤ daily intake schedule count, unchanged semantics)
// - streakActivity: ≤ daily intake count × 365 (unchanged)
@@ -492,13 +493,10 @@ async function buildDashboardSummary(
time("sparkline", () =>
readSparkBuckets(prisma, userId, measurementTypes, SPARK_DAYS),
),
// Counted from the DAY rollups where they cover the days, not by one
// index entry per reading (see `readAllTimeCounts`).
time("allTime", () =>
prisma.measurement.groupBy({
by: ["type"],
where: { userId, type: { in: measurementTypes }, deletedAt: null },
_count: { _all: true },
_max: { measuredAt: true },
}),
readAllTimeCounts(prisma, userId, measurementTypes, now),
),
// v1.4.39 — the dashboard compliance tile pulled
// `MedicationIntakeEvent` rows for the today-window only. Daily
@@ -583,17 +581,20 @@ async function buildDashboardSummary(
// Per-type metadata lookup — typed Map so a metric with no readings
// at all falls through `metaForType` to the `{ allTimeCount: 0,
// lastSeenAt: null }` default. The aggregate row's `_count._all` is
// always `number` per Prisma's runtime contract; defending against
// undefined keeps the helper's narrow signature stable.
// lastSeenAt: null }` default.
const allTimeByType = new Map<
MeasurementType,
{ allTimeCount: number; lastSeenAt: Date | null }
>();
// The newest reading is the one `latestEver` already read per type; its
// instant is the metric's last-seen time.
const lastSeenByType = new Map(
latestEver.map((row) => [row.type, row.measured_at]),
);
for (const row of allTimeAggregate) {
allTimeByType.set(row.type, {
allTimeCount: row._count?._all ?? 0,
lastSeenAt: row._max?.measuredAt ?? null,
allTimeCount: row.count,
lastSeenAt: lastSeenByType.get(row.type) ?? null,
});
}
function metaForType(type: MeasurementType): {
@@ -28,6 +28,8 @@ vi.mock("@/lib/db", () => ({
},
webauthnMfaCredential: { count: vi.fn(async () => 0) },
passkey: { count: vi.fn(async () => 0) },
// The password hash is read off the stored row; none here.
user: { findUnique: vi.fn(async () => null) },
},
}));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
+119 -95
View File
@@ -15,6 +15,11 @@ import { FITBIT_BACKFILL_QUEUE } from "@/lib/jobs/fitbit-backfill";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { markReconnected } from "@/lib/integrations/status";
import { Prisma } from "@/generated/prisma/client";
import {
consumeLedgerState,
duplicateOAuthCallback,
settleOAuthCallback,
} from "@/lib/oauth/callback-claim";
import { NextRequest, NextResponse } from "next/server";
import { timingSafeEqual } from "node:crypto";
@@ -37,6 +42,12 @@ import { timingSafeEqual } from "node:crypto";
* On success: exchange the code, fetch the Fitbit profile for the external
* `fitbitUserId`, persist the encrypted `FitbitConnection`, clear any prior
* reauth state, and enqueue the self-converging history backfill.
*
* A repeat of a consumed nonce is told apart from a replay by the callback
* claim taken in the same transaction (`src/lib/oauth/callback-claim.ts`): a
* browser that delivers the callback twice gets one exchange, and the repeat
* follows the first request to the same page. Only a nonce with no claim left
* lands on `replay`.
*/
const ERR = (reason: string) =>
NextResponse.redirect(
@@ -79,14 +90,24 @@ export const GET = apiHandler(async (request: NextRequest) => {
codeVerifier: string | null;
} | null = null;
try {
stateRow = await prisma.fitbitOAuthState.delete({
where: { nonce: state },
});
stateRow = await consumeLedgerState("fitbit", state, (tx) =>
tx.fitbitOAuthState.delete({ where: { nonce: state } }),
);
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === "P2025"
) {
// A duplicate of a callback that is consuming or has consumed this
// row: follow that request's result instead of showing an error.
const duplicate = await duplicateOAuthCallback("fitbit", state, {
fallbackUrl: new URL(
"/settings/integrations",
process.env.NEXT_PUBLIC_APP_URL!,
).toString(),
stateCookie: FITBIT_OAUTH_STATE_COOKIE,
});
if (duplicate) return duplicate;
annotate({ meta: { reason: "replay" } });
return ERR("replay");
}
@@ -96,105 +117,108 @@ export const GET = apiHandler(async (request: NextRequest) => {
return ERR("state");
}
if (stateRow.expiresAt <= new Date()) {
annotate({ meta: { reason: "expired" } });
return ERR("expired");
}
if (stateRow.userId !== user.id) {
annotate({ meta: { reason: "cross_user" } });
return ERR("cross_user");
}
if (!code) {
return ERR("nocode");
}
// The PKCE verifier minted at connect rides on the ledger row. Its absence
// means a malformed / legacy handshake — refuse rather than send a code
// exchange Fitbit will reject for a missing verifier.
if (!stateRow.codeVerifier) {
annotate({ meta: { reason: "no_verifier" } });
return ERR("no_verifier");
}
try {
const creds = await getUserFitbitCredentials(user.id);
if (!creds) {
return ERR("nocreds");
const consumed = stateRow;
return settleOAuthCallback("fitbit", state, async () => {
if (consumed.expiresAt <= new Date()) {
annotate({ meta: { reason: "expired" } });
return ERR("expired");
}
if (consumed.userId !== user.id) {
annotate({ meta: { reason: "cross_user" } });
return ERR("cross_user");
}
const tokens = await exchangeCode(code, stateRow.codeVerifier, creds);
// The initial code exchange MUST return a refresh token. If Fitbit ever
// omits it, refuse to persist — a stored empty refresh token silently bricks
// every future token refresh (the refresh POST then 400s and the connection
// dies the moment the access token expires). Error out cleanly so the user
// re-consents rather than landing a dead connection.
if (!tokens.refresh_token) {
getEvent()?.addWarning(
`fitbit callback: token response carried no refresh_token for user ${user.id}`,
);
annotate({ meta: { reason: "no_refresh_token" } });
return ERR("no_refresh_token");
if (!code) {
return ERR("nocode");
}
const profile = await fetchProfile(tokens.access_token);
const fitbitUserId = resolveFitbitUserId(profile);
const expiresAt = new Date(Date.now() + tokens.expires_in * 1000);
// The PKCE verifier minted at connect rides on the ledger row. Its absence
// means a malformed / legacy handshake — refuse rather than send a code
// exchange Fitbit will reject for a missing verifier.
if (!consumed.codeVerifier) {
annotate({ meta: { reason: "no_verifier" } });
return ERR("no_verifier");
}
await prisma.fitbitConnection.upsert({
where: { userId: user.id },
update: {
fitbitUserId,
accessToken: encrypt(tokens.access_token),
refreshToken: encrypt(tokens.refresh_token),
tokenExpiresAt: expiresAt,
scope: tokens.scope ?? FITBIT_OAUTH_SCOPE,
backfillCompletedAt: null,
},
create: {
userId: user.id,
fitbitUserId,
accessToken: encrypt(tokens.access_token),
refreshToken: encrypt(tokens.refresh_token),
tokenExpiresAt: expiresAt,
scope: tokens.scope ?? FITBIT_OAUTH_SCOPE,
},
});
try {
const creds = await getUserFitbitCredentials(user.id);
if (!creds) {
return ERR("nocreds");
}
await auditLog("fitbit.connect", {
userId: user.id,
details: { fitbitUserId },
});
const tokens = await exchangeCode(code, consumed.codeVerifier, creds);
// Re-completing OAuth clears any prior reauth-required state.
await markReconnected(user.id, "fitbit");
// Enqueue the self-converging history backfill. Best-effort: the boot-time
// discovery query (`backfillCompletedAt IS NULL`) is the safety net, so a
// missing boss instance here doesn't strand the connection.
const boss = getGlobalBoss();
if (boss) {
await boss
.send(FITBIT_BACKFILL_QUEUE, {
userId: user.id,
enqueuedAt: new Date().toISOString(),
})
.catch((err) =>
getEvent()?.addWarning(`fitbit-backfill enqueue failed: ${err}`),
// The initial code exchange MUST return a refresh token. If Fitbit ever
// omits it, refuse to persist — a stored empty refresh token silently bricks
// every future token refresh (the refresh POST then 400s and the connection
// dies the moment the access token expires). Error out cleanly so the user
// re-consents rather than landing a dead connection.
if (!tokens.refresh_token) {
getEvent()?.addWarning(
`fitbit callback: token response carried no refresh_token for user ${user.id}`,
);
}
annotate({ meta: { reason: "no_refresh_token" } });
return ERR("no_refresh_token");
}
const response = NextResponse.redirect(
new URL(
"/settings/integrations?fitbit=connected",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
response.cookies.delete(FITBIT_OAUTH_STATE_COOKIE);
return response;
} catch (err) {
getEvent()?.setError(err);
return ERR("token");
}
const profile = await fetchProfile(tokens.access_token);
const fitbitUserId = resolveFitbitUserId(profile);
const expiresAt = new Date(Date.now() + tokens.expires_in * 1000);
await prisma.fitbitConnection.upsert({
where: { userId: user.id },
update: {
fitbitUserId,
accessToken: encrypt(tokens.access_token),
refreshToken: encrypt(tokens.refresh_token),
tokenExpiresAt: expiresAt,
scope: tokens.scope ?? FITBIT_OAUTH_SCOPE,
backfillCompletedAt: null,
},
create: {
userId: user.id,
fitbitUserId,
accessToken: encrypt(tokens.access_token),
refreshToken: encrypt(tokens.refresh_token),
tokenExpiresAt: expiresAt,
scope: tokens.scope ?? FITBIT_OAUTH_SCOPE,
},
});
await auditLog("fitbit.connect", {
userId: user.id,
details: { fitbitUserId },
});
// Re-completing OAuth clears any prior reauth-required state.
await markReconnected(user.id, "fitbit");
// Enqueue the self-converging history backfill. Best-effort: the boot-time
// discovery query (`backfillCompletedAt IS NULL`) is the safety net, so a
// missing boss instance here doesn't strand the connection.
const boss = getGlobalBoss();
if (boss) {
await boss
.send(FITBIT_BACKFILL_QUEUE, {
userId: user.id,
enqueuedAt: new Date().toISOString(),
})
.catch((err) =>
getEvent()?.addWarning(`fitbit-backfill enqueue failed: ${err}`),
);
}
const response = NextResponse.redirect(
new URL(
"/settings/integrations?fitbit=connected",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
response.cookies.delete(FITBIT_OAUTH_STATE_COOKIE);
return response;
} catch (err) {
getEvent()?.setError(err);
return ERR("token");
}
});
});
+129 -105
View File
@@ -15,6 +15,11 @@ import { GOOGLE_HEALTH_BACKFILL_QUEUE } from "@/lib/jobs/google-health-backfill"
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { markReconnected } from "@/lib/integrations/status";
import { Prisma } from "@/generated/prisma/client";
import {
consumeLedgerState,
duplicateOAuthCallback,
settleOAuthCallback,
} from "@/lib/oauth/callback-claim";
import { NextRequest, NextResponse } from "next/server";
import { timingSafeEqual } from "node:crypto";
@@ -36,6 +41,12 @@ import { timingSafeEqual } from "node:crypto";
* Health profile for the external `googleUserId`, persist the encrypted
* `GoogleHealthConnection`, clear any prior reauth state (`needsReauth:false` +
* the status ledger), and enqueue the self-converging history backfill.
*
* A repeat of a consumed nonce is told apart from a replay by the callback
* claim taken in the same transaction (`src/lib/oauth/callback-claim.ts`): a
* browser that delivers the callback twice gets one exchange, and the repeat
* follows the first request to the same page. Only a nonce with no claim left
* lands on `replay`.
*/
const ERR = (reason: string) =>
NextResponse.redirect(
@@ -81,14 +92,24 @@ export const GET = apiHandler(async (request: NextRequest) => {
codeVerifier: string | null;
} | null = null;
try {
stateRow = await prisma.googleHealthOAuthState.delete({
where: { nonce: state },
});
stateRow = await consumeLedgerState("google-health", state, (tx) =>
tx.googleHealthOAuthState.delete({ where: { nonce: state } }),
);
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === "P2025"
) {
// A duplicate of a callback that is consuming or has consumed this
// row: follow that request's result instead of showing an error.
const duplicate = await duplicateOAuthCallback("google-health", state, {
fallbackUrl: new URL(
"/settings/integrations",
process.env.NEXT_PUBLIC_APP_URL!,
).toString(),
stateCookie: GOOGLE_HEALTH_OAUTH_STATE_COOKIE,
});
if (duplicate) return duplicate;
annotate({ meta: { reason: "replay" } });
return ERR("replay");
}
@@ -98,114 +119,117 @@ export const GET = apiHandler(async (request: NextRequest) => {
return ERR("state");
}
if (stateRow.expiresAt <= new Date()) {
annotate({ meta: { reason: "expired" } });
return ERR("expired");
}
if (stateRow.userId !== user.id) {
annotate({ meta: { reason: "cross_user" } });
return ERR("cross_user");
}
if (!stateRow.codeVerifier) {
annotate({ meta: { reason: "no_verifier" } });
return ERR("no_verifier");
}
if (!code) {
return ERR("nocode");
}
try {
const creds = await getUserGoogleHealthCredentials(user.id);
if (!creds) {
return ERR("nocreds");
const consumed = stateRow;
return settleOAuthCallback("google-health", state, async () => {
if (consumed.expiresAt <= new Date()) {
annotate({ meta: { reason: "expired" } });
return ERR("expired");
}
if (consumed.userId !== user.id) {
annotate({ meta: { reason: "cross_user" } });
return ERR("cross_user");
}
if (!consumed.codeVerifier) {
annotate({ meta: { reason: "no_verifier" } });
return ERR("no_verifier");
}
const tokens = await exchangeCode(code, stateRow.codeVerifier, creds);
// The initial code exchange MUST return a refresh token (the connect route
// forces `prompt=consent` + `access_type=offline`). If Google ever omits
// it, refuse to persist — a stored empty refresh token silently bricks
// every future token refresh (the refresh POST then 400s and the
// connection dies the moment the access token expires). Error out cleanly
// so the user re-consents rather than landing a dead connection.
if (!tokens.refresh_token) {
getEvent()?.addWarning(
`google-health callback: token response carried no refresh_token for user ${user.id}`,
);
annotate({ meta: { reason: "no_refresh_token" } });
return ERR("no_refresh_token");
if (!code) {
return ERR("nocode");
}
const profile = await fetchProfile(tokens.access_token);
const googleUserId = resolveGoogleHealthUserId(profile);
const expiresAt = new Date(Date.now() + tokens.expires_in * 1000);
const scope = tokens.scope ?? getGoogleHealthScopeString();
try {
const creds = await getUserGoogleHealthCredentials(user.id);
if (!creds) {
return ERR("nocreds");
}
await prisma.googleHealthConnection.upsert({
where: { userId: user.id },
update: {
googleUserId,
accessToken: encrypt(tokens.access_token),
refreshToken: encrypt(tokens.refresh_token),
tokenExpiresAt: expiresAt,
scope,
// Fresh consent clears the soft-disconnect flag so the card drops the
// reconnect CTA and the sync path stops short-circuiting.
needsReauth: false,
backfillCompletedAt: null,
// A new grant walks history from the start: what an earlier
// backfill finished was read under the old one.
backfillProgress: Prisma.DbNull,
},
create: {
userId: user.id,
googleUserId,
accessToken: encrypt(tokens.access_token),
refreshToken: encrypt(tokens.refresh_token),
tokenExpiresAt: expiresAt,
scope,
needsReauth: false,
},
});
const tokens = await exchangeCode(code, consumed.codeVerifier, creds);
await auditLog("google_health.connect", {
userId: user.id,
details: { googleUserId },
});
// Re-completing OAuth clears any prior reauth-required state on the status
// ledger too (the `needsReauth` column above is the connection-local flag;
// this resets the integration-status streak).
await markReconnected(user.id, "google-health");
// Enqueue the self-converging history backfill. Best-effort: the boot-time
// discovery query (`backfillCompletedAt IS NULL`) is the safety net, so a
// missing boss instance here doesn't strand the connection.
const boss = getGlobalBoss();
if (boss) {
await boss
.send(GOOGLE_HEALTH_BACKFILL_QUEUE, {
userId: user.id,
enqueuedAt: new Date().toISOString(),
})
.catch((err) =>
getEvent()?.addWarning(
`google-health-backfill enqueue failed: ${err}`,
),
// The initial code exchange MUST return a refresh token (the connect route
// forces `prompt=consent` + `access_type=offline`). If Google ever omits
// it, refuse to persist — a stored empty refresh token silently bricks
// every future token refresh (the refresh POST then 400s and the
// connection dies the moment the access token expires). Error out cleanly
// so the user re-consents rather than landing a dead connection.
if (!tokens.refresh_token) {
getEvent()?.addWarning(
`google-health callback: token response carried no refresh_token for user ${user.id}`,
);
}
annotate({ meta: { reason: "no_refresh_token" } });
return ERR("no_refresh_token");
}
const response = NextResponse.redirect(
new URL(
"/settings/integrations?googleHealth=connected",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
response.cookies.delete(GOOGLE_HEALTH_OAUTH_STATE_COOKIE);
return response;
} catch (err) {
getEvent()?.setError(err);
return ERR("token");
}
const profile = await fetchProfile(tokens.access_token);
const googleUserId = resolveGoogleHealthUserId(profile);
const expiresAt = new Date(Date.now() + tokens.expires_in * 1000);
const scope = tokens.scope ?? getGoogleHealthScopeString();
await prisma.googleHealthConnection.upsert({
where: { userId: user.id },
update: {
googleUserId,
accessToken: encrypt(tokens.access_token),
refreshToken: encrypt(tokens.refresh_token),
tokenExpiresAt: expiresAt,
scope,
// Fresh consent clears the soft-disconnect flag so the card drops the
// reconnect CTA and the sync path stops short-circuiting.
needsReauth: false,
backfillCompletedAt: null,
// A new grant walks history from the start: what an earlier
// backfill finished was read under the old one.
backfillProgress: Prisma.DbNull,
},
create: {
userId: user.id,
googleUserId,
accessToken: encrypt(tokens.access_token),
refreshToken: encrypt(tokens.refresh_token),
tokenExpiresAt: expiresAt,
scope,
needsReauth: false,
},
});
await auditLog("google_health.connect", {
userId: user.id,
details: { googleUserId },
});
// Re-completing OAuth clears any prior reauth-required state on the status
// ledger too (the `needsReauth` column above is the connection-local flag;
// this resets the integration-status streak).
await markReconnected(user.id, "google-health");
// Enqueue the self-converging history backfill. Best-effort: the boot-time
// discovery query (`backfillCompletedAt IS NULL`) is the safety net, so a
// missing boss instance here doesn't strand the connection.
const boss = getGlobalBoss();
if (boss) {
await boss
.send(GOOGLE_HEALTH_BACKFILL_QUEUE, {
userId: user.id,
enqueuedAt: new Date().toISOString(),
})
.catch((err) =>
getEvent()?.addWarning(
`google-health-backfill enqueue failed: ${err}`,
),
);
}
const response = NextResponse.redirect(
new URL(
"/settings/integrations?googleHealth=connected",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
response.cookies.delete(GOOGLE_HEALTH_OAUTH_STATE_COOKIE);
return response;
} catch (err) {
getEvent()?.setError(err);
return ERR("token");
}
});
});
+11 -1
View File
@@ -54,9 +54,19 @@ vi.mock("@/lib/rollups/measurement-rollups", async () => {
const actual = await vi.importActual<
typeof import("@/lib/rollups/measurement-rollups")
>("@/lib/rollups/measurement-rollups");
const perKey = vi.fn().mockResolvedValue(undefined);
return {
...actual,
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
recomputeBucketsForMeasurement: perKey,
// The keyed refold, observed as the per-key recomputes it stands for.
recomputeBucketsForKeys: vi.fn(
async (
userId: string,
keys: Array<{ type: string; measuredAt: Date }>,
) => {
for (const k of keys) await perKey(userId, k.type, k.measuredAt);
},
),
};
});
+11 -1
View File
@@ -58,9 +58,19 @@ vi.mock("@/lib/rollups/measurement-rollups", async () => {
const actual = await vi.importActual<
typeof import("@/lib/rollups/measurement-rollups")
>("@/lib/rollups/measurement-rollups");
const perKey = vi.fn().mockResolvedValue(undefined);
return {
...actual,
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
recomputeBucketsForMeasurement: perKey,
// The keyed refold, observed as the per-key recomputes it stands for.
recomputeBucketsForKeys: vi.fn(
async (
userId: string,
keys: Array<{ type: string; measuredAt: Date }>,
) => {
for (const k of keys) await perKey(userId, k.type, k.measuredAt);
},
),
};
});
@@ -406,6 +406,9 @@ describe("GET /api/integrations/healthkit — sync health", () => {
expect(entries.WEIGHT.lastEnteredHereAt).toBe(
enteredHereAt.toISOString(),
);
// The reading entered here is in Apple Health too, so it is the type's
// newest reading: the row and the verdict read the same instant.
expect(entries.WEIGHT.lastSeenAt).toBe(enteredHereAt.toISOString());
// A read-only type keeps its own verdict and carries no such field.
expect(entries.PULSE.stale).toBe(true);
expect(entries.PULSE).not.toHaveProperty("lastEnteredHereAt");
@@ -485,3 +485,26 @@ describe("/api/integrations/status — the unified connection health", () => {
}
});
});
describe("/api/integrations/status — one round of reads", () => {
it("asks for the connection health and the freshness without waiting on the provider rows", async () => {
userFind.mockResolvedValue({});
// Hold the user row back: under the old shape nothing else was asked
// until it (and the rest of the first round) had answered.
let release: (row: unknown) => void = () => {};
userFind.mockImplementationOnce(
() => new Promise((resolve) => (release = resolve)),
);
const { loadConnectionHealth } =
await import("@/lib/integrations/connection-health-load");
const pending = fetchEnvelope();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(loadConnectionHealth).toHaveBeenCalledTimes(1);
expect(listLiveMeasurementTypes).toHaveBeenCalled();
release({});
const envelope = await pending;
expect(envelope.integrations.length).toBeGreaterThan(0);
});
});
+130 -121
View File
@@ -64,124 +64,138 @@ export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "integrations.status" } });
// One round: the per-provider rows, the unified connection health and the
// per-metric freshness do not depend on each other. Awaited one after
// another, a cold first visit paid the three latencies end to end.
let metricFreshnessDegraded = false;
const [
withingsStatus,
whoopStatus,
fitbitStatus,
googleHealthStatus,
polarStatus,
ouraStatus,
stravaStatus,
nightscoutStatus,
dawarichStatus,
dbUser,
withingsConn,
whoopConn,
fitbitConn,
googleHealthConn,
// v1.17.1 — `available` reports whether usable OAuth credentials resolve
// (per-user BYO first, then the shared env app), mirroring the per-card
// /api/<provider>/status the consolidated envelope now replaces.
polarAvailable,
ouraAvailable,
stravaAvailable,
[
withingsStatus,
whoopStatus,
fitbitStatus,
googleHealthStatus,
polarStatus,
ouraStatus,
stravaStatus,
nightscoutStatus,
dawarichStatus,
dbUser,
withingsConn,
whoopConn,
fitbitConn,
googleHealthConn,
// v1.17.1 — `available` reports whether usable OAuth credentials resolve
// (per-user BYO first, then the shared env app), mirroring the per-card
// /api/<provider>/status the consolidated envelope now replaces.
polarAvailable,
ouraAvailable,
stravaAvailable,
],
health,
metricFreshness,
] = await Promise.all([
getIntegrationStatus(user.id, "withings"),
getIntegrationStatus(user.id, "whoop"),
getIntegrationStatus(user.id, "fitbit"),
getIntegrationStatus(user.id, "google-health"),
getIntegrationStatus(user.id, "polar"),
getIntegrationStatus(user.id, "oura"),
getIntegrationStatus(user.id, "strava"),
getIntegrationStatus(user.id, "nightscout"),
getIntegrationStatus(user.id, "dawarich"),
prisma.user.findUnique({
where: { id: user.id },
select: {
withingsClientIdEncrypted: true,
withingsClientSecretEncrypted: true,
whoopClientIdEncrypted: true,
whoopClientSecretEncrypted: true,
fitbitClientIdEncrypted: true,
fitbitClientSecretEncrypted: true,
googleHealthClientIdEncrypted: true,
googleHealthClientSecretEncrypted: true,
polarAccessTokenEncrypted: true,
polarClientIdEncrypted: true,
polarClientSecretEncrypted: true,
ouraAccessTokenEncrypted: true,
ouraClientIdEncrypted: true,
ouraClientSecretEncrypted: true,
ouraScopeDenied: true,
stravaAccessTokenEncrypted: true,
stravaClientIdEncrypted: true,
stravaClientSecretEncrypted: true,
// Nightscout folds onto the envelope; the card reads these three off
// it instead of its own /api/nightscout/status round-trip.
nightscoutUrlEncrypted: true,
nightscoutTokenEncrypted: true,
nightscoutAllowPrivateHost: true,
dawarichUrlEncrypted: true,
},
Promise.all([
getIntegrationStatus(user.id, "withings"),
getIntegrationStatus(user.id, "whoop"),
getIntegrationStatus(user.id, "fitbit"),
getIntegrationStatus(user.id, "google-health"),
getIntegrationStatus(user.id, "polar"),
getIntegrationStatus(user.id, "oura"),
getIntegrationStatus(user.id, "strava"),
getIntegrationStatus(user.id, "nightscout"),
getIntegrationStatus(user.id, "dawarich"),
prisma.user.findUnique({
where: { id: user.id },
select: {
withingsClientIdEncrypted: true,
withingsClientSecretEncrypted: true,
whoopClientIdEncrypted: true,
whoopClientSecretEncrypted: true,
fitbitClientIdEncrypted: true,
fitbitClientSecretEncrypted: true,
googleHealthClientIdEncrypted: true,
googleHealthClientSecretEncrypted: true,
polarAccessTokenEncrypted: true,
polarClientIdEncrypted: true,
polarClientSecretEncrypted: true,
ouraAccessTokenEncrypted: true,
ouraClientIdEncrypted: true,
ouraClientSecretEncrypted: true,
ouraScopeDenied: true,
stravaAccessTokenEncrypted: true,
stravaClientIdEncrypted: true,
stravaClientSecretEncrypted: true,
// Nightscout folds onto the envelope; the card reads these three off
// it instead of its own /api/nightscout/status round-trip.
nightscoutUrlEncrypted: true,
nightscoutTokenEncrypted: true,
nightscoutAllowPrivateHost: true,
dawarichUrlEncrypted: true,
},
}),
prisma.withingsConnection.findUnique({
where: { userId: user.id },
select: {
tokenExpiresAt: true,
lastSyncedAt: true,
createdAt: true,
scope: true,
apiPlanState: true,
},
}),
prisma.whoopConnection.findUnique({
where: { userId: user.id },
select: {
tokenExpiresAt: true,
whoopUserId: true,
lastSyncedAt: true,
createdAt: true,
backfillCompletedAt: true,
},
}),
prisma.fitbitConnection.findUnique({
where: { userId: user.id },
select: {
tokenExpiresAt: true,
lastSyncedAt: true,
createdAt: true,
backfillCompletedAt: true,
},
}),
prisma.googleHealthConnection.findUnique({
where: { userId: user.id },
select: {
tokenExpiresAt: true,
lastSyncedAt: true,
createdAt: true,
backfillCompletedAt: true,
backfillProgress: true,
needsReauth: true,
},
}),
getPolarClientCredentials(user.id).then((c) => !!c),
getOuraClientCredentials(user.id).then((c) => !!c),
getStravaClientCredentials(user.id).then((c) => !!c),
]),
// The unified per-connection state. Resolved by the same loader the
// per-provider routes and the integration watchdog use, so the envelope
// and every other surface say the same thing about a connection.
loadConnectionHealth(user.id, [
"withings",
"whoop",
"fitbit",
"google-health",
"polar",
"oura",
"strava",
"nightscout",
"dawarich",
]),
// See `metricFreshnessDegraded` below.
getSourceMetricFreshness(user.id).catch(() => {
metricFreshnessDegraded = true;
return {} as Partial<Record<IntegrationKey, MetricFreshnessSample[]>>;
}),
prisma.withingsConnection.findUnique({
where: { userId: user.id },
select: {
tokenExpiresAt: true,
lastSyncedAt: true,
createdAt: true,
scope: true,
apiPlanState: true,
},
}),
prisma.whoopConnection.findUnique({
where: { userId: user.id },
select: {
tokenExpiresAt: true,
whoopUserId: true,
lastSyncedAt: true,
createdAt: true,
backfillCompletedAt: true,
},
}),
prisma.fitbitConnection.findUnique({
where: { userId: user.id },
select: {
tokenExpiresAt: true,
lastSyncedAt: true,
createdAt: true,
backfillCompletedAt: true,
},
}),
prisma.googleHealthConnection.findUnique({
where: { userId: user.id },
select: {
tokenExpiresAt: true,
lastSyncedAt: true,
createdAt: true,
backfillCompletedAt: true,
backfillProgress: true,
needsReauth: true,
},
}),
getPolarClientCredentials(user.id).then((c) => !!c),
getOuraClientCredentials(user.id).then((c) => !!c),
getStravaClientCredentials(user.id).then((c) => !!c),
]);
// The unified per-connection state. Resolved by the same loader the
// per-provider routes and the integration watchdog use, so the envelope and
// every other surface say the same thing about a connection.
const health = await loadConnectionHealth(user.id, [
"withings",
"whoop",
"fitbit",
"google-health",
"polar",
"oura",
"strava",
"nightscout",
"dawarich",
]);
// Per-metric-type last-value timestamps so the card can show that a
@@ -196,12 +210,7 @@ export const GET = apiHandler(async () => {
// the wire, and the honesty signal quietly became the thing it was built to
// prevent. `metricFreshnessDegraded` separates them. One flag rather than a
// nullable array per entry, because this is ONE query for all eight providers
// — it fails for all of them or for none.
let metricFreshnessDegraded = false;
const metricFreshness = await getSourceMetricFreshness(user.id).catch(() => {
metricFreshnessDegraded = true;
return {} as Partial<Record<IntegrationKey, MetricFreshnessSample[]>>;
});
// — it fails for all of them or for none. (Read in the round above.)
const now = Date.now();
const whoopConnected = !!whoopConn?.whoopUserId;
@@ -35,6 +35,8 @@ vi.mock("@/lib/db", () => ({
},
webauthnMfaCredential: { count: vi.fn(async () => 0) },
passkey: { count: vi.fn(async () => 0) },
// The password hash is read off the stored row; none here.
user: { findUnique: vi.fn(async () => null) },
},
}));
vi.mock("@/lib/app-settings", () => ({
@@ -19,6 +19,8 @@ vi.mock("@/lib/db", () => ({
},
webauthnMfaCredential: { count: vi.fn(async () => 0) },
passkey: { count: vi.fn(async () => 0) },
// The password hash is read off the stored row; none here.
user: { findUnique: vi.fn(async () => null) },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
@@ -47,10 +47,20 @@ vi.mock("@/lib/rollups/measurement-rollups", async () => {
const actual = await vi.importActual<
typeof import("@/lib/rollups/measurement-rollups")
>("@/lib/rollups/measurement-rollups");
const perKey = vi.fn().mockResolvedValue(undefined);
return {
// keep the real collapse so the test exercises the actual fan-out logic
collapseToTypeDayKeys: actual.collapseToTypeDayKeys,
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
recomputeBucketsForMeasurement: perKey,
// The keyed refold, observed as the per-key recomputes it stands for.
recomputeBucketsForKeys: vi.fn(
async (
userId: string,
keys: Array<{ type: string; measuredAt: Date }>,
) => {
for (const k of keys) await perKey(userId, k.type, k.measuredAt);
},
),
};
});
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
@@ -47,10 +47,20 @@ vi.mock("@/lib/rollups/measurement-rollups", async () => {
const actual = await vi.importActual<
typeof import("@/lib/rollups/measurement-rollups")
>("@/lib/rollups/measurement-rollups");
const perKey = vi.fn().mockResolvedValue(undefined);
return {
// keep the real collapse so the test exercises the actual fan-out logic
collapseToTypeDayKeys: actual.collapseToTypeDayKeys,
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
recomputeBucketsForMeasurement: perKey,
// The keyed refold, observed as the per-key recomputes it stands for.
recomputeBucketsForKeys: vi.fn(
async (
userId: string,
keys: Array<{ type: string; measuredAt: Date }>,
) => {
for (const k of keys) await perKey(userId, k.type, k.measuredAt);
},
),
};
});
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
@@ -1,5 +1,29 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Single-use claiming is covered against Postgres in
// tests/integration/oauth-callback-single-use.test.ts; here every callback
// is the first and only one.
const { claimSpy } = vi.hoisted(() => ({ claimSpy: vi.fn() }));
vi.mock("@/lib/oauth/callback-claim", () => ({
runOAuthCallbackOnce: (
_p: string,
_s: string,
_o: unknown,
run: () => Promise<unknown>,
) => {
claimSpy();
return run();
},
settleOAuthCallback: (_p: string, _s: string, run: () => Promise<unknown>) =>
run(),
consumeLedgerState: async (
_p: string,
_s: string,
consume: (tx: unknown) => Promise<unknown>,
) => consume((await import("@/lib/db")).prisma),
duplicateOAuthCallback: async () => null,
}));
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}));
@@ -207,3 +231,36 @@ describe("GET /api/oura/callback — connect-time first sync", () => {
expect(res.headers.get("location")).toContain("oura=connected");
});
});
// A refused callback must not spend the state: Oura sends a denied consent
// back without a code, and the person may return to the same authorize URL
// and allow it.
describe("GET /api/oura/callback — claim only what can be exchanged", () => {
it("does not claim the state for a callback without a code", async () => {
const res = await GET(makeReq({ state: "S", cookie: "S" }));
expect(res.headers.get("location")).toContain("reason=nocode");
expect(claimSpy).not.toHaveBeenCalled();
});
it("does not claim the state for a foreign issuer or a cross-user session", async () => {
await GET(
makeReq({
code: "c",
state: "S",
cookie: "S",
iss: "https://evil.example/oauth",
}),
);
(getSession as ReturnType<typeof vi.fn>).mockResolvedValue({
user: { id: "attacker" },
});
await GET(makeReq({ code: "c", state: "S", cookie: "S" }));
expect(claimSpy).not.toHaveBeenCalled();
});
it("claims the state once before exchanging a valid code", async () => {
await GET(makeReq({ code: "c", state: "S", cookie: "S" }));
expect(claimSpy).toHaveBeenCalledTimes(1);
expect(exchangeMock).toHaveBeenCalledTimes(1);
});
});
+52 -9
View File
@@ -12,6 +12,8 @@ import {
stateMatchesCookie,
verifySignedState,
} from "@/lib/oauth/signed-state";
import { runOAuthCallbackOnce } from "@/lib/oauth/callback-claim";
import { ouraCollectionsMissingFromGrant } from "@/lib/oura/scope";
import { markReconnected } from "@/lib/integrations/status";
import { OURA_SYNC_QUEUE } from "@/lib/jobs/integration-poll-queues";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
@@ -26,9 +28,22 @@ import { NextRequest, NextResponse } from "next/server";
* binding. The `userId` is read from inside the verified token, cross-checked
* against the web session when present.
*
* The callback is single-use: the verified state is claimed before anything
* else (`src/lib/oauth/callback-claim.ts`). A browser that delivers it twice
* gets one exchange; the duplicate is sent where the first request went and
* never touches the code, because Oura revokes the first exchange's token
* when it sees the code reused.
*
* On success: exchange the code, persist the encrypted access + refresh token
* on `User`, clear any prior reauth state, and redirect back to Settings.
* on `User`, note the collections the granted scopes leave out, clear any
* prior reauth state, and redirect back to Settings.
*/
const SETTINGS_URL = () =>
new URL(
"/settings/integrations",
process.env.NEXT_PUBLIC_APP_URL!,
).toString();
const ERR = (reason: string) =>
NextResponse.redirect(
new URL(
@@ -40,9 +55,7 @@ const ERR = (reason: string) =>
export const GET = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "oura.callback" } });
const { searchParams } = new URL(request.url);
const code = searchParams.get("code");
const state = searchParams.get("state");
const state = new URL(request.url).searchParams.get("state");
const cookieState = request.cookies.get(oauthStateCookieName("oura"))?.value;
if (!stateMatchesCookie(state, cookieState)) {
@@ -57,6 +70,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const userId = verified.userId;
// Everything that can refuse the callback without the code runs before
// the claim, so a refused callback (a denied consent arrives without a
// code) never spends the state: the same authorize URL still works. The
// claim stays strictly before the exchange.
const params = new URL(request.url).searchParams;
const code = params.get("code");
const evt = getEvent();
if (evt) evt.setAuth({ user_id: userId, auth_method: "session" });
@@ -71,26 +91,49 @@ export const GET = apiHandler(async (request: NextRequest) => {
// 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");
const iss = params.get("iss");
if (!isAcceptedOuraIssuer(iss)) {
annotate({ meta: { reason: "issuer" } });
return ERR("issuer");
}
return runOAuthCallbackOnce(
"oura",
state,
{
fallbackUrl: SETTINGS_URL(),
stateCookie: oauthStateCookieName("oura"),
},
() => completeCallback(userId, code, iss),
);
});
async function completeCallback(
userId: string,
code: string,
iss: string | null,
): Promise<NextResponse> {
try {
const creds = await getOuraClientCredentials(userId);
if (!creds) return ERR("nocreds");
const tokens = await exchangeCode(code, creds, iss);
// The token response names the granted scopes when Oura sends them; a
// permission the person unticked on the consent screen is known from it
// at once. Without them, the first sync finds out per collection.
const missing = ouraCollectionsMissingFromGrant(tokens.scope);
if (missing && missing.length > 0) {
annotate({ meta: { oura_scope_denied: missing } });
}
await prisma.user.update({
where: { id: userId },
data: {
ouraAccessTokenEncrypted: encrypt(tokens.access_token),
ouraRefreshTokenEncrypted: encrypt(tokens.refresh_token),
// A new grant carries the scopes asked for now; whatever the old one
// lacked is found out again by the first sync.
ouraScopeDenied: Prisma.DbNull,
ouraScopeDenied:
missing && missing.length > 0 ? missing : Prisma.DbNull,
},
});
@@ -124,4 +167,4 @@ export const GET = apiHandler(async (request: NextRequest) => {
getEvent()?.setError(err);
return ERR("token");
}
});
}
@@ -22,6 +22,30 @@ const {
bossMock: vi.fn(),
}));
// Single-use claiming is covered against Postgres in
// tests/integration/oauth-callback-single-use.test.ts; here every callback
// is the first and only one.
const { claimSpy } = vi.hoisted(() => ({ claimSpy: vi.fn() }));
vi.mock("@/lib/oauth/callback-claim", () => ({
runOAuthCallbackOnce: (
_p: string,
_s: string,
_o: unknown,
run: () => Promise<unknown>,
) => {
claimSpy();
return run();
},
settleOAuthCallback: (_p: string, _s: string, run: () => Promise<unknown>) =>
run(),
consumeLedgerState: async (
_p: string,
_s: string,
consume: (tx: unknown) => Promise<unknown>,
) => consume((await import("@/lib/db")).prisma),
duplicateOAuthCallback: async () => null,
}));
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}));
@@ -58,9 +82,9 @@ import { GET } from "../route";
process.env.NEXT_PUBLIC_APP_URL = "https://app.example";
function makeReq(): Parameters<typeof GET>[0] {
function makeReq(query = "code=abc&state=s"): Parameters<typeof GET>[0] {
return {
url: "https://app.example/api/polar/callback?code=abc&state=s",
url: `https://app.example/api/polar/callback?${query}`,
cookies: { get: () => ({ value: "s" }) },
} as unknown as Parameters<typeof GET>[0];
}
@@ -143,3 +167,14 @@ describe("GET /api/polar/callback — connect-time first sync", () => {
expect(res.headers.get("location")).toContain("polar=connected");
});
});
describe("GET /api/polar/callback — claim only what can be exchanged", () => {
it("does not claim the state for a callback without a code or for another user", async () => {
const denied = await run(makeReq("state=s&error=access_denied"));
expect(denied.headers.get("location")).toContain("reason=nocode");
getSessionMock.mockResolvedValue({ user: { id: "other" } });
await run(makeReq());
expect(claimSpy).not.toHaveBeenCalled();
expect(exchangeCodeMock).not.toHaveBeenCalled();
});
});
+29 -4
View File
@@ -11,6 +11,7 @@ import {
stateMatchesCookie,
verifySignedState,
} from "@/lib/oauth/signed-state";
import { runOAuthCallbackOnce } from "@/lib/oauth/callback-claim";
import { markReconnected } from "@/lib/integrations/status";
import { POLAR_SYNC_QUEUE } from "@/lib/jobs/integration-poll-queues";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
@@ -42,9 +43,7 @@ const ERR = (reason: string) =>
export const GET = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "polar.callback" } });
const { searchParams } = new URL(request.url);
const code = searchParams.get("code");
const state = searchParams.get("state");
const state = new URL(request.url).searchParams.get("state");
const cookieState = request.cookies.get(oauthStateCookieName("polar"))?.value;
// CSRF double-submit: the URL state must equal the cookie (constant-time).
@@ -61,6 +60,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const userId = verified.userId;
// Everything that can refuse the callback without the code runs before
// the claim, so a refused callback (a denied consent arrives without a
// code) never spends the state: the same authorize URL still works. The
// claim stays strictly before the exchange.
const code = new URL(request.url).searchParams.get("code");
const evt = getEvent();
if (evt) evt.setAuth({ user_id: userId, auth_method: "session" });
@@ -73,6 +78,26 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (!code) return ERR("nocode");
// Single-use: a callback delivered twice exchanges its code once, and the
// duplicate follows the first request's result (src/lib/oauth/callback-claim.ts).
return runOAuthCallbackOnce(
"polar",
state,
{
fallbackUrl: new URL(
"/settings/integrations",
process.env.NEXT_PUBLIC_APP_URL!,
).toString(),
stateCookie: oauthStateCookieName("polar"),
},
() => completeCallback(userId, code),
);
});
async function completeCallback(
userId: string,
code: string,
): Promise<NextResponse> {
try {
const creds = await getPolarClientCredentials(userId);
if (!creds) return ERR("nocreds");
@@ -129,4 +154,4 @@ export const GET = apiHandler(async (request: NextRequest) => {
getEvent()?.setError(err);
return ERR("token");
}
});
}
@@ -38,38 +38,50 @@ export const GET = apiHandler(async () => {
const context = await requireGuardianAuth();
const access = resolveGuardianRecordSettingsAccess(context);
if (!access) throw new SharingAccessDeniedError();
const [statuses, withings, whoop, fitbit, googleHealth] = await Promise.all([
Promise.all(
MANAGED_INTEGRATIONS.map((integration) =>
getIntegrationStatus(access.recordId, integration),
const [statuses, withings, whoop, fitbit, googleHealth, credentials] =
await Promise.all([
Promise.all(
MANAGED_INTEGRATIONS.map((integration) =>
getIntegrationStatus(access.recordId, integration),
),
),
),
prisma.withingsConnection.findUnique({
where: { userId: access.recordId },
select: { id: true },
}),
prisma.whoopConnection.findUnique({
where: { userId: access.recordId },
select: { whoopUserId: true },
}),
prisma.fitbitConnection.findUnique({
where: { userId: access.recordId },
select: { id: true },
}),
prisma.googleHealthConnection.findUnique({
where: { userId: access.recordId },
select: { id: true },
}),
]);
prisma.withingsConnection.findUnique({
where: { userId: access.recordId },
select: { id: true },
}),
prisma.whoopConnection.findUnique({
where: { userId: access.recordId },
select: { whoopUserId: true },
}),
prisma.fitbitConnection.findUnique({
where: { userId: access.recordId },
select: { id: true },
}),
prisma.googleHealthConnection.findUnique({
where: { userId: access.recordId },
select: { id: true },
}),
// Presence only; the credentials themselves are not on the per-request
// user and are not read past this null check.
prisma.user.findUnique({
where: { id: context.user.id },
select: {
nightscoutUrlEncrypted: true,
polarAccessTokenEncrypted: true,
ouraAccessTokenEncrypted: true,
stravaAccessTokenEncrypted: true,
},
}),
]);
const connected: Record<IntegrationKey, boolean> = {
withings: withings !== null,
whoop: whoop?.whoopUserId != null,
fitbit: fitbit !== null,
"google-health": googleHealth !== null,
nightscout: context.user.nightscoutUrlEncrypted !== null,
polar: context.user.polarAccessTokenEncrypted !== null,
oura: context.user.ouraAccessTokenEncrypted !== null,
strava: context.user.stravaAccessTokenEncrypted !== null,
nightscout: (credentials?.nightscoutUrlEncrypted ?? null) !== null,
polar: (credentials?.polarAccessTokenEncrypted ?? null) !== null,
oura: (credentials?.ouraAccessTokenEncrypted ?? null) !== null,
strava: (credentials?.stravaAccessTokenEncrypted ?? null) !== null,
// Not offered on a managed profile (no managed integrations before
// v1.44); listed so the map stays total.
dawarich: false,
@@ -28,6 +28,8 @@ vi.mock("@/lib/db", () => ({
},
webauthnMfaCredential: { count: vi.fn(async () => 0) },
passkey: { count: vi.fn(async () => 0) },
// The password hash is read off the stored row; none here.
user: { findUnique: vi.fn(async () => null) },
},
}));
@@ -25,6 +25,30 @@ const {
markReconnectedMock: vi.fn(async () => {}),
}));
// Single-use claiming is covered against Postgres in
// tests/integration/oauth-callback-single-use.test.ts; here every callback
// is the first and only one.
const { claimSpy } = vi.hoisted(() => ({ claimSpy: vi.fn() }));
vi.mock("@/lib/oauth/callback-claim", () => ({
runOAuthCallbackOnce: (
_p: string,
_s: string,
_o: unknown,
run: () => Promise<unknown>,
) => {
claimSpy();
return run();
},
settleOAuthCallback: (_p: string, _s: string, run: () => Promise<unknown>) =>
run(),
consumeLedgerState: async (
_p: string,
_s: string,
consume: (tx: unknown) => Promise<unknown>,
) => consume((await import("@/lib/db")).prisma),
duplicateOAuthCallback: async () => null,
}));
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}));
@@ -65,11 +89,12 @@ import { GET } from "../route";
process.env.NEXT_PUBLIC_APP_URL = "https://app.example";
function callback(): Promise<{ headers: { get(k: string): string | null } }> {
const req = new NextRequest(
"http://localhost/api/strava/callback?code=abc&state=signed-state",
{ headers: { cookie: "strava_oauth_state=signed-state" } },
);
function callback(
query = "code=abc&state=signed-state",
): Promise<{ headers: { get(k: string): string | null } }> {
const req = new NextRequest(`http://localhost/api/strava/callback?${query}`, {
headers: { cookie: "strava_oauth_state=signed-state" },
});
return (GET as unknown as (r: NextRequest) => Promise<never>)(req);
}
@@ -107,3 +132,12 @@ describe("GET /api/strava/callback — S-1 backfill enqueue", () => {
expect(bossSend).not.toHaveBeenCalled();
});
});
describe("GET /api/strava/callback — claim only what can be exchanged", () => {
it("does not claim the state for a callback without a code", async () => {
const res = await callback("state=signed-state&error=access_denied");
expect(res.headers.get("location")).toContain("reason=nocode");
expect(claimSpy).not.toHaveBeenCalled();
expect(exchangeCodeMock).not.toHaveBeenCalled();
});
});
+29 -4
View File
@@ -11,6 +11,7 @@ import {
stateMatchesCookie,
verifySignedState,
} from "@/lib/oauth/signed-state";
import { runOAuthCallbackOnce } from "@/lib/oauth/callback-claim";
import { markReconnected } from "@/lib/integrations/status";
import { STRAVA_BACKFILL_QUEUE } from "@/lib/jobs/strava-backfill";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
@@ -42,9 +43,7 @@ const ERR = (reason: string) =>
export const GET = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "strava.callback" } });
const { searchParams } = new URL(request.url);
const code = searchParams.get("code");
const state = searchParams.get("state");
const state = new URL(request.url).searchParams.get("state");
const cookieState = request.cookies.get(
oauthStateCookieName("strava"),
)?.value;
@@ -61,6 +60,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const userId = verified.userId;
// Everything that can refuse the callback without the code runs before
// the claim, so a refused callback (a denied consent arrives without a
// code) never spends the state: the same authorize URL still works. The
// claim stays strictly before the exchange.
const code = new URL(request.url).searchParams.get("code");
const evt = getEvent();
if (evt) evt.setAuth({ user_id: userId, auth_method: "session" });
@@ -72,6 +77,26 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (!code) return ERR("nocode");
// Single-use: a callback delivered twice exchanges its code once, and the
// duplicate follows the first request's result (src/lib/oauth/callback-claim.ts).
return runOAuthCallbackOnce(
"strava",
state,
{
fallbackUrl: new URL(
"/settings/integrations",
process.env.NEXT_PUBLIC_APP_URL!,
).toString(),
stateCookie: oauthStateCookieName("strava"),
},
() => completeCallback(userId, code),
);
});
async function completeCallback(
userId: string,
code: string,
): Promise<NextResponse> {
try {
const creds = await getStravaClientCredentials(userId);
if (!creds) return ERR("nocreds");
@@ -139,4 +164,4 @@ export const GET = apiHandler(async (request: NextRequest) => {
getEvent()?.setError(err);
return ERR("token");
}
});
}
@@ -1,15 +1,29 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Single-use claiming is covered against Postgres in
// tests/integration/oauth-callback-single-use.test.ts. Here the state row is
// consumed for real against the mocked client, the claim always wins, and
// every callback is the first and only one.
vi.mock("@/lib/oauth/callback-claim", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/oauth/callback-claim")>()),
settleOAuthCallback: (_p: string, _s: string, run: () => Promise<unknown>) =>
run(),
duplicateOAuthCallback: async () => null,
}));
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}));
vi.mock("@/lib/db", () => ({
prisma: {
vi.mock("@/lib/db", () => {
const prisma = {
whoopOAuthState: { delete: vi.fn() },
whoopConnection: { findUnique: vi.fn(), upsert: vi.fn() },
},
}));
oAuthCallbackClaim: { createMany: vi.fn(async () => ({ count: 1 })) },
$transaction: async (run: (tx: unknown) => unknown) => run(prisma),
};
return { prisma };
});
vi.mock("@/lib/auth/session", () => ({
// Native ticket path → no web session present.
+121 -95
View File
@@ -17,6 +17,11 @@ import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { markReconnected } from "@/lib/integrations/status";
import { Prisma } from "@/generated/prisma/client";
import { isP2002 } from "@/lib/prisma-errors";
import {
consumeLedgerState,
duplicateOAuthCallback,
settleOAuthCallback,
} from "@/lib/oauth/callback-claim";
import { NextRequest, NextResponse } from "next/server";
import { timingSafeEqual } from "node:crypto";
@@ -51,6 +56,12 @@ import { timingSafeEqual } from "node:crypto";
* On success: exchange the code, fetch the WHOOP profile for `whoopUserId`,
* persist the encrypted `WhoopConnection`, clear any prior reauth state, and
* enqueue the self-converging history backfill.
*
* A repeat of a consumed nonce is told apart from a replay by the callback
* claim taken in the same transaction (`src/lib/oauth/callback-claim.ts`): a
* browser that delivers the callback twice gets one exchange, and the repeat
* follows the first request to the same page. Only a nonce with no claim left
* lands on `replay`.
*/
/** Web-URL redirect (default + every pre-row-resolution path). */
@@ -119,12 +130,24 @@ export const GET = apiHandler(async (request: NextRequest) => {
returnScheme: string | null;
} | null = null;
try {
stateRow = await prisma.whoopOAuthState.delete({ where: { nonce: state } });
stateRow = await consumeLedgerState("whoop", state, (tx) =>
tx.whoopOAuthState.delete({ where: { nonce: state } }),
);
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === "P2025"
) {
// A duplicate of a callback that is consuming or has consumed this
// row: follow that request's result instead of showing an error.
const duplicate = await duplicateOAuthCallback("whoop", state, {
fallbackUrl: new URL(
"/settings/integrations",
process.env.NEXT_PUBLIC_APP_URL!,
).toString(),
stateCookie: WHOOP_OAUTH_STATE_COOKIE,
});
if (duplicate) return duplicate;
annotate({ meta: { reason: "replay" } });
return WEB_ERR("replay");
}
@@ -134,109 +157,112 @@ export const GET = apiHandler(async (request: NextRequest) => {
return WEB_ERR("state");
}
// The validated native return scheme (if any) is now known; every redirect
// below honours it. Identity is the row's userId (bound at connect time from
// the session or the one-time ticket).
const returnScheme = stateRow.returnScheme;
const userId = stateRow.userId;
const consumed = stateRow;
return settleOAuthCallback("whoop", state, async () => {
// The validated native return scheme (if any) is now known; every redirect
// below honours it. Identity is the row's userId (bound at connect time from
// the session or the one-time ticket).
const returnScheme = consumed.returnScheme;
const userId = consumed.userId;
const evt = getEvent();
if (evt) {
// Identity resolved from the consumed state row (bound at connect time
// from the session or the one-time ticket); no role is asserted here.
evt.setAuth({ user_id: userId, auth_method: "session" });
}
if (stateRow.expiresAt <= new Date()) {
annotate({ meta: { reason: "expired" } });
return outcomeRedirect(returnScheme, "error", "expired");
}
// Optional session cross-check: only when a web session cookie is present.
// The native ticket path carries no session — the nonce cookie + atomic
// single-use delete already bind the row to its originator.
const sessionData = await getSession();
if (sessionData && sessionData.user.id !== userId) {
annotate({ meta: { reason: "cross_user" } });
return outcomeRedirect(returnScheme, "error", "cross_user");
}
if (!code) {
return outcomeRedirect(returnScheme, "error", "nocode");
}
try {
const creds = await getUserWhoopCredentials(userId);
if (!creds) {
return outcomeRedirect(returnScheme, "error", "nocreds");
const evt = getEvent();
if (evt) {
// Identity resolved from the consumed state row (bound at connect time
// from the session or the one-time ticket); no role is asserted here.
evt.setAuth({ user_id: userId, auth_method: "session" });
}
const tokens = await exchangeCode(code, creds);
const profile = await fetchProfile(tokens.access_token);
const whoopUserId = String(profile.user_id);
const expiresAt = new Date(Date.now() + tokens.expires_in * 1000);
const owner = await prisma.whoopConnection.findUnique({
where: { whoopUserId },
select: { userId: true },
});
if (owner && owner.userId !== userId) {
annotate({ meta: { reason: "owner_conflict" } });
return outcomeRedirect(returnScheme, "error", "owner_conflict");
if (consumed.expiresAt <= new Date()) {
annotate({ meta: { reason: "expired" } });
return outcomeRedirect(returnScheme, "error", "expired");
}
// Optional session cross-check: only when a web session cookie is present.
// The native ticket path carries no session — the nonce cookie + atomic
// single-use delete already bind the row to its originator.
const sessionData = await getSession();
if (sessionData && sessionData.user.id !== userId) {
annotate({ meta: { reason: "cross_user" } });
return outcomeRedirect(returnScheme, "error", "cross_user");
}
if (!code) {
return outcomeRedirect(returnScheme, "error", "nocode");
}
try {
await prisma.whoopConnection.upsert({
where: { userId },
update: {
whoopUserId,
accessToken: encrypt(tokens.access_token),
refreshToken: encrypt(tokens.refresh_token),
tokenExpiresAt: expiresAt,
scope: tokens.scope ?? WHOOP_OAUTH_SCOPE,
backfillCompletedAt: null,
},
create: {
userId,
whoopUserId,
accessToken: encrypt(tokens.access_token),
refreshToken: encrypt(tokens.refresh_token),
tokenExpiresAt: expiresAt,
scope: tokens.scope ?? WHOOP_OAUTH_SCOPE,
},
const creds = await getUserWhoopCredentials(userId);
if (!creds) {
return outcomeRedirect(returnScheme, "error", "nocreds");
}
const tokens = await exchangeCode(code, creds);
const profile = await fetchProfile(tokens.access_token);
const whoopUserId = String(profile.user_id);
const expiresAt = new Date(Date.now() + tokens.expires_in * 1000);
const owner = await prisma.whoopConnection.findUnique({
where: { whoopUserId },
select: { userId: true },
});
} catch (err) {
if (isP2002(err)) {
if (owner && owner.userId !== userId) {
annotate({ meta: { reason: "owner_conflict" } });
return outcomeRedirect(returnScheme, "error", "owner_conflict");
}
throw err;
try {
await prisma.whoopConnection.upsert({
where: { userId },
update: {
whoopUserId,
accessToken: encrypt(tokens.access_token),
refreshToken: encrypt(tokens.refresh_token),
tokenExpiresAt: expiresAt,
scope: tokens.scope ?? WHOOP_OAUTH_SCOPE,
backfillCompletedAt: null,
},
create: {
userId,
whoopUserId,
accessToken: encrypt(tokens.access_token),
refreshToken: encrypt(tokens.refresh_token),
tokenExpiresAt: expiresAt,
scope: tokens.scope ?? WHOOP_OAUTH_SCOPE,
},
});
} catch (err) {
if (isP2002(err)) {
annotate({ meta: { reason: "owner_conflict" } });
return outcomeRedirect(returnScheme, "error", "owner_conflict");
}
throw err;
}
await auditLog("whoop.connect", { userId });
// Re-completing OAuth clears any prior reauth-required state.
await markReconnected(userId, "whoop");
// Enqueue the self-converging history backfill. Best-effort: the boot-time
// discovery query (`backfillCompletedAt IS NULL`) is the safety net, so a
// missing boss instance here doesn't strand the connection.
const boss = getGlobalBoss();
if (boss) {
await boss
.send(WHOOP_BACKFILL_QUEUE, {
userId,
enqueuedAt: new Date().toISOString(),
})
.catch((err) =>
getEvent()?.addWarning(`whoop-backfill enqueue failed: ${err}`),
);
}
const response = outcomeRedirect(returnScheme, "connected");
response.cookies.delete(WHOOP_OAUTH_STATE_COOKIE);
return response;
} catch (err) {
getEvent()?.setError(err);
return outcomeRedirect(returnScheme, "error", "token");
}
await auditLog("whoop.connect", { userId });
// Re-completing OAuth clears any prior reauth-required state.
await markReconnected(userId, "whoop");
// Enqueue the self-converging history backfill. Best-effort: the boot-time
// discovery query (`backfillCompletedAt IS NULL`) is the safety net, so a
// missing boss instance here doesn't strand the connection.
const boss = getGlobalBoss();
if (boss) {
await boss
.send(WHOOP_BACKFILL_QUEUE, {
userId,
enqueuedAt: new Date().toISOString(),
})
.catch((err) =>
getEvent()?.addWarning(`whoop-backfill enqueue failed: ${err}`),
);
}
const response = outcomeRedirect(returnScheme, "connected");
response.cookies.delete(WHOOP_OAUTH_STATE_COOKIE);
return response;
} catch (err) {
getEvent()?.setError(err);
return outcomeRedirect(returnScheme, "error", "token");
}
});
});
@@ -20,6 +20,17 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// Single-use claiming is covered against Postgres in
// tests/integration/oauth-callback-single-use.test.ts. Here the state row is
// consumed for real against the mocked client, the claim always wins, and
// every callback is the first and only one.
vi.mock("@/lib/oauth/callback-claim", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/oauth/callback-claim")>()),
settleOAuthCallback: (_p: string, _s: string, run: () => Promise<unknown>) =>
run(),
duplicateOAuthCallback: async () => null,
}));
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({
@@ -36,16 +47,19 @@ vi.mock("@/lib/api-handler", () => ({
},
}));
vi.mock("@/lib/db", () => ({
prisma: {
vi.mock("@/lib/db", () => {
const prisma = {
withingsOAuthState: {
delete: vi.fn(),
},
withingsConnection: {
upsert: vi.fn(),
},
},
}));
oAuthCallbackClaim: { createMany: vi.fn(async () => ({ count: 1 })) },
$transaction: async (run: (tx: unknown) => unknown) => run(prisma),
};
return { prisma };
});
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
+127 -103
View File
@@ -9,6 +9,11 @@ import { WITHINGS_OAUTH_STATE_COOKIE } from "@/lib/withings/oauth-state";
import { setupWebhook } from "@/lib/withings/sync";
import { markReconnected } from "@/lib/integrations/status";
import { Prisma } from "@/generated/prisma/client";
import {
consumeLedgerState,
duplicateOAuthCallback,
settleOAuthCallback,
} from "@/lib/oauth/callback-claim";
import { NextRequest, NextResponse } from "next/server";
import { timingSafeEqual } from "node:crypto";
@@ -39,6 +44,12 @@ import { timingSafeEqual } from "node:crypto";
* carries only the opaque nonce, so a future refactor flipping it to
* non-httpOnly cannot leak the user id into request logs / network
* captures.
*
* A repeat of a consumed nonce is told apart from a replay by the callback
* claim taken in the same transaction (`src/lib/oauth/callback-claim.ts`): a
* browser that delivers the callback twice gets one exchange, and the repeat
* follows the first request to the same page. Only a nonce with no claim left
* lands on `replay`.
*/
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
@@ -88,15 +99,25 @@ export const GET = apiHandler(async (request: NextRequest) => {
// lookup before either issues the delete.
let stateRow: { userId: string; expiresAt: Date } | null = null;
try {
stateRow = await prisma.withingsOAuthState.delete({
where: { nonce: state },
});
stateRow = await consumeLedgerState("withings", state, (tx) =>
tx.withingsOAuthState.delete({ where: { nonce: state } }),
);
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === "P2025"
) {
// Row already consumed (replay) or never existed.
// A duplicate of a callback that is consuming or has consumed this
// row: follow that request's result instead of showing an error.
const duplicate = await duplicateOAuthCallback("withings", state, {
fallbackUrl: new URL(
"/settings/integrations",
process.env.NEXT_PUBLIC_APP_URL!,
).toString(),
stateCookie: WITHINGS_OAUTH_STATE_COOKIE,
});
if (duplicate) return duplicate;
annotate({ meta: { reason: "replay" } });
return NextResponse.redirect(
new URL(
@@ -125,116 +146,119 @@ export const GET = apiHandler(async (request: NextRequest) => {
);
}
// Validity checks against the consumed row. The row is already
// gone from the ledger by this point — single-use semantics —
// whether the expiry tripped, the userId mismatched, or
// everything looked good.
if (stateRow.expiresAt <= new Date()) {
annotate({ meta: { reason: "expired" } });
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=expired",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
}
if (stateRow.userId !== user.id) {
annotate({ meta: { reason: "cross_user" } });
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=cross_user",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
}
if (!code) {
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=nocode",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
}
try {
const creds = await getUserWithingsCredentials(user.id);
if (!creds) {
const consumed = stateRow;
return settleOAuthCallback("withings", state, async () => {
// Validity checks against the consumed row. The row is already
// gone from the ledger by this point — single-use semantics —
// whether the expiry tripped, the userId mismatched, or
// everything looked good.
if (consumed.expiresAt <= new Date()) {
annotate({ meta: { reason: "expired" } });
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=nocreds",
"/settings/integrations?withings=error&reason=expired",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
}
if (consumed.userId !== user.id) {
annotate({ meta: { reason: "cross_user" } });
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=cross_user",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
}
const tokens = await exchangeCode(code, creds);
if (!code) {
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=nocode",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
}
const expiresAt = new Date(Date.now() + tokens.expires_in * 1000);
const subscriptionRetryAt = new Date();
try {
const creds = await getUserWithingsCredentials(user.id);
if (!creds) {
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=nocreds",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
}
// v1.4.25 — persist the OAuth scope we requested. The token
// response doesn't echo the granted scopes back as a top-level
// field, but Withings honours the scope param verbatim — there's
// no scope downgrade unless the user explicitly revokes one in
// their Health Mate account, which would fail the token exchange
// entirely. Storing the requested set is therefore a safe
// representation of what the connection holds.
await prisma.withingsConnection.upsert({
where: { userId: user.id },
update: {
withingsUserId: tokens.userid,
accessToken: encrypt(tokens.access_token),
refreshToken: encrypt(tokens.refresh_token),
tokenExpiresAt: expiresAt,
scope: WITHINGS_OAUTH_SCOPE,
webhookSubscriptionState: Prisma.DbNull,
webhookSubscriptionRetryAt: subscriptionRetryAt,
// A reconnect may come with another developer app (another plan), so
// what the old grant's plan refused is asked for again.
apiPlanState: Prisma.DbNull,
},
create: {
const tokens = await exchangeCode(code, creds);
const expiresAt = new Date(Date.now() + tokens.expires_in * 1000);
const subscriptionRetryAt = new Date();
// v1.4.25 — persist the OAuth scope we requested. The token
// response doesn't echo the granted scopes back as a top-level
// field, but Withings honours the scope param verbatim — there's
// no scope downgrade unless the user explicitly revokes one in
// their Health Mate account, which would fail the token exchange
// entirely. Storing the requested set is therefore a safe
// representation of what the connection holds.
await prisma.withingsConnection.upsert({
where: { userId: user.id },
update: {
withingsUserId: tokens.userid,
accessToken: encrypt(tokens.access_token),
refreshToken: encrypt(tokens.refresh_token),
tokenExpiresAt: expiresAt,
scope: WITHINGS_OAUTH_SCOPE,
webhookSubscriptionState: Prisma.DbNull,
webhookSubscriptionRetryAt: subscriptionRetryAt,
// A reconnect may come with another developer app (another plan), so
// what the old grant's plan refused is asked for again.
apiPlanState: Prisma.DbNull,
},
create: {
userId: user.id,
withingsUserId: tokens.userid,
accessToken: encrypt(tokens.access_token),
refreshToken: encrypt(tokens.refresh_token),
tokenExpiresAt: expiresAt,
scope: WITHINGS_OAUTH_SCOPE,
webhookSubscriptionState: Prisma.DbNull,
webhookSubscriptionRetryAt: subscriptionRetryAt,
},
});
// Reconcile every category from a clean state after connect/reconnect.
setupWebhook(user.id).catch(() =>
getEvent()?.addWarning("Webhook setup failed"),
);
await auditLog("withings.connect", {
userId: user.id,
withingsUserId: tokens.userid,
accessToken: encrypt(tokens.access_token),
refreshToken: encrypt(tokens.refresh_token),
tokenExpiresAt: expiresAt,
scope: WITHINGS_OAUTH_SCOPE,
webhookSubscriptionState: Prisma.DbNull,
webhookSubscriptionRetryAt: subscriptionRetryAt,
},
});
details: { withingsUserId: tokens.userid },
});
// Reconcile every category from a clean state after connect/reconnect.
setupWebhook(user.id).catch(() =>
getEvent()?.addWarning("Webhook setup failed"),
);
// Re-completing OAuth clears any prior `error_reauth` state. We
// don't write a fresh success-time — that's the next sync's job.
await markReconnected(user.id, "withings");
await auditLog("withings.connect", {
userId: user.id,
details: { withingsUserId: tokens.userid },
});
// Re-completing OAuth clears any prior `error_reauth` state. We
// don't write a fresh success-time — that's the next sync's job.
await markReconnected(user.id, "withings");
const response = NextResponse.redirect(
new URL(
"/settings/integrations?withings=connected",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
response.cookies.delete(WITHINGS_OAUTH_STATE_COOKIE);
return response;
} catch {
getEvent()?.setError(new Error("Withings callback failed"));
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=token",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
}
const response = NextResponse.redirect(
new URL(
"/settings/integrations?withings=connected",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
response.cookies.delete(WITHINGS_OAUTH_STATE_COOKIE);
return response;
} catch {
getEvent()?.setError(new Error("Withings callback failed"));
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=token",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
}
});
});
+8 -1
View File
@@ -193,6 +193,7 @@ import {
export default function DashboardPageClient({
batchWindow: batchWindowProp,
demoMode = false,
}: {
/**
* v1.30.9 — the batched-series ISO window computed server-side from the
@@ -204,6 +205,12 @@ export default function DashboardPageClient({
* under, so the prefetched slice lands instead of triggering a refetch.
*/
batchWindow?: BatchWindow;
/**
* True on a `DEMO_MODE` instance. The demo is one shared, read-only
* account a visitor cannot set up, so the setup checklist has nothing to
* offer there, as the shell already leaves out the tour.
*/
demoMode?: boolean;
} = {}) {
const { isAuthenticated } = useAuth();
const mounted = useMounted();
@@ -2312,7 +2319,7 @@ export default function DashboardPageClient({
* content (tiles, charts) keeps a stable position, and the
* near-empty dashboards of the new accounts the card targets keep
* it visible without scrolling anyway. */}
<GettingStartedChecklist />
{demoMode ? null : <GettingStartedChecklist />}
</div>
);
}
+8 -3
View File
@@ -76,12 +76,15 @@ const DIGEST_SOFT_BUDGET_MS = 400;
* others.
*/
export default async function DashboardPage() {
// `DEMO_MODE` is a server-only env var; resolved here and threaded in, as
// the layout does for the shell.
const demoMode = process.env.DEMO_MODE === "true";
// Operator/test escape hatch: `DASHBOARD_SSR_PREFETCH=false` renders the
// pure client-fetch dashboard (pre-prefetch behaviour). The e2e server
// sets it so Playwright route mocks — which only see CLIENT fetches —
// keep governing what the dashboard paints. It gates EVERY prefetch below.
if (process.env.DASHBOARD_SSR_PREFETCH === "false") {
return <DashboardPageClient />;
return <DashboardPageClient demoMode={demoMode} />;
}
let dehydratedState = null;
let batchWindow: BatchWindow | undefined;
@@ -171,11 +174,13 @@ export default async function DashboardPage() {
}
if (dehydratedState === null) {
return <DashboardPageClient batchWindow={batchWindow} />;
return (
<DashboardPageClient batchWindow={batchWindow} demoMode={demoMode} />
);
}
return (
<HydrationBoundary state={dehydratedState}>
<DashboardPageClient batchWindow={batchWindow} />
<DashboardPageClient batchWindow={batchWindow} demoMode={demoMode} />
</HydrationBoundary>
);
}
@@ -80,7 +80,7 @@ beforeEach(() => {
describe("<RecoverySection> empty note", () => {
it.each([
["oura_permission", "heart-health access"],
["oura_permission", "stress permission"],
["general", "WHOOP or Polar"],
])("renders the server's %s reason with the one action", (reason, text) => {
const html = render(reason);
+10 -1
View File
@@ -5,6 +5,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
session: { findUnique: vi.fn() },
user: { findUnique: vi.fn() },
webauthnMfaCredential: { count: vi.fn() },
passkey: { count: vi.fn() },
},
@@ -50,7 +51,15 @@ beforeEach(() => {
vi.resetAllMocks();
});
function mockSession(user: unknown) {
function mockSession(user: {
id: string;
totpConfirmedAt: Date | null;
passwordHash?: string | null;
}) {
// The password hash is read off the stored row, not the session user.
vi.mocked(prisma.user.findUnique).mockResolvedValue({
passwordHash: user.passwordHash ?? null,
} as never);
vi.mocked(getSession).mockResolvedValue({
session: { id: "sess-1", expiresAt: new Date(Date.now() + 1e6) },
user,
@@ -72,13 +72,17 @@ beforeEach(() => {
});
function cookie(
user: unknown,
user: { id: string; passwordHash?: string | null },
stamps: {
mfaVerifiedAt?: Date | null;
createdAt?: Date;
reproofAt?: Date | null;
},
) {
// The password hash is read off the stored row, not the session user.
vi.mocked(prisma.user.findUnique).mockResolvedValue({
passwordHash: user.passwordHash ?? null,
} as never);
vi.mocked(getSession).mockResolvedValue({
session: { id: "sess-1", expiresAt: new Date(Date.now() + 1e6) },
user,
+17
View File
@@ -225,6 +225,23 @@ describe("codex-oauth", () => {
expect(err.oauthError).toBeNull();
});
it("does not read a bare 403 as a dead sign-in", async () => {
// An edge challenge in front of the issuer answers a valid grant so.
failWith(403, "<html>challenge</html>");
const err = await refreshError();
expect(err.credentialExpired).toBe(false);
expect(err.httpStatus).toBe(403);
});
it("does not read refresh_token_reused as a dead sign-in", async () => {
// The loser of two refreshes of one token hears this; the winner's
// rotated token is alive.
failWith(400, JSON.stringify({ error: "refresh_token_reused" }));
const err = await refreshError();
expect(err.credentialExpired).toBe(false);
expect(err.oauthError).toBe("refresh_token_reused");
});
it("never quotes the response body in the message", async () => {
failWith(
401,
+12 -5
View File
@@ -296,14 +296,18 @@ export async function refreshDeviceTokens(
/**
* OAuth error codes on the refresh grant that mean the stored refresh token
* is dead for good: revoked, expired, already rotated, or issued to another
* client. Retrying cannot help; only signing in again can.
* is dead for good: revoked, expired, or issued to another client. Retrying
* cannot help; only signing in again can.
*
* `refresh_token_reused` is deliberately not among them. It is what the loser
* of two refreshes of one token hears while the winner's rotated token is
* perfectly alive, so it says nothing about the sign-in: the refresh re-reads
* the row and carries on with the newer token (`refreshCodexCredential`).
*/
const DEAD_REFRESH_TOKEN_CODES: ReadonlySet<string> = new Set([
"invalid_grant",
"invalid_refresh_token",
"refresh_token_expired",
"refresh_token_reused",
"refresh_token_invalidated",
"invalid_token",
]);
@@ -333,9 +337,12 @@ export class CodexRefreshError extends Error {
readonly oauthError: string | null;
constructor(status: number, oauthError: string | null) {
// A bare 403 is not proof: an edge challenge in front of the issuer
// answers 403 to a perfectly valid grant. It keeps its own status and
// with it the ledger's timed auth cooldown; only a 401 or the issuer's
// own dead-grant code parks the sign-in until the person reconnects.
const credentialExpired =
status === 401 ||
status === 403 ||
(oauthError !== null && DEAD_REFRESH_TOKEN_CODES.has(oauthError));
super(
credentialExpired
@@ -345,7 +352,7 @@ export class CodexRefreshError extends Error {
this.name = "CodexRefreshError";
this.credentialExpired = credentialExpired;
this.oauthError = oauthError;
this.httpStatus = credentialExpired && status !== 403 ? 401 : status;
this.httpStatus = credentialExpired ? 401 : status;
}
}
+27
View File
@@ -266,6 +266,33 @@ export const postgresProviderHealthLedger: ProviderHealthLedger = {
},
};
/**
* Drop the recorded outcomes of a credential that has just been replaced.
*
* The ledger describes a credential, not a provider slot: once the person
* (or, for `admin-codex`, the operator) signs in again, the `auth_failed`
* row of the refused credential would otherwise keep the fresh one at the
* tail of the chain and reported as expired for the rest of its cooldown.
* Without `userId` the rows of every account are dropped — the operator's
* central connection is one credential shared by all of them. Best effort,
* like every ledger write.
*/
export async function forgetProviderHealth(target: {
providerType: ProviderChainType;
userId?: string;
}): Promise<void> {
try {
await prisma.providerHealth.deleteMany({
where: {
providerType: target.providerType,
...(target.userId ? { userId: target.userId } : {}),
},
});
} catch {
// A stale row only deprioritises; it never blocks.
}
}
/**
* Providers whose credential is currently known-bad (auth-class failure
* inside the cooldown window) for a user. Drives the proactive
+326 -88
View File
@@ -14,6 +14,8 @@ import {
decryptCodexCreds,
encryptAdminCodexCreds,
decryptAdminCodexCreds,
CodexRefreshError,
type CodexCreds,
} from "./codex-oauth";
import { isPublicUrl } from "@/lib/validations/notifications";
import { isLocalAiHostAllowed } from "./local-host-allowlist";
@@ -30,6 +32,12 @@ import {
type ProviderWorkAuthority,
} from "@/lib/sharing/provider-work-authority";
import { memoizePerRequest } from "@/lib/request-cache";
import { emitSignal } from "@/lib/logging/signal";
import {
acquireProviderTokenRefreshLock,
PROVIDER_REFRESH_TRANSACTION_OPTIONS,
} from "@/lib/integrations/oauth-refresh";
import type { Prisma } from "@/generated/prisma/client";
import type { AiModality } from "@/lib/ai/capabilities/types";
import type {
ProviderEntryPresence,
@@ -42,6 +50,291 @@ import {
const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; // 5 minutes
/**
* Whether a failed Codex refresh proves the sign-in dead: the issuer refused
* the grant itself (`CodexRefreshError.credentialExpired`), as opposed to a
* 429, a 5xx or a network error, which say nothing about the credential.
* Read by shape rather than `instanceof` so a mocked `codex-oauth` module
* classifies the same way.
*/
function isDeadCodexSignIn(error: unknown): boolean {
return (
(error as { credentialExpired?: unknown } | null)?.credentialExpired ===
true
);
}
function oauthErrorOf(error: unknown): string | null {
const code = (error as { oauthError?: unknown } | null)?.oauthError;
return typeof code === "string" ? code : null;
}
type CodexCredentialOwner = "codex" | "admin-codex";
/**
* One Codex credential row as a refresh sees it: the user's own connection or
* the operator's central one. `writeRotated` and `park` are compare-and-set on
* the refresh token this refresh spent, so a sign-in that landed meanwhile (the
* device-code flow writes without the refresh lock) is never overwritten.
*/
interface CodexCredentialStore {
owner: CodexCredentialOwner;
lockId: string;
read(tx: Prisma.TransactionClient): Promise<{
status: string | null;
refreshEncrypted: string | null;
creds: CodexCreds | null;
} | null>;
writeRotated(
tx: Prisma.TransactionClient,
spentRefreshEncrypted: string,
fresh: CodexCreds,
): Promise<number>;
park(
tx: Prisma.TransactionClient,
spentRefreshEncrypted: string,
): Promise<number>;
}
function userCodexStore(userId: string): CodexCredentialStore {
return {
owner: "codex",
lockId: userId,
async read(tx) {
const row = await tx.user.findUnique({
where: { id: userId },
select: {
codexConnectionStatus: true,
codexAccessTokenEncrypted: true,
codexRefreshTokenEncrypted: true,
},
});
if (!row) return null;
return {
status: row.codexConnectionStatus,
refreshEncrypted: row.codexRefreshTokenEncrypted,
creds:
row.codexAccessTokenEncrypted && row.codexRefreshTokenEncrypted
? decryptCodexCreds({
accessEncrypted: row.codexAccessTokenEncrypted,
refreshEncrypted: row.codexRefreshTokenEncrypted,
})
: null,
};
},
async writeRotated(tx, spent, fresh) {
const enc = encryptCodexCreds(fresh);
const written = await tx.user.updateMany({
where: { id: userId, codexRefreshTokenEncrypted: spent },
data: {
codexAccessTokenEncrypted: enc.accessEncrypted,
codexRefreshTokenEncrypted: enc.refreshEncrypted,
codexTokenExpiresAt: fresh.expiresAt,
codexConnectionStatus: "connected",
},
});
return written.count;
},
async park(tx, spent) {
const parked = await tx.user.updateMany({
where: {
id: userId,
codexConnectionStatus: "connected",
codexRefreshTokenEncrypted: spent,
},
data: { codexConnectionStatus: "expired" },
});
return parked.count;
},
};
}
const centralCodexStore: CodexCredentialStore = {
owner: "admin-codex",
lockId: "singleton",
async read(tx) {
const row = await tx.appSettings.findUnique({
where: { id: "singleton" },
select: {
adminCodexConnectionStatus: true,
adminCodexAccessTokenEncrypted: true,
adminCodexRefreshTokenEncrypted: true,
adminCodexAccountIdEncrypted: true,
adminCodexTokenExpiresAt: true,
},
});
if (!row) return null;
return {
status: row.adminCodexConnectionStatus,
refreshEncrypted: row.adminCodexRefreshTokenEncrypted,
creds:
row.adminCodexAccessTokenEncrypted &&
row.adminCodexRefreshTokenEncrypted &&
row.adminCodexAccountIdEncrypted
? decryptAdminCodexCreds({
accessEncrypted: row.adminCodexAccessTokenEncrypted,
refreshEncrypted: row.adminCodexRefreshTokenEncrypted,
accountIdEncrypted: row.adminCodexAccountIdEncrypted,
expiresAt: row.adminCodexTokenExpiresAt,
})
: null,
};
},
async writeRotated(tx, spent, fresh) {
const enc = encryptAdminCodexCreds(fresh);
const written = await tx.appSettings.updateMany({
where: { id: "singleton", adminCodexRefreshTokenEncrypted: spent },
data: {
adminCodexAccessTokenEncrypted: enc.accessEncrypted,
adminCodexRefreshTokenEncrypted: enc.refreshEncrypted,
adminCodexAccountIdEncrypted: enc.accountIdEncrypted,
adminCodexTokenExpiresAt: enc.expiresAt,
adminCodexConnectionStatus: "connected",
},
});
return written.count;
},
async park(tx, spent) {
const parked = await tx.appSettings.updateMany({
where: {
id: "singleton",
adminCodexConnectionStatus: "connected",
adminCodexRefreshTokenEncrypted: spent,
},
data: { adminCodexConnectionStatus: "expired" },
});
return parked.count;
},
};
/** Refreshes in flight in this process, keyed on credential and spent token. */
const codexRefreshesInFlight = new Map<string, Promise<CodexCreds>>();
/**
* Refresh one Codex credential, at most once per spent token.
*
* The issuer rotates the refresh token on every refresh and honours each one
* once. Two callers that refreshed the same token used to race: the loser got
* `refresh_token_reused`, read it as a dead sign-in and parked the connection
* while the winner's fresh tokens landed beside an `expired` status. So:
*
* - callers in one process share one refresh (single flight), and callers
* in different processes queue on the per-credential advisory lock the
* provider syncs use (`acquireProviderTokenRefreshLock`);
* - inside the lock the row is read again, and a token a peer already
* rotated is used as it is, without asking the issuer;
* - the rotated tokens are written compare-and-set on the spent token, and
* the write sets `connected`;
* - after a refusal the row is read again too, and only a refusal of the
* token still stored, with the issuer's dead-grant answer, parks it
* (`expired`, the state Settings renders as "reconnect ChatGPT"). The
* transition is signalled once: only the lock holder can make it.
*
* Parking stops the chain, the presence probe and every AI capability from
* counting the connection until the person signs in again, which is what
* stopped every run from asking the issuer about the same dead token.
*/
function refreshCodexCredential(
store: CodexCredentialStore,
spentRefreshEncrypted: string,
): Promise<CodexCreds> {
const key = `${store.owner}:${store.lockId}:${spentRefreshEncrypted}`;
const inFlight = codexRefreshesInFlight.get(key);
if (inFlight) return inFlight;
const run = runCodexRefresh(store, spentRefreshEncrypted).finally(() => {
codexRefreshesInFlight.delete(key);
});
codexRefreshesInFlight.set(key, run);
return run;
}
async function runCodexRefresh(
store: CodexCredentialStore,
spent: string,
): Promise<CodexCreds> {
const outcome = await prisma.$transaction(
async (
tx,
): Promise<
| { fresh: CodexCreds }
| { refused: unknown; parked: boolean }
| { failed: unknown }
> => {
await acquireProviderTokenRefreshLock(tx, store.owner, store.lockId);
const row = await store.read(tx);
if (!row || row.status !== "connected" || !row.refreshEncrypted) {
return {
refused: codexReconnectRequired("no refresh token"),
parked: false,
};
}
if (row.refreshEncrypted !== spent && row.creds) {
// A peer rotated while this caller waited: its token is the live one.
return { fresh: row.creds };
}
if (!row.creds) {
const refused = codexReconnectRequired("token storage corrupt");
return {
refused,
parked: (await store.park(tx, row.refreshEncrypted)) === 1,
};
}
let fresh: CodexCreds;
try {
fresh = await refreshDeviceTokens(row.creds.refreshToken);
} catch (error) {
const now = await store.read(tx);
if (now?.refreshEncrypted !== row.refreshEncrypted && now?.creds) {
// A sign-in replaced the token while the issuer refused the old one.
return { fresh: now.creds };
}
// `refresh_token_reused` is a dead grant here and only here: under the
// lock, with the row still holding the very token just spent, no peer
// of ours rotated it, so the issuer is refusing the whole family (a
// revoked sign-in answers every refresh so). Outside the lock it is
// the lost race it usually is (see DEAD_REFRESH_TOKEN_CODES).
const refused =
oauthErrorOf(error) === "refresh_token_reused"
? new CodexRefreshError(401, "refresh_token_reused")
: error;
if (!isDeadCodexSignIn(refused)) return { failed: error };
return {
refused,
parked: (await store.park(tx, row.refreshEncrypted)) === 1,
};
}
if ((await store.writeRotated(tx, row.refreshEncrypted, fresh)) === 0) {
// A sign-in replaced the row meanwhile; it wins over this refresh. A
// row with no usable credential left was disconnected meanwhile: the
// person took Codex away, so nothing may be sent with it any more.
const now = await store.read(tx);
if (now?.status === "connected" && now.creds) {
return { fresh: now.creds };
}
return {
refused: codexReconnectRequired("disconnected during refresh"),
parked: false,
};
}
return { fresh };
},
PROVIDER_REFRESH_TRANSACTION_OPTIONS,
);
if ("fresh" in outcome) return outcome.fresh;
if ("failed" in outcome) throw outcome.failed;
if (outcome.parked) {
emitSignal({
action: "ai.credential.expired",
level: "warn",
meta: {
provider: store.owner,
oauth_error: oauthErrorOf(outcome.refused),
},
});
}
throw outcome.refused;
}
class NoProvider implements AIProvider {
readonly type = "none" as const;
@@ -296,21 +589,16 @@ async function resolveCodexProvider(
// Proactive refresh if the access token is within 5 min of expiry.
if (stored.expiresAt.getTime() < Date.now() + TOKEN_REFRESH_BUFFER_MS) {
try {
const fresh = await refreshDeviceTokens(stored.refreshToken);
active = fresh;
const enc = encryptCodexCreds(fresh);
await prisma.user.update({
where: { id: userId },
data: {
codexAccessTokenEncrypted: enc.accessEncrypted,
codexRefreshTokenEncrypted: enc.refreshEncrypted,
codexTokenExpiresAt: fresh.expiresAt,
},
});
} catch {
// Fall through — CodexClient will trigger an on-401 refresh
// and persist there.
active = await refreshCodexCredential(
userCodexStore(userId),
user.codexRefreshTokenEncrypted,
);
} catch (error) {
// A refused grant drops the connection from this chain (the refresh
// parked it): calling Codex with the expired access token could only
// 401 into a second refusal. Anything else falls through — CodexClient
// will trigger an on-401 refresh.
if (isDeadCodexSignIn(error)) return null;
}
}
@@ -318,36 +606,17 @@ async function resolveCodexProvider(
accessToken: active.accessToken,
accountId: active.accountId,
onTokenRefresh: async () => {
const freshUser = await prisma.user.findUnique({
const current = await prisma.user.findUnique({
where: { id: userId },
select: {
codexAccessTokenEncrypted: true,
codexRefreshTokenEncrypted: true,
},
select: { codexRefreshTokenEncrypted: true },
});
if (
!freshUser?.codexAccessTokenEncrypted ||
!freshUser.codexRefreshTokenEncrypted
) {
if (!current?.codexRefreshTokenEncrypted) {
throw codexReconnectRequired("no refresh token");
}
const decoded = decryptCodexCreds({
accessEncrypted: freshUser.codexAccessTokenEncrypted,
refreshEncrypted: freshUser.codexRefreshTokenEncrypted,
});
if (!decoded) {
throw codexReconnectRequired("token storage corrupt");
}
const fresh = await refreshDeviceTokens(decoded.refreshToken);
const enc = encryptCodexCreds(fresh);
await prisma.user.update({
where: { id: userId },
data: {
codexAccessTokenEncrypted: enc.accessEncrypted,
codexRefreshTokenEncrypted: enc.refreshEncrypted,
codexTokenExpiresAt: fresh.expiresAt,
},
});
const fresh = await refreshCodexCredential(
userCodexStore(userId),
current.codexRefreshTokenEncrypted,
);
return { accessToken: fresh.accessToken, accountId: fresh.accountId };
},
});
@@ -409,21 +678,15 @@ async function resolveAdminCodexProvider(): Promise<AIProvider | null> {
if (stored.expiresAt.getTime() < Date.now() + TOKEN_REFRESH_BUFFER_MS) {
try {
const fresh = await refreshDeviceTokens(stored.refreshToken);
active = fresh;
const enc = encryptAdminCodexCreds(fresh);
await prisma.appSettings.update({
where: { id: "singleton" },
data: {
adminCodexAccessTokenEncrypted: enc.accessEncrypted,
adminCodexRefreshTokenEncrypted: enc.refreshEncrypted,
adminCodexAccountIdEncrypted: enc.accountIdEncrypted,
adminCodexTokenExpiresAt: enc.expiresAt,
},
});
} catch {
// Fall through — CodexClient triggers an on-401 refresh and persists via
// the callback below.
active = await refreshCodexCredential(
centralCodexStore,
settings.adminCodexRefreshTokenEncrypted,
);
} catch (error) {
// A refused grant parked the central connection (see
// `refreshCodexCredential`); anything else falls through — CodexClient
// triggers an on-401 refresh via the callback below.
if (isDeadCodexSignIn(error)) return null;
}
}
@@ -431,42 +694,17 @@ async function resolveAdminCodexProvider(): Promise<AIProvider | null> {
accessToken: active.accessToken,
accountId: active.accountId,
onTokenRefresh: async () => {
const fresh = await prisma.appSettings.findUnique({
const current = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: {
adminCodexAccessTokenEncrypted: true,
adminCodexRefreshTokenEncrypted: true,
adminCodexAccountIdEncrypted: true,
adminCodexTokenExpiresAt: true,
},
select: { adminCodexRefreshTokenEncrypted: true },
});
if (
!fresh?.adminCodexAccessTokenEncrypted ||
!fresh.adminCodexRefreshTokenEncrypted ||
!fresh.adminCodexAccountIdEncrypted
) {
if (!current?.adminCodexRefreshTokenEncrypted) {
throw codexReconnectRequired("no central refresh token");
}
const decoded = decryptAdminCodexCreds({
accessEncrypted: fresh.adminCodexAccessTokenEncrypted,
refreshEncrypted: fresh.adminCodexRefreshTokenEncrypted,
accountIdEncrypted: fresh.adminCodexAccountIdEncrypted,
expiresAt: fresh.adminCodexTokenExpiresAt,
});
if (!decoded) {
throw codexReconnectRequired("central token storage corrupt");
}
const refreshed = await refreshDeviceTokens(decoded.refreshToken);
const enc = encryptAdminCodexCreds(refreshed);
await prisma.appSettings.update({
where: { id: "singleton" },
data: {
adminCodexAccessTokenEncrypted: enc.accessEncrypted,
adminCodexRefreshTokenEncrypted: enc.refreshEncrypted,
adminCodexAccountIdEncrypted: enc.accountIdEncrypted,
adminCodexTokenExpiresAt: enc.expiresAt,
},
});
const refreshed = await refreshCodexCredential(
centralCodexStore,
current.adminCodexRefreshTokenEncrypted,
);
return {
accessToken: refreshed.accessToken,
accountId: refreshed.accountId,
+9 -7
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import type { AccountGrant, User } from "@/generated/prisma/client";
import type { AccountGrant } from "@/generated/prisma/client";
import { SESSION_USER_SELECT, type SessionUser } from "./auth/session-user";
import {
applyBypassRouteHeaders,
bypassRouteRefusal,
@@ -472,7 +473,7 @@ export type AuthContext = {
*/
readonly recordEpoch?: number;
};
user: User;
user: SessionUser;
/**
* Transport the caller authenticated with, derived from the SAME branch that
* sets the wide event's `auth_method`: the cookie-session path resolves
@@ -886,7 +887,7 @@ export async function requireActorAuth(): Promise<AuthContext> {
*/
export interface RecordAuthContext extends AuthContext {
/** The authenticated caller. Equals `user` when the caller acts as themselves. */
readonly actor: User;
readonly actor: SessionUser;
/** The grant being exercised, or null when the caller acts as themselves. */
readonly grantId: string | null;
}
@@ -1125,7 +1126,7 @@ export async function requireGuardianAuth(): Promise<RecordAuthContext> {
async function resolveSwitchedRecord(
auth: AuthContext,
carrier: Extract<ActingCarrier, { accountId: string }>,
admit: (grant: AccountGrant, owner: User) => string | null,
admit: (grant: AccountGrant, owner: SessionUser) => string | null,
): Promise<RecordAuthContext> {
/** Every refusal below is this one error, with the reason kept off the wire. */
const denied = (reason: string): SharingAccessDeniedError => {
@@ -1155,6 +1156,7 @@ async function resolveSwitchedRecord(
const owner = await prisma.user.findUnique({
where: { id: grant.grantorId },
select: SESSION_USER_SELECT,
});
// Unreachable while the FK cascade holds — a grant naming a deleted account
// is deleted with it. Fail closed anyway: an authorization we cannot resolve
@@ -1584,7 +1586,7 @@ export async function requireRecentProof(options: {
* caller themselves (the admin routes, which must stay on `requireAdmin`).
*/
export async function assertRecentCookieProof(
user: User,
user: SessionUser,
sessionId: string,
): Promise<void> {
const enrolled = await hasSecondFactorEnrolled(user);
@@ -1713,7 +1715,7 @@ export const STEP_UP_ELEVATION_HEADER = "x-step-up";
* own device login. A comment would not have caught that; a type does.
*/
export type MfaManagementContext = {
user: User;
user: SessionUser;
/**
* Spend the elevation. Call it immediately BEFORE the mutation, once every
* cheap validation has passed — a 429, a 422, or a wrong factor code must not
@@ -1837,7 +1839,7 @@ export async function requireMfaManagementAuth(
* the wire.
*/
async function resolveBearerElevation(
auth: { user: User; apiTokenId: string },
auth: { user: SessionUser; apiTokenId: string },
options: { freshFactor: boolean; proofSource: FreshFactorProofSource },
): Promise<() => Promise<void>> {
const { freshFactor, proofSource } = options;
@@ -14,6 +14,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
session: { findUnique: vi.fn(), update: vi.fn() },
user: { findUnique: vi.fn() },
webauthnMfaCredential: { count: vi.fn() },
passkey: { count: vi.fn() },
authChallenge: { findUnique: vi.fn() },
@@ -38,7 +39,10 @@ vi.mock("@/lib/auth/mfa/webauthn", () => ({
verifyMfaAuthentication: vi.fn(),
createMfaAuthenticationOptions: vi.fn(),
}));
vi.mock("@/lib/auth/mfa/verify-factor", () => ({ verifyMfaFactor: vi.fn() }));
vi.mock("@/lib/auth/mfa/verify-factor", () => ({
verifyMfaFactor: vi.fn(),
loadFactorUser: vi.fn(),
}));
vi.mock("@/lib/api-handler", () => ({
apiHandler: (fn: unknown) => fn,
requireCookieAuth: vi.fn(),
@@ -47,7 +51,7 @@ vi.mock("@/lib/api-handler", () => ({
import { prisma } from "@/lib/db";
import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit";
import { verifyPassword } from "@/lib/auth/password";
import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
import { loadFactorUser, verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
import { createRegistrationOptions } from "@/lib/auth/passkey";
import { requireCookieAuth } from "@/lib/api-handler";
import {
@@ -79,6 +83,21 @@ const ENROLLED: {
};
const PLAIN = { ...ENROLLED, totpSecretEncrypted: null, totpConfirmedAt: null };
/**
* The account as the request sees it: its secrets live in the stored row,
* which the verifiers read for themselves, and not on the session user.
*/
function stored(user: typeof ENROLLED | typeof PLAIN) {
vi.mocked(prisma.user.findUnique).mockResolvedValue(user as never);
vi.mocked(loadFactorUser).mockResolvedValue(user as never);
return {
id: user.id,
username: user.username,
email: user.email,
totpConfirmedAt: user.totpConfirmedAt,
};
}
function req(body?: unknown): Request {
return new Request("http://localhost/api/auth/me/mfa/totp/setup", {
method: "POST",
@@ -101,7 +120,7 @@ function session(stamps: {
function gate(user: typeof ENROLLED | typeof PLAIN, request: Request) {
return checkCookieEnrollmentProof({
user: user as never,
user: stored(user),
sessionId: "sess-1",
request,
ipAddress: "203.0.113.9",
@@ -198,7 +217,7 @@ describe("POST /api/auth/passkey/register-options", () => {
it("refuses a password on an account with a second factor, before verifying it", async () => {
vi.mocked(requireCookieAuth).mockResolvedValue({
user: ENROLLED,
user: stored(ENROLLED),
session: { id: "sess-1" },
} as never);
const res = await PASSKEY_REGISTER_OPTIONS(
@@ -214,7 +233,7 @@ describe("POST /api/auth/passkey/register-options", () => {
it("still takes a password on an account without one", async () => {
vi.mocked(requireCookieAuth).mockResolvedValue({
user: PLAIN,
user: stored(PLAIN),
session: { id: "sess-1" },
} as never);
vi.mocked(verifyPassword).mockResolvedValue(true);
@@ -236,7 +255,7 @@ describe("changing the email address", () => {
opts: { cookie?: boolean; password?: string | null } = {},
) {
return authorizeSensitiveChange({
user: user as never,
user: stored(user),
cookieSessionId: opts.cookie === false ? null : "sess-1",
currentPassword: opts.password ?? null,
ipAddress: "203.0.113.9",
+3 -2
View File
@@ -14,9 +14,9 @@
* HTTP path keeps its existing `auth.bearer.*` audit trail), so this layer adds
* no I/O beyond the two reads the validation needs.
*/
import type { User } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { hashToken } from "@/lib/auth/hmac";
import { SESSION_USER_SELECT, type SessionUser } from "@/lib/auth/session-user";
/** Why a Bearer token was rejected. Stable handles for audit details. */
export type BearerAuthReason =
@@ -67,7 +67,7 @@ export class BearerAuthError extends Error {
/** The validated identity behind a Bearer token. */
export interface BearerResolution {
user: User;
user: SessionUser;
/** The `ApiToken` row id (the token-id half of the `<user>:<token>` binding). */
tokenId: string;
/** The token's granted scopes (`["*"]` = wildcard). */
@@ -147,6 +147,7 @@ export async function resolveBearerToken(
const user = await prisma.user.findUnique({
where: { id: apiToken.userId },
select: SESSION_USER_SELECT,
});
if (!user) {
+18 -22
View File
@@ -16,7 +16,6 @@
* (same key, so its counter carries straight over). A shared bucket means a
* guesser cannot multiply the budget by rotating between routes.
*/
import type { User } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { apiError } from "@/lib/api-response";
import { REPROOF_REQUIRED_CODE } from "@/lib/api-errors";
@@ -34,7 +33,7 @@ import {
import { verifyPassword } from "@/lib/auth/password";
import { verifyAuthentication } from "@/lib/auth/passkey";
import { verifyMfaAuthentication } from "@/lib/auth/mfa/webauthn";
import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
import { loadFactorUser, verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
import {
stepUpMintSchema,
type StepUpMintRequest,
@@ -93,6 +92,9 @@ export async function refundReproof(userId: string): Promise<void> {
export type ProofOutcome = { ok: true } | { ok: false; reason: string };
/** The caller a proof is checked for: who they are and whether TOTP is live. */
type ProofUser = { id: string; totpConfirmedAt: Date | null };
/**
* Verify a proof against the account. Never throws for a bad proof: a
* malformed or expired WebAuthn challenge is a failed attempt, not a 500.
@@ -102,24 +104,25 @@ export type ProofOutcome = { ok: true } | { ok: false; reason: string };
* A TOTP code is accepted only from a confirmed secret (`verifyMfaFactor`).
*/
export async function verifyExistingFactorProof(
user: Pick<
User,
| "id"
| "passwordHash"
| "totpSecretEncrypted"
| "totpLastStep"
| "totpConfirmedAt"
>,
user: ProofUser,
proof: ExistingFactorProof,
): Promise<ProofOutcome> {
// The secrets are read here, for this one check: the caller's row is the
// per-request projection, which carries none of them.
if (proof.method === "password") {
if (!user.passwordHash) return { ok: false, reason: "no_password" };
const ok = await verifyPassword(user.passwordHash, proof.password);
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { passwordHash: true },
});
if (!row?.passwordHash) return { ok: false, reason: "no_password" };
const ok = await verifyPassword(row.passwordHash, proof.password);
return ok ? { ok } : { ok: false, reason: "bad_password" };
}
if (proof.method === "totp") {
const result = await verifyMfaFactor(user, "totp", proof.code);
const factorUser = await loadFactorUser(user.id);
if (!factorUser) return { ok: false, reason: "bad_totp" };
const result = await verifyMfaFactor(factorUser, "totp", proof.code);
if (result.ok) return { ok: true };
return { ok: false, reason: result.replay ? "totp_replay" : "bad_totp" };
}
@@ -230,7 +233,7 @@ export const RECENT_PROOF_MAX_AGE_MS = 5 * 60 * 1000;
* throttled and audited through the shared re-proof bucket.
*/
export async function checkCookieEnrollmentProof(args: {
user: User;
user: ProofUser;
sessionId: string;
request: Request;
ipAddress: string | null;
@@ -373,14 +376,7 @@ export type SensitiveChangeProof =
* of such an account; the shipped app does not change it.
*/
export async function authorizeSensitiveChange(args: {
user: Pick<
User,
| "id"
| "passwordHash"
| "totpSecretEncrypted"
| "totpLastStep"
| "totpConfirmedAt"
>;
user: ProofUser;
/** The session row id on a cookie request; null on a Bearer request. */
cookieSessionId: string | null;
currentPassword: string | null;
+22
View File
@@ -36,6 +36,28 @@ export interface FactorUser {
totpConfirmedAt: Date | null;
}
/**
* Load the factor columns for one check, at the moment of the check.
*
* The per-request user (`SESSION_USER_SELECT`) carries no secret material, so
* a route that verifies a factor for an already-authenticated caller reads the
* secret and its replay floor here. Reading them fresh is also the right
* answer for the floor: it is the value the guarded update below compares.
*/
export async function loadFactorUser(
userId: string,
): Promise<FactorUser | null> {
return prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
totpSecretEncrypted: true,
totpLastStep: true,
totpConfirmedAt: true,
},
});
}
export interface FactorResult {
ok: boolean;
/** TOTP only: the code matched the secret but its step was already used. */
+9 -4
View File
@@ -1,4 +1,3 @@
import type { User } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
/**
@@ -53,19 +52,25 @@ export type RecentProofMethod = "password" | "totp" | "webauthn" | "passkey";
* endpoint cannot disagree.
*/
export async function recentProofMethods(
user: Pick<User, "id" | "totpConfirmedAt" | "passwordHash">,
user: { id: string; totpConfirmedAt: Date | null },
enrolled?: boolean,
): Promise<RecentProofMethod[]> {
const hasSecond = enrolled ?? (await hasSecondFactorEnrolled(user));
const [keys, passkeys] = await Promise.all([
// Whether a password exists is read here rather than off the caller's row:
// the hash is not part of the per-request user (`SESSION_USER_SELECT`).
const [keys, passkeys, credential] = await Promise.all([
prisma.webauthnMfaCredential.count({ where: { userId: user.id } }),
prisma.passkey.count({ where: { userId: user.id } }),
prisma.user.findUnique({
where: { id: user.id },
select: { passwordHash: true },
}),
]);
const methods: RecentProofMethod[] = [];
if (hasSecond) {
if (user.totpConfirmedAt) methods.push("totp");
if (keys > 0) methods.push("webauthn");
} else if (user.passwordHash) {
} else if (credential?.passwordHash) {
methods.push("password");
}
if (passkeys > 0) methods.push("passkey");
+126
View File
@@ -0,0 +1,126 @@
import type { Prisma } from "@/generated/prisma/client";
/**
* The columns of `User` that ride on every authenticated request.
*
* Every cookie session and every Bearer token resolves its caller through one
* of the lookups in `session.ts` / `bearer.ts`, so whatever this projection
* names is read once per request, for every request the app serves. The row it
* replaced was the whole `users` record, and that record carries a profile
* picture of up to a few megabytes, the cached briefing text, the encrypted
* home location and air-quality history, and every stored integration
* credential. None of that is needed to answer "who is calling", and all of it
* was copied into a fresh buffer on each request.
*
* So the projection is an allowlist: small scalars that the request path reads
* (identity, role, locale and display preferences, the onboarding and MFA
* flags, the small preference documents). Anything else is read by the one
* route that needs it, with its own `select`, at the moment it needs it:
*
* - bytes and large text: `avatarBytes`, `insightsCachedText`,
* `insightsCachedTextEncrypted`, `homeLocationEncrypted`,
* `environmentAqHistoryJson`, `dawarichPrefsEncrypted`;
* - credentials and secrets: `passwordHash`, `totpSecretEncrypted`,
* `totpLastStep`, `telegramBotToken`, every `*Encrypted` integration and
* provider key, `insuranceNumberEncrypted`.
*
* `src/__tests__/session-user-projection-guard.test.ts` holds both halves: no
* caller lookup may load the full row, and none of the columns above may join
* this list.
*/
export const SESSION_USER_SELECT = {
id: true,
username: true,
email: true,
role: true,
oidcIssuer: true,
oidcSub: true,
createdAt: true,
updatedAt: true,
managedProfileAt: true,
heightCm: true,
dateOfBirth: true,
gender: true,
timezone: true,
homeTimezone: true,
homeSince: true,
environmentAirQualityEnabled: true,
codexTokenExpiresAt: true,
codexConnectedAt: true,
codexConnectionStatus: true,
useCentralCodex: true,
insightsPrivacyMode: true,
insightsCachedAt: true,
insightsCachedLocale: true,
insightsSnapshotHash: true,
insightsWarmFailedAt: true,
insightsBriefingRerollDate: true,
morningDigestRefreshedOn: true,
telegramChatId: true,
telegramEnabled: true,
nightscoutAllowPrivateHost: true,
dawarichAllowPrivateHost: true,
ouraScopeDenied: true,
stravaAthleteId: true,
stravaLastActivityAt: true,
stravaBackfillCompletedAt: true,
stravaBackfillResumeBefore: true,
locale: true,
thresholdsJson: true,
dashboardWidgetsJson: true,
insightsLayoutJson: true,
medicationListLayoutJson: true,
documentsLayoutJson: true,
moodTagLayoutJson: true,
glucoseUnit: true,
hasDiabetes: true,
labsLocalOcrEnabled: true,
unitPreference: true,
timeFormat: true,
dateFormat: true,
aiProvider: true,
aiModel: true,
aiBaseUrl: true,
aiCompatBaseUrl: true,
aiCompatModel: true,
aiProviderChain: true,
aiResponseTimeoutSeconds: true,
documentsAutoAiRead: true,
onboardingCompletedAt: true,
onboardingTourCompleted: true,
onboardingTourProgressJson: true,
disclaimerAcknowledgedAt: true,
disclaimerAcknowledgedVersion: true,
healthKitConfigJson: true,
healthKitLastSyncedAt: true,
healthKitLastSyncTrigger: true,
healthKitLastBackgroundSyncAt: true,
displayName: true,
lastReportPracticeName: true,
coachPrefsJson: true,
sourcePriorityJson: true,
lastSyncedAt: true,
syncResetAt: true,
moodReminderEnabled: true,
reportSelectionJson: true,
notificationPrefs: true,
disableCoach: true,
aiUsageLevel: true,
modulePreferencesJson: true,
healthScoreConfigJson: true,
documentQuotaBytes: true,
coachLastSeenAt: true,
avatarContentType: true,
avatarUpdatedAt: true,
totpConfirmedAt: true,
mfaEnforced: true,
passkeyUpgradeNudgeDismissed: true,
fullName: true,
insurerName: true,
insurerIkNumber: true,
} as const satisfies Prisma.UserSelect;
/** The caller as every authenticated request sees them. */
export type SessionUser = Prisma.UserGetPayload<{
select: typeof SESSION_USER_SELECT;
}>;
+9 -6
View File
@@ -1,8 +1,8 @@
import { randomBytes } from "node:crypto";
import { prisma } from "@/lib/db";
import { cookies } from "next/headers";
import type { User } from "@/generated/prisma/client";
import { hashToken } from "@/lib/auth/hmac";
import { SESSION_USER_SELECT, type SessionUser } from "@/lib/auth/session-user";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { getEvent } from "@/lib/logging/context";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
@@ -99,13 +99,13 @@ async function findSessionByCookie(cookieValue: string) {
if (cookieValue.startsWith(SESSION_TOKEN_PREFIX)) {
return await prisma.session.findUnique({
where: { tokenHash: hashToken(cookieValue) },
include: { user: true },
include: { user: { select: SESSION_USER_SELECT } },
});
}
const legacy = await prisma.session.findUnique({
where: { id: cookieValue },
include: { user: true },
include: { user: { select: SESSION_USER_SELECT } },
});
// A row that already holds a secret has retired its id as a credential.
return legacy && legacy.tokenHash === null ? legacy : null;
@@ -148,7 +148,10 @@ async function findSessionByCookie(cookieValue: string) {
*/
export async function validateSessionFromCookieValue(
cookieValue: string | undefined | null,
): Promise<{ session: { id: string; expiresAt: Date }; user: User } | null> {
): Promise<{
session: { id: string; expiresAt: Date };
user: SessionUser;
} | null> {
if (!cookieValue) return null;
const session = await findSessionByCookie(cookieValue);
if (!session || session.expiresAt < new Date()) return null;
@@ -179,7 +182,7 @@ export async function validateSessionWithCreatedAt(
cookieValue: string | undefined | null,
): Promise<{
session: { id: string; createdAt: Date; expiresAt: Date };
user: User;
user: SessionUser;
} | null> {
if (!cookieValue) return null;
const session = await findSessionByCookie(cookieValue);
@@ -296,7 +299,7 @@ export async function getSession(): Promise<{
actingAsUserId: string | null;
recordEpoch: number;
};
user: User;
user: SessionUser;
} | null> {
try {
await ensureDbCompatibility();
+11 -4
View File
@@ -26,7 +26,7 @@
* on `MEASUREMENT_ANOMALY`, never here. It fires ONLY inside a local morning
* window, which is the quiet-hours guarantee for this nudge.
*/
import type { User } from "@/generated/prisma/client";
import { SESSION_USER_SELECT, type SessionUser } from "@/lib/auth/session-user";
import type { PrismaClient } from "@/generated/prisma/client";
import { annotate, getEvent } from "@/lib/logging/context";
@@ -82,11 +82,15 @@ export type DailyBriefingDispatchResult =
export interface DailyBriefingDispatchDeps {
dispatch?: typeof dispatchNotification;
loadDigest?: (user: User, now: Date, locale: Locale) => Promise<DailyDigest>;
loadDigest?: (
user: SessionUser,
now: Date,
locale: Locale,
) => Promise<DailyDigest>;
}
function loadDailyBriefingDigest(
user: User,
user: SessionUser,
now: Date,
locale: Locale,
): Promise<DailyDigest> {
@@ -181,7 +185,10 @@ export async function maybeDispatchDailyBriefing(
const loadDigest = deps.loadDigest ?? loadDailyBriefingDigest;
try {
const user = await prisma.user.findUnique({ where: { id: userId } });
const user = await prisma.user.findUnique({
where: { id: userId },
select: SESSION_USER_SELECT,
});
if (!user) {
annotate({ action: { name: "daily.briefing_push.missing_user" } });
return "missing-user";
+2 -2
View File
@@ -12,7 +12,7 @@
* module-gated at the build layer) plus the resolved module map the item
* builders consult.
*/
import type { User } from "@/generated/prisma/client";
import type { SessionUser } from "@/lib/auth/session-user";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
@@ -547,7 +547,7 @@ export interface DailyDigestLoadOptions {
}
export async function loadDailyDigest(
user: User,
user: SessionUser,
now: Date = new Date(),
options: DailyDigestLoadOptions = {},
): Promise<DailyDigest> {
@@ -11,7 +11,7 @@
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { User } from "@/generated/prisma/client";
import type { SessionUser } from "@/lib/auth/session-user";
import {
AI_AVAILABLE,
aiUnavailable,
@@ -29,7 +29,19 @@ const briefingBody = {
const buildDashboardSnapshot = vi.fn(async () => briefingBody);
const aiCapabilityToServe = vi.fn();
vi.mock("@/lib/db", () => ({ prisma: {} }));
// Generated a moment ago, so the today rule keeps it and these tests see
// only the capability decision. Read off its own row, not the session user.
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: async () => ({
insightsCachedText: JSON.stringify({
briefingGeneratedAt: new Date().toISOString(),
}),
}),
},
},
}));
vi.mock("@/lib/dashboard/snapshot", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/dashboard/snapshot")>()),
buildDashboardSnapshot: () => buildDashboardSnapshot(),
@@ -44,16 +56,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({
const { readDashboardSnapshotCached } = await import("../snapshot-read");
const { __resetAllCachesForTests } = await import("@/lib/cache/server-cache");
// Generated a moment ago, so the today rule keeps it and these tests see
// only the capability decision.
const USER = {
id: "user-briefing-gate",
locale: "en",
timezone: "UTC",
insightsCachedText: JSON.stringify({
briefingGeneratedAt: new Date().toISOString(),
}),
} as unknown as User;
} as unknown as SessionUser;
beforeEach(() => {
__resetAllCachesForTests();
@@ -5,7 +5,7 @@
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { User } from "@/generated/prisma/client";
import type { SessionUser } from "@/lib/auth/session-user";
import {
AI_AVAILABLE,
aiUnavailable,
@@ -47,7 +47,13 @@ const body = (pulseLastSeen: string) => ({
let current = body("2026-10-04T06:50:00.000Z");
const buildDashboardSnapshot = vi.fn(async () => current);
vi.mock("@/lib/db", () => ({ prisma: {} }));
// The cached briefing text is read off its own row, not the session user.
const cacheRow = vi.hoisted(() => ({
current: null as { insightsCachedText: string | null } | null,
}));
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: async () => cacheRow.current } },
}));
vi.mock("@/lib/dashboard/snapshot", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/dashboard/snapshot")>()),
buildDashboardSnapshot: () => buildDashboardSnapshot(),
@@ -72,16 +78,18 @@ const { __resetAllCachesForTests } = await import("@/lib/cache/server-cache");
/** 2026-10-05 21:00 in Berlin. */
const NOW = new Date("2026-10-05T19:00:00.000Z");
function user(generatedAt: string | null): User {
return {
id: "user-briefing-today",
locale: "en",
timezone: "Europe/Berlin",
function user(generatedAt: string | null): SessionUser {
cacheRow.current = {
insightsCachedText: JSON.stringify({
dailyBriefing: BRIEFING,
...(generatedAt ? { briefingGeneratedAt: generatedAt } : {}),
}),
} as unknown as User;
};
return {
id: "user-briefing-today",
locale: "en",
timezone: "Europe/Berlin",
} as unknown as SessionUser;
}
beforeEach(() => {
@@ -14,12 +14,14 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { User } from "@/generated/prisma/client";
import type { SessionUser } from "@/lib/auth/session-user";
const buildDashboardSnapshot = vi.fn(async () => ({ tiles: {} }));
const resolveServerLocale = vi.fn(async () => "de");
vi.mock("@/lib/db", () => ({ prisma: {} }));
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: async () => null } },
}));
vi.mock("@/lib/dashboard/snapshot", () => ({
buildDashboardSnapshot: (...args: unknown[]) =>
buildDashboardSnapshot(...(args as [])),
@@ -46,7 +48,7 @@ const {
invalidateUserMedications,
} = await import("@/lib/cache/invalidate");
const USER = { id: "user-key-guard", locale: "de" } as unknown as User;
const USER = { id: "user-key-guard", locale: "de" } as unknown as SessionUser;
beforeEach(() => {
__resetAllCachesForTests();
+25 -15
View File
@@ -21,7 +21,7 @@
* that moves the registry back off the global breaks this paragraph, not
* just a performance assumption.
*/
import type { User } from "@/generated/prisma/client";
import type { SessionUser } from "@/lib/auth/session-user";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
@@ -41,7 +41,10 @@ import { briefingForToday } from "@/lib/daily/briefing-today";
import { requestTodayBriefingWarm } from "@/lib/daily/briefing-today-warm";
import { readBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at";
import { userDayKey } from "@/lib/tz/format";
import { openInsightsCachedText } from "@/lib/insights/cached-text-cipher";
import {
INSIGHTS_CACHE_SELECT,
openInsightsCachedText,
} from "@/lib/insights/cached-text-cipher";
/**
* Per-key TTL for the snapshot cache entry. Strictly greater than the
@@ -118,12 +121,29 @@ export interface SnapshotReadOptions {
* timing surface; the RSC prefetch passes none.
*/
export async function readDashboardSnapshotCached(
user: User,
user: SessionUser,
time?: <T>(label: string, builder: () => Promise<T>) => Promise<T>,
options: SnapshotReadOptions = {},
): Promise<SnapshotReadResult> {
// `User` row is already in hand at both call sites — no extra round-trip.
const cachedText = openInsightsCachedText(user);
// The cached briefing text is not on the per-request user
// (`SESSION_USER_SELECT`), so it is read here, beside the locale: every
// read needs it for the generated-at stamp below, not only a cache miss.
const [cacheRow, locale] = await Promise.all([
prisma.user.findUnique({
where: { id: user.id },
select: INSIGHTS_CACHE_SELECT,
}),
// v1.21.2 — the briefing recall + forward-look is locale-specific
// prose, so the snapshot cache key carries the resolved locale: an EN and
// a DE session never share a snapshot cell (and never see each other's
// memory wording). The `${user.id}|` prefix still covers the key under
// the measurement-write stale-sweep.
resolveServerLocale({
userLocale: user.locale,
override: options.locale ?? null,
}),
]);
const cachedText = openInsightsCachedText(cacheRow);
const snapshotUser: SnapshotUserInput = {
id: user.id,
username: user.username,
@@ -143,16 +163,6 @@ export async function readDashboardSnapshotCached(
healthScoreConfigJson: user.healthScoreConfigJson,
};
// v1.21.2 — the briefing recall + forward-look is locale-specific
// prose, so the snapshot cache key carries the resolved locale: an EN and
// a DE session never share a snapshot cell (and never see each other's
// memory wording). The `${user.id}|` prefix still covers the key under
// the measurement-write stale-sweep.
const locale = await resolveServerLocale({
userLocale: user.locale,
override: options.locale ?? null,
});
// Stale-while-revalidate: a measurement write marks the analytics
// bucket stale rather than hard-evicting the snapshot, so a busy iOS
// sync serves the prior snapshot immediately (within the bucket's
+77
View File
@@ -17,6 +17,7 @@
*/
import { Prisma, type PrismaClient } from "@/generated/prisma/client";
import type { MeasurementType } from "@/generated/prisma/client";
import { ROLLUP_FOLD_WINDOW_MS } from "@/lib/rollups/measurement-rollups";
/** v1.4.38 — the single most recent reading per measurement
* type, irrespective of age. One row per metric the user has ever
@@ -166,6 +167,82 @@ export function readSparkBuckets(
`;
}
/** One metric's all-time reading count, for a type that has any. */
export interface AllTimeCountRow {
type: MeasurementType;
count: number;
}
/**
* The first UTC day the DAY rollups are trusted to hold in full: one day
* inside the fold window. Every fold that ever ran for the account began at
* or before it (its window was anchored on an earlier `now`), and the write
* hooks keep every later day current.
*/
export function allTimeRollupBoundary(now: Date): Date {
const DAY_MS = 24 * 60 * 60 * 1000;
const start = now.getTime() - ROLLUP_FOLD_WINDOW_MS;
return new Date(Math.floor(start / DAY_MS) * DAY_MS + DAY_MS);
}
/**
* Total live readings per measurement type, all time.
*
* Counting them on `measurements` reads one index entry per reading: on an
* account with a watch that is millions of entries per request, two to four
* seconds whenever they are not in memory, which is the dashboard's slowest
* read by far. The DAY rollups already carry a count per (type, day, source)
* — every live reading of the day, every source — so the sum of a type's DAY
* buckets is its count over the days they cover. Readings before the fold
* window, which the fold never buckets, are counted live; that range is a
* small, bounded index slice. A type with no DAY bucket at all (not folded
* yet) is counted live in full, exactly as before. Types with no readings
* are absent.
*/
export async function readAllTimeCounts(
db: PrismaClient,
userId: string,
measurementTypes: readonly MeasurementType[],
now: Date = new Date(),
): Promise<AllTimeCountRow[]> {
const boundary = allTimeRollupBoundary(now);
const rows = await db.$queryRaw<
Array<{ type: MeasurementType; count: number }>
>`
SELECT wanted."type" AS type,
(CASE
WHEN EXISTS (
SELECT 1 FROM measurement_rollups r
WHERE r."user_id" = ${userId}
AND r."type" = wanted."type"
AND r."granularity" = 'DAY'
)
THEN COALESCE((
SELECT SUM(r."count") FROM measurement_rollups r
WHERE r."user_id" = ${userId}
AND r."type" = wanted."type"
AND r."granularity" = 'DAY'
AND r."bucket_start" >= ${boundary}
), 0) + (
SELECT COUNT(*) FROM measurements m
WHERE m."user_id" = ${userId}
AND m."type" = wanted."type"
AND m."deleted_at" IS NULL
AND m."measured_at" < ${boundary}
)
ELSE (
SELECT COUNT(*) FROM measurements m
WHERE m."user_id" = ${userId}
AND m."type" = wanted."type"
AND m."deleted_at" IS NULL
)
END)::int AS count
FROM unnest(${[...measurementTypes]}::"measurement_type"[]) AS wanted("type")
ORDER BY wanted."type"
`;
return rows.filter((row) => row.count > 0);
}
/**
* The local days in `[since, now]` that hold at least one live reading, as
* `YYYY-MM-DD` keys in `timeZone`: the measurement half of the streak.
+2
View File
@@ -348,6 +348,8 @@ export const INSTANCE_SCOPED: Readonly<Record<string, string>> = {
EncryptionKeyCanary:
"one known value per configured encryption key id, checked at boot; belongs to the instance's keys, not to any account",
RateLimit: "instance-wide rate-limit buckets, keyed by string, self-expiring",
OAuthCallbackClaim:
"single-use claims on in-flight OAuth callbacks, keyed on a hash of the state, minutes-lived and swept by expiry; no user id",
HostMetric: "instance-wide host telemetry, not attributable to an account",
OffhostBackupKeyUse:
"per encryption key, when the nightly off-host run last wrote an object needing it; says nothing about any account",
+2 -4
View File
@@ -18,7 +18,7 @@ import { getEvent } from "@/lib/logging/context";
import { dropImplausibleMeasurements } from "@/lib/measurements/plausibility-gate";
import {
collapseToTypeDayKeys,
recomputeBucketsForMeasurement,
recomputeBucketsForKeys,
} from "@/lib/rollups/measurement-rollups";
import { refreshAccessToken } from "./client";
import { getUserFitbitCredentials } from "./credentials";
@@ -894,9 +894,7 @@ export async function upsertFitbitMeasurements(
try {
const keys = collapseToTypeDayKeys(touched);
for (const k of keys) {
await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt);
}
await recomputeBucketsForKeys(userId, keys);
invalidateStatusInsightsForTypes(
userId,
keys.map((k) => k.type),
+2 -4
View File
@@ -41,7 +41,7 @@ import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit
import { recordSyncFailure, type FailureKind } from "@/lib/integrations/status";
import {
collapseToTypeDayKeys,
recomputeBucketsForMeasurement,
recomputeBucketsForKeys,
} from "@/lib/rollups/measurement-rollups";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import {
@@ -1587,9 +1587,7 @@ export async function upsertGoogleHealthMeasurements(
try {
const keys = collapseToTypeDayKeys(touched);
for (const k of keys) {
await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt);
}
await recomputeBucketsForKeys(userId, keys);
invalidateStatusInsightsForTypes(
userId,
keys.map((k) => k.type),
+163 -43
View File
@@ -17,11 +17,17 @@ import {
foldMeanSql,
isHourlyMeanTypeSql,
} from "@/lib/measurements/day-mean";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import {
loadUserSourcePriority,
utcBucketEnd,
} from "@/lib/rollups/measurement-read";
import { classifyAgainstEffectiveRange } from "@/lib/labs/reference-range";
import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day";
import { resolveLabFields } from "@/lib/labs/serialise";
import { readRollupBuckets } from "@/lib/rollups/measurement-rollups";
import {
readRollupBuckets,
ROLLUP_FOLD_WINDOW_MS,
} from "@/lib/rollups/measurement-rollups";
import { deriveBucketedTypes } from "@/lib/signals/adapters/correlation";
import type {
AggregatedFeatures,
@@ -40,11 +46,10 @@ interface AllTimeExtremes {
}
/**
* v1.18.11 — full-history min / max / mean per measurement type via ONE
* grouped SQL aggregation, with NO row materialisation in JS. Fills the
* `allTime*` feature fields; the windowed `summarize()` covers trends + recent
* windows, and this covers the long-horizon figures the prompt labels
* "allTime".
* v1.18.11 — full-history min / max / mean per measurement type, folded in
* SQL with NO row materialisation in JS. Fills the `allTime*` feature fields;
* the windowed `summarize()` covers trends + recent windows, and this covers
* the long-horizon figures the prompt labels "allTime".
*
* One source per (type, day): each source is folded per day first, each day
* keeps its ladder-canonical source, then the days fold into the figures —
@@ -52,6 +57,26 @@ interface AllTimeExtremes {
* row let a device that samples densely (a watch's pulse) outweigh the
* readings the source ladder prefers on the same days.
*
* Where the days come from. A day is the same per-source fold either way —
* count, sum, the extremes of the readings and, for an hourly-mean type, the
* day value — and the DAY rollup tier already holds exactly that per (type,
* source, day), recomputed on every write. Reading the raw rows instead meant
* two passes over every reading of these types the account ever recorded on
* every call, the largest read on the database. So the figures fold:
*
* - the DAY tier, for every whole UTC day from `tierFrom` on, for each type
* whose tier rows in that range all carry the reading extremes
* (migration 0417; the boot re-fold fills older rows) and account for
* the live record day by day (same days, same reading counts);
* - the raw rows for the rest: the days before `tierFrom` (older than the
* fold window, or the partial day the `floor` falls in), and every day of
* a type the tier does not cover yet or no longer matches.
*
* The split is at a UTC midnight, so no day is folded from both, and the
* canonical pick per day is the same on both sides. The tier's pulse day
* value follows the stored day-mean method; the raw fold follows this
* process's mirror of it, which trails a change by at most a minute.
*
* Only the four types that expose `allTime*` fields are aggregated (weight,
* systolic, diastolic, pulse). Returns a map keyed by `MeasurementType`; a type
* with no rows is simply absent.
@@ -67,15 +92,116 @@ export async function readAllTimeExtremes(
): Promise<Map<MeasurementType, AllTimeExtremes>> {
const out = new Map<MeasurementType, AllTimeExtremes>();
if (types.length === 0) return out;
// The first whole UTC day the tier may answer: after the fold window's
// edge (the fold writes nothing older, and the edge day may be partial)
// and after the floor's own day (only part of it is inside the floor).
const windowEdge = new Date(Date.now() - ROLLUP_FOLD_WINDOW_MS);
const tierFrom = utcBucketEnd(
floor !== null && floor > windowEdge ? floor : windowEdge,
"DAY",
);
const typeList = (list: readonly MeasurementType[]) =>
Prisma.join(list.map((t) => Prisma.sql`${t}::measurement_type`));
const [priorityJson, tierRows] = await Promise.all([
loadUserSourcePriority(userId),
// A type is read from the tier only when the tier accounts for the live
// record day by day: every day with a live reading has DAY rows whose
// counts add up to that day's live count, and every DAY row has its day
// of live readings. A bucket that outlived its rows (a hard delete with no
// rollup recompute) or a day the tier never got fails the comparison and
// the type is read raw. The figures from here are the anchor the Coach
// snapshot checks its coarse history against, so they must never repeat
// a stale bucket. The live side is a count per day off the partial
// `(user_id, type, measured_at)` live index, not a read of the rows.
prisma.$queryRaw<Array<{ type: string }>>`
WITH live AS (
SELECT m."type", date_trunc('day', m."measured_at") AS day,
COUNT(*)::bigint AS n
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."deleted_at" IS NULL
AND m."type" IN (${typeList(types)})
AND m."measured_at" >= ${tierFrom}
GROUP BY 1, 2
),
tier AS (
SELECT r."type", r."bucket_start" AT TIME ZONE 'UTC' AS day,
SUM(r."count")::bigint AS n,
bool_and(
r."reading_min_value" IS NOT NULL
AND r."reading_max_value" IS NOT NULL
) AS complete
FROM measurement_rollups r
WHERE r."user_id" = ${userId}
AND r."granularity" = 'DAY'
AND r."type" IN (${typeList(types)})
AND r."bucket_start" >= ${tierFrom}
GROUP BY 1, 2
)
SELECT COALESCE(l."type", t."type")::text AS type
FROM live l
FULL JOIN tier t ON t."type" = l."type" AND t.day = l.day
GROUP BY 1
HAVING bool_and(COALESCE(l.n = t.n AND t.complete, false))
`,
]);
const tierTypes = new Set(tierRows.map((r) => r.type));
const fromTier = types.filter((t) => tierTypes.has(t));
const rawOnly = types.filter((t) => !tierTypes.has(t));
const since =
floor === null ? Prisma.empty : Prisma.sql`AND m."measured_at" >= ${floor}`;
const priorityJson = await loadUserSourcePriority(userId);
const rank = Prisma.raw(
buildSourceRankCase(priorityJson, 'p."type"', 'p."source"'),
);
const typeList = Prisma.join(
types.map((t) => Prisma.sql`${t}::measurement_type`),
);
const rawArms: Prisma.Sql[] = [];
if (rawOnly.length > 0) {
rawArms.push(Prisma.sql`
SELECT m."type", m."source", m."measured_at", m."value"
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."deleted_at" IS NULL
AND m."type" IN (${typeList(rawOnly)})
${since}`);
}
if (fromTier.length > 0) {
rawArms.push(Prisma.sql`
SELECT m."type", m."source", m."measured_at", m."value"
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."deleted_at" IS NULL
AND m."type" IN (${typeList(fromTier)})
${since}
AND m."measured_at" < ${tierFrom}`);
}
const rank = (alias: string) =>
Prisma.raw(
buildSourceRankCase(priorityJson, `${alias}."type"`, `${alias}."source"`),
);
// The tier's canonical days, in the shape of the raw fold's: the day's
// reading-weighted sum and count, its reading extremes, and its day value
// with a weight of one (a DAY row's `mean` is the day value for an
// hourly-mean type).
const tierCanon =
fromTier.length === 0
? Prisma.empty
: Prisma.sql`
UNION ALL
SELECT * FROM (
SELECT DISTINCT ON (r."type", r."bucket_start")
r."type",
r."count" AS cnt,
COALESCE(r."sum_value", r."count" * r."mean")::double precision
AS total,
r."reading_min_value" AS min_value,
r."reading_max_value" AS max_value,
r."mean" AS wsum,
1.0::double precision AS wdays
FROM measurement_rollups r
WHERE r."user_id" = ${userId}
AND r."granularity" = 'DAY'
AND r."type" IN (${typeList(fromTier)})
AND r."bucket_start" >= ${tierFrom}
ORDER BY r."type", r."bucket_start", (${rank("r")}), r."source"::text
) tier`;
// A day of pulse is the mean of its hours' means and the full history the
// mean of its days (`day-mean.ts`). `weighted` carries, per source and day,
// the day-weighted sum and the day's weight (one) for those types only;
@@ -92,14 +218,10 @@ export async function readAllTimeExtremes(
const rows = await prisma.$queryRaw<
Array<{ type: string; mean: number; min: number; max: number }>
>`
WITH hourly_src AS (
SELECT m.*
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."deleted_at" IS NULL
AND m."type" IN (${typeList})
${since}
AND ${Prisma.raw(isHourlyMeanTypeSql('m."type"'))}
WITH live AS (${Prisma.join(rawArms, " UNION ALL ")}),
hourly_src AS (
SELECT * FROM live l
WHERE ${Prisma.raw(isHourlyMeanTypeSql('l."type"'))}
),
weighted AS (
SELECT
@@ -113,34 +235,32 @@ export async function readAllTimeExtremes(
),
per_source AS (
SELECT
m."type",
m."source",
date_trunc('day', m."measured_at") AS day,
l."type",
l."source",
date_trunc('day', l."measured_at") AS day,
COUNT(*)::int AS cnt,
SUM(m."value")::double precision AS total,
MIN(m."value")::double precision AS min_value,
MAX(m."value")::double precision AS max_value
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."deleted_at" IS NULL
AND m."type" IN (${typeList})
${since}
GROUP BY m."type", m."source", 3
),
canon AS (
SELECT DISTINCT ON (p."type", p.day)
p."type", p.cnt, p.total, p.min_value, p.max_value, w.wsum, w.wdays
FROM per_source p
LEFT JOIN weighted w
ON w."type" = p."type" AND w."source" = p."source" AND w.day = p.day
ORDER BY p."type", p.day, (${rank}), p."source"::text
SUM(l."value")::double precision AS total,
MIN(l."value")::double precision AS min_value,
MAX(l."value")::double precision AS max_value
FROM live l
GROUP BY l."type", l."source", 3
)
SELECT
c."type"::text AS type,
${mean}::double precision AS mean,
MIN(c.min_value)::double precision AS min,
MAX(c.max_value)::double precision AS max
FROM canon c
FROM (
SELECT * FROM (
SELECT DISTINCT ON (p."type", p.day)
p."type", p.cnt, p.total, p.min_value, p.max_value, w.wsum, w.wdays
FROM per_source p
LEFT JOIN weighted w
ON w."type" = p."type" AND w."source" = p."source" AND w.day = p.day
ORDER BY p."type", p.day, (${rank("p")}), p."source"::text
) raw
${tierCanon}
) c
GROUP BY c."type"
`;
for (const r of rows) {
+4 -3
View File
@@ -9,9 +9,10 @@
* - `oura_permission`: Oura is connected and the stored grant leaves out
* the scope its resilience level sits behind; one reconnect fixes it.
* - `oura_no_resilience`: Oura is connected and no level has arrived. The
* server cannot tell "the grant covers it" from "not recorded yet" (the
* scope note is cleared on every reconnect and written only by a sync
* that reached Oura), so this case never claims coverage.
* server cannot always tell "the grant covers it" from "not recorded yet"
* (the scope note is rewritten on every reconnect from the scopes Oura
* reports, when it reports them, and otherwise only by a sync that
* reached Oura), so this case never claims coverage.
* - `general`: anyone else.
*
* `null` means the page has something to chart.
+2 -1
View File
@@ -61,7 +61,8 @@ export async function persistRotatedToken(
return opts.readPeerAccessToken();
}
export type SerializedRefreshProvider = "whoop" | "withings";
export type SerializedRefreshProvider =
"whoop" | "withings" | "oura" | "codex" | "admin-codex";
/**
* Provider refreshes hold a database transaction open around one bounded
+12 -6
View File
@@ -342,21 +342,27 @@ export function classifyMetricFreshness(
return samples.map((sample) => {
// A type the app writes back to the phone (#1247): what is entered here
// goes to Apple Health and never arrives back, so a person who keeps
// their readings here would otherwise watch the type go quiet. The newer
// of the two is what says whether the type is still in use.
// their readings here would otherwise watch the type go quiet. That
// reading is in Apple Health too, so the newer of the two is the type's
// newest reading: it is what the row shows and what the verdict reads.
const written = enteredHere?.has(sample.type)
? { lastEnteredHereAt: enteredHere.get(sample.type) ?? null }
: {};
if (verdict !== "fresh" || SPARSE_METRIC_TYPES.has(sample.type)) {
return { ...sample, ...written, stale: false };
}
const seen = newest(
toMillis(sample.lastSeenAt),
toMillis(written.lastEnteredHereAt),
);
return {
const resolved = {
...sample,
...written,
lastSeenAt:
seen != null ? new Date(seen).toISOString() : sample.lastSeenAt,
};
if (verdict !== "fresh" || SPARSE_METRIC_TYPES.has(sample.type)) {
return { ...resolved, stale: false };
}
return {
...resolved,
stale: seen != null && nowMs - seen > METRIC_QUIET_AFTER_MS,
};
});
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff. Show more