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")}

- diff --git a/src/components/insights/__tests__/insight-status-card.test.tsx b/src/components/insights/__tests__/insight-status-card.test.tsx index b58fcda94..6ecabe3fc 100644 --- a/src/components/insights/__tests__/insight-status-card.test.tsx +++ b/src/components/insights/__tests__/insight-status-card.test.tsx @@ -86,7 +86,8 @@ describe("", () => { it("renders the empty-text state without crashing on null", () => { const html = render(); - expect(html).toContain("No analysis yet."); + // v1.12.2 — the empty state shares the canonical assessment noun. + expect(html).toContain("No assessment yet."); }); it("never surfaces a cached badge — the card has no cached affordance", () => { diff --git a/src/components/insights/insight-status-card.tsx b/src/components/insights/insight-status-card.tsx index 0f1680ca2..b32396919 100644 --- a/src/components/insights/insight-status-card.tsx +++ b/src/components/insights/insight-status-card.tsx @@ -3,7 +3,8 @@ import { useEffect, useRef, useState } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { useTranslations } from "@/lib/i18n/context"; +import { formatRelativeTime } from "@/lib/i18n/relative-time"; import { stripChartTokens } from "@/lib/insights/chart-tokens"; import { cn } from "@/lib/utils"; import { useFeatureFlags } from "@/hooks/use-feature-flags"; @@ -154,6 +155,9 @@ export function InsightStatusCard({ // on the success card closes the load→loaded transition signal. - {t("insights.lastUpdated")}: {fmt.dateTime(updatedAt)} + {t("insights.lastUpdated")}: {formatRelativeTime(updatedAt, t)}

); } diff --git a/src/components/insights/slug-insight-status-card.tsx b/src/components/insights/slug-insight-status-card.tsx new file mode 100644 index 000000000..39c00311e --- /dev/null +++ b/src/components/insights/slug-insight-status-card.tsx @@ -0,0 +1,48 @@ +"use client"; + +import type { ReactNode } from "react"; + +import { + useInsightStatus, + type InsightStatusMetric, +} from "@/hooks/use-insight-status"; +import { useTranslations } from "@/lib/i18n/context"; +import { InsightStatusCard } from "@/components/insights/insight-status-card"; + +interface SlugInsightStatusCardProps { + /** The bespoke metric slug the `/api/insights/-status` route keys on. */ + slug: InsightStatusMetric; + /** The leading glyph (Scale, Smile, Pill, …). */ + icon: ReactNode; +} + +/** + * Shared mount for the bespoke per-metric assessment card. + * + * The five bespoke slug pages (`weight`, `bmi`, `pulse`, `blood-pressure`, + * `mood`) each hand-wired the same eight-prop `` block off + * `useInsightStatus(slug)`, with only the icon differing. This seam owns the + * hook + the `status?.x ?? default` prop-defaulting so the title and every + * field name live in one place — the sibling of ``, which + * does the same for the generic `useInsightMetricStatus(metric)` route. Each + * bespoke page drops ~9 lines and can no longer drift on a field name. + */ +export function SlugInsightStatusCard({ + slug, + icon, +}: SlugInsightStatusCardProps) { + const { t } = useTranslations(); + const { data: status, isLoading } = useInsightStatus(slug); + + return ( + + ); +} diff --git a/src/components/medications/__tests__/card-parts.test.tsx b/src/components/medications/__tests__/card-parts.test.tsx index 9cb63d44e..73744fd5f 100644 --- a/src/components/medications/__tests__/card-parts.test.tsx +++ b/src/components/medications/__tests__/card-parts.test.tsx @@ -51,9 +51,11 @@ describe("medication card-parts — shared presentational components", () => { expect(html).toContain("90%"); expect(html).toContain("88%"); expect(html).toContain("lucide-flame"); - // Canonical streak/warning token — NOT the Tailwind-stock drift. - expect(html).toContain("text-dracula-orange"); + // v1.12.2 — semantic warning token, NOT the Tailwind-stock drift nor the + // raw Dracula palette token. + expect(html).toContain("text-warning"); expect(html).not.toContain("text-orange-400"); + expect(html).not.toContain("text-dracula-orange"); }); it("compliance bars scale the row labels to the chosen windows", () => { @@ -93,15 +95,32 @@ describe("medication card-parts — shared presentational components", () => { expect(html).toContain("lucide-circle-check"); }); - it("status pill stamps the warning token + glyph when very late", () => { + it("status pill stamps the warning token + glyph when late (semantic, not Dracula yellow)", () => { + // v1.12.2 — the middle "late" tier converged off the lone + // `text-dracula-yellow` stray onto the semantic warning token. const html = render( , ); expect(html).toContain("text-warning"); + expect(html).not.toContain("text-dracula-yellow"); + expect(html).toContain("lucide-circle-alert"); + }); + + it("status pill stamps the destructive token + glyph when very late", () => { + // v1.12.2 — very-late is the most-urgent tier; it reads as destructive + // (red) so the pill is a clean success → warning → destructive ramp. + const html = render( + , + ); + expect(html).toContain("text-destructive"); expect(html).toContain("lucide-triangle-alert"); }); @@ -129,8 +148,9 @@ describe("medication card-parts — shared presentational components", () => { /** * Cross-variant streak-token parity: with a positive streak seeded, the - * flame on BOTH the generic and the GLP-1 card resolves to the canonical - * `text-dracula-orange`, and neither carries the legacy `text-orange-400`. + * flame on BOTH the generic and the GLP-1 card resolves to the semantic + * `text-warning` token (v1.12.2), and neither carries the legacy + * `text-orange-400` nor the raw `text-dracula-orange`. */ describe("streak-token parity — generic vs GLP-1 card", () => { const ramipril = { @@ -218,8 +238,9 @@ describe("streak-token parity — generic vs GLP-1 card", () => { for (const html of [ramiprilHtml, mounjaroHtml]) { expect(html).toContain("lucide-flame"); - expect(html).toContain("text-dracula-orange"); + expect(html).toContain("text-warning"); expect(html).not.toContain("text-orange-400"); + expect(html).not.toContain("text-dracula-orange"); } }); }); diff --git a/src/components/medications/__tests__/use-medication-intake.test.ts b/src/components/medications/__tests__/use-medication-intake.test.ts new file mode 100644 index 000000000..ede88e3a5 --- /dev/null +++ b/src/components/medications/__tests__/use-medication-intake.test.ts @@ -0,0 +1,194 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { QueryClient } from "@tanstack/react-query"; + +import { toast } from "sonner"; +import { + runRecordIntake, + runUndoIntake, +} from "@/components/medications/use-medication-intake"; + +/** + * v1.12.2 — the shared intake orchestration consumed by both the generic + * `` and the ``. The two cards used to + * inline their own `recordIntake`, and the GLP-1 copy never gained the + * v1.11.3 failure-toast (C1) or the Undo action (C2): a failed POST was + * swallowed silently and the success toast had no Undo. + * + * Because both cards now call the same `runRecordIntake`, these tests + * proving C1 + C2 fire on the shared path are the proof that the GLP-1 card + * behaves identically to the generic one — there is no second copy left to + * diverge. + * + * The repo has no `@testing-library/react` / `renderHook`, so the pure + * `run*` helpers take their dependencies (translator, query client) injected + * and `fetch` / `toast` are module-mocked. + */ + +vi.mock("sonner", () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +const t = (key: string, params?: Record) => + params?.name ? `${key}:${params.name}` : key; + +function fakeQueryClient(): QueryClient { + return { + invalidateQueries: vi.fn().mockResolvedValue(undefined), + } as unknown as QueryClient; +} + +const medication = { id: "med-1", name: "Mounjaro" }; + +beforeEach(() => { + vi.restoreAllMocks(); + vi.mocked(toast.success).mockClear(); + vi.mocked(toast.error).mockClear(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("runRecordIntake — shared C1 failure toast + C2 Undo", () => { + it("surfaces the failure toast and never the success toast on a non-ok POST", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ ok: false, json: vi.fn() }), + ); + const setIntakeLoading = vi.fn(); + const queryClient = fakeQueryClient(); + const onRecorded = vi.fn(); + + await runRecordIntake({ + medication, + skipped: false, + t, + queryClient, + setIntakeLoading, + undoIntake: vi.fn(), + onRecorded, + }); + + // C1 — the failed POST surfaces the failure toast (with the med name), + // and the success path never fires. + expect(toast.error).toHaveBeenCalledWith( + "medications.intakeToastFailed:Mounjaro", + ); + expect(toast.success).not.toHaveBeenCalled(); + // No follow-up (no injection-site prompt) on a failed record. + expect(onRecorded).not.toHaveBeenCalled(); + // The spinner is always cleared. + expect(setIntakeLoading).toHaveBeenLastCalledWith(null); + }); + + it("surfaces the failure toast when the POST throws (network error)", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline"))); + const setIntakeLoading = vi.fn(); + + await runRecordIntake({ + medication, + skipped: true, + t, + queryClient: fakeQueryClient(), + setIntakeLoading, + undoIntake: vi.fn(), + }); + + expect(toast.error).toHaveBeenCalledWith( + "medications.intakeToastFailed:Mounjaro", + ); + expect(toast.success).not.toHaveBeenCalled(); + expect(setIntakeLoading).toHaveBeenLastCalledWith(null); + }); + + it("shows a success toast carrying an Undo action whose onClick reverses the dose", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: { id: "evt-99" } }), + }), + ); + const undoIntake = vi.fn(); + const onRecorded = vi.fn(); + + await runRecordIntake({ + medication, + skipped: false, + t, + queryClient: fakeQueryClient(), + setIntakeLoading: vi.fn(), + undoIntake, + onRecorded, + }); + + // C2 — the success toast carries an Undo action. + expect(toast.success).toHaveBeenCalledTimes(1); + const [message, opts] = vi.mocked(toast.success).mock.calls[0]; + expect(message).toBe("medications.intakeToastTaken:Mounjaro"); + const action = opts?.action as + | { label: string; onClick: (e: never) => void } + | undefined; + expect(action?.label).toBe("medications.intakeUndo"); + + // Firing the action calls undoIntake with the just-created event id. + action?.onClick({} as never); + expect(undoIntake).toHaveBeenCalledWith("evt-99"); + + // The post-success follow-up receives the event id + skipped flag. + expect(onRecorded).toHaveBeenCalledWith("evt-99", false); + }); + + it("omits the Undo action when the POST body carries no event id", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: {} }), + }), + ); + + await runRecordIntake({ + medication, + skipped: false, + t, + queryClient: fakeQueryClient(), + setIntakeLoading: vi.fn(), + undoIntake: vi.fn(), + }); + + const [, opts] = vi.mocked(toast.success).mock.calls[0]; + expect(opts).toBeUndefined(); + }); +}); + +describe("runUndoIntake — shared soft-delete", () => { + it("reverts via DELETE and confirms on success", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + const queryClient = fakeQueryClient(); + + await runUndoIntake({ medication, eventId: "evt-1", t, queryClient }); + + expect(fetchMock).toHaveBeenCalledWith( + "/api/medications/med-1/intake/evt-1", + { method: "DELETE" }, + ); + expect(toast.success).toHaveBeenCalledWith("medications.intakeUndone"); + expect(queryClient.invalidateQueries).toHaveBeenCalled(); + }); + + it("surfaces the undo-failure toast on a non-ok DELETE", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false })); + + await runUndoIntake({ + medication, + eventId: "evt-1", + t, + queryClient: fakeQueryClient(), + }); + + expect(toast.error).toHaveBeenCalledWith("medications.intakeUndoFailed"); + expect(toast.success).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/medications/card-parts/medication-compliance-bars.tsx b/src/components/medications/card-parts/medication-compliance-bars.tsx index 4d6de452a..da65d000f 100644 --- a/src/components/medications/card-parts/medication-compliance-bars.tsx +++ b/src/components/medications/card-parts/medication-compliance-bars.tsx @@ -1,7 +1,7 @@ import { Flame } from "lucide-react"; import { Progress } from "@/components/ui/progress"; -import { useTranslations } from "@/lib/i18n/context"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; interface MedicationComplianceBarsProps { rate7: number; @@ -29,9 +29,11 @@ interface MedicationComplianceBarsProps { * 365-day long window. The labels are parametrised on the chosen day-counts * so each row names the window it actually covers. * - * The streak flame uses the canonical `text-dracula-orange` warning/streak - * token (globals.css). The generic card historically drifted onto Tailwind - * stock `text-orange-400`; unifying here closes that token gap. + * The streak flame uses the semantic `text-warning` token (an alias over + * Dracula orange in dark mode, AA-safe on the light card). The generic card + * historically drifted onto Tailwind stock `text-orange-400`, and the flame + * later carried the raw `text-dracula-orange` palette token; v1.12.2 routes + * it through the semantic vocabulary the rest of the status surface uses. */ export function MedicationComplianceBars({ rate7, @@ -41,16 +43,23 @@ export function MedicationComplianceBars({ longDays = 30, }: MedicationComplianceBarsProps) { const { t } = useTranslations(); + const fmt = useFormatters(); const shortLabel = t("medications.complianceWindow", { days: shortDays }); const longLabel = t("medications.complianceWindow", { days: longDays }); + // v1.12.2 — route the rate through the locale number formatter and round + // so a non-integer rate (e.g. 33.333) never leaks raw into the caption, + // matching how every other percentage renders. + const shortPct = fmt.number(Math.round(rate7)); + const longPct = fmt.number(Math.round(rate30)); + return (
{shortLabel} - {rate7}% + {shortPct}%
{/* aria-label so the bar has an accessible name. */} @@ -59,7 +68,7 @@ export function MedicationComplianceBars({
{longLabel} - {rate30}% + {longPct}%
@@ -68,7 +77,7 @@ export function MedicationComplianceBars({ row doesn't leave a residual gap below the bars. */} {streak > 0 && (
- + {streak} {t("medications.dayStreak")} diff --git a/src/components/medications/card-parts/medication-status-pill.tsx b/src/components/medications/card-parts/medication-status-pill.tsx index 521a05196..db96f15f0 100644 --- a/src/components/medications/card-parts/medication-status-pill.tsx +++ b/src/components/medications/card-parts/medication-status-pill.tsx @@ -30,12 +30,18 @@ export function MedicationStatusPill({

{status === "in_window" ? ( diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index 277362e4f..1d339193a 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -15,6 +15,7 @@ import { MedicationComplianceSkeleton, } from "@/components/medications/card-parts/medication-compliance-bars"; import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions"; +import { useMedicationIntake } from "@/components/medications/use-medication-intake"; import { MedicationNextLastSlot, useWeekdayLabel, @@ -186,7 +187,6 @@ export function Glp1MedicationCard({ const { t } = useTranslations(); const fmt = useFormatters(); const weekdayLabel = useWeekdayLabel(); - const [intakeLoading, setIntakeLoading] = useState(null); const [, forceUpdate] = useReducer((x: number) => x + 1, 0); // v1.8.5 — post-dose injection-site prompt (see medication-card.tsx). const [siteIntakeId, setSiteIntakeId] = useState(null); @@ -195,6 +195,19 @@ export function Glp1MedicationCard({ medication.deliveryForm === "INJECTION" && medication.trackInjectionSites === true; + // v1.12.2 — take / skip + failure-toast (C1) + Undo (C2) come from the + // SAME shared hook the generic card uses, closing the robustness gap + // where a failed GLP-1 POST was swallowed silently and the success toast + // carried no Undo. The card keeps its post-success injection-site prompt. + const { intakeLoading, recordIntake } = useMedicationIntake({ + medication, + onRecorded: (eventId, skipped) => { + if (!skipped && tracksInjection && eventId) { + setSiteIntakeId(eventId); + } + }, + }); + const { data: compliance } = useQuery({ queryKey: queryKeys.medicationCompliance(medication.id), queryFn: async () => { @@ -242,42 +255,6 @@ export function Glp1MedicationCard({ return () => clearInterval(interval); }, []); - async function recordIntake(skipped: boolean) { - const key = skipped ? "skip" : "take"; - setIntakeLoading(key); - try { - const res = await fetch(`/api/medications/${medication.id}/intake`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ skipped }), - }); - if (res.ok) { - toast.success( - t( - skipped - ? "medications.intakeToastSkipped" - : "medications.intakeToastTaken", - { name: medication.name }, - ), - ); - await invalidateKeys(queryClient, medicationDependentKeys); - // v1.8.5 — prompt (skippably) for the injection site on a taken - // dose when tracking is enabled. - if (!skipped && tracksInjection) { - try { - const json = await res.json(); - const eventId = json?.data?.id as string | undefined; - if (eventId) setSiteIntakeId(eventId); - } catch { - /* dose recorded; the site prompt is best-effort */ - } - } - } - } finally { - setIntakeLoading(null); - } - } - async function confirmInjectionSite(site: InjectionSiteKey) { const intakeId = siteIntakeId; if (!intakeId) return; diff --git a/src/components/medications/intake-import-dialog.tsx b/src/components/medications/intake-import-dialog.tsx index c54a237e5..41271a4ef 100644 --- a/src/components/medications/intake-import-dialog.tsx +++ b/src/components/medications/intake-import-dialog.tsx @@ -191,7 +191,7 @@ export function IntakeImportDialog({ /> {result && (

diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index c338661c5..13573dd12 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useReducer } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { Card, CardContent } from "@/components/ui/card"; +import { useMedicationIntake } from "@/components/medications/use-medication-intake"; import { cn } from "@/lib/utils"; import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader"; import { MedicationCardMenu } from "@/components/medications/medication-card-menu"; @@ -129,7 +130,6 @@ export function MedicationCard({ const { t, locale } = useTranslations(); const fmt = useFormatters(); const weekdayLabel = useWeekdayLabel(); - const [intakeLoading, setIntakeLoading] = useState(null); // v1.8.5 — post-dose injection-site prompt state. Holds the intake // event id returned by the take POST so the confirm handler can PATCH // the chosen site onto it. Null = dialog closed. @@ -139,6 +139,19 @@ export function MedicationCard({ medication.deliveryForm === "INJECTION" && medication.trackInjectionSites === true; + // v1.12.2 — intake take / skip + failure-toast (C1) + Undo (C2) live in + // a shared hook so the generic and GLP-1 cards can never re-diverge. The + // card keeps only its post-success follow-up: prompting (skippably) for + // the injection site on a taken dose when tracking is enabled. + const { intakeLoading, recordIntake } = useMedicationIntake({ + medication, + onRecorded: (eventId, skipped) => { + if (!skipped && tracksInjection && eventId) { + setSiteIntakeId(eventId); + } + }, + }); + const { data: compliance } = useQuery({ queryKey: queryKeys.medicationCompliance(medication.id), queryFn: async () => { @@ -168,86 +181,6 @@ export function MedicationCard({ return () => clearInterval(interval); }, []); - // v1.11.3 C2 — reverse the just-recorded intake via the soft-delete - // route. Surfaced from the success toast's Undo action so a misclicked - // take / skip no longer needs a history dive to correct. - async function undoIntake(eventId: string) { - try { - const res = await fetch( - `/api/medications/${medication.id}/intake/${eventId}`, - { method: "DELETE" }, - ); - if (!res.ok) { - toast.error(t("medications.intakeUndoFailed")); - return; - } - await invalidateKeys(queryClient, medicationDependentKeys); - toast.success(t("medications.intakeUndone")); - } catch { - toast.error(t("medications.intakeUndoFailed")); - } - } - - async function recordIntake(skipped: boolean) { - const key = skipped ? "skip" : "take"; - setIntakeLoading(key); - try { - const res = await fetch(`/api/medications/${medication.id}/intake`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ skipped }), - }); - // v1.11.3 C1 — a failed POST used to clear the spinner silently, so - // the user believed the dose was logged when it was not. Surface the - // failure and never show the success confirmation in that case. - if (!res.ok) { - toast.error( - t("medications.intakeToastFailed", { name: medication.name }), - ); - return; - } - // The POST returns the created event (`apiSuccess(event, 201)`); its - // id drives both the Undo affordance and the optional injection-site - // prompt below. - let eventId: string | undefined; - try { - const json = await res.json(); - eventId = json?.data?.id as string | undefined; - } catch { - /* dose recorded; the body is best-effort for the id */ - } - toast.success( - t( - skipped - ? "medications.intakeToastSkipped" - : "medications.intakeToastTaken", - { name: medication.name }, - ), - eventId - ? { - action: { - label: t("medications.intakeUndo"), - onClick: () => void undoIntake(eventId), - }, - } - : undefined, - ); - await invalidateKeys(queryClient, medicationDependentKeys); - // v1.8.5 — after a TAKEN dose on a tracking-enabled injection, - // prompt (skippably) for the site. The dialog PATCHes it onto - // the just-created event via the status-toggle route. - if (!skipped && tracksInjection && eventId) { - setSiteIntakeId(eventId); - } - } catch { - toast.error( - t("medications.intakeToastFailed", { name: medication.name }), - ); - } finally { - setIntakeLoading(null); - } - } - async function confirmInjectionSite(site: InjectionSiteKey) { const intakeId = siteIntakeId; if (!intakeId) return; diff --git a/src/components/medications/medication-detail-summary.tsx b/src/components/medications/medication-detail-summary.tsx index 641a235de..e5eae9751 100644 --- a/src/components/medications/medication-detail-summary.tsx +++ b/src/components/medications/medication-detail-summary.tsx @@ -66,11 +66,15 @@ export function MedicationDetailSummary({ ? t("medications.detail.status.paused") : t("medications.detail.status.ended"); + // v1.12.2 — use the semantic `bg-success` / `bg-warning` utilities (each a + // straight alias over the same colour the raw `hsl(var(--…))` resolved to) + // so the detail status dot speaks the same token vocabulary as the card + // status pill and streak instead of the raw HSL var form. const dotClass = status === "active" - ? "bg-[hsl(var(--success))]" + ? "bg-success" : status === "paused" - ? "bg-[hsl(var(--warning))]" + ? "bg-warning" : "bg-muted-foreground"; const cadenceLine = oneShot diff --git a/src/components/medications/use-medication-intake.ts b/src/components/medications/use-medication-intake.ts new file mode 100644 index 000000000..0a9de8a31 --- /dev/null +++ b/src/components/medications/use-medication-intake.ts @@ -0,0 +1,185 @@ +"use client"; + +import { useState } from "react"; +import { useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { useTranslations } from "@/lib/i18n/context"; +import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys"; + +type Translator = ( + key: string, + params?: Record, +) => string; + +interface MedicationIntakeIdentity { + id: string; + name: string; +} + +/** + * v1.12.2 — the take / skip + Undo intake orchestration shared by the + * generic {@link MedicationCard} and the {@link Glp1MedicationCard}. + * + * Both cards used to inline their own `recordIntake`, and the two copies + * drifted: the generic card gained the v1.11.3 failure-toast (C1) and the + * Undo action (C2) while the GLP-1 card silently swallowed a failed POST + * and offered no Undo — same "Taken" button, two behaviours. Lifting the + * logic here makes the two cards behave identically by construction. + * + * The hook owns: the in-flight loading key, the intake POST, the failure + * toast, the success toast carrying an Undo action, the soft-delete undo + * route, and the dependent-key invalidation. Each card keeps its own + * post-success hook (`onRecorded`) for card-specific follow-ups — today + * the optional injection-site prompt. + */ +interface UseMedicationIntakeParams { + medication: MedicationIntakeIdentity; + /** + * Card-specific follow-up after a successful record. Receives the + * created event's id (when the POST body carried one) and whether the + * dose was skipped. Used by the cards to open the injection-site prompt + * on a taken dose. The intake itself is already recorded + invalidated + * by the time this fires. + */ + onRecorded?: (eventId: string | undefined, skipped: boolean) => void; +} + +interface UseMedicationIntakeResult { + /** "take" | "skip" while the matching request is in flight, else null. */ + intakeLoading: string | null; + /** Record a take (skipped=false) or skip (skipped=true). */ + recordIntake: (skipped: boolean) => Promise; + /** Reverse a just-recorded intake via the soft-delete route. */ + undoIntake: (eventId: string) => Promise; +} + +/** + * Pure orchestration for a single intake record, with every dependency + * injected so it is unit-testable without a React render (the repo has no + * `renderHook`; the convention is SSR markup + direct invocation). The + * hook below is a thin wrapper that binds the real `fetch` / `toast` / + * translator / query client. + */ +export async function runRecordIntake(deps: { + medication: MedicationIntakeIdentity; + skipped: boolean; + t: Translator; + queryClient: QueryClient; + setIntakeLoading: (value: string | null) => void; + undoIntake: (eventId: string) => void | Promise; + onRecorded?: (eventId: string | undefined, skipped: boolean) => void; +}): Promise { + const { + medication, + skipped, + t, + queryClient, + setIntakeLoading, + undoIntake, + onRecorded, + } = deps; + + setIntakeLoading(skipped ? "skip" : "take"); + try { + const res = await fetch(`/api/medications/${medication.id}/intake`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ skipped }), + }); + // v1.11.3 C1 — a failed POST used to clear the spinner silently, so the + // user believed the dose was logged when it was not. Surface the failure + // and never show the success confirmation in that case. (The GLP-1 card + // missed this port until v1.12.2 lifted the logic here.) + if (!res.ok) { + toast.error(t("medications.intakeToastFailed", { name: medication.name })); + return; + } + // The POST returns the created event (`apiSuccess(event, 201)`); its id + // drives both the Undo affordance and the card's post-success hook + // (the optional injection-site prompt). + let eventId: string | undefined; + try { + const json = await res.json(); + eventId = json?.data?.id as string | undefined; + } catch { + /* dose recorded; the body is best-effort for the id */ + } + // v1.11.3 C2 — the success toast carries an Undo action so a misclicked + // take / skip no longer needs a history dive to correct. + toast.success( + t( + skipped + ? "medications.intakeToastSkipped" + : "medications.intakeToastTaken", + { name: medication.name }, + ), + eventId + ? { + action: { + label: t("medications.intakeUndo"), + onClick: () => void undoIntake(eventId), + }, + } + : undefined, + ); + await invalidateKeys(queryClient, medicationDependentKeys); + onRecorded?.(eventId, skipped); + } catch { + toast.error(t("medications.intakeToastFailed", { name: medication.name })); + } finally { + setIntakeLoading(null); + } +} + +/** + * Pure soft-delete undo for a just-recorded intake, dependencies injected + * for the same testability reason as {@link runRecordIntake}. + */ +export async function runUndoIntake(deps: { + medication: MedicationIntakeIdentity; + eventId: string; + t: Translator; + queryClient: QueryClient; +}): Promise { + const { medication, eventId, t, queryClient } = deps; + try { + const res = await fetch( + `/api/medications/${medication.id}/intake/${eventId}`, + { method: "DELETE" }, + ); + if (!res.ok) { + toast.error(t("medications.intakeUndoFailed")); + return; + } + await invalidateKeys(queryClient, medicationDependentKeys); + toast.success(t("medications.intakeUndone")); + } catch { + toast.error(t("medications.intakeUndoFailed")); + } +} + +export function useMedicationIntake({ + medication, + onRecorded, +}: UseMedicationIntakeParams): UseMedicationIntakeResult { + const queryClient = useQueryClient(); + const { t } = useTranslations(); + const [intakeLoading, setIntakeLoading] = useState(null); + + const undoIntake = (eventId: string) => + runUndoIntake({ medication, eventId, t, queryClient }); + + const recordIntake = (skipped: boolean) => + runRecordIntake({ + medication, + skipped, + t, + queryClient, + setIntakeLoading, + undoIntake, + onRecorded, + }); + + return { intakeLoading, recordIntake, undoIntake }; +} diff --git a/src/hooks/use-insight-status.ts b/src/hooks/use-insight-status.ts index db3b92279..43f93d702 100644 --- a/src/hooks/use-insight-status.ts +++ b/src/hooks/use-insight-status.ts @@ -102,7 +102,7 @@ export function nextStatusPollInterval( * `["insights", "weight-status", locale]` array typoed once is exactly * the class of bug `queryKeys` was introduced to defend against. */ -type InsightStatusMetric = +export type InsightStatusMetric = | "blood-pressure" | "weight" | "pulse" diff --git a/src/lib/jobs/__tests__/tls-pin-monitor-queue.test.ts b/src/lib/jobs/__tests__/tls-pin-monitor-queue.test.ts new file mode 100644 index 000000000..4c5a44a0e --- /dev/null +++ b/src/lib/jobs/__tests__/tls-pin-monitor-queue.test.ts @@ -0,0 +1,53 @@ +/** + * v1.12.2 ios-coord — TLS-pin-monitor queue registration guard. + * + * Same source-text-grep approach as the other queue-wiring guards: assert + * the queue is imported, registered in `allQueues`, scheduled, and wired to + * a `boss.work` handler — without booting pg-boss + Prisma. An unregistered + * queue silently never drains (the recurring v1.4.37 dead-queue bug), which + * would leave the pinned-leaf-rotation alarm dark. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const REMINDER_WORKER_PATH = join(__dirname, "..", "reminder-worker.ts"); +const workerSource = readFileSync(REMINDER_WORKER_PATH, "utf8"); + +describe("reminder-worker — tls-pin-monitor wiring", () => { + it("imports the queue symbols from the tls-pin-monitor module", () => { + expect(workerSource).toMatch( + /from\s*["']@\/lib\/jobs\/tls-pin-monitor["']/, + ); + expect(workerSource).toMatch(/\bTLS_PIN_MONITOR_QUEUE\b/); + expect(workerSource).toMatch(/\bTLS_PIN_MONITOR_CRON\b/); + expect(workerSource).toMatch(/\brunTlsPinMonitor\b/); + }); + + it("registers the tls-pin-monitor queue in the allQueues loop", () => { + const allQueuesMatch = workerSource.match( + /const allQueues\s*=\s*\[([\s\S]*?)\];/, + ); + expect(allQueuesMatch).not.toBeNull(); + expect(allQueuesMatch![1]).toMatch(/\bTLS_PIN_MONITOR_QUEUE\b/); + }); + + it("schedules the tls-pin-monitor cron", () => { + expect(workerSource).toMatch( + /\[TLS_PIN_MONITOR_QUEUE,\s*TLS_PIN_MONITOR_CRON\]/, + ); + }); + + it("registers a boss.work handler for the queue", () => { + expect(workerSource).toMatch( + /boss\.work[\s\S]{0,200}TLS_PIN_MONITOR_QUEUE[\s\S]{0,200}handleTlsPinMonitor/, + ); + }); + + it("runs the monitor pass inside the handler", () => { + expect(workerSource).toMatch( + /handleTlsPinMonitor[\s\S]{0,400}runTlsPinMonitor/, + ); + }); +}); diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index b674568ba..af50e25d6 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -31,7 +31,10 @@ import { enqueueBootTimeWhoopBackfill, type WhoopBackfillPayload, } from "@/lib/jobs/whoop-backfill"; -import { cleanupExpiredWhoopOAuthStates } from "@/lib/jobs/whoop-oauth-state-cleanup"; +import { + cleanupExpiredWhoopConnectTickets, + cleanupExpiredWhoopOAuthStates, +} from "@/lib/jobs/whoop-oauth-state-cleanup"; import { runFitbitPollCohort } from "@/lib/fitbit/sync"; import { FITBIT_BACKFILL_QUEUE, @@ -70,6 +73,11 @@ import { GEO_BACKFILL_QUEUE, GEO_BACKFILL_CRON, } from "@/lib/jobs/geo-backfill"; +import { + runTlsPinMonitor, + TLS_PIN_MONITOR_QUEUE, + TLS_PIN_MONITOR_CRON, +} from "@/lib/jobs/tls-pin-monitor"; import { PR_DETECTION_QUEUE, PR_DETECTION_CONCURRENCY, @@ -489,6 +497,10 @@ interface MoodReminderPayload { triggeredAt: string; } +interface TlsPinMonitorPayload { + triggeredAt: string; +} + // Re-export timezone utilities under local names for backward compatibility const getUserTodayBounds = getUserTodayBoundsUtil; @@ -1720,6 +1732,8 @@ async function handleWhoopOAuthStateCleanup( try { const deleted = await cleanupExpiredWhoopOAuthStates(p); evt.addMeta("whoop_oauth_state_cleanup_deleted", deleted); + const ticketsDeleted = await cleanupExpiredWhoopConnectTickets(p); + evt.addMeta("whoop_connect_ticket_cleanup_deleted", ticketsDeleted); } catch (err) { evt.addWarning(`whoop-oauth-state-cleanup failed: ${err}`); } @@ -1886,6 +1900,24 @@ async function handleGeoBackfill(jobs: Job[]) { }); } +async function handleTlsPinMonitor(jobs: Job[]) { + void jobs; + await withBackgroundEvent("job.tls_pin_monitor", async (evt) => { + const p = getWorkerPrisma(); + try { + const summary = await runTlsPinMonitor(p); + evt.addMeta("tls_pin_monitor_outcome", summary.outcome); + evt.addMeta("tls_pin_monitor_host", summary.host); + evt.addMeta("tls_pin_monitor_known_count", summary.knownPinCount); + } catch (err) { + // runTlsPinMonitor swallows probe failures internally; anything that + // escapes is unexpected. Log and move on so a one-off failure does not + // poison the queue and block the next tick. + evt.addWarning(`tls-pin-monitor failed: ${err}`); + } + }); +} + async function handlePrDetection( jobs: Job[], ) { @@ -2327,6 +2359,12 @@ export async function startReminderWorker() { HOST_METRIC_QUEUE, FEEDBACK_AGGREGATOR_QUEUE, GEO_BACKFILL_QUEUE, + // v1.12.2 ios-coord — TLS leaf SPKI-change monitor. The iOS client + // pins the served leaf certificate; this queue probes it every 6 h and + // alarms when the served SPKI leaves the operator's known-good set. The + // queue MUST be registered here or pg-boss never provisions it and the + // schedule below silently no-ops (the v1.4.37 dead-queue class). + TLS_PIN_MONITOR_QUEUE, PR_DETECTION_QUEUE, MEDICATION_INVENTORY_EXPIRE_QUEUE, // v1.4.46 — hourly auto-skip pass for stale unmarked intakes. @@ -2507,6 +2545,10 @@ export async function startReminderWorker() { // long tail of audit rows that landed with the offline MMDB // missing or the online provider unreachable. [GEO_BACKFILL_QUEUE, GEO_BACKFILL_CRON], + // v1.12.2 ios-coord — every-6-hour TLS leaf SPKI probe (:07 off the + // hourly sync crons). Surfaces a pinned-leaf rotation well inside the + // ≥11-day re-pin window the iOS release owner needs. + [TLS_PIN_MONITOR_QUEUE, TLS_PIN_MONITOR_CRON], // Fallback rescan every 30 minutes — protects against ingest paths // that ship measurements without enqueueing a per-user job. The // cron payload deliberately omits a `userId` so the handler iterates @@ -2738,6 +2780,13 @@ export async function startReminderWorker() { { localConcurrency: 1 }, handleGeoBackfill, ); + // v1.12.2 ios-coord — TLS leaf SPKI-change monitor. Single-flight: one + // short outbound TLS handshake per tick, no benefit to overlapping ticks. + await boss.work( + TLS_PIN_MONITOR_QUEUE, + { localConcurrency: 1 }, + handleTlsPinMonitor, + ); // v0.5.4 ios-coord — single-flight worker. localConcurrency=1 keeps // two reminder ticks from interleaving against the same user row; // the dedup ledger would still save us, but skipping the race here diff --git a/src/lib/jobs/tls-pin-monitor.ts b/src/lib/jobs/tls-pin-monitor.ts new file mode 100644 index 000000000..095fe8334 --- /dev/null +++ b/src/lib/jobs/tls-pin-monitor.ts @@ -0,0 +1,225 @@ +/** + * TLS leaf SPKI-change monitor. + * + * The native iOS client SPKI-pins the server's TLS LEAF certificate. When + * the leaf auto-renews (e.g. Google Trust Services re-issues it) the leaf + * keypair — and so the SPKI pin — changes, and a client shipped only the + * old pin will refuse to connect on the next renewal: a silent outage. + * + * This job probes the served leaf on a schedule, computes the iOS SPKI pin + * (`base64(sha256(DER subjectPublicKeyInfo))`), and compares it against the + * operator's known-good set in `TLS_LEAF_SPKI_PINS` (comma-separated to + * cover the dual-pin renewal window). When the served pin leaves that set, + * it fires LOUD: + * + * 1. a `tls.pin.leaf_changed` wide-event annotation (old set + served pin), + * 2. a `system.tls.pin_changed` audit-log row, and + * 3. a high-priority `SYSTEM_ALERT` to every ADMIN user, reusing the same + * admin-fan-out idiom as the deploy webhook + integration-status alarm. + * + * The runbook at `docs/ops/tls-cert-pin.md` documents the operator / iOS + * release-owner response (re-extract, dual-pin, ship to TestFlight) and how + * to set the baseline. + * + * BASELINE SOURCE — env var, deliberately, NOT a persisted last-seen row. + * A persisted auto-baseline would silently adopt the first rotated pin and + * suppress the very alarm the pinned iOS client needs; the env baseline is + * the single source of truth the operator also derives the shipped iOS pin + * set from, and an unset baseline fails LOUD ("not configured"), never by + * auto-adopting whatever it observed. + */ +import type { PrismaClient } from "@/generated/prisma/client"; +import { getEvent } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised"; +import { + fetchLeafSpki, + parseKnownPins, + isPinKnown, + resolveAppTlsTarget, + type LeafProbeResult, +} from "@/lib/tls/leaf-spki"; + +/** + * pg-boss queue name + cron. Every 6 hours at :07 (offset off the :00 / :05 + * / :08 hourly sync crons so the probe doesn't pile onto a busy boss poll). + * GTS leaves renew roughly every ~90 days; a 6-hour cadence surfaces a + * change well inside the ≥11-day re-pin window the runbook targets while + * costing one short outbound TLS handshake per tick. + */ +export const TLS_PIN_MONITOR_QUEUE = "tls-pin-monitor"; +export const TLS_PIN_MONITOR_CRON = "7 */6 * * *"; + +export interface TlsPinMonitorSummary { + /** "ok" served pin is in the known set; "changed" it is not; "skipped" no target / no baseline; "probe_failed" the TLS probe threw. */ + outcome: "ok" | "changed" | "skipped" | "probe_failed"; + host: string | null; + servedPin: string | null; + knownPinCount: number; + validTo: string | null; +} + +/** + * Fan out a high-priority SYSTEM_ALERT to every ADMIN user. Mirrors the + * deploy-webhook + integration-status admin-alert idiom: the dispatcher + * silently no-ops on a user with no configured channel, so this is safe + * even before any operator notification channel is wired. + */ +async function alertAdminsOfPinChange( + prisma: PrismaClient, + host: string, + probe: LeafProbeResult, + knownPins: string[], +): Promise { + const admins = await prisma.user.findMany({ + where: { role: "ADMIN" }, + select: { id: true }, + }); + + if (admins.length === 0) { + getEvent()?.addWarning( + "No admin user configured to alert about TLS leaf SPKI change", + ); + return; + } + + for (const admin of admins) { + await dispatchLocalisedNotification({ + userId: admin.id, + titleKey: "notifications.admin.tlsPinChangedTitle", + messageKey: "notifications.admin.tlsPinChangedBody", + params: { + host, + servedPin: probe.pin, + validTo: probe.validTo, + }, + metadata: { + source: "tls-pin-monitor", + host, + servedPin: probe.pin, + knownPins: knownPins.join(","), + validTo: probe.validTo, + fingerprint256: probe.fingerprint256, + }, + }); + } +} + +/** + * One monitor pass. Resolves the target host from the app URL, probes the + * served leaf, compares its SPKI pin against the baseline set, and on a + * change emits the wide-event annotation + audit row + admin alert. + * + * Never throws on a probe / TLS error — a transient handshake failure + * annotates `tls.pin.probe_failed` and returns; the alarm only fires on a + * confirmed pin that is genuinely absent from the known set. + */ +export async function runTlsPinMonitor( + prisma: PrismaClient, +): Promise { + const evt = getEvent(); + const target = resolveAppTlsTarget(); + + if (!target) { + // No HTTPS app URL configured (plain-HTTP LAN/VPN self-host) — there is + // no leaf to pin, so the monitor is a no-op. + evt?.addMeta("tls_pin_outcome", "skipped_no_https_target"); + return { + outcome: "skipped", + host: null, + servedPin: null, + knownPinCount: 0, + validTo: null, + }; + } + + const knownPins = parseKnownPins(process.env.TLS_LEAF_SPKI_PINS); + + let probe: LeafProbeResult; + try { + probe = await fetchLeafSpki(target.host, target.port); + } catch (err) { + // Transient TLS / network failure — surface but do not alarm. A real + // pin change is confirmed only by a successful probe returning a pin + // outside the known set. + evt?.setAction({ name: "tls.pin.probe_failed" }); + evt?.addMeta("tls_pin_outcome", "probe_failed"); + evt?.addMeta("tls_pin_host", target.host); + evt?.addWarning( + `tls-pin-monitor probe failed for ${target.host}:${target.port}: ${err instanceof Error ? err.message : String(err)}`, + ); + return { + outcome: "probe_failed", + host: target.host, + servedPin: null, + knownPinCount: knownPins.length, + validTo: null, + }; + } + + if (knownPins.length === 0) { + // Baseline not configured. Fail LOUD (no silent auto-adopt): record the + // served pin so the operator can seed TLS_LEAF_SPKI_PINS, but treat as a + // no-op alarm-wise — there is nothing to compare against yet. + evt?.addMeta("tls_pin_outcome", "skipped_no_baseline"); + evt?.addMeta("tls_pin_host", target.host); + evt?.addMeta("tls_pin_served", probe.pin); + evt?.addWarning( + `tls-pin-monitor: TLS_LEAF_SPKI_PINS is not configured; served leaf pin for ${target.host} is ${probe.pin} (set the env baseline to enable the alarm)`, + ); + return { + outcome: "skipped", + host: target.host, + servedPin: probe.pin, + knownPinCount: 0, + validTo: probe.validTo, + }; + } + + if (isPinKnown(probe.pin, knownPins)) { + evt?.addMeta("tls_pin_outcome", "ok"); + evt?.addMeta("tls_pin_host", target.host); + return { + outcome: "ok", + host: target.host, + servedPin: probe.pin, + knownPinCount: knownPins.length, + validTo: probe.validTo, + }; + } + + // ── Alarm: the served leaf SPKI is not in the pinned set ── + evt?.setAction({ name: "tls.pin.leaf_changed" }); + evt?.addMeta("tls_pin_outcome", "changed"); + evt?.addMeta("tls_pin_host", target.host); + evt?.addMeta("tls_pin_served", probe.pin); + evt?.addMeta("tls_pin_known", knownPins.join(",")); + evt?.addMeta("tls_pin_valid_to", probe.validTo); + + await auditLog("system.tls.pin_changed", { + details: { + host: target.host, + port: target.port, + servedPin: probe.pin, + knownPins, + validTo: probe.validTo, + fingerprint256: probe.fingerprint256, + }, + }); + + try { + await alertAdminsOfPinChange(prisma, target.host, probe, knownPins); + } catch (err) { + evt?.addWarning( + `tls-pin-monitor admin alert failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + return { + outcome: "changed", + host: target.host, + servedPin: probe.pin, + knownPinCount: knownPins.length, + validTo: probe.validTo, + }; +} diff --git a/src/lib/jobs/whoop-oauth-state-cleanup.ts b/src/lib/jobs/whoop-oauth-state-cleanup.ts index 62d2e2f0d..59ca863d1 100644 --- a/src/lib/jobs/whoop-oauth-state-cleanup.ts +++ b/src/lib/jobs/whoop-oauth-state-cleanup.ts @@ -21,3 +21,20 @@ export async function cleanupExpiredWhoopOAuthStates( }); return count; } + +/** + * v1.12.2 — sweep expired (or already-consumed-and-now-stale) + * `whoop_connect_tickets`. The tickets are single-use + ~60s-lived, so the + * connect route consumes the live ones; this only reaps rows whose `expiresAt` + * has passed (covers both never-used and consumed-then-expired). Same daily + * cadence as the OAuth-state sweep above. + */ +export async function cleanupExpiredWhoopConnectTickets( + prisma: PrismaClient, + now: Date = new Date(), +): Promise { + const { count } = await prisma.whoopConnectTicket.deleteMany({ + where: { expiresAt: { lt: now } }, + }); + return count; +} diff --git a/src/lib/logging/__tests__/redact.test.ts b/src/lib/logging/__tests__/redact.test.ts index 6ee7c294e..afbfa3ac7 100644 --- a/src/lib/logging/__tests__/redact.test.ts +++ b/src/lib/logging/__tests__/redact.test.ts @@ -34,6 +34,10 @@ describe("redactSecrets", () => { expect(redactSecrets("export ?insuranceNumber=A123456780&days=30")).toBe( "export ?insuranceNumber=[REDACTED]&days=30", ); + // v1.12.2 — one-time WHOOP connect ticket in the connect URL. + expect( + redactSecrets("GET /api/whoop/connect?ticket=abc_DEF-123&return_scheme=x"), + ).toBe("GET /api/whoop/connect?ticket=[REDACTED]&return_scheme=x"); }); it("redacts OpenAI and Anthropic API keys", () => { diff --git a/src/lib/logging/redact.ts b/src/lib/logging/redact.ts index ec35045ba..6aaa70f0b 100644 --- a/src/lib/logging/redact.ts +++ b/src/lib/logging/redact.ts @@ -93,8 +93,11 @@ export function redactSecrets(input: string): string { // insurance number) added as defence-in-depth: the value is // encrypted at rest and never deliberately logged, but a stray // query-string leak (`?kvnr=…`) is scrubbed at the egress boundary. + // v1.12.2 — `ticket` (the one-time WHOOP connect ticket rides in the + // `GET /api/whoop/connect?ticket=…` URL): scrub it so the opaque value + // never lands in `http.path`/error strings reaching Loki/Glitchtip. .replace( - /([?&])(secret|code|token|api[_-]?key|insurance(?:number)?|kvnr)=[^&\s]+/gi, + /([?&])(secret|code|token|ticket|api[_-]?key|insurance(?:number)?|kvnr)=[^&\s]+/gi, "$1$2=[REDACTED]", ) ); diff --git a/src/lib/tls/__tests__/leaf-spki.test.ts b/src/lib/tls/__tests__/leaf-spki.test.ts new file mode 100644 index 000000000..e10852fc8 --- /dev/null +++ b/src/lib/tls/__tests__/leaf-spki.test.ts @@ -0,0 +1,161 @@ +/** + * Unit coverage for the TLS leaf SPKI pin helpers. + * + * The SPKI computation is pinned against a static self-signed fixture + * certificate (RSA-2048, generated once with `openssl req -x509`). Its + * known pin is `base64(sha256(DER subjectPublicKeyInfo))` — the iOS pin + * convention. If the computation regresses (e.g. someone swaps in the raw + * EC point / RSA key from `getPeerCertificate().pubkey`, which is NOT the + * SPKI), this fixture catches it: the served-vs-pinned compare is the whole + * alarm, so the hash must match Apple's byte-for-byte. + */ +import { describe, expect, it } from "vitest"; +import { createHash, X509Certificate } from "node:crypto"; + +import { + spkiPinFromCertificate, + parseKnownPins, + isPinKnown, + resolveAppTlsTarget, +} from "@/lib/tls/leaf-spki"; + +// Self-signed RSA-2048 leaf, CN=test.healthlog.example. Static fixture — +// its SPKI pin is deterministic and recorded below. +const FIXTURE_CERT_PEM = `-----BEGIN CERTIFICATE----- +MIIDIzCCAgugAwIBAgIUQuLHUZ5FLA5N96Qc6686X5uyh70wDQYJKoZIhvcNAQEL +BQAwITEfMB0GA1UEAwwWdGVzdC5oZWFsdGhsb2cuZXhhbXBsZTAeFw0yNjA2MDUw +NTUzMjFaFw0zNjA2MDIwNTUzMjFaMCExHzAdBgNVBAMMFnRlc3QuaGVhbHRobG9n +LmV4YW1wbGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDhJFlWVLZC +rb/dVQltFOt1i1QWMRYLSRNXb5KL2XlAxDY3iTmalAwyss/Uh9PDXuavevtOhu/L +GNFoPdE2Y40WzLaJtmM+OagXzaZ2ZOEvhZjBn4yDI4y+6WuruHJ1KCi4777PGFFl +UiBKlGtcI1NyJbcGilrPV7a8YnAUYzbv7feTlVxg3td4d0fhzPCC7vgBORiaM8c7 +xDvDkwF+38/Nmx2td6qjqtI+c4+HN4XF1jiVV227DU2hD/Oe056oL46qrj9W1psY +h5G8xSH2eWGOKlhoD3hnSDs8CwxZsEBaq3ky2k+JXARcbpxXKHj2FkNfcdnKDaTh +y7EGhHJSy17tAgMBAAGjUzBRMB0GA1UdDgQWBBTNRvThVPTH/b8p+bqILqP7ZPws +IjAfBgNVHSMEGDAWgBTNRvThVPTH/b8p+bqILqP7ZPwsIjAPBgNVHRMBAf8EBTAD +AQH/MA0GCSqGSIb3DQEBCwUAA4IBAQDVHga3RM+ACgDMdNtZ8R+kFo9/A3/LMkHF +0S2kI7ZqwDiZC7OhSac4sbFRR69fWwJfG6ORuYEWXExxnQpKOzEu91n8PURGC5CA +lRtPm3Oqoat7W6rtJuef9xzFXWsgsFPfVzWqCpYR727ketFeS2uo2Onm7tl3uZMk +C02Ev7XglT2r7qmyuY2R+BPmCqOK8YG5w9001KzyYHNTVlr5a9+6R22vLNh6mj/y +bPFU8Ozb1ze27ORT1CoRPhDfrU3+UzOvd1/ENfSFhyZ3SFI9BGV0ezt/hCUXsyLA +5vU8y43XR8vAQdGOF43har8K3S4Ijjy1ruaKjWFkCC0rjStJAt/f +-----END CERTIFICATE-----`; + +const FIXTURE_PIN = "/W80wXNcE/ANQgZGEQK4grEcWul1vITYjPZ4HUvL090="; + +describe("spkiPinFromCertificate", () => { + it("computes the iOS pin from a PEM fixture", () => { + expect(spkiPinFromCertificate(FIXTURE_CERT_PEM)).toBe(FIXTURE_PIN); + }); + + it("computes the same pin from an X509Certificate instance", () => { + const x509 = new X509Certificate(FIXTURE_CERT_PEM); + expect(spkiPinFromCertificate(x509)).toBe(FIXTURE_PIN); + }); + + it("computes the same pin from the DER bytes", () => { + const x509 = new X509Certificate(FIXTURE_CERT_PEM); + expect(spkiPinFromCertificate(x509.raw)).toBe(FIXTURE_PIN); + }); + + it("matches base64(sha256(DER subjectPublicKeyInfo)) — the iOS convention", () => { + // Cross-check the pin against an independent computation off the DER + // SPKI so the test pins the *meaning*, not just the recorded constant. + const x509 = new X509Certificate(FIXTURE_CERT_PEM); + const spkiDer = x509.publicKey.export({ type: "spki", format: "der" }); + const expected = createHash("sha256").update(spkiDer).digest("base64"); + expect(spkiPinFromCertificate(FIXTURE_CERT_PEM)).toBe(expected); + }); + + it("is base64 and 44 chars (32-byte SHA-256 digest)", () => { + const pin = spkiPinFromCertificate(FIXTURE_CERT_PEM); + expect(pin).toMatch(/^[A-Za-z0-9+/]{43}=$/); + }); +}); + +describe("parseKnownPins", () => { + it("returns an empty set for unset / empty baseline", () => { + expect(parseKnownPins(undefined)).toEqual([]); + expect(parseKnownPins(null)).toEqual([]); + expect(parseKnownPins("")).toEqual([]); + expect(parseKnownPins(" ")).toEqual([]); + }); + + it("parses a single pin", () => { + expect(parseKnownPins(FIXTURE_PIN)).toEqual([FIXTURE_PIN]); + }); + + it("parses a comma-separated dual-pin window and trims whitespace", () => { + const next = "AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIIIJJJJKKK="; + expect(parseKnownPins(` ${FIXTURE_PIN} , ${next} `)).toEqual([ + FIXTURE_PIN, + next, + ]); + }); + + it("deduplicates repeated pins", () => { + expect(parseKnownPins(`${FIXTURE_PIN},${FIXTURE_PIN}`)).toEqual([ + FIXTURE_PIN, + ]); + }); +}); + +describe("isPinKnown — change detection", () => { + const known = parseKnownPins(FIXTURE_PIN); + + it("treats the served pin as known when it is in the set (no alarm)", () => { + expect(isPinKnown(FIXTURE_PIN, known)).toBe(true); + }); + + it("treats a rotated leaf pin as unknown (the alarm condition)", () => { + const rotated = "ZZZZYYYYXXXXWWWWVVVVUUUUTTTTSSSSRRRRQQQQPPP="; + expect(isPinKnown(rotated, known)).toBe(false); + }); + + it("treats any served pin as unknown when the baseline is empty", () => { + expect(isPinKnown(FIXTURE_PIN, [])).toBe(false); + }); + + it("matches against any pin in a dual-pin window", () => { + const next = "ZZZZYYYYXXXXWWWWVVVVUUUUTTTTSSSSRRRRQQQQPPP="; + const dual = parseKnownPins(`${FIXTURE_PIN},${next}`); + expect(isPinKnown(FIXTURE_PIN, dual)).toBe(true); + expect(isPinKnown(next, dual)).toBe(true); + }); +}); + +describe("resolveAppTlsTarget", () => { + it("prefers APP_URL over NEXT_PUBLIC_APP_URL", () => { + expect( + resolveAppTlsTarget("https://app.example.com", "https://other.example.com"), + ).toEqual({ host: "app.example.com", port: 443 }); + }); + + it("falls back to NEXT_PUBLIC_APP_URL when APP_URL is unset", () => { + expect(resolveAppTlsTarget(undefined, "https://pub.example.com")).toEqual({ + host: "pub.example.com", + port: 443, + }); + }); + + it("honours an explicit https port", () => { + expect(resolveAppTlsTarget("https://app.example.com:8443", undefined)).toEqual( + { host: "app.example.com", port: 8443 }, + ); + }); + + it("skips a plain-HTTP origin (no leaf to pin)", () => { + expect(resolveAppTlsTarget("http://lan.local", undefined)).toBeNull(); + }); + + it("skips a non-https candidate but uses the next https one", () => { + expect( + resolveAppTlsTarget("http://lan.local", "https://pub.example.com"), + ).toEqual({ host: "pub.example.com", port: 443 }); + }); + + it("returns null when nothing is configured", () => { + expect(resolveAppTlsTarget(undefined, undefined)).toBeNull(); + expect(resolveAppTlsTarget("not a url", "also bad")).toBeNull(); + }); +}); diff --git a/src/lib/tls/leaf-spki.ts b/src/lib/tls/leaf-spki.ts new file mode 100644 index 000000000..895f07161 --- /dev/null +++ b/src/lib/tls/leaf-spki.ts @@ -0,0 +1,182 @@ +/** + * TLS leaf SubjectPublicKeyInfo (SPKI) pin helpers. + * + * The native iOS client SPKI-pins the server's TLS **leaf** certificate. + * A pin is `base64(sha256(DER subjectPublicKeyInfo))` — the same value + * Apple's `NSPinnedDomains` / a manual `SecTrust` SPKI check computes, and + * the same value the HTTP `Public-Key-Pins` header used historically. + * + * When the leaf certificate auto-renews (e.g. Google Trust Services + * re-issues it), the leaf keypair — and therefore the SPKI hash — changes. + * A pinned client that has not been shipped the new pin will then refuse + * to connect: a silent outage on the next renewal. There is no server-side + * signal today that any client pins the served leaf, so this module lets a + * scheduled job fail loudly the moment the served SPKI leaves the operator's + * known-good set, giving the iOS release owner time to re-pin and ship a + * TestFlight build before the old pin is gone. + * + * CORRECTNESS NOTE — why `X509Certificate.publicKey.export({spki})`, not + * `tls.getPeerCertificate(true).pubkey`: + * + * `getPeerCertificate(detailed).pubkey` is NOT the DER `subjectPublicKeyInfo`. + * For an EC leaf it is only the raw ~65-byte EC point; for RSA it is the + * bare `RSAPublicKey` SEQUENCE. Hashing it yields a value that does NOT + * match the iOS pin. We therefore parse `cert.raw` (the leaf DER) through + * `crypto.X509Certificate` and export the public key as `spki`/`der`, which + * is exactly the `subjectPublicKeyInfo` Apple hashes. + */ +import { connect as tlsConnect, type PeerCertificate } from "node:tls"; +import { createHash, X509Certificate } from "node:crypto"; + +/** + * Compute the iOS SPKI pin for a leaf certificate supplied as DER bytes, + * a `crypto.X509Certificate`, or a PEM string. Returns + * `base64(sha256(DER subjectPublicKeyInfo))`. + */ +export function spkiPinFromCertificate( + cert: Buffer | X509Certificate | string, +): string { + const x509 = + cert instanceof X509Certificate ? cert : new X509Certificate(cert); + // `spki` + `der` => the DER subjectPublicKeyInfo, the exact byte sequence + // an iOS SPKI pin hashes. Do NOT substitute the raw EC point / RSA key. + const spkiDer = x509.publicKey.export({ type: "spki", format: "der" }); + return createHash("sha256").update(spkiDer).digest("base64"); +} + +/** + * Parse the operator's known-good pin set from the env baseline. + * + * `TLS_LEAF_SPKI_PINS` is comma-separated so the operator can hold BOTH + * the current leaf pin AND the next leaf pin during the dual-pin window + * that precedes a renewal — exactly mirroring the pin set shipped to the + * iOS app. An empty / unset value yields an empty set; the caller treats + * that as "baseline not configured" (a loud no-op, never a silent + * auto-adopt). + */ +export function parseKnownPins(raw: string | undefined | null): string[] { + if (!raw) return []; + const seen = new Set(); + for (const part of raw.split(",")) { + const pin = part.trim(); + if (pin.length > 0) seen.add(pin); + } + return [...seen]; +} + +/** + * Whether the served pin is in the operator's known-good set. A served pin + * that is NOT in the set is the alarm condition — the leaf the iOS app + * pins has rotated underneath the shipped pin set. + */ +export function isPinKnown(servedPin: string, knownPins: string[]): boolean { + return knownPins.includes(servedPin); +} + +/** + * Derive the public host (and port) the iOS client connects to from the + * app URL env. Prefers `APP_URL`, then `NEXT_PUBLIC_APP_URL` — the same + * precedence the passkey origin resolver uses. Returns `null` when no + * usable HTTPS URL is configured (plain-HTTP self-hosts have no leaf to + * pin, so the monitor is a no-op there). + */ +export function resolveAppTlsTarget( + appUrl: string | undefined = process.env.APP_URL, + publicAppUrl: string | undefined = process.env.NEXT_PUBLIC_APP_URL, +): { host: string; port: number } | null { + for (const candidate of [appUrl, publicAppUrl]) { + const trimmed = candidate?.trim(); + if (!trimmed) continue; + let url: URL; + try { + url = new URL(trimmed); + } catch { + continue; + } + // The iOS pin only matters for the TLS leaf; a plain-HTTP origin has + // no certificate to pin, so we skip it rather than probing :80. + if (url.protocol !== "https:") continue; + const port = url.port ? Number(url.port) : 443; + if (!Number.isFinite(port) || port <= 0) continue; + return { host: url.hostname, port }; + } + return null; +} + +export interface LeafProbeResult { + /** The iOS SPKI pin of the served leaf certificate. */ + pin: string; + /** Leaf certificate validity end, ISO 8601, for the re-pin deadline. */ + validTo: string; + /** SHA-256 fingerprint of the whole leaf cert, for cross-referencing. */ + fingerprint256: string; +} + +/** + * Open a raw TLS socket to `host:port`, read the served LEAF certificate, + * and compute its iOS SPKI pin. Works regardless of how the reverse proxy + * terminates TLS — we observe exactly the certificate a pinned client + * would, because we speak TLS to the same public endpoint it does. + * + * `rejectUnauthorized: false` is deliberate: we are inspecting the served + * leaf, not authenticating it. An expired / self-signed / chain-broken + * cert is itself signal the operator wants surfaced, not a reason to abort + * the probe before we can read the pin. The connection is read-only and + * torn down the instant the certificate is in hand. + */ +export function fetchLeafSpki( + host: string, + port = 443, + timeoutMs = 10_000, +): Promise { + return new Promise((resolve, reject) => { + const socket = tlsConnect( + { + host, + port, + servername: host, + rejectUnauthorized: false, + // No ALPN / no app data — we only need the handshake's leaf cert. + }, + () => { + try { + const cert: PeerCertificate = socket.getPeerCertificate(true); + if (!cert || !cert.raw) { + cleanup(); + reject(new Error("no peer certificate served")); + return; + } + const x509 = new X509Certificate(cert.raw); + const result: LeafProbeResult = { + pin: spkiPinFromCertificate(x509), + validTo: new Date(x509.validTo).toISOString(), + fingerprint256: x509.fingerprint256, + }; + cleanup(); + resolve(result); + } catch (err) { + cleanup(); + reject(err instanceof Error ? err : new Error(String(err))); + } + }, + ); + + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`TLS probe to ${host}:${port} timed out`)); + }, timeoutMs); + + function cleanup() { + clearTimeout(timer); + socket.removeListener("error", onError); + socket.destroy(); + } + + function onError(err: Error) { + cleanup(); + reject(err); + } + + socket.once("error", onError); + }); +} diff --git a/src/lib/whoop/__tests__/connect-ticket.test.ts b/src/lib/whoop/__tests__/connect-ticket.test.ts new file mode 100644 index 000000000..cb021d893 --- /dev/null +++ b/src/lib/whoop/__tests__/connect-ticket.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/auth/hmac", () => ({ + // Deterministic stand-in so the test can assert hash binding without the + // real HMAC key. `hash()` => `h:`. + hashToken: (raw: string) => `h:${raw}`, +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + whoopConnectTicket: { + create: vi.fn(), + updateMany: vi.fn(), + findUnique: vi.fn(), + }, + }, +})); + +import { + WHOOP_CONNECT_TICKET_TTL_MS, + mintWhoopConnectTicket, + consumeWhoopConnectTicket, +} from "../connect-ticket"; +import { prisma } from "@/lib/db"; + +const create = prisma.whoopConnectTicket.create as ReturnType; +const updateMany = prisma.whoopConnectTicket.updateMany as ReturnType< + typeof vi.fn +>; +const findUnique = prisma.whoopConnectTicket.findUnique as ReturnType< + typeof vi.fn +>; + +describe("WHOOP connect ticket", () => { + beforeEach(() => vi.clearAllMocks()); + + it("pins a ~60s TTL", () => { + expect(WHOOP_CONNECT_TICKET_TTL_MS).toBe(60 * 1000); + }); + + it("mints an opaque ticket and stores ONLY its hash, never the raw value", async () => { + create.mockResolvedValue({}); + const before = Date.now(); + const raw = await mintWhoopConnectTicket("u1"); + + // Opaque, high-entropy, URL-safe. + expect(raw).toMatch(/^[A-Za-z0-9_-]{43}$/); + + expect(create).toHaveBeenCalledTimes(1); + const arg = create.mock.calls[0][0].data; + expect(arg.userId).toBe("u1"); + expect(arg.tokenHash).toBe(`h:${raw}`); + // The raw ticket must NOT be persisted in any plaintext column — only the + // (here-stubbed) hash carries it. The row has exactly userId/tokenHash/ + // expiresAt; no field equals the raw value. + expect(Object.keys(arg).sort()).toEqual([ + "expiresAt", + "tokenHash", + "userId", + ]); + for (const [key, value] of Object.entries(arg)) { + if (key === "tokenHash") continue; + expect(value).not.toBe(raw); + } + expect(arg.expiresAt.getTime()).toBeGreaterThanOrEqual( + before + WHOOP_CONNECT_TICKET_TTL_MS - 50, + ); + }); + + it("consumes a valid ticket atomically and resolves the user", async () => { + updateMany.mockResolvedValue({ count: 1 }); + findUnique.mockResolvedValue({ userId: "u1" }); + + const res = await consumeWhoopConnectTicket("raw-ticket"); + expect(res).toEqual({ userId: "u1" }); + + // Atomic single-use: the WHERE pins unconsumed + unexpired, the data + // stamps consumedAt in the same statement. + const where = updateMany.mock.calls[0][0].where; + expect(where.tokenHash).toBe("h:raw-ticket"); + expect(where.consumedAt).toBeNull(); + expect(where.expiresAt.gt).toBeInstanceOf(Date); + expect(updateMany.mock.calls[0][0].data.consumedAt).toBeInstanceOf(Date); + }); + + it("rejects reuse / expired / unknown (updateMany matches 0 rows)", async () => { + updateMany.mockResolvedValue({ count: 0 }); + const res = await consumeWhoopConnectTicket("already-used"); + expect(res).toBeNull(); + // Never re-reads the user when nothing was consumed. + expect(findUnique).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/whoop/__tests__/return-scheme.test.ts b/src/lib/whoop/__tests__/return-scheme.test.ts new file mode 100644 index 000000000..65ea904cf --- /dev/null +++ b/src/lib/whoop/__tests__/return-scheme.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { + buildReturnSchemeRedirect, + validateReturnScheme, +} from "../return-scheme"; + +describe("validateReturnScheme", () => { + it("accepts the allowlisted native scheme (case-insensitive)", () => { + expect(validateReturnScheme("dev.healthlog.app")).toBe("dev.healthlog.app"); + expect(validateReturnScheme("DEV.HealthLog.App")).toBe("dev.healthlog.app"); + }); + + it("returns null for absent / empty input", () => { + expect(validateReturnScheme(null)).toBeNull(); + expect(validateReturnScheme(undefined)).toBeNull(); + expect(validateReturnScheme("")).toBeNull(); + }); + + it("rejects forbidden web/script schemes even if shaped like a scheme", () => { + for (const s of ["http", "https", "javascript", "data", "file", "vbscript"]) { + expect(validateReturnScheme(s)).toBeNull(); + } + }); + + it("rejects a syntactically valid but non-allowlisted custom scheme", () => { + expect(validateReturnScheme("com.evil.app")).toBeNull(); + expect(validateReturnScheme("myapp")).toBeNull(); + }); + + it("rejects malformed schemes (bad first char, spaces, slashes)", () => { + expect(validateReturnScheme("1app")).toBeNull(); + expect(validateReturnScheme("dev healthlog")).toBeNull(); + expect(validateReturnScheme("dev/healthlog")).toBeNull(); + expect(validateReturnScheme("dev:healthlog")).toBeNull(); + expect(validateReturnScheme("dev.healthlog.app://whoop")).toBeNull(); + }); + + it("rejects an over-long value", () => { + expect(validateReturnScheme("a".repeat(65))).toBeNull(); + }); +}); + +describe("buildReturnSchemeRedirect", () => { + it("builds the connected target", () => { + expect(buildReturnSchemeRedirect("dev.healthlog.app", "connected")).toBe( + "dev.healthlog.app://whoop?whoop=connected", + ); + }); + + it("builds the error target with an encoded reason", () => { + expect( + buildReturnSchemeRedirect("dev.healthlog.app", "error", "expired"), + ).toBe("dev.healthlog.app://whoop?whoop=error&reason=expired"); + }); + + it("defaults a missing reason to 'unknown'", () => { + expect(buildReturnSchemeRedirect("dev.healthlog.app", "error")).toBe( + "dev.healthlog.app://whoop?whoop=error&reason=unknown", + ); + }); +}); diff --git a/src/lib/whoop/connect-ticket.ts b/src/lib/whoop/connect-ticket.ts new file mode 100644 index 000000000..48548306e --- /dev/null +++ b/src/lib/whoop/connect-ticket.ts @@ -0,0 +1,88 @@ +/** + * v1.12.2 — one-time, Bearer-mintable WHOOP connect ticket. + * + * A purely Bearer-authenticated native client (no web-session cookie) cannot + * start the WHOOP OAuth handshake through `GET /api/whoop/connect` directly, + * because that route resolves the user from the session cookie. The ticket + * bridges that gap: the client mints one via an authenticated Bearer + * `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 cookie, marks it consumed, sets the nonce cookie, and 302s to WHOOP. + * + * Security shape (mirrors the Bearer-token storage pattern in + * `src/lib/auth/hmac.ts`): + * - The raw ticket is 32 random bytes → base64url; opaque, 256 bits. + * - Only its HMAC-SHA256 hash (keyed by `API_TOKEN_HMAC_KEY`) is persisted — + * a DB read cannot recover a usable ticket. + * - Single-use: consumption is an atomic conditional `updateMany` predicated + * on `consumedAt IS NULL` + `expiresAt > now()`; a `count` of 0 means the + * ticket was already consumed, expired, or never existed (all rejected). + * - Short-lived (~60s TTL). + */ +import { randomBytes } from "node:crypto"; +import { hashToken } from "@/lib/auth/hmac"; +import { prisma } from "@/lib/db"; + +/** 60-second TTL: long enough to hand off into the in-app web session, short + * enough that an intercepted ticket is near-useless. */ +export const WHOOP_CONNECT_TICKET_TTL_MS = 60 * 1000; + +/** Opaque raw ticket: 32 random bytes → 43 base64url chars (256 bits). */ +function mintRawTicket(): string { + return randomBytes(32).toString("base64url"); +} + +/** + * Mint and persist a single-use connect ticket for `userId`. Returns the raw + * opaque ticket exactly once; only its hash is stored. Caller is responsible + * for having authenticated the user (Bearer `requireAuth`). + */ +export async function mintWhoopConnectTicket(userId: string): Promise { + const raw = mintRawTicket(); + await prisma.whoopConnectTicket.create({ + data: { + userId, + tokenHash: hashToken(raw), + expiresAt: new Date(Date.now() + WHOOP_CONNECT_TICKET_TTL_MS), + }, + }); + return raw; +} + +/** + * Atomically consume a connect ticket. Resolves the owning `userId` only when + * the ticket exists, is unexpired, AND was not previously consumed — and in + * that same operation stamps `consumedAt`, so a concurrent or later second + * presentation of the same raw ticket finds zero matching rows and is + * rejected. Returns `null` for any invalid / expired / already-consumed + * ticket (the caller maps `null` to a typed 401). + */ +export async function consumeWhoopConnectTicket( + rawTicket: string, +): Promise<{ userId: string } | null> { + const tokenHash = hashToken(rawTicket); + const now = new Date(); + + // Single-statement atomic consume: the WHERE pins the still-usable + // predicate, so two racers can't both flip the same row. `updateMany` + // returns the affected count; 1 = we won, 0 = unusable. + const consumed = await prisma.whoopConnectTicket.updateMany({ + where: { + tokenHash, + consumedAt: null, + expiresAt: { gt: now }, + }, + data: { consumedAt: now }, + }); + + if (consumed.count !== 1) return null; + + // Re-read to recover the owning user. The row is now consumed; this read + // only resolves the userId for the connect handshake. + const row = await prisma.whoopConnectTicket.findUnique({ + where: { tokenHash }, + select: { userId: true }, + }); + return row ? { userId: row.userId } : null; +} diff --git a/src/lib/whoop/return-scheme.ts b/src/lib/whoop/return-scheme.ts new file mode 100644 index 000000000..95882652e --- /dev/null +++ b/src/lib/whoop/return-scheme.ts @@ -0,0 +1,87 @@ +/** + * v1.12.2 — `return_scheme` validation for the WHOOP connect-in-app flow. + * + * The native client may pass `?return_scheme=` to + * `GET /api/whoop/connect`. When a VALID scheme is supplied, the callback's + * FINAL redirect targets `://whoop?whoop=connected|error&reason=…` + * instead of the web settings URL, so `ASWebAuthenticationSession` + * auto-completes on its `callbackURLScheme` match. An invalid/absent scheme + * falls back to the existing web redirect — this helper NEVER throws and never + * reflects an arbitrary attacker-supplied scheme. + * + * Security: a custom app scheme only. We pin to a small allowlist (easy to + * extend) AND require the strict RFC-3986 scheme shape, with the dangerous + * web/script schemes explicitly rejected as defence-in-depth so a future + * allowlist edit can't accidentally admit `javascript:`/`data:`/etc. + */ + +/** + * RFC 3986 scheme grammar: ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ). + * Lower-cased before the test so the comparison is case-insensitive in effect. + */ +const SCHEME_PATTERN = /^[a-z][a-z0-9.+-]*$/; + +/** + * Schemes that must never be honoured even if some future edit widens the + * allowlist. `http`/`https` would turn the redirect into an open-redirect to + * an arbitrary web origin; the rest are classic XSS / local-resource vectors. + */ +const FORBIDDEN_SCHEMES: ReadonlySet = new Set([ + "http", + "https", + "javascript", + "data", + "file", + "vbscript", +]); + +/** + * Allowlist of custom app schemes we honour. Keep this small and explicit; + * add the exact scheme string a self-host / TestFlight build advertises as its + * `ASWebAuthenticationSession` `callbackURLScheme`. + */ +const ALLOWED_SCHEMES: ReadonlySet = new Set([ + // The native HealthLog client's advertised custom scheme. + "dev.healthlog.app", +]); + +/** + * Validate a raw `return_scheme` query value. Returns the normalised + * (lower-cased) scheme when it passes every gate, or `null` for absent / + * malformed / forbidden / non-allowlisted input. Callers treat `null` as + * "use the web redirect". + */ +export function validateReturnScheme( + raw: string | null | undefined, +): string | null { + if (!raw) return null; + // A leading-byte length cap keeps a pathological value out of the DB column + // and any downstream URL; real custom schemes are short. + if (raw.length > 64) return null; + + const scheme = raw.toLowerCase(); + if (!SCHEME_PATTERN.test(scheme)) return null; + if (FORBIDDEN_SCHEMES.has(scheme)) return null; + if (!ALLOWED_SCHEMES.has(scheme)) return null; + + return scheme; +} + +/** + * Build the native custom-scheme redirect target for the callback's FINAL + * redirect. `scheme` MUST already be a validated allowlisted scheme (caller + * passes the result of {@link validateReturnScheme}). Shape: + * `://whoop?whoop=connected` + * `://whoop?whoop=error&reason=` + */ +export function buildReturnSchemeRedirect( + scheme: string, + outcome: "connected" | "error", + reason?: string, +): string { + const query = + outcome === "error" + ? `whoop=error&reason=${encodeURIComponent(reason ?? "unknown")}` + : "whoop=connected"; + return `${scheme}://whoop?${query}`; +} diff --git a/tests/integration/whoop-connect-ticket.test.ts b/tests/integration/whoop-connect-ticket.test.ts new file mode 100644 index 000000000..915e88348 --- /dev/null +++ b/tests/integration/whoop-connect-ticket.test.ts @@ -0,0 +1,245 @@ +/** + * v1.12.2 — integration coverage for the WHOOP connect-in-app enhancements + * against real Postgres: + * + * - POST /api/whoop/connect/ticket (Bearer-auth) mints a single-use ticket; + * only its HMAC hash lands in `whoop_connect_tickets`, the raw value never. + * - GET /api/whoop/connect?ticket= resolves the user from the ticket IN + * LIEU of a session cookie, consumes it (consumedAt stamped), and 302s to + * WHOOP, stamping the `whoop_state` ledger row + nonce cookie. + * - A SECOND presentation of the same ticket is rejected (typed 401). + * - An expired ticket is rejected (typed 401). + * - `?return_scheme=dev.healthlog.app` is validated + persisted on the state + * row so the callback can drive the native custom-scheme redirect. + * + * The whole point of the ticket is the native-only user with NO web session + * cookie, so the connect call below carries no cookie at all. + */ +import { NextRequest } from "next/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +// Seed deterministic keys before any module that reads them lazily. +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +process.env.API_TOKEN_HMAC_KEY ??= + "test-hmac-key-whoop-connect-ticket-integration-32-bytes-12345678"; + +const { hashToken } = await import("@/lib/auth/hmac"); + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const TEST_USER_ID = "user-whoop-connect-ticket"; +const RAW_BEARER = "hlk_whoopconnectticketintegrationrawtokenvalue00000001"; +const WHOOP_AUTH_URL = "https://api.prod.whoop.com/oauth/oauth2/auth"; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + + const { encrypt } = await import("@/lib/crypto"); + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { + id: TEST_USER_ID, + username: "whoop-ticket-user", + email: "whoop-ticket@example.test", + role: "USER", + whoopClientIdEncrypted: encrypt("whoop-client-id"), + whoopClientSecretEncrypted: encrypt("whoop-client-secret"), + }, + }); + // Bearer token for the native client minting the ticket. + await prisma.apiToken.create({ + data: { + userId: TEST_USER_ID, + name: "native", + tokenHash: hashToken(RAW_BEARER), + permissions: ["*"], + }, + }); + + process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000"; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +async function mintTicketViaBearer(): Promise { + // Bearer only — no session cookie. + headerJar.set("authorization", `Bearer ${RAW_BEARER}`); + const { POST } = await import("@/app/api/whoop/connect/ticket/route"); + const res = await (POST as unknown as () => Promise)(); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { ticket: string } }; + headerJar.delete("authorization"); + return body.data.ticket; +} + +describe("WHOOP connect ticket lifecycle (real Postgres)", () => { + it("mints a ticket storing only its hash, never the raw value", async () => { + const ticket = await mintTicketViaBearer(); + expect(ticket).toMatch(/^[A-Za-z0-9_-]{43}$/); + + const prisma = getPrismaClient(); + const rows = await prisma.whoopConnectTicket.findMany({ + where: { userId: TEST_USER_ID }, + }); + expect(rows).toHaveLength(1); + expect(rows[0].tokenHash).toBe(hashToken(ticket)); + expect(rows[0].consumedAt).toBeNull(); + // The raw ticket is not stored in any column. + expect(JSON.stringify(rows[0])).not.toContain(ticket); + }); + + it("connect?ticket= consumes the ticket (no cookie) and 302s to WHOOP", async () => { + const ticket = await mintTicketViaBearer(); + cookieJar.clear(); // native-only: NO session cookie on the connect call + + const { GET } = await import("@/app/api/whoop/connect/route"); + const res = await GET( + new NextRequest( + `http://localhost/api/whoop/connect?ticket=${encodeURIComponent(ticket)}`, + ), + ); + + expect(res.status).toBe(307); + expect(res.headers.get("location")).toContain(WHOOP_AUTH_URL); + expect(res.headers.get("set-cookie")).toContain("whoop_state="); + + const prisma = getPrismaClient(); + // Ticket consumed. + const consumed = await prisma.whoopConnectTicket.findUnique({ + where: { tokenHash: hashToken(ticket) }, + }); + expect(consumed?.consumedAt).not.toBeNull(); + // State ledger row was minted for this user. + const states = await prisma.whoopOAuthState.findMany({ + where: { userId: TEST_USER_ID }, + }); + expect(states).toHaveLength(1); + expect(states[0].returnScheme).toBeNull(); + }); + + it("rejects a second use of the same ticket with a typed 401", async () => { + const ticket = await mintTicketViaBearer(); + cookieJar.clear(); + + const { GET } = await import("@/app/api/whoop/connect/route"); + const first = await GET( + new NextRequest( + `http://localhost/api/whoop/connect?ticket=${encodeURIComponent(ticket)}`, + ), + ); + expect(first.status).toBe(307); + + const second = await GET( + new NextRequest( + `http://localhost/api/whoop/connect?ticket=${encodeURIComponent(ticket)}`, + ), + ); + expect(second.status).toBe(401); + const body = (await second.json()) as { error: string | null }; + expect(body.error).toMatch(/invalid, expired, or already used/i); + + // No second state row was minted. + const prisma = getPrismaClient(); + const states = await prisma.whoopOAuthState.count({ + where: { userId: TEST_USER_ID }, + }); + expect(states).toBe(1); + }); + + it("rejects an expired ticket with a typed 401", async () => { + const prisma = getPrismaClient(); + // Hand-seed an already-expired ticket row. + const raw = "expired-raw-ticket-value-deadbeef"; + await prisma.whoopConnectTicket.create({ + data: { + userId: TEST_USER_ID, + tokenHash: hashToken(raw), + expiresAt: new Date(Date.now() - 1000), + }, + }); + cookieJar.clear(); + + const { GET } = await import("@/app/api/whoop/connect/route"); + const res = await GET( + new NextRequest( + `http://localhost/api/whoop/connect?ticket=${encodeURIComponent(raw)}`, + ), + ); + expect(res.status).toBe(401); + // The expired row was not consumed (consumedAt stays null). + const row = await prisma.whoopConnectTicket.findUnique({ + where: { tokenHash: hashToken(raw) }, + }); + expect(row?.consumedAt).toBeNull(); + }); + + it("persists a valid return_scheme alongside a ticket connect", async () => { + const ticket = await mintTicketViaBearer(); + cookieJar.clear(); + + const { GET } = await import("@/app/api/whoop/connect/route"); + const res = await GET( + new NextRequest( + `http://localhost/api/whoop/connect?ticket=${encodeURIComponent(ticket)}&return_scheme=dev.healthlog.app`, + ), + ); + expect(res.status).toBe(307); + + const prisma = getPrismaClient(); + const states = await prisma.whoopOAuthState.findMany({ + where: { userId: TEST_USER_ID }, + }); + expect(states).toHaveLength(1); + expect(states[0].returnScheme).toBe("dev.healthlog.app"); + }); + + it("drops a forbidden return_scheme (http) to null", async () => { + const ticket = await mintTicketViaBearer(); + cookieJar.clear(); + + const { GET } = await import("@/app/api/whoop/connect/route"); + const res = await GET( + new NextRequest( + `http://localhost/api/whoop/connect?ticket=${encodeURIComponent(ticket)}&return_scheme=http`, + ), + ); + expect(res.status).toBe(307); + + const prisma = getPrismaClient(); + const states = await prisma.whoopOAuthState.findMany({ + where: { userId: TEST_USER_ID }, + }); + expect(states[0].returnScheme).toBeNull(); + }); +});