mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
Merge branch 'v1423/integrations' into release/v1.42.3
This commit is contained in:
commit
035ae37ee4
49 files changed
+2715
-27
No files matched your search
@@ -103,6 +103,8 @@ const LEGIT_IDENTICAL = new Set<string>([
|
||||
// "{hours} h {minutes} min": the SI symbols for hour and minute.
|
||||
"measurements.durationHoursMinutes",
|
||||
"settings.sections.sources.metrics.vo2Max",
|
||||
"settings.integrations.withings.plan.items.vo2Max",
|
||||
"settings.integrations.oura.scope.items.vo2max",
|
||||
// Brand / product / proper nouns.
|
||||
"settings.googleHealth",
|
||||
"admin.glitchtipDsn",
|
||||
|
||||
@@ -52,7 +52,12 @@ const ouraAvailable = vi.fn(async () => false);
|
||||
vi.mock("@/lib/polar/credentials", () => ({
|
||||
getPolarClientCredentials: () => polarAvailable(),
|
||||
}));
|
||||
vi.mock("@/lib/oura/credentials", () => ({
|
||||
vi.mock("@/lib/oura/credentials", async () => ({
|
||||
readOuraScopeDenied: (
|
||||
await vi.importActual<typeof import("@/lib/oura/credentials")>(
|
||||
"@/lib/oura/credentials",
|
||||
)
|
||||
).readOuraScopeDenied,
|
||||
getOuraClientCredentials: () => ouraAvailable(),
|
||||
}));
|
||||
|
||||
@@ -387,3 +392,55 @@ describe("/api/integrations/status — a provider with no ledger row", () => {
|
||||
expect(whoop.syncHealth?.verdict).toBe("disconnected");
|
||||
});
|
||||
});
|
||||
|
||||
describe("/api/integrations/status — what a provider withholds by contract", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("names the readings the Withings plan withholds and the Oura collections the grant lacks", async () => {
|
||||
(
|
||||
prisma.withingsConnection.findUnique as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValueOnce({
|
||||
tokenExpiresAt: new Date(Date.now() + 3_600_000),
|
||||
lastSyncedAt: null,
|
||||
createdAt: new Date(),
|
||||
scope: "user.metrics,user.activity",
|
||||
apiPlanState: {
|
||||
version: 1,
|
||||
measures: {
|
||||
unavailable: [155, 54],
|
||||
checkedAt: new Date().toISOString(),
|
||||
},
|
||||
ecg: { unavailable: ["heart"], checkedAt: new Date().toISOString() },
|
||||
},
|
||||
});
|
||||
userFind.mockResolvedValue({
|
||||
ouraAccessTokenEncrypted: "tok",
|
||||
ouraScopeDenied: ["resilience", "spo2", "not_a_collection"],
|
||||
});
|
||||
|
||||
const entries = (await fetchEntries()) as Array<
|
||||
Entry & { planUnavailable?: string[]; scopeReconnect?: string[] }
|
||||
>;
|
||||
const withings = entries.find((e) => e.integration === "withings")!;
|
||||
const oura = entries.find((e) => e.integration === "oura")!;
|
||||
|
||||
expect(withings.planUnavailable).toEqual(["spo2", "vascularAge", "ecg"]);
|
||||
expect(oura.scopeReconnect).toEqual(["spo2", "resilience"]);
|
||||
});
|
||||
|
||||
it("publishes empty lists when nothing is withheld", async () => {
|
||||
userFind.mockResolvedValue({ ouraAccessTokenEncrypted: "tok" });
|
||||
|
||||
const entries = (await fetchEntries()) as Array<
|
||||
Entry & { planUnavailable?: string[]; scopeReconnect?: string[] }
|
||||
>;
|
||||
expect(
|
||||
entries.find((e) => e.integration === "withings")!.planUnavailable,
|
||||
).toEqual([]);
|
||||
expect(
|
||||
entries.find((e) => e.integration === "oura")!.scopeReconnect,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -35,7 +35,19 @@ import {
|
||||
resolveSyncVerdict,
|
||||
type SyncHealth,
|
||||
} from "@/lib/integrations/sync-verdict";
|
||||
import { getOuraClientCredentials } from "@/lib/oura/credentials";
|
||||
import {
|
||||
getOuraClientCredentials,
|
||||
readOuraScopeDenied,
|
||||
} from "@/lib/oura/credentials";
|
||||
import {
|
||||
ouraScopeReconnectList,
|
||||
type OuraScopeGatedCollection,
|
||||
} from "@/lib/oura/scope";
|
||||
import { parseWithingsApiPlanState } from "@/lib/withings/api-plan";
|
||||
import {
|
||||
withingsPlanUnavailableFeatures,
|
||||
type WithingsPlanFeature,
|
||||
} from "@/lib/withings/plan-features";
|
||||
import { getPolarClientCredentials } from "@/lib/polar/credentials";
|
||||
import { getStravaClientCredentials } from "@/lib/strava/credentials";
|
||||
import { hasActivityScope } from "@/lib/withings/client";
|
||||
@@ -92,6 +104,7 @@ export const GET = apiHandler(async () => {
|
||||
ouraAccessTokenEncrypted: true,
|
||||
ouraClientIdEncrypted: true,
|
||||
ouraClientSecretEncrypted: true,
|
||||
ouraScopeDenied: true,
|
||||
stravaAccessTokenEncrypted: true,
|
||||
stravaClientIdEncrypted: true,
|
||||
stravaClientSecretEncrypted: true,
|
||||
@@ -109,6 +122,7 @@ export const GET = apiHandler(async () => {
|
||||
lastSyncedAt: true,
|
||||
createdAt: true,
|
||||
scope: true,
|
||||
apiPlanState: true,
|
||||
},
|
||||
}),
|
||||
prisma.whoopConnection.findUnique({
|
||||
@@ -280,6 +294,13 @@ export const GET = apiHandler(async () => {
|
||||
// Null `scope` = legacy connection that predates activity-scope reads.
|
||||
scope: withingsConn?.scope ?? null,
|
||||
hasActivityScope: hasActivityScope(withingsConn?.scope ?? null),
|
||||
// What this connection's Withings API plan withholds (status 277),
|
||||
// resolved here so the card only renders it.
|
||||
planUnavailable: withingsConn
|
||||
? withingsPlanUnavailableFeatures(
|
||||
parseWithingsApiPlanState(withingsConn.apiPlanState),
|
||||
)
|
||||
: [],
|
||||
...resolve("withings", withingsStatus, {
|
||||
connected: !!withingsConn,
|
||||
configured: withingsConfigured,
|
||||
@@ -374,11 +395,17 @@ export const GET = apiHandler(async () => {
|
||||
hasOwnCredentials:
|
||||
!!dbUser?.ouraClientIdEncrypted &&
|
||||
!!dbUser?.ouraClientSecretEncrypted,
|
||||
// Collections the stored grant does not cover; one reconnect asks for
|
||||
// the missing scopes. Not a failure, so it never colours the pill.
|
||||
scopeReconnect: ouraConnected
|
||||
? ouraScopeReconnectList(readOuraScopeDenied(dbUser?.ouraScopeDenied))
|
||||
: [],
|
||||
...resolve("oura", ouraStatus, {
|
||||
connected: ouraConnected,
|
||||
configured: ouraConnected,
|
||||
}),
|
||||
} satisfies IntegrationViewModel & OAuthProviderExtras,
|
||||
} satisfies IntegrationViewModel &
|
||||
OAuthProviderExtras & { scopeReconnect: OuraScopeGatedCollection[] },
|
||||
{
|
||||
...publish(stravaStatus),
|
||||
connected: stravaConnected,
|
||||
@@ -442,6 +469,7 @@ interface WithingsExtras {
|
||||
tokenExpired: boolean | null;
|
||||
scope: string | null;
|
||||
hasActivityScope: boolean;
|
||||
planUnavailable: WithingsPlanFeature[];
|
||||
}
|
||||
|
||||
interface WhoopExtras {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
@@ -87,6 +88,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
data: {
|
||||
ouraAccessTokenEncrypted: encrypt(tokens.access_token),
|
||||
ouraRefreshTokenEncrypted: encrypt(tokens.refresh_token),
|
||||
// A new grant carries the scopes asked for now; whatever the old one
|
||||
// lacked is found out again by the first sync.
|
||||
ouraScopeDenied: Prisma.DbNull,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiSuccess,
|
||||
@@ -96,6 +97,7 @@ export const DELETE = apiHandler(async () => {
|
||||
data: {
|
||||
ouraAccessTokenEncrypted: null,
|
||||
ouraRefreshTokenEncrypted: null,
|
||||
ouraScopeDenied: Prisma.DbNull,
|
||||
},
|
||||
});
|
||||
await clearOuraClientCredentials(user.id);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
@@ -37,6 +38,7 @@ export const POST = apiHandler(async () => {
|
||||
data: {
|
||||
ouraAccessTokenEncrypted: null,
|
||||
ouraRefreshTokenEncrypted: null,
|
||||
ouraScopeDenied: Prisma.DbNull,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -190,6 +190,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
scope: WITHINGS_OAUTH_SCOPE,
|
||||
webhookSubscriptionState: Prisma.DbNull,
|
||||
webhookSubscriptionRetryAt: subscriptionRetryAt,
|
||||
// A reconnect may come with another developer app (another plan), so
|
||||
// what the old grant's plan refused is asked for again.
|
||||
apiPlanState: Prisma.DbNull,
|
||||
},
|
||||
create: {
|
||||
userId: user.id,
|
||||
|
||||
@@ -232,3 +232,43 @@ describe("OAuthProviderCard — sync-now action", () => {
|
||||
).not.toContain('data-testid="polar-sync"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("OAuthProviderCard — Oura scope note", () => {
|
||||
const scopeGap = {
|
||||
connected: true,
|
||||
configured: true,
|
||||
available: true,
|
||||
state: "connected" as const,
|
||||
lastSuccessAt: "2026-10-14T00:00:00.000Z",
|
||||
lastError: null,
|
||||
syncHealth: { verdict: "fresh" as const, since: null },
|
||||
scopeReconnect: ["spo2" as const, "vo2max" as const],
|
||||
};
|
||||
|
||||
function renderOura(viewModel: ViewModel) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<OAuthProviderCard
|
||||
provider="oura"
|
||||
statusQueryKey={["oura"]}
|
||||
i18nPrefix="settings.oura"
|
||||
icon={Watch}
|
||||
dataHref="/insights/sleep"
|
||||
viewModel={viewModel}
|
||||
callbackUrl="https://app.example/api/oura/callback"
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
it("asks for one reconnect beside a healthy pill, not an error", () => {
|
||||
const html = renderOura(scopeGap);
|
||||
expect(html).toContain('data-testid="oura-scope-note"');
|
||||
expect(html).toContain("Reconnect Oura once to allow SpO2 and VO2 max.");
|
||||
expect(html).not.toContain('data-testid="oura-error"');
|
||||
});
|
||||
|
||||
it("stays away from the other OAuth providers", () => {
|
||||
expect(render({ viewModel: scopeGap })).not.toContain("scope-note");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* The two "withheld by contract" notes: the Withings card names what the
|
||||
* account's API plan refuses, the Oura card asks for one reconnect when the
|
||||
* grant lacks a scope. Both render nothing when nothing is withheld, and both
|
||||
* render the server-resolved list rather than deriving one.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { WithingsPlanNote } from "../withings-plan-note";
|
||||
import { OuraScopeNote } from "../oura-scope-note";
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<WithingsPlanNote>", () => {
|
||||
it("lists the withheld readings and links the Withings plans page", () => {
|
||||
const html = render(
|
||||
<WithingsPlanNote features={["spo2", "vascularAge", "ecg"]} />,
|
||||
);
|
||||
expect(html).toContain('data-testid="withings-plan-note"');
|
||||
expect(html).toContain("SpO2, vascular age, and ECG and AFib");
|
||||
expect(html).toContain("Weight, blood pressure, body composition");
|
||||
expect(html).toContain(
|
||||
"https://static.withings.com/content/api/plans/plans.html",
|
||||
);
|
||||
});
|
||||
|
||||
it("speaks the reader's language", () => {
|
||||
const html = render(<WithingsPlanNote features={["vascularAge"]} />, "de");
|
||||
expect(html).toContain("Gefäßalter");
|
||||
expect(html).toContain("kostenpflichtigen API-Plan");
|
||||
});
|
||||
|
||||
it("renders nothing when the plan withholds nothing", () => {
|
||||
expect(render(<WithingsPlanNote features={[]} />)).toBe("");
|
||||
expect(render(<WithingsPlanNote features={undefined} />)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<OuraScopeNote>", () => {
|
||||
it("asks for one reconnect naming the collections the grant lacks", () => {
|
||||
const html = render(
|
||||
<OuraScopeNote
|
||||
collections={["spo2", "vo2max", "cardio_age", "resilience"]}
|
||||
onReconnect={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-testid="oura-scope-note"');
|
||||
expect(html).toContain(
|
||||
"Reconnect Oura once to allow SpO2, VO2 max, cardiovascular age, and resilience.",
|
||||
);
|
||||
expect(html).toContain('data-testid="oura-scope-reconnect"');
|
||||
});
|
||||
|
||||
it("renders nothing when the grant covers everything", () => {
|
||||
expect(
|
||||
render(<OuraScopeNote collections={[]} onReconnect={vi.fn()} />),
|
||||
).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
|
||||
import { OuraScopeNote } from "./oura-scope-note";
|
||||
import {
|
||||
readSyncOutcome,
|
||||
useSyncOutcomeMessage,
|
||||
@@ -72,6 +73,7 @@ import {
|
||||
type IntegrationDocsProvider,
|
||||
} from "./setup-guide-link";
|
||||
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
|
||||
import type { OuraScopeGatedCollection } from "@/lib/oura/scope";
|
||||
import { loadDocument } from "@/lib/navigation/load-document";
|
||||
|
||||
export interface OAuthProviderStatus {
|
||||
@@ -99,6 +101,8 @@ export interface OAuthProviderStatus {
|
||||
/** The server-resolved liveness verdict; the pill is a projection of it. */
|
||||
syncHealth?: SyncHealth;
|
||||
metricFreshness?: MetricFreshnessEntry[];
|
||||
/** Oura: collections the stored grant does not cover. */
|
||||
scopeReconnect?: OuraScopeGatedCollection[];
|
||||
}
|
||||
|
||||
export interface OAuthProviderCardProps {
|
||||
@@ -330,6 +334,14 @@ export function OAuthProviderCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{provider === "oura" && status?.connected && (
|
||||
<OuraScopeNote
|
||||
collections={status.scopeReconnect}
|
||||
onReconnect={handleConnect}
|
||||
disabled={serverUnavailable}
|
||||
/>
|
||||
)}
|
||||
|
||||
{status?.connected && (
|
||||
<MetricFreshnessDisclosure
|
||||
entries={status.metricFreshness}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { Link2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { OuraScopeGatedCollection } from "@/lib/oura/scope";
|
||||
|
||||
/**
|
||||
* A calm note on the Oura card when the stored grant does not cover some
|
||||
* collections (they answer 401 while their siblings succeed). Not an error and
|
||||
* not counted toward the reconnect threshold: everything else keeps syncing,
|
||||
* and one reconnect asks Oura for the missing scopes.
|
||||
*/
|
||||
export function OuraScopeNote({
|
||||
collections,
|
||||
onReconnect,
|
||||
disabled,
|
||||
}: {
|
||||
collections: readonly OuraScopeGatedCollection[] | undefined;
|
||||
onReconnect: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { t, locale } = useTranslations();
|
||||
if (!collections || collections.length === 0) return null;
|
||||
|
||||
const labels: Record<OuraScopeGatedCollection, string> = {
|
||||
spo2: t("settings.integrations.oura.scope.items.spo2"),
|
||||
vo2max: t("settings.integrations.oura.scope.items.vo2max"),
|
||||
cardio_age: t("settings.integrations.oura.scope.items.cardio_age"),
|
||||
resilience: t("settings.integrations.oura.scope.items.resilience"),
|
||||
};
|
||||
const list = new Intl.ListFormat(locale, {
|
||||
style: "long",
|
||||
type: "conjunction",
|
||||
}).format(collections.map((c) => labels[c]));
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="oura-scope-note"
|
||||
className="border-info/30 bg-info/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<p className="min-w-0 break-words">
|
||||
{t("settings.integrations.oura.scope.body", { list })}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onReconnect}
|
||||
disabled={disabled}
|
||||
data-testid="oura-scope-reconnect"
|
||||
className="min-h-11"
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
{t("settings.integrations.oura.scope.action")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,8 @@ import type {
|
||||
SyncVerdict,
|
||||
} from "@/lib/integrations/sync-verdict";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { WithingsPlanFeature } from "@/lib/withings/plan-features";
|
||||
import type { OuraScopeGatedCollection } from "@/lib/oura/scope";
|
||||
|
||||
// v1.4.15 Phase B2: shared status payload for both integration cards.
|
||||
// v1.4.19 Phase A5: the redundant in-card status banner is gone — the
|
||||
@@ -70,6 +72,10 @@ export interface IntegrationStatusViewModel {
|
||||
// Withings activity-scope reconnect banner.
|
||||
scope?: string | null;
|
||||
hasActivityScope?: boolean;
|
||||
// Withings: readings the account's API plan withholds (server-resolved).
|
||||
planUnavailable?: WithingsPlanFeature[];
|
||||
// Oura: collections the stored grant does not cover (server-resolved).
|
||||
scopeReconnect?: OuraScopeGatedCollection[];
|
||||
// WHOOP / Fitbit backfill-in-progress note.
|
||||
backfillCompleted?: boolean | null;
|
||||
// v1.27.0 — Google Health surfaces `needsReauth` when the refresh token has
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
type IntegrationStatusViewModel,
|
||||
} from "./shared";
|
||||
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
|
||||
import { WithingsPlanNote } from "./withings-plan-note";
|
||||
import {
|
||||
readSyncOutcome,
|
||||
useSyncOutcomeMessage,
|
||||
@@ -316,6 +317,10 @@ export function WithingsCard({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status?.connected && (
|
||||
<WithingsPlanNote features={status.planUnavailable} />
|
||||
)}
|
||||
|
||||
{status?.connected && (
|
||||
<MetricFreshnessDisclosure
|
||||
entries={viewModel?.metricFreshness}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { ExternalLink } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
WITHINGS_API_PLANS_URL,
|
||||
type WithingsPlanFeature,
|
||||
} from "@/lib/withings/plan-features";
|
||||
|
||||
/**
|
||||
* A calm note on the Withings card when the account's API plan withholds some
|
||||
* readings (status 277 since the 2026-10-12 plan split). Not an error: the
|
||||
* sync skips those readings and keeps importing the rest, so this sits in the
|
||||
* card body in the info wash, never in the pill.
|
||||
*/
|
||||
export function WithingsPlanNote({
|
||||
features,
|
||||
}: {
|
||||
features: readonly WithingsPlanFeature[] | undefined;
|
||||
}) {
|
||||
const { t, locale } = useTranslations();
|
||||
if (!features || features.length === 0) return null;
|
||||
|
||||
const labels: Record<WithingsPlanFeature, string> = {
|
||||
spo2: t("settings.integrations.withings.plan.items.spo2"),
|
||||
vascularAge: t("settings.integrations.withings.plan.items.vascularAge"),
|
||||
pulseWaveVelocity: t(
|
||||
"settings.integrations.withings.plan.items.pulseWaveVelocity",
|
||||
),
|
||||
vo2Max: t("settings.integrations.withings.plan.items.vo2Max"),
|
||||
visceralFat: t("settings.integrations.withings.plan.items.visceralFat"),
|
||||
skinTemperature: t(
|
||||
"settings.integrations.withings.plan.items.skinTemperature",
|
||||
),
|
||||
ecg: t("settings.integrations.withings.plan.items.ecg"),
|
||||
sleepScore: t("settings.integrations.withings.plan.items.sleepScore"),
|
||||
sleepHrv: t("settings.integrations.withings.plan.items.sleepHrv"),
|
||||
sleepHeartRate: t(
|
||||
"settings.integrations.withings.plan.items.sleepHeartRate",
|
||||
),
|
||||
sleepRespiratoryRate: t(
|
||||
"settings.integrations.withings.plan.items.sleepRespiratoryRate",
|
||||
),
|
||||
sleepSpo2: t("settings.integrations.withings.plan.items.sleepSpo2"),
|
||||
bodyTemperature: t(
|
||||
"settings.integrations.withings.plan.items.bodyTemperature",
|
||||
),
|
||||
bodyFat: t("settings.integrations.withings.plan.items.bodyFat"),
|
||||
fatMass: t("settings.integrations.withings.plan.items.fatMass"),
|
||||
fatFreeMass: t("settings.integrations.withings.plan.items.fatFreeMass"),
|
||||
muscleMass: t("settings.integrations.withings.plan.items.muscleMass"),
|
||||
bodyWater: t("settings.integrations.withings.plan.items.bodyWater"),
|
||||
boneMass: t("settings.integrations.withings.plan.items.boneMass"),
|
||||
heartRate: t("settings.integrations.withings.plan.items.heartRate"),
|
||||
bloodPressure: t("settings.integrations.withings.plan.items.bloodPressure"),
|
||||
weight: t("settings.integrations.withings.plan.items.weight"),
|
||||
};
|
||||
const list = new Intl.ListFormat(locale, {
|
||||
style: "long",
|
||||
type: "conjunction",
|
||||
}).format(features.map((f) => labels[f]));
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="withings-plan-note"
|
||||
className="border-info/30 bg-info/10 space-y-1 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<p className="font-medium">
|
||||
{t("settings.integrations.withings.plan.title")}
|
||||
</p>
|
||||
<p>{t("settings.integrations.withings.plan.body", { list })}</p>
|
||||
<a
|
||||
href={WITHINGS_API_PLANS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary inline-flex items-center gap-1 text-xs font-medium underline-offset-2 hover:underline"
|
||||
>
|
||||
{t("settings.integrations.withings.plan.link")}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -439,6 +439,7 @@ export const USER_RESET = {
|
||||
ouraRefreshTokenEncrypted: null,
|
||||
ouraClientIdEncrypted: null,
|
||||
ouraClientSecretEncrypted: null,
|
||||
ouraScopeDenied: Prisma.DbNull,
|
||||
stravaClientIdEncrypted: null,
|
||||
stravaClientSecretEncrypted: null,
|
||||
stravaAccessTokenEncrypted: null,
|
||||
|
||||
@@ -885,6 +885,9 @@ export const USER_COLUMN_BACKUP_CLASS = {
|
||||
ouraRefreshTokenEncrypted: "CREDENTIAL",
|
||||
ouraClientIdEncrypted: "CREDENTIAL",
|
||||
ouraClientSecretEncrypted: "CREDENTIAL",
|
||||
// What the Oura grant on this host does not cover; meaningless beside a
|
||||
// grant that does not travel, and found out again by the first sync.
|
||||
ouraScopeDenied: "CREDENTIAL",
|
||||
stravaClientIdEncrypted: "CREDENTIAL",
|
||||
stravaClientSecretEncrypted: "CREDENTIAL",
|
||||
stravaAccessTokenEncrypted: "CREDENTIAL",
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
syncUserMeasurements,
|
||||
} from "@/lib/withings/sync";
|
||||
import { syncUserActivity } from "@/lib/withings/sync-activity";
|
||||
import { releasePlanGatedWithingsParks } from "@/lib/withings/api-plan";
|
||||
import { syncUserSleep } from "@/lib/withings/sync-sleep";
|
||||
import { syncUserEcg } from "@/lib/withings/sync-ecg";
|
||||
import type { WithingsEcgSyncPayload } from "@/lib/jobs/withings-ecg-queue";
|
||||
@@ -74,6 +75,15 @@ export async function handleWithingsFallbackSync(
|
||||
"Withings subscription repair failed; continuing fallback sync",
|
||||
);
|
||||
}
|
||||
// Connections an earlier build parked at `error_reauth` over a Withings
|
||||
// API-plan refusal (277) are lifted first, so this pass and the activity,
|
||||
// sleep and ECG legs read again. Best-effort: a failure here leaves the
|
||||
// rows parked until the next tick, it never blocks the pass.
|
||||
try {
|
||||
await releasePlanGatedWithingsParks();
|
||||
} catch {
|
||||
evt.addWarning("Withings plan-park release failed; continuing");
|
||||
}
|
||||
const connections = await prisma.withingsConnection.findMany({
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
@@ -34,6 +34,8 @@ import { z } from "zod/v4";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { fitbitCredentialsSchema } from "@/lib/validations/fitbit";
|
||||
import { googleHealthCredentialsSchema } from "@/lib/validations/google-health";
|
||||
import { WITHINGS_PLAN_FEATURES } from "@/lib/withings/plan-features";
|
||||
import { OURA_SCOPE_GATED_COLLECTIONS } from "@/lib/oura/scope";
|
||||
import { ouraCredentialsSchema } from "@/lib/validations/oura";
|
||||
import { polarCredentialsSchema } from "@/lib/validations/polar";
|
||||
import { stravaCredentialsSchema } from "@/lib/validations/strava";
|
||||
@@ -925,12 +927,24 @@ const integrationEnvelopeEntry = z
|
||||
backfillCompleted: z.boolean().nullable().optional(),
|
||||
scope: z.string().nullable().optional().describe("Withings only."),
|
||||
hasActivityScope: z.boolean().optional().describe("Withings only."),
|
||||
planUnavailable: z
|
||||
.array(z.enum(WITHINGS_PLAN_FEATURES))
|
||||
.optional()
|
||||
.describe(
|
||||
"Withings only. The readings this connection's Withings API plan withholds (status 277 since the 2026-10-12 plan split), deduplicated and in display order. Empty when the plan refuses nothing. The sync skips these and keeps importing everything else; the list refreshes when the weekly re-probe asks again.",
|
||||
),
|
||||
needsReauth: z.boolean().optional().describe("Google Health only."),
|
||||
available: z.boolean().optional().describe("Oura / Polar / Strava only."),
|
||||
hasOwnCredentials: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Oura / Polar / Strava only."),
|
||||
scopeReconnect: z
|
||||
.array(z.enum(OURA_SCOPE_GATED_COLLECTIONS))
|
||||
.optional()
|
||||
.describe(
|
||||
"Oura only. Collections the stored grant does not cover: they answered 401 while sibling collections succeeded with the same token. Not a failure and not counted toward the reconnect threshold; one reconnect asks for the missing scopes. Empty when the grant covers everything HealthLog reads.",
|
||||
),
|
||||
hasToken: z.boolean().optional().describe("Nightscout only."),
|
||||
allowPrivateHost: z.boolean().optional().describe("Nightscout only."),
|
||||
})
|
||||
|
||||
@@ -670,8 +670,13 @@ describe("mapResilience", () => {
|
||||
});
|
||||
|
||||
describe("OURA_OAUTH_SCOPE", () => {
|
||||
it("requests daily + personal", () => {
|
||||
expect(OURA_OAUTH_SCOPE).toBe("daily personal");
|
||||
it("requests daily, personal and the scopes behind SpO2 and heart health", () => {
|
||||
expect(OURA_OAUTH_SCOPE.split(" ")).toEqual([
|
||||
"daily",
|
||||
"personal",
|
||||
"spo2",
|
||||
"heart_health",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ const {
|
||||
invalidateMock,
|
||||
transactionMock,
|
||||
reconcileMock,
|
||||
scopeDeniedMock,
|
||||
} = vi.hoisted(() => ({
|
||||
getConnMock: vi.fn(),
|
||||
storeTokensMock: vi.fn(),
|
||||
@@ -60,12 +61,14 @@ const {
|
||||
invalidateMock: vi.fn(),
|
||||
transactionMock: vi.fn(),
|
||||
reconcileMock: vi.fn(),
|
||||
scopeDeniedMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../credentials", () => ({
|
||||
getOuraConnection: getConnMock,
|
||||
storeOuraTokens: storeTokensMock,
|
||||
getOuraClientCredentials: getCredsMock,
|
||||
syncOuraScopeDenied: scopeDeniedMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
@@ -159,6 +162,7 @@ beforeEach(() => {
|
||||
fetchResilienceMock.mockReset().mockResolvedValue([]);
|
||||
fetchCyclePhasesMock.mockReset().mockResolvedValue([]);
|
||||
refreshMock.mockReset();
|
||||
scopeDeniedMock.mockReset().mockResolvedValue(undefined);
|
||||
transactionMock
|
||||
.mockReset()
|
||||
.mockImplementation(async (run: (tx: unknown) => unknown) => run({}));
|
||||
@@ -894,3 +898,84 @@ describe("resolveOuraLookbackDays", () => {
|
||||
expect(await resolveOuraLookbackDays("u1")).toBe(21);
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncUserOura — a grant without the spo2 / heart_health scopes", () => {
|
||||
const SCOPED = ["spo2", "vo2max", "cardio_age", "resilience"];
|
||||
|
||||
function denyScopedCollections() {
|
||||
fetchSpo2Mock.mockRejectedValue(err401());
|
||||
fetchVo2MaxMock.mockRejectedValue(err401());
|
||||
fetchCardioAgeMock.mockRejectedValue(err401());
|
||||
fetchResilienceMock.mockRejectedValue(err401());
|
||||
fetchReadinessMock.mockResolvedValue([
|
||||
{ id: "x", day: "2026-06-10", score: 70 },
|
||||
]);
|
||||
}
|
||||
|
||||
it("records the scope gap instead of a failure, and never toward the reconnect count", async () => {
|
||||
getConnMock.mockResolvedValue(CONN);
|
||||
denyScopedCollections();
|
||||
refreshMock.mockResolvedValue({
|
||||
access_token: "newAcc",
|
||||
refresh_token: "newRef",
|
||||
expires_in: 86400,
|
||||
});
|
||||
|
||||
const result = await syncUserOura("u1");
|
||||
|
||||
expect(result).toEqual({ imported: 1, failed: false });
|
||||
expect(recordFailureMock).not.toHaveBeenCalled();
|
||||
expect(recordSuccessMock).toHaveBeenCalledWith("u1", "oura");
|
||||
expect(scopeDeniedMock).toHaveBeenCalledWith("u1", SCOPED);
|
||||
});
|
||||
|
||||
it("spends no token refresh on a scope gap it already knows", async () => {
|
||||
getConnMock.mockResolvedValue({ ...CONN, scopeDenied: SCOPED });
|
||||
denyScopedCollections();
|
||||
|
||||
const result = await syncUserOura("u1");
|
||||
|
||||
expect(refreshMock).not.toHaveBeenCalled();
|
||||
expect(result.failed).toBe(false);
|
||||
expect(recordFailureMock).not.toHaveBeenCalled();
|
||||
expect(scopeDeniedMock).toHaveBeenCalledWith("u1", SCOPED);
|
||||
});
|
||||
|
||||
it("clears the note once the grant covers every collection", async () => {
|
||||
getConnMock.mockResolvedValue({ ...CONN, scopeDenied: SCOPED });
|
||||
|
||||
await syncUserOura("u1");
|
||||
|
||||
expect(scopeDeniedMock).toHaveBeenCalledWith("u1", []);
|
||||
expect(recordSuccessMock).toHaveBeenCalledWith("u1", "oura");
|
||||
});
|
||||
|
||||
it("still treats a 401 on every collection as a dead token", async () => {
|
||||
getConnMock.mockResolvedValue(CONN);
|
||||
for (const m of [
|
||||
fetchReadinessMock,
|
||||
fetchSleepMock,
|
||||
fetchActivityMock,
|
||||
fetchDailySleepMock,
|
||||
fetchSpo2Mock,
|
||||
fetchVo2MaxMock,
|
||||
fetchCardioAgeMock,
|
||||
fetchResilienceMock,
|
||||
]) {
|
||||
m.mockRejectedValue(err401());
|
||||
}
|
||||
refreshMock.mockResolvedValue({
|
||||
access_token: "newAcc",
|
||||
refresh_token: "newRef",
|
||||
expires_in: 86400,
|
||||
});
|
||||
|
||||
const result = await syncUserOura("u1");
|
||||
|
||||
expect(result.failed).toBe(true);
|
||||
expect(scopeDeniedMock).toHaveBeenCalledWith("u1", []);
|
||||
expect(recordFailureMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ integration: "oura", kind: "reauth_required" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
+18
-2
@@ -109,8 +109,24 @@ export function getOuraRedirectUri(): string {
|
||||
);
|
||||
}
|
||||
|
||||
/** `daily` covers sleep / readiness / activity; `personal` is profile info. */
|
||||
export const OURA_OAUTH_SCOPE = "daily personal" as const;
|
||||
/**
|
||||
* The scopes HealthLog asks Oura for.
|
||||
*
|
||||
* - `daily` : readiness, sleep, daily sleep, daily activity
|
||||
* - `personal` : profile info
|
||||
* - `spo2` : `daily_spo2`
|
||||
* - `heart_health` : `vO2_max`, `daily_cardiovascular_age`,
|
||||
* `daily_resilience`
|
||||
*
|
||||
* Oura's current authorization server enforces these per collection: a grant
|
||||
* without `spo2` or `heart_health` answers 401 on those collections while every
|
||||
* other one succeeds. The published OpenAPI security scheme (openapi-1.41)
|
||||
* lists `spo2` and `heart_health` but maps no endpoint to them; the mapping
|
||||
* above is what the live API enforces, as recorded by integrations that probed
|
||||
* it. Grants issued before these were requested keep the old set until the
|
||||
* user reconnects once — see `syncOuraScopeDenied`.
|
||||
*/
|
||||
export const OURA_OAUTH_SCOPE = "daily personal spo2 heart_health" as const;
|
||||
|
||||
export function getAuthorizationUrl(
|
||||
state: string,
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
* tokens).
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import { persistRotatedToken } from "@/lib/integrations/oauth-refresh";
|
||||
import { getOuraCredentials, type OuraCredentials } from "./client";
|
||||
@@ -84,6 +85,8 @@ export interface OuraConnection {
|
||||
* concurrent sync that already rotated the token is not clobbered.
|
||||
*/
|
||||
refreshTokenCiphertext: string;
|
||||
/** Collections the grant was last seen not to cover (`ouraScopeDenied`). */
|
||||
scopeDenied?: string[];
|
||||
}
|
||||
|
||||
export async function getOuraConnection(
|
||||
@@ -94,6 +97,7 @@ export async function getOuraConnection(
|
||||
select: {
|
||||
ouraAccessTokenEncrypted: true,
|
||||
ouraRefreshTokenEncrypted: true,
|
||||
ouraScopeDenied: true,
|
||||
},
|
||||
});
|
||||
if (!user?.ouraAccessTokenEncrypted || !user?.ouraRefreshTokenEncrypted) {
|
||||
@@ -103,6 +107,7 @@ export async function getOuraConnection(
|
||||
accessToken: decrypt(user.ouraAccessTokenEncrypted),
|
||||
refreshToken: decrypt(user.ouraRefreshTokenEncrypted),
|
||||
refreshTokenCiphertext: user.ouraRefreshTokenEncrypted,
|
||||
scopeDenied: readOuraScopeDenied(user.ouraScopeDenied),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -148,3 +153,37 @@ export async function storeOuraTokens(
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Collections the current Oura grant does not cover, by collection name. Read
|
||||
* by the settings envelope; an absent or malformed value reads as none.
|
||||
*/
|
||||
export function readOuraScopeDenied(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter(
|
||||
(v): v is string => typeof v === "string" && /^[a-z0-9_]{1,40}$/.test(v),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record which collections the grant does not cover after a sync, writing only
|
||||
* when the set changed so an hourly poll does not touch the user row for
|
||||
* nothing. An empty set clears the column.
|
||||
*/
|
||||
export async function syncOuraScopeDenied(
|
||||
userId: string,
|
||||
denied: readonly string[],
|
||||
): Promise<void> {
|
||||
const next = [...new Set(denied)].sort();
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { ouraScopeDenied: true },
|
||||
});
|
||||
const current = readOuraScopeDenied(row?.ouraScopeDenied).sort();
|
||||
if (current.length === next.length && current.every((v, i) => v === next[i]))
|
||||
return;
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { ouraScopeDenied: next.length > 0 ? next : Prisma.DbNull },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Oura collections that sit behind a scope beyond `daily` (`spo2`,
|
||||
* `heart_health`). A grant issued before HealthLog requested those scopes
|
||||
* answers 401 on exactly these, so they are the only names the settings card
|
||||
* can be asked to explain. Each has a label under
|
||||
* `settings.integrations.oura.scope.items`.
|
||||
*/
|
||||
export const OURA_SCOPE_GATED_COLLECTIONS = [
|
||||
"spo2",
|
||||
"vo2max",
|
||||
"cardio_age",
|
||||
"resilience",
|
||||
] as const;
|
||||
|
||||
export type OuraScopeGatedCollection =
|
||||
(typeof OURA_SCOPE_GATED_COLLECTIONS)[number];
|
||||
|
||||
/** Narrow a stored list to the known names, in display order. */
|
||||
export function ouraScopeReconnectList(
|
||||
denied: readonly string[],
|
||||
): OuraScopeGatedCollection[] {
|
||||
return OURA_SCOPE_GATED_COLLECTIONS.filter((c) => denied.includes(c));
|
||||
}
|
||||
+87
-7
@@ -35,7 +35,7 @@
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import {
|
||||
markSyncFailureRecorded,
|
||||
recordSyncFailure,
|
||||
@@ -87,6 +87,7 @@ import {
|
||||
getOuraClientCredentials,
|
||||
getOuraConnection,
|
||||
storeOuraTokens,
|
||||
syncOuraScopeDenied,
|
||||
} from "./credentials";
|
||||
import { OuraApiError, classifyOuraError } from "./response-classifier";
|
||||
import { syncUserOuraCyclePhases } from "./cycle-sync";
|
||||
@@ -243,6 +244,42 @@ export interface OuraFetchResult {
|
||||
* run-indexed `seg:<i>` row — before the fresh set upserts.
|
||||
*/
|
||||
sleepSweeps: SleepSegmentSweep[];
|
||||
/**
|
||||
* Collections refused with 401/403 while a sibling succeeded with the same
|
||||
* token: the token is fine, the grant does not cover them. Not failures; the
|
||||
* card asks for one reconnect instead.
|
||||
*/
|
||||
scopeDenied: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A 401 from Oura: the token, or the grant's scope, was refused. A 403 stays a
|
||||
* plain failure: Oura also answers 403 for a lapsed membership, which a
|
||||
* reconnect does not fix.
|
||||
*/
|
||||
function isAuthRefusal(err: unknown): boolean {
|
||||
return err instanceof OuraApiError && err.httpStatus === 401;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the auth refusals out of a run's failures once a sibling collection has
|
||||
* proven the token. Oura's authorization server enforces scopes per collection,
|
||||
* so a grant issued without `spo2` / `heart_health` answers 401 on exactly the
|
||||
* collections behind them and 200 everywhere else. Counting those toward the
|
||||
* reconnect ladder painted a working connection as broken on every sync, and
|
||||
* refreshing the token over them changed nothing.
|
||||
*/
|
||||
function splitScopeDenied(
|
||||
failures: OuraCollectionFailure[],
|
||||
tokenProven: boolean,
|
||||
): { failures: OuraCollectionFailure[]; scopeDenied: string[] } {
|
||||
if (!tokenProven) return { failures, scopeDenied: [] };
|
||||
return {
|
||||
failures: failures.filter((f) => !isAuthRefusal(f.err)),
|
||||
scopeDenied: failures
|
||||
.filter((f) => isAuthRefusal(f.err))
|
||||
.map((f) => f.name),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -267,6 +304,7 @@ async function fetchAll(
|
||||
refreshToken: string,
|
||||
refreshTokenCiphertext: string,
|
||||
lookbackDays: number,
|
||||
knownScopeDenied: readonly string[] = [],
|
||||
): Promise<OuraFetchResult> {
|
||||
// Oura keys its daily collections by the user's calendar date, so the
|
||||
// window is cut on the user's own days, and date-only records are anchored
|
||||
@@ -386,6 +424,7 @@ async function fetchAll(
|
||||
failed: OuraCollectionSpec[];
|
||||
failures: OuraCollectionFailure[];
|
||||
auth401: boolean;
|
||||
succeeded: number;
|
||||
}> => {
|
||||
const settled = await Promise.allSettled(
|
||||
specs.map((spec) => spec.collect(token)),
|
||||
@@ -394,9 +433,11 @@ async function fetchAll(
|
||||
const failed: OuraCollectionSpec[] = [];
|
||||
const failures: OuraCollectionFailure[] = [];
|
||||
let auth401 = false;
|
||||
let succeeded = 0;
|
||||
settled.forEach((res, i) => {
|
||||
const spec = specs[i]!;
|
||||
if (res.status === "fulfilled") {
|
||||
succeeded++;
|
||||
readings.push(...res.value);
|
||||
} else {
|
||||
const err = res.reason;
|
||||
@@ -406,12 +447,24 @@ async function fetchAll(
|
||||
failures.push({ name: spec.name, err });
|
||||
}
|
||||
});
|
||||
return { readings, failed, failures, auth401 };
|
||||
return { readings, failed, failures, auth401, succeeded };
|
||||
};
|
||||
|
||||
const first = await attempt(accessToken, collections);
|
||||
if (!first.auth401) {
|
||||
return { readings: first.readings, failures: first.failures, sleepSweeps };
|
||||
// A 401 limited to collections already known to sit outside the grant, next
|
||||
// to collections that succeeded, is the grant's scope again: a refresh cannot
|
||||
// widen it, so none is spent. Any other 401 still takes the one refresh, and
|
||||
// whatever 401s after it beside a success is recorded as scope.
|
||||
const knownDenied = new Set(knownScopeDenied);
|
||||
const onlyKnownScope401 = first.failures
|
||||
.filter((f) => isAuthRefusal(f.err))
|
||||
.every((f) => knownDenied.has(f.name));
|
||||
if (!first.auth401 || (first.succeeded > 0 && onlyKnownScope401)) {
|
||||
return {
|
||||
readings: first.readings,
|
||||
...splitScopeDenied(first.failures, first.succeeded > 0),
|
||||
sleepSweeps,
|
||||
};
|
||||
}
|
||||
|
||||
// Reactive refresh: the access token expired (a 401 on the shared token).
|
||||
@@ -419,7 +472,12 @@ async function fetchAll(
|
||||
// failed. A failed refresh throws so the caller parks reauth_required.
|
||||
const creds = await getOuraClientCredentials(userId);
|
||||
if (!creds)
|
||||
return { readings: first.readings, failures: first.failures, sleepSweeps };
|
||||
return {
|
||||
readings: first.readings,
|
||||
failures: first.failures,
|
||||
sleepSweeps,
|
||||
scopeDenied: [],
|
||||
};
|
||||
|
||||
const rotated = await refreshAccessToken(refreshToken, creds);
|
||||
// Compare-and-swap persist: on a lost race against a concurrent sync this
|
||||
@@ -432,12 +490,19 @@ async function fetchAll(
|
||||
refreshTokenCiphertext,
|
||||
);
|
||||
if (!usableToken)
|
||||
return { readings: first.readings, failures: first.failures, sleepSweeps };
|
||||
return {
|
||||
readings: first.readings,
|
||||
failures: first.failures,
|
||||
sleepSweeps,
|
||||
scopeDenied: [],
|
||||
};
|
||||
|
||||
const retry = await attempt(usableToken, first.failed);
|
||||
return {
|
||||
readings: [...first.readings, ...retry.readings],
|
||||
failures: retry.failures,
|
||||
// Proven by either pass: a collection that succeeded before the refresh
|
||||
// proves the grant as much as one that succeeded after it.
|
||||
...splitScopeDenied(retry.failures, first.succeeded + retry.succeeded > 0),
|
||||
sleepSweeps,
|
||||
};
|
||||
}
|
||||
@@ -472,6 +537,7 @@ export async function syncUserOura(
|
||||
conn.refreshToken,
|
||||
conn.refreshTokenCiphertext,
|
||||
lookbackDays,
|
||||
conn.scopeDenied,
|
||||
);
|
||||
} catch (err) {
|
||||
// Only a whole-connection failure (a failed token refresh → reauth) reaches
|
||||
@@ -482,6 +548,20 @@ export async function syncUserOura(
|
||||
throw markSyncFailureRecorded(err);
|
||||
}
|
||||
|
||||
// Remember which collections the grant does not cover, so the settings card
|
||||
// can ask for one reconnect. Best-effort: the note is a convenience, the
|
||||
// sync outcome does not depend on it.
|
||||
if (result.scopeDenied.length > 0) {
|
||||
annotate({
|
||||
meta: { oura_scope_denied: result.scopeDenied },
|
||||
});
|
||||
}
|
||||
try {
|
||||
await syncOuraScopeDenied(userId, result.scopeDenied);
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(`oura: scope note write failed: ${err}`);
|
||||
}
|
||||
|
||||
// Clear whatever an earlier scoring left under the re-fetched sleep records
|
||||
// before the fresh set upserts (mirrors Google Health's replace-by-window
|
||||
// order). Best-effort inside the helper — a sweep failure never fails the
|
||||
|
||||
@@ -27,6 +27,7 @@ vi.mock("@/lib/db", () => {
|
||||
withingsConnection: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
$executeRaw: vi.fn(),
|
||||
$transaction: vi.fn(
|
||||
async (fn: (tx: unknown) => unknown) => await fn({ ecgRecording }),
|
||||
),
|
||||
@@ -466,3 +467,82 @@ describe("syncUserEcg", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncUserEcg — Withings API plan (status 277)", () => {
|
||||
function install277() {
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
status: 277,
|
||||
error:
|
||||
"One of the data type requested in this API request is not available in your API plan",
|
||||
}),
|
||||
}));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
/** The leg the plan-state write merged in, read off the raw SQL values. */
|
||||
function persistedLeg(): { leg: unknown; value: unknown } | null {
|
||||
const call = vi.mocked(prisma.$executeRaw).mock.calls[0];
|
||||
if (!call) return null;
|
||||
const values = call.slice(1);
|
||||
return { leg: values[0], value: JSON.parse(String(values[1])) };
|
||||
}
|
||||
|
||||
it("skips quietly on a 277: no failure, no throw, the refusal remembered", async () => {
|
||||
install277();
|
||||
const imported = await syncUserEcg("user-1");
|
||||
|
||||
expect(imported).toBe(0);
|
||||
expect(recordSyncFailure).not.toHaveBeenCalled();
|
||||
expect(recordWithingsSyncFailure).not.toHaveBeenCalled();
|
||||
expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings", {
|
||||
leg: "ecg",
|
||||
});
|
||||
expect(persistedLeg()).toEqual({
|
||||
leg: "ecg",
|
||||
value: { unavailable: ["heart"], checkedAt: expect.any(String) },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not ask the Heart endpoint again while the refusal is fresh", async () => {
|
||||
const fetchMock = install277();
|
||||
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({
|
||||
id: "connection-1",
|
||||
apiPlanState: {
|
||||
version: 1,
|
||||
ecg: { unavailable: ["heart"], checkedAt: new Date().toISOString() },
|
||||
},
|
||||
} as never);
|
||||
|
||||
const imported = await syncUserEcg("user-1");
|
||||
|
||||
expect(imported).toBe(0);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(recordSyncFailure).not.toHaveBeenCalled();
|
||||
expect(prisma.$executeRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-probes after a week and forgets the refusal when the plan now allows it", async () => {
|
||||
const fetchMock = installFetchMock([]);
|
||||
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({
|
||||
id: "connection-1",
|
||||
apiPlanState: {
|
||||
version: 1,
|
||||
ecg: {
|
||||
unavailable: ["heart"],
|
||||
checkedAt: new Date(Date.now() - 8 * 86_400_000).toISOString(),
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
await syncUserEcg("user-1");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
expect(persistedLeg()).toEqual({
|
||||
leg: "ecg",
|
||||
value: { unavailable: [], checkedAt: expect.any(String) },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,7 @@ vi.mock("@/lib/crypto", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../client", () => ({
|
||||
WITHINGS_MEASTYPES: [1, 9, 10, 11],
|
||||
fetchMeasurements: vi.fn(),
|
||||
refreshAccessToken: vi.fn(),
|
||||
subscribeWebhook: vi.fn(),
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
/**
|
||||
* Withings API plans (2026-10-12): a request naming one meastype outside the
|
||||
* account's plan is refused whole with status 277. These tests drive the real
|
||||
* measure sync and the real client against a mocked `safeFetch` that behaves
|
||||
* like the plan: any refused meastype in `meastypes` answers 277, anything
|
||||
* else answers with one reading per requested type.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: {
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
withingsConnection: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
integrationStatus: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
$executeRaw: vi.fn(),
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((v: string) => v),
|
||||
encrypt: vi.fn((v: string) => v),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/safe-fetch", () => ({
|
||||
safeFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../credentials", () => ({
|
||||
getUserWithingsCredentials: vi.fn(async () => ({
|
||||
clientId: "cid",
|
||||
clientSecret: "secret",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/integrations/status", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/lib/integrations/status")
|
||||
>("@/lib/integrations/status");
|
||||
return {
|
||||
...actual,
|
||||
isReauthRequired: vi.fn().mockResolvedValue(false),
|
||||
recordSyncFailure: vi.fn().mockResolvedValue(undefined),
|
||||
recordSyncSuccess: vi.fn().mockResolvedValue(undefined),
|
||||
resumeIntegrationFromPark: vi.fn().mockResolvedValue({ wasParked: false }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/rollups/measurement-rollups", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/lib/rollups/measurement-rollups")
|
||||
>("@/lib/rollups/measurement-rollups");
|
||||
return {
|
||||
...actual,
|
||||
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/insights/comprehensive-generate", () => ({
|
||||
invalidateStatusInsightsForTypes: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserDashboardSnapshot: vi.fn(),
|
||||
invalidateUserMeasurements: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/arrivals/measurement-emit", () => ({
|
||||
emitInsertedMeasurementArrivals: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
getEvent: vi.fn(() => ({
|
||||
addExternalCall: vi.fn(),
|
||||
addWarning: vi.fn(),
|
||||
addMeta: vi.fn(),
|
||||
toJSON: () => ({ meta: {} }),
|
||||
})),
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import {
|
||||
recordSyncFailure,
|
||||
recordSyncSuccess,
|
||||
resumeIntegrationFromPark,
|
||||
} from "@/lib/integrations/status";
|
||||
import { classifyWithingsResponse } from "../response-classifier";
|
||||
import { WithingsApiError } from "../response-classifier";
|
||||
import {
|
||||
recordWithingsSyncFailure,
|
||||
syncUserMeasurements,
|
||||
WITHINGS_INCREMENTAL_OVERLAP_MS,
|
||||
} from "../sync";
|
||||
import {
|
||||
releasePlanGatedWithingsParks,
|
||||
requestWithinPlan,
|
||||
WITHINGS_ADVANCED_MEASTYPES,
|
||||
WITHINGS_PLAN_MAX_REQUESTS,
|
||||
} from "../api-plan";
|
||||
import { withingsPlanUnavailableFeatures } from "../plan-features";
|
||||
|
||||
const LAST_GOOD_SYNC = new Date("2026-10-12T21:12:00Z");
|
||||
|
||||
/** The meastypes the free plan refuses in these tests (the documented set). */
|
||||
let refused = new Set<number>(WITHINGS_ADVANCED_MEASTYPES);
|
||||
|
||||
/** Every `meastypes` list the client sent, in order. */
|
||||
const requestedTypes: number[][] = [];
|
||||
const requestedStartdates: string[] = [];
|
||||
|
||||
function installPlanFetch() {
|
||||
vi.mocked(safeFetch).mockImplementation(async (_url, init) => {
|
||||
const params = new URLSearchParams(String(init?.body ?? ""));
|
||||
const types = (params.get("meastypes") ?? "")
|
||||
.split(",")
|
||||
.filter(Boolean)
|
||||
.map(Number);
|
||||
requestedTypes.push(types);
|
||||
requestedStartdates.push(params.get("startdate") ?? "");
|
||||
const body = types.some((t) => refused.has(t))
|
||||
? {
|
||||
status: 277,
|
||||
error:
|
||||
"One of the data type requested in this API request is not available in your API plan",
|
||||
}
|
||||
: {
|
||||
status: 0,
|
||||
body: {
|
||||
measuregrps: [
|
||||
{
|
||||
grpid: 1,
|
||||
date: Math.floor(Date.parse("2026-10-13T07:00:00Z") / 1000),
|
||||
measures: types.map((type) => ({ type, value: 800, unit: -1 })),
|
||||
},
|
||||
],
|
||||
more: 0,
|
||||
},
|
||||
};
|
||||
return { status: 200, json: async () => body } as unknown as Response;
|
||||
});
|
||||
}
|
||||
|
||||
function connection(
|
||||
apiPlanState: unknown = null,
|
||||
lastSyncedAt = LAST_GOOD_SYNC,
|
||||
) {
|
||||
return {
|
||||
id: "conn-1",
|
||||
userId: "user-1",
|
||||
withingsUserId: "wu-1",
|
||||
accessToken: "enc-access",
|
||||
refreshToken: "enc-refresh",
|
||||
tokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
lastSyncedAt,
|
||||
apiPlanState,
|
||||
};
|
||||
}
|
||||
|
||||
/** The legs the sync merged into `api_plan_state`, read off the raw SQL. */
|
||||
function persistedLegs(): Array<{ leg: unknown; value: unknown }> {
|
||||
return vi.mocked(prisma.$executeRaw).mock.calls.map((call) => ({
|
||||
leg: call[1],
|
||||
value: JSON.parse(String(call[2])),
|
||||
}));
|
||||
}
|
||||
|
||||
function createdTypes(): string[] {
|
||||
return vi
|
||||
.mocked(prisma.measurement.create)
|
||||
.mock.calls.map((c) => (c[0] as { data: { type: string } }).data.type);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
requestedTypes.length = 0;
|
||||
requestedStartdates.length = 0;
|
||||
refused = new Set<number>(WITHINGS_ADVANCED_MEASTYPES);
|
||||
installPlanFetch();
|
||||
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue(
|
||||
connection() as never,
|
||||
);
|
||||
vi.mocked(prisma.withingsConnection.update).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.measurement.create).mockImplementation((async (args: {
|
||||
data: { type: string; measuredAt: Date };
|
||||
}) => ({
|
||||
id: `m-${args.data.type}`,
|
||||
type: args.data.type,
|
||||
measuredAt: args.data.measuredAt,
|
||||
})) as never);
|
||||
vi.mocked(prisma.$executeRaw).mockResolvedValue(1 as never);
|
||||
});
|
||||
|
||||
describe("syncUserMeasurements inside the Withings API plan", () => {
|
||||
it("retries without the advanced types on a 277 and imports the core ones", async () => {
|
||||
const result = await syncUserMeasurements("user-1");
|
||||
|
||||
expect(requestedTypes).toHaveLength(2);
|
||||
expect(requestedTypes[0]).toEqual(
|
||||
expect.arrayContaining([...WITHINGS_ADVANCED_MEASTYPES, 1, 9, 10]),
|
||||
);
|
||||
expect(requestedTypes[1]!.some((t) => refused.has(t))).toBe(false);
|
||||
expect(requestedTypes[1]).toEqual(expect.arrayContaining([1, 9, 10, 11]));
|
||||
|
||||
expect(result).toEqual({ imported: expect.any(Number), failed: false });
|
||||
expect(result.imported).toBeGreaterThan(0);
|
||||
expect(createdTypes()).toEqual(
|
||||
expect.arrayContaining([
|
||||
"WEIGHT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"PULSE",
|
||||
]),
|
||||
);
|
||||
expect(createdTypes()).not.toContain("VASCULAR_AGE");
|
||||
|
||||
// A partial-by-plan run is a success, and the watermark moves on.
|
||||
expect(recordSyncFailure).not.toHaveBeenCalled();
|
||||
expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings", {
|
||||
leg: "measures",
|
||||
});
|
||||
expect(prisma.withingsConnection.update).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1" },
|
||||
data: { lastSyncedAt: expect.any(Date) },
|
||||
});
|
||||
|
||||
expect(persistedLegs()).toEqual([
|
||||
{
|
||||
leg: "measures",
|
||||
value: {
|
||||
unavailable: [...WITHINGS_ADVANCED_MEASTYPES].sort((a, b) => a - b),
|
||||
checkedAt: expect.any(String),
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips the remembered types on the next sync without a refused round trip", async () => {
|
||||
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue(
|
||||
connection({
|
||||
version: 1,
|
||||
measures: {
|
||||
unavailable: [...WITHINGS_ADVANCED_MEASTYPES],
|
||||
checkedAt: new Date().toISOString(),
|
||||
},
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const result = await syncUserMeasurements("user-1");
|
||||
|
||||
expect(requestedTypes).toHaveLength(1);
|
||||
expect(requestedTypes[0]!.some((t) => refused.has(t))).toBe(false);
|
||||
expect(result.failed).toBe(false);
|
||||
expect(prisma.$executeRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("asks for the full set again once the remembered refusal is a week old", async () => {
|
||||
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue(
|
||||
connection({
|
||||
version: 1,
|
||||
measures: {
|
||||
unavailable: [...WITHINGS_ADVANCED_MEASTYPES],
|
||||
checkedAt: new Date(Date.now() - 8 * 86_400_000).toISOString(),
|
||||
},
|
||||
}) as never,
|
||||
);
|
||||
// The account moved to a plan that allows everything.
|
||||
refused = new Set();
|
||||
|
||||
await syncUserMeasurements("user-1");
|
||||
|
||||
expect(requestedTypes).toHaveLength(1);
|
||||
expect(requestedTypes[0]).toEqual(
|
||||
expect.arrayContaining([...WITHINGS_ADVANCED_MEASTYPES]),
|
||||
);
|
||||
expect(createdTypes()).toContain("VASCULAR_AGE");
|
||||
expect(persistedLegs()).toEqual([
|
||||
{
|
||||
leg: "measures",
|
||||
value: { unavailable: [], checkedAt: expect.any(String) },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("narrows further when the reduced request is still refused", async () => {
|
||||
// An undocumented refusal on top of the documented set: fat ratio (6).
|
||||
refused = new Set([...WITHINGS_ADVANCED_MEASTYPES, 6]);
|
||||
|
||||
const result = await syncUserMeasurements("user-1");
|
||||
|
||||
expect(result.failed).toBe(false);
|
||||
expect(requestedTypes.length).toBeLessThanOrEqual(
|
||||
WITHINGS_PLAN_MAX_REQUESTS,
|
||||
);
|
||||
expect(createdTypes()).toEqual(
|
||||
expect.arrayContaining(["WEIGHT", "BLOOD_PRESSURE_SYS", "PULSE"]),
|
||||
);
|
||||
expect(createdTypes()).not.toContain("BODY_FAT");
|
||||
const [persisted] = persistedLegs();
|
||||
expect(persisted?.value).toEqual({
|
||||
unavailable: [6, ...WITHINGS_ADVANCED_MEASTYPES].sort((a, b) => a - b),
|
||||
checkedAt: expect.any(String),
|
||||
});
|
||||
expect(recordSyncFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads the whole gap since the last good sync", async () => {
|
||||
await syncUserMeasurements("user-1");
|
||||
|
||||
const expected = String(
|
||||
Math.floor(
|
||||
(LAST_GOOD_SYNC.getTime() - WITHINGS_INCREMENTAL_OVERLAP_MS) / 1000,
|
||||
),
|
||||
);
|
||||
expect(requestedStartdates.every((s) => s === expected)).toBe(true);
|
||||
});
|
||||
|
||||
it("holds the watermark and records no failure when the plan refuses everything", async () => {
|
||||
refused = new Set([
|
||||
1, 5, 6, 8, 9, 10, 11, 12, 35, 54, 71, 76, 77, 88, 91, 123, 155, 170, 73,
|
||||
]);
|
||||
|
||||
const result = await syncUserMeasurements("user-1");
|
||||
|
||||
expect(result).toEqual({ imported: 0, failed: false });
|
||||
expect(prisma.withingsConnection.update).not.toHaveBeenCalled();
|
||||
expect(recordSyncFailure).not.toHaveBeenCalled();
|
||||
expect(requestedTypes.length).toBeLessThanOrEqual(
|
||||
WITHINGS_PLAN_MAX_REQUESTS,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a 277 is never an auth failure", () => {
|
||||
it("classifies 277 as persistent, not reauth_required", () => {
|
||||
expect(classifyWithingsResponse(200, { status: 277 }).classification).toBe(
|
||||
"persistent",
|
||||
);
|
||||
// The rest of the OAuth range keeps its meaning.
|
||||
expect(classifyWithingsResponse(200, { status: 250 }).classification).toBe(
|
||||
"reauth_required",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a stray 277 off the ledger entirely", async () => {
|
||||
await recordWithingsSyncFailure(
|
||||
"user-1",
|
||||
new WithingsApiError({
|
||||
verb: "measure",
|
||||
classification: "persistent",
|
||||
withingsStatus: 277,
|
||||
reason: "withings_277",
|
||||
}),
|
||||
"activity",
|
||||
);
|
||||
expect(recordSyncFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lifts connections an earlier build parked over a 277, and only those", async () => {
|
||||
vi.mocked(prisma.integrationStatus.findMany).mockResolvedValue([
|
||||
{
|
||||
userId: "plan-user",
|
||||
lastError:
|
||||
"Withings measure error: 277 - One of the data type requested in this API request is not available in your API plan",
|
||||
},
|
||||
{ userId: "revoked-user", lastError: "Withings refresh error: 101" },
|
||||
] as never);
|
||||
|
||||
const released = await releasePlanGatedWithingsParks();
|
||||
|
||||
expect(released).toBe(1);
|
||||
expect(resumeIntegrationFromPark).toHaveBeenCalledTimes(1);
|
||||
expect(resumeIntegrationFromPark).toHaveBeenCalledWith(
|
||||
"plan-user",
|
||||
"withings",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requestWithinPlan", () => {
|
||||
const gatedError = () =>
|
||||
new WithingsApiError({
|
||||
verb: "measure",
|
||||
classification: "persistent",
|
||||
withingsStatus: 277,
|
||||
reason: "withings_277",
|
||||
});
|
||||
|
||||
it("isolates refused keys deterministically and keeps every accepted part", async () => {
|
||||
const refusedKeys = new Set(["c", "f"]);
|
||||
const calls: string[][] = [];
|
||||
const run = () =>
|
||||
requestWithinPlan({
|
||||
keys: ["a", "b", "c", "d", "e", "f", "g"],
|
||||
knownGated: [],
|
||||
fetch: async (keys: string[]) => {
|
||||
calls.push(keys);
|
||||
if (keys.some((k) => refusedKeys.has(k))) throw gatedError();
|
||||
return keys;
|
||||
},
|
||||
});
|
||||
|
||||
const first = await run();
|
||||
const firstCalls = calls.splice(0);
|
||||
const second = await run();
|
||||
|
||||
expect(first.unavailable.sort()).toEqual(["c", "f"]);
|
||||
expect(first.results.sort()).toEqual(["a", "b", "d", "e", "g"]);
|
||||
expect(first.gated).toBe(true);
|
||||
expect(first.acceptedAny).toBe(true);
|
||||
expect(calls).toEqual(firstCalls);
|
||||
expect(second.unavailable.sort()).toEqual(["c", "f"]);
|
||||
});
|
||||
|
||||
it("stops at the request budget and counts what is left as unavailable", async () => {
|
||||
const result = await requestWithinPlan({
|
||||
keys: ["a", "b", "c", "d", "e", "f", "g", "h"],
|
||||
knownGated: [],
|
||||
maxRequests: 3,
|
||||
fetch: async () => {
|
||||
throw gatedError();
|
||||
},
|
||||
});
|
||||
expect(result.requests).toBe(3);
|
||||
expect(result.unavailable.sort()).toEqual([
|
||||
"a",
|
||||
"b",
|
||||
"c",
|
||||
"d",
|
||||
"e",
|
||||
"f",
|
||||
"g",
|
||||
"h",
|
||||
]);
|
||||
expect(result.acceptedAny).toBe(false);
|
||||
});
|
||||
|
||||
it("lets any other error through untouched", async () => {
|
||||
const boom = new Error("Withings measure error: 503");
|
||||
await expect(
|
||||
requestWithinPlan({
|
||||
keys: [1, 2],
|
||||
knownGated: [],
|
||||
fetch: async () => {
|
||||
throw boom;
|
||||
},
|
||||
}),
|
||||
).rejects.toBe(boom);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withingsPlanUnavailableFeatures", () => {
|
||||
it("names each withheld reading once, in display order", () => {
|
||||
expect(
|
||||
withingsPlanUnavailableFeatures({
|
||||
version: 1,
|
||||
measures: { unavailable: [155, 35, 54, 91], checkedAt: "x" },
|
||||
sleepSummary: { unavailable: ["sleep_score"], checkedAt: "x" },
|
||||
ecg: { unavailable: ["heart"], checkedAt: "x" },
|
||||
}),
|
||||
).toEqual([
|
||||
"spo2",
|
||||
"vascularAge",
|
||||
"pulseWaveVelocity",
|
||||
"ecg",
|
||||
"sleepScore",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ vi.mock("@/lib/db", () => ({
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
withingsConnection: { findUnique: vi.fn() },
|
||||
$executeRaw: vi.fn(),
|
||||
$transaction: transactionMock,
|
||||
},
|
||||
}));
|
||||
@@ -55,6 +56,7 @@ vi.mock("@/lib/logging/context", () => ({
|
||||
addExternalCall: vi.fn(),
|
||||
addWarning: vi.fn(),
|
||||
})),
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
// v1.4.39.1 — the sleep sync now folds the persistent measurement
|
||||
@@ -733,3 +735,106 @@ describe("syncUserSleep — measurement rollup hook (v1.4.39.1)", () => {
|
||||
expect(imported).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncUserSleep — Withings API plan (status 277)", () => {
|
||||
const ADVANCED = new Set(["sleep_score", "sdnn_1", "rr_average"]);
|
||||
|
||||
/** Segments answer normally; the summary refuses any advanced field. */
|
||||
function installPlanFetch(refused: Set<string>) {
|
||||
const summaryFields: string[][] = [];
|
||||
const nightEnd = Math.floor(Date.UTC(2026, 9, 13, 6) / 1000);
|
||||
const fetchMock = vi.fn(async (_url: string, init: { body: string }) => {
|
||||
const params = new URLSearchParams(init.body);
|
||||
if (params.get("action") !== "getsummary") {
|
||||
return {
|
||||
status: 200,
|
||||
json: async () => ({ status: 0, body: { series: [] } }),
|
||||
};
|
||||
}
|
||||
const fields = (params.get("data_fields") ?? "").split(",");
|
||||
summaryFields.push(fields);
|
||||
if (fields.some((f) => refused.has(f))) {
|
||||
return { status: 200, json: async () => ({ status: 277 }) };
|
||||
}
|
||||
const data = Object.fromEntries(
|
||||
fields.map((f) => [f, f === "spo2_average" ? 95 : 58]),
|
||||
);
|
||||
return {
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
status: 0,
|
||||
body: {
|
||||
series: [
|
||||
{
|
||||
id: 9,
|
||||
startdate: nightEnd - 8 * 3600,
|
||||
enddate: nightEnd,
|
||||
data,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return summaryFields;
|
||||
}
|
||||
|
||||
it("drops the advanced fields, imports the rest and remembers the refusal", async () => {
|
||||
const summaryFields = installPlanFetch(ADVANCED);
|
||||
reconcileMock.mockResolvedValue({ status: "inserted", row: {} });
|
||||
|
||||
const imported = await syncUserSleep("user-1");
|
||||
|
||||
expect(summaryFields).toEqual([
|
||||
["hr_average", "rr_average", "sdnn_1", "spo2_average", "sleep_score"],
|
||||
["hr_average", "spo2_average"],
|
||||
]);
|
||||
expect(imported).toBe(2);
|
||||
expect(recordSyncFailure).not.toHaveBeenCalled();
|
||||
const call = vi.mocked(prisma.$executeRaw).mock.calls[0]!;
|
||||
expect(call[1]).toBe("sleepSummary");
|
||||
expect(JSON.parse(String(call[2]))).toEqual({
|
||||
unavailable: ["rr_average", "sdnn_1", "sleep_score"],
|
||||
checkedAt: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it("asks only for the allowed fields while the refusal is fresh", async () => {
|
||||
const summaryFields = installPlanFetch(ADVANCED);
|
||||
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({
|
||||
scope: "user.metrics,user.activity",
|
||||
apiPlanState: {
|
||||
version: 1,
|
||||
sleepSummary: {
|
||||
unavailable: ["sleep_score", "sdnn_1", "rr_average"],
|
||||
checkedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
reconcileMock.mockResolvedValue({ status: "inserted", row: {} });
|
||||
|
||||
await syncUserSleep("user-1");
|
||||
|
||||
expect(summaryFields).toEqual([["hr_average", "spo2_average"]]);
|
||||
expect(prisma.$executeRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not fail the leg when the stage timeline itself is refused", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({
|
||||
status: 200,
|
||||
json: async () => ({ status: 277 }),
|
||||
})),
|
||||
);
|
||||
|
||||
const imported = await syncUserSleep("user-1");
|
||||
|
||||
expect(imported).toBe(0);
|
||||
expect(recordSyncFailure).not.toHaveBeenCalled();
|
||||
expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings", {
|
||||
leg: "sleep",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* Withings API plans (since 2026-10-12).
|
||||
*
|
||||
* Withings split its public API into plans. A self-hoster's own developer app
|
||||
* sits on the free "Start for Free" plan, which carries the core biomarkers
|
||||
* (activity, sleep, body, heart). The FDA/CE-cleared biomarkers and a handful
|
||||
* of body-composition extras need the paid "Advanced Biomarkers" plan. A
|
||||
* request that names anything outside the plan is refused WHOLE with status
|
||||
* 277 ("One of the data type requested in this API request is not available in
|
||||
* your API plan") — so one gated meastype in a `getmeas` call used to stop
|
||||
* weight and blood pressure from importing too.
|
||||
*
|
||||
* This module keeps the sync inside the plan:
|
||||
*
|
||||
* - `requestWithinPlan` retries a refused request without the types Withings
|
||||
* documents as advanced, then, if it is still refused, splits what is left
|
||||
* in halves until every accepted subset has been read and every refused key
|
||||
* is isolated. Bounded by `WITHINGS_PLAN_MAX_REQUESTS`; anything the budget
|
||||
* could not resolve counts as unavailable until the next probe.
|
||||
* - `WithingsApiPlanState` remembers per request family what the plan
|
||||
* refused, so later runs skip those keys without paying a refused round
|
||||
* trip, and re-probes them weekly in case the account's plan changes.
|
||||
* - `releasePlanGatedWithingsParks` lifts connections an earlier build parked
|
||||
* at `error_reauth` over a 277. Status 277 sits inside the 200..299 OAuth
|
||||
* range the classifier used to read as a revoked grant; it is a contract
|
||||
* limit, and reconnecting does not change it.
|
||||
*
|
||||
* Which keys are advanced comes from Withings' own biomarker table
|
||||
* (https://developer.withings.com/developer-guide/v3/data-api/all-available-health-data/,
|
||||
* the "Advanced Biomarkers" column) and the plans page
|
||||
* (https://static.withings.com/content/api/plans/plans.html).
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { resumeIntegrationFromPark } from "@/lib/integrations/status";
|
||||
import { WithingsApiError } from "./response-classifier";
|
||||
|
||||
/** The Withings body status for "not available in your API plan". */
|
||||
export const WITHINGS_PLAN_GATED_STATUS = 277;
|
||||
|
||||
/** Upper bound on requests one plan probe may spend, the first one included. */
|
||||
export const WITHINGS_PLAN_MAX_REQUESTS = 12;
|
||||
|
||||
/** How long a remembered refusal is trusted before the full set is re-probed. */
|
||||
export const WITHINGS_PLAN_REPROBE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Meastypes HealthLog requests that Withings lists under the Advanced
|
||||
* Biomarkers plan: pulse-wave velocity (91), vascular age (155), visceral fat
|
||||
* (170), VO2 max (123) and skin temperature (73). Manual SpO2 (54), body
|
||||
* temperature (71), body composition and blood pressure are listed as free.
|
||||
* Types the table does not list at all (fat ratio 6, legacy temperature 12,
|
||||
* legacy SpO2 35) are left to the split, which finds out instead of guessing.
|
||||
*/
|
||||
export const WITHINGS_ADVANCED_MEASTYPES: readonly number[] = [
|
||||
91, 155, 170, 123, 73,
|
||||
];
|
||||
|
||||
/**
|
||||
* Sleep-summary fields HealthLog requests that the table marks advanced: the
|
||||
* sleep score, the overnight HRV and the respiration rate. The nightly average
|
||||
* heart rate is listed both ways (free for trackers, advanced for the Sleep
|
||||
* Analyzer) and the nightly SpO2 average is not listed, so both go to the split.
|
||||
*/
|
||||
export const WITHINGS_ADVANCED_SLEEP_FIELDS: readonly string[] = [
|
||||
"sleep_score",
|
||||
"sdnn_1",
|
||||
"rr_average",
|
||||
];
|
||||
|
||||
// ── error recognition ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* True when a caught error is Withings' plan refusal. Reads the typed error
|
||||
* first and falls back to the message shape for errors that lost their
|
||||
* prototype on a job round trip.
|
||||
*/
|
||||
export function isPlanGatedError(err: unknown): boolean {
|
||||
if (err instanceof WithingsApiError) {
|
||||
return err.withingsStatus === WITHINGS_PLAN_GATED_STATUS;
|
||||
}
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return PLAN_GATED_MESSAGE.test(msg);
|
||||
}
|
||||
|
||||
const PLAN_GATED_MESSAGE = /Withings\s+\w+\s+error:\s*277\b/;
|
||||
|
||||
// ── probing ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface WithinPlanResult<K, R> {
|
||||
/** Everything the accepted requests returned. */
|
||||
results: R[];
|
||||
/** The requested keys the plan refused (or the budget could not clear). */
|
||||
unavailable: K[];
|
||||
/** True when at least one request was refused with 277. */
|
||||
gated: boolean;
|
||||
/** True when at least one request was accepted. */
|
||||
acceptedAny: boolean;
|
||||
/** How many requests the call spent. */
|
||||
requests: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fetch` over `keys`, staying inside the account's API plan.
|
||||
*
|
||||
* The first request asks for every key. On a 277 the documented advanced keys
|
||||
* are dropped and the rest asked for again; on a further 277 the remainder is
|
||||
* split in halves, depth first and in input order, so the same refusal always
|
||||
* spends the same requests. A single key that is still refused is unavailable.
|
||||
* When the budget runs out, the subsets not yet tried count as unavailable for
|
||||
* now; the weekly re-probe asks for them again.
|
||||
*
|
||||
* Any error other than a 277 propagates unchanged: this only decides what the
|
||||
* plan allows, never whether the connection works.
|
||||
*/
|
||||
export async function requestWithinPlan<K, R>(opts: {
|
||||
keys: readonly K[];
|
||||
knownGated: readonly K[];
|
||||
fetch: (keys: K[]) => Promise<R[]>;
|
||||
maxRequests?: number;
|
||||
}): Promise<WithinPlanResult<K, R>> {
|
||||
const budget = opts.maxRequests ?? WITHINGS_PLAN_MAX_REQUESTS;
|
||||
const keys = [...opts.keys];
|
||||
const results: R[] = [];
|
||||
const unavailable: K[] = [];
|
||||
let requests = 0;
|
||||
let gated = false;
|
||||
let acceptedAny = false;
|
||||
|
||||
// One request. `true` = accepted (results kept), `false` = refused by plan.
|
||||
const attempt = async (subset: K[]): Promise<boolean> => {
|
||||
requests++;
|
||||
try {
|
||||
results.push(...(await opts.fetch(subset)));
|
||||
acceptedAny = true;
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (!isPlanGatedError(err)) throw err;
|
||||
gated = true;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if (keys.length === 0) {
|
||||
return { results, unavailable, gated, acceptedAny, requests };
|
||||
}
|
||||
if (await attempt(keys)) {
|
||||
return { results, unavailable, gated, acceptedAny, requests };
|
||||
}
|
||||
|
||||
const knownGated = new Set(opts.knownGated);
|
||||
const dropped = keys.filter((k) => knownGated.has(k));
|
||||
let remaining = keys.filter((k) => !knownGated.has(k));
|
||||
if (dropped.length > 0) {
|
||||
unavailable.push(...dropped);
|
||||
if (remaining.length === 0) {
|
||||
return { results, unavailable, gated, acceptedAny, requests };
|
||||
}
|
||||
if (requests >= budget) {
|
||||
unavailable.push(...remaining);
|
||||
return { results, unavailable, gated, acceptedAny, requests };
|
||||
}
|
||||
if (await attempt(remaining)) {
|
||||
return { results, unavailable, gated, acceptedAny, requests };
|
||||
}
|
||||
}
|
||||
|
||||
// `remaining` is known to be refused as a whole. Split it until each accepted
|
||||
// part has been read and each refused key stands alone.
|
||||
if (remaining.length === 1) {
|
||||
unavailable.push(...remaining);
|
||||
return { results, unavailable, gated, acceptedAny, requests };
|
||||
}
|
||||
const stack: K[][] = halves(remaining).reverse();
|
||||
remaining = [];
|
||||
while (stack.length > 0) {
|
||||
const subset = stack.pop()!;
|
||||
if (requests >= budget) {
|
||||
unavailable.push(...subset);
|
||||
continue;
|
||||
}
|
||||
if (await attempt(subset)) continue;
|
||||
if (subset.length === 1) {
|
||||
unavailable.push(subset[0]!);
|
||||
continue;
|
||||
}
|
||||
stack.push(...halves(subset).reverse());
|
||||
}
|
||||
|
||||
return { results, unavailable, gated, acceptedAny, requests };
|
||||
}
|
||||
|
||||
function halves<K>(list: K[]): K[][] {
|
||||
const mid = Math.ceil(list.length / 2);
|
||||
return [list.slice(0, mid), list.slice(mid)];
|
||||
}
|
||||
|
||||
// ── remembered state ───────────────────────────────────────────────────────
|
||||
|
||||
/** The request families the plan can refuse independently. */
|
||||
export type WithingsPlanLeg = "measures" | "sleepSummary" | "ecg";
|
||||
|
||||
export interface WithingsPlanLegState<K> {
|
||||
/** What the plan refused at the last probe. Empty = nothing refused. */
|
||||
unavailable: K[];
|
||||
/** When the full set was last asked for. */
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
export interface WithingsApiPlanState {
|
||||
version: 1;
|
||||
measures?: WithingsPlanLegState<number>;
|
||||
sleepSummary?: WithingsPlanLegState<string>;
|
||||
/** `unavailable` holds `"heart"` when the Heart endpoints are refused. */
|
||||
ecg?: WithingsPlanLegState<string>;
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseLeg<K>(
|
||||
value: unknown,
|
||||
isKey: (v: unknown) => v is K,
|
||||
): WithingsPlanLegState<K> | undefined {
|
||||
if (!isObject(value)) return undefined;
|
||||
const { unavailable, checkedAt } = value;
|
||||
if (!Array.isArray(unavailable) || !unavailable.every(isKey))
|
||||
return undefined;
|
||||
if (typeof checkedAt !== "string" || !Number.isFinite(Date.parse(checkedAt)))
|
||||
return undefined;
|
||||
return { unavailable: [...new Set(unavailable)], checkedAt };
|
||||
}
|
||||
|
||||
const isMeastype = (v: unknown): v is number =>
|
||||
typeof v === "number" && Number.isSafeInteger(v) && v > 0;
|
||||
const isFieldName = (v: unknown): v is string =>
|
||||
typeof v === "string" && /^[a-z0-9_]{1,64}$/.test(v);
|
||||
|
||||
/**
|
||||
* Persisted JSON is an untrusted boundary. A leg that does not validate is
|
||||
* dropped, which only means the next run asks for the full set again.
|
||||
*/
|
||||
export function parseWithingsApiPlanState(
|
||||
value: unknown,
|
||||
): WithingsApiPlanState {
|
||||
const state: WithingsApiPlanState = { version: 1 };
|
||||
if (!isObject(value) || value.version !== 1) return state;
|
||||
const measures = parseLeg(value.measures, isMeastype);
|
||||
const sleepSummary = parseLeg(value.sleepSummary, isFieldName);
|
||||
const ecg = parseLeg(value.ecg, isFieldName);
|
||||
if (measures) state.measures = measures;
|
||||
if (sleepSummary) state.sleepSummary = sleepSummary;
|
||||
if (ecg) state.ecg = ecg;
|
||||
return state;
|
||||
}
|
||||
|
||||
/** True when a remembered refusal is old enough to ask for the full set. */
|
||||
export function isPlanReprobeDue(
|
||||
leg: WithingsPlanLegState<unknown> | undefined,
|
||||
now: Date = new Date(),
|
||||
): boolean {
|
||||
if (!leg || leg.unavailable.length === 0) return false;
|
||||
return now.getTime() - Date.parse(leg.checkedAt) >= WITHINGS_PLAN_REPROBE_MS;
|
||||
}
|
||||
|
||||
/** The keys a run should leave out: the remembered refusal, unless it is due. */
|
||||
export function planSkipFor<K>(
|
||||
leg: WithingsPlanLegState<K> | undefined,
|
||||
now: Date = new Date(),
|
||||
): K[] {
|
||||
if (!leg || isPlanReprobeDue(leg, now)) return [];
|
||||
return leg.unavailable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one leg of the plan state. A single statement merges the leg into the
|
||||
* stored object, so the measures, sleep and ECG legs (separate crons, separate
|
||||
* minutes) cannot overwrite each other's memory with a stale read.
|
||||
*/
|
||||
export async function persistWithingsPlanLeg(
|
||||
userId: string,
|
||||
leg: WithingsPlanLeg,
|
||||
state: WithingsPlanLegState<number> | WithingsPlanLegState<string>,
|
||||
): Promise<void> {
|
||||
const value = JSON.stringify({
|
||||
unavailable: [...new Set<unknown>(state.unavailable)],
|
||||
checkedAt: state.checkedAt,
|
||||
});
|
||||
await prisma.$executeRaw`
|
||||
UPDATE "withings_connections"
|
||||
SET "api_plan_state" =
|
||||
COALESCE(
|
||||
CASE WHEN jsonb_typeof("api_plan_state") = 'object'
|
||||
AND "api_plan_state"->'version' = '1'::jsonb
|
||||
THEN "api_plan_state" END,
|
||||
'{}'::jsonb
|
||||
)
|
||||
|| jsonb_build_object('version', 1)
|
||||
|| jsonb_build_object(${leg}::text, ${value}::jsonb),
|
||||
"updated_at" = ${new Date()}
|
||||
WHERE "user_id" = ${userId}`;
|
||||
annotate({
|
||||
meta: {
|
||||
withings_plan: {
|
||||
leg,
|
||||
unavailable: state.unavailable.map(String),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a run's outcome is worth writing down. A run that met a 277
|
||||
* always is; so is a re-probe (the full set was asked for), whatever it found;
|
||||
* a run that skipped remembered keys and was accepted changes nothing.
|
||||
*/
|
||||
export function shouldPersistPlanLeg(opts: {
|
||||
gated: boolean;
|
||||
reprobed: boolean;
|
||||
}): boolean {
|
||||
return opts.gated || opts.reprobed;
|
||||
}
|
||||
|
||||
// ── lifting parks an earlier build left ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Lift every Withings connection parked over a plan refusal.
|
||||
*
|
||||
* Before 2026-10-14 the classifier read 277 as a revoked grant (it falls in the
|
||||
* 200..299 OAuth range), so the first refused `getmeas` parked the connection
|
||||
* at `error_reauth` and every leg stopped. Reconnecting cannot fix a plan
|
||||
* limit, so these rows are resumed instead: the next run reads inside the plan
|
||||
* and, because a refused run never advanced `lastSyncedAt`, imports everything
|
||||
* since the last good sync. The decision reads the stored error message, which
|
||||
* carries the Withings status the park was recorded with.
|
||||
*
|
||||
* Returns how many rows were lifted.
|
||||
*/
|
||||
export async function releasePlanGatedWithingsParks(): Promise<number> {
|
||||
const rows = await prisma.integrationStatus.findMany({
|
||||
where: {
|
||||
integration: "withings",
|
||||
state: "error_reauth",
|
||||
},
|
||||
select: { userId: true, lastError: true },
|
||||
});
|
||||
let released = 0;
|
||||
for (const row of rows) {
|
||||
if (!row.lastError || !parkedOverPlan(row.lastError)) continue;
|
||||
await resumeIntegrationFromPark(row.userId, "withings");
|
||||
released++;
|
||||
}
|
||||
if (released > 0) {
|
||||
annotate({ meta: { withings_plan_parks_released: released } });
|
||||
}
|
||||
return released;
|
||||
}
|
||||
|
||||
function parkedOverPlan(lastErrorCiphertext: string): boolean {
|
||||
try {
|
||||
return PLAN_GATED_MESSAGE.test(decrypt(lastErrorCiphertext));
|
||||
} catch {
|
||||
getEvent()?.addWarning("withings plan release: undecryptable lastError");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -244,17 +244,26 @@ export interface WithingsMeasureGroup {
|
||||
measures: Array<{ type: number; value: number; unit: number }>;
|
||||
}
|
||||
|
||||
/** Every meastype HealthLog maps, in ascending order. */
|
||||
export const WITHINGS_MEASTYPES: readonly number[] =
|
||||
Object.keys(MEASURE_TYPE_MAP).map(Number);
|
||||
|
||||
/**
|
||||
* Fetch measurements from Withings API.
|
||||
*
|
||||
* `measTypes` narrows the request to a subset of `MEASURE_TYPE_MAP` (the sync
|
||||
* leaves out what the account's API plan refuses); omitted, every mapped type
|
||||
* is asked for.
|
||||
*/
|
||||
export async function fetchMeasurements(
|
||||
accessToken: string,
|
||||
startDate?: Date,
|
||||
endDate?: Date,
|
||||
opts: { measTypes?: readonly number[] } = {},
|
||||
): Promise<WithingsMeasure[]> {
|
||||
const baseParams: Record<string, string> = {
|
||||
action: "getmeas",
|
||||
meastypes: Object.keys(MEASURE_TYPE_MAP).join(","),
|
||||
meastypes: (opts.measTypes ?? WITHINGS_MEASTYPES).join(","),
|
||||
};
|
||||
if (startDate) {
|
||||
baseParams.startdate = String(Math.floor(startDate.getTime() / 1000));
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* The readings a Withings API-plan refusal can withhold, as the settings card
|
||||
* names them. Pure: no database, no network, so the OpenAPI registry and the
|
||||
* client bundle can import it.
|
||||
*/
|
||||
import type { WithingsApiPlanState } from "./api-plan";
|
||||
|
||||
/** The Withings plans page, linked from the settings card and the docs. */
|
||||
export const WITHINGS_API_PLANS_URL =
|
||||
"https://static.withings.com/content/api/plans/plans.html";
|
||||
|
||||
/**
|
||||
* The closed set of reading names the Withings card can show for a plan
|
||||
* refusal, in display order. Server-resolved so every client renders the same
|
||||
* list; each has a label under `settings.integrations.withings.plan.items`.
|
||||
*/
|
||||
export const WITHINGS_PLAN_FEATURES = [
|
||||
"spo2",
|
||||
"vascularAge",
|
||||
"pulseWaveVelocity",
|
||||
"vo2Max",
|
||||
"visceralFat",
|
||||
"skinTemperature",
|
||||
"ecg",
|
||||
"sleepScore",
|
||||
"sleepHrv",
|
||||
"sleepHeartRate",
|
||||
"sleepRespiratoryRate",
|
||||
"sleepSpo2",
|
||||
"bodyTemperature",
|
||||
"bodyFat",
|
||||
"fatMass",
|
||||
"fatFreeMass",
|
||||
"muscleMass",
|
||||
"bodyWater",
|
||||
"boneMass",
|
||||
"heartRate",
|
||||
"bloodPressure",
|
||||
"weight",
|
||||
] as const;
|
||||
|
||||
export type WithingsPlanFeature = (typeof WITHINGS_PLAN_FEATURES)[number];
|
||||
|
||||
const MEASTYPE_FEATURE: Record<number, WithingsPlanFeature> = {
|
||||
1: "weight",
|
||||
5: "fatFreeMass",
|
||||
6: "bodyFat",
|
||||
8: "fatMass",
|
||||
9: "bloodPressure",
|
||||
10: "bloodPressure",
|
||||
11: "heartRate",
|
||||
12: "bodyTemperature",
|
||||
35: "spo2",
|
||||
54: "spo2",
|
||||
71: "bodyTemperature",
|
||||
73: "skinTemperature",
|
||||
76: "muscleMass",
|
||||
77: "bodyWater",
|
||||
88: "boneMass",
|
||||
91: "pulseWaveVelocity",
|
||||
123: "vo2Max",
|
||||
155: "vascularAge",
|
||||
170: "visceralFat",
|
||||
};
|
||||
|
||||
const SLEEP_FIELD_FEATURE: Record<string, WithingsPlanFeature> = {
|
||||
hr_average: "sleepHeartRate",
|
||||
rr_average: "sleepRespiratoryRate",
|
||||
sdnn_1: "sleepHrv",
|
||||
spo2_average: "sleepSpo2",
|
||||
sleep_score: "sleepScore",
|
||||
};
|
||||
|
||||
/**
|
||||
* The readings this connection's plan currently withholds, deduplicated and in
|
||||
* display order. Empty when nothing is refused.
|
||||
*/
|
||||
export function withingsPlanUnavailableFeatures(
|
||||
state: WithingsApiPlanState,
|
||||
): WithingsPlanFeature[] {
|
||||
const found = new Set<WithingsPlanFeature>();
|
||||
for (const t of state.measures?.unavailable ?? []) {
|
||||
const f = MEASTYPE_FEATURE[t];
|
||||
if (f) found.add(f);
|
||||
}
|
||||
for (const field of state.sleepSummary?.unavailable ?? []) {
|
||||
const f = SLEEP_FIELD_FEATURE[field];
|
||||
if (f) found.add(f);
|
||||
}
|
||||
if ((state.ecg?.unavailable.length ?? 0) > 0) found.add("ecg");
|
||||
return WITHINGS_PLAN_FEATURES.filter((f) => found.has(f));
|
||||
}
|
||||
@@ -100,12 +100,20 @@ const TRANSIENT_CODES = new Set<number>([503, 601, 2554, 2555, 2556]);
|
||||
* - 294 = "already subscribed" (subscribeWebhook treats it as success;
|
||||
* any OTHER endpoint receiving 294 is a contract bug)
|
||||
*
|
||||
* - 277 = "One of the data type requested in this API request is not
|
||||
* available in your API plan" (Withings API plans, 2026-10-12).
|
||||
* A contract limit on the developer app, not a revoked grant, so it
|
||||
* must never park the connection at `error_reauth` the way the
|
||||
* 200..299 range below would. The sync paths handle it before it
|
||||
* reaches the ledger (`src/lib/withings/api-plan.ts`); this entry is
|
||||
* the floor for any path that does not.
|
||||
*
|
||||
* Note: 294 is intentionally listed here. `subscribeWebhook` overrides
|
||||
* the classification for its own call site (already-subscribed is
|
||||
* idempotent there); every other client call that sees 294 surfaces it
|
||||
* as a persistent failure so the bug doesn't hide.
|
||||
*/
|
||||
const PERSISTENT_CODES = new Set<number>([293, 294]);
|
||||
const PERSISTENT_CODES = new Set<number>([277, 293, 294]);
|
||||
|
||||
/**
|
||||
* Classify a single Withings API response.
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
getValidToken,
|
||||
recordWithingsSyncFailure,
|
||||
} from "./sync";
|
||||
import { isPlanGatedError } from "./api-plan";
|
||||
import {
|
||||
isReauthRequired,
|
||||
parkIntegrationAtReauth,
|
||||
@@ -298,6 +299,19 @@ export async function syncUserActivity(
|
||||
// (Withings emits 403 only through the HTTP layer for scope-gated
|
||||
// resources), so the override has to live at the call site rather
|
||||
// than in the shared classifier.
|
||||
//
|
||||
// Steps, distance and calories are free-plan biomarkers, so a plan refusal
|
||||
// (277) is not expected. If Withings ever moves one, the leg reads nothing
|
||||
// rather than failing, so a plan limit cannot park every Withings leg.
|
||||
if (isPlanGatedError(err)) {
|
||||
getEvent()?.addWarning(
|
||||
"withings activity: refused by the API plan (277), skipped",
|
||||
);
|
||||
await recordSyncSuccess(userId, "withings", {
|
||||
leg: WITHINGS_LEG_ACTIVITY,
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
const withingsStatus =
|
||||
err instanceof WithingsApiError ? err.withingsStatus : undefined;
|
||||
if (withingsStatus === 403) {
|
||||
|
||||
@@ -69,6 +69,12 @@ import {
|
||||
getValidToken,
|
||||
recordWithingsSyncFailure,
|
||||
} from "./sync";
|
||||
import {
|
||||
isPlanGatedError,
|
||||
isPlanReprobeDue,
|
||||
parseWithingsApiPlanState,
|
||||
persistWithingsPlanLeg,
|
||||
} from "./api-plan";
|
||||
import {
|
||||
isReauthRequired,
|
||||
recordSyncFailure,
|
||||
@@ -370,6 +376,22 @@ export async function syncUserEcg(
|
||||
? Math.max(defaultEndUnix, options.enddate!)
|
||||
: defaultEndUnix;
|
||||
|
||||
// Withings API plans (2026-10-12): ECG signals and the AFib verdicts are
|
||||
// Advanced Biomarkers. On the free plan the Heart endpoints answer 277; that
|
||||
// is remembered, and the leg stays quiet until the weekly re-probe asks again.
|
||||
const planRow = await prisma.withingsConnection.findUnique({
|
||||
where: { userId },
|
||||
select: { apiPlanState: true },
|
||||
});
|
||||
const ecgPlan = parseWithingsApiPlanState(planRow?.apiPlanState).ecg;
|
||||
const ecgRefused = (ecgPlan?.unavailable.length ?? 0) > 0;
|
||||
const ecgReprobe = isPlanReprobeDue(ecgPlan, now);
|
||||
if (ecgRefused && !ecgReprobe) {
|
||||
annotate({ meta: { withings_ecg_skipped: "api_plan" } });
|
||||
await recordSyncSuccess(userId, "withings", { leg: WITHINGS_LEG_ECG });
|
||||
return 0;
|
||||
}
|
||||
|
||||
let entries: WithingsHeartEntry[];
|
||||
try {
|
||||
entries = await fetchWithingsHeartList(
|
||||
@@ -378,6 +400,12 @@ export async function syncUserEcg(
|
||||
endUnix,
|
||||
);
|
||||
} catch (err) {
|
||||
if (isPlanGatedError(err)) {
|
||||
await rememberEcgPlan(userId, ["heart"], now);
|
||||
annotate({ meta: { withings_ecg_skipped: "api_plan" } });
|
||||
await recordSyncSuccess(userId, "withings", { leg: WITHINGS_LEG_ECG });
|
||||
return 0;
|
||||
}
|
||||
// The Heart List endpoint rides the user.metrics scope, so a 403 here is a
|
||||
// genuine token-revoked / reauth case rather than the activity-scope skip
|
||||
// the sleep/activity syncs guard against.
|
||||
@@ -398,6 +426,9 @@ export async function syncUserEcg(
|
||||
throw err;
|
||||
}
|
||||
|
||||
// A re-probe the plan now accepts: forget the refusal.
|
||||
if (ecgRefused) await rememberEcgPlan(userId, [], now);
|
||||
|
||||
let imported = 0;
|
||||
let waveformsCaptured = 0;
|
||||
const touched: Array<{ type: MeasurementType; measuredAt: Date }> = [];
|
||||
@@ -520,3 +551,18 @@ export async function syncUserEcg(
|
||||
await recordSyncSuccess(userId, "withings", { leg: WITHINGS_LEG_ECG });
|
||||
return imported;
|
||||
}
|
||||
|
||||
async function rememberEcgPlan(
|
||||
userId: string,
|
||||
unavailable: string[],
|
||||
now: Date,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await persistWithingsPlanLeg(userId, "ecg", {
|
||||
unavailable,
|
||||
checkedAt: now.toISOString(),
|
||||
});
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(`withings ecg: plan state write failed: ${err}`);
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@
|
||||
import type { MeasurementType, SleepStage } from "@/generated/prisma/client";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import { getUnitForType } from "@/lib/validations/measurement";
|
||||
import {
|
||||
@@ -60,6 +60,16 @@ import {
|
||||
} from "@/lib/measurements/reconcile-external-measurement";
|
||||
|
||||
import { hasActivityScope } from "./client";
|
||||
import {
|
||||
isPlanGatedError,
|
||||
isPlanReprobeDue,
|
||||
parseWithingsApiPlanState,
|
||||
persistWithingsPlanLeg,
|
||||
planSkipFor,
|
||||
requestWithinPlan,
|
||||
shouldPersistPlanLeg,
|
||||
WITHINGS_ADVANCED_SLEEP_FIELDS,
|
||||
} from "./api-plan";
|
||||
import {
|
||||
WithingsApiError,
|
||||
classifyWithingsResponse,
|
||||
@@ -94,8 +104,13 @@ const WITHINGS_SLEEP_URL = "https://wbsapi.withings.net/v2/sleep";
|
||||
* requested; snoring / breathing-disturbance intensity have no enum and are
|
||||
* deliberately left out (no home to store them).
|
||||
*/
|
||||
const SLEEP_SUMMARY_DATA_FIELDS =
|
||||
"hr_average,rr_average,sdnn_1,spo2_average,sleep_score";
|
||||
export const SLEEP_SUMMARY_DATA_FIELDS: readonly string[] = [
|
||||
"hr_average",
|
||||
"rr_average",
|
||||
"sdnn_1",
|
||||
"spo2_average",
|
||||
"sleep_score",
|
||||
];
|
||||
|
||||
/**
|
||||
* One nightly summary row from `POST /v2/sleep?action=getsummary`. The vitals
|
||||
@@ -276,12 +291,13 @@ export async function fetchWithingsSleepSummary(
|
||||
accessToken: string,
|
||||
startymd: string,
|
||||
endymd: string,
|
||||
dataFields: readonly string[] = SLEEP_SUMMARY_DATA_FIELDS,
|
||||
): Promise<WithingsSleepSummary[]> {
|
||||
const params = new URLSearchParams({
|
||||
action: "getsummary",
|
||||
startdateymd: startymd,
|
||||
enddateymd: endymd,
|
||||
data_fields: SLEEP_SUMMARY_DATA_FIELDS,
|
||||
data_fields: dataFields.join(","),
|
||||
});
|
||||
|
||||
const pageStart = performance.now();
|
||||
@@ -384,6 +400,15 @@ export async function syncUserSleep(
|
||||
endUnix,
|
||||
);
|
||||
} catch (err) {
|
||||
// The stage timeline is a free-plan biomarker, so a plan refusal here is
|
||||
// not expected. If Withings ever moves it, the leg reads nothing rather
|
||||
// than failing: a 277 must not climb the strike ladder and park the
|
||||
// measures, activity and ECG legs along with it.
|
||||
if (isPlanGatedError(err)) {
|
||||
annotate({ meta: { withings_sleep_segments_skipped: "api_plan" } });
|
||||
await recordSyncSuccess(userId, "withings", { leg: WITHINGS_LEG_SLEEP });
|
||||
return 0;
|
||||
}
|
||||
// v1.4.43 W7-B3 — typed-classification path (see sync-activity.ts
|
||||
// for the full rationale). The thrown `WithingsApiError` carries
|
||||
// the classification verdict; `recordWithingsSyncFailure` routes
|
||||
@@ -513,13 +538,40 @@ export async function syncUserSleep(
|
||||
// v1.18.10 P0 — nightly sleep vitals (avg HR / respiratory rate / SDNN HRV
|
||||
// / avg SpO2 / sleep score) from the per-night summary. Fetch failure stays
|
||||
// isolated from the stage writes; database failures are hard sync failures.
|
||||
//
|
||||
// Withings API plans (2026-10-12): the sleep score, the overnight HRV and the
|
||||
// respiration rate are Advanced Biomarkers, and one of them in `data_fields`
|
||||
// has the whole summary refused with 277. Ask only for what the plan allows,
|
||||
// remember what it refused, and re-probe weekly.
|
||||
let summaries: WithingsSleepSummary[] = [];
|
||||
try {
|
||||
summaries = await fetchWithingsSleepSummary(
|
||||
tokenInfo.accessToken,
|
||||
ymdUtc(start),
|
||||
ymdUtc(now),
|
||||
);
|
||||
const planRow = await prisma.withingsConnection.findUnique({
|
||||
where: { userId },
|
||||
select: { apiPlanState: true },
|
||||
});
|
||||
const sleepPlan = parseWithingsApiPlanState(
|
||||
planRow?.apiPlanState,
|
||||
).sleepSummary;
|
||||
const reprobed = isPlanReprobeDue(sleepPlan, now);
|
||||
const skip = planSkipFor(sleepPlan, now);
|
||||
const plan = await requestWithinPlan({
|
||||
keys: SLEEP_SUMMARY_DATA_FIELDS.filter((f) => !skip.includes(f)),
|
||||
knownGated: WITHINGS_ADVANCED_SLEEP_FIELDS,
|
||||
fetch: (fields) =>
|
||||
fetchWithingsSleepSummary(
|
||||
tokenInfo.accessToken,
|
||||
ymdUtc(start),
|
||||
ymdUtc(now),
|
||||
fields,
|
||||
),
|
||||
});
|
||||
summaries = plan.results;
|
||||
if (shouldPersistPlanLeg({ gated: plan.gated, reprobed })) {
|
||||
await persistWithingsPlanLeg(userId, "sleepSummary", {
|
||||
unavailable: [...skip, ...plan.unavailable],
|
||||
checkedAt: now.toISOString(),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(
|
||||
`withings sleep: summary fetch failed for ${userId}: ${err}`,
|
||||
|
||||
@@ -10,7 +10,21 @@ import {
|
||||
fetchMeasurements,
|
||||
refreshAccessToken,
|
||||
subscribeWebhook,
|
||||
WITHINGS_MEASTYPES,
|
||||
type WithingsMeasure,
|
||||
} from "./client";
|
||||
import {
|
||||
isPlanGatedError,
|
||||
isPlanReprobeDue,
|
||||
parseWithingsApiPlanState,
|
||||
persistWithingsPlanLeg,
|
||||
planSkipFor,
|
||||
requestWithinPlan,
|
||||
shouldPersistPlanLeg,
|
||||
WITHINGS_ADVANCED_MEASTYPES,
|
||||
WITHINGS_PLAN_GATED_STATUS,
|
||||
type WithinPlanResult,
|
||||
} from "./api-plan";
|
||||
import { getUserWithingsCredentials } from "./credentials";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { isP2002 } from "@/lib/prisma-errors";
|
||||
@@ -233,13 +247,66 @@ export async function syncUserMeasurements(
|
||||
)
|
||||
: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30 days back
|
||||
|
||||
let fetched: Awaited<ReturnType<typeof fetchMeasurements>>;
|
||||
// Withings API plans (2026-10-12): a request naming one meastype outside the
|
||||
// account's plan is refused whole with 277. Leave out what the plan refused
|
||||
// last time (unless the weekly re-probe is due), and let `requestWithinPlan`
|
||||
// narrow the request if the plan refuses something new. A refused run never
|
||||
// reaches the watermark stamp below, so the run after this fix reads the
|
||||
// whole gap since the last good sync.
|
||||
const now = new Date();
|
||||
const planState = parseWithingsApiPlanState(connection.apiPlanState);
|
||||
const reprobed = isPlanReprobeDue(planState.measures, now);
|
||||
const skip = planSkipFor(planState.measures, now);
|
||||
const requested = WITHINGS_MEASTYPES.filter((t) => !skip.includes(t));
|
||||
|
||||
let plan: WithinPlanResult<number, WithingsMeasure>;
|
||||
try {
|
||||
fetched = await fetchMeasurements(tokenInfo.accessToken, startDate);
|
||||
plan = await requestWithinPlan({
|
||||
keys: requested,
|
||||
knownGated: WITHINGS_ADVANCED_MEASTYPES,
|
||||
fetch: (measTypes) =>
|
||||
fetchMeasurements(tokenInfo.accessToken, startDate, undefined, {
|
||||
measTypes,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
await recordWithingsSyncFailure(userId, err, WITHINGS_LEG_MEASURES);
|
||||
throw err;
|
||||
}
|
||||
if (shouldPersistPlanLeg({ gated: plan.gated, reprobed })) {
|
||||
try {
|
||||
await persistWithingsPlanLeg(userId, "measures", {
|
||||
unavailable: [...skip, ...plan.unavailable].sort((a, b) => a - b),
|
||||
checkedAt: now.toISOString(),
|
||||
});
|
||||
} catch (err) {
|
||||
// Losing the memory only costs a refused round trip next run.
|
||||
getEvent()?.addWarning(`withings: plan state write failed: ${err}`);
|
||||
}
|
||||
}
|
||||
if (plan.gated) {
|
||||
annotate({
|
||||
action: {
|
||||
name: "withings.sync.plan_gated",
|
||||
details: {
|
||||
unavailable: plan.unavailable,
|
||||
requests: plan.requests,
|
||||
acceptedAny: plan.acceptedAny,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (plan.gated && !plan.acceptedAny) {
|
||||
// The plan refused every type asked for. Nothing was read, so the
|
||||
// watermark stays where it is (a later plan change re-reads the gap), and
|
||||
// nothing failed: the connection is healthy, the plan is the limit. The
|
||||
// settings card names what the plan withholds.
|
||||
await recordSyncSuccess(userId, "withings", {
|
||||
leg: WITHINGS_LEG_MEASURES,
|
||||
});
|
||||
return { imported: 0, failed: false };
|
||||
}
|
||||
const fetched = plan.results;
|
||||
|
||||
// The app's own plausibility band applies to a provider reading exactly as
|
||||
// it applies to a typed one. A remote that reports a pulse in the millions
|
||||
@@ -470,6 +537,15 @@ export async function recordWithingsSyncFailure(
|
||||
err: unknown,
|
||||
leg: WithingsSyncLeg,
|
||||
): Promise<void> {
|
||||
// A plan refusal (277) is a limit on the developer app, not a failure of the
|
||||
// connection. It never enters the ledger, so no path that forgets to handle
|
||||
// it can climb the strike ladder or park every leg over it.
|
||||
if (isPlanGatedError(err)) {
|
||||
annotate({
|
||||
meta: { withings_plan_gated_unhandled: { leg } },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await recordSyncFailure({
|
||||
userId,
|
||||
@@ -531,6 +607,8 @@ export function isWithingsRefreshReauthFailure(message: string): boolean {
|
||||
const n = Number.parseInt(status, 10);
|
||||
if (!Number.isFinite(n)) return false;
|
||||
if (n === 100 || n === 101 || n === 102) return true;
|
||||
// 277 is the API-plan refusal: a limit on the developer app, not a grant.
|
||||
if (n === WITHINGS_PLAN_GATED_STATUS) return false;
|
||||
if (n >= 200 && n <= 299) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user