Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.production.example
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,17 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# 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
# -----------------------------------------------------------------------------
Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/api/openapi.yaml
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
127 changes: 127 additions & 0 deletions docs/ops/tls-cert-pin.md
Original file line number Diff line number Diff line change
@@ -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 2>/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="<current-leaf-pin>"
```

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="<old-pin>,<new-pin>"`. 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.
22 changes: 12 additions & 10 deletions messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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."
Expand Down
14 changes: 8 additions & 6 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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."
Expand Down
Loading
Loading