Merge pull request #884 from MBombeck/release/v1.38.1

chore(release): v1.38.1 — push readings in, see the range at a glance
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-09-01 21:26:13 +02:00
commit d5cf82fb6e
33 files changed
+654 -126

No files matched your search

+16 -12
View File
@@ -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
+93 -18
View File
@@ -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
+1
View File
@@ -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 && \
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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",
+20 -36
View File
@@ -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:
+5
View File
@@ -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"
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.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/*`
@@ -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([]);
});
});
+13 -1
View File
@@ -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 <IntegrationsSection callbackUrls={getIntegrationCallbackUrls()} />;
}
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,
@@ -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(
<I18nProvider initialLocale="en">
<IntegrationsSection />
<IntegrationsSection callbackUrls={CALLBACK_URLS} />
</I18nProvider>,
);
}
@@ -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(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
@@ -184,7 +194,10 @@ describe("settings sections — SSR smoke", () => {
});
it("<IntegrationsSection> renders Withings card + the delivery-channels group", () => {
const html = renderFramed("integrations", <IntegrationsSection />);
const html = renderFramed(
"integrations",
<IntegrationsSection callbackUrls={CALLBACK_URLS} />,
);
expect(html).toContain("Integrations");
expect(html).toContain("Withings");
// v1.25.7 — delivery channels moved here from Notifications: the
@@ -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")}
/>
<ConnectionsPanel />
<ConnectionsPanel callbackUrls={callbackUrls} />
{/* 2026-07-17 UX/IA audit M7 — the opt-in nutrients sync inventory
(moved from Settings → Sources, where "source priority" only
@@ -0,0 +1,75 @@
/**
* `<CallbackMismatchNotice>` 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<typeof import("react")>();
return {
...actual,
useSyncExternalStore: <T,>(
_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(
<I18nProvider initialLocale="en">
<CallbackMismatchNotice provider="WHOOP" callbackUrl={callbackUrl} />
</I18nProvider>,
);
}
describe("<CallbackMismatchNotice>", () => {
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("");
});
});
@@ -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(
<I18nProvider initialLocale="en">
<ConnectionsPanel />
<ConnectionsPanel callbackUrls={CALLBACK_URLS} />
</I18nProvider>,
);
}
@@ -41,6 +41,7 @@ function render({
dataHref="/insights/sleep"
credentials={credentials}
viewModel={viewModel}
callbackUrl="https://app.example/api/polar/callback"
/>
</I18nProvider>,
);
@@ -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: {
@@ -15,11 +15,15 @@ import { I18nProvider } from "@/lib/i18n/context";
import { WhoopCard } from "../whoop-card";
import type { IntegrationStatusViewModel } from "../shared";
function render(viewModel?: Partial<IntegrationStatusViewModel>) {
function render(
viewModel?: Partial<IntegrationStatusViewModel>,
callbackUrl: string | null = "https://app.example/api/whoop/callback",
) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<WhoopCard
viewModel={viewModel as IntegrationStatusViewModel | undefined}
callbackUrl={callbackUrl}
/>
</I18nProvider>,
);
@@ -27,13 +31,19 @@ function render(viewModel?: Partial<IntegrationStatusViewModel>) {
describe("<WhoopCard> 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"');
@@ -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<IntegrationKey> = 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. */}
<div id="withings" className="scroll-mt-28">
<WithingsCard viewModel={withingsViewModel} />
<WithingsCard
viewModel={withingsViewModel}
callbackUrl={callbackUrls.withings}
/>
</div>
<div id="whoop" className="scroll-mt-28">
<WhoopCard viewModel={whoopViewModel} />
<WhoopCard
viewModel={whoopViewModel}
callbackUrl={callbackUrls.whoop}
/>
</div>
<div id="fitbit" className="scroll-mt-28">
<FitbitCard viewModel={fitbitViewModel} />
<FitbitCard
viewModel={fitbitViewModel}
callbackUrl={callbackUrls.fitbit}
/>
</div>
<div id="google-health" className="scroll-mt-28">
<GoogleHealthCard viewModel={googleHealthViewModel} />
<GoogleHealthCard
viewModel={googleHealthViewModel}
callbackUrl={callbackUrls["google-health"]}
/>
</div>
<div id="apple-health" className="scroll-mt-28">
<AppleHealthCard enabled={isAuthenticated} />
</div>
<div id="polar" className="scroll-mt-28">
<PolarCard viewModel={polarViewModel} />
<PolarCard
viewModel={polarViewModel}
callbackUrl={callbackUrls.polar}
/>
</div>
<div id="oura" className="scroll-mt-28">
<OuraCard viewModel={ouraViewModel} />
<OuraCard viewModel={ouraViewModel} callbackUrl={callbackUrls.oura} />
</div>
<div id="strava" className="scroll-mt-28">
<StravaCard viewModel={stravaViewModel} />
<StravaCard
viewModel={stravaViewModel}
callbackUrl={callbackUrls.strava}
/>
</div>
<div id="nightscout" className="scroll-mt-28">
<NightscoutCard viewModel={nightscoutViewModel} />
@@ -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({
<IntegrationRedirectGuide
provider="fitbit"
providerLabel={t("settings.fitbit")}
callbackUrl={callbackUrl}
/>
)}
<form onSubmit={handleSaveCredentials} className="space-y-3">
@@ -525,7 +529,7 @@ export function FitbitCard({
</div>
<CallbackMismatchNotice
provider={t("settings.fitbit")}
callbackUrl={integrationCallbackUrl("fitbit")}
callbackUrl={callbackUrl}
/>
</SettingsCard>
);
@@ -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({
</div>
<CallbackMismatchNotice
provider={t("settings.googleHealth")}
callbackUrl={integrationCallbackUrl("google-health")}
callbackUrl={callbackUrl}
/>
</SettingsCard>
);
@@ -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({
<IntegrationRedirectGuide
provider={provider}
providerLabel={t(i18nPrefix)}
callbackUrl={callbackUrl}
/>
)}
<form onSubmit={handleSaveCredentials} className="space-y-3">
@@ -526,7 +530,7 @@ export function OAuthProviderCard({
</div>
<CallbackMismatchNotice
provider={t(i18nPrefix)}
callbackUrl={integrationCallbackUrl(provider)}
callbackUrl={callbackUrl}
/>
</SettingsCard>
);
@@ -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 (
<OAuthProviderCard
provider="oura"
@@ -22,6 +29,7 @@ export function OuraCard({ viewModel }: { viewModel?: OAuthProviderStatus }) {
dataHref="/insights/sleep"
credentials
viewModel={viewModel}
callbackUrl={callbackUrl}
/>
);
}
@@ -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 (
<OAuthProviderCard
provider="polar"
@@ -22,6 +29,7 @@ export function PolarCard({ viewModel }: { viewModel?: OAuthProviderStatus }) {
dataHref="/insights/sleep"
credentials
viewModel={viewModel}
callbackUrl={callbackUrl}
/>
);
}
@@ -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 (
<div
className="bg-muted/40 border-border/60 space-y-1.5 rounded-md border p-3 text-xs"
@@ -131,7 +135,7 @@ export function IntegrationRedirectGuide({
className="bg-background text-foreground rounded px-1 py-0.5 font-mono break-all"
data-testid={`${provider}-redirect-uri`}
>
{callbackUrl}
{shownCallbackUrl}
</code>
</li>
<li>{t("settings.integrationRedirectGuide.step3")}</li>
@@ -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 (
<OAuthProviderCard
provider="strava"
@@ -23,6 +30,7 @@ export function StravaCard({ viewModel }: { viewModel?: OAuthProviderStatus }) {
dataHref="/insights/workouts"
credentials
viewModel={viewModel}
callbackUrl={callbackUrl}
/>
);
}
@@ -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({
<IntegrationRedirectGuide
provider="whoop"
providerLabel={t("settings.whoop")}
callbackUrl={callbackUrl}
/>
)}
<form onSubmit={handleSaveCredentials} className="space-y-3">
@@ -507,7 +511,7 @@ export function WhoopCard({
</div>
<CallbackMismatchNotice
provider={t("settings.whoop")}
callbackUrl={integrationCallbackUrl("whoop")}
callbackUrl={callbackUrl}
/>
</SettingsCard>
);
@@ -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({
<IntegrationRedirectGuide
provider="withings"
providerLabel={t("settings.withings")}
callbackUrl={callbackUrl}
/>
)}
<form onSubmit={handleSaveCredentials} className="space-y-3">
@@ -556,7 +560,7 @@ export function WithingsCard({
</div>
<CallbackMismatchNotice
provider={t("settings.withings")}
callbackUrl={integrationCallbackUrl("withings")}
callbackUrl={callbackUrl}
/>
</SettingsCard>
);
+3 -3
View File
@@ -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,
+3 -3
View File
@@ -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,
@@ -0,0 +1,113 @@
/**
* `getIntegrationCallbackUrls()` must hand the Settings → Integrations cards
* the exact `redirect_uri` each provider's client registers: the explicit
* `<PROVIDER>_REDIRECT_URI` when the operator set one, the
* `${NEXT_PUBLIC_APP_URL}/api/<provider>/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<string, string | undefined>;
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/<provider>/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 <PROVIDER>_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");
});
});
+78
View File
@@ -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<IntegrationCallbackProvider, () => 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"),
};
}
+3 -3
View File
@@ -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",
);
+3 -3
View File
@@ -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,
});