mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
33 files changed
+654
-126
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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
@@ -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",
|
||||
|
||||
Generated
+20
-36
@@ -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:
|
||||
|
||||
@@ -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
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user