From 087d0011fce1d66501ff396113854ded1897ff58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 24 Oct 2026 18:11:21 +0200 Subject: [PATCH 1/2] fix(deps): raise the floors for body-parser, fast-uri and undici 7 body-parser 2.3.0 (CVE-2026-111181, high) and fast-uri 3.1.8 (CVE-2026-110180, CVE-2026-110306) arrive under the MCP SDK on a production path. undici 7.30.0 (CVE-2026-109869, CVE-2026-109841, CVE-2026-109924) is the development-only copy under shadcn; the app's own undici 8 is already past the fix. Each bound moves the flagged copy and nothing else. --- pnpm-lock.yaml | 45 +++++++++++++++++++++++++++------------------ pnpm-workspace.yaml | 14 +++++++++----- 2 files changed, 36 insertions(+), 23 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88a0075bc..a47e436fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,12 +14,12 @@ overrides: nanoid@<3.3.18: ^3.3.18 '@hono/node-server@<1.19.15': ^1.19.15 picomatch@<4.0.4: ^4.0.4 - body-parser@<2.3.0: ^2.3.0 + body-parser@<2.4.0: ^2.4.0 source-map-js@<1.2.2: ^1.2.2 postcss-selector-parser@<7.1.6: ^7.1.6 smol-toml@<1.9.0: ^1.9.0 qs@<6.16.0: ^6.16.0 - fast-uri@<3.1.8: ^3.1.8 + fast-uri@<3.1.9: ^3.1.9 flatted@<3.4.2: ^3.4.2 proxy-addr@<2.0.8: ^2.0.8 mysql2@<3.22.0: ^3.22.0 @@ -35,7 +35,7 @@ overrides: brace-expansion@<1.1.21: ^1.1.21 brace-expansion@>=2.0.0 <2.1.7: ^2.1.7 brace-expansion@>=4.0.0 <5.0.12: ^5.0.12 - undici@>=7.0.0 <7.29.1: ^7.29.1 + undici@>=7.0.0 <7.30.1: ^7.30.1 vite@>=8.0.0 <8.1.6: ^8.1.6 patchedDependencies: @@ -3730,8 +3730,8 @@ packages: bn.js@4.12.3: resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==} - body-parser@2.3.0: - resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + body-parser@2.4.0: + resolution: {integrity: sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==} engines: {node: '>=18'} bowser@2.14.1: @@ -4555,8 +4555,8 @@ packages: fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - fast-uri@3.1.8: - resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} + fast-uri@3.1.9: + resolution: {integrity: sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==} fast-wrap-ansi@0.2.2: resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} @@ -6199,6 +6199,10 @@ packages: resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} engines: {node: '>=0.6'} + qs@6.16.1: + resolution: {integrity: sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==} + engines: {node: '>=0.6'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -6960,8 +6964,8 @@ packages: undici-types@8.9.0: resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} - undici@7.30.0: - resolution: {integrity: sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==} + undici@7.30.1: + resolution: {integrity: sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==} engines: {node: '>=20.18.1'} undici@8.12.3: @@ -7991,7 +7995,7 @@ snapshots: open: 8.4.2 picomatch: 4.0.7 systeminformation: 5.33.15 - undici: 7.30.0 + undici: 7.30.1 which: 4.0.0 yocto-spinner: 1.2.2 @@ -9986,7 +9990,7 @@ snapshots: stringify-object: 5.0.0 tsconfig-paths: 4.2.0 type-fest: 4.41.0 - undici: 7.30.0 + undici: 7.30.1 zod: 4.6.7 transitivePeerDependencies: - supports-color @@ -10740,7 +10744,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.8 + fast-uri: 3.1.9 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -10966,7 +10970,7 @@ snapshots: bn.js@4.12.3: {} - body-parser@2.3.0(supports-color@7.2.0): + body-parser@2.4.0(supports-color@7.2.0): dependencies: bytes: 3.1.2 content-type: 2.1.0 @@ -10974,7 +10978,7 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.7.3 on-finished: 2.4.1 - qs: 6.16.0 + qs: 6.16.1 raw-body: 3.0.2 type-is: 2.1.0 transitivePeerDependencies: @@ -11878,7 +11882,7 @@ snapshots: express@5.2.1(supports-color@7.2.0): dependencies: accepts: 2.0.0 - body-parser: 2.3.0(supports-color@7.2.0) + body-parser: 2.4.0(supports-color@7.2.0) content-disposition: 1.1.0 content-type: 1.0.5 cookie: 0.7.2 @@ -11962,7 +11966,7 @@ snapshots: dependencies: fast-string-truncated-width: 3.0.3 - fast-uri@3.1.8: {} + fast-uri@3.1.9: {} fast-wrap-ansi@0.2.2: dependencies: @@ -13471,6 +13475,11 @@ snapshots: es-define-property: 1.0.1 side-channel: 1.1.1 + qs@6.16.1: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + queue-microtask@1.2.3: {} radix-ui@1.8.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1): @@ -13918,7 +13927,7 @@ snapshots: stringify-object: 5.0.0 ts-morph: 26.0.0 tsconfig-paths: 4.2.0 - undici: 7.30.0 + undici: 7.30.1 validate-npm-package-name: 7.0.2 zod: 4.6.7 transitivePeerDependencies: @@ -14474,7 +14483,7 @@ snapshots: undici-types@8.9.0: {} - undici@7.30.0: {} + undici@7.30.1: {} undici@8.12.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b70efd59f..4c68bc572 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -30,7 +30,9 @@ overrides: # for. "@hono/node-server@<1.19.15": "^1.19.15" "picomatch@<4.0.4": "^4.0.4" - "body-parser@<2.3.0": "^2.3.0" + # CVE-2026-111181 (HIGH) raised the floor from 2.3.0. The copy arrives + # under express in the MCP SDK, on a production path. + "body-parser@<2.4.0": "^2.4.0" # CVE-2026-93749 (source-map-js < 1.2.2, HIGH, under postcss and the # Tailwind build), CVE-2026-104844 (postcss-selector-parser < 7.1.6, under # shadcn) and GHSA-r4xh-jqrq-34v2 (smol-toml < 1.9.0, under knip). All three @@ -46,8 +48,9 @@ overrides: # malformed authorities. It arrives under ajv, including the connector SDK's # copy, so it sits on a path this app exposes. # Authority injection through an unvalidated port in serialize, and a - # follow-up fixed in 3.1.8, raised the floor from 3.1.6. - "fast-uri@<3.1.8": "^3.1.8" + # follow-up fixed in 3.1.8, raised the floor from 3.1.6. CVE-2026-110180 and + # CVE-2026-110306 raised it again to 3.1.9. + "fast-uri@<3.1.9": "^3.1.9" "flatted@<3.4.2": "^3.4.2" # GHSA-jqcg-44mw-7w3h: IP spoofing through IPv4-mapped addresses. It # arrives under express in the MCP SDK; the app resolves client IPs itself, @@ -100,12 +103,13 @@ overrides: # Development-only copies (test containers, build tooling) that the # dependency alerts flag: gRPC under the test containers, brace-expansion on - # three major lines, and undici 7 next to the app's own undici 8. + # three major lines, and undici 7 next to the app's own undici 8 + # (CVE-2026-109869, CVE-2026-109841 and CVE-2026-109924 raised it to 7.30.1). "@grpc/grpc-js@>=1.14.0 <1.14.5": "^1.14.5" "brace-expansion@<1.1.21": "^1.1.21" "brace-expansion@>=2.0.0 <2.1.7": "^2.1.7" "brace-expansion@>=4.0.0 <5.0.12": "^5.0.12" - "undici@>=7.0.0 <7.29.1": "^7.29.1" + "undici@>=7.0.0 <7.30.1": "^7.30.1" # GHSA-587h-7wmj-h5pm (HIGH), GHSA-rq7h-c2jc-7f22, GHSA-r825-w8mm-5pxw, # GHSA-698v-4r3x-3vcv and GHSA-9jrq-w75r-8gcw: dev-server file serving, From a61fe1c715e22ebec73d92b51d3de33cc087d71b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 24 Oct 2026 18:28:19 +0200 Subject: [PATCH 2/2] fix(demo): give the short-window blood pressure a shape to show Every 90-day series in the seed is a mean-reverting walk, and around a settled target that is flat noise. A 7-day trend line over the last 30 days of blood pressure drew a straight line, which is the view the demo exists to show. A busy stretch three to four weeks back now lifts blood pressure and pulse and lets them settle again, the mood dips with it, and weekend readings sit a touch higher. Blood pressure also pulls back towards its target harder, so a seed no longer ends several mmHg above the band its stored texts describe; today's reading settles on the days after the stretch. The two stored texts that summarise the last weeks mention the stretch. An integration test runs the real seed and holds the 7-day mean to a visible swing and today's reading to the settled side of it. --- scripts/seed-demo.ts | 71 ++++++++++++++++++++--- tests/integration/demo-seed-shape.test.ts | 65 +++++++++++++++++++++ 2 files changed, 127 insertions(+), 9 deletions(-) create mode 100644 tests/integration/demo-seed-shape.test.ts diff --git a/scripts/seed-demo.ts b/scripts/seed-demo.ts index 51e780256..f81712b1d 100644 --- a/scripts/seed-demo.ts +++ b/scripts/seed-demo.ts @@ -225,11 +225,15 @@ function randomWalk( // fresh seed — iOS #33); clamping every step keeps the series inside the // same band the input validator enforces. clamp?: { min: number; max: number }, + // How hard each step pulls back towards the target. The default lets a + // series wander; blood pressure needs a firmer pull, or a seed could end + // several mmHg above the settled band its stored texts describe. + reversion = 0.03, ): number[] { const values: number[] = [start]; for (let i = 1; i < days; i++) { const prev = values[i - 1]; - const drift = (target - prev) * 0.03; // mean reversion + const drift = (target - prev) * reversion; const noise = (Math.random() - 0.5) * volatility; let next = Math.round((prev + drift + noise) * 10) / 10; if (clamp) next = Math.min(clamp.max, Math.max(clamp.min, next)); @@ -397,6 +401,23 @@ async function bulkInsert( } } +/** + * A busy stretch inside the 90-day window, in days before "now": blood + * pressure and pulse rise and settle again, and the mood dips with them. + * Without it every short-window series was flat noise around its target and a + * 7-day trend line over 30 days drew a straight line; with it the line has a + * shape to follow. It ends well before today, so the latest readings, the + * tiles and the stored texts still describe a settled, optimal band. + */ +const BUSY_STRETCH = { from: 27, to: 12 } as const; + +/** 0 outside the busy stretch, rising smoothly to 1 at its middle. */ +function busyStretch(daysBefore: number): number { + const { from, to } = BUSY_STRETCH; + if (daysBefore > from || daysBefore < to) return 0; + return Math.sin((Math.PI * (from - daysBefore)) / (from - to)) ** 2; +} + /** Pollen season bump: 0 outside `[from, to]`, peaking at `peak` mid-season. */ function pollenSeason( doy: number, @@ -563,6 +584,7 @@ export async function seed() { span, 3, VALUE_RANGES.BLOOD_PRESSURE_SYS, + 0.1, ); // Diastolic BP: 82 → settled ~76 (clamped) const diaBP = randomWalk( @@ -571,6 +593,7 @@ export async function seed() { span, 2.5, VALUE_RANGES.BLOOD_PRESSURE_DIA, + 0.1, ); // Spot pulse (daytime heart rate): ~72, gently lower const pulse = randomWalk(74, 70, span, 3, VALUE_RANGES.PULSE); @@ -597,9 +620,35 @@ export async function seed() { ); // Steps: 6500 → a solid ~9000 const steps = randomWalk(6500, 9000, span, 1500); - for (const series of [weights, sysBP, diaBP, pulse, restingHr]) { + // The busy stretch lifts blood pressure and pulse, and weekends read a + // touch higher than weekdays, as home readings tend to. + for (let i = 0; i < span; i++) { + const before = days - i; + const weekday = daysAgo(before).getDay(); + const weekend = weekday === 0 || weekday === 6 ? 1 : 0; + const busy = busyStretch(before); + const lift = ( + series: number[], + by: number, + range: { min: number; max: number }, + ) => { + series[i] = Math.min( + range.max, + Math.max(range.min, Math.round((series[i] + by) * 10) / 10), + ); + }; + lift(sysBP, busy * 12 + weekend * 2, VALUE_RANGES.BLOOD_PRESSURE_SYS); + lift(diaBP, busy * 7 + weekend * 1, VALUE_RANGES.BLOOD_PRESSURE_DIA); + lift(pulse, busy * 6, VALUE_RANGES.PULSE); + } + for (const series of [weights, restingHr]) { settleLatest(series); } + // Today's reading settles on the days after the busy stretch only, so it + // describes the band the series came back to. + for (const series of [sysBP, diaBP, pulse]) { + settleLatest(series, BUSY_STRETCH.to - 1); + } // Each series carries the source that writes it on a real account, the // way the integration writes it: the Withings scale and cuff (weight, @@ -1580,7 +1629,9 @@ export async function seed() { if (Math.random() < 0.05) continue; const date = daysAgo(days - i); - const rawScore = Math.round(Math.min(5, Math.max(1, moodTrend[i]))); + // The busy stretch costs about a point of mood at its height. + const moodScore = moodTrend[i] - 1.2 * busyStretch(days - i); + const rawScore = Math.round(Math.min(5, Math.max(1, moodScore))); const mood = moodOptions[5 - rawScore]; // invert: score 5 = SUPER_GUT const tags = tagOptions[5 - rawScore]; const loggedAt = new Date(date); @@ -3302,8 +3353,9 @@ export async function seed() { content: "How has my blood pressure been trending lately?", }, answer( - "Your blood pressure has been settling nicely. Over the last few " + - "weeks the morning readings have drifted into the optimal band, and " + + "Your blood pressure has been settling nicely. After a higher " + + "stretch about three weeks ago, the morning readings have come back " + + "down into the optimal band, and " + "your resting heart rate is sitting in a comfortable range too. " + "Keeping up the steady sleep and daily movement looks like it is " + "paying off — worth staying consistent with the morning measurements " + @@ -3675,10 +3727,11 @@ export async function seed() { { scope: "blood-pressure", text: - "Your blood pressure is averaging about 118/76 over the last few " + - "weeks — down roughly 10 mmHg systolic from where this quarter " + - "started, and now comfortably inside the optimal band. That is a " + - "real, earned shift, not day-to-day noise. Keeping the morning " + + "Your blood pressure is averaging about 118/76 over the last two " + + "weeks, back down after a busier stretch before that. It sits " + + "roughly 10 mmHg systolic below where this quarter started, " + + "comfortably inside the optimal band. That is a real, earned " + + "shift, not day-to-day noise. Keeping the morning " + "readings on a steady schedule will make the trend even easier to " + "follow.", }, diff --git a/tests/integration/demo-seed-shape.test.ts b/tests/integration/demo-seed-shape.test.ts new file mode 100644 index 000000000..849b1d9af --- /dev/null +++ b/tests/integration/demo-seed-shape.test.ts @@ -0,0 +1,65 @@ +/** + * The demo's short-window charts need a shape to show. Every 90-day series in + * the seed is a mean-reverting walk, and around a settled target that is flat + * noise: a 7-day trend line over the last 30 days of blood pressure drew a + * straight line, which is exactly the view the demo exists to show off. The + * seed now carries a busy stretch a few weeks back that lifts blood pressure + * and lets it settle again. + * + * This runs the real `scripts/seed-demo.ts` and holds the last 30 days of + * systolic readings to two things at once: the 7-day mean moves visibly, and + * today's reading has come back down from the stretch. Before the stretch the + * swing measured 2 to 3 mmHg; with it, about 8 to 14. The seed is random, so the + * bounds sit between the two rather than at either. + */ +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +let systolic: number[]; +let means: number[]; + +beforeAll(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + const { seed } = await import("../../scripts/seed-demo"); + await seed(); + const user = await prisma.user.findUniqueOrThrow({ + where: { username: "demo" }, + select: { id: true }, + }); + const since = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000); + const rows = await prisma.measurement.findMany({ + where: { + userId: user.id, + type: "BLOOD_PRESSURE_SYS", + source: "WITHINGS", + deletedAt: null, + measuredAt: { gte: since }, + }, + orderBy: { measuredAt: "asc" }, + select: { value: true }, + }); + systolic = rows.map((r) => Number(r.value)); + means = []; + for (let i = 6; i < systolic.length; i++) { + const week = systolic.slice(i - 6, i + 1); + means.push(week.reduce((a, b) => a + b, 0) / week.length); + } +}, 180_000); + +describe("demo seed, short-window shape", () => { + it("gives the 7-day systolic mean over 30 days a visible rise and fall", () => { + expect(systolic.length).toBeGreaterThanOrEqual(28); + const swing = Math.max(...means) - Math.min(...means); + expect(swing).toBeGreaterThanOrEqual(6); + }); + + it("brings today's systolic reading back down from the stretch", () => { + expect(systolic.at(-1)).toBeLessThanOrEqual(Math.max(...means) - 3); + }); +});