Merge pull request #1265 from MBombeck/fix/v1441-next-security

Release v1.44.1
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-10-22 12:26:44 +02:00
commit f08277e595
13 files changed
+391 -137

No files matched your search

+16 -16
View File
@@ -14,9 +14,10 @@ updates:
# single bumps: patch and minor updates travel together and pass the
# same checks once. Majors stay single so each gets its own review.
#
# The Next.js line travels alone: while a 16.4 patch is still being
# re-measured, a red Next bump inside the group holds every harmless
# update in it red as well.
# The Next.js line travels alone. Its bumps are the ones that move
# URL state after history traversal (see the note on 16.4 below), and a
# red Next bump inside the group would hold every harmless update in
# it red as well.
production-weekly:
dependency-type: "production"
update-types:
@@ -32,19 +33,18 @@ updates:
exclude-patterns:
- "eslint-config-next"
- "@next/bundle-analyzer"
ignore:
# Next 16.4.0 breaks URL state after history traversal: with a sheet
# opened through `?doc=`, Back leaves it open, and `[` / `]` sometimes
# do not move `?day=`. Reproduced in three end-to-end journeys (3 of 40
# repeated runs on 16.4.0, 0 of 80 on 16.3.8 with everything else
# equal). 16.3.8 carries every published security fix, so the line
# waits for a 16.4 patch; the two packages that move with it wait too.
- dependency-name: "next"
versions: ["16.4.0"]
- dependency-name: "eslint-config-next"
versions: ["16.4.0"]
- dependency-name: "@next/bundle-analyzer"
versions: ["16.4.0"]
# No ignore for the Next.js line any more. 16.4.0 lost URL state after
# history traversal: with a sheet opened through `?doc=`, Back left it
# open, and `[` / `]` sometimes did not move `?day=` (3 of 40 repeated
# runs of three end-to-end journeys, 0 of 80 on 16.3.8). 16.4.2 does the
# same (the day keys lost a step in 2 of 20 runs locally, the `?doc=`
# journey failed once in CI). Both were ours to fix: the sheet followed
# `useSearchParams`, which 16.4 updates after the write it copies, and a
# day step wrote the URL before Next had adopted the previous step, whose
# late adoption then put the older URL back. The sheet now follows the
# address bar and a step waits for the layer. Measured after both fixes:
# 200 of 200 repeated runs of the three journeys, and another 80 of 80
# each for the `?doc=` and day-key journeys.
- package-ecosystem: "github-actions"
directory: "/"
schedule:
+32
View File
@@ -1,5 +1,37 @@
# Changelog
## [1.44.1] — 2026-10-22
### Security
- **Next.js 16.4.2.** The framework fixed two critical advisories that
allow unauthenticated remote code execution, one in `use cache` functions
that return typed arrays (GHSA-673q-mjm6-r5xr) and one in the image
optimizer when it handles SVG (GHSA-2464-fhr4-w9xw), and a high-severity
denial of service through Server Actions (GHSA-p3m5-59mp-jv5r). HealthLog
uses neither `use cache` nor the image component, and it defines no Server
Actions and refuses requests that call one. Every instance runs the same
framework, though, so it updates anyway.
- **The image optimizer is off.** Nothing in HealthLog renders through it,
so `/_next/image` now answers 404 instead of processing whatever image a
request names. Icons and logos load as before.
- **nodemailer 10.1.7** fixes several advisories in how email addresses are
parsed (GHSA-8c8m-p3m8-cjg8 among them).
### Fixed
- **`[` and `]` move the open day on every press.** Pressed quickly in a
row, one press could be lost and the day ended one short. A step now
starts from the day in the address bar and waits until the previous one
is shown.
- **Back closes a document opened from the vault.** Pressed right after
opening a document, Back could leave its sheet open. The sheet now follows
the address bar directly.
### Upgrade notes
- None. Pull the new image and restart.
## [1.44.0] — 2026-10-22
### Added
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.44.0
version: 1.44.1
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+10
View File
@@ -35,6 +35,16 @@ const nextConfig: NextConfig = {
// still covers the full program.
typescript: { tsconfigPath: "tsconfig.build.json" },
poweredByHeader: false,
// The image optimizer is off. Nothing renders through `next/image`: logos,
// icons and avatars are plain `<img>` elements served from `public/` or
// from an authenticated route. With the optimizer on, `/_next/image` still
// fetched, decoded and re-encoded whatever local path a request named,
// which is surface this app has no use for and where the framework has
// shipped critical advisories. Off, Next answers that route with 404
// before it reads a single parameter. `next-image-optimizer-guard.test.ts`
// holds this: it fails when `next/image` is imported while the optimizer
// is off, and when the optimizer comes back with SVG input allowed.
images: { unoptimized: true },
// v1.4.38.4 — expose the package.json version to the client bundle
// so the `<VersionPoller>` can compare the shell-baked version
// against the live `/api/version` response and trigger a self-heal
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.44.0",
"version": "1.44.1",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
@@ -79,8 +79,8 @@
"jspdf-autotable": "^5.0.8",
"lucide-react": "^1.56.0",
"mmdb-lib": "^3.0.3",
"next": "16.3.8",
"nodemailer": "10.1.0",
"next": "16.4.2",
"nodemailer": "10.1.7",
"otpauth": "^9.5.2",
"p-limit": "^7.3.3",
"pg": "^8.23.1",
@@ -106,7 +106,7 @@
},
"devDependencies": {
"@axe-core/playwright": "^4.13.0",
"@next/bundle-analyzer": "^16.3.8",
"@next/bundle-analyzer": "16.4.2",
"@playwright/test": "^1.64.0",
"@tailwindcss/postcss": "^4",
"@tesseract.js-data/deu": "1.0.0",
@@ -121,7 +121,7 @@
"@vitest/coverage-v8": "^5.0.4",
"ajv": "^8.20.0",
"eslint": "^9",
"eslint-config-next": "16.3.8",
"eslint-config-next": "16.4.2",
"knip": "^6.41.0",
"lefthook": "^2.2.2",
"pdf-parse": "^2.4.5",
+65 -91
View File
@@ -120,11 +120,11 @@ importers:
specifier: ^3.0.3
version: 3.0.3
next:
specifier: 16.3.8
version: 16.3.8(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@26.6.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
specifier: 16.4.2
version: 16.4.2(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@26.6.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
nodemailer:
specifier: 10.1.0
version: 10.1.0
specifier: 10.1.7
version: 10.1.7
otpauth:
specifier: ^9.5.2
version: 9.5.2
@@ -196,8 +196,8 @@ importers:
specifier: ^4.13.0
version: 4.13.0(playwright-core@1.64.0)
'@next/bundle-analyzer':
specifier: ^16.3.8
version: 16.3.8
specifier: 16.4.2
version: 16.4.2
'@playwright/test':
specifier: ^1.64.0
version: 1.64.0
@@ -241,8 +241,8 @@ importers:
specifier: ^9
version: 9.39.3(jiti@2.7.0)(supports-color@7.2.0)
eslint-config-next:
specifier: 16.3.8
version: 16.3.8(@typescript-eslint/parser@8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
specifier: 16.4.2
version: 16.4.2(@typescript-eslint/parser@8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
knip:
specifier: ^6.41.0
version: 6.41.0
@@ -1075,13 +1075,6 @@ packages:
resolution: {integrity: sha512-vt5tog1cr+QKadIddGe+3LrcX0EmjEd0izF717Uwf92mBmgYfq2XRmL2r4bZU40spB69exxhrBn9I91HYEr/7w==}
engines: {node: '>= 10'}
'@napi-rs/wasm-runtime@1.2.4':
resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==}
engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
peerDependencies:
'@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4
'@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4
'@napi-rs/wasm-runtime@1.2.5':
resolution: {integrity: sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==}
engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
@@ -1089,63 +1082,63 @@ packages:
'@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4
'@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4
'@next/bundle-analyzer@16.3.8':
resolution: {integrity: sha512-8VBk5/pjANz7fxbQZrDwUMTAs598Vckb4PE7BSYrDIPthJskKCXEHOK+WEMgDD6X2xWlKjrXPo2r41N/G1azSw==}
'@next/bundle-analyzer@16.4.2':
resolution: {integrity: sha512-tpNTC/WZYsmmixg3MZfx5JUuTh+Sgryu4eKhe7leQRcXHabztqtTkPCQM1Bk1VsE0trZ3w7NUCC3Zf7SeIBVzg==}
'@next/env@16.3.8':
resolution: {integrity: sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==}
'@next/env@16.4.2':
resolution: {integrity: sha512-nTUTFH7G5/J/fmpkjVLegOkqyKOP0CEsJcMsyO4UCYeN1su+YMW/MfaSErnJskL7XM+UDK6aTvslxsRCyya/sA==}
'@next/eslint-plugin-next@16.3.8':
resolution: {integrity: sha512-eCR9RcLTZrVS+K3+VOhi0xMdUUGNuriCHHhU1ZoABC/QQ/Gp0OWbLKQxV2/9DkmihYdYRcQWFmZXW8M1AMUXYw==}
'@next/eslint-plugin-next@16.4.2':
resolution: {integrity: sha512-gCTWlw0e+Y6kajpFyKKgA195jUJCRhlzJE0lV7ufe8a+ghIsANWZ0/FiJwg6DuOG1+OvRbcZDcFBoRh3mNJx0g==}
'@next/swc-darwin-arm64@16.3.8':
resolution: {integrity: sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==}
'@next/swc-darwin-arm64@16.4.2':
resolution: {integrity: sha512-ONkhFvZNFWlsEHOM/81AtxdyuJP2KRYKYR0JxPwvcu1AuPMMMgcL+wW2luldJvMtZwKtUxoSkCychW5wIeoYSw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/swc-darwin-x64@16.3.8':
resolution: {integrity: sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==}
'@next/swc-darwin-x64@16.4.2':
resolution: {integrity: sha512-luPCMbOsCoaF+kXJEUjmjmOZg1lC3BflhmUu+ZmW4tBu2vW/4AXak8JBFvePizGkxVmNiAw/r9ra+6UviCWtWQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/swc-linux-arm64-gnu@16.3.8':
resolution: {integrity: sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==}
'@next/swc-linux-arm64-gnu@16.4.2':
resolution: {integrity: sha512-xhwFTAncJyaCNy5KqKKuKaIIdCy1/1ZV1s5S1G0hhT7wGMkDTlfJ6aQbfRFnIG/s3/kDrxQ8aycVe+c5QnP8ow==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@16.3.8':
resolution: {integrity: sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==}
'@next/swc-linux-arm64-musl@16.4.2':
resolution: {integrity: sha512-T1PNmfbPcxr5ndtWUL/3wQtLXsz808lq6sc2GrNT5k2oquF7UyqZddHSuAewHVsWsGXe6nsl9HoN1YNriVjuSg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@16.3.8':
resolution: {integrity: sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==}
'@next/swc-linux-x64-gnu@16.4.2':
resolution: {integrity: sha512-N9cDUOCGtkJG5PNKDFZUF3xXndrJJcpLp121uAGjIGvO/U2p9tfdNOM9htj7+5f72l0m8rMw/cvYacUJBQoudg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@16.3.8':
resolution: {integrity: sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==}
'@next/swc-linux-x64-musl@16.4.2':
resolution: {integrity: sha512-yY2uh1YhX6ufuYy2FCbw1dqblbhI57GLYAJfCkqD9auTv6pLrH67Uccjc3QMKBeCYAG6lkgu08ExmX1bbjejjQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@16.3.8':
resolution: {integrity: sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==}
'@next/swc-win32-arm64-msvc@16.4.2':
resolution: {integrity: sha512-j/ArWRIHpMMB6wYLMhr2puTguBt70lNU/Cmjl9p6dTDE8DGknYJMP9PdwPk2dqpCNL/KIX9yfngDvIesxJPmsg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/swc-win32-x64-msvc@16.3.8':
resolution: {integrity: sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==}
'@next/swc-win32-x64-msvc@16.4.2':
resolution: {integrity: sha512-AA4edyWHDko3NCSo0x6GomsdgFb0oR1J3MXU1b5xt6FRFg4AucrMPW3S0hCe/9onU0O1BbpGQBsqo1r0Gneu1g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -3348,11 +3341,6 @@ packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
baseline-browser-mapping@2.11.26:
resolution: {integrity: sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==}
engines: {node: '>=6.0.0'}
hasBin: true
baseline-browser-mapping@2.11.27:
resolution: {integrity: sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==}
engines: {node: '>=6.0.0'}
@@ -3459,9 +3447,6 @@ packages:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
caniuse-lite@1.0.30001814:
resolution: {integrity: sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==}
caniuse-lite@1.0.30001815:
resolution: {integrity: sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==}
@@ -3961,8 +3946,8 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
eslint-config-next@16.3.8:
resolution: {integrity: sha512-81vovwMe6NGnoFsl0KUJWzlS+y239i3fdsxs49gSEX3pDQW2cuKy+fxUPXNkQqqx6OCPheYPtBlSufc9aZ0k+w==}
eslint-config-next@16.4.2:
resolution: {integrity: sha512-P33vjcETMXc+qBjpF77TeJHZLzk6cPcTy8KjpxLucYB3mCIFIORMcZ+cBXcAiIYjE5RM7jyknEEOwMNdnx/ADQ==}
peerDependencies:
eslint: '>=9.0.0'
typescript: '>=3.3.1'
@@ -5177,11 +5162,6 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@3.3.19:
resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@3.3.20:
resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -5199,8 +5179,8 @@ packages:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
next@16.3.8:
resolution: {integrity: sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==}
next@16.4.2:
resolution: {integrity: sha512-r+oBJojfefUjOiWkBhXgLS+igxX/7BWyK59uHnyOyplMSLtBZ+QLP3okrdYTbrfrbrmNTVWLBPaHDX9KRaIt3w==}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
@@ -5241,8 +5221,8 @@ packages:
resolution: {integrity: sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==}
engines: {node: '>=18'}
nodemailer@10.1.0:
resolution: {integrity: sha512-fXW8AulB25aOTuwfRYV6lqHyfz2VM4rr4SGXH03mE7x5znEUpzL5mu6anARUpZN7744Ziwkmr4AxSLPJsBQspg==}
nodemailer@10.1.7:
resolution: {integrity: sha512-OQa6FDln5fLttmqSbjDiRYvFDOFNrBqfiOwHPlEbURil3b+vPB1v8xpi+Sp46drzNDlsTnNsS502vthmz56r4w==}
engines: {node: '>=20.0.0'}
non-error@0.1.0:
@@ -7727,7 +7707,7 @@ snapshots:
'@napi-rs/canvas-win32-arm64-msvc': 1.0.11
'@napi-rs/canvas-win32-x64-msvc': 1.0.11
'@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
'@napi-rs/wasm-runtime@1.2.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies:
'@emnapi/core': 1.10.0
'@emnapi/runtime': 1.10.0
@@ -7748,44 +7728,44 @@ snapshots:
'@tybys/wasm-util': 0.10.4
optional: true
'@next/bundle-analyzer@16.3.8':
'@next/bundle-analyzer@16.4.2':
dependencies:
webpack-bundle-analyzer: 4.10.1
transitivePeerDependencies:
- bufferutil
- utf-8-validate
'@next/env@16.3.8': {}
'@next/env@16.4.2': {}
'@next/eslint-plugin-next@16.3.8(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))':
'@next/eslint-plugin-next@16.4.2(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))
fast-glob: 3.3.1
transitivePeerDependencies:
- eslint
'@next/swc-darwin-arm64@16.3.8':
'@next/swc-darwin-arm64@16.4.2':
optional: true
'@next/swc-darwin-x64@16.3.8':
'@next/swc-darwin-x64@16.4.2':
optional: true
'@next/swc-linux-arm64-gnu@16.3.8':
'@next/swc-linux-arm64-gnu@16.4.2':
optional: true
'@next/swc-linux-arm64-musl@16.3.8':
'@next/swc-linux-arm64-musl@16.4.2':
optional: true
'@next/swc-linux-x64-gnu@16.3.8':
'@next/swc-linux-x64-gnu@16.4.2':
optional: true
'@next/swc-linux-x64-musl@16.3.8':
'@next/swc-linux-x64-musl@16.4.2':
optional: true
'@next/swc-win32-arm64-msvc@16.3.8':
'@next/swc-win32-arm64-msvc@16.4.2':
optional: true
'@next/swc-win32-x64-msvc@16.3.8':
'@next/swc-win32-x64-msvc@16.4.2':
optional: true
'@noble/hashes@2.4.0': {}
@@ -9625,7 +9605,7 @@ snapshots:
dependencies:
'@emnapi/core': 1.10.0
'@emnapi/runtime': 1.10.0
'@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
'@napi-rs/wasm-runtime': 1.2.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
optional: true
'@unrs/resolver-binding-win32-arm64-msvc@1.12.2':
@@ -9991,8 +9971,6 @@ snapshots:
base64-js@1.5.1: {}
baseline-browser-mapping@2.11.26: {}
baseline-browser-mapping@2.11.27: {}
bcrypt-pbkdf@1.0.2:
@@ -10120,8 +10098,6 @@ snapshots:
camelcase@5.3.1: {}
caniuse-lite@1.0.30001814: {}
caniuse-lite@1.0.30001815: {}
canvg@3.0.11:
@@ -10644,9 +10620,9 @@ snapshots:
escape-string-regexp@4.0.0: {}
eslint-config-next@16.3.8(@typescript-eslint/parser@8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3):
eslint-config-next@16.4.2(@typescript-eslint/parser@8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3):
dependencies:
'@next/eslint-plugin-next': 16.3.8(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))
'@next/eslint-plugin-next': 16.4.2(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))
eslint: 9.39.3(jiti@2.7.0)(supports-color@7.2.0)
eslint-import-resolver-node: 0.3.10(supports-color@7.2.0)
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)
@@ -11907,8 +11883,6 @@ snapshots:
nanoid@3.3.18: {}
nanoid@3.3.19: {}
nanoid@3.3.20: {}
napi-postinstall@0.3.4: {}
@@ -11917,25 +11891,25 @@ snapshots:
negotiator@1.0.0: {}
next@16.3.8(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@26.6.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
next@16.4.2(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@26.6.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
dependencies:
'@next/env': 16.3.8
'@next/env': 16.4.2
'@swc/helpers': 0.5.23
baseline-browser-mapping: 2.11.26
caniuse-lite: 1.0.30001814
baseline-browser-mapping: 2.11.27
caniuse-lite: 1.0.30001815
postcss: 8.5.23
react: 19.3.0
react-dom: 19.3.0(react@19.3.0)
styled-jsx: 5.1.6(@babel/core@7.29.7(supports-color@7.2.0))(react@19.3.0)
optionalDependencies:
'@next/swc-darwin-arm64': 16.3.8
'@next/swc-darwin-x64': 16.3.8
'@next/swc-linux-arm64-gnu': 16.3.8
'@next/swc-linux-arm64-musl': 16.3.8
'@next/swc-linux-x64-gnu': 16.3.8
'@next/swc-linux-x64-musl': 16.3.8
'@next/swc-win32-arm64-msvc': 16.3.8
'@next/swc-win32-x64-msvc': 16.3.8
'@next/swc-darwin-arm64': 16.4.2
'@next/swc-darwin-x64': 16.4.2
'@next/swc-linux-arm64-gnu': 16.4.2
'@next/swc-linux-arm64-musl': 16.4.2
'@next/swc-linux-x64-gnu': 16.4.2
'@next/swc-linux-x64-musl': 16.4.2
'@next/swc-win32-arm64-msvc': 16.4.2
'@next/swc-win32-x64-msvc': 16.4.2
'@opentelemetry/api': 1.9.1
'@playwright/test': 1.64.0
sharp: 0.35.5(@types/node@26.6.5)
@@ -11959,7 +11933,7 @@ snapshots:
node-releases@2.0.58: {}
nodemailer@10.1.0: {}
nodemailer@10.1.7: {}
non-error@0.1.0: {}
@@ -12300,7 +12274,7 @@ snapshots:
postcss@8.5.23:
dependencies:
nanoid: 3.3.19
nanoid: 3.3.20
picocolors: 1.1.1
source-map-js: 1.2.2
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.44.0";
/* @sw-version-fallback */ "v1.44.1";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
@@ -0,0 +1,110 @@
/**
* The image optimizer stays off while nothing uses it.
*
* `next.config.ts` sets `images.unoptimized`, which makes Next answer
* `/_next/image` with 404 before it reads a parameter, so the route that
* fetches, decodes and re-encodes images is not reachable at all. Two ways
* to undo that quietly, and this pins both:
*
* 1. A component imports `next/image` (or `next/legacy/image`) while the
* optimizer is off. It would render, unoptimized, and the next person
* to notice the missing optimization would switch the optimizer back on
* without the review this flag deserves. Using `next/image` means
* revisiting this decision, so the import fails here first.
* 2. The optimizer comes back on with `dangerouslyAllowSVG`. An SVG is a
* document, not a picture, and the framework's own advisories have been
* about exactly that input. Re-enabling the optimizer is allowed; doing
* it with SVG input allowed is not.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
requireFloor,
stripComments,
walkSourceFiles,
} from "./helpers/source-files";
const ROOT = process.cwd();
const SRC = join(ROOT, "src");
type ImagesConfig = {
unoptimized?: boolean;
dangerouslyAllowSVG?: boolean;
};
async function imagesConfig(): Promise<ImagesConfig> {
const mod = (await import("../../next.config")) as {
default: { images?: ImagesConfig };
};
return mod.default.images ?? {};
}
// Any import of the module: static, dynamic, `require`, side effect or
// re-export. Two literals with one shape rather than one built from a string,
// so nothing has to be escaped into a pattern.
const NEXT_IMAGE_IMPORT =
/(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*|^\s*import\s+)["']next\/(?:legacy\/)?image["']/m;
const NEXT_LINK_IMPORT =
/(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*|^\s*import\s+)["']next\/link["']/m;
/** Every non-generated, non-test source file under `src/` matching `re`. */
function importers(re: RegExp): string[] {
return walkSourceFiles(SRC, { floor: 3000 })
.filter((rel) => !rel.startsWith("generated/"))
.filter((rel) => !rel.includes("__tests__/"))
.filter((rel) =>
re.test(stripComments(readFileSync(join(SRC, rel), "utf8"))),
);
}
describe("next/image optimizer", () => {
it("the import matcher recognises every form of the import", () => {
// The sweep below passes when it matches nothing, so the matcher has to
// be shown to match before an empty result means anything.
for (const line of [
'import Image from "next/image";',
"import Image from 'next/legacy/image';",
'const Image = await import("next/image");',
'const Image = require("next/image");',
'import "next/image";',
'export { default } from "next/image";',
]) {
expect(NEXT_IMAGE_IMPORT.test(line), line).toBe(true);
}
for (const line of [
"// `next/image` is wrong here",
'const path = "/_next/image?url=x";',
'import { foo } from "next/image-extra";',
]) {
expect(NEXT_IMAGE_IMPORT.test(stripComments(line)), line).toBe(false);
}
});
it("the same sweep finds the imports of a sibling module", () => {
// An empty result for `next/image` is only worth something if the walk,
// the comment stripping and the matcher together do find real imports.
// `next/link` is imported the same way all over the tree.
requireFloor("next/link importers", importers(NEXT_LINK_IMPORT), 100);
});
it("is off, and then nothing imports next/image", async () => {
const images = await imagesConfig();
if (images.unoptimized !== true) return;
expect(importers(NEXT_IMAGE_IMPORT)).toEqual([]);
});
it("is never on with SVG input allowed", async () => {
const images = await imagesConfig();
if (images.unoptimized === true) return;
expect(images.dangerouslyAllowSVG ?? false).toBe(false);
});
it("is off today", async () => {
// The two cases above each skip on the other branch. This pins which
// branch the tree is on, so flipping it is a visible change to this
// file and not a silent switch between the two.
expect((await imagesConfig()).unoptimized).toBe(true);
});
});
@@ -6,7 +6,8 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
*
* - opening adds exactly one entry, so Back closes the layer;
* - opening another day while one is open swaps it in place;
* - stepping to the neighbouring day never adds an entry, however often;
* - stepping to the neighbouring day never adds an entry, however often,
* starts from the day in the URL and never passes today;
* - closing a day the layer opened goes Back (the next Back leaves the
* page); closing a deep link only drops the parameter and stays.
*/
@@ -57,6 +58,8 @@ function installFakeBrowser(initialUrl: string) {
let browser: ReturnType<typeof installFakeBrowser>;
const TODAY = "2026-01-10";
beforeEach(() => {
browser = installFakeBrowser("/insights/blood-pressure?range=90");
});
@@ -88,18 +91,63 @@ describe("day layer history", () => {
it("steps without adding history entries", async () => {
const { openDay, stepDay } = await import("../day-layer-controller");
openDay("2026-01-03");
stepDay("2026-01-03", -1);
stepDay("2026-01-02", -1);
stepDay("2026-01-01", 1);
stepDay(-1, TODAY);
stepDay(-1, TODAY);
stepDay(1, TODAY);
expect(browser.length()).toBe(2);
expect(browser.current()).toContain("day=2026-01-02");
});
it("steps from the day in the URL, so quick steps in a row all count", async () => {
// Nothing republishes the open day between these calls, which is what a
// fast key repeat looks like while the layer is still rendering the
// previous step. Each step must still move from where the last one went.
const { openDay, stepDay } = await import("../day-layer-controller");
openDay("2026-01-03");
expect(stepDay(1, TODAY)).toBe("2026-01-04");
expect(stepDay(1, TODAY)).toBe("2026-01-05");
expect(browser.current()).toContain("day=2026-01-05");
});
it("holds a step while the layer still shows another day, then makes it", async () => {
// The layer publishes the day it shows only once Next has adopted the
// URL. A step written before that can be put back by Next's own write,
// so it waits for the layer and goes in one write when it catches up.
const { openDay, publishOpenDay, stepDay } =
await import("../day-layer-controller");
openDay("2026-01-03");
publishOpenDay("2026-01-03");
expect(stepDay(-1, TODAY)).toBe("2026-01-02");
// The layer still shows the 3rd: these two wait.
expect(stepDay(1, TODAY)).toBeNull();
expect(stepDay(1, TODAY)).toBeNull();
expect(browser.current()).toContain("day=2026-01-02");
publishOpenDay("2026-01-02");
expect(browser.current()).toContain("day=2026-01-04");
expect(browser.length()).toBe(2);
publishOpenDay(null);
});
it("never steps past today, however often it is asked", async () => {
const { openDay, stepDay } = await import("../day-layer-controller");
openDay("2026-01-09");
expect(stepDay(1, TODAY)).toBe(TODAY);
expect(stepDay(1, TODAY)).toBeNull();
expect(stepDay(1, TODAY)).toBeNull();
expect(browser.current()).toContain(`day=${TODAY}`);
});
it("does nothing while no day is open", async () => {
const { stepDay } = await import("../day-layer-controller");
expect(stepDay(-1, TODAY)).toBeNull();
expect(browser.current()).toBe("/insights/blood-pressure?range=90");
});
it("closes a pushed day with Back, landing on the page without it", async () => {
const { closeDay, openDay, stepDay } =
await import("../day-layer-controller");
openDay("2026-01-03");
stepDay("2026-01-03", 1);
stepDay(1, TODAY);
closeDay();
expect(browser.current()).toBe("/insights/blood-pressure?range=90");
});
@@ -107,7 +155,7 @@ describe("day layer history", () => {
it("closes a deep link in place: the parameter goes, the page stays", async () => {
browser = installFakeBrowser("/?day=2026-01-03");
const { closeDay, stepDay } = await import("../day-layer-controller");
stepDay("2026-01-03", -1);
stepDay(-1, TODAY);
expect(browser.current()).toBe("/?day=2026-01-02");
closeDay();
expect(browser.current()).toBe("/");
+53 -4
View File
@@ -2,11 +2,12 @@
import { useSyncExternalStore } from "react";
import type { DateKey } from "@/lib/day/contract";
import { DAY_QUERY_PARAM, type DateKey } from "@/lib/day/contract";
import {
dayHistoryState,
historyEntryOwnedByLayer,
parseDayParam,
shiftDateKey,
withDayHref,
} from "./day-url";
@@ -100,6 +101,13 @@ export function publishOpenDay(date: DateKey | null): void {
if (memory.open === date) return;
memory.open = date;
emit();
// The layer has caught up with the URL: the steps that waited for it go
// now, in one write, from the day it shows.
const waiting = pendingStep;
pendingStep = null;
if (waiting !== null && date !== null) {
stepDay(waiting.delta, waiting.today);
}
}
/**
@@ -155,9 +163,50 @@ export function openDay(date: DateKey, options: OpenDayOptions = {}): void {
}
}
/** Move the open day by `delta` calendar days, without a new history entry. */
export function stepDay(from: DateKey, delta: number): DateKey {
const next = shiftDateKey(from, delta);
/**
* Steps asked for while the layer had not yet shown the previous one. Kept
* as one sum, with the `today` of the latest request.
*/
let pendingStep: { delta: number; today: DateKey } | null = null;
/**
* Move the open day by `delta` calendar days, without a new history entry,
* and never past `today`. Null when nothing is written now: no day is open,
* the step would pass today, or it waits for the previous one.
*
* Two things keep quick steps (a key held down, `]` pressed twice) from
* losing one.
*
* The step starts from the day in the URL, not from the day a caller last
* rendered, which reaches it through `useSearchParams`, a render and an
* effect and can be a step behind.
*
* And a step does not write while the layer still shows a different day
* than the URL. The write goes past Next's router, which adopts it a render
* later, and since Next 16.4 that render can land after a second write and
* put the first one's URL back: `[` then `]` pressed quickly ended a day
* short. So a step that finds the layer behind waits, and `publishOpenDay`
* makes it as soon as the layer shows the day the URL names.
*/
export function stepDay(delta: number, today: DateKey): DateKey | null {
if (typeof window === "undefined") return null;
const from = parseDayParam(
new URLSearchParams(window.location.search).get(DAY_QUERY_PARAM),
today,
);
if (from === null) {
pendingStep = null;
return null;
}
const total = (pendingStep?.delta ?? 0) + delta;
if (memory.open !== null && memory.open !== from) {
pendingStep = { delta: total, today };
return null;
}
pendingStep = null;
const target = shiftDateKey(from, total);
const next = target > today ? today : target;
if (next === from) return null;
jumpDay(next);
return next;
}
+1 -2
View File
@@ -348,8 +348,7 @@ function DayLayer() {
const onStep = useCallback(
(delta: number) => {
if (date === null) return;
if (delta > 0 && date >= today) return;
stepDay(date, delta);
stepDay(delta, today);
},
[date, today],
);
+38 -6
View File
@@ -31,7 +31,14 @@ import {
} from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
@@ -110,6 +117,26 @@ const BULK_VISIT_OPTIONS = 12;
const DOCUMENT_QUERY_ID_PATTERN = /^[a-zA-Z0-9_-]{1,40}(?![\s\S])/;
const DOCUMENT_SELECTION_HISTORY_KEY = "__healthlogPushedDocumentSelection";
/**
* The query string in the address bar, read at render time.
*
* The open sheet follows `?doc=`, and `useSearchParams` is not a faithful
* copy of it: Next 16.4 applies the copy of a `pushState` later than the write,
* and a Back pressed in between moves the address bar from `?doc=` to the
* page without it while `useSearchParams` can read "no document" on both sides.
* The sheet then saw no change and stayed open. `window.location` is never
* behind; `popstate` re-reads it on every traversal, and every other render
* (including the ones Next's own navigations cause) reads it afresh.
*/
function subscribeToTraversal(onChange: () => void): () => void {
window.addEventListener("popstate", onChange);
return () => window.removeEventListener("popstate", onChange);
}
function readLocationSearch(): string {
return window.location.search;
}
export function documentSelectionHistoryState(
documentId: string,
): Record<string, unknown> {
@@ -818,7 +845,13 @@ export function DocumentsView() {
const [detailOpen, setDetailOpen] = useState(false);
const handedOffDocumentRef = useRef<string | null>(null);
const rawDocParam = searchParams.get("doc");
// Before hydration the server's view of the query stands in for the URL.
const locationSearch = useSyncExternalStore(
subscribeToTraversal,
readLocationSearch,
() => searchParams.toString(),
);
const rawDocParam = new URLSearchParams(locationSearch).get("doc");
const docParam =
rawDocParam !== null && DOCUMENT_QUERY_ID_PATTERN.test(rawDocParam)
? rawDocParam
@@ -837,14 +870,14 @@ export function DocumentsView() {
const openDetail = useCallback(
(id: string) => {
const href = documentSelectionHref(pathname, searchParams.toString(), id);
const href = documentSelectionHref(pathname, window.location.search, id);
if (href === null) return;
handedOffDocumentRef.current = null;
setDetailId(id);
setDetailOpen(true);
window.history.pushState(documentSelectionHistoryState(id), "", href);
},
[pathname, searchParams],
[pathname],
);
const handleDetailOpenChange = useCallback((open: boolean) => {
@@ -881,7 +914,7 @@ export function DocumentsView() {
handedOffDocumentRef.current = closeDocumentSelectionAfterCoachHandoff(
window.history,
pathname,
searchParams.toString(),
window.location.search,
docParam,
handedOffDocumentRef.current,
coachLaunch?.closeIntent,
@@ -925,7 +958,6 @@ export function DocumentsView() {
docParam,
moduleEnabled,
pathname,
searchParams,
]);
// Hover/focus intent prefetches the detail METADATA (never the blob —
+5 -5
View File
@@ -192,12 +192,12 @@ export function GlobalShortcuts() {
setShortcutsHelpOpen(true);
return;
case "day": {
const date = state.openDay;
if (date === null) return;
if (state.openDay === null) return;
event.preventDefault();
// No day after today: the layer refuses it, so the step does too.
if (action.delta > 0 && date >= state.today) return;
stepDay(date, action.delta);
// The step reads the day from the URL, which is ahead of
// `openDay` while a previous step is still rendering, and refuses
// a day after today itself.
stepDay(action.delta, state.today);
return;
}
case "day-toggle":