feat(whoop): WHOOP v2 API client, response classifier, credential resolution

Add the WHOOP integration data layer on copy-of-Withings rails:

- client.ts: hand-rolled fetch over safeFetch (no SDK) — OAuth authorize
  URL / code exchange / rotating-refresh, the four v2 collection fetchers
  (recovery, sleep, cycle, workout) with next_token pagination, single
  body-measurement + profile reads, and the field->Measurement mappers
  (RECOVERY_SCORE/HRV_RMSSD/RESTING_HEART_RATE/OXYGEN_SATURATION/
  SKIN_TEMPERATURE, per-stage SLEEP_DURATION ms->min, SLEEP_NEED/SLEEP_*,
  DAY_STRAIN/ENERGY_EXPENDITURE_KJ, source=WHOOP). Day strain maps to
  DAY_STRAIN and RMSSD to HRV_RMSSD so the device-native values never
  share a bucket with the COMPUTED STRAIN_SCORE / SDNN proxies.
- response-classifier.ts: HTTP-status-driven success/transient/
  reauth_required/persistent verdicts + WhoopApiError, mirroring the
  Withings reason-tag classifier.
- credentials.ts: per-user BYO-key resolution, decrypting the client
  id/secret from the User columns.
- oauth-state.ts: opaque-nonce mint helper + cookie/TTL constants.
- mapping.md: field->Measurement reference, kept in sync with the mappers.

Unit tests cover the classifier, pagination, every fetcher, the mappers
(ms->min, kJ->kcal factor, stage mapping, unscored-record handling), and
credential resolution against mocked WHOOP JSON. No routes / OAuth flow /
sync workers / migration yet.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 01:05:06 +02:00
1 parent 999f2b6b08
commit 2258cbb489
9 files changed
+1518

No files matched your search

