diff --git a/.env.production.example b/.env.production.example
index d9cab1cae..383a9e0b9 100644
--- a/.env.production.example
+++ b/.env.production.example
@@ -67,6 +67,17 @@ API_TOKEN_HMAC_KEY=""
# DEPLOY_WEBHOOK_SECRET=""
+# -----------------------------------------------------------------------------
+# TLS leaf pin monitor -- alarm when the pinned leaf certificate rotates
+# -----------------------------------------------------------------------------
+# The native client SPKI-pins the served TLS leaf. Set this to the
+# known-good leaf pin(s) so the monitor can alarm on a rotation. The value
+# is base64(sha256(DER subjectPublicKeyInfo)); comma-separate to hold both
+# the current and the next pin during a dual-pin renewal window. See
+# docs/ops/tls-cert-pin.md for extraction + the re-pin runbook.
+# TLS_LEAF_SPKI_PINS=""
+
+
# -----------------------------------------------------------------------------
# Off-host backups -- all-or-none
# -----------------------------------------------------------------------------
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ab4a98804..e8919edba 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,21 @@
## [Unreleased]
+## [1.12.2] — 2026-06-05 — WHOOP connect from the app, consistent assessments and medications
+
+### Added
+
+- **WHOOP can be connected from the native app.** The OAuth handshake now works for an app that holds no web session: the client obtains a short-lived one-time connect token and opens the WHOOP authorization in an in-app browser that returns straight to the app. (No change for the web flow.)
+
+### Changed
+
+- **One word for "assessment", everywhere.** The AI assessment was labelled four different ways across the app; it now reads consistently in every language, and the assessment is always the final block on a metric page.
+- **Every medication behaves the same when you log a dose.** Marking a GLP-1 dose taken now shows the same failure notice and one-tap undo that the other medication types already had, and medication status colours, the compliance percentage, and tap-target sizes are consistent across the cards.
+
+### Operations
+
+- **An alarm when the TLS certificate's public key changes.** Self-hosters whose native clients pin the server certificate now get a loud, logged alert (and an admin notification) when the served leaf key rotates, so the pin can be refreshed before it lapses. Set the expected pin(s) via `TLS_LEAF_SPKI_PINS`; see `docs/ops/tls-cert-pin.md`.
+
## [1.12.1] — 2026-06-05 — security, data-integrity, and insight-quality hardening
### Changed
diff --git a/docker-compose.yml b/docker-compose.yml
index 72fd5ebb4..5cc35e198 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -65,6 +65,13 @@ services:
# container (the `environment:` block is a whitelist).
WHOOP_WEBHOOK_SECRET: "${WHOOP_WEBHOOK_SECRET:-}"
WHOOP_REDIRECT_URI: "${WHOOP_REDIRECT_URI:-}"
+ # TLS leaf SPKI-change alarm baseline (optional). The native client
+ # pins the served TLS leaf; this is the known-good pin set the monitor
+ # alarms against on a rotation. Comma-separated for the dual-pin
+ # renewal window. Listed here so an operator value in .env reaches the
+ # container (the `environment:` block is a whitelist). See
+ # docs/ops/tls-cert-pin.md.
+ TLS_LEAF_SPKI_PINS: "${TLS_LEAF_SPKI_PINS:-}"
depends_on:
db:
condition: service_healthy
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 6482f423d..2c28933b7 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
- version: 1.12.1
+ version: 1.12.2
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
diff --git a/docs/ops/tls-cert-pin.md b/docs/ops/tls-cert-pin.md
new file mode 100644
index 000000000..bc2588e58
--- /dev/null
+++ b/docs/ops/tls-cert-pin.md
@@ -0,0 +1,127 @@
+# TLS leaf SPKI pin — coupling, alarm, and re-pin runbook
+
+The native client SPKI-pins the server's TLS **leaf** certificate. This is a
+deliberate hardening choice on the client side, but it couples the client to
+the exact leaf keypair the server serves: when that leaf rotates, a client
+that was only ever shipped the old pin refuses to connect. This page explains
+the coupling, the server-side alarm that watches for a rotation, and the
+operator / release-owner steps when it fires.
+
+## The coupling
+
+A SPKI pin is `base64(sha256(DER subjectPublicKeyInfo))` — a hash of the
+certificate's public key, not of the whole certificate. The native client
+carries a set of these pins and rejects any TLS connection whose leaf public
+key is not in the set.
+
+The app host's certificate is issued by Google Trust Services (GTS) and
+auto-renews roughly every 90 days. Each renewal mints a **new leaf keypair**,
+so the SPKI pin changes on every renewal. Intermediate and root rolls have
+the same effect on the leaf chain. Because the client pins the leaf, every
+such change is a hard outage for any client that hasn't been shipped the new
+pin — and there is otherwise no server-side signal that a client is pinning
+the served leaf at all.
+
+## The alarm
+
+A scheduled job (`src/lib/jobs/tls-pin-monitor.ts`) probes the served leaf
+every 6 hours and fails loudly when it changes:
+
+- It derives the public host from `APP_URL` (falling back to
+ `NEXT_PUBLIC_APP_URL`) and opens a raw TLS socket to it — observing exactly
+ the certificate a pinned client would, regardless of how the reverse proxy
+ terminates TLS.
+- It reads the served leaf, computes its SPKI pin
+ (`base64(sha256(DER subjectPublicKeyInfo))`), and compares it against the
+ operator's known-good set in `TLS_LEAF_SPKI_PINS`.
+- On a served pin that is **not** in the known set it emits a
+ `tls.pin.leaf_changed` wide-event annotation (old set + served pin), writes
+ a `system.tls.pin_changed` audit-log row, and fans out a high-priority
+ `SYSTEM_ALERT` to every admin user through the notification dispatcher
+ (Telegram / ntfy / Web Push / APNs, whichever the operator has wired).
+
+A transient TLS or network failure during the probe is annotated
+(`tls.pin.probe_failed`) and never alarms — the alarm fires only on a
+successful probe returning a pin genuinely outside the known set.
+
+### Baseline source: an env var, not auto-learning
+
+`TLS_LEAF_SPKI_PINS` (comma-separated) is the known-good set. The baseline is
+an env var on purpose, **not** a persisted "last-seen" row:
+
+- The pinned client only trusts the pins it was shipped, so the operator must
+ derive the client's pin set from a single explicit source of truth. The env
+ var **is** that source — the same value goes into the client's pin set and
+ into `TLS_LEAF_SPKI_PINS`.
+- A persisted last-seen baseline would silently adopt the first rotated pin
+ and suppress the very alarm the pinned client needs.
+- When the env var is unset, the monitor fails **loud, not open**: it logs
+ the served pin (so you can seed the baseline) and warns "not configured",
+ but never auto-adopts what it observed.
+
+## Setting the baseline
+
+Extract the current leaf SPKI pin from the served certificate:
+
+```sh
+openssl s_client -connect "$APP_HOST:443" -servername "$APP_HOST" /dev/null \
+ | openssl x509 -noout -pubkey \
+ | openssl pkey -pubin -outform der 2>/dev/null \
+ | openssl dgst -sha256 -binary \
+ | openssl base64
+```
+
+`$APP_HOST` is the host in your configured `APP_URL` (the host the client
+connects to). The output is the `base64(sha256(DER subjectPublicKeyInfo))`
+pin. Set it in the deployment env:
+
+```
+TLS_LEAF_SPKI_PINS=""
+```
+
+The variable is on the compose `environment:` whitelist and the
+`scripts/env-manifest.json` optional groups, so `pnpm check-env` will report
+whether it is set. It is optional: without it the monitor still runs and logs
+the served pin, but cannot alarm on a change.
+
+## When the alarm fires — re-pin procedure
+
+When you see a `tls.pin.leaf_changed` alert / `system.tls.pin_changed` audit
+row, the served leaf has rotated. The pinned client will stop connecting once
+its shipped pin no longer matches a served leaf. Act inside the certificate's
+remaining validity, with margin for app review and client roll-out — target
+**≥ 11 days before the old pin's certificate expires** (the alert body and
+audit row both carry the new leaf's `validTo`).
+
+1. **Re-extract the new leaf pin** with the `openssl` pipeline above. It
+ should equal the `servedPin` in the alert.
+2. **Dual-pin.** Add the new pin to the client's pin set **alongside** the old
+ one (do not replace it yet), and update `TLS_LEAF_SPKI_PINS` to the
+ comma-separated pair, e.g.
+ `TLS_LEAF_SPKI_PINS=","`. Holding both pins means clients
+ on the old build keep working through the cutover and the server-side
+ alarm goes quiet because the served pin is now in the known set. Re-deploy
+ the server so the new env value takes effect.
+3. **Ship the client build** carrying the dual-pin set to TestFlight (and
+ onward to release). Give it long enough to roll out to the installed base
+ before the next renewal.
+4. **Retire the old pin** once the old leaf is no longer served and the
+ dual-pinned client build has reached the installed base: drop the old pin
+ from both the client pin set and `TLS_LEAF_SPKI_PINS`, leaving only the
+ current leaf pin.
+
+Treat the dual-pin window as standing practice: always ship the **next** pin
+before the current leaf rotates, so a renewal never catches a single-pinned
+client. If GTS renews on a fixed cadence, pre-extracting and dual-pinning the
+upcoming leaf ahead of the renewal turns every rotation into a no-op.
+
+## Environment assumption
+
+The monitor speaks TLS directly to the public host from `APP_URL` /
+`NEXT_PUBLIC_APP_URL`, which is correct regardless of the reverse-proxy
+topology — it observes the same leaf a real client does. The one assumption
+is that the host the client connects to is the host in that env var. If the
+client is pinned against a different hostname (e.g. a vanity domain that
+fronts the configured `APP_URL`), point the monitor at that host by setting
+`APP_URL` to it, or extend the job to probe the additional host. Plain-HTTP
+self-hosts have no leaf to pin and the monitor no-ops there by design.
diff --git a/messages/de.json b/messages/de.json
index fc2c4d664..86fcb9031 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -1539,11 +1539,11 @@
"bmiEmptyTitle": "Größe hinzufügen für BMI",
"bmiEmptyDescription": "Für den BMI wird deine Körpergröße benötigt. Hinterlege sie unter Einstellungen → Account.",
"bmiEmptyAction": "Account-Einstellungen öffnen",
- "noAnalysisYet": "Noch keine Analyse vorhanden.",
+ "noAnalysisYet": "Noch keine Einschätzung vorhanden.",
"assessmentPreparing": "Einschätzung wird vorbereitet…",
- "noProviderConfigured": "Auswertung nicht verfügbar.",
- "startAnalysis": "Analyse starten",
- "refreshAnalysis": "Analyse aktualisieren",
+ "noProviderConfigured": "Einschätzung nicht verfügbar.",
+ "startAnalysis": "Einschätzung starten",
+ "refreshAnalysis": "Einschätzung aktualisieren",
"keyTakeaway": "Das Wichtigste",
"findingsTitle": "Befunde",
"heroFindingPositive": "Wichtigster Befund",
@@ -1590,11 +1590,11 @@
"heroPersonalBaseline": "Basierend auf deinen letzten 90 Tagen",
"heroRegenerate": "Neu generieren",
"heroRegenerating": "Wird generiert",
- "regenerateAnalysis": "Analyse neu starten",
- "regenerateSuccess": "Analyse wurde neu erstellt",
- "warmAssessments": "Auswertungen vorbereiten",
- "warmAssessmentsHint": "Auswertungen werden über Nacht automatisch aktualisiert. Bereite sie jetzt vor, um den neuesten Stand zu sehen.",
- "warmStarted": "Auswertungen werden im Hintergrund erstellt",
+ "regenerateAnalysis": "Einschätzung neu erstellen",
+ "regenerateSuccess": "Einschätzung wurde neu erstellt",
+ "warmAssessments": "Einschätzungen vorbereiten",
+ "warmAssessmentsHint": "Einschätzungen werden über Nacht automatisch aktualisiert. Bereite sie jetzt vor, um den neuesten Stand zu sehen.",
+ "warmStarted": "Einschätzungen werden im Hintergrund erstellt",
"narrativeTitle": "Dein Zeitraum im Rückblick",
"narrativeWeek": "Diese Woche",
"narrativeMonth": "Dieser Monat",
@@ -4583,7 +4583,9 @@
"reminderCheckOverdueTitle": "Einnahme überfällig: {medication}",
"reminderCheckOverdueBody": "{medication} ({dose}) im Zeitfenster {window} ist seit {minutes} Minuten überfällig.",
"offlineGeoUnavailableTitle": "Offline-Geodatenbanken nicht geladen",
- "offlineGeoUnavailableBody": "HealthLog löst Login-Standorte aktuell über den Online-Fallback ipwho.is auf. Um die Offline-Datenbanken GeoLite2-City und GeoLite2-ASN zu aktivieren, setze MAXMIND_LICENSE_KEY in den GitHub-Actions-Secrets unter {secretsUrl} und deploye neu. Bis dahin bleibt die Carrier-Spalte in der Login-Übersicht leer."
+ "offlineGeoUnavailableBody": "HealthLog löst Login-Standorte aktuell über den Online-Fallback ipwho.is auf. Um die Offline-Datenbanken GeoLite2-City und GeoLite2-ASN zu aktivieren, setze MAXMIND_LICENSE_KEY in den GitHub-Actions-Secrets unter {secretsUrl} und deploye neu. Bis dahin bleibt die Carrier-Spalte in der Login-Übersicht leer.",
+ "tlsPinChangedTitle": "TLS-Leaf-Zertifikat geändert: {host}",
+ "tlsPinChangedBody": "Das von {host} ausgelieferte TLS-Leaf hat jetzt den SPKI-Pin {servedPin}, der nicht im bekannten Pin-Satz enthalten ist. Gepinnte Clients (die native App) können sich nicht mehr verbinden, sobald der alte Pin weg ist. Extrahiere den neuen Leaf-Pin erneut, ergänze ihn in TLS_LEAF_SPKI_PINS sowie im Pin-Satz des nativen Clients und veröffentliche einen Build, bevor dieses Zertifikat am {validTo} abläuft. Siehe docs/ops/tls-cert-pin.md."
},
"user": {
"telegramTestBody": "HealthLog: Verbindung hergestellt. Telegram-Benachrichtigungen sind aktiv."
diff --git a/messages/en.json b/messages/en.json
index 8aebe5038..5d4da4ca3 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -1533,7 +1533,7 @@
"notAvailableForCompliance": "Comparison N/A for compliance"
},
"insights": {
- "noAnalysisYet": "No analysis yet.",
+ "noAnalysisYet": "No assessment yet.",
"assessmentPreparing": "Preparing your assessment…",
"emptyTitle": "Not enough data yet",
"emptyDescription": "Insights light up once you've logged a handful of measurements. Start with weight or blood pressure.",
@@ -1542,8 +1542,8 @@
"bmiEmptyDescription": "BMI needs your height. Add it under Settings → Account.",
"bmiEmptyAction": "Open account settings",
"noProviderConfigured": "Assessment unavailable.",
- "startAnalysis": "Start analysis",
- "refreshAnalysis": "Refresh analysis",
+ "startAnalysis": "Start assessment",
+ "refreshAnalysis": "Refresh assessment",
"keyTakeaway": "Key Takeaway",
"findingsTitle": "Findings",
"heroFindingPositive": "Top finding",
@@ -1590,8 +1590,8 @@
"heroPersonalBaseline": "Based on your last 90 days",
"heroRegenerate": "Regenerate",
"heroRegenerating": "Regenerating",
- "regenerateAnalysis": "Re-run analysis",
- "regenerateSuccess": "Analysis refreshed",
+ "regenerateAnalysis": "Re-run assessment",
+ "regenerateSuccess": "Assessment refreshed",
"warmAssessments": "Prepare assessments",
"warmAssessmentsHint": "Assessments refresh overnight automatically. Prepare them now to read the latest.",
"warmStarted": "Assessments are being prepared in the background",
@@ -4583,7 +4583,9 @@
"reminderCheckOverdueTitle": "Dose overdue: {medication}",
"reminderCheckOverdueBody": "{medication} ({dose}) in the {window} window is {minutes} minutes overdue.",
"offlineGeoUnavailableTitle": "Offline geolocation databases not loaded",
- "offlineGeoUnavailableBody": "HealthLog is resolving login locations through the online ipwho.is fallback. To enable the offline GeoLite2-City + GeoLite2-ASN databases, set MAXMIND_LICENSE_KEY in GitHub Actions secrets at {secretsUrl} and redeploy. Until then the carrier column on the login overview stays empty."
+ "offlineGeoUnavailableBody": "HealthLog is resolving login locations through the online ipwho.is fallback. To enable the offline GeoLite2-City + GeoLite2-ASN databases, set MAXMIND_LICENSE_KEY in GitHub Actions secrets at {secretsUrl} and redeploy. Until then the carrier column on the login overview stays empty.",
+ "tlsPinChangedTitle": "TLS leaf certificate changed: {host}",
+ "tlsPinChangedBody": "The TLS leaf served by {host} now has SPKI pin {servedPin}, which is not in the known pin set. Pinned clients (the native app) will fail to connect once the old pin is gone. Re-extract the new leaf pin, add it to TLS_LEAF_SPKI_PINS and the native client's pin set, and ship a build before this certificate expires on {validTo}. See docs/ops/tls-cert-pin.md."
},
"user": {
"telegramTestBody": "HealthLog: connection successful. Telegram notifications are active."
diff --git a/messages/es.json b/messages/es.json
index 2e4324c1e..81c68c037 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -1533,17 +1533,17 @@
"notAvailableForCompliance": "Comparación no disponible para el cumplimiento"
},
"insights": {
- "noAnalysisYet": "Aún no hay análisis.",
- "assessmentPreparing": "Preparando tu evaluación…",
+ "noAnalysisYet": "Aún no hay valoración.",
+ "assessmentPreparing": "Preparando tu valoración…",
"emptyTitle": "Aún no hay datos suficientes",
"emptyDescription": "Los insights aparecen en cuanto registres algunas mediciones. Empieza por peso o tensión arterial.",
"emptyAddMeasurement": "Registrar medición",
"bmiEmptyTitle": "Añade tu altura para calcular el IMC",
"bmiEmptyDescription": "El IMC necesita tu altura. Guárdala en Ajustes → Cuenta.",
"bmiEmptyAction": "Abrir ajustes de cuenta",
- "noProviderConfigured": "Análisis no disponible.",
- "startAnalysis": "Iniciar análisis",
- "refreshAnalysis": "Actualizar análisis",
+ "noProviderConfigured": "Valoración no disponible.",
+ "startAnalysis": "Iniciar valoración",
+ "refreshAnalysis": "Actualizar valoración",
"keyTakeaway": "Lo más importante",
"findingsTitle": "Hallazgos",
"heroFindingPositive": "Hallazgo principal",
@@ -1590,11 +1590,11 @@
"heroPersonalBaseline": "Basado en tus últimos 90 días",
"heroRegenerate": "Regenerar",
"heroRegenerating": "Generando",
- "regenerateAnalysis": "Volver a iniciar el análisis",
- "regenerateSuccess": "Análisis regenerado",
- "warmAssessments": "Preparar evaluaciones",
- "warmAssessmentsHint": "Las evaluaciones se actualizan automáticamente durante la noche. Prepáralas ahora para ver lo más reciente.",
- "warmStarted": "Las evaluaciones se están preparando en segundo plano",
+ "regenerateAnalysis": "Volver a generar la valoración",
+ "regenerateSuccess": "Valoración regenerada",
+ "warmAssessments": "Preparar valoraciones",
+ "warmAssessmentsHint": "Las valoraciones se actualizan automáticamente durante la noche. Prepáralas ahora para ver lo más reciente.",
+ "warmStarted": "Las valoraciones se están preparando en segundo plano",
"narrativeTitle": "Tu período en resumen",
"narrativeWeek": "Esta semana",
"narrativeMonth": "Este mes",
@@ -4583,7 +4583,9 @@
"reminderCheckOverdueTitle": "Dosis atrasada: {medication}",
"reminderCheckOverdueBody": "{medication} ({dose}) en la ventana {window} lleva {minutes} minutos de retraso.",
"offlineGeoUnavailableTitle": "Bases de datos de geolocalización sin conexión no cargadas",
- "offlineGeoUnavailableBody": "HealthLog está resolviendo las ubicaciones de inicio de sesión mediante el respaldo en línea ipwho.is. Para habilitar las bases sin conexión GeoLite2-City y GeoLite2-ASN, configura MAXMIND_LICENSE_KEY en los secrets de GitHub Actions en {secretsUrl} y vuelve a desplegar. Hasta entonces, la columna de operador en el resumen de inicios de sesión permanece vacía."
+ "offlineGeoUnavailableBody": "HealthLog está resolviendo las ubicaciones de inicio de sesión mediante el respaldo en línea ipwho.is. Para habilitar las bases sin conexión GeoLite2-City y GeoLite2-ASN, configura MAXMIND_LICENSE_KEY en los secrets de GitHub Actions en {secretsUrl} y vuelve a desplegar. Hasta entonces, la columna de operador en el resumen de inicios de sesión permanece vacía.",
+ "tlsPinChangedTitle": "Certificado de hoja TLS cambiado: {host}",
+ "tlsPinChangedBody": "La hoja TLS servida por {host} ahora tiene el pin SPKI {servedPin}, que no está en el conjunto de pines conocidos. Los clientes con pin fijo (la app nativa) no podrán conectarse una vez que desaparezca el pin antiguo. Vuelve a extraer el nuevo pin de la hoja, añádelo a TLS_LEAF_SPKI_PINS y al conjunto de pines del cliente nativo, y publica una versión antes de que este certificado caduque el {validTo}. Consulta docs/ops/tls-cert-pin.md."
},
"user": {
"telegramTestBody": "HealthLog: conexión correcta. Las notificaciones de Telegram están activas."
diff --git a/messages/fr.json b/messages/fr.json
index 10360185a..db919b66c 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -1533,7 +1533,7 @@
"notAvailableForCompliance": "Comparaison indisponible pour l’observance"
},
"insights": {
- "noAnalysisYet": "Pas encore d’analyse.",
+ "noAnalysisYet": "Pas encore d’évaluation.",
"assessmentPreparing": "Préparation de votre évaluation…",
"emptyTitle": "Pas encore assez de données",
"emptyDescription": "Les insights apparaissent dès que tu auras saisi quelques mesures. Commence par le poids ou la tension.",
@@ -1541,9 +1541,9 @@
"bmiEmptyTitle": "Ajoute ta taille pour l’IMC",
"bmiEmptyDescription": "L’IMC nécessite ta taille. Renseigne-la dans Paramètres → Compte.",
"bmiEmptyAction": "Ouvrir les paramètres du compte",
- "noProviderConfigured": "Analyse indisponible.",
- "startAnalysis": "Lancer l’analyse",
- "refreshAnalysis": "Actualiser l’analyse",
+ "noProviderConfigured": "Évaluation indisponible.",
+ "startAnalysis": "Lancer l’évaluation",
+ "refreshAnalysis": "Actualiser l’évaluation",
"keyTakeaway": "L’essentiel",
"findingsTitle": "Constats",
"heroFindingPositive": "Constat principal",
@@ -1590,8 +1590,8 @@
"heroPersonalBaseline": "Basé sur tes 90 derniers jours",
"heroRegenerate": "Régénérer",
"heroRegenerating": "Génération en cours",
- "regenerateAnalysis": "Relancer l’analyse",
- "regenerateSuccess": "Analyse regénérée",
+ "regenerateAnalysis": "Relancer l’évaluation",
+ "regenerateSuccess": "Évaluation regénérée",
"warmAssessments": "Préparer les évaluations",
"warmAssessmentsHint": "Les évaluations sont actualisées automatiquement pendant la nuit. Préparez-les maintenant pour voir les dernières données.",
"warmStarted": "Les évaluations sont en cours de préparation en arrière-plan",
@@ -4583,7 +4583,9 @@
"reminderCheckOverdueTitle": "Prise en retard : {medication}",
"reminderCheckOverdueBody": "{medication} ({dose}) dans la fenêtre {window} est en retard de {minutes} minutes.",
"offlineGeoUnavailableTitle": "Bases de géolocalisation hors ligne non chargées",
- "offlineGeoUnavailableBody": "HealthLog résout les localisations de connexion via le fallback en ligne ipwho.is. Pour activer les bases hors ligne GeoLite2-City et GeoLite2-ASN, définissez MAXMIND_LICENSE_KEY dans les secrets GitHub Actions à {secretsUrl} puis redéployez. En attendant, la colonne opérateur dans l'aperçu des connexions reste vide."
+ "offlineGeoUnavailableBody": "HealthLog résout les localisations de connexion via le fallback en ligne ipwho.is. Pour activer les bases hors ligne GeoLite2-City et GeoLite2-ASN, définissez MAXMIND_LICENSE_KEY dans les secrets GitHub Actions à {secretsUrl} puis redéployez. En attendant, la colonne opérateur dans l'aperçu des connexions reste vide.",
+ "tlsPinChangedTitle": "Certificat feuille TLS modifié : {host}",
+ "tlsPinChangedBody": "La feuille TLS servie par {host} a maintenant le pin SPKI {servedPin}, qui ne figure pas dans l'ensemble de pins connus. Les clients épinglés (l'app native) ne pourront plus se connecter une fois l'ancien pin disparu. Réextrayez le nouveau pin de la feuille, ajoutez-le à TLS_LEAF_SPKI_PINS et à l'ensemble de pins du client natif, et publiez une version avant l'expiration de ce certificat le {validTo}. Voir docs/ops/tls-cert-pin.md."
},
"user": {
"telegramTestBody": "HealthLog: connexion réussie. Les notifications Telegram sont actives."
diff --git a/messages/it.json b/messages/it.json
index 82b6af4e4..e50b0f5f8 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -1533,7 +1533,7 @@
"notAvailableForCompliance": "Confronto non disponibile per l’aderenza"
},
"insights": {
- "noAnalysisYet": "Ancora nessuna analisi.",
+ "noAnalysisYet": "Ancora nessuna valutazione.",
"assessmentPreparing": "Preparazione della valutazione…",
"emptyTitle": "Dati ancora insufficienti",
"emptyDescription": "Gli insights compaiono appena registri qualche misurazione. Inizia da peso o pressione.",
@@ -1541,9 +1541,9 @@
"bmiEmptyTitle": "Aggiungi l’altezza per il BMI",
"bmiEmptyDescription": "Per il BMI serve la tua altezza. Salvala in Impostazioni → Account.",
"bmiEmptyAction": "Apri impostazioni account",
- "noProviderConfigured": "Analisi non disponibile.",
- "startAnalysis": "Avvia analisi",
- "refreshAnalysis": "Aggiorna analisi",
+ "noProviderConfigured": "Valutazione non disponibile.",
+ "startAnalysis": "Avvia valutazione",
+ "refreshAnalysis": "Aggiorna valutazione",
"keyTakeaway": "Ciò che conta",
"findingsTitle": "Riscontri",
"heroFindingPositive": "Riscontro principale",
@@ -1590,8 +1590,8 @@
"heroPersonalBaseline": "Basato sugli ultimi 90 giorni",
"heroRegenerate": "Rigenera",
"heroRegenerating": "Generazione in corso",
- "regenerateAnalysis": "Riavvia l’analisi",
- "regenerateSuccess": "Analisi rigenerata",
+ "regenerateAnalysis": "Rigenera la valutazione",
+ "regenerateSuccess": "Valutazione rigenerata",
"warmAssessments": "Prepara le valutazioni",
"warmAssessmentsHint": "Le valutazioni si aggiornano automaticamente durante la notte. Preparale ora per vedere i dati più recenti.",
"warmStarted": "Le valutazioni vengono preparate in background",
@@ -4583,7 +4583,9 @@
"reminderCheckOverdueTitle": "Dose in ritardo: {medication}",
"reminderCheckOverdueBody": "{medication} ({dose}) nella finestra {window} è in ritardo di {minutes} minuti.",
"offlineGeoUnavailableTitle": "Database di geolocalizzazione offline non caricati",
- "offlineGeoUnavailableBody": "HealthLog sta risolvendo le posizioni di accesso tramite il fallback online ipwho.is. Per abilitare i database offline GeoLite2-City e GeoLite2-ASN, imposta MAXMIND_LICENSE_KEY nei secret di GitHub Actions su {secretsUrl} e ridistribuisci. Fino ad allora la colonna operatore nella panoramica degli accessi rimane vuota."
+ "offlineGeoUnavailableBody": "HealthLog sta risolvendo le posizioni di accesso tramite il fallback online ipwho.is. Per abilitare i database offline GeoLite2-City e GeoLite2-ASN, imposta MAXMIND_LICENSE_KEY nei secret di GitHub Actions su {secretsUrl} e ridistribuisci. Fino ad allora la colonna operatore nella panoramica degli accessi rimane vuota.",
+ "tlsPinChangedTitle": "Certificato foglia TLS modificato: {host}",
+ "tlsPinChangedBody": "La foglia TLS servita da {host} ora ha il pin SPKI {servedPin}, che non è nell'insieme dei pin noti. I client con pin fisso (l'app nativa) non potranno più connettersi una volta scomparso il vecchio pin. Riestrai il nuovo pin della foglia, aggiungilo a TLS_LEAF_SPKI_PINS e all'insieme dei pin del client nativo, e pubblica una build prima che questo certificato scada il {validTo}. Vedi docs/ops/tls-cert-pin.md."
},
"user": {
"telegramTestBody": "HealthLog: connessione riuscita. Le notifiche Telegram sono attive."
diff --git a/messages/pl.json b/messages/pl.json
index 4562d276a..647ee2e35 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -1533,7 +1533,7 @@
"notAvailableForCompliance": "Porównanie niedostępne dla przestrzegania"
},
"insights": {
- "noAnalysisYet": "Brak analizy.",
+ "noAnalysisYet": "Brak oceny.",
"assessmentPreparing": "Przygotowywanie oceny…",
"emptyTitle": "Jeszcze za mało danych",
"emptyDescription": "Insights pojawią się, gdy zarejestrujesz kilka pomiarów. Zacznij od wagi lub ciśnienia.",
@@ -1541,9 +1541,9 @@
"bmiEmptyTitle": "Dodaj wzrost, aby obliczyć BMI",
"bmiEmptyDescription": "BMI wymaga twojego wzrostu. Zapisz go w Ustawieniach → Konto.",
"bmiEmptyAction": "Otwórz ustawienia konta",
- "noProviderConfigured": "Analiza niedostępna.",
- "startAnalysis": "Uruchom analizę",
- "refreshAnalysis": "Odśwież analizę",
+ "noProviderConfigured": "Ocena niedostępna.",
+ "startAnalysis": "Uruchom ocenę",
+ "refreshAnalysis": "Odśwież ocenę",
"keyTakeaway": "Najważniejsze",
"findingsTitle": "Wnioski",
"heroFindingPositive": "Najważniejszy wniosek",
@@ -1590,11 +1590,11 @@
"heroPersonalBaseline": "Na podstawie ostatnich 90 dni",
"heroRegenerate": "Wygeneruj ponownie",
"heroRegenerating": "Generowanie",
- "regenerateAnalysis": "Uruchom analizę ponownie",
- "regenerateSuccess": "Analiza wygenerowana ponownie",
- "warmAssessments": "Przygotuj analizy",
- "warmAssessmentsHint": "Analizy odświeżają się automatycznie w nocy. Przygotuj je teraz, aby zobaczyć najnowsze dane.",
- "warmStarted": "Analizy są przygotowywane w tle",
+ "regenerateAnalysis": "Uruchom ocenę ponownie",
+ "regenerateSuccess": "Ocena wygenerowana ponownie",
+ "warmAssessments": "Przygotuj oceny",
+ "warmAssessmentsHint": "Oceny odświeżają się automatycznie w nocy. Przygotuj je teraz, aby zobaczyć najnowsze dane.",
+ "warmStarted": "Oceny są przygotowywane w tle",
"narrativeTitle": "Twój okres w skrócie",
"narrativeWeek": "Ten tydzień",
"narrativeMonth": "Ten miesiąc",
@@ -4583,7 +4583,9 @@
"reminderCheckOverdueTitle": "Dawka opóźniona: {medication}",
"reminderCheckOverdueBody": "{medication} ({dose}) w przedziale {window} jest opóźnione o {minutes} minut.",
"offlineGeoUnavailableTitle": "Bazy geolokalizacji offline nie zostały załadowane",
- "offlineGeoUnavailableBody": "HealthLog rozwiązuje lokalizacje logowania przez awaryjne źródło online ipwho.is. Aby włączyć bazy offline GeoLite2-City i GeoLite2-ASN, ustaw MAXMIND_LICENSE_KEY w sekretach GitHub Actions pod adresem {secretsUrl} i ponownie wdroż. Do tego czasu kolumna operatora w przeglądzie logowań pozostaje pusta."
+ "offlineGeoUnavailableBody": "HealthLog rozwiązuje lokalizacje logowania przez awaryjne źródło online ipwho.is. Aby włączyć bazy offline GeoLite2-City i GeoLite2-ASN, ustaw MAXMIND_LICENSE_KEY w sekretach GitHub Actions pod adresem {secretsUrl} i ponownie wdroż. Do tego czasu kolumna operatora w przeglądzie logowań pozostaje pusta.",
+ "tlsPinChangedTitle": "Zmieniono certyfikat liścia TLS: {host}",
+ "tlsPinChangedBody": "Liść TLS serwowany przez {host} ma teraz pin SPKI {servedPin}, którego nie ma w znanym zestawie pinów. Klienci z przypiętym pinem (aplikacja natywna) nie połączą się po usunięciu starego pinu. Ponownie wyodrębnij nowy pin liścia, dodaj go do TLS_LEAF_SPKI_PINS oraz do zestawu pinów klienta natywnego i opublikuj kompilację, zanim ten certyfikat wygaśnie {validTo}. Zobacz docs/ops/tls-cert-pin.md."
},
"user": {
"telegramTestBody": "HealthLog: połączenie udane. Powiadomienia Telegram są aktywne."
diff --git a/package.json b/package.json
index cdc4da40b..03f5d073c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "healthlog",
- "version": "1.12.1",
+ "version": "1.12.2",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "AGPL-3.0-only",
"homepage": "https://healthlog.dev",
diff --git a/prisma/migrations/0124_v1122_whoop_connect_ticket/migration.sql b/prisma/migrations/0124_v1122_whoop_connect_ticket/migration.sql
new file mode 100644
index 000000000..6ac99d75d
--- /dev/null
+++ b/prisma/migrations/0124_v1122_whoop_connect_ticket/migration.sql
@@ -0,0 +1,55 @@
+-- v1.12.2 — WHOOP connect-in-app enhancements (two iOS-requested asks).
+--
+-- 1. `whoop_oauth_states` += `return_scheme` (nullable). The native client may
+-- pass `?return_scheme=` to `GET /api/whoop/connect`; the
+-- connect route validates it against a strict allowlist and stores it on the
+-- in-flight state row so it survives the OAuth round-trip server-side (never
+-- in the URL or cookie). The callback reads it off the consumed row to send
+-- its FINAL redirect to `://whoop?whoop=connected|error&reason=…`
+-- instead of the web settings URL. Null = unchanged web redirect.
+--
+-- 2. `whoop_connect_tickets` — a one-time, Bearer-mintable connect ticket so a
+-- purely Bearer-authenticated native client (no web-session cookie) can
+-- start the WHOOP handshake. The client mints a ticket via an authenticated
+-- `POST /api/whoop/connect/ticket`, then opens
+-- `GET /api/whoop/connect?ticket=` in an in-app web session. Only
+-- the HMAC-SHA256 hash of the opaque ticket is stored (`token_hash`, keyed
+-- by `API_TOKEN_HMAC_KEY`); the raw value never persists. The ticket is
+-- single-use (`consumed_at` stamped atomically on first use) and short-lived
+-- (~60s `expires_at`). Mirrors `whoop_oauth_states` in shape + lifecycle.
+--
+-- Idempotent guards (`IF NOT EXISTS`) make reruns safe. Forward-only.
+--
+-- Reversibility: the column drops with `DROP COLUMN IF EXISTS "return_scheme"`,
+-- the table with `DROP TABLE IF EXISTS "whoop_connect_tickets"`.
+
+-- ── 1. whoop_oauth_states — native return-scheme carrier ──────────────
+ALTER TABLE "whoop_oauth_states"
+ ADD COLUMN IF NOT EXISTS "return_scheme" TEXT;
+
+-- ── 2. whoop_connect_tickets — one-time Bearer-startable connect ticket ─
+CREATE TABLE IF NOT EXISTS "whoop_connect_tickets" (
+ "id" TEXT NOT NULL,
+ "user_id" TEXT NOT NULL,
+ "token_hash" TEXT NOT NULL,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "expires_at" TIMESTAMP(3) NOT NULL,
+ "consumed_at" TIMESTAMP(3),
+
+ CONSTRAINT "whoop_connect_tickets_pkey" PRIMARY KEY ("id")
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS "whoop_connect_tickets_token_hash_key"
+ ON "whoop_connect_tickets" ("token_hash");
+
+CREATE INDEX IF NOT EXISTS "whoop_connect_tickets_expires_at_idx"
+ ON "whoop_connect_tickets" ("expires_at");
+
+DO $$ BEGIN
+ ALTER TABLE "whoop_connect_tickets"
+ ADD CONSTRAINT "whoop_connect_tickets_user_id_fkey"
+ FOREIGN KEY ("user_id") REFERENCES "users"("id")
+ ON DELETE CASCADE ON UPDATE CASCADE;
+EXCEPTION
+ WHEN duplicate_object THEN NULL;
+END $$;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 250120304..e07cb488e 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -372,6 +372,7 @@ model User {
// OAuth-state ledger (1:N) relations.
whoopConnection WhoopConnection?
whoopOAuthStates WhoopOAuthState[]
+ whoopConnectTickets WhoopConnectTicket[]
// v1.12.0 — Fitbit/Pixel integration. Mirrors the WHOOP connection (1:1) +
// OAuth-state ledger (1:N) relations.
fitbitConnection FitbitConnection?
@@ -1892,6 +1893,12 @@ model WhoopOAuthState {
userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
expiresAt DateTime @map("expires_at")
+ /// v1.12.2 — optional native custom-scheme (e.g. `dev.healthlog.app`) the
+ /// connect route validated against the strict allowlist. Carried here so it
+ /// survives the OAuth round-trip server-side (never in the URL/cookie); the
+ /// callback reads it off the consumed row to drive its FINAL redirect to
+ /// `://whoop?…` instead of the web settings URL. Null = web redirect.
+ returnScheme String? @map("return_scheme")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -1899,6 +1906,38 @@ model WhoopOAuthState {
@@map("whoop_oauth_states")
}
+/// v1.12.2 — one-time, Bearer-mintable WHOOP connect ticket. Lets a purely
+/// Bearer-authenticated native client (no web-session cookie) start the WHOOP
+/// OAuth handshake: it mints a ticket via `POST /api/whoop/connect/ticket`,
+/// then opens `GET /api/whoop/connect?ticket=` in an in-app web
+/// session. The connect route resolves the user from the unconsumed/unexpired
+/// ticket IN LIEU of a session cookie.
+///
+/// Security shape (mirrors the Bearer-token storage pattern):
+/// * The raw ticket is returned to the client exactly once and NEVER stored;
+/// only its HMAC-SHA256 hash (`tokenHash`, keyed by `API_TOKEN_HMAC_KEY`)
+/// lands in the table — a DB read cannot recover a usable ticket.
+/// * Single-use: `consumedAt` is stamped by an atomic conditional update at
+/// the connect route; a second presentation finds the row already consumed
+/// and is rejected.
+/// * Short-lived (~60s `expiresAt`); the daily WHOOP OAuth-state cleanup
+/// cron also sweeps expired/consumed ticket rows.
+model WhoopConnectTicket {
+ id String @id @default(cuid())
+ userId String @map("user_id")
+ /// HMAC-SHA256 of the opaque raw ticket. The raw value never persists.
+ tokenHash String @unique @map("token_hash")
+ createdAt DateTime @default(now()) @map("created_at")
+ expiresAt DateTime @map("expires_at")
+ /// Stamped on first (and only) successful consumption. Null = still usable.
+ consumedAt DateTime? @map("consumed_at")
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@index([expiresAt])
+ @@map("whoop_connect_tickets")
+}
+
/// v1.12.0 — Fitbit/Pixel connection over the Google Health API. Mirrors
/// `WhoopConnection` 1:1 per user. Tokens are encrypted at the app level
/// before persistence. Unlike WHOOP, Google refresh tokens do not rotate per
diff --git a/scripts/env-manifest.json b/scripts/env-manifest.json
index 1bc7b0c8e..0f2710c4e 100644
--- a/scripts/env-manifest.json
+++ b/scripts/env-manifest.json
@@ -90,6 +90,17 @@
}
]
},
+ {
+ "name": "TLS leaf pin monitor",
+ "description": "Baseline for the TLS leaf SPKI-change alarm. The native client pins the served leaf certificate; without this the monitor logs the served pin but cannot alarm on a rotation. See docs/ops/tls-cert-pin.md.",
+ "required": false,
+ "variables": [
+ {
+ "name": "TLS_LEAF_SPKI_PINS",
+ "purpose": "Comma-separated known-good leaf SPKI pins, base64(sha256(DER subjectPublicKeyInfo)) — the same value the native client pins. Hold both the current and the next pin during a dual-pin renewal window."
+ }
+ ]
+ },
{
"name": "Off-host backups",
"description": "Required ALL-OR-NONE: any of these without the rest disables backups silently.",
diff --git a/src/app/api/whoop/callback/__tests__/route.test.ts b/src/app/api/whoop/callback/__tests__/route.test.ts
new file mode 100644
index 000000000..ca630c84a
--- /dev/null
+++ b/src/app/api/whoop/callback/__tests__/route.test.ts
@@ -0,0 +1,128 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+vi.mock("@/lib/api-handler", () => ({
+ apiHandler: unknown>(fn: T) => fn,
+}));
+
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ whoopOAuthState: { delete: vi.fn() },
+ whoopConnection: { upsert: vi.fn() },
+ },
+}));
+
+vi.mock("@/lib/auth/session", () => ({
+ // Native ticket path → no web session present.
+ getSession: vi.fn(async () => null),
+}));
+
+vi.mock("@/lib/logging/context", () => ({
+ annotate: vi.fn(),
+ getEvent: () => ({
+ setError: vi.fn(),
+ addWarning: vi.fn(),
+ setAuth: vi.fn(),
+ }),
+}));
+
+vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
+vi.mock("@/lib/crypto", () => ({ encrypt: (s: string) => `enc:${s}` }));
+
+vi.mock("@/lib/whoop/client", () => ({
+ WHOOP_OAUTH_SCOPE: "read:recovery",
+ exchangeCode: vi.fn(async () => ({
+ access_token: "at",
+ refresh_token: "rt",
+ expires_in: 3600,
+ scope: "read:recovery",
+ })),
+ fetchProfile: vi.fn(async () => ({ user_id: 42 })),
+}));
+
+vi.mock("@/lib/whoop/credentials", () => ({
+ getUserWhoopCredentials: vi.fn(async () => ({
+ clientId: "cid",
+ clientSecret: "secret",
+ })),
+}));
+
+vi.mock("@/lib/jobs/whoop-backfill", () => ({ WHOOP_BACKFILL_QUEUE: "q" }));
+vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: () => null }));
+vi.mock("@/lib/integrations/status", () => ({ markReconnected: vi.fn() }));
+
+import { GET } from "../route";
+import { prisma } from "@/lib/db";
+import type { NextRequest } from "next/server";
+
+const stateDelete = prisma.whoopOAuthState.delete as ReturnType;
+const connUpsert = prisma.whoopConnection.upsert as ReturnType;
+
+process.env.NEXT_PUBLIC_APP_URL = "https://app.example";
+
+function makeReq(nonce: string): NextRequest {
+ return {
+ url: `https://app.example/api/whoop/callback?code=auth-code&state=${nonce}`,
+ cookies: { get: (name: string) => (name === "whoop_state" ? { value: nonce } : undefined) },
+ } as unknown as NextRequest;
+}
+
+describe("GET /api/whoop/callback custom-scheme redirect", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ connUpsert.mockResolvedValue({});
+ });
+
+ it("redirects to the native custom scheme on success when returnScheme is set", async () => {
+ stateDelete.mockResolvedValue({
+ userId: "ticket-user",
+ expiresAt: new Date(Date.now() + 60_000),
+ returnScheme: "dev.healthlog.app",
+ });
+
+ const res = await GET(makeReq("nonce-abc"));
+ expect(res.headers.get("location")).toBe(
+ "dev.healthlog.app://whoop?whoop=connected",
+ );
+ expect(connUpsert).toHaveBeenCalled();
+ });
+
+ it("uses the web redirect on success when no returnScheme", async () => {
+ stateDelete.mockResolvedValue({
+ userId: "u1",
+ expiresAt: new Date(Date.now() + 60_000),
+ returnScheme: null,
+ });
+
+ const res = await GET(makeReq("nonce-web"));
+ expect(res.headers.get("location")).toBe(
+ "https://app.example/settings/integrations?whoop=connected",
+ );
+ });
+
+ it("routes an error to the native scheme when returnScheme is set (expired row)", async () => {
+ stateDelete.mockResolvedValue({
+ userId: "u1",
+ expiresAt: new Date(Date.now() - 1000),
+ returnScheme: "dev.healthlog.app",
+ });
+
+ const res = await GET(makeReq("nonce-exp"));
+ expect(res.headers.get("location")).toBe(
+ "dev.healthlog.app://whoop?whoop=error&reason=expired",
+ );
+ expect(connUpsert).not.toHaveBeenCalled();
+ });
+
+ it("pre-resolution CSRF rejection always uses the web redirect", async () => {
+ // Cookie/url state mismatch → csrf1, no row consumed.
+ const req = {
+ url: "https://app.example/api/whoop/callback?code=c&state=URLSTATE",
+ cookies: { get: () => ({ value: "DIFFERENT" }) },
+ } as unknown as NextRequest;
+ const res = await GET(req);
+ expect(res.headers.get("location")).toBe(
+ "https://app.example/settings/integrations?whoop=error&reason=csrf1",
+ );
+ expect(stateDelete).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/app/api/whoop/callback/route.ts b/src/app/api/whoop/callback/route.ts
index a9e065f33..cff863413 100644
--- a/src/app/api/whoop/callback/route.ts
+++ b/src/app/api/whoop/callback/route.ts
@@ -1,5 +1,6 @@
import { prisma } from "@/lib/db";
-import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { apiHandler } from "@/lib/api-handler";
+import { getSession } from "@/lib/auth/session";
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { encrypt } from "@/lib/crypto";
@@ -10,6 +11,7 @@ import {
} from "@/lib/whoop/client";
import { getUserWhoopCredentials } from "@/lib/whoop/credentials";
import { WHOOP_OAUTH_STATE_COOKIE } from "@/lib/whoop/oauth-state";
+import { buildReturnSchemeRedirect } from "@/lib/whoop/return-scheme";
import { WHOOP_BACKFILL_QUEUE } from "@/lib/jobs/whoop-backfill";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { markReconnected } from "@/lib/integrations/status";
@@ -18,23 +20,40 @@ import { NextRequest, NextResponse } from "next/server";
import { timingSafeEqual } from "node:crypto";
/**
- * OAuth callback from WHOOP (v1.11.0). Mirrors the Withings callback: the
- * `state` param is a random base64url nonce keyed against the
- * `WhoopOAuthState` ledger. The in-flight user is resolved via the row's
- * `userId` (never parsed from the cookie value) and cross-checked against the
- * session. The row is consumed (deleted) atomically on every exit branch so a
- * replay of the same nonce fails the second time.
+ * OAuth callback from WHOOP (v1.11.0; native enhancements v1.12.2). Mirrors the
+ * Withings callback: the `state` param is a random base64url nonce keyed
+ * against the `WhoopOAuthState` ledger. The in-flight user is resolved via the
+ * row's `userId` (never parsed from the cookie value) — this is the
+ * authoritative identity, bound at connect time from either the session or the
+ * one-time Bearer connect ticket. The row is consumed (deleted) atomically on
+ * every exit branch so a replay of the same nonce fails the second time.
*
- * Reason tags distinguish the four post-delete branches for the audit trail:
+ * Auth model. The state row's `userId` is the source of truth. When a web
+ * session cookie is ALSO present (the browser/web-login path) we additionally
+ * cross-check it matches the row, so a logged-in user can't complete another
+ * user's in-flight handshake. The cookie is OPTIONAL: the native ticket path
+ * (v1.12.2) carries no web session, and the nonce-cookie CSRF check + atomic
+ * single-use delete already pin the row to the caller who started the flow.
+ *
+ * Reason tags distinguish the post-delete branches for the audit trail:
* `csrf1` (URL/cookie mismatch, short-circuit before delete), `replay`
* (P2025 — nonce already consumed), `expired` (valid row, TTL elapsed), and
* `cross_user` (valid row, session userId mismatch).
*
+ * v1.12.2 — when the state row carries a validated `returnScheme` (set by the
+ * connect route from a native `?return_scheme=`), the FINAL redirect targets
+ * `://whoop?whoop=connected|error&reason=…` instead of the web settings
+ * URL, so `ASWebAuthenticationSession` auto-completes on its custom-scheme
+ * match. The pre-resolution CSRF rejections (`csrf1`/`replay`/`state`) have no
+ * row yet, so they always use the web redirect.
+ *
* 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.
*/
-const ERR = (reason: string) =>
+
+/** Web-URL redirect (default + every pre-row-resolution path). */
+const WEB_ERR = (reason: string) =>
NextResponse.redirect(
new URL(
`/settings/integrations?whoop=error&reason=${reason}`,
@@ -42,8 +61,31 @@ const ERR = (reason: string) =>
),
);
+/**
+ * Outcome redirect honouring an optional validated native return scheme.
+ * `scheme` null → web URL (unchanged behaviour).
+ */
+const outcomeRedirect = (
+ scheme: string | null,
+ outcome: "connected" | "error",
+ reason?: string,
+) => {
+ if (scheme) {
+ return NextResponse.redirect(
+ buildReturnSchemeRedirect(scheme, outcome, reason),
+ );
+ }
+ return outcome === "error"
+ ? WEB_ERR(reason ?? "unknown")
+ : NextResponse.redirect(
+ new URL(
+ "/settings/integrations?whoop=connected",
+ process.env.NEXT_PUBLIC_APP_URL!,
+ ),
+ );
+};
+
export const GET = apiHandler(async (request: NextRequest) => {
- const { user } = await requireAuth();
annotate({ action: { name: "whoop.callback" } });
const { searchParams } = new URL(request.url);
@@ -61,15 +103,20 @@ export const GET = apiHandler(async (request: NextRequest) => {
!timingSafeEqual(Buffer.from(state), Buffer.from(storedState))
) {
annotate({ meta: { reason: "csrf1" } });
- return ERR("csrf1");
+ return WEB_ERR("csrf1");
}
// CSRF leg 2: atomically consume the ledger row. `delete` returns the row
- // on success so the `expiresAt` + `userId` checks run against the consumed
- // payload. P2025 means the nonce was already consumed (replay) or never
- // existed. Atomic at the Postgres row level: two concurrent callbacks with
- // the same nonce can't both pass before either deletes.
- let stateRow: { userId: string; expiresAt: Date } | null = null;
+ // on success so the `expiresAt` + `userId` + `returnScheme` checks run
+ // against the consumed payload. P2025 means the nonce was already consumed
+ // (replay) or never existed. Atomic at the Postgres row level: two
+ // concurrent callbacks with the same nonce can't both pass before either
+ // deletes.
+ let stateRow: {
+ userId: string;
+ expiresAt: Date;
+ returnScheme: string | null;
+ } | null = null;
try {
stateRow = await prisma.whoopOAuthState.delete({ where: { nonce: state } });
} catch (err) {
@@ -78,31 +125,49 @@ export const GET = apiHandler(async (request: NextRequest) => {
err.code === "P2025"
) {
annotate({ meta: { reason: "replay" } });
- return ERR("replay");
+ return WEB_ERR("replay");
}
const errName = err instanceof Error ? err.name : "unknown";
getEvent()?.addWarning(`oauth-state-delete failed: ${errName}`);
annotate({ meta: { reason: "state" } });
- return ERR("state");
+ 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 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 ERR("expired");
+ return outcomeRedirect(returnScheme, "error", "expired");
}
- if (stateRow.userId !== user.id) {
+
+ // 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 ERR("cross_user");
+ return outcomeRedirect(returnScheme, "error", "cross_user");
}
if (!code) {
- return ERR("nocode");
+ return outcomeRedirect(returnScheme, "error", "nocode");
}
try {
- const creds = await getUserWhoopCredentials(user.id);
+ const creds = await getUserWhoopCredentials(userId);
if (!creds) {
- return ERR("nocreds");
+ return outcomeRedirect(returnScheme, "error", "nocreds");
}
const tokens = await exchangeCode(code, creds);
@@ -111,7 +176,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
const expiresAt = new Date(Date.now() + tokens.expires_in * 1000);
await prisma.whoopConnection.upsert({
- where: { userId: user.id },
+ where: { userId },
update: {
whoopUserId,
accessToken: encrypt(tokens.access_token),
@@ -121,7 +186,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
backfillCompletedAt: null,
},
create: {
- userId: user.id,
+ userId,
whoopUserId,
accessToken: encrypt(tokens.access_token),
refreshToken: encrypt(tokens.refresh_token),
@@ -131,12 +196,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
await auditLog("whoop.connect", {
- userId: user.id,
+ userId,
details: { whoopUserId },
});
// Re-completing OAuth clears any prior reauth-required state.
- await markReconnected(user.id, "whoop");
+ 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
@@ -145,7 +210,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (boss) {
await boss
.send(WHOOP_BACKFILL_QUEUE, {
- userId: user.id,
+ userId,
enqueuedAt: new Date().toISOString(),
})
.catch((err) =>
@@ -153,16 +218,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
);
}
- const response = NextResponse.redirect(
- new URL(
- "/settings/integrations?whoop=connected",
- process.env.NEXT_PUBLIC_APP_URL!,
- ),
- );
+ const response = outcomeRedirect(returnScheme, "connected");
response.cookies.delete(WHOOP_OAUTH_STATE_COOKIE);
return response;
} catch (err) {
getEvent()?.setError(err);
- return ERR("token");
+ return outcomeRedirect(returnScheme, "error", "token");
}
});
diff --git a/src/app/api/whoop/connect/__tests__/route.test.ts b/src/app/api/whoop/connect/__tests__/route.test.ts
new file mode 100644
index 000000000..219c0fa70
--- /dev/null
+++ b/src/app/api/whoop/connect/__tests__/route.test.ts
@@ -0,0 +1,149 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+vi.mock("@/lib/api-handler", () => ({
+ apiHandler: unknown>(fn: T) => fn,
+ requireAuth: vi.fn(async () => ({ user: { id: "cookie-user" } })),
+}));
+
+vi.mock("@/lib/db", () => ({
+ prisma: { whoopOAuthState: { create: vi.fn() } },
+}));
+
+vi.mock("@/lib/logging/context", () => ({
+ annotate: vi.fn(),
+ getEvent: () => ({ setError: vi.fn() }),
+}));
+
+vi.mock("@/lib/rate-limit", () => ({
+ checkRateLimit: vi.fn(async () => ({ allowed: true })),
+}));
+
+vi.mock("@/lib/whoop/client", () => ({
+ getAuthorizationUrl: vi.fn(
+ (nonce: string) => `https://api.prod.whoop.com/oauth/oauth2/auth?state=${nonce}`,
+ ),
+}));
+
+vi.mock("@/lib/whoop/credentials", () => ({
+ getUserWhoopCredentials: vi.fn(async () => ({
+ clientId: "cid",
+ clientSecret: "secret",
+ })),
+}));
+
+vi.mock("@/lib/whoop/connect-ticket", () => ({
+ consumeWhoopConnectTicket: vi.fn(),
+}));
+
+vi.mock("@/lib/auth/secure-cookie", () => ({
+ shouldEmitSecureCookie: () => true,
+}));
+
+vi.mock("@/lib/api-response", () => ({
+ apiError: (error: string, status: number) => ({
+ __apiError: true,
+ error,
+ status,
+ }),
+}));
+
+import { GET } from "../route";
+import { prisma } from "@/lib/db";
+import { requireAuth } from "@/lib/api-handler";
+import { consumeWhoopConnectTicket } from "@/lib/whoop/connect-ticket";
+import { checkRateLimit } from "@/lib/rate-limit";
+import type { NextRequest } from "next/server";
+
+const create = prisma.whoopOAuthState.create as ReturnType;
+const consume = consumeWhoopConnectTicket as unknown as ReturnType;
+const reqAuth = requireAuth as unknown as ReturnType;
+
+function makeReq(url: string): NextRequest {
+ return { url } as NextRequest;
+}
+
+describe("GET /api/whoop/connect", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ create.mockResolvedValue({});
+ (checkRateLimit as ReturnType).mockResolvedValue({
+ allowed: true,
+ });
+ });
+
+ it("cookie path: 302s to WHOOP and stores the state row (no scheme)", async () => {
+ const res = await GET(
+ makeReq("https://app.example/api/whoop/connect"),
+ );
+ expect(res.status).toBe(307);
+ expect(res.headers.get("location")).toContain(
+ "api.prod.whoop.com/oauth/oauth2/auth",
+ );
+ expect(reqAuth).toHaveBeenCalled();
+ expect(consume).not.toHaveBeenCalled();
+ const data = create.mock.calls[0][0].data;
+ expect(data.userId).toBe("cookie-user");
+ expect(data.returnScheme).toBeNull();
+ // Nonce cookie is set.
+ expect(res.cookies.get("whoop_state")?.value).toBeTruthy();
+ });
+
+ it("ticket path: resolves user from the ticket, NOT requireAuth, and 302s to WHOOP", async () => {
+ consume.mockResolvedValue({ userId: "ticket-user" });
+ const res = await GET(
+ makeReq("https://app.example/api/whoop/connect?ticket=opaque123"),
+ );
+ expect(res.status).toBe(307);
+ expect(res.headers.get("location")).toContain("api.prod.whoop.com");
+ expect(consume).toHaveBeenCalledWith("opaque123");
+ expect(reqAuth).not.toHaveBeenCalled();
+ expect(create.mock.calls[0][0].data.userId).toBe("ticket-user");
+ });
+
+ it("ticket path: invalid/expired/used ticket returns typed 401", async () => {
+ consume.mockResolvedValue(null);
+ const res = (await GET(
+ makeReq("https://app.example/api/whoop/connect?ticket=bad"),
+ )) as unknown as { __apiError: boolean; status: number };
+ expect(res.__apiError).toBe(true);
+ expect(res.status).toBe(401);
+ expect(create).not.toHaveBeenCalled();
+ });
+
+ it("stores a valid return_scheme on the state row", async () => {
+ const res = await GET(
+ makeReq(
+ "https://app.example/api/whoop/connect?return_scheme=dev.healthlog.app",
+ ),
+ );
+ expect(res.status).toBe(307);
+ expect(create.mock.calls[0][0].data.returnScheme).toBe("dev.healthlog.app");
+ });
+
+ it("rejects http/javascript return_scheme → null (web fallback)", async () => {
+ await GET(
+ makeReq("https://app.example/api/whoop/connect?return_scheme=http"),
+ );
+ expect(create.mock.calls[0][0].data.returnScheme).toBeNull();
+ create.mockClear();
+ await GET(
+ makeReq(
+ "https://app.example/api/whoop/connect?return_scheme=javascript",
+ ),
+ );
+ expect(create.mock.calls[0][0].data.returnScheme).toBeNull();
+ });
+
+ it("ticket + return_scheme combine", async () => {
+ consume.mockResolvedValue({ userId: "ticket-user" });
+ const res = await GET(
+ makeReq(
+ "https://app.example/api/whoop/connect?ticket=opaque&return_scheme=dev.healthlog.app",
+ ),
+ );
+ expect(res.status).toBe(307);
+ const data = create.mock.calls[0][0].data;
+ expect(data.userId).toBe("ticket-user");
+ expect(data.returnScheme).toBe("dev.healthlog.app");
+ });
+});
diff --git a/src/app/api/whoop/connect/route.ts b/src/app/api/whoop/connect/route.ts
index fdf1283be..8f5bf8226 100644
--- a/src/app/api/whoop/connect/route.ts
+++ b/src/app/api/whoop/connect/route.ts
@@ -4,17 +4,20 @@ import { prisma } from "@/lib/db";
import { annotate, getEvent } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { getAuthorizationUrl } from "@/lib/whoop/client";
+import { consumeWhoopConnectTicket } from "@/lib/whoop/connect-ticket";
import { getUserWhoopCredentials } from "@/lib/whoop/credentials";
import {
WHOOP_OAUTH_STATE_COOKIE,
WHOOP_OAUTH_STATE_TTL_MS,
mintWhoopOAuthStateNonce,
} from "@/lib/whoop/oauth-state";
+import { validateReturnScheme } from "@/lib/whoop/return-scheme";
import { NextRequest, NextResponse } from "next/server";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
/**
- * Redirect the user to the WHOOP OAuth authorization page (v1.11.0).
+ * Redirect the user to the WHOOP OAuth authorization page (v1.11.0; native
+ * enhancements v1.12.2).
*
* Mirrors the Withings connect route: a fully-random base64url state nonce
* backed by a 10-minute `WhoopOAuthState` ledger row carries the
@@ -23,20 +26,54 @@ import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
* httpOnly + Secure cookie carries JUST the nonce; the callback resolves the
* user via the row's `userId`.
*
+ * v1.12.2 — two native-client enhancements:
+ * - `?ticket=` lets a purely Bearer-authenticated native client (no
+ * web-session cookie) start the handshake. The connect route resolves the
+ * user from a one-time, unconsumed/unexpired ticket IN LIEU of a cookie,
+ * consumes it, and proceeds exactly as the cookie path. Expired / consumed
+ * / invalid → typed 401. The ticket is minted via the Bearer
+ * `POST /api/whoop/connect/ticket` route.
+ * - `?return_scheme=` (validated against a strict allowlist)
+ * is stored on the state row so it survives the OAuth round-trip; the
+ * callback uses it to send its FINAL redirect to a native custom scheme.
+ *
* Rate-limited per user (10 calls / 60 s) so a logged-in session can't spam
* ledger rows for the full 10-min TTL window. Both the rate-limit and
- * create-failure paths redirect (not JSON) because the entry point is a
+ * create-failure paths redirect (not JSON) because the cookie entry point is a
* browser navigation — a 429 envelope would surface as a blank page.
*/
const CONNECT_RATE_LIMIT = 10;
const CONNECT_WINDOW_MS = 60_000;
export const GET = apiHandler(async (req: NextRequest) => {
- const { user } = await requireAuth();
+ const { searchParams } = new URL(req.url);
+ const ticket = searchParams.get("ticket");
+
+ // Resolve the in-flight user. The ticket path is for Bearer-only native
+ // clients whose in-app web session carries no cookie; it must NOT call
+ // requireAuth (which would 401 the cookieless session). The cookie path is
+ // unchanged. The ticket is single-use + consumed atomically here.
+ let userId: string;
+ if (ticket) {
+ annotate({ action: { name: "whoop.connect.ticket.consume" } });
+ const resolved = await consumeWhoopConnectTicket(ticket);
+ if (!resolved) {
+ annotate({ action: { name: "whoop.connect.ticket.invalid" } });
+ // Typed 401 — the in-app session surfaces this; the client re-mints.
+ return apiError(
+ "Connect ticket is invalid, expired, or already used.",
+ 401,
+ );
+ }
+ userId = resolved.userId;
+ } else {
+ const { user } = await requireAuth();
+ userId = user.id;
+ }
annotate({ action: { name: "whoop.connect" } });
const rl = await checkRateLimit(
- `whoop:connect:${user.id}`,
+ `whoop:connect:${userId}`,
CONNECT_RATE_LIMIT,
CONNECT_WINDOW_MS,
);
@@ -50,7 +87,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
);
}
- const creds = await getUserWhoopCredentials(user.id);
+ const creds = await getUserWhoopCredentials(userId);
if (!creds) {
return apiError(
"Please configure your WHOOP Client ID and Client Secret in Settings first.",
@@ -58,13 +95,19 @@ export const GET = apiHandler(async (req: NextRequest) => {
);
}
+ // Validate the optional native return scheme against the strict allowlist.
+ // An invalid/absent value resolves to null → the callback uses the web
+ // redirect (never an arbitrary reflected scheme).
+ const returnScheme = validateReturnScheme(searchParams.get("return_scheme"));
+
const nonce = mintWhoopOAuthStateNonce();
try {
await prisma.whoopOAuthState.create({
data: {
nonce,
- userId: user.id,
+ userId,
expiresAt: new Date(Date.now() + WHOOP_OAUTH_STATE_TTL_MS),
+ returnScheme,
},
});
} catch (err) {
diff --git a/src/app/api/whoop/connect/ticket/__tests__/route.test.ts b/src/app/api/whoop/connect/ticket/__tests__/route.test.ts
new file mode 100644
index 000000000..3c56362bd
--- /dev/null
+++ b/src/app/api/whoop/connect/ticket/__tests__/route.test.ts
@@ -0,0 +1,69 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+vi.mock("@/lib/api-handler", () => ({
+ apiHandler: unknown>(fn: T) => fn,
+ requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
+}));
+
+vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
+
+vi.mock("@/lib/rate-limit", () => ({
+ checkRateLimit: vi.fn(async () => ({ allowed: true })),
+}));
+
+vi.mock("@/lib/whoop/connect-ticket", () => ({
+ mintWhoopConnectTicket: vi.fn(async () => "opaque-raw-ticket"),
+}));
+
+vi.mock("@/lib/whoop/credentials", () => ({
+ getUserWhoopCredentials: vi.fn(async () => ({
+ clientId: "cid",
+ clientSecret: "s",
+ })),
+}));
+
+vi.mock("@/lib/api-response", () => ({
+ apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
+ apiError: (error: string, status: number) => ({ data: null, error, status }),
+}));
+
+import { POST } from "../route";
+import { checkRateLimit } from "@/lib/rate-limit";
+import { getUserWhoopCredentials } from "@/lib/whoop/credentials";
+import { mintWhoopConnectTicket } from "@/lib/whoop/connect-ticket";
+
+const rl = checkRateLimit as ReturnType;
+const creds = getUserWhoopCredentials as unknown as ReturnType;
+const mint = mintWhoopConnectTicket as unknown as ReturnType;
+
+const call = () =>
+ (POST as unknown as () => Promise<{ data: unknown; status: number }>)();
+
+describe("POST /api/whoop/connect/ticket", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ rl.mockResolvedValue({ allowed: true });
+ creds.mockResolvedValue({ clientId: "cid", clientSecret: "s" });
+ });
+
+ it("mints and returns a ticket once", async () => {
+ const res = await call();
+ expect(res.status).toBe(200);
+ expect((res.data as { ticket: string }).ticket).toBe("opaque-raw-ticket");
+ expect(mint).toHaveBeenCalledWith("u1");
+ });
+
+ it("429s when rate-limited", async () => {
+ rl.mockResolvedValue({ allowed: false });
+ const res = await call();
+ expect(res.status).toBe(429);
+ expect(mint).not.toHaveBeenCalled();
+ });
+
+ it("400s when WHOOP credentials are not configured", async () => {
+ creds.mockResolvedValue(null);
+ const res = await call();
+ expect(res.status).toBe(400);
+ expect(mint).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/app/api/whoop/connect/ticket/route.ts b/src/app/api/whoop/connect/ticket/route.ts
new file mode 100644
index 000000000..459921481
--- /dev/null
+++ b/src/app/api/whoop/connect/ticket/route.ts
@@ -0,0 +1,49 @@
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { apiError, apiSuccess } from "@/lib/api-response";
+import { annotate } from "@/lib/logging/context";
+import { checkRateLimit } from "@/lib/rate-limit";
+import { mintWhoopConnectTicket } from "@/lib/whoop/connect-ticket";
+import { getUserWhoopCredentials } from "@/lib/whoop/credentials";
+
+/**
+ * Mint a one-time, short-lived WHOOP connect ticket (v1.12.2).
+ *
+ * Bearer-capable: a purely Bearer-authenticated native client (no web-session
+ * cookie) mints a ticket here, then opens
+ * `GET /api/whoop/connect?ticket=` in an in-app web session to start
+ * the WHOOP OAuth handshake. The raw ticket is returned exactly once; only its
+ * hash is stored (see `src/lib/whoop/connect-ticket.ts`).
+ *
+ * Rate-limited per user so a token can't mint an unbounded backlog of tickets.
+ */
+const TICKET_RATE_LIMIT = 10;
+const TICKET_WINDOW_MS = 60_000;
+
+export const POST = apiHandler(async () => {
+ const { user } = await requireAuth();
+ annotate({ action: { name: "whoop.connect.ticket.mint" } });
+
+ const rl = await checkRateLimit(
+ `whoop:connect:ticket:${user.id}`,
+ TICKET_RATE_LIMIT,
+ TICKET_WINDOW_MS,
+ );
+ if (!rl.allowed) {
+ annotate({ action: { name: "whoop.connect.ticket.rate_limited" } });
+ return apiError("Too many connect-ticket requests. Try again shortly.", 429);
+ }
+
+ // BYO-key gate: a ticket is only useful if the user has WHOOP credentials
+ // configured (the connect route would 400 otherwise). Fail fast with a clear
+ // error rather than minting a ticket that can't complete.
+ const creds = await getUserWhoopCredentials(user.id);
+ if (!creds) {
+ return apiError(
+ "Please configure your WHOOP Client ID and Client Secret in Settings first.",
+ 400,
+ );
+ }
+
+ const ticket = await mintWhoopConnectTicket(user.id);
+ return apiSuccess({ ticket });
+});
diff --git a/src/app/insights/__tests__/assessment-spine-order.test.ts b/src/app/insights/__tests__/assessment-spine-order.test.ts
new file mode 100644
index 000000000..49e85c895
--- /dev/null
+++ b/src/app/insights/__tests__/assessment-spine-order.test.ts
@@ -0,0 +1,85 @@
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+/**
+ * v1.12.2 — canonical metric-detail spine guard.
+ *
+ * v1.12.0 made the AI assessment the LAST content block on every metric
+ * sub-page (primary → stat strip → chart → range → target → forecast →
+ * assessment). The generic `HealthKitMetricPage` scaffold enforces this for
+ * the ~29 HealthKit pages structurally; the six bespoke pages each hand-write
+ * their body, so a future edit can silently slot a card after the assessment
+ * (as `mood` and `medications` did before this release).
+ *
+ * This is a source-level structural guard: the assessment card element must be
+ * the last JSX child of the page's ``. Asserting on the source
+ * (rather than rendered markup) keeps the test deterministic — the preceding
+ * blocks (`MetricTargetSummary`, `MoodInsightsSections`, `TherapyTimeline`)
+ * all self-suppress on a data miss, so a render-order assertion would depend
+ * on mocking the whole data layer.
+ */
+
+const PAGES_DIR = join(process.cwd(), "src", "app", "insights");
+
+// Each bespoke page + the element that mounts its assessment card. The five
+// `useInsightStatus`-backed pages render ``; the
+// medications page reads the richer `summary`-shaped route inline, so it
+// renders `` directly.
+const BESPOKE_PAGES: ReadonlyArray<{ slug: string; assessmentTag: string }> = [
+ { slug: "weight", assessmentTag: " {
+ for (const { slug, assessmentTag } of BESPOKE_PAGES) {
+ it(`renders the assessment card as the last SubPageShell child on /insights/${slug}`, () => {
+ const source = readFileSync(
+ join(PAGES_DIR, slug, "page.tsx"),
+ "utf8",
+ );
+
+ // The render branch that carries the assessment is the final
+ // `…` in the module (the empty-state
+ // branches close their own shell earlier and never mount the card).
+ const shellClose = source.lastIndexOf("");
+ expect(shellClose, `${slug}: no closing `).toBeGreaterThan(
+ -1,
+ );
+ const shellOpen = source.lastIndexOf("` that closes it, then assert nothing but
+ // whitespace separates that close from ``. The icon
+ // passed as a prop (``) self-closes BEFORE the element's own
+ // `/>`, so the first `/>` after the assessment open is the icon's and
+ // the second is the element's — scan for the last `/>` in the body,
+ // which belongs to the trailing assessment element by construction.
+ const selfClose = body.lastIndexOf("/>");
+ expect(
+ selfClose,
+ `${slug}: assessment card is not self-closing as expected`,
+ ).toBeGreaterThan(assessmentIndex);
+
+ const trailing = body.slice(selfClose + 2).trim();
+ expect(
+ trailing,
+ `${slug}: "${trailing.slice(0, 40)}" renders AFTER the assessment card — ` +
+ `the assessment must be the last block on the canonical spine`,
+ ).toBe("");
+ });
+ }
+});
diff --git a/src/app/insights/blood-pressure/page.tsx b/src/app/insights/blood-pressure/page.tsx
index 12553fc69..a6bbc6922 100644
--- a/src/app/insights/blood-pressure/page.tsx
+++ b/src/app/insights/blood-pressure/page.tsx
@@ -4,13 +4,12 @@ import Link from "next/link";
import { HeartPulse } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
-import { useInsightStatus } from "@/hooks/use-insight-status";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { Button } from "@/components/ui/button";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
-import { InsightStatusCard } from "@/components/insights/insight-status-card";
+import { SlugInsightStatusCard } from "@/components/insights/slug-insight-status-card";
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricLastMeasurementCard } from "@/components/insights/metric-last-measurement-card";
@@ -44,9 +43,6 @@ export default function InsightsBlutdruckPage() {
const { t } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(user != null);
- const { data: status, isLoading: isStatusLoading } =
- useInsightStatus("blood-pressure");
-
const { data: analytics, isEmpty } =
useInsightsAnalytics("BLOOD_PRESSURE_SYS");
const bpLastSeenAt =
@@ -147,14 +143,9 @@ export default function InsightsBlutdruckPage() {
{/* v1.12.0 — Einschätzung is the last block on the canonical
metric-detail spine. */}
- }
- text={status?.text ?? null}
- hasProvider={status?.hasProvider ?? false}
- updatedAt={status?.updatedAt ?? null}
- loading={isStatusLoading}
- preparing={status?.preparing ?? false}
/>
);
diff --git a/src/app/insights/bmi/page.tsx b/src/app/insights/bmi/page.tsx
index 5b74d78dc..7d3788a3d 100644
--- a/src/app/insights/bmi/page.tsx
+++ b/src/app/insights/bmi/page.tsx
@@ -4,7 +4,6 @@ import Link from "next/link";
import { Ruler } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
-import { useInsightStatus } from "@/hooks/use-insight-status";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
@@ -12,7 +11,7 @@ import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
-import { InsightStatusCard } from "@/components/insights/insight-status-card";
+import { SlugInsightStatusCard } from "@/components/insights/slug-insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricRangeControls } from "@/components/insights/metric-range-controls";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
@@ -45,8 +44,6 @@ export default function InsightsBmiPage() {
const { t } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
- const { data: status, isLoading: isStatusLoading } = useInsightStatus("bmi");
-
const { isEmpty } = useInsightsAnalytics("BMI");
// v1.4.27 F17 — BMI is derived from WEIGHT. When no weight readings
@@ -137,15 +134,7 @@ export default function InsightsBmiPage() {
- }
- text={status?.text ?? null}
- hasProvider={status?.hasProvider ?? false}
- updatedAt={status?.updatedAt ?? null}
- loading={isStatusLoading}
- preparing={status?.preparing ?? false}
- />
+ } />
);
}
diff --git a/src/app/insights/medications/page.tsx b/src/app/insights/medications/page.tsx
index 835f561e2..5c5e3227e 100644
--- a/src/app/insights/medications/page.tsx
+++ b/src/app/insights/medications/page.tsx
@@ -283,6 +283,18 @@ export default function InsightsMedikamentePage() {
+ {/* v1.4.25 W4d — GLP-1 therapy timeline. Self-hides for users
+ without an active GLP-1 medication, so the page collapses
+ back to the compliance grid for everyone else. */}
+
+
+ {/* v1.12.2 — the assessment is the LAST block on every bespoke
+ metric-detail page, matching the canonical spine the generic
+ scaffold renders. This card reads the medication-compliance
+ status route, which carries a richer envelope (`summary` +
+ per-medication `text`) than the standard `text`-only generators,
+ so it keeps its inline wiring rather than the shared
+ `` seam. */}
}
@@ -292,11 +304,6 @@ export default function InsightsMedikamentePage() {
loading={isStatusLoading}
preparing={status?.preparing ?? false}
/>
-
- {/* v1.4.25 W4d — GLP-1 therapy timeline. Self-hides for users
- without an active GLP-1 medication, so the page collapses
- back to the compliance grid for everyone else. */}
-
);
}
diff --git a/src/app/insights/mood/page.tsx b/src/app/insights/mood/page.tsx
index 9f3d3d9fc..a49461cbd 100644
--- a/src/app/insights/mood/page.tsx
+++ b/src/app/insights/mood/page.tsx
@@ -6,16 +6,15 @@ import Link from "next/link";
import { Smile } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
-import { useInsightStatus } from "@/hooks/use-insight-status";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { Button } from "@/components/ui/button";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
-import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { MoodInsightsSections } from "@/components/insights/mood/mood-insights-sections";
+import { SlugInsightStatusCard } from "@/components/insights/slug-insight-status-card";
import { SubPageShell } from "@/components/insights/sub-page-shell";
/**
@@ -45,8 +44,6 @@ export default function InsightsStimmungPage() {
const { t } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
- const { data: status, isLoading: isStatusLoading } = useInsightStatus("mood");
-
// Reuse the mother-page comprehensive query — TanStack Query
// dedups so this is a free cache read for the common case.
const { data: comprehensive } = useQuery({
@@ -110,22 +107,16 @@ export default function InsightsStimmungPage() {
userTimezone={user?.timezone}
/>
- {/* v1.8.7 — the AI assessment reads best directly under the first
- chart: the reader sees the trend, then the narration of it,
- before the calendar / distribution / correlation breakdowns. */}
- }
- text={status?.text ?? null}
- hasProvider={status?.hasProvider ?? false}
- updatedAt={status?.updatedAt ?? null}
- loading={isStatusLoading}
- preparing={status?.preparing ?? false}
- />
-
+
+ {/* v1.12.2 — the assessment is the LAST block on every bespoke
+ metric-detail page, matching the canonical spine the generic
+ scaffold (weight / bmi / pulse / blood-pressure) renders. The
+ reader sees the trend and the breakdown sections first, then the
+ narration of them at the foot. */}
+ } />
);
}
diff --git a/src/app/insights/pulse/page.tsx b/src/app/insights/pulse/page.tsx
index b2186faba..50834520c 100644
--- a/src/app/insights/pulse/page.tsx
+++ b/src/app/insights/pulse/page.tsx
@@ -4,13 +4,12 @@ import Link from "next/link";
import { Heart } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
-import { useInsightStatus } from "@/hooks/use-insight-status";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { Button } from "@/components/ui/button";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
-import { InsightStatusCard } from "@/components/insights/insight-status-card";
+import { SlugInsightStatusCard } from "@/components/insights/slug-insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
import { MetricPrimaryTile } from "@/components/insights/metric-primary-tile";
@@ -46,9 +45,6 @@ export default function InsightsPulsPage() {
const { t } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
- const { data: status, isLoading: isStatusLoading } =
- useInsightStatus("pulse");
-
// v1.4.25 W16a — VO2 max chart-row consumes the same `/api/analytics`
// bundle the mother page reads. Sharing the cache key keeps the
// payload single-fetch on tab navigation (React-Query unwraps from
@@ -171,15 +167,7 @@ export default function InsightsPulsPage() {
{/* v1.12.0 — Einschätzung is the last block on the canonical
metric-detail spine. */}
- }
- text={status?.text ?? null}
- hasProvider={status?.hasProvider ?? false}
- updatedAt={status?.updatedAt ?? null}
- loading={isStatusLoading}
- preparing={status?.preparing ?? false}
- />
+ } />
);
}
diff --git a/src/app/insights/weight/page.tsx b/src/app/insights/weight/page.tsx
index bce1b93a8..391c8de98 100644
--- a/src/app/insights/weight/page.tsx
+++ b/src/app/insights/weight/page.tsx
@@ -4,13 +4,12 @@ import Link from "next/link";
import { Scale } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
-import { useInsightStatus } from "@/hooks/use-insight-status";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { Button } from "@/components/ui/button";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
-import { InsightStatusCard } from "@/components/insights/insight-status-card";
+import { SlugInsightStatusCard } from "@/components/insights/slug-insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
import { MetricPrimaryTile } from "@/components/insights/metric-primary-tile";
@@ -40,9 +39,6 @@ export default function InsightsGewichtPage() {
const { t } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
- const { data: status, isLoading: isStatusLoading } =
- useInsightStatus("weight");
-
const { data: analytics, isEmpty } = useInsightsAnalytics("WEIGHT");
const weightSummary = analytics?.summaries?.WEIGHT ?? null;
const weightLastSeenAt =
@@ -128,15 +124,7 @@ export default function InsightsGewichtPage() {
off the overview onto its metric page). */}
- }
- text={status?.text ?? null}
- hasProvider={status?.hasProvider ?? false}
- updatedAt={status?.updatedAt ?? null}
- loading={isStatusLoading}
- preparing={status?.preparing ?? false}
- />
+ } />
);
}
diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx
index b36458230..251020397 100644
--- a/src/app/medications/page.tsx
+++ b/src/app/medications/page.tsx
@@ -256,7 +256,10 @@ export default function MedicationsPage() {
{t("medications.subtitle")}