From cba389515cabd5517daa28404883f293e1dff4ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 1 Sep 2026 20:28:32 +0200 Subject: [PATCH 1/5] ci: split the integration suite four ways and widen the guard Three of the last four pushes to main failed the integration workflow without a single failing test. Shard 1 of 3 was still logging green results when the 25-minute job timeout cancelled it; the same tests in the same shard had run in 13 minutes the day before. A slow runner day doubles every test's wall time, and a guard under twice the ordinary run cannot absorb that. Four shards keep each slice around fifty files, and the guard moves to 40 minutes, three times an ordinary run. A wedged test still fails through vitest's own 60-second budget, so the job timeout only has to catch a container that never boots. The fan-in job is unchanged; it still refuses a matrix with any cancelled shard. --- .github/workflows/integration.yml | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 15b9696ac..1bb859920 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -10,21 +10,25 @@ permissions: contents: read jobs: - # The suite outgrew a single runner, and then a half-suite outgrew a slow - # one: two shards cleared in ~14 minutes on a quiet day, but with six pull - # requests running side by side the larger shard hit the same 25-minute - # guard the split was meant to escape. Three shards keep the biggest slice - # around 60 files, which clears the guard with real headroom even on the - # slowest runner observed. Each shard boots its own Postgres testcontainer; - # the timeout stays a hang detector, not a performance budget. + # The suite outgrew a single runner, then a half-suite outgrew a slow one, + # and then a third of it did too. Three shards of about 68 files cleared in + # 13 minutes on an ordinary runner, but a slow runner day ran every test at + # twice its usual duration and the first shard was still working through + # its list when the 25-minute guard cut it off: three of four pushes to main + # between 2026-08-31 and 2026-09-01 failed that way, with green tests logged + # up to the last second. Four shards keep each slice around 50 files, and + # the guard sits at three times the ordinary run rather than under two. A + # wedged test still surfaces through vitest's own 60-second budget, so the + # job timeout only needs to catch a container that never boots. Each shard + # boots its own Postgres testcontainer. shard: runs-on: ubuntu-latest - timeout-minutes: 25 + timeout-minutes: 40 strategy: fail-fast: false matrix: - shard: [1, 2, 3] - name: integration shard ${{ matrix.shard }}/3 + shard: [1, 2, 3, 4] + name: integration shard ${{ matrix.shard }}/4 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -44,7 +48,7 @@ jobs: # vitest a stray positional argument and the shard flag never applies — # both "shards" then run the full suite. Proven by a paired local run # before this workflow shipped. - - run: pnpm exec vitest run --config vitest.integration.config.mts --shard=${{ matrix.shard }}/3 + - run: pnpm exec vitest run --config vitest.integration.config.mts --shard=${{ matrix.shard }}/4 env: # The integration suite boots a Postgres testcontainer and points # Prisma at it via DATABASE_URL set inside `tests/integration/setup.ts`. @@ -60,7 +64,7 @@ jobs: SESSION_SECRET: "integration-tests" # Branch protection requires a check named exactly "integration". This job - # carries that name and answers for both shards: it fails when any shard + # carries that name and answers for every shard: it fails when any shard # failed or was cancelled, so a half-green matrix cannot satisfy the gate. integration: runs-on: ubuntu-latest From c462d9a2826cbeac82a1f8023c12f2761d8320a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 1 Sep 2026 20:33:30 +0200 Subject: [PATCH 2/5] =?UTF-8?q?chore(release):=20v1.38.1=20=E2=80=94=20pus?= =?UTF-8?q?h=20readings=20in,=20see=20the=20range=20at=20a=20glance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A measurement ingest token for readings from your own hardware, a reference-range bar beside every lab reading, lab OCR working with a GPT-5 model, an impossible reference window no longer written to the record, and the image build kept intact across the Prisma adapter bump. Dependency updates ride along. --- CHANGELOG.md | 93 ++++++++++++++++++++++++++++++++++++------- docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 81 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e1a77033..e8cb5e36c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,14 @@ # Changelog -## [Unreleased] +## [1.38.1] — 2026-09-01 -You can push readings in from your own hardware again. +You can push readings in from your own hardware again, and a lab reading +shows where it sits against its reference range at a glance. -Since v1.30.17 there was no way to do it. That release made API tokens -enforce their scope, which closed a real hole — a token issued for +Since v1.30.17 there was no way to push a reading in. That release made API +tokens enforce their scope, which closed a real hole (a token issued for medication intake had been reaching every other endpoint, the full backup -export included — and removed the generic "create API token" button along +export included) and removed the generic "create API token" button along with it. What went unnoticed was that the button had been the only self-service route to a token that could write a measurement. A scale or a watch bridge piped through Home Assistant had no door left, and the OAuth @@ -17,19 +18,31 @@ There is now a token for exactly that job, and for nothing else. ### Added -- **A measurement ingest token, minted from Settings → API & Tokens.** It - posts readings to `/api/measurements` and `/api/measurements/batch`, on - your own record. That is the whole list. It cannot read a reading back, - cannot change or delete one, cannot reach the export, and cannot mint - another token — so a credential sitting in a container you did not write - has a worst case of junk in your own record, not your health history - leaving the building. Shown once, expires after a year, and revoked from - the same page as every other token. +- **A measurement ingest token, minted from Settings → API & Tokens.** + Contributed by @Antiheld86 in #881, from their proposal in #878. It posts + readings to `/api/measurements` and `/api/measurements/batch`, on your own + record. That is the whole list. It cannot read a reading back, cannot + change or delete one, cannot reach the export, and cannot mint another + token, so a credential sitting in a container you did not write has a + worst case of junk in your own record, not your health history leaving + the building. Shown once, expires after a year, and revoked from the same + page as every other token. - **A guide for wiring one up**, with a worked Home Assistant `rest_command` and automation, the measurement types a scale or a watch bridge tends to report, and what each error response means. - The API endpoint list on that settings page now shows the two ingest endpoints alongside the medication one. +- **A reference-range bar beside every lab reading**, contributed by + @TimonBed in #860 and #880. The lab list, its mobile cards and the history + table on an analyte's page carry a compact bar: the reference window in + green, the reading as a marker on it, blue below the window, amber above + it. A range with only a minimum or only a maximum gets a bar too, with + the one stated bound labelled. A reading far outside its window (vitamin + D at 7 against a floor of 30, say) widens the bar's scale so the marker + stays visible instead of sitting pinned to the edge. In the list the status badge, the bar and the trend + sparkline line up in columns across rows, and every point on the + sparkline takes the colour the bar gives it. A qualitative reading, or one + without a range, shows no bar. ### Changed @@ -43,15 +56,65 @@ There is now a token for exactly that job, and for nothing else. have: the checkpoint records that your phone delivered what it was holding, and a scale advancing it could make the phone skip a window of its own samples. +- The below-range and above-range badges on a lab reading carry a light + tint (blue below, amber above) instead of the neutral grey they shared; + in range stays neutral. Part of #860. +- Routine dependency updates: the Prisma Postgres adapter to 7.10.0 (see + Fixed), and the Argon2 binding, the WebAuthn server library, Radix, + Lucide, the form resolvers and the dev tooling by a patch or minor each. + +### Fixed + +- Lab OCR with a GPT-5 model on the OpenAI provider path (your own key or + the operator's admin key), fixed by @TimonBed in #861. The list of models + the app trusts to read an image stopped at `gpt-4o`, `gpt-4.1`, + `gpt-4-turbo` and the full o-series, so a GPT-5 model was judged unable + to read a photo and never sent one; the Codex path already knew better. + The extraction call's completion budget doubles from 4000 to 8000 tokens, + because a GPT-5 model spends part of it on reasoning and a dense report's + JSON was being cut off before the last row. And when the provider itself + fails, the error now says so ("The configured AI provider could not + process this report") and the server log carries the upstream status, + model and error excerpt, where it used to blame the photo and ask for a + clearer one. +- A lab report imported as a document could leave a reference window whose + floor sits above its ceiling on the record. The bounds come from the + extraction: when a report prints no range text, the two numbers the model + reported are the whole of the window, and a transposed pair (100 as the + low bound, 30 as the high) went through unchecked. Manual entry, the OCR + path and the biomarker endpoint all refuse such a pair; the document path + did not, and when the analyte was new to the record the pair was also + written to its freshly minted catalog marker, so every later reading of + that analyte was judged against a window that cannot be true. The pair is + now dropped rather than swapped, because a transposed range says the + transcription is wrong and not which of the two numbers is; the verbatim + range text survives, so nothing the report actually printed is lost. The + extraction drops it, editing a staged fact on the review screen refuses + it with the same message the lab forms use, and confirming a fact drops + it as the last gate, which is what covers facts staged before this rule + existed. Counted as `labs.referenceRange.transposed` in the wide events. + The trend sparkline had read the same pair as a minimum-only range and + painted a high value green; it now shows a neutral point, as the range + bar next to it already showed nothing (#882). +- Bumping the Prisma Postgres adapter to 7.10.0 broke the image build on + both architectures, before any release carried it, with + `Cannot find module '/app/node_modules/pg/lib/index.js'`. `pg` had sat in + devDependencies, and the assertion that the standalone image can resolve + it (pg-boss loads it by plain Node resolution) had passed only because our + pin and the adapter's happened to agree on a version; the bump moved the + adapter's copy to 8.23.0 while ours stayed at 8.22.0, the file tracer + packed only the adapter's copy, and the top-level link pointed at the + other. `pg` is a runtime dependency now and the lockfile carries one copy, + so the two cannot drift apart on the next adapter release (#872). ### Security - The token is confined to your own record. Pointing it at a record somebody has shared with you is refused, and refused before the sharing - grant is even consulted — so no future change to what sharing permits can + grant is even consulted, so no future change to what sharing permits can widen what one of these tokens reaches. - Minting one requires being signed in on the web. No API token can mint - another, whatever it is allowed to do — the sign-in a phone holds lasts a + another, whatever it is allowed to do: the sign-in a phone holds lasts a day and what it could mint here lasts a year, so allowing it would let a credential that leaked for an afternoon leave behind one that outlives cancelling it. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index cb4298b0b..8889ef682 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.38.0 + version: 1.38.1 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 a9dbb750f..1e2905bb5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.38.0", + "version": "1.38.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", diff --git a/public/sw.js b/public/sw.js index 3c6085690..9f03d8d64 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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.38.0"; + /* @sw-version-fallback */ "v1.38.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/*` From 97b1603cb90ad1fe9ef4c4ea9c1543154b897f62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 1 Sep 2026 20:53:47 +0200 Subject: [PATCH 3/5] Pin mysql2 past GHSA-3f6p-5ww8-9rcr in both installs The dependency audit went red on the release branch: mysql2 below 3.22.0 downgrades its auth plugin to mysql_clear_password and leaks the credential. It reaches the tree only through prisma's MySQL driver, which this app never opens, but the gate does not distinguish reachable from present. Pinned to ^3.22.0 in the workspace overrides and mirrored with npm's own override for the /opt/prisma-cli install, the pair the lockstep guard holds together. The audit reads three moderates, below the gate. --- Dockerfile | 1 + pnpm-lock.yaml | 56 ++++++++++++++++----------------------------- pnpm-workspace.yaml | 5 ++++ 3 files changed, 26 insertions(+), 36 deletions(-) diff --git a/Dockerfile b/Dockerfile index 810746d9c..f62b66d3b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -195,6 +195,7 @@ RUN mkdir -p /opt/prisma-cli && \ cd /opt/prisma-cli && \ npm init -y && \ npm pkg set 'overrides.deepmerge-ts=^8.0.0' && \ + npm pkg set 'overrides.mysql2=^3.22.0' && \ npm install --omit=dev prisma@7.8.0 @prisma/engines@7.8.0 tsx@4.23.1 dotenv@17.4.2 && \ ln -sfn /opt/prisma-cli/node_modules/.bin/tsx /usr/local/bin/healthlog-tsx && \ ln -sfn /opt/prisma-cli/node_modules/dotenv /app/node_modules/dotenv && \ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8833fa5a7..3423fa752 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,6 +15,7 @@ overrides: fast-uri@<3.1.5: ^3.1.5 flatted@<3.4.2: ^3.4.2 js-yaml@>=4.0.0 <4.3.1: ^4.3.1 + mysql2@<3.22.0: ^3.22.0 minimatch@<3.1.4: ^3.1.4 ip-address@<10.3.1: ^10.3.1 sharp@<0.35.0: ^0.35.3 @@ -57,7 +58,7 @@ importers: version: 7.10.0 '@prisma/client': specifier: ^7.8.0 - version: 7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3))(typescript@6.0.3) + version: 7.8.0(prisma@7.8.0(@types/node@26.1.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3))(typescript@6.0.3) '@simplewebauthn/browser': specifier: ^13.3.0 version: 13.3.0 @@ -126,7 +127,7 @@ importers: version: 12.27.0 prisma: specifier: ^7.8.0 - version: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3) + version: 7.8.0(@types/node@26.1.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3) qrcode: specifier: ^1.5.4 version: 1.5.4 @@ -4166,10 +4167,6 @@ packages: defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} - denque@2.1.0: - resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} - engines: {node: '>=0.10'} - depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -4856,10 +4853,6 @@ packages: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} - iconv-lite@0.7.2: - resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} - engines: {node: '>=0.10.0'} - iconv-lite@0.7.3: resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} @@ -5572,9 +5565,11 @@ packages: resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} engines: {node: ^20.17.0 || >=22.9.0} - mysql2@3.15.3: - resolution: {integrity: sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==} + mysql2@3.24.2: + resolution: {integrity: sha512-l9kXeKGwd6VCbSjmpO/bLWb+YCYpbvE4whte8ec6InXebvCNyEEydyRCnRU+TSHNqRLJ8B+Tk1uWb+vMCJgJzA==} engines: {node: '>= 8.0'} + peerDependencies: + '@types/node': '>= 8' named-placeholders@1.1.6: resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} @@ -6410,9 +6405,6 @@ packages: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} - seq-queue@0.0.5: - resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} - serialize-error@13.0.1: resolution: {integrity: sha512-bBZaRwLH9PN5HbLCjPId4dP5bNGEtumcErgOX952IsvOhVPrm3/AeK1y0UHA/QaPG701eg0yEnOKsCOC6X/kaA==} engines: {node: '>=20'} @@ -6526,9 +6518,9 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} - sqlstring@2.3.3: - resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} - engines: {node: '>= 0.6'} + sql-escaper@1.5.1: + resolution: {integrity: sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==} + engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'} ssh-remote-port-forward@1.0.4: resolution: {integrity: sha512-x0LV1eVDwjf1gmG7TTnfqIzf+3VPRz7vrNIjX6oYLbeCrf/PeVY6hkT68Mg+q02qXxQhrLjB0jfgvhevoCRmLQ==} @@ -8863,11 +8855,11 @@ snapshots: '@prisma/client-runtime-utils@7.8.0': {} - '@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3))(typescript@6.0.3)': + '@prisma/client@7.8.0(prisma@7.8.0(@types/node@26.1.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3))(typescript@6.0.3)': dependencies: '@prisma/client-runtime-utils': 7.8.0 optionalDependencies: - prisma: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3) + prisma: 7.8.0(@types/node@26.1.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3) typescript: 6.0.3 '@prisma/config@7.8.0(magicast@0.5.2)': @@ -11036,8 +11028,6 @@ snapshots: defu@6.1.7: {} - denque@2.1.0: {} - depd@2.0.0: {} destr@2.0.5: {} @@ -11942,10 +11932,6 @@ snapshots: human-signals@8.0.1: {} - iconv-lite@0.7.2: - dependencies: - safer-buffer: 2.1.2 - iconv-lite@0.7.3: dependencies: safer-buffer: 2.1.2 @@ -12580,17 +12566,16 @@ snapshots: mute-stream@3.0.0: {} - mysql2@3.15.3: + mysql2@3.24.2(@types/node@26.1.1): dependencies: + '@types/node': 26.1.1 aws-ssl-profiles: 1.1.2 - denque: 2.1.0 generate-function: 2.3.1 - iconv-lite: 0.7.2 + iconv-lite: 0.7.3 long: 5.3.2 lru.min: 1.1.4 named-placeholders: 1.1.6 - seq-queue: 0.0.5 - sqlstring: 2.3.3 + sql-escaper: 1.5.1 named-placeholders@1.1.6: dependencies: @@ -13051,17 +13036,18 @@ snapshots: dependencies: parse-ms: 4.0.0 - prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3): + prisma@7.8.0(@types/node@26.1.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3): dependencies: '@prisma/config': 7.8.0(magicast@0.5.2) '@prisma/dev': 0.24.3(typescript@6.0.3) '@prisma/engines': 7.8.0 '@prisma/studio-core': 0.27.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - mysql2: 3.15.3 + mysql2: 3.24.2(@types/node@26.1.1) postgres: 3.4.7 optionalDependencies: typescript: 6.0.3 transitivePeerDependencies: + - '@types/node' - '@types/react' - '@types/react-dom' - magicast @@ -13502,8 +13488,6 @@ snapshots: transitivePeerDependencies: - supports-color - seq-queue@0.0.5: {} - serialize-error@13.0.1: dependencies: non-error: 0.1.0 @@ -13686,7 +13670,7 @@ snapshots: split2@4.2.0: {} - sqlstring@2.3.3: {} + sql-escaper@1.5.1: {} ssh-remote-port-forward@1.0.4: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e25a0b26e..c90da0443 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -11,6 +11,11 @@ overrides: # The advisory covers 4.x only; the bound keeps a hypothetical 3.x consumer # off a major it never asked for. "js-yaml@>=4.0.0 <4.3.1": "^4.3.1" + # GHSA-3f6p-5ww8-9rcr: auth-plugin downgrade leaking plaintext credentials. + # Reached only through prisma's MySQL driver, which this app never opens; + # pinned so the audit reads clean, and mirrored for /opt/prisma-cli in the + # Dockerfile. + "mysql2@<3.22.0": "^3.22.0" # The scanner suggests 10.x (latest), but the ReDoS fix is backported: only # <3.1.4 is vulnerable, so the 3.x line stays where its consumers expect it. "minimatch@<3.1.4": "^3.1.4" From 05d3736d75c54b5fbdbdd87a75126b2b3b569464 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 1 Sep 2026 21:00:12 +0200 Subject: [PATCH 4/5] Resolve OAuth callback URLs on the server for integration cards The v1.38.0 notice that warns when the configured OAuth callback origin differs from the origin the browser is on never rendered in the published image, and the copyable callback address in the redirect guide showed a bare path. `integrationCallbackUrl()` read `process.env.NEXT_PUBLIC_APP_URL` inside a "use client" module. Next.js inlines NEXT_PUBLIC_* into client bundles at build time, and the GHCR image is built without that variable (only the version, build-sha and built-at args reach the build), so the base compiled to an empty string, `new URL("/api//callback")` threw, and the notice returned null on every card for every self-hoster. The value now comes from the server at request time. A new map (`src/lib/integrations/callback-urls.ts`) calls each provider's own redirect-URI function (the four private `getRedirectUri()` helpers are exported under provider-specific names; Polar, Oura and Strava already exported theirs), so the page shows exactly what the handshake registers, explicit `*_REDIRECT_URI` override included. The settings route resolves the map and passes it as a prop through `IntegrationsSection` and `ConnectionsPanel` to every card. Fitbit and Google Health validate their URI and throw on a bad configuration; that surfaces as `null`, the notice skips the comparison and the guide falls back to the fixed path. Tests: a map test proving the explicit override wins and the derived form is used otherwise, a component test rendering the notice from a passed value (mismatch renders, match does not), and a structural guard that fails when any "use client" file under src/components/settings/integrations reads NEXT_PUBLIC_APP_URL. --- .../integration-callback-url-guard.test.ts | 56 +++++++++ src/app/settings/[section]/page.tsx | 14 ++- .../__tests__/integrations-section.test.tsx | 12 +- .../settings/__tests__/sections.test.tsx | 15 ++- .../settings/integrations-section.tsx | 10 +- .../callback-mismatch-notice.test.tsx | 75 ++++++++++++ .../connections-panel-coverage.test.tsx | 12 +- .../__tests__/oauth-provider-card.test.tsx | 2 +- .../__tests__/whoop-card.test.tsx | 14 ++- .../integrations/connections-panel.tsx | 45 +++++-- .../settings/integrations/fitbit-card.tsx | 8 +- .../integrations/google-health-card.tsx | 7 +- .../integrations/oauth-provider-card.tsx | 8 +- .../settings/integrations/oura-card.tsx | 10 +- .../settings/integrations/polar-card.tsx | 10 +- .../integrations/setup-guide-link.tsx | 34 +++--- .../settings/integrations/strava-card.tsx | 10 +- .../settings/integrations/whoop-card.tsx | 8 +- .../settings/integrations/withings-card.tsx | 8 +- src/lib/fitbit/client.ts | 6 +- src/lib/google-health/client.ts | 6 +- .../__tests__/callback-urls.test.ts | 113 ++++++++++++++++++ src/lib/integrations/callback-urls.ts | 78 ++++++++++++ src/lib/whoop/client.ts | 6 +- src/lib/withings/client.ts | 6 +- 25 files changed, 516 insertions(+), 57 deletions(-) create mode 100644 src/__tests__/integration-callback-url-guard.test.ts create mode 100644 src/components/settings/integrations/__tests__/callback-mismatch-notice.test.tsx create mode 100644 src/lib/integrations/__tests__/callback-urls.test.ts create mode 100644 src/lib/integrations/callback-urls.ts diff --git a/src/__tests__/integration-callback-url-guard.test.ts b/src/__tests__/integration-callback-url-guard.test.ts new file mode 100644 index 000000000..4ab934aa1 --- /dev/null +++ b/src/__tests__/integration-callback-url-guard.test.ts @@ -0,0 +1,56 @@ +/** + * Structural guard: no client module on the Settings → Integrations surface + * may read `process.env.NEXT_PUBLIC_APP_URL`. + * + * Why this is a tripwire and not a behavioural test. Next.js inlines every + * `NEXT_PUBLIC_*` reference in a `"use client"` module at BUILD time. The + * published image is built without `NEXT_PUBLIC_APP_URL` (the Dockerfile + * passes only the version / build-sha / built-at args, and `.env` files are + * not in the build context), so any such read compiles to `undefined` for + * every self-hoster, no matter what their runtime env says. A unit test with + * the variable set in `process.env` cannot see that: under Vitest the read is + * live, so the code passes while the shipped bundle is inert. v1.38.0's + * callback-origin notice shipped exactly that way. + * + * The callback URL therefore comes from the server (`getIntegrationCallbackUrls` + * in `src/lib/integrations/callback-urls.ts`) and travels down as a prop. This + * guard fails the moment a client file on the surface reaches for the env var + * again. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SURFACE = join(process.cwd(), "src/components/settings/integrations"); + +const CLIENT_DIRECTIVE = /^\s*["']use client["']\s*;?/m; +const APP_URL_READ = /process\.env\.NEXT_PUBLIC_APP_URL/; + +function surfaceFiles(): string[] { + return walkSourceFiles(SURFACE, { floor: 15 }) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")); +} + +describe("Settings → Integrations client modules never read NEXT_PUBLIC_APP_URL", () => { + it('every "use client" file on the surface is free of the env read', () => { + const files = surfaceFiles(); + const clientFiles = files.filter((rel) => + CLIENT_DIRECTIVE.test(readFileSync(join(SURFACE, rel), "utf8")), + ); + // The walk must have found the surface, and the surface must still be + // client-rendered — an empty set would pass on a tree the guard never read. + expect(files.length).toBeGreaterThanOrEqual(15); + expect(clientFiles.length).toBeGreaterThanOrEqual(10); + + const offenders = clientFiles.filter((rel) => + APP_URL_READ.test(readFileSync(join(SURFACE, rel), "utf8")), + ); + expect( + offenders, + `NEXT_PUBLIC_APP_URL is inlined at build time in client bundles and is empty in the published image; pass the server-derived callback URL as a prop instead: ${offenders.join(", ")}`, + ).toEqual([]); + }); +}); diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx index 572123d39..f5c4a559a 100644 --- a/src/app/settings/[section]/page.tsx +++ b/src/app/settings/[section]/page.tsx @@ -29,6 +29,7 @@ import { } from "@/components/settings/section-slugs"; import { SettingsShell } from "@/components/settings/settings-shell"; import { RecordSettingsSectionGate } from "@/components/settings/record-settings-section-gate"; +import { getIntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; /** * Dynamic settings section route. Each of the `SETTINGS_SECTION_SLUGS` @@ -46,6 +47,17 @@ export function generateStaticParams() { return SETTINGS_SECTION_SLUGS.map((section) => ({ section })); } +/** + * Integrations is the one section that needs a server-resolved value: the + * OAuth callback URL each provider registers. It is read from the runtime env + * here, per request, and handed to the client section as a prop, because a + * `NEXT_PUBLIC_*` read inside a client module is inlined at build time and the + * published image is built without it. + */ +function IntegrationsSectionWithCallbackUrls() { + return ; +} + const SECTION_COMPONENTS: Record< SettingsSectionSlug, () => JSX.Element | null @@ -57,7 +69,7 @@ const SECTION_COMPONENTS: Record< about: AboutSection, ai: AiSection, coach: CoachSection, - integrations: IntegrationsSection, + integrations: IntegrationsSectionWithCallbackUrls, sources: SourcesSection, notifications: NotificationsSection, layout: LayoutSection, diff --git a/src/components/settings/__tests__/integrations-section.test.tsx b/src/components/settings/__tests__/integrations-section.test.tsx index 929dfb29e..cdf1792b0 100644 --- a/src/components/settings/__tests__/integrations-section.test.tsx +++ b/src/components/settings/__tests__/integrations-section.test.tsx @@ -75,10 +75,20 @@ vi.mock("@tanstack/react-query", () => ({ import { I18nProvider } from "@/lib/i18n/context"; import { IntegrationsSection } from "../integrations-section"; +const CALLBACK_URLS = { + withings: "https://app.example/api/withings/callback", + whoop: "https://app.example/api/whoop/callback", + fitbit: "https://app.example/api/fitbit/callback", + "google-health": "https://app.example/api/google-health/callback", + polar: "https://app.example/api/polar/callback", + oura: "https://app.example/api/oura/callback", + strava: "https://app.example/api/strava/callback", +}; + function render() { return renderToStaticMarkup( - + , ); } diff --git a/src/components/settings/__tests__/sections.test.tsx b/src/components/settings/__tests__/sections.test.tsx index 782bfe7ed..0abf949a0 100644 --- a/src/components/settings/__tests__/sections.test.tsx +++ b/src/components/settings/__tests__/sections.test.tsx @@ -104,6 +104,16 @@ import { NotificationsSection } from "../notifications-section"; // slug like every other settings section. import { ThresholdsSection } from "../thresholds-section"; +const CALLBACK_URLS = { + withings: "https://app.example/api/withings/callback", + whoop: "https://app.example/api/whoop/callback", + fitbit: "https://app.example/api/fitbit/callback", + "google-health": "https://app.example/api/google-health/callback", + polar: "https://app.example/api/polar/callback", + oura: "https://app.example/api/oura/callback", + strava: "https://app.example/api/strava/callback", +}; + function render(node: React.ReactElement, locale: "en" | "de" = "en") { return renderToStaticMarkup( {node}, @@ -184,7 +194,10 @@ describe("settings sections — SSR smoke", () => { }); it(" renders Withings card + the delivery-channels group", () => { - const html = renderFramed("integrations", ); + const html = renderFramed( + "integrations", + , + ); expect(html).toContain("Integrations"); expect(html).toContain("Withings"); // v1.25.7 — delivery channels moved here from Notifications: the diff --git a/src/components/settings/integrations-section.tsx b/src/components/settings/integrations-section.tsx index 1408d4698..8f1247cb3 100644 --- a/src/components/settings/integrations-section.tsx +++ b/src/components/settings/integrations-section.tsx @@ -32,6 +32,7 @@ import Link from "next/link"; import { Link2, Send } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; +import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; import { ConnectionsPanel } from "@/components/settings/integrations/connections-panel"; import { SectionHeading } from "@/components/ui/section-heading"; import { NotificationChannelsPanel } from "@/components/settings/integrations/notification-channels-panel"; @@ -43,7 +44,12 @@ export { oauthReasonKey, } from "@/components/settings/integrations/connections-panel"; -export function IntegrationsSection() { +export function IntegrationsSection({ + callbackUrls, +}: { + /** Server-resolved per-provider OAuth callback URLs; see `ConnectionsPanel`. */ + callbackUrls: IntegrationCallbackUrls; +}) { const { t } = useTranslations(); // v1.18.6 (W9) — the visible page heading + subtitle come from the shared @@ -59,7 +65,7 @@ export function IntegrationsSection() { icon={Link2} title={t("settings.sections.integrations.connectionsHeading")} /> - + {/* 2026-07-17 UX/IA audit M7 — the opt-in nutrients sync inventory (moved from Settings → Sources, where "source priority" only diff --git a/src/components/settings/integrations/__tests__/callback-mismatch-notice.test.tsx b/src/components/settings/integrations/__tests__/callback-mismatch-notice.test.tsx new file mode 100644 index 000000000..38f9d2271 --- /dev/null +++ b/src/components/settings/integrations/__tests__/callback-mismatch-notice.test.tsx @@ -0,0 +1,75 @@ +/** + * `` renders from the callback URL it is HANDED, not + * from anything it reads itself. The origin the browser is on comes through + * `useSyncExternalStore`, whose server snapshot is deliberately `null` (the + * server cannot know the browser origin, and a notice that flips at hydration + * would flash). A static render therefore never shows it; to exercise the + * client path here the hook is replaced with its client snapshot and a bare + * `window.location.origin` is provided. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useSyncExternalStore: ( + _subscribe: (onChange: () => void) => () => void, + getSnapshot: () => T, + ): T => getSnapshot(), + }; +}); + +import { I18nProvider } from "@/lib/i18n/context"; +import { CallbackMismatchNotice } from "../setup-guide-link"; + +const BROWSER_ORIGIN = "https://health.example"; + +function render(callbackUrl: string | null) { + return renderToStaticMarkup( + + + , + ); +} + +describe("", () => { + const priorWindow = (globalThis as { window?: unknown }).window; + + beforeEach(() => { + (globalThis as { window?: unknown }).window = { + location: { origin: BROWSER_ORIGIN }, + }; + }); + + afterEach(() => { + if (priorWindow === undefined) { + delete (globalThis as { window?: unknown }).window; + } else { + (globalThis as { window?: unknown }).window = priorWindow; + } + }); + + it("renders the warning when the configured callback origin differs from the browser origin", () => { + const html = render("https://edge.example/api/whoop/callback"); + expect(html).toContain("WHOOP"); + expect(html).toContain("https://edge.example"); + expect(html).toContain(BROWSER_ORIGIN); + expect(html).toContain("differs from this app address"); + }); + + it("renders nothing when the callback origin matches the browser origin", () => { + expect(render(`${BROWSER_ORIGIN}/api/whoop/callback`)).toBe(""); + }); + + it("renders nothing when the server could not produce a callback URL", () => { + expect(render(null)).toBe(""); + }); + + it("renders nothing for a callback URL that is not absolute", () => { + // The pre-fix client bundle produced exactly this shape and the notice + // stayed silent; it still must, rather than throwing out of the card. + expect(render("/api/whoop/callback")).toBe(""); + }); +}); diff --git a/src/components/settings/integrations/__tests__/connections-panel-coverage.test.tsx b/src/components/settings/integrations/__tests__/connections-panel-coverage.test.tsx index a8cdb2e1f..c06fa4d22 100644 --- a/src/components/settings/integrations/__tests__/connections-panel-coverage.test.tsx +++ b/src/components/settings/integrations/__tests__/connections-panel-coverage.test.tsx @@ -84,10 +84,20 @@ function fullEnvelope() { }; } +const CALLBACK_URLS = { + withings: "https://app.example/api/withings/callback", + whoop: "https://app.example/api/whoop/callback", + fitbit: "https://app.example/api/fitbit/callback", + "google-health": "https://app.example/api/google-health/callback", + polar: "https://app.example/api/polar/callback", + oura: "https://app.example/api/oura/callback", + strava: "https://app.example/api/strava/callback", +}; + function render() { return renderToStaticMarkup( - + , ); } diff --git a/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx b/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx index b4fc2d3ef..b47765b63 100644 --- a/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx +++ b/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx @@ -41,6 +41,7 @@ function render({ dataHref="/insights/sleep" credentials={credentials} viewModel={viewModel} + callbackUrl="https://app.example/api/polar/callback" /> , ); @@ -162,7 +163,6 @@ describe("OAuthProviderCard — per-user BYO credentials form (v1.17.1)", () => describe("OAuthProviderCard — redirect-URI mini-guide (v1.29.x, UX audit H2)", () => { it("shows the callback URL guide before the user has BYO credentials", () => { - process.env.NEXT_PUBLIC_APP_URL = "https://app.example"; const html = render({ credentials: true, viewModel: { diff --git a/src/components/settings/integrations/__tests__/whoop-card.test.tsx b/src/components/settings/integrations/__tests__/whoop-card.test.tsx index 3faae333a..6d5bc5388 100644 --- a/src/components/settings/integrations/__tests__/whoop-card.test.tsx +++ b/src/components/settings/integrations/__tests__/whoop-card.test.tsx @@ -15,11 +15,15 @@ import { I18nProvider } from "@/lib/i18n/context"; import { WhoopCard } from "../whoop-card"; import type { IntegrationStatusViewModel } from "../shared"; -function render(viewModel?: Partial) { +function render( + viewModel?: Partial, + callbackUrl: string | null = "https://app.example/api/whoop/callback", +) { return renderToStaticMarkup( , ); @@ -27,13 +31,19 @@ function render(viewModel?: Partial) { describe(" redirect-URI mini-guide", () => { it("shows the callback URL guide before credentials are configured", () => { - process.env.NEXT_PUBLIC_APP_URL = "https://app.example"; const html = render({ connected: false, configured: false }); expect(html).toContain('data-testid="whoop-redirect-guide"'); expect(html).toContain('data-testid="whoop-redirect-uri"'); expect(html).toContain("https://app.example/api/whoop/callback"); }); + it("falls back to the fixed path when the server has no redirect URI", () => { + const html = render({ connected: false, configured: false }, null); + expect(html).toContain('data-testid="whoop-redirect-uri"'); + expect(html).toContain("/api/whoop/callback"); + expect(html).not.toContain("undefined/api/whoop/callback"); + }); + it("hides the guide once credentials are configured", () => { const html = render({ connected: false, configured: true }); expect(html).not.toContain('data-testid="whoop-redirect-guide"'); diff --git a/src/components/settings/integrations/connections-panel.tsx b/src/components/settings/integrations/connections-panel.tsx index c2e5c01c1..89dbfeb89 100644 --- a/src/components/settings/integrations/connections-panel.tsx +++ b/src/components/settings/integrations/connections-panel.tsx @@ -35,6 +35,7 @@ import { import { WhoopCard } from "@/components/settings/integrations/whoop-card"; import { WithingsCard } from "@/components/settings/integrations/withings-card"; import { useAuth } from "@/hooks/use-auth"; +import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -209,7 +210,17 @@ const CARD_BACKED_INTEGRATIONS: ReadonlySet = new Set([ "nightscout", ]); -export function ConnectionsPanel() { +export function ConnectionsPanel({ + callbackUrls, +}: { + /** + * The per-provider OAuth callback URLs, resolved on the server per request + * (`getIntegrationCallbackUrls`) and threaded down from the route. A client + * module cannot derive them: `NEXT_PUBLIC_APP_URL` is inlined at build + * time and absent from the published image. + */ + callbackUrls: IntegrationCallbackUrls; +}) { const { t } = useTranslations(); const { isAuthenticated } = useAuth(); const router = useRouter(); @@ -328,28 +339,46 @@ export function ConnectionsPanel() { {/* Per-provider anchors so deep-links (onboarding "more sources" chips, `/settings/integrations#oura`) scroll straight to the right card. */}
- +
- +
- +
- +
- +
- +
- +
diff --git a/src/components/settings/integrations/fitbit-card.tsx b/src/components/settings/integrations/fitbit-card.tsx index 98e7c4056..2d16a8f7a 100644 --- a/src/components/settings/integrations/fitbit-card.tsx +++ b/src/components/settings/integrations/fitbit-card.tsx @@ -68,13 +68,16 @@ import { CallbackMismatchNotice, IntegrationCardDescription, IntegrationRedirectGuide, - integrationCallbackUrl, } from "./setup-guide-link"; +import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; export function FitbitCard({ viewModel, + callbackUrl, }: { viewModel: IntegrationStatusViewModel | undefined; + /** Server-resolved OAuth callback URL; see `getIntegrationCallbackUrls`. */ + callbackUrl: IntegrationCallbackUrls["fitbit"]; }) { const { t } = useTranslations(); const describeSyncOutcome = useSyncOutcomeMessage(); @@ -314,6 +317,7 @@ export function FitbitCard({ )}
@@ -525,7 +529,7 @@ export function FitbitCard({
); diff --git a/src/components/settings/integrations/google-health-card.tsx b/src/components/settings/integrations/google-health-card.tsx index 99b127409..86ea06dc3 100644 --- a/src/components/settings/integrations/google-health-card.tsx +++ b/src/components/settings/integrations/google-health-card.tsx @@ -83,13 +83,16 @@ import { import { CallbackMismatchNotice, IntegrationCardDescription, - integrationCallbackUrl, } from "./setup-guide-link"; +import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; export function GoogleHealthCard({ viewModel, + callbackUrl, }: { viewModel: IntegrationStatusViewModel | undefined; + /** Server-resolved OAuth callback URL; see `getIntegrationCallbackUrls`. */ + callbackUrl: IntegrationCallbackUrls["google-health"]; }) { const { t } = useTranslations(); const describeSyncOutcome = useSyncOutcomeMessage(); @@ -764,7 +767,7 @@ export function GoogleHealthCard({ ); diff --git a/src/components/settings/integrations/oauth-provider-card.tsx b/src/components/settings/integrations/oauth-provider-card.tsx index 147fcc79a..4b8d2e5b7 100644 --- a/src/components/settings/integrations/oauth-provider-card.tsx +++ b/src/components/settings/integrations/oauth-provider-card.tsx @@ -69,8 +69,8 @@ import { IntegrationCardDescription, IntegrationRedirectGuide, type IntegrationDocsProvider, - integrationCallbackUrl, } from "./setup-guide-link"; +import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; export interface OAuthProviderStatus { connected: boolean; @@ -123,6 +123,8 @@ export interface OAuthProviderCardProps { * exist. */ viewModel?: OAuthProviderStatus; + /** Server-resolved OAuth callback URL; see `getIntegrationCallbackUrls`. */ + callbackUrl: IntegrationCallbackUrls[OAuthProviderCardProps["provider"]]; } export function OAuthProviderCard({ @@ -133,6 +135,7 @@ export function OAuthProviderCard({ dataHref, credentials = false, viewModel, + callbackUrl, }: OAuthProviderCardProps) { const { t } = useTranslations(); const describeSyncOutcome = useSyncOutcomeMessage(); @@ -341,6 +344,7 @@ export function OAuthProviderCard({ )} @@ -526,7 +530,7 @@ export function OAuthProviderCard({ ); diff --git a/src/components/settings/integrations/oura-card.tsx b/src/components/settings/integrations/oura-card.tsx index 068726982..cb669d917 100644 --- a/src/components/settings/integrations/oura-card.tsx +++ b/src/components/settings/integrations/oura-card.tsx @@ -10,9 +10,16 @@ import { OAuthProviderCard, type OAuthProviderStatus, } from "@/components/settings/integrations/oauth-provider-card"; +import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; import { queryKeys } from "@/lib/query-keys"; -export function OuraCard({ viewModel }: { viewModel?: OAuthProviderStatus }) { +export function OuraCard({ + viewModel, + callbackUrl, +}: { + viewModel?: OAuthProviderStatus; + callbackUrl: IntegrationCallbackUrls["oura"]; +}) { return ( ); } diff --git a/src/components/settings/integrations/polar-card.tsx b/src/components/settings/integrations/polar-card.tsx index 592e04836..4900e449e 100644 --- a/src/components/settings/integrations/polar-card.tsx +++ b/src/components/settings/integrations/polar-card.tsx @@ -10,9 +10,16 @@ import { OAuthProviderCard, type OAuthProviderStatus, } from "@/components/settings/integrations/oauth-provider-card"; +import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; import { queryKeys } from "@/lib/query-keys"; -export function PolarCard({ viewModel }: { viewModel?: OAuthProviderStatus }) { +export function PolarCard({ + viewModel, + callbackUrl, +}: { + viewModel?: OAuthProviderStatus; + callbackUrl: IntegrationCallbackUrls["polar"]; +}) { return ( ); } diff --git a/src/components/settings/integrations/setup-guide-link.tsx b/src/components/settings/integrations/setup-guide-link.tsx index 23889a30a..dab6e8cbb 100644 --- a/src/components/settings/integrations/setup-guide-link.tsx +++ b/src/components/settings/integrations/setup-guide-link.tsx @@ -40,25 +40,21 @@ export function integrationDocsHref(provider: IntegrationDocsProvider): string { } /** - * The OAuth callback path every BYO-key provider registers against. Every - * provider's `getRedirectUri()` (`src/lib/{provider}/client.ts`) derives the - * same shape from `NEXT_PUBLIC_APP_URL` unless an operator overrides it with - * an explicit `*_REDIRECT_URI` env var — the common case this card targets. + * Shows a compact warning when the callback origin differs from the app. + * + * `callbackUrl` is the value the server registers as `redirect_uri`, resolved + * per request by `getIntegrationCallbackUrls()` and passed down as a prop. It + * must not be derived here: a `NEXT_PUBLIC_*` read in a client module is + * inlined at build time, and the published image is built without it. + * `null` means the provider cannot build a redirect URI from the current env, + * so there is nothing to compare. */ -export function integrationCallbackUrl( - provider: IntegrationDocsProvider, -): string { - const base = process.env.NEXT_PUBLIC_APP_URL ?? ""; - return `${base}/api/${provider}/callback`; -} - -/** Shows a compact warning when the callback origin differs from the app. */ export function CallbackMismatchNotice({ provider, callbackUrl, }: { provider: string; - callbackUrl: string; + callbackUrl: string | null; }) { // Get the current origin of the app const { t } = useTranslations(); @@ -69,6 +65,7 @@ export function CallbackMismatchNotice({ ); // Get the origin of the callback URL const callbackOrigin = (() => { + if (!callbackUrl) return null; try { return new URL(callbackUrl).origin; } catch { @@ -103,12 +100,19 @@ export function CallbackMismatchNotice({ export function IntegrationRedirectGuide({ provider, providerLabel, + callbackUrl, }: { provider: IntegrationDocsProvider; providerLabel: string; + /** + * The server-resolved callback URL (see `CallbackMismatchNotice`). When the + * provider cannot build one from the current env, show the fixed path so + * the step still names what the vendor form expects. + */ + callbackUrl: string | null; }) { const { t } = useTranslations(); - const callbackUrl = integrationCallbackUrl(provider); + const shownCallbackUrl = callbackUrl ?? `/api/${provider}/callback`; return (
- {callbackUrl} + {shownCallbackUrl}
  • {t("settings.integrationRedirectGuide.step3")}
  • diff --git a/src/components/settings/integrations/strava-card.tsx b/src/components/settings/integrations/strava-card.tsx index c2a501f50..534558e02 100644 --- a/src/components/settings/integrations/strava-card.tsx +++ b/src/components/settings/integrations/strava-card.tsx @@ -11,9 +11,16 @@ import { OAuthProviderCard, type OAuthProviderStatus, } from "@/components/settings/integrations/oauth-provider-card"; +import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; import { queryKeys } from "@/lib/query-keys"; -export function StravaCard({ viewModel }: { viewModel?: OAuthProviderStatus }) { +export function StravaCard({ + viewModel, + callbackUrl, +}: { + viewModel?: OAuthProviderStatus; + callbackUrl: IntegrationCallbackUrls["strava"]; +}) { return ( ); } diff --git a/src/components/settings/integrations/whoop-card.tsx b/src/components/settings/integrations/whoop-card.tsx index db02bd914..2b4fad121 100644 --- a/src/components/settings/integrations/whoop-card.tsx +++ b/src/components/settings/integrations/whoop-card.tsx @@ -59,13 +59,16 @@ import { CallbackMismatchNotice, IntegrationCardDescription, IntegrationRedirectGuide, - integrationCallbackUrl, } from "./setup-guide-link"; +import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; export function WhoopCard({ viewModel, + callbackUrl, }: { viewModel: IntegrationStatusViewModel | undefined; + /** Server-resolved OAuth callback URL; see `getIntegrationCallbackUrls`. */ + callbackUrl: IntegrationCallbackUrls["whoop"]; }) { const { t } = useTranslations(); const describeSyncOutcome = useSyncOutcomeMessage(); @@ -296,6 +299,7 @@ export function WhoopCard({ )} @@ -507,7 +511,7 @@ export function WhoopCard({
    ); diff --git a/src/components/settings/integrations/withings-card.tsx b/src/components/settings/integrations/withings-card.tsx index b85c8b03b..ec8d61ce5 100644 --- a/src/components/settings/integrations/withings-card.tsx +++ b/src/components/settings/integrations/withings-card.tsx @@ -58,13 +58,16 @@ import { CallbackMismatchNotice, IntegrationCardDescription, IntegrationRedirectGuide, - integrationCallbackUrl, } from "./setup-guide-link"; +import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; export function WithingsCard({ viewModel, + callbackUrl, }: { viewModel: IntegrationStatusViewModel | undefined; + /** Server-resolved OAuth callback URL; see `getIntegrationCallbackUrls`. */ + callbackUrl: IntegrationCallbackUrls["withings"]; }) { const { t } = useTranslations(); const describeSyncOutcome = useSyncOutcomeMessage(); @@ -335,6 +338,7 @@ export function WithingsCard({ )} @@ -556,7 +560,7 @@ export function WithingsCard({ ); diff --git a/src/lib/fitbit/client.ts b/src/lib/fitbit/client.ts index 29704735f..1015e15fa 100644 --- a/src/lib/fitbit/client.ts +++ b/src/lib/fitbit/client.ts @@ -75,7 +75,7 @@ export interface FitbitCredentials { * - must land on the fixed `/api/fitbit/callback` path, * - when derived from `NEXT_PUBLIC_APP_URL`, must stay same-origin with it. */ -function getRedirectUri(): string { +export function getFitbitRedirectUri(): string { const explicit = process.env.FITBIT_REDIRECT_URI; const appUrl = process.env.NEXT_PUBLIC_APP_URL; const raw = @@ -188,7 +188,7 @@ export function getAuthorizationUrl( const params = new URLSearchParams({ response_type: "code", client_id: creds.clientId, - redirect_uri: getRedirectUri(), + redirect_uri: getFitbitRedirectUri(), scope: FITBIT_OAUTH_SCOPE, code_challenge: codeChallenge, code_challenge_method: "S256", @@ -287,7 +287,7 @@ export async function exchangeCode( grant_type: "authorization_code", code, client_id: creds.clientId, - redirect_uri: getRedirectUri(), + redirect_uri: getFitbitRedirectUri(), code_verifier: codeVerifier, }), creds, diff --git a/src/lib/google-health/client.ts b/src/lib/google-health/client.ts index 659ccc75a..cb5a33b94 100644 --- a/src/lib/google-health/client.ts +++ b/src/lib/google-health/client.ts @@ -146,7 +146,7 @@ export interface GoogleHealthCredentials { * - must land on the fixed `/api/google-health/callback` path, * - when derived from `NEXT_PUBLIC_APP_URL`, must stay same-origin with it. */ -function getRedirectUri(): string { +export function getGoogleHealthRedirectUri(): string { const explicit = process.env.GOOGLE_HEALTH_REDIRECT_URI; const appUrl = process.env.NEXT_PUBLIC_APP_URL; const raw = @@ -276,7 +276,7 @@ export function getAuthorizationUrl( const params = new URLSearchParams({ response_type: "code", client_id: creds.clientId, - redirect_uri: getRedirectUri(), + redirect_uri: getGoogleHealthRedirectUri(), scope: getGoogleHealthScopeString(), access_type: "offline", prompt: "consent", @@ -371,7 +371,7 @@ export async function exchangeCode( grant_type: "authorization_code", code, client_id: creds.clientId, - redirect_uri: getRedirectUri(), + redirect_uri: getGoogleHealthRedirectUri(), code_verifier: codeVerifier, }), creds, diff --git a/src/lib/integrations/__tests__/callback-urls.test.ts b/src/lib/integrations/__tests__/callback-urls.test.ts new file mode 100644 index 000000000..27d25c2e3 --- /dev/null +++ b/src/lib/integrations/__tests__/callback-urls.test.ts @@ -0,0 +1,113 @@ +/** + * `getIntegrationCallbackUrls()` must hand the Settings → Integrations cards + * the exact `redirect_uri` each provider's client registers: the explicit + * `_REDIRECT_URI` when the operator set one, the + * `${NEXT_PUBLIC_APP_URL}/api//callback` form otherwise. The map + * goes through the provider functions themselves rather than restating the + * rule, so this test pins that the wiring reaches every provider and that a + * provider whose validation throws surfaces as `null` instead of taking the + * page down. + */ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + INTEGRATION_CALLBACK_PROVIDERS, + getIntegrationCallbackUrls, +} from "../callback-urls"; + +const OVERRIDE_VARS: Record< + (typeof INTEGRATION_CALLBACK_PROVIDERS)[number], + string +> = { + withings: "WITHINGS_REDIRECT_URI", + whoop: "WHOOP_REDIRECT_URI", + fitbit: "FITBIT_REDIRECT_URI", + "google-health": "GOOGLE_HEALTH_REDIRECT_URI", + polar: "POLAR_REDIRECT_URI", + oura: "OURA_REDIRECT_URI", + strava: "STRAVA_REDIRECT_URI", +}; + +const TOUCHED = ["NEXT_PUBLIC_APP_URL", ...Object.values(OVERRIDE_VARS)]; +let saved: Record; + +beforeEach(() => { + saved = Object.fromEntries(TOUCHED.map((k) => [k, process.env[k]])); + for (const k of TOUCHED) delete process.env[k]; +}); + +afterEach(() => { + for (const k of TOUCHED) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } +}); + +describe("getIntegrationCallbackUrls", () => { + it("derives ${NEXT_PUBLIC_APP_URL}/api//callback when nothing overrides it", () => { + process.env.NEXT_PUBLIC_APP_URL = "https://health.example"; + const urls = getIntegrationCallbackUrls(); + for (const provider of INTEGRATION_CALLBACK_PROVIDERS) { + expect(urls[provider], provider).toBe( + `https://health.example/api/${provider}/callback`, + ); + } + }); + + it("lets the explicit _REDIRECT_URI win over the derived form", () => { + // Withings / WHOOP / Polar / Oura / Strava accept any explicit value. + process.env.NEXT_PUBLIC_APP_URL = "https://health.example"; + for (const provider of [ + "withings", + "whoop", + "polar", + "oura", + "strava", + ] as const) { + process.env[OVERRIDE_VARS[provider]] = + `https://edge.example/api/${provider}/callback`; + } + const urls = getIntegrationCallbackUrls(); + for (const provider of [ + "withings", + "whoop", + "polar", + "oura", + "strava", + ] as const) { + expect(urls[provider], provider).toBe( + `https://edge.example/api/${provider}/callback`, + ); + } + // No override for these two, so they still derive. + expect(urls.fitbit).toBe("https://health.example/api/fitbit/callback"); + expect(urls["google-health"]).toBe( + "https://health.example/api/google-health/callback", + ); + }); + + it("uses the explicit Fitbit / Google Health override when the app URL is unset", () => { + // Those two pin an explicit override to the app origin when both are set; + // with only the override present it is taken verbatim. + process.env.FITBIT_REDIRECT_URI = + "https://edge.example/api/fitbit/callback"; + process.env.GOOGLE_HEALTH_REDIRECT_URI = + "https://edge.example/api/google-health/callback"; + const urls = getIntegrationCallbackUrls(); + expect(urls.fitbit).toBe("https://edge.example/api/fitbit/callback"); + expect(urls["google-health"]).toBe( + "https://edge.example/api/google-health/callback", + ); + }); + + it("reports null for a provider whose redirect URI fails validation, leaving the rest intact", () => { + // Fitbit refuses a plain-http, non-loopback origin; the others do not + // validate and keep deriving. + process.env.NEXT_PUBLIC_APP_URL = "http://health.example"; + const urls = getIntegrationCallbackUrls(); + expect(urls.fitbit).toBeNull(); + expect(urls["google-health"]).toBeNull(); + expect(urls.withings).toBe("http://health.example/api/withings/callback"); + expect(urls.whoop).toBe("http://health.example/api/whoop/callback"); + }); +}); diff --git a/src/lib/integrations/callback-urls.ts b/src/lib/integrations/callback-urls.ts new file mode 100644 index 000000000..d9e7036bf --- /dev/null +++ b/src/lib/integrations/callback-urls.ts @@ -0,0 +1,78 @@ +/** + * The OAuth callback URL each BYO-key provider registers, resolved on the + * server at request time. + * + * Settings → Integrations shows two things built from this value: the + * copyable callback address in the redirect guide, and the notice that warns + * when the configured callback origin differs from the origin the browser is + * on. Both used to read `process.env.NEXT_PUBLIC_APP_URL` inside a + * `"use client"` module. Next.js inlines `NEXT_PUBLIC_*` into client bundles + * at build time, and the published image is built without that variable, so + * every self-hoster's bundle carried an empty base: the guide showed a bare + * path and the notice never rendered. Resolving here, in server code, reads + * the runtime env the operator actually set. + * + * Each entry is the exact value the provider's client sends as `redirect_uri` + * on the wire, through the same function, so what the page shows and what the + * handshake registers cannot drift apart. Fitbit and Google Health validate + * their URI and throw on a malformed configuration; here that surfaces as + * `null` (nothing would be registered), and the cards fall back to the fixed + * path for the guide and skip the origin comparison. + */ + +import { getFitbitRedirectUri } from "@/lib/fitbit/client"; +import { getGoogleHealthRedirectUri } from "@/lib/google-health/client"; +import { getOuraRedirectUri } from "@/lib/oura/client"; +import { getPolarRedirectUri } from "@/lib/polar/client"; +import { getStravaRedirectUri } from "@/lib/strava/client"; +import { getWhoopRedirectUri } from "@/lib/whoop/client"; +import { getWithingsRedirectUri } from "@/lib/withings/client"; + +export const INTEGRATION_CALLBACK_PROVIDERS = [ + "withings", + "whoop", + "fitbit", + "google-health", + "polar", + "oura", + "strava", +] as const; + +export type IntegrationCallbackProvider = + (typeof INTEGRATION_CALLBACK_PROVIDERS)[number]; + +/** `null` when the provider cannot produce a redirect URI from the current env. */ +export type IntegrationCallbackUrls = Record< + IntegrationCallbackProvider, + string | null +>; + +const RESOLVERS: Record string> = { + withings: getWithingsRedirectUri, + whoop: getWhoopRedirectUri, + fitbit: getFitbitRedirectUri, + "google-health": getGoogleHealthRedirectUri, + polar: getPolarRedirectUri, + oura: getOuraRedirectUri, + strava: getStravaRedirectUri, +}; + +function resolve(provider: IntegrationCallbackProvider): string | null { + try { + return RESOLVERS[provider](); + } catch { + return null; + } +} + +export function getIntegrationCallbackUrls(): IntegrationCallbackUrls { + return { + withings: resolve("withings"), + whoop: resolve("whoop"), + fitbit: resolve("fitbit"), + "google-health": resolve("google-health"), + polar: resolve("polar"), + oura: resolve("oura"), + strava: resolve("strava"), + }; +} diff --git a/src/lib/whoop/client.ts b/src/lib/whoop/client.ts index e3d7704b8..6419e0170 100644 --- a/src/lib/whoop/client.ts +++ b/src/lib/whoop/client.ts @@ -32,7 +32,7 @@ export interface WhoopCredentials { clientSecret: string; } -function getRedirectUri(): string { +export function getWhoopRedirectUri(): string { // `||`, not `??`: the compose whitelist materialises the var as an empty // string when unset, which must still fall through to the derived URI. return ( @@ -61,7 +61,7 @@ export function getAuthorizationUrl( const params = new URLSearchParams({ response_type: "code", client_id: creds.clientId, - redirect_uri: getRedirectUri(), + redirect_uri: getWhoopRedirectUri(), scope: WHOOP_OAUTH_SCOPE, state, }); @@ -119,7 +119,7 @@ export async function exchangeCode( code, client_id: creds.clientId, client_secret: creds.clientSecret, - redirect_uri: getRedirectUri(), + redirect_uri: getWhoopRedirectUri(), }), "exchangeCode", ); diff --git a/src/lib/withings/client.ts b/src/lib/withings/client.ts index c36b86d94..db6a41775 100644 --- a/src/lib/withings/client.ts +++ b/src/lib/withings/client.ts @@ -20,7 +20,7 @@ export interface WithingsCredentials { clientSecret: string; } -function getRedirectUri(): string { +export function getWithingsRedirectUri(): string { return ( process.env.WITHINGS_REDIRECT_URI ?? `${process.env.NEXT_PUBLIC_APP_URL}/api/withings/callback` @@ -79,7 +79,7 @@ export function getAuthorizationUrl( const params = new URLSearchParams({ response_type: "code", client_id: creds.clientId, - redirect_uri: getRedirectUri(), + redirect_uri: getWithingsRedirectUri(), scope: WITHINGS_OAUTH_SCOPE, state, }); @@ -105,7 +105,7 @@ export async function exchangeCode( grant_type: "authorization_code", client_id: creds.clientId, client_secret: creds.clientSecret, - redirect_uri: getRedirectUri(), + redirect_uri: getWithingsRedirectUri(), code, }); From a2e50c761f0d5c02e3b7792bf703224c7f946639 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 1 Sep 2026 21:00:50 +0200 Subject: [PATCH 5/5] Note the callback-notice fix and the mysql2 pin in the changelog --- CHANGELOG.md | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8cb5e36c..f34b1ac0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,14 @@ There is now a token for exactly that job, and for nothing else. ### Fixed +- Settings, Integrations: the warning that the configured OAuth callback + origin differs from the address you are browsing on, added in v1.38.0, + never appeared in the published image, and the copyable callback address in + each setup guide showed only a path. Both read the app address inside the + browser bundle, where it is fixed at build time and the published image is + built without it. Both values now come from the server at request time and + reflect the `NEXT_PUBLIC_APP_URL` or `*_REDIRECT_URI` you actually set. A + structural test refuses a client module that reads that variable again. - Lab OCR with a GPT-5 model on the OpenAI provider path (your own key or the operator's admin key), fixed by @TimonBed in #861. The list of models the app trusts to read an image stopped at `gpt-4o`, `gpt-4.1`, @@ -109,15 +117,19 @@ There is now a token for exactly that job, and for nothing else. ### Security +- `mysql2` is pinned past GHSA-3f6p-5ww8-9rcr (an auth-plugin downgrade + that leaks the credential) in both the application tree and the separate + Prisma CLI install inside the image. It reaches the tree only through + Prisma's MySQL driver, which this app never opens. - The token is confined to your own record. Pointing it at a record somebody has shared with you is refused, and refused before the sharing grant is even consulted, so no future change to what sharing permits can widen what one of these tokens reaches. - Minting one requires being signed in on the web. No API token can mint - another, whatever it is allowed to do: the sign-in a phone holds lasts a - day and what it could mint here lasts a year, so allowing it would let a - credential that leaked for an afternoon leave behind one that outlives - cancelling it. + one of these, whatever it is allowed to do: the sign-in a phone holds + lasts a day and what it could mint here lasts a year, so allowing it would + let a credential that leaked for an afternoon leave behind one that + outlives cancelling it. - Closed a narrower gap found while building this. A repeated request carrying an `Idempotency-Key` could be answered from the cache after a check that asked only whether a sharing grant was live, and not which