+370
View File
@@ -0,0 +1,370 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
KJ_TO_KCAL,
WHOOP_FIELD_MAP,
WHOOP_OAUTH_SCOPE,
WHOOP_PAGE_LIMIT,
exchangeCode,
fetchBodyMeasurement,
fetchCycles,
fetchProfile,
fetchRecoveries,
fetchSleeps,
fetchWorkouts,
getAuthorizationUrl,
mapCycle,
mapRecovery,
mapSleep,
refreshAccessToken,
type WhoopCycle,
type WhoopRecovery,
type WhoopSleep,
} from "../client";
import { WhoopApiError } from "../response-classifier";
const CREDS = { clientId: "cid", clientSecret: "csecret" };
/** Stub global fetch with a queue of `{ status, body }` responses. */
function installFetchMock(pages: Array<{ status: number; body: unknown }>) {
let i = 0;
const fetchMock = vi.fn(async () => {
const page = pages[Math.min(i, pages.length - 1)]!;
i += 1;
return {
status: page.status,
json: async () => page.body,
};
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("getAuthorizationUrl", () => {
it("builds the v2 authorize URL with the offline scope and state", () => {
const url = getAuthorizationUrl("nonce123", CREDS);
expect(url).toContain("api.prod.whoop.com/oauth/oauth2/auth");
expect(url).toContain("response_type=code");
expect(url).toContain("client_id=cid");
expect(url).toContain("state=nonce123");
// URLSearchParams encodes the space-separated scope with `+`; compare the
// parsed `scope` param back to the canonical constant.
const scope = new URL(url).searchParams.get("scope");
expect(scope).toBe(WHOOP_OAUTH_SCOPE);
expect(WHOOP_OAUTH_SCOPE).toContain("offline");
});
});
describe("token exchange + refresh", () => {
it("exchanges an authorization code for a token pair", async () => {
installFetchMock([
{
status: 200,
body: {
access_token: "at",
refresh_token: "rt",
expires_in: 3600,
scope: "offline read:recovery",
},
},
]);
const tok = await exchangeCode("code", CREDS);
expect(tok.access_token).toBe("at");
expect(tok.refresh_token).toBe("rt");
expect(tok.expires_in).toBe(3600);
});
it("re-requests the offline scope on refresh so a new refresh token rotates in", async () => {
const fetchMock = installFetchMock([
{
status: 200,
body: { access_token: "at2", refresh_token: "rt2", expires_in: 3600 },
},
]);
const tok = await refreshAccessToken("rt1", CREDS);
expect(tok.refresh_token).toBe("rt2");
const [, init] = fetchMock.mock.calls[0] as unknown as [
string,
{ body: string },
];
expect(init.body).toContain("grant_type=refresh_token");
expect(init.body).toContain("scope=offline");
});
it("throws a classified WhoopApiError on a 401 token response", async () => {
installFetchMock([{ status: 401, body: { error: "invalid_grant" } }]);
await expect(exchangeCode("bad", CREDS)).rejects.toMatchObject({
name: "WhoopApiError",
classification: "reauth_required",
});
});
});
describe("fetchCollection pagination", () => {
it("walks next_token across pages and concatenates records", async () => {
const fetchMock = installFetchMock([
{ status: 200, body: { records: [{ id: 1 }, { id: 2 }], next_token: "t1" } },
{ status: 200, body: { records: [{ id: 3 }], next_token: null } },
]);
const recs = await fetchRecoveries("at");
expect(recs.map((r) => (r as unknown as { id: number }).id)).toEqual([
1, 2, 3,
]);
expect(fetchMock).toHaveBeenCalledTimes(2);
// Page 1 carries no nextToken; page 2 forwards the cursor.
const [url1] = fetchMock.mock.calls[0] as unknown as [string];
const [url2] = fetchMock.mock.calls[1] as unknown as [string];
expect(url1).toContain(`limit=${WHOOP_PAGE_LIMIT}`);
expect(url1).not.toContain("nextToken");
expect(url2).toContain("nextToken=t1");
});
it("stops on a single page when next_token is absent", async () => {
const fetchMock = installFetchMock([
{ status: 200, body: { records: [{ id: 1 }] } },
]);
await fetchRecoveries("at");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("throws WhoopApiError when a page returns 500", async () => {
installFetchMock([{ status: 500, body: { error: "boom" } }]);
await expect(fetchRecoveries("at")).rejects.toBeInstanceOf(WhoopApiError);
});
it("forwards start/end window params on collection reads", async () => {
const fetchMock = installFetchMock([
{ status: 200, body: { records: [] } },
]);
await fetchSleeps("at", {
start: new Date("2026-06-01T00:00:00.000Z"),
end: new Date("2026-06-02T00:00:00.000Z"),
});
const [url] = fetchMock.mock.calls[0] as unknown as [string];
expect(url).toContain("/v2/activity/sleep");
expect(url).toContain("start=2026-06-01T00%3A00%3A00.000Z");
expect(url).toContain("end=2026-06-02T00%3A00%3A00.000Z");
});
});
describe("collection endpoint paths", () => {
it("hits the documented v2 path for each collection", async () => {
const cases: Array<[() => Promise<unknown>, string]> = [
[() => fetchCycles("at"), "/v2/cycle"],
[() => fetchWorkouts("at"), "/v2/activity/workout"],
];
for (const [call, path] of cases) {
const fetchMock = installFetchMock([
{ status: 200, body: { records: [] } },
]);
await call();
const [url] = fetchMock.mock.calls[0] as unknown as [string];
expect(url).toContain(path);
vi.unstubAllGlobals();
}
});
});
describe("single-object endpoints", () => {
it("fetches the body measurement (max_heart_rate is a profile constant)", async () => {
installFetchMock([
{
status: 200,
body: { height_meter: 1.8, weight_kilogram: 80, max_heart_rate: 190 },
},
]);
const body = await fetchBodyMeasurement("at");
expect(body.weight_kilogram).toBe(80);
expect(body.max_heart_rate).toBe(190);
});
it("fetches the basic profile", async () => {
installFetchMock([
{ status: 200, body: { user_id: 42, first_name: "A" } },
]);
const profile = await fetchProfile("at");
expect(profile.user_id).toBe(42);
});
it("throws a classified error on a 401 single-object read", async () => {
installFetchMock([{ status: 401, body: null }]);
await expect(fetchProfile("bad")).rejects.toMatchObject({
classification: "reauth_required",
});
});
});
describe("mapRecovery", () => {
const base: WhoopRecovery = {
cycle_id: 1,
sleep_id: "sleep-uuid",
user_id: 42,
created_at: "2026-06-01T06:00:00.000Z",
updated_at: "2026-06-01T07:00:00.000Z",
score_state: "SCORED",
score: {
user_calibrating: false,
recovery_score: 66,
resting_heart_rate: 52,
hrv_rmssd_milli: 48.7,
spo2_percentage: 97,
skin_temp_celsius: 33.4,
},
};
it("maps recovery score, RMSSD (not SDNN), RHR, spo2, skin-temp", () => {
const rows = mapRecovery(base);
const byType = Object.fromEntries(rows.map((r) => [r.type, r]));
expect(byType.RECOVERY_SCORE?.value).toBe(66);
expect(byType.HRV_RMSSD?.value).toBe(48.7);
expect(byType.HRV_RMSSD?.unit).toBe("ms");
// RMSSD must never relabel as the SDNN HEART_RATE_VARIABILITY.
expect(byType.HEART_RATE_VARIABILITY).toBeUndefined();
expect(byType.RESTING_HEART_RATE?.value).toBe(52);
expect(byType.OXYGEN_SATURATION?.value).toBe(97);
expect(byType.SKIN_TEMPERATURE?.value).toBe(33.4);
// measuredAt tracks updated_at (the re-score timestamp).
expect(byType.RECOVERY_SCORE?.measuredAt.toISOString()).toBe(
"2026-06-01T07:00:00.000Z",
);
// Every row carries a distinct field-tag for the externalId.
expect(new Set(rows.map((r) => r.fieldTag)).size).toBe(rows.length);
});
it("emits nothing for an unscored recovery (score null)", () => {
expect(mapRecovery({ ...base, score: null })).toEqual([]);
});
it("omits optional spo2 / skin-temp when absent", () => {
const rows = mapRecovery({
...base,
score: { ...base.score!, spo2_percentage: undefined, skin_temp_celsius: undefined },
});
expect(rows.some((r) => r.type === "OXYGEN_SATURATION")).toBe(false);
expect(rows.some((r) => r.type === "SKIN_TEMPERATURE")).toBe(false);
});
});
describe("mapSleep", () => {
const base: WhoopSleep = {
id: "sleep-uuid",
user_id: 42,
created_at: "2026-06-01T05:00:00.000Z",
updated_at: "2026-06-01T07:00:00.000Z",
start: "2026-05-31T23:00:00.000Z",
end: "2026-06-01T07:00:00.000Z",
nap: false,
score_state: "SCORED",
score: {
stage_summary: {
total_in_bed_time_milli: 28_800_000, // 480 min
total_awake_time_milli: 1_800_000, // 30 min
total_light_sleep_time_milli: 14_400_000, // 240 min
total_slow_wave_sleep_time_milli: 5_400_000, // 90 min
total_rem_sleep_time_milli: 7_200_000, // 120 min
},
sleep_needed: {
baseline_milli: 27_000_000, // 450 min
need_from_sleep_debt_milli: 1_800_000, // 30 min
need_from_recent_strain_milli: 600_000, // 10 min
need_from_recent_nap_milli: 0,
},
respiratory_rate: 15.2,
sleep_performance_percentage: 88,
sleep_efficiency_percentage: 93.5,
sleep_consistency_percentage: 71,
},
};
it("maps per-stage SLEEP_DURATION rows (ms→min) with sleepStage", () => {
const rows = mapSleep(base);
const dur = rows.filter((r) => r.type === "SLEEP_DURATION");
const byStage = Object.fromEntries(dur.map((r) => [r.sleepStage, r.value]));
expect(byStage.CORE).toBe(240); // light → CORE
expect(byStage.DEEP).toBe(90); // slow-wave → DEEP
expect(byStage.REM).toBe(120);
expect(byStage.AWAKE).toBe(30);
expect(byStage.IN_BED).toBe(480);
expect(dur.every((r) => r.unit === "minutes")).toBe(true);
});
it("sums SLEEP_NEED components ms→min", () => {
const need = mapSleep(base).find((r) => r.type === "SLEEP_NEED");
expect(need?.value).toBe(490); // 450 + 30 + 10 + 0
expect(need?.unit).toBe("minutes");
});
it("maps the SLEEP_* percentages and respiratory rate", () => {
const byType = Object.fromEntries(mapSleep(base).map((r) => [r.type, r]));
expect(byType.SLEEP_PERFORMANCE?.value).toBe(88);
expect(byType.SLEEP_EFFICIENCY?.value).toBe(93.5);
expect(byType.SLEEP_CONSISTENCY?.value).toBe(71);
expect(byType.RESPIRATORY_RATE?.value).toBe(15.2);
expect(byType.RESPIRATORY_RATE?.unit).toBe("breaths/min");
});
it("uses sleep.end as measuredAt", () => {
const row = mapSleep(base)[0]!;
expect(row.measuredAt.toISOString()).toBe("2026-06-01T07:00:00.000Z");
});
it("emits nothing for an unscored sleep", () => {
expect(mapSleep({ ...base, score: null })).toEqual([]);
});
});
describe("mapCycle", () => {
const base: WhoopCycle = {
id: 1234567890,
user_id: 42,
created_at: "2026-06-01T00:00:00.000Z",
updated_at: "2026-06-01T23:59:00.000Z",
start: "2026-06-01T00:00:00.000Z",
end: "2026-06-01T23:59:00.000Z",
score_state: "SCORED",
score: {
strain: 12.34,
kilojoule: 8765.4,
average_heart_rate: 70,
max_heart_rate: 180,
},
};
it("maps DAY_STRAIN (not STRAIN_SCORE) and ENERGY_EXPENDITURE_KJ in native kJ", () => {
const byType = Object.fromEntries(mapCycle(base).map((r) => [r.type, r]));
expect(byType.DAY_STRAIN?.value).toBe(12.34);
expect(byType.DAY_STRAIN?.unit).toBe("score");
// WHOOP day-strain must never collide with the COMPUTED STRAIN_SCORE.
expect(byType.STRAIN_SCORE).toBeUndefined();
expect(byType.ENERGY_EXPENDITURE_KJ?.value).toBe(8765.4);
expect(byType.ENERGY_EXPENDITURE_KJ?.unit).toBe("kJ");
// measuredAt uses cycle.start.
expect(byType.DAY_STRAIN?.measuredAt.toISOString()).toBe(
"2026-06-01T00:00:00.000Z",
);
});
it("emits nothing for an unscored cycle", () => {
expect(mapCycle({ ...base, score: null })).toEqual([]);
});
});
describe("WHOOP_FIELD_MAP", () => {
it("documents the kJ→kcal factor for the workout energy path", () => {
expect(WHOOP_FIELD_MAP["workout.score.kilojoule"]?.factor).toBeCloseTo(
KJ_TO_KCAL,
);
expect(KJ_TO_KCAL).toBeCloseTo(1 / 4.184);
});
it("keeps day-strain and workout-strain off the COMPUTED STRAIN_SCORE type", () => {
expect(WHOOP_FIELD_MAP["cycle.score.strain"]?.type).toBe("DAY_STRAIN");
expect(WHOOP_FIELD_MAP["workout.score.strain"]?.type).toBe(
"WORKOUT_STRAIN",
);
});
});
@@ -0,0 +1,62 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const findUnique = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: (...args: unknown[]) => findUnique(...args) } },
}));
// decrypt() is the fail-closed AES-256-GCM reader; the resolver only needs it
// to round-trip the stored ciphertext, so a `dec(<x>)` shim is sufficient here.
vi.mock("@/lib/crypto", () => ({
decrypt: (cipher: string) => `dec(${cipher})`,
}));
import { getUserWhoopCredentials } from "../credentials";
describe("getUserWhoopCredentials", () => {
beforeEach(() => {
findUnique.mockReset();
});
it("decrypts and returns the per-user BYO client id/secret", async () => {
findUnique.mockResolvedValue({
whoopClientIdEncrypted: "enc-id",
whoopClientSecretEncrypted: "enc-secret",
});
const creds = await getUserWhoopCredentials("user-1");
expect(creds).toEqual({
clientId: "dec(enc-id)",
clientSecret: "dec(enc-secret)",
});
// Resolution is user-scoped and selects only the two encrypted columns.
expect(findUnique).toHaveBeenCalledWith({
where: { id: "user-1" },
select: {
whoopClientIdEncrypted: true,
whoopClientSecretEncrypted: true,
},
});
});
it("returns null when the user has no WHOOP credentials configured", async () => {
findUnique.mockResolvedValue({
whoopClientIdEncrypted: null,
whoopClientSecretEncrypted: null,
});
expect(await getUserWhoopCredentials("user-1")).toBeNull();
});
it("returns null when only one half of the pair is present", async () => {
findUnique.mockResolvedValue({
whoopClientIdEncrypted: "enc-id",
whoopClientSecretEncrypted: null,
});
expect(await getUserWhoopCredentials("user-1")).toBeNull();
});
it("returns null when the user row is missing", async () => {
findUnique.mockResolvedValue(null);
expect(await getUserWhoopCredentials("ghost")).toBeNull();
});
});
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import {
WHOOP_OAUTH_STATE_COOKIE,
WHOOP_OAUTH_STATE_TTL_MS,
mintWhoopOAuthStateNonce,
} from "../oauth-state";
describe("WHOOP OAuth state", () => {
it("pins the cookie name and 10-minute TTL", () => {
expect(WHOOP_OAUTH_STATE_COOKIE).toBe("whoop_state");
expect(WHOOP_OAUTH_STATE_TTL_MS).toBe(10 * 60 * 1000);
});
it("mints a 22-char base64url nonce (128 bits, no padding)", () => {
const nonce = mintWhoopOAuthStateNonce();
expect(nonce).toHaveLength(22);
expect(nonce).toMatch(/^[A-Za-z0-9_-]{22}$/);
});
it("mints unique nonces", () => {
const set = new Set(
Array.from({ length: 100 }, () => mintWhoopOAuthStateNonce()),
);
expect(set.size).toBe(100);
});
});
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import {
WhoopApiError,
classifyWhoopError,
classifyWhoopResponse,
} from "../response-classifier";
describe("classifyWhoopResponse", () => {
it("classifies 2xx as success", () => {
for (const status of [200, 201, 204, 299]) {
const v = classifyWhoopResponse(status);
expect(v.classification).toBe("success");
expect(v.httpStatus).toBe(status);
}
});
it("classifies 429 as transient (rate-limit)", () => {
const v = classifyWhoopResponse(429);
expect(v.classification).toBe("transient");
expect(v.reason).toBe("http_429");
});
it("classifies 401 and 403 as reauth_required", () => {
expect(classifyWhoopResponse(401).classification).toBe("reauth_required");
expect(classifyWhoopResponse(403).classification).toBe("reauth_required");
});
it("classifies other 4xx as persistent", () => {
for (const status of [400, 404, 422]) {
expect(classifyWhoopResponse(status).classification).toBe("persistent");
}
});
it("classifies 5xx as transient", () => {
for (const status of [500, 502, 503, 504]) {
expect(classifyWhoopResponse(status).classification).toBe("transient");
}
});
it("defaults unknown / 3xx statuses to transient", () => {
const v = classifyWhoopResponse(302);
expect(v.classification).toBe("transient");
expect(v.reason).toBe("http_302_unknown");
});
});
describe("WhoopApiError", () => {
it("carries the classification verdict and a bounded message", () => {
const err = new WhoopApiError({
verb: "fetchRecoveries",
classification: "reauth_required",
httpStatus: 401,
reason: "http_401",
});
expect(err.classification).toBe("reauth_required");
expect(err.httpStatus).toBe(401);
expect(err.name).toBe("WhoopApiError");
expect(err.message).toContain("WHOOP fetchRecoveries error: 401");
});
it("caps a runaway upstream error body at 1024 chars", () => {
const err = new WhoopApiError({
verb: "token",
classification: "persistent",
httpStatus: 400,
reason: "http_400",
upstreamError: "x".repeat(5000),
});
expect(err.message.length).toBeLessThanOrEqual(1024);
});
});
describe("classifyWhoopError", () => {
it("reads the verdict from a WhoopApiError directly", () => {
const err = new WhoopApiError({
verb: "fetchSleeps",
classification: "transient",
httpStatus: 503,
reason: "http_503",
});
expect(classifyWhoopError(err)).toBe("transient");
});
it("parses the status out of an unwrapped message", () => {
const err = new Error("WHOOP fetchCycles error: 403 - forbidden");
expect(classifyWhoopError(err)).toBe("reauth_required");
});
it("falls back to transient for an unrecognised error", () => {
expect(classifyWhoopError(new Error("network down"))).toBe("transient");
expect(classifyWhoopError("boom")).toBe("transient");
});
});
+688
View File
@@ -0,0 +1,688 @@
/**
* WHOOP API v2 client for OAuth and data fetching.
* Docs: https://developer.whoop.com (re-verify at build — the space moves).
*
* Mirrors the Withings client structure (`src/lib/withings/client.ts`):
* hand-rolled fetch over `safeFetch` (no SDK), an OAuth handshake
* (`getAuthorizationUrl` / `exchangeCode` / `refreshAccessToken`), typed
* collection fetchers with `next_token` pagination, and a single
* source-of-truth field→Measurement mapping (`WHOOP_FIELD_MAP`, kept in sync
* with `src/lib/whoop/mapping.md`).
*
* v2 only — v1 was removed 2025-10-01. Sleep / workout / recovery ids are
* UUID strings; cycle id is int64. Token TTL 3600 s; refresh tokens ROTATE
* (each refresh invalidates the prior access AND refresh token — the sync
* layer persists BOTH rotated tokens). The `offline` scope is required to
* receive a refresh token at all.
*/
import { getEvent } from "@/lib/logging/context";
import { safeFetch } from "@/lib/safe-fetch";
import { WhoopApiError, classifyWhoopResponse } from "./response-classifier";
const WHOOP_API_BASE = "https://api.prod.whoop.com/developer";
const WHOOP_OAUTH_AUTH_URL =
"https://api.prod.whoop.com/oauth/oauth2/auth";
const WHOOP_OAUTH_TOKEN_URL =
"https://api.prod.whoop.com/oauth/oauth2/token";
/** WHOOP collection reads cap `limit` at 25. */
export const WHOOP_PAGE_LIMIT = 25;
export interface WhoopCredentials {
clientId: string;
clientSecret: string;
}
function getRedirectUri(): string {
return (
process.env.WHOOP_REDIRECT_URI ??
`${process.env.NEXT_PUBLIC_APP_URL}/api/whoop/callback`
);
}
/**
* OAuth scopes HealthLog requests (space-separated on the wire). `offline` is
* mandatory to receive a refresh token; the rest are read-only collection
* scopes. Request all so the user grants once and every sync resource is
* covered.
*/
export const WHOOP_OAUTH_SCOPE =
"offline read:recovery read:sleep read:workout read:cycles read:profile read:body_measurement" as const;
/**
* Generate the WHOOP OAuth authorization URL (a browser redirect, not a
* fetch). `state` is the opaque CSRF nonce minted by `oauth-state.ts`.
*/
export function getAuthorizationUrl(
state: string,
creds: WhoopCredentials,
): string {
const params = new URLSearchParams({
response_type: "code",
client_id: creds.clientId,
redirect_uri: getRedirectUri(),
scope: WHOOP_OAUTH_SCOPE,
state,
});
return `${WHOOP_OAUTH_AUTH_URL}?${params}`;
}
export interface WhoopTokenResponse {
access_token: string;
refresh_token: string;
expires_in: number;
scope?: string;
token_type?: string;
}
async function postToken(
params: URLSearchParams,
verb: string,
): Promise<WhoopTokenResponse> {
const start = performance.now();
const res = await safeFetch(WHOOP_OAUTH_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: params.toString(),
});
const json = await res.json().catch(() => null);
const verdict = classifyWhoopResponse(res.status);
getEvent()?.addExternalCall({
service: "whoop",
method: verb,
duration_ms: Math.round(performance.now() - start),
status: res.status,
error: verdict.classification === "success" ? undefined : verdict.reason,
});
if (verdict.classification !== "success") {
throw new WhoopApiError({
verb,
classification: verdict.classification,
httpStatus: verdict.httpStatus,
reason: verdict.reason,
upstreamError:
typeof json?.error === "string" ? json.error : undefined,
});
}
return json as WhoopTokenResponse;
}
/** Exchange an authorization code for the initial token pair. */
export async function exchangeCode(
code: string,
creds: WhoopCredentials,
): Promise<WhoopTokenResponse> {
return postToken(
new URLSearchParams({
grant_type: "authorization_code",
code,
client_id: creds.clientId,
client_secret: creds.clientSecret,
redirect_uri: getRedirectUri(),
}),
"exchangeCode",
);
}
/**
* Refresh an expired access token. WHOOP rotates the refresh token on every
* use — the caller MUST persist the returned `refresh_token`, not just the
* access token. `scope` must include `offline` (re-sent here per the WHOOP
* spec) for the rotation to return a fresh refresh token.
*/
export async function refreshAccessToken(
refreshToken: string,
creds: WhoopCredentials,
): Promise<WhoopTokenResponse> {
return postToken(
new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: creds.clientId,
client_secret: creds.clientSecret,
scope: "offline",
}),
"refreshAccessToken",
);
}
// ─── Collection envelopes (v2) ─────────────────────────────────
/** WHOOP paginated collection envelope: `{ records, next_token }`. */
export interface WhoopCollection<T> {
records: T[];
next_token?: string | null;
}
/** Recovery record. `score` is null until WHOOP finishes scoring. */
export interface WhoopRecovery {
cycle_id: number;
sleep_id: string;
user_id: number;
created_at: string;
updated_at: string;
score_state: string;
score: {
user_calibrating: boolean;
recovery_score: number;
resting_heart_rate: number;
hrv_rmssd_milli: number;
spo2_percentage?: number;
skin_temp_celsius?: number;
} | null;
}
/** Sleep activity record. */
export interface WhoopSleep {
id: string;
user_id: number;
created_at: string;
updated_at: string;
start: string;
end: string;
nap: boolean;
score_state: string;
score: {
stage_summary: {
total_in_bed_time_milli: number;
total_awake_time_milli: number;
total_light_sleep_time_milli: number;
total_slow_wave_sleep_time_milli: number;
total_rem_sleep_time_milli: number;
};
sleep_needed: {
baseline_milli: number;
need_from_sleep_debt_milli: number;
need_from_recent_strain_milli: number;
need_from_recent_nap_milli: number;
};
respiratory_rate?: number;
sleep_performance_percentage?: number;
sleep_consistency_percentage?: number;
sleep_efficiency_percentage?: number;
} | null;
}
/** Physiological cycle (day) record. `id` is an int64. */
export interface WhoopCycle {
id: number;
user_id: number;
created_at: string;
updated_at: string;
start: string;
end?: string | null;
score_state: string;
score: {
strain: number;
kilojoule: number;
average_heart_rate: number;
max_heart_rate: number;
} | null;
}
/** Workout activity record. */
export interface WhoopWorkout {
id: string;
user_id: number;
created_at: string;
updated_at: string;
start: string;
end: string;
sport_id?: number;
sport_name?: string;
score_state: string;
score: {
strain: number;
average_heart_rate: number;
max_heart_rate: number;
kilojoule: number;
percent_recorded: number;
distance_meter?: number;
altitude_gain_meter?: number;
altitude_change_meter?: number;
zone_durations?: Record<string, number>;
} | null;
}
/** Body-measurement object (single, not paginated). */
export interface WhoopBodyMeasurement {
height_meter?: number;
weight_kilogram?: number;
max_heart_rate?: number;
}
/** Basic profile object (single, not paginated). */
export interface WhoopProfile {
user_id: number;
email?: string;
first_name?: string;
last_name?: string;
}
interface CollectionQuery {
start?: Date;
end?: Date;
/** Hard ceiling on pages walked (defence against a runaway cursor). */
maxPages?: number;
}
async function fetchCollection<T>(
path: string,
accessToken: string,
verb: string,
query: CollectionQuery = {},
): Promise<T[]> {
const records: T[] = [];
let nextToken: string | null | undefined;
let pageCount = 0;
const maxPages = query.maxPages ?? 1000;
do {
const params = new URLSearchParams({ limit: String(WHOOP_PAGE_LIMIT) });
if (query.start) params.set("start", query.start.toISOString());
if (query.end) params.set("end", query.end.toISOString());
if (nextToken) params.set("nextToken", nextToken);
const pageStart = performance.now();
const res = await safeFetch(`${WHOOP_API_BASE}${path}?${params}`, {
method: "GET",
headers: { Authorization: `Bearer ${accessToken}` },
});
const json = (await res.json().catch(() => null)) as
| WhoopCollection<T>
| null;
const verdict = classifyWhoopResponse(res.status);
getEvent()?.addExternalCall({
service: "whoop",
method: `${verb}(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 WhoopApiError({
verb,
classification: verdict.classification,
httpStatus: verdict.httpStatus,
reason: verdict.reason,
});
}
for (const r of json?.records ?? []) records.push(r);
nextToken = json?.next_token ?? null;
pageCount += 1;
} while (nextToken && pageCount < maxPages);
return records;
}
async function fetchSingle<T>(
path: string,
accessToken: string,
verb: string,
): Promise<T> {
const start = performance.now();
const res = await safeFetch(`${WHOOP_API_BASE}${path}`, {
method: "GET",
headers: { Authorization: `Bearer ${accessToken}` },
});
const json = (await res.json().catch(() => null)) as T | null;
const verdict = classifyWhoopResponse(res.status);
getEvent()?.addExternalCall({
service: "whoop",
method: verb,
duration_ms: Math.round(performance.now() - start),
status: res.status,
error: verdict.classification === "success" ? undefined : verdict.reason,
});
if (verdict.classification !== "success") {
throw new WhoopApiError({
verb,
classification: verdict.classification,
httpStatus: verdict.httpStatus,
reason: verdict.reason,
});
}
return json as T;
}
export function fetchRecoveries(
accessToken: string,
query?: CollectionQuery,
): Promise<WhoopRecovery[]> {
return fetchCollection<WhoopRecovery>(
"/v2/recovery",
accessToken,
"fetchRecoveries",
query,
);
}
export function fetchSleeps(
accessToken: string,
query?: CollectionQuery,
): Promise<WhoopSleep[]> {
return fetchCollection<WhoopSleep>(
"/v2/activity/sleep",
accessToken,
"fetchSleeps",
query,
);
}
export function fetchCycles(
accessToken: string,
query?: CollectionQuery,
): Promise<WhoopCycle[]> {
return fetchCollection<WhoopCycle>(
"/v2/cycle",
accessToken,
"fetchCycles",
query,
);
}
export function fetchWorkouts(
accessToken: string,
query?: CollectionQuery,
): Promise<WhoopWorkout[]> {
return fetchCollection<WhoopWorkout>(
"/v2/activity/workout",
accessToken,
"fetchWorkouts",
query,
);
}
export function fetchBodyMeasurement(
accessToken: string,
): Promise<WhoopBodyMeasurement> {
return fetchSingle<WhoopBodyMeasurement>(
"/v2/user/measurement/body",
accessToken,
"fetchBodyMeasurement",
);
}
export function fetchProfile(accessToken: string): Promise<WhoopProfile> {
return fetchSingle<WhoopProfile>(
"/v2/user/profile/basic",
accessToken,
"fetchProfile",
);
}
// ─── Field → Measurement mapping ───────────────────────────────
// The single source of truth is `src/lib/whoop/mapping.md` — keep both in
// sync when adding entries.
/** Milliseconds → minutes. */
const MS_TO_MIN = 1 / 60_000;
/** Kilojoules → kilocalories (workout energy). */
export const KJ_TO_KCAL = 1 / 4.184;
/**
* A single mapped reading destined for one `Measurement` row. The `source`
* (`WHOOP`) and `externalId` (`<resource-uuid>:<fieldTag>`) are stamped by the
* sync layer (W3); the mapper only emits the type/value/unit/measuredAt and
* the field-tag that disambiguates the several rows derived from one resource.
*/
export interface MappedMeasurement {
type: string;
value: number;
unit: string;
measuredAt: Date;
/** Disambiguator appended to the resource uuid to form the externalId. */
fieldTag: string;
/** Per-stage sleep rows carry the SleepStage; everything else omits it. */
sleepStage?: "CORE" | "DEEP" | "REM" | "AWAKE" | "IN_BED";
}
/** WHOOP sleep stage → HealthLog SleepStage (light→CORE, slow-wave→DEEP). */
const SLEEP_STAGE_MAP: Record<
string,
{ stage: MappedMeasurement["sleepStage"]; fieldTag: string }
> = {
total_light_sleep_time_milli: { stage: "CORE", fieldTag: "sleep_core" },
total_slow_wave_sleep_time_milli: { stage: "DEEP", fieldTag: "sleep_deep" },
total_rem_sleep_time_milli: { stage: "REM", fieldTag: "sleep_rem" },
total_awake_time_milli: { stage: "AWAKE", fieldTag: "sleep_awake" },
total_in_bed_time_milli: { stage: "IN_BED", fieldTag: "sleep_in_bed" },
};
function round2(n: number): number {
return parseFloat(n.toFixed(2));
}
/**
* Map one WHOOP recovery record into Measurement readings. An unscored record
* (`score === null`) yields nothing. `recovery.score.recovery_score` →
* `RECOVERY_SCORE` (source WHOOP, distinct from the COMPUTED proxy);
* `hrv_rmssd_milli` → `HRV_RMSSD` (distinct from the SDNN
* `HEART_RATE_VARIABILITY`); RHR / SpO2 / skin-temp resolve through the
* cross-source picker.
*/
export function mapRecovery(r: WhoopRecovery): MappedMeasurement[] {
if (!r.score) return [];
const measuredAt = new Date(r.updated_at);
const out: MappedMeasurement[] = [
{
type: "RECOVERY_SCORE",
value: round2(r.score.recovery_score),
unit: "score",
measuredAt,
fieldTag: "recovery",
},
{
type: "HRV_RMSSD",
value: round2(r.score.hrv_rmssd_milli),
unit: "ms",
measuredAt,
fieldTag: "hrv_rmssd",
},
{
type: "RESTING_HEART_RATE",
value: round2(r.score.resting_heart_rate),
unit: "bpm",
measuredAt,
fieldTag: "rhr",
},
];
if (typeof r.score.spo2_percentage === "number") {
out.push({
type: "OXYGEN_SATURATION",
value: round2(r.score.spo2_percentage),
unit: "%",
measuredAt,
fieldTag: "spo2",
});
}
if (typeof r.score.skin_temp_celsius === "number") {
out.push({
type: "SKIN_TEMPERATURE",
value: round2(r.score.skin_temp_celsius),
unit: "celsius",
measuredAt,
fieldTag: "skin_temp",
});
}
return out;
}
/**
* Map one WHOOP sleep record into Measurement readings: per-stage
* `SLEEP_DURATION` rows (ms→min, one per stage), `SLEEP_NEED` (ms→min, summed
* components), the `SLEEP_*` percentage scores, and `RESPIRATORY_RATE`.
*/
export function mapSleep(s: WhoopSleep): MappedMeasurement[] {
if (!s.score) return [];
const measuredAt = new Date(s.end);
const out: MappedMeasurement[] = [];
const stages = s.score.stage_summary;
for (const [key, mapping] of Object.entries(SLEEP_STAGE_MAP)) {
const ms = stages[key as keyof typeof stages];
if (typeof ms !== "number") continue;
out.push({
type: "SLEEP_DURATION",
value: round2(ms * MS_TO_MIN),
unit: "minutes",
measuredAt,
fieldTag: mapping.fieldTag,
sleepStage: mapping.stage,
});
}
const need = s.score.sleep_needed;
const totalNeedMilli =
need.baseline_milli +
need.need_from_sleep_debt_milli +
need.need_from_recent_strain_milli +
need.need_from_recent_nap_milli;
out.push({
type: "SLEEP_NEED",
value: round2(totalNeedMilli * MS_TO_MIN),
unit: "minutes",
measuredAt,
fieldTag: "sleep_need",
});
const pct: Array<[string, number | undefined, string]> = [
["SLEEP_PERFORMANCE", s.score.sleep_performance_percentage, "sleep_perf"],
["SLEEP_EFFICIENCY", s.score.sleep_efficiency_percentage, "sleep_eff"],
[
"SLEEP_CONSISTENCY",
s.score.sleep_consistency_percentage,
"sleep_consistency",
],
];
for (const [type, value, fieldTag] of pct) {
if (typeof value === "number") {
out.push({
type,
value: round2(value),
unit: "%",
measuredAt,
fieldTag,
});
}
}
if (typeof s.score.respiratory_rate === "number") {
out.push({
type: "RESPIRATORY_RATE",
value: round2(s.score.respiratory_rate),
unit: "breaths/min",
measuredAt,
fieldTag: "resp_rate",
});
}
return out;
}
/**
* Map one WHOOP cycle (day) record: `DAY_STRAIN` (distinct from the COMPUTED
* `STRAIN_SCORE`) and `ENERGY_EXPENDITURE_KJ` (kept in native kJ). Energy is
* NOT converted to kcal here — that conversion is for the workout path only.
*/
export function mapCycle(c: WhoopCycle): MappedMeasurement[] {
if (!c.score) return [];
const measuredAt = new Date(c.start);
return [
{
type: "DAY_STRAIN",
value: round2(c.score.strain),
unit: "score",
measuredAt,
fieldTag: "day_strain",
},
{
type: "ENERGY_EXPENDITURE_KJ",
value: round2(c.score.kilojoule),
unit: "kJ",
measuredAt,
fieldTag: "energy_kj",
},
];
}
/**
* Field→Measurement mapping table (mirror of `mapping.md`). Documents which
* WHOOP source field becomes which MeasurementType + unit. Used as the
* single-glance reference and by the mapper tests; the mappers above are the
* executable form.
*/
export const WHOOP_FIELD_MAP: Record<
string,
{ type: string; unit: string; factor?: number; note?: string }
> = {
"recovery.score.recovery_score": { type: "RECOVERY_SCORE", unit: "score" },
"recovery.score.hrv_rmssd_milli": { type: "HRV_RMSSD", unit: "ms" },
"recovery.score.resting_heart_rate": {
type: "RESTING_HEART_RATE",
unit: "bpm",
},
"recovery.score.spo2_percentage": {
type: "OXYGEN_SATURATION",
unit: "%",
},
"recovery.score.skin_temp_celsius": {
type: "SKIN_TEMPERATURE",
unit: "celsius",
},
"sleep.score.stage_summary.*_time_milli": {
type: "SLEEP_DURATION",
unit: "minutes",
factor: MS_TO_MIN,
note: "one row per stage (light→CORE, slow-wave→DEEP, rem→REM, awake→AWAKE, in-bed→IN_BED)",
},
"sleep.score.sleep_needed.*_milli": {
type: "SLEEP_NEED",
unit: "minutes",
factor: MS_TO_MIN,
note: "baseline + debt + strain + nap components summed",
},
"sleep.score.sleep_performance_percentage": {
type: "SLEEP_PERFORMANCE",
unit: "%",
},
"sleep.score.sleep_efficiency_percentage": {
type: "SLEEP_EFFICIENCY",
unit: "%",
},
"sleep.score.sleep_consistency_percentage": {
type: "SLEEP_CONSISTENCY",
unit: "%",
},
"sleep.score.respiratory_rate": {
type: "RESPIRATORY_RATE",
unit: "breaths/min",
},
"cycle.score.strain": { type: "DAY_STRAIN", unit: "score" },
"cycle.score.kilojoule": { type: "ENERGY_EXPENDITURE_KJ", unit: "kJ" },
"workout.score.strain": {
type: "WORKOUT_STRAIN",
unit: "score",
note: "preferentially stored in Workout.metadata, not a free-floating Measurement",
},
"workout.score.kilojoule": {
type: "Workout.totalEnergyKcal",
unit: "kcal",
factor: KJ_TO_KCAL,
note: "kJ→kcal for the workout row",
},
"body.weight_kilogram": {
type: "WEIGHT",
unit: "kg",
note: "picker ranks a real scale above WHOOP",
},
"body.max_heart_rate": {
type: "WhoopConnection.maxHeartRate",
unit: "bpm",
note: "profile constant — stored on the connection, not a Measurement",
},
};
+37
View File
@@ -0,0 +1,37 @@
/**
* Helper to retrieve per-user WHOOP OAuth credentials from the database.
* Mirrors `src/lib/withings/credentials.ts`.
*
* WHOOP ships per-user BYO-keys: each self-hoster registers their own WHOOP
* dev app and pastes the client id/secret into Settings, stored encrypted on
* `User` (the per-app authorized-user cap makes a single shared app
* unworkable for a multi-operator product).
*/
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import type { WhoopCredentials } from "./client";
/**
* Fetch and decrypt the user's WHOOP API credentials.
* Returns null if the user has not configured credentials.
*/
export async function getUserWhoopCredentials(
userId: string,
): Promise<WhoopCredentials | null> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
whoopClientIdEncrypted: true,
whoopClientSecretEncrypted: true,
},
});
if (!user?.whoopClientIdEncrypted || !user?.whoopClientSecretEncrypted) {
return null;
}
return {
clientId: decrypt(user.whoopClientIdEncrypted),
clientSecret: decrypt(user.whoopClientSecretEncrypted),
};
}
+65
View File
@@ -0,0 +1,65 @@
# WHOOP field → Measurement mapping
Single source of truth for the WHOOP v2 field → HealthLog `Measurement`
mapping. Keep this in sync with `WHOOP_FIELD_MAP` and the `mapRecovery` /
`mapSleep` / `mapCycle` mappers in `client.ts`. Every WHOOP row ingests
server-side with `source = WHOOP` and `externalId = <resource-uuid>:<fieldTag>`
(the field-tag disambiguates the several measurements derived from one WHOOP
resource). WHOOP rows tag `deviceType = band`.
Stance reference: `.planning/v1.11-build/epic-A-whoop-buildspec.md` §3.2 + §5.
## Recovery (`/v2/recovery`) — record `updated_at` is `measuredAt`
| Source field | MeasurementType | Unit | fieldTag | Note |
|---|---|---|---|---|
| `recovery.score.recovery_score` | `RECOVERY_SCORE` | score | `recovery` | Native WHOOP recovery. Same type as the COMPUTED proxy, distinguished by `source = WHOOP`. |
| `recovery.score.hrv_rmssd_milli` | `HRV_RMSSD` | ms | `hrv_rmssd` | RMSSD — kept distinct from the SDNN `HEART_RATE_VARIABILITY`. |
| `recovery.score.resting_heart_rate` | `RESTING_HEART_RATE` | bpm | `rhr` | Cross-source picker resolves WHOOP vs Apple vs Withings. |
| `recovery.score.spo2_percentage` | `OXYGEN_SATURATION` | % | `spo2` | Optional; already 0–100. |
| `recovery.score.skin_temp_celsius` | `SKIN_TEMPERATURE` | celsius | `skin_temp` | Optional; distinct from `BODY_TEMPERATURE`. |
A recovery record with `score === null` (WHOOP still scoring) maps to nothing.
## Sleep (`/v2/activity/sleep`) — record `end` is `measuredAt`
| Source field | MeasurementType | Unit | fieldTag | sleepStage |
|---|---|---|---|---|
| `stage_summary.total_light_sleep_time_milli` | `SLEEP_DURATION` | minutes | `sleep_core` | `CORE` |
| `stage_summary.total_slow_wave_sleep_time_milli` | `SLEEP_DURATION` | minutes | `sleep_deep` | `DEEP` |
| `stage_summary.total_rem_sleep_time_milli` | `SLEEP_DURATION` | minutes | `sleep_rem` | `REM` |
| `stage_summary.total_awake_time_milli` | `SLEEP_DURATION` | minutes | `sleep_awake` | `AWAKE` |
| `stage_summary.total_in_bed_time_milli` | `SLEEP_DURATION` | minutes | `sleep_in_bed` | `IN_BED` |
| `sleep_needed.{baseline,debt,strain,nap}_milli` (summed) | `SLEEP_NEED` | minutes | `sleep_need` | — |
| `sleep_performance_percentage` | `SLEEP_PERFORMANCE` | % | `sleep_perf` | — |
| `sleep_efficiency_percentage` | `SLEEP_EFFICIENCY` | % | `sleep_eff` | — |
| `sleep_consistency_percentage` | `SLEEP_CONSISTENCY` | % | `sleep_consistency` | — |
| `respiratory_rate` | `RESPIRATORY_RATE` | breaths/min | `resp_rate` | — |
Stage durations are ms→minutes (`÷ 60000`). One row per stage per night
(same pattern as Apple Health). Percentage / respiratory fields are optional.
## Cycle (`/v2/cycle`) — record `start` is `measuredAt`
| Source field | MeasurementType | Unit | fieldTag | Note |
|---|---|---|---|---|
| `cycle.score.strain` | `DAY_STRAIN` | score | `day_strain` | 0–21 WHOOP scale. Distinct from the COMPUTED `STRAIN_SCORE` (0–100 TRIMP proxy). |
| `cycle.score.kilojoule` | `ENERGY_EXPENDITURE_KJ` | kJ | `energy_kj` | Native kJ — NOT converted to kcal (the workout path converts). |
## Workout (`/v2/activity/workout`) — into `Workout`, not `Measurement`
| Source field | Destination | Unit | Note |
|---|---|---|---|
| `workout.score.strain` | `Workout.metadata` (`WORKOUT_STRAIN` type exists for the rare detached case) | score | Tied to the workout row so a phantom strain row never survives workout dedup. |
| `workout.score.kilojoule` | `Workout.totalEnergyKcal` | kcal | kJ→kcal (`÷ 4.184`). |
| `workout.score.{zone_durations,percent_recorded,distance_meter,altitude_*}` | `Workout.metadata` | — | HR-zone durations + recording quality + altitude. |
Workout ingest + dedup lands in W3/W6 (the E-slice); this module only owns the
fetch + the score→energy conversion factor (`KJ_TO_KCAL`).
## Body / profile (single objects, no pagination)
| Source field | Destination | Unit | Note |
|---|---|---|---|
| `body.weight_kilogram` | `WEIGHT` | kg | Picker ranks a real scale above WHOOP. |
| `body.max_heart_rate` | `WhoopConnection.maxHeartRate` | bpm | Profile constant — stored on the connection, not a `Measurement`. |
+34
View File
@@ -0,0 +1,34 @@
/**
* v1.11.0 — shared constants + mint helper for the WHOOP OAuth state nonce
* ledger. Mirrors `src/lib/withings/oauth-state.ts`.
*
* The nonce is random + opaque; a short-lived `WhoopOAuthState` row carries
* the `(nonce → userId)` mapping so the user id never leaks into request logs
* or network captures via the OAuth `state` param. The connect route, the
* callback route, and the cleanup cron all read these constants so the
* contract has one source of truth and the unit tests can pin it without
* importing either route file.
*/
import { randomBytes } from "node:crypto";
/**
* Cookie name carrying the in-flight state nonce. httpOnly + Secure +
* sameSite:lax at the connect route (mirrors the Withings cookie shape).
*/
export const WHOOP_OAUTH_STATE_COOKIE = "whoop_state" as const;
/**
* 10-minute TTL on the state row. Matches the cookie `maxAge`. Long enough to
* cover a user approving the WHOOP consent prompt; short enough that an
* abandoned handshake doesn't strand a row for hours.
*/
export const WHOOP_OAUTH_STATE_TTL_MS = 10 * 60 * 1000;
/**
* Mint a fresh state nonce. 16 random bytes → 22 base64url chars without
* padding — 128 bits of entropy, aligned with the OAuth 2.0 §10.10
* "at least 128 bits" recommendation.
*/
export function mintWhoopOAuthStateNonce(): string {
return randomBytes(16).toString("base64url");
}
+143
View File
@@ -0,0 +1,143 @@
/**
* Off-response classifier for WHOOP API replies.
*
* Mirrors the Withings classifier (`src/lib/withings/response-classifier.ts`)
* but speaks the WHOOP wire instead. WHOOP, unlike Withings, signals failure
* through the HTTP status code itself (no `status` field embedded in a 200
* body), so this classifier is HTTP-status-driven.
*
* Classification buckets:
*
* - `success` → HTTP 2xx
* - `transient` → retryable: HTTP 429 (rate-limited — WHOOP caps at
* 100 req/min + 10 000 req/day per app), 5xx upstream
* outages, 3xx CDN/network glitches, and an empty /
* off-spec body where we never reached the envelope.
* - `reauth_required` → HTTP 401 / 403 — the access token is rejected or the
* grant was revoked; the user must redo OAuth.
* - `persistent` → any other 4xx (400, 404, 422, …) — a malformed
* request or a contract mismatch that retrying without
* operator intervention will not fix.
*
* The classifier is conservative: anything it doesn't recognise defaults to
* `transient` so a single unknown response doesn't hard-disable the
* integration. The 3-strike admin-alert ladder in `recordSyncFailure`
* already catches the "keeps happening" case for recurring transients.
*/
/** Outcome buckets for a WHOOP response. Same shape as Withings. */
export type WhoopClassification =
| "success"
| "transient"
| "reauth_required"
| "persistent";
export interface ClassifiedWhoopResponse {
classification: WhoopClassification;
/** The HTTP status code that drove the verdict (undefined if none seen). */
httpStatus: number | undefined;
/** Short human-readable label for logs / audit details. */
reason: string;
}
/**
* Classify a single WHOOP API response by its HTTP status code.
*
* @param httpStatus The HTTP status from fetch.
*/
export function classifyWhoopResponse(
httpStatus: number,
): ClassifiedWhoopResponse {
if (httpStatus >= 200 && httpStatus < 300) {
return { classification: "success", httpStatus, reason: "ok" };
}
// 429 is rate-limit — honour `X-RateLimit-*` for backoff at the call site,
// but the verdict is a plain transient so the next sync retries.
if (httpStatus === 429) {
return { classification: "transient", httpStatus, reason: "http_429" };
}
// 401/403 — the bearer token is rejected or the grant revoked. The user has
// to reconnect; do not retry silently.
if (httpStatus === 401 || httpStatus === 403) {
return {
classification: "reauth_required",
httpStatus,
reason: `http_${httpStatus}`,
};
}
// Any other 4xx is a malformed request / contract mismatch — persistent.
if (httpStatus >= 400 && httpStatus < 500) {
return {
classification: "persistent",
httpStatus,
reason: `http_${httpStatus}`,
};
}
// 5xx upstream outage — retryable.
if (httpStatus >= 500 && httpStatus < 600) {
return {
classification: "transient",
httpStatus,
reason: `http_${httpStatus}`,
};
}
// 3xx (a CDN / redirect glitch — safeFetch uses redirect:"manual", so a
// surfaced 3xx is unexpected) and everything else defaults to transient.
return {
classification: "transient",
httpStatus,
reason: `http_${httpStatus}_unknown`,
};
}
/**
* Typed Error subclass carrying the classification verdict so downstream
* `try/catch` blocks can branch without re-parsing the message. Mirrors
* `WithingsApiError`.
*/
export class WhoopApiError extends Error {
readonly classification: WhoopClassification;
readonly httpStatus: number | undefined;
readonly reason: string;
readonly verb: string;
constructor(opts: {
verb: string;
classification: WhoopClassification;
httpStatus: number | undefined;
reason: string;
upstreamError?: string;
}) {
const statusLabel =
typeof opts.httpStatus === "number" ? opts.httpStatus : "?";
const errSegment = opts.upstreamError ? ` - ${opts.upstreamError}` : "";
// Cap the message at 1024 chars in the constructor (same bound as
// WithingsApiError) so a misbehaving upstream returning a multi-MB error
// body can't bloat an AuditLog row downstream.
const raw = `WHOOP ${opts.verb} error: ${statusLabel}${errSegment}`;
super(raw.slice(0, 1024));
this.name = "WhoopApiError";
this.verb = opts.verb;
this.classification = opts.classification;
this.httpStatus = opts.httpStatus;
this.reason = opts.reason;
}
}
/**
* Read the classification from any caught error. Returns `transient` for a
* non-`WhoopApiError` input so a call site surfacing a plain `Error` retries
* rather than permanently disabling the integration. Falls back to parsing the
* HTTP status out of the legacy `"WHOOP <verb> error: <status>"` message shape
* for callers that lose the original prototype across a pg-boss retry.
*/
export function classifyWhoopError(err: unknown): WhoopClassification {
if (err instanceof WhoopApiError) return err.classification;
const msg = err instanceof Error ? err.message : String(err);
const m = /WHOOP\s+\w+\s+error:\s*(\d+)/.exec(msg);
if (!m) return "transient";
const status = Number.parseInt(m[1]!, 10);
if (!Number.isFinite(status)) return "transient";
return classifyWhoopResponse(status).classification;
}