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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e1a77033..f34b1ac0d 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,18 +56,80 @@ 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 + +- 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`, + `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 +- `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 + 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 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/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/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" 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/*` 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, });