mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
155 files changed
+6365
-1353
No files matched your search
@@ -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=""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -4657,7 +4657,7 @@
|
||||
"scoreCrossLink": "종합 회복 점수 보기",
|
||||
"emptyTitle": "아직 회복 데이터가 없어요",
|
||||
"empty": "이 페이지에는 WHOOP 또는 Polar의 회복, 부하, 훈련 부하 값과 Oura의 회복탄력성 수준이 표시돼요.",
|
||||
"emptyOuraPermission": "Oura의 회복탄력성 수준에는 심장 건강 데이터 접근 권한이 필요하니, 연동에서 Oura를 한 번 다시 연결하고 허용해 주세요.",
|
||||
"emptyOuraPermission": "Oura의 회복탄력성 수준에는 스트레스 데이터 권한이 필요하니, 연동에서 Oura를 한 번 다시 연결하고 허용해 주세요.",
|
||||
"emptyOuraNoResilience": "Oura가 연결되어 있으며, Oura가 회복탄력성 수준을 제공하는 즉시 여기에 표시돼요.",
|
||||
"emptyAction": "통합 열기",
|
||||
"block": {
|
||||
|
||||
+1
-1
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Generated
+11
-11
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() }));
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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() }));
|
||||
|
||||
|
||||
@@ -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!,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}>;
|
||||
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in new issue
Block a user