From b87ded090cd8022aab4566e0561ce4326c38db0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 01:27:49 +0200 Subject: [PATCH 1/6] fix(notifications): bound the SMTP transport with explicit timeouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The email sender built its nodemailer transport without connection, greeting, or socket timeouts, so nodemailer's defaults applied — up to a 10-minute socket timeout. A mail server that accepts the TCP connection then stalls (firewalled relay, overloaded Postfix, blackholed smarthost) could hold a dispatch worker for minutes per notification, while every other egress in the cascade is bounded. Pin connection/greeting at 10s and socket at 20s so a stall maps to the transient send outcome the SMTP classifier already retries, matching the webhook sender's ethos. --- .../senders/__tests__/email.test.ts | 26 ++++++++++++++++--- src/lib/notifications/senders/email.ts | 8 ++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/lib/notifications/senders/__tests__/email.test.ts b/src/lib/notifications/senders/__tests__/email.test.ts index 53066c67a..e78403306 100644 --- a/src/lib/notifications/senders/__tests__/email.test.ts +++ b/src/lib/notifications/senders/__tests__/email.test.ts @@ -8,15 +8,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const sendMailMock = vi.fn(); -const createTransportMock = vi.fn((..._args: unknown[]) => ({ - sendMail: sendMailMock, -})); +const createTransportMock = vi.fn((options: unknown) => { + void options; + return { sendMail: sendMailMock }; +}); const loadEmailConfigMock = vi.fn(); const recordPushAttemptMock = vi.fn(); vi.mock("nodemailer", () => ({ default: { - createTransport: (...args: unknown[]) => createTransportMock(...args), + createTransport: (options: unknown) => createTransportMock(options), }, })); @@ -86,6 +87,23 @@ describe("sendViaEmail", () => { ); }); + it("builds the transport with explicit SMTP timeouts (no unbounded hang)", async () => { + loadEmailConfigMock.mockReturnValue(transport); + sendMailMock.mockResolvedValue({ messageId: "1" }); + + await sendViaEmail({ recipient: "you@example.com" }, payload()); + + expect(createTransportMock).toHaveBeenCalledTimes(1); + const opts = createTransportMock.mock.calls[0][0] as { + connectionTimeout?: number; + greetingTimeout?: number; + socketTimeout?: number; + }; + expect(opts.connectionTimeout).toBe(10_000); + expect(opts.greetingTimeout).toBe(10_000); + expect(opts.socketTimeout).toBe(20_000); + }); + it("soft-skips when SMTP is unconfigured (no channel burn)", async () => { loadEmailConfigMock.mockReturnValue(null); diff --git a/src/lib/notifications/senders/email.ts b/src/lib/notifications/senders/email.ts index 289c7490b..b072cb90b 100644 --- a/src/lib/notifications/senders/email.ts +++ b/src/lib/notifications/senders/email.ts @@ -55,6 +55,14 @@ function getTransporter(): { transporter: Transporter; from: string } | null { port: config.port, secure: config.secure, ...(config.auth ? { auth: config.auth } : {}), + // Bound every phase of the SMTP exchange. nodemailer's defaults (2 min + // connect, 30 s greeting, 10 min socket) would let a hung/blackholed relay + // pin a dispatch worker for minutes per send. These caps map a stall to the + // transient `SendOutcome` `classifySmtpError` already retries, matching the + // webhook sender's 5 s ethos. + connectionTimeout: 10_000, + greetingTimeout: 10_000, + socketTimeout: 20_000, }); cachedTransportKey = key; return { transporter: cachedTransporter, from: config.from }; From 33e7f2073652236159158ddc09532889f8b51e0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 01:27:57 +0200 Subject: [PATCH 2/6] perf(reminders): bound the Vorsorge cron scan to due reminders The measurement-reminder tick loaded every enabled reminder across all tenants every 15 minutes and gated due-ness in Node, so the shipped measurement_reminders_user_id_next_due_at_idx went unused. Add a nextDueAt bound (not null, lte now plus one tick of slack) to the query so Postgres scans only rows that could plausibly fire. nextDueAt is stamped at the notify-hour boundary, so anything due is already past; non-recurring reminders carry a null nextDueAt and can never fire, which the in-Node predicate already short-circuits. A reminder satisfied early re-anchors once it crosses due, before any nudge, so dropping future rows is safe. The in-Node hour gate stays the authoritative fire decision. --- .../__tests__/measurement-reminder.test.ts | 27 +++++++++++++++++++ src/lib/jobs/measurement-reminder.ts | 24 ++++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/lib/jobs/__tests__/measurement-reminder.test.ts b/src/lib/jobs/__tests__/measurement-reminder.test.ts index 5c6ad36d0..d64cd34aa 100644 --- a/src/lib/jobs/__tests__/measurement-reminder.test.ts +++ b/src/lib/jobs/__tests__/measurement-reminder.test.ts @@ -268,6 +268,33 @@ describe("runMeasurementReminderTick", () => { expect(updates[0].data.nextDueAt).toBeInstanceOf(Date); }); + it("bounds the scan with a nextDueAt filter so the index is used", async () => { + const { prisma } = makePrisma({ + reminders: [reminder({})], + measurementMatch: null, + }); + const dispatch = vi.fn(async () => OK); + + await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch }); + + const calls = prisma.measurementReminder.findMany.mock + .calls as unknown as unknown[][]; + const args = calls[0][0] as { + where: { + deletedAt: null; + enabled: boolean; + nextDueAt: { not: null; lte: Date }; + }; + }; + expect(args.where.enabled).toBe(true); + expect(args.where.deletedAt).toBeNull(); + expect(args.where.nextDueAt.not).toBeNull(); + // Floor is now + one tick of slack; anything due (<= floor) is included. + expect(args.where.nextDueAt.lte.getTime()).toBe( + NINE_LOCAL.getTime() + 15 * 60_000, + ); + }); + it("skips a reminder outside its notify-hour window", async () => { const { prisma } = makePrisma({ reminders: [reminder({ measurementType: null })], diff --git a/src/lib/jobs/measurement-reminder.ts b/src/lib/jobs/measurement-reminder.ts index 7223d76ec..2afa31687 100644 --- a/src/lib/jobs/measurement-reminder.ts +++ b/src/lib/jobs/measurement-reminder.ts @@ -41,6 +41,14 @@ import { type ReminderScheduleInput, } from "@/lib/measurement-reminders/scheduling"; +/** + * Slack added to `now` when scanning for due reminders, so a `nextDueAt` + * stamped just ahead of a tick still falls inside the same hour window. + * One tick interval (15 min) — small enough that the in-Node hour gate stays + * the authoritative fire decision. + */ +const DUE_QUERY_SLACK_MS = 15 * 60_000; + export interface MeasurementReminderSummary { candidatesScanned: number; inWindow: number; @@ -141,8 +149,22 @@ export async function runMeasurementReminderTick( failed: 0, }; + // Bound the scan to reminders that could plausibly fire this tick so Postgres + // uses `measurement_reminders_user_id_next_due_at_idx` instead of loading the + // whole cross-tenant enabled set 4×/hour. `nextDueAt` is stamped at the + // notify-hour boundary, so anything due is already <= now; the small slack + // (one tick interval) covers a stamp landing just ahead of a :00 tick. A + // null `nextDueAt` is a non-recurring reminder that can never fire — the + // in-Node `evaluateMeasurementReminderDue` short-circuits it anyway, so + // excluding it here is parity. Reminders satisfied early re-anchor once they + // cross due, before any nudge fires, so dropping future rows is safe. + const dueFloor = new Date(now.getTime() + DUE_QUERY_SLACK_MS); const reminders = await prisma.measurementReminder.findMany({ - where: { deletedAt: null, enabled: true }, + where: { + deletedAt: null, + enabled: true, + nextDueAt: { not: null, lte: dueFloor }, + }, include: { user: { select: { From 790fecddb843a352168219c816a8a2db9fdf3de4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 01:28:04 +0200 Subject: [PATCH 3/6] fix(doctor-report): aggregate sleep per night, not per stage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The doctor-report PDF computed SLEEP_DURATION min/avg/max over the raw per-stage rows (a 40-minute DEEP block, a 12-minute AWAKE block), so its sleep figure was per-stage-row minutes while every other surface — the dashboard slim slice, /insights/sleep, /api/sleep/night, the iOS feed — shows the reconstructed per-night asleep total. The sleep-stamping work rewrites exactly those raw rows, so the report could drift independently. Route the report sleep value through the night reconstruction engine, the same way the recovery score routes through the canonical recovery resolver — one number, every surface. The user's source priority feeds the reconstruction so the multi-source de-dup matches the dashboard. --- src/lib/__tests__/doctor-report-data.test.ts | Bin 10248 -> 12832 bytes src/lib/doctor-report-data.ts | 39 ++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/lib/__tests__/doctor-report-data.test.ts b/src/lib/__tests__/doctor-report-data.test.ts index a1fae17148cb5765f054afeb688a737d98baa530..9c3bfc1f14138b9310fca171e47f9f09f575f1e3 100644 GIT binary patch delta 2556 zcmaJ@O>f&q5Jj%ZrI)rqPXnDuSfXjwZDSb@px8p{Hnt2~X@D9=ye3!TBIGW+Tw1aN zgE#M z3!}@(oTZ%S$6}P2qKQw6GRv42w|wQ?+~geW*_mM@eyYYFHJo-|L(X-pKwuTe8Zeet;{rSe?d`CY2?_iC(;i%h9j)dg?ES@MJ7m~m+Gi@D{ z*bwTI!0JX^MjVD(W%f*^F;tq3=k20U!%{{@C<)Xa(v2qy)xlp{$PqG5r}LjRvmK<8 znDjf#nzK?N`I=J8`UgP98B)vRuIW@D`4|pRDwM`2s_A9?*3?@P7_}2k{E1nXb6`#V9rd^S)6FsyP^rPRyo{qty)eL zO)%^&Cn-#$<-F2vw=U_1v`aI%vJ0(N!OMi!N;`|IQlv3X(PFEubPDY4`i0<;ZV}3D z6Gt$uQa>1q)Nt+9Lhz(r*)njTkLpeM3_>>@zSC(fE4#*jq9LM;E_INx+*=<8ou{?$ z+i9rg{RremCEkqss${Y9clG6V19u2!-7Z}& zKJ|C28J^kH!NafjcBn1ic$?rx6b5ivQewJ?)=j!p2H3Q=>1BnxQ;cwNIgosk^T>Ga z>T{g=RbW)_my)I4IZo_FO>S-JD!2(ERZ8Qd<#XfEHW}Ii0U}ygRv!@rrU+om;GEfeVvOV5~~K5U!RKjeEgf$8J$f?_ON5mb|um zXa~$atLzmkxj=3;am<{E7Tb!jW(u<{xBAjnraG`)S}ynqM-oLi@q!?jIs4PvaUnPf z6U!Ag#F-1Z*1SEnMxIXGzZ+H*T+@x$u|?H?Wu$^s$q`oJ4oEJlSlr%xVl>ZqEm_y& Z-8y>>c43?lFKWs)eSJY6ufM$i>_7XzE7Je~ delta 17 ZcmZ3G(h;y>D&uAzrk|plmnl{W002XU2KE2| diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index 62ddc5ee0..f08da5c08 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -27,6 +27,10 @@ import type { MeasurementSource, } from "@/generated/prisma/client"; import { resolveCanonicalRecovery } from "@/lib/insights/derived/recovery-resolve"; +import { + reconstructSleepNights, + type SleepStageRow, +} from "@/lib/analytics/sleep-night"; import { DEFAULT_DOCTOR_REPORT_PREFS, type DoctorReportPrefs, @@ -685,6 +689,10 @@ export async function collectDoctorReportData( // v1.17 W1a — the user timezone anchors the ledger compliance // band minter (matches the detail page's dose-day attribution). timezone: true, + // v1.17.1 — source-priority feeds the per-night sleep reconstruction + // so the report's SLEEP_DURATION resolves the same canonical night + // (multi-source de-dup) the dashboard + iOS feed show. + sourcePriorityJson: true, // v1.7.0 — patient-identity fields for the export cover + FHIR // Patient. KVNR is encrypted (and not selected here) — the route // decrypts it and hands it to the builders. @@ -708,6 +716,36 @@ export async function collectDoctorReportData( }); } + // v1.17.1 parity — SLEEP_DURATION enters `byType` as RAW per-stage rows (a + // 40-min DEEP block, a 12-min AWAKE block, …). Every other sleep surface + // (dashboard slim slice, /insights/sleep, /api/sleep/night, the iOS feed) + // shows the per-night reconstructed asleep total via `summarizeSleepNights`, + // and the v1.17.1 stamping fixes rewrite exactly those raw stage rows. Route + // the report's sleep value through the same engine — one number, one surface + // — exactly as RECOVERY_SCORE routes through `summariseCanonicalRecovery`. + const reportTz = userProfile?.timezone ?? "Europe/Berlin"; + const sleepRows = measurements.filter( + (m) => m.type === "SLEEP_DURATION", + ) as unknown as SleepStageRow[]; + if (sleepRows.length > 0) { + const nights = reconstructSleepNights( + sleepRows, + reportTz, + userProfile?.sourcePriorityJson ?? null, + ).filter((n) => n.asleepMinutes > 0); + // Per-night asleep totals, ascending by night, replace the raw per-stage + // rows so the clinical vitals table reads time-asleep hours, not stage-row + // minutes. Empty (no scorable night) drops SLEEP_DURATION entirely. + if (nights.length > 0) { + byType.SLEEP_DURATION = nights.map((n) => ({ + value: n.asleepMinutes, + measuredAt: n.measuredAt.toISOString(), + })); + } else { + delete byType.SLEEP_DURATION; + } + } + // Per-type stats. const stats: Record = {}; for (const [type, entries] of Object.entries(byType)) { @@ -728,7 +766,6 @@ export async function collectDoctorReportData( // detail page (slot dedup, band timing, cadence honoured). As-needed / PRN // medications are excluded — no schedule, no expected dose, no fabricated // 100 % on a clinical report. The medication itself stays on the list. - const reportTz = userProfile?.timezone ?? "Europe/Berlin"; const compliance = buildLedgerCompliance( medications.map((m) => ({ id: m.id, From b336ed5b732d56e7c6196f688578045822fea1bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 01:28:12 +0200 Subject: [PATCH 4/6] fix(settings): confirm VAPID regenerate in-app; let labs subtitle wrap The VAPID regenerate overwrite guard used a native window.confirm for a subscription-invalidating destructive action, where the rest of the release uses the project's AlertDialog. Drive the overwrite confirm through an AlertDialog (calm in-app dialog, destructive action variant) and retry with force from its action. The /labs header subtitle used truncate, clipping mid-word in longer locales on a 375px viewport. Drop truncate so it wraps; the add button stays shrink-0 and the row aligns to the top. --- messages/de.json | 2 + messages/en.json | 2 + messages/es.json | 2 + messages/fr.json | 2 + messages/it.json | 2 + messages/pl.json | 2 + src/app/labs/page.tsx | 4 +- .../admin/web-push-vapid-section.tsx | 50 ++++++++++++++++--- 8 files changed, 58 insertions(+), 8 deletions(-) diff --git a/messages/de.json b/messages/de.json index 1d1ba962d..b847bccee 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4932,6 +4932,8 @@ "webPushVapidGenerated": "VAPID-Schlüssel erzeugt. Betreff speichern, um abzuschließen.", "webPushVapidGenerateFailed": "VAPID-Schlüssel konnten nicht erzeugt werden.", "webPushVapidGenerateConfirm": "VAPID-Schlüssel sind bereits konfiguriert. Ein neues Schlüsselpaar macht alle Browser-Push-Abos ungültig — jedes Gerät muss sich neu anmelden. Fortfahren?", + "webPushVapidGenerateConfirmTitle": "VAPID-Schlüssel neu erzeugen?", + "webPushVapidRegenerate": "Neu erzeugen", "configured": "Konfiguriert", "monitoringTestSuccess": "Testevent wurde gesendet", "monitoringTestFailed": "Monitoring-Test fehlgeschlagen", diff --git a/messages/en.json b/messages/en.json index 3a8b08b12..3613256a8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4932,6 +4932,8 @@ "webPushVapidGenerated": "VAPID keys generated. Save the subject to finish.", "webPushVapidGenerateFailed": "Could not generate VAPID keys.", "webPushVapidGenerateConfirm": "VAPID keys are already configured. Generating a new pair invalidates every browser push subscription — each device must re-subscribe. Continue?", + "webPushVapidGenerateConfirmTitle": "Regenerate VAPID keys?", + "webPushVapidRegenerate": "Regenerate", "configured": "Configured", "monitoringTestSuccess": "Test event sent", "monitoringTestFailed": "Monitoring test failed", diff --git a/messages/es.json b/messages/es.json index 210c603ad..fde506b9e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4932,6 +4932,8 @@ "webPushVapidGenerated": "Claves VAPID generadas. Guarda el asunto para terminar.", "webPushVapidGenerateFailed": "No se pudieron generar las claves VAPID.", "webPushVapidGenerateConfirm": "Las claves VAPID ya están configuradas. Generar un par nuevo invalida todas las suscripciones push del navegador: cada dispositivo debe volver a suscribirse. ¿Continuar?", + "webPushVapidGenerateConfirmTitle": "¿Regenerar las claves VAPID?", + "webPushVapidRegenerate": "Regenerar", "configured": "Configurado", "monitoringTestSuccess": "Evento de prueba enviado", "monitoringTestFailed": "La prueba de monitorización falló", diff --git a/messages/fr.json b/messages/fr.json index e73f56d89..e34333cd1 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4932,6 +4932,8 @@ "webPushVapidGenerated": "Clés VAPID générées. Enregistrez le sujet pour terminer.", "webPushVapidGenerateFailed": "Impossible de générer les clés VAPID.", "webPushVapidGenerateConfirm": "Les clés VAPID sont déjà configurées. Générer une nouvelle paire invalide tous les abonnements push du navigateur — chaque appareil doit se réabonner. Continuer ?", + "webPushVapidGenerateConfirmTitle": "Régénérer les clés VAPID ?", + "webPushVapidRegenerate": "Régénérer", "configured": "Configuré", "monitoringTestSuccess": "Événement de test envoyé", "monitoringTestFailed": "Échec du test de surveillance", diff --git a/messages/it.json b/messages/it.json index 5e20abdb5..e21d8c410 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4932,6 +4932,8 @@ "webPushVapidGenerated": "Chiavi VAPID generate. Salva l'oggetto per completare.", "webPushVapidGenerateFailed": "Impossibile generare le chiavi VAPID.", "webPushVapidGenerateConfirm": "Le chiavi VAPID sono già configurate. Generare una nuova coppia invalida tutte le sottoscrizioni push del browser: ogni dispositivo deve ri-sottoscriversi. Continuare?", + "webPushVapidGenerateConfirmTitle": "Rigenerare le chiavi VAPID?", + "webPushVapidRegenerate": "Rigenera", "configured": "Configurato", "monitoringTestSuccess": "Evento di prova inviato", "monitoringTestFailed": "Test di monitoraggio non riuscito", diff --git a/messages/pl.json b/messages/pl.json index f62178eb5..58998f833 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4932,6 +4932,8 @@ "webPushVapidGenerated": "Klucze VAPID wygenerowane. Zapisz temat, aby zakończyć.", "webPushVapidGenerateFailed": "Nie udało się wygenerować kluczy VAPID.", "webPushVapidGenerateConfirm": "Klucze VAPID są już skonfigurowane. Wygenerowanie nowej pary unieważnia wszystkie subskrypcje push w przeglądarce — każde urządzenie musi zasubskrybować ponownie. Kontynuować?", + "webPushVapidGenerateConfirmTitle": "Wygenerować nowe klucze VAPID?", + "webPushVapidRegenerate": "Wygeneruj ponownie", "configured": "Skonfigurowano", "monitoringTestSuccess": "Wysłano zdarzenie testowe", "monitoringTestFailed": "Test monitorowania nie powiódł się", diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx index 524e903e2..2030d0510 100644 --- a/src/app/labs/page.tsx +++ b/src/app/labs/page.tsx @@ -50,12 +50,12 @@ export default function LabsPage() { return (
-
+

{t("labs.title")}

-

+

{t("labs.subtitle")}

diff --git a/src/components/admin/web-push-vapid-section.tsx b/src/components/admin/web-push-vapid-section.tsx index e409d96f8..d67d49bc6 100644 --- a/src/components/admin/web-push-vapid-section.tsx +++ b/src/components/admin/web-push-vapid-section.tsx @@ -6,6 +6,16 @@ import { toast } from "sonner"; import { BellRing, KeyRound, Loader2 } from "lucide-react"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { Button } from "@/components/ui/button"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useTranslations } from "@/lib/i18n/context"; @@ -32,6 +42,11 @@ export function WebPushVapidSection() { string | null >(null); const [generating, setGenerating] = useState(false); + // Overwrite guard: the first generate returns 409 when keys already exist; + // we surface the project's AlertDialog (not a native window.confirm) before + // retrying with force, since regenerating invalidates every live Web-Push + // subscription. + const [overwriteConfirmOpen, setOverwriteConfirmOpen] = useState(false); const webPushVapidPublicKeyValue = webPushVapidPublicKeyDraft ?? settings?.webPushVapidPublicKey ?? ""; @@ -71,12 +86,10 @@ export function WebPushVapidSection() { ); if (res.status === 409) { - // Overwrite guard — existing keys would be replaced. Confirm with - // the operator (regenerating invalidates current subscriptions), - // then retry with force. - if (window.confirm(t("admin.webPushVapidGenerateConfirm"))) { - await generateVapidKeys(true); - } + // Overwrite guard — existing keys would be replaced. Surface the + // in-app confirm dialog (regenerating invalidates current + // subscriptions); the dialog action retries with force. + setOverwriteConfirmOpen(true); return; } @@ -201,6 +214,31 @@ export function WebPushVapidSection() { {t("common.save")}
+ + + + + + {t("admin.webPushVapidGenerateConfirmTitle")} + + + {t("admin.webPushVapidGenerateConfirm")} + + + + {t("common.cancel")} + void generateVapidKeys(true)} + > + {t("admin.webPushVapidRegenerate")} + + + +
); } From aeb30464ac738ec232bd3ce22e0da18ae2aac9a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 01:28:19 +0200 Subject: [PATCH 5/6] chore(observability): add headerValue to the redact denylist The webhook shared secret lives only inside the AES-GCM-encrypted channel config blob and the GET masks it, so no current path excerpts it into a wide event. Add headervalue to the sensitive-key denylist as defence in depth: if a future change ever routes a webhook body through the payload diagnostic, the secret redacts instead of landing verbatim. The Live Activity push token is already covered by the token pattern. --- .../observability/__tests__/redact-payload.test.ts | 14 ++++++++++++++ src/lib/observability/redact-payload.ts | 7 +++++++ 2 files changed, 21 insertions(+) diff --git a/src/lib/observability/__tests__/redact-payload.test.ts b/src/lib/observability/__tests__/redact-payload.test.ts index 713f47897..0f9491efb 100644 --- a/src/lib/observability/__tests__/redact-payload.test.ts +++ b/src/lib/observability/__tests__/redact-payload.test.ts @@ -163,4 +163,18 @@ describe("redactSensitiveFields", () => { benignField: "kept", }); }); + + it("redacts the webhook shared secret (headerValue) and the Live Activity token", () => { + const body = { + headerValue: "super-secret-webhook-key", + liveActivityPushToken: "abc123", + webhookUrl: "https://example.com/hook", + }; + expect(redactSensitiveFields(body)).toEqual({ + headerValue: "[redacted]", + // Covered by the existing /token/i pattern. + liveActivityPushToken: "[redacted]", + webhookUrl: "https://example.com/hook", + }); + }); }); diff --git a/src/lib/observability/redact-payload.ts b/src/lib/observability/redact-payload.ts index 1ee5992e8..6b7a1f4a7 100644 --- a/src/lib/observability/redact-payload.ts +++ b/src/lib/observability/redact-payload.ts @@ -65,6 +65,13 @@ export const SENSITIVE_KEY_PATTERNS: readonly RegExp[] = [ /progesteronetest/i, /contraceptive/i, /cervicalmucus/i, + // v1.17.1 — the webhook shared secret. `headerValue` lives only inside the + // AES-GCM-encrypted `NotificationChannel.config` blob and the GET masks it + // to `hasHeaderValue`, so no current path excerpts it. Defence-in-depth: if + // a future change routes a webhook body through `buildPayloadDiagnostic`, + // the secret redacts instead of landing verbatim. The Live Activity push + // token is already covered by `/token/i`. + /headervalue/i, ]; const REDACTED = "[redacted]"; From 1ed2e5d2a2d5269f5ac0ac98a06aa99888dfefeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 01:28:26 +0200 Subject: [PATCH 6/6] docs(recovery): note the date-anchor bucketing gap for negative-UTC users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Oura and Polar recovery rows are bucketed on the local day of their raw measuredAt, on the assumption that both stamp the wake morning. Polar always anchors at UTC midnight of the wake date and Oura falls back to the same anchor when no real wake instant is present, so for a user in a far negative UTC zone that anchor reads as the previous local evening and a single night can split across two recovery days near the date line. A correct fix needs an anchor-kind signal threaded through the recovery read so the date-anchored sources read their wake day in UTC while a real wake instant stays local — out of scope here. Document the assumption at the bucketing site; positive-UTC users are unaffected. --- src/lib/insights/derived/recovery-resolve.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/lib/insights/derived/recovery-resolve.ts b/src/lib/insights/derived/recovery-resolve.ts index b87298301..ce8cb09e0 100644 --- a/src/lib/insights/derived/recovery-resolve.ts +++ b/src/lib/insights/derived/recovery-resolve.ts @@ -76,6 +76,20 @@ function rankOf(source: MeasurementSource): number { * shifted forward by one before the local-day read — aligning it onto the same * wake day the WHOOP row for that night carries. Reading the key in the user's * timezone keeps a near-midnight or late re-score on the same night. + * + * KNOWN LIMITATION (05-M2, deferred to v1.17.2). OURA and POLAR are bucketed by + * the local day of their raw `measuredAt` on the assumption that both stamp the + * wake morning. Polar always anchors `measuredAt = ${date}T00:00:00.000Z` + * (UTC-midnight of the wake date); Oura prefers a real `bedtimeEnd` wake instant + * but FALLS BACK to the same UTC-midnight anchor. For a user in a far-negative + * UTC zone, a UTC-midnight anchor reads as the PREVIOUS local evening, so a + * Polar (or Oura-fallback) night can land one day earlier than the WHOOP/Oura + * night it should align with — splitting one physiological night across two + * recovery days near the date line. A correct fix needs an "anchor kind" + * (date-midnight vs real-instant) threaded through the recovery read so the + * date-anchored sources read their wake-day in UTC while WHOOP / Oura-bedtimeEnd + * stay local; that DTO change is out of scope for v1.17.1. Europe / positive-UTC + * users (the common case) are unaffected. */ function wakeDayKeyOf(d: Date, source: MeasurementSource, tz: string): string { const anchor =