Files
HealthLog/src/lib/withings/client.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

427 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Withings API client for OAuth and data fetching.
* Docs: https://developer.withings.com/api-reference
*/
import { getEvent } from "@/lib/logging/context";
import { safeFetch } from "@/lib/safe-fetch";
import {
WithingsApiError,
classifyWithingsResponse,
} from "./response-classifier";
const WITHINGS_OAUTH_URL =
"https://account.withings.com/oauth2_user/authorize2";
const WITHINGS_TOKEN_URL = "https://wbsapi.withings.net/v2/oauth2";
const WITHINGS_MEASURE_URL = "https://wbsapi.withings.net/measure";
const WITHINGS_NOTIFY_URL = "https://wbsapi.withings.net/notify";
export interface WithingsCredentials {
clientId: string;
clientSecret: string;
}
export function getWithingsRedirectUri(): string {
return (
process.env.WITHINGS_REDIRECT_URI ??
`${process.env.NEXT_PUBLIC_APP_URL}/api/withings/callback`
);
}
/**
* v1.4.25 W5d — Withings OAuth scope set HealthLog requests.
*
* - `user.metrics` : every meastype the Measure / Heart-list /
* Sleep endpoints expose (weight, BP, pulse,
* SpO2, body comp, temperature, VO2 max, …)
* - `user.activity` : steps / active energy / distance / floors —
* served by `POST /v2/measure?action=getactivity`.
* The sync routine itself lands in v1.4.26; the
* scope is requested now so existing users
* reconnect once instead of twice.
*
* v1.4.24-and-earlier connections requested `user.metrics` only — the
* Settings → Integrations card surfaces a reconnect banner for those
* users (see `WithingsConnection.scope IS NULL` or scope without
* `user.activity`).
*/
export const WITHINGS_OAUTH_SCOPE = "user.metrics,user.activity" as const;
/**
* Parse the persisted scope string into a Set for membership checks.
* Legacy NULL → empty set (treated as "no scopes yet") so the
* reconnect-banner conditional reads as truthy.
*/
export function parseWithingsScope(scope: string | null): Set<string> {
if (!scope) return new Set();
return new Set(
scope
.split(",")
.map((s) => s.trim())
.filter(Boolean),
);
}
/**
* Returns true when the connection holds `user.activity` — the
* minimum scope required by the Activity / Sleep endpoints.
*/
export function hasActivityScope(scope: string | null): boolean {
return parseWithingsScope(scope).has("user.activity");
}
/**
* Generate Withings OAuth authorization URL.
*/
export function getAuthorizationUrl(
state: string,
creds: WithingsCredentials,
): string {
const params = new URLSearchParams({
response_type: "code",
client_id: creds.clientId,
redirect_uri: getWithingsRedirectUri(),
scope: WITHINGS_OAUTH_SCOPE,
state,
});
return `${WITHINGS_OAUTH_URL}?${params}`;
}
export interface WithingsTokenResponse {
userid: string;
access_token: string;
refresh_token: string;
expires_in: number;
}
/**
* Exchange authorization code for tokens.
*/
export async function exchangeCode(
code: string,
creds: WithingsCredentials,
): Promise<WithingsTokenResponse> {
const params = new URLSearchParams({
action: "requesttoken",
grant_type: "authorization_code",
client_id: creds.clientId,
client_secret: creds.clientSecret,
redirect_uri: getWithingsRedirectUri(),
code,
});
const start = performance.now();
const res = await safeFetch(WITHINGS_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: params.toString(),
});
const json = await res.json();
const verdict = classifyWithingsResponse(res.status, json);
getEvent()?.addExternalCall({
service: "withings",
method: "exchangeCode",
duration_ms: Math.round(performance.now() - start),
status: res.status,
error: verdict.classification === "success" ? undefined : verdict.reason,
});
if (verdict.classification !== "success") {
throw new WithingsApiError({
verb: "token",
classification: verdict.classification,
withingsStatus: verdict.withingsStatus,
reason: verdict.reason,
upstreamError: typeof json?.error === "string" ? json.error : undefined,
});
}
return json.body as WithingsTokenResponse;
}
/**
* Refresh an expired access token.
*/
export async function refreshAccessToken(
refreshToken: string,
creds: WithingsCredentials,
): Promise<WithingsTokenResponse> {
const params = new URLSearchParams({
action: "requesttoken",
grant_type: "refresh_token",
client_id: creds.clientId,
client_secret: creds.clientSecret,
refresh_token: refreshToken,
});
const start = performance.now();
const res = await safeFetch(WITHINGS_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: params.toString(),
});
const json = await res.json();
const verdict = classifyWithingsResponse(res.status, json);
getEvent()?.addExternalCall({
service: "withings",
method: "refreshAccessToken",
duration_ms: Math.round(performance.now() - start),
status: res.status,
error: verdict.classification === "success" ? undefined : verdict.reason,
});
if (verdict.classification !== "success") {
throw new WithingsApiError({
verb: "refresh",
classification: verdict.classification,
withingsStatus: verdict.withingsStatus,
reason: verdict.reason,
upstreamError: typeof json?.error === "string" ? json.error : undefined,
});
}
return json.body as WithingsTokenResponse;
}
// Withings measure type mapping. The single source of truth is
// `src/lib/withings/mapping.md` — keep both in sync when adding entries.
// https://developer.withings.com/api-reference#tag/measure/operation/measure-getmeas
export const MEASURE_TYPE_MAP: Record<
number,
{ type: string; factor?: number }
> = {
1: { type: "WEIGHT" }, // Weight (kg)
9: { type: "BLOOD_PRESSURE_DIA" }, // Diastolic BP
10: { type: "BLOOD_PRESSURE_SYS" }, // Systolic BP
11: { type: "PULSE" }, // Heart rate
6: { type: "BODY_FAT" }, // Body fat %
// v1.4.25 — first-gen Thermo (WBT01) reports temperature as meastype 12;
// current-gen Thermo ships meastype 71. Both canonical °C, both into
// the BODY_TEMPERATURE bucket. Skin temperature (73, ScanWatch) stays
// out until v1.4.26 ships a SKIN_TEMPERATURE enum — surface temps
// (~32 °C) and core temps (~37 °C) must not share a rollup.
12: { type: "BODY_TEMPERATURE" },
71: { type: "BODY_TEMPERATURE" },
77: { type: "TOTAL_BODY_WATER" }, // Hydration / water mass (kg)
88: { type: "BONE_MASS" }, // Bone mass (kg)
54: { type: "OXYGEN_SATURATION" }, // SpO2 (% — only ScanWatch / pulse-ox products)
// v1.4.25 — older Withings firmware (and some SDK examples) report
// SpO2 under meastype 35 instead of 54. Both target OXYGEN_SATURATION
// in percent; the exponent decode handles 0.97 vs 97 transparently.
35: { type: "OXYGEN_SATURATION" },
// v1.4.25 — VO2 max from the ScanWatch family. Withings reports
// mL/(kg·min) directly, matching the canonical DB unit.
123: { type: "VO2_MAX" },
// ── v1.4.25 W5d — Withings full coverage ──
// Body composition expansion (Body+, Body Cardio, Body Comp, Body
// Scan ship these values on every measurement). All canonical kg.
5: { type: "FAT_FREE_MASS" }, // Fat-free mass
8: { type: "FAT_MASS" }, // Fat mass (kg form of BODY_FAT)
76: { type: "MUSCLE_MASS" }, // Muscle mass
// Skin temperature (ScanWatch dermal sensor). Distinct from
// BODY_TEMPERATURE — surface temps ~32 °C, core ~37 °C. Same °C
// unit; the SKIN_TEMPERATURE enum value keeps the rollup honest.
73: { type: "SKIN_TEMPERATURE" },
// Pulse-wave velocity m/s — Body Cardio / Body Scan exclusive.
91: { type: "PULSE_WAVE_VELOCITY" },
// Vascular age in years — Body Scan composite of PWV + age.
155: { type: "VASCULAR_AGE" },
// Visceral fat rating (Withings 1–12 scale). Stored under the
// canonical VISCERAL_FAT enum with `rating` as the unit string.
170: { type: "VISCERAL_FAT" },
};
export interface WithingsMeasure {
type: string;
value: number;
measuredAt: Date;
}
export interface WithingsMeasureGroup {
grpid: number;
date: number; // unix timestamp
measures: Array<{ type: number; value: number; unit: number }>;
}
/**
* Fetch measurements from Withings API.
*/
export async function fetchMeasurements(
accessToken: string,
startDate?: Date,
endDate?: Date,
): Promise<WithingsMeasure[]> {
const baseParams: Record<string, string> = {
action: "getmeas",
meastypes: Object.keys(MEASURE_TYPE_MAP).join(","),
};
if (startDate) {
baseParams.startdate = String(Math.floor(startDate.getTime() / 1000));
}
if (endDate) {
baseParams.enddate = String(Math.floor(endDate.getTime() / 1000));
}
const results: WithingsMeasure[] = [];
let offset = 0;
let pageCount = 0;
while (true) {
const params = new URLSearchParams({
...baseParams,
offset: String(offset),
});
const pageStart = performance.now();
const res = await safeFetch(WITHINGS_MEASURE_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Bearer ${accessToken}`,
},
body: params.toString(),
});
const json = await res.json();
const verdict = classifyWithingsResponse(res.status, json);
getEvent()?.addExternalCall({
service: "withings",
method: `fetchMeasurements(page=${pageCount})`,
duration_ms: Math.round(performance.now() - pageStart),
status: res.status,
error: verdict.classification === "success" ? undefined : verdict.reason,
});
if (verdict.classification !== "success") {
throw new WithingsApiError({
verb: "measure",
classification: verdict.classification,
withingsStatus: verdict.withingsStatus,
reason: verdict.reason,
upstreamError: typeof json?.error === "string" ? json.error : undefined,
});
}
const body = json.body ?? {};
const groups: WithingsMeasureGroup[] = body.measuregrps ?? [];
for (const group of groups) {
const measuredAt = new Date(group.date * 1000);
for (const m of group.measures) {
const mapping = MEASURE_TYPE_MAP[m.type];
if (!mapping) continue;
// Withings stores value * 10^unit (unit is usually negative)
const value = m.value * Math.pow(10, m.unit);
results.push({
type: mapping.type,
value: parseFloat(value.toFixed(2)),
measuredAt,
});
}
}
const hasMore = body.more === true || body.more === 1;
if (!hasMore) break;
const nextOffset = Number(body.offset);
if (!Number.isFinite(nextOffset) || nextOffset <= offset) break;
offset = nextOffset;
pageCount += 1;
if (pageCount > 1000) break;
}
return results;
}
/**
* Subscribe to Withings webhook notifications.
*/
export async function subscribeWebhook(
accessToken: string,
callbackUrl: string,
appli: number = 1, // 1 = user measures
): Promise<void> {
const params = new URLSearchParams({
action: "subscribe",
callbackurl: callbackUrl,
appli: String(appli),
});
const start = performance.now();
const res = await safeFetch(WITHINGS_NOTIFY_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Bearer ${accessToken}`,
},
body: params.toString(),
});
const json = await res.json();
const verdict = classifyWithingsResponse(res.status, json);
// 294 = already-subscribed is idempotent success at the subscribe
// call-site (Withings preserves the existing subscription). The
// classifier surfaces it as `persistent` because any OTHER endpoint
// receiving 294 is a contract bug; here we explicitly downgrade it.
const isAlreadySubscribed = json?.status === 294;
const treatAsSuccess =
verdict.classification === "success" || isAlreadySubscribed;
getEvent()?.addExternalCall({
service: "withings",
method: "subscribeWebhook",
duration_ms: Math.round(performance.now() - start),
status: res.status,
error: treatAsSuccess ? undefined : verdict.reason,
});
if (!treatAsSuccess) {
throw new WithingsApiError({
verb: "subscribe",
classification: verdict.classification,
withingsStatus: verdict.withingsStatus,
reason: verdict.reason,
upstreamError: typeof json?.error === "string" ? json.error : undefined,
});
}
}
/**
* Unsubscribe from Withings webhook notifications.
*/
export async function unsubscribeWebhook(
accessToken: string,
callbackUrl: string,
appli: number = 1,
): Promise<void> {
const params = new URLSearchParams({
action: "revoke",
callbackurl: callbackUrl,
appli: String(appli),
});
const start = performance.now();
const res = await safeFetch(WITHINGS_NOTIFY_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Bearer ${accessToken}`,
},
body: params.toString(),
});
const json = await res.json();
const verdict = classifyWithingsResponse(res.status, json);
getEvent()?.addExternalCall({
service: "withings",
method: "unsubscribeWebhook",
duration_ms: Math.round(performance.now() - start),
status: res.status,
error: verdict.classification === "success" ? undefined : verdict.reason,
});
// Unsubscribe is best-effort — a stale subscription will eventually
// be garbage-collected by Withings, so we never throw here. We do
// bubble the verdict into a warning so the audit-log path still
// sees the off-response.
if (verdict.classification !== "success") {
getEvent()?.addWarning(`Withings unsubscribe warning: ${verdict.reason}`);
}
}