+
{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 };
+}
From 540abebf09acb1dff585b2d855587652f3f9445f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 5 Jun 2026 08:02:54 +0200
Subject: [PATCH 3/5] feat(ops): alarm on a TLS leaf SPKI change for pinned
native clients
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The native client SPKI-pins the server's TLS leaf certificate. When the
leaf auto-renews (GTS re-issues it roughly every 90 days) the leaf keypair
— and so the SPKI pin — changes, and a client shipped only the old pin
refuses to connect on the next renewal: a silent outage. Nothing
server-side signalled that a client pins the served leaf.
Add a pg-boss monitor that probes the served leaf every 6 hours, computes
its iOS SPKI pin (base64(sha256(DER subjectPublicKeyInfo)) via a raw TLS
socket + X509Certificate), and compares it against the operator's
known-good set in TLS_LEAF_SPKI_PINS (comma-separated for the dual-pin
renewal window). On a served pin outside the set it fails loud: a
tls.pin.leaf_changed wide-event annotation, a system.tls.pin_changed audit
row, and a high-priority SYSTEM_ALERT fanned out to admins through the
existing dispatcher idiom. Transient TLS/network failures annotate
tls.pin.probe_failed and never alarm.
The baseline is an env var, not a persisted last-seen row — a self-learning
baseline would silently adopt the first rotation and suppress the alarm the
pinned client needs; an unset baseline logs the served pin and warns rather
than auto-adopting. The runbook documents extraction, the re-pin + dual-pin
procedure (≥11 days before expiry, ship to TestFlight), and the assumption
that the probed host is the host the client connects to.
Wires the queue into the worker (allQueues + cron + worker), adds the
optional env to the manifest, compose whitelist, and .env example, and
covers the SPKI computation + change detection with a fixture-cert unit
test plus a queue-registration guard.
(cherry picked from commit cdb88cd77b88aec71ad2f21caf2b15c185dfc01e)
---
.env.production.example | 11 +
docker-compose.yml | 7 +
docs/ops/tls-cert-pin.md | 127 ++++++++++
messages/de.json | 4 +-
messages/en.json | 4 +-
messages/es.json | 4 +-
messages/fr.json | 4 +-
messages/it.json | 4 +-
messages/pl.json | 4 +-
scripts/env-manifest.json | 11 +
.../__tests__/tls-pin-monitor-queue.test.ts | 53 +++++
src/lib/jobs/reminder-worker.ts | 44 ++++
src/lib/jobs/tls-pin-monitor.ts | 225 ++++++++++++++++++
src/lib/tls/__tests__/leaf-spki.test.ts | 161 +++++++++++++
src/lib/tls/leaf-spki.ts | 182 ++++++++++++++
15 files changed, 839 insertions(+), 6 deletions(-)
create mode 100644 docs/ops/tls-cert-pin.md
create mode 100644 src/lib/jobs/__tests__/tls-pin-monitor-queue.test.ts
create mode 100644 src/lib/jobs/tls-pin-monitor.ts
create mode 100644 src/lib/tls/__tests__/leaf-spki.test.ts
create mode 100644 src/lib/tls/leaf-spki.ts
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/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/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 04aa733f6..86fcb9031 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -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 3858e3844..5d4da4ca3 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -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 e525013dd..81c68c037 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -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 84f5cf2ea..db919b66c 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -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 e2f2462a7..e50b0f5f8 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -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 63a80ff5a..647ee2e35 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -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/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/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..92b46ad36 100644
--- a/src/lib/jobs/reminder-worker.ts
+++ b/src/lib/jobs/reminder-worker.ts
@@ -70,6 +70,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 +494,10 @@ interface MoodReminderPayload {
triggeredAt: string;
}
+interface TlsPinMonitorPayload {
+ triggeredAt: string;
+}
+
// Re-export timezone utilities under local names for backward compatibility
const getUserTodayBounds = getUserTodayBoundsUtil;
@@ -1886,6 +1895,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 +2354,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 +2540,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 +2775,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/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);
+ });
+}
From 1a478d3028ebdcca708849cd7374d1efc8809721 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 5 Jun 2026 08:08:51 +0200
Subject: [PATCH 4/5] feat(whoop): native-scheme connect redirect and Bearer
connect ticket
Two connect-in-app enhancements for the native client.
return_scheme: GET /api/whoop/connect accepts an optional custom scheme,
threaded through the server-side OAuth-state row so it survives the round
trip without ever riding in the URL or cookie. When a valid scheme is
present, the callback's final redirect targets ://whoop?whoop=
connected|error&reason=... so an in-app web session auto-completes on its
custom-scheme match; absent or invalid, the web settings redirect is
unchanged. Schemes validate against a strict allowlist (custom app scheme
only, ^[a-z][a-z0-9.+-]*$, http/https/javascript/data/file/vbscript
rejected, pinned to dev.healthlog.app). A rejected scheme falls back to
the web redirect and never reflects an arbitrary value.
Bearer connect ticket: a purely Bearer-authenticated client holds no web
session cookie, so it cannot start the handshake through the cookie-gated
connect route. POST /api/whoop/connect/ticket (Bearer) mints a one-time,
~60s, opaque ticket bound to the user; only its HMAC-SHA256 hash is stored
(the raw value never persists). GET /api/whoop/connect?ticket=
accepts the ticket in lieu of a cookie, consumes it atomically (single-use,
reuse rejected), sets the nonce cookie, and 302s to WHOOP exactly as the
cookie path. Expired, consumed, or invalid tickets return a typed 401. The
two combine. The daily WHOOP OAuth-state cleanup also sweeps expired ticket
rows.
The ticket query param is added to the egress redaction denylist so the
opaque value never reaches logs or error reports.
Migration 0124 adds whoop_connect_tickets (id, user_id FK cascade,
token_hash unique, expires_at, consumed_at, created_at) and a nullable
return_scheme column on whoop_oauth_states.
Co-Authored-By: Claude Opus 4.8 (1M context)
(cherry picked from commit 221b362011d52dda314139e0be7cc195c47d016e)
---
.../migration.sql | 55 ++++
prisma/schema.prisma | 39 +++
.../whoop/callback/__tests__/route.test.ts | 128 +++++++++
src/app/api/whoop/callback/route.ts | 132 +++++++---
.../api/whoop/connect/__tests__/route.test.ts | 149 +++++++++++
src/app/api/whoop/connect/route.ts | 55 +++-
.../connect/ticket/__tests__/route.test.ts | 69 +++++
src/app/api/whoop/connect/ticket/route.ts | 49 ++++
src/lib/jobs/reminder-worker.ts | 7 +-
src/lib/jobs/whoop-oauth-state-cleanup.ts | 17 ++
src/lib/logging/__tests__/redact.test.ts | 4 +
src/lib/logging/redact.ts | 5 +-
.../whoop/__tests__/connect-ticket.test.ts | 93 +++++++
src/lib/whoop/__tests__/return-scheme.test.ts | 61 +++++
src/lib/whoop/connect-ticket.ts | 88 +++++++
src/lib/whoop/return-scheme.ts | 87 +++++++
.../integration/whoop-connect-ticket.test.ts | 245 ++++++++++++++++++
17 files changed, 1239 insertions(+), 44 deletions(-)
create mode 100644 prisma/migrations/0124_v1122_whoop_connect_ticket/migration.sql
create mode 100644 src/app/api/whoop/callback/__tests__/route.test.ts
create mode 100644 src/app/api/whoop/connect/__tests__/route.test.ts
create mode 100644 src/app/api/whoop/connect/ticket/__tests__/route.test.ts
create mode 100644 src/app/api/whoop/connect/ticket/route.ts
create mode 100644 src/lib/whoop/__tests__/connect-ticket.test.ts
create mode 100644 src/lib/whoop/__tests__/return-scheme.test.ts
create mode 100644 src/lib/whoop/connect-ticket.ts
create mode 100644 src/lib/whoop/return-scheme.ts
create mode 100644 tests/integration/whoop-connect-ticket.test.ts
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/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/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts
index 92b46ad36..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,
@@ -1729,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}`);
}
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/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();
+ });
+});
From 5d8a9799acf3bfc7863a5114a26e7dbb86903c5f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 5 Jun 2026 08:21:00 +0200
Subject: [PATCH 5/5] =?UTF-8?q?chore(release):=20v1.12.2=20=E2=80=94=20WHO?=
=?UTF-8?q?OP=20connect=20from=20the=20app,=20consistent=20assessments=20a?=
=?UTF-8?q?nd=20medications?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
CHANGELOG.md | 15 +++++++++++++++
docs/api/openapi.yaml | 2 +-
package.json | 2 +-
3 files changed, 17 insertions(+), 2 deletions(-)
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/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/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",