Files
HealthLog/src/lib/integrations/callback-urls.ts
T
Marc-André Bombeck 05d3736d75 Resolve OAuth callback URLs on the server for integration cards
The v1.38.0 notice that warns when the configured OAuth callback origin
differs from the origin the browser is on never rendered in the published
image, and the copyable callback address in the redirect guide showed a
bare path. `integrationCallbackUrl()` read `process.env.NEXT_PUBLIC_APP_URL`
inside a "use client" module. Next.js inlines NEXT_PUBLIC_* into client
bundles at build time, and the GHCR image is built without that variable
(only the version, build-sha and built-at args reach the build), so the
base compiled to an empty string, `new URL("/api/<provider>/callback")`
threw, and the notice returned null on every card for every self-hoster.

The value now comes from the server at request time. A new map
(`src/lib/integrations/callback-urls.ts`) calls each provider's own
redirect-URI function (the four private `getRedirectUri()` helpers are
exported under provider-specific names; Polar, Oura and Strava already
exported theirs), so the page shows exactly what the handshake registers,
explicit `*_REDIRECT_URI` override included. The settings route resolves
the map and passes it as a prop through `IntegrationsSection` and
`ConnectionsPanel` to every card. Fitbit and Google Health validate their
URI and throw on a bad configuration; that surfaces as `null`, the notice
skips the comparison and the guide falls back to the fixed path.

Tests: a map test proving the explicit override wins and the derived form
is used otherwise, a component test rendering the notice from a passed
value (mismatch renders, match does not), and a structural guard that
fails when any "use client" file under src/components/settings/integrations
reads NEXT_PUBLIC_APP_URL.
2026-09-01 21:00:12 +02:00

79 lines
2.8 KiB
TypeScript

/**
* 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"),
};
}