Merge branch 'v1423/integrations' into release/v1.42.3

This commit is contained in:
Marc-André Bombeck committed 2026-10-14 19:20:16 +02:00
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([]);
});
});
+30 -2
View File
@@ -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 {
+4
View File
@@ -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,
},
});
+2
View File
@@ -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);
+2
View File
@@ -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,
},
});
+3
View File
@@ -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>
);
}
+1
View File
@@ -439,6 +439,7 @@ export const USER_RESET = {
ouraRefreshTokenEncrypted: null,
ouraClientIdEncrypted: null,
ouraClientSecretEncrypted: null,
ouraScopeDenied: Prisma.DbNull,
stravaClientIdEncrypted: null,
stravaClientSecretEncrypted: null,
stravaAccessTokenEncrypted: null,
+3
View File
@@ -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",
+10
View File
@@ -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 },
});
+14
View File
@@ -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."),
})
+7 -2
View File
@@ -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",
]);
});
});
+85
View File
@@ -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
View File
@@ -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,
+39
View File
@@ -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 },
});
}
+23
View File
@@ -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
View File
@@ -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",
});
});
});
+368
View File
@@ -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;
}
}
+10 -1
View File
@@ -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));
+92
View File
@@ -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));
}
+9 -1
View File
@@ -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.
+14
View File
@@ -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) {
+46
View File
@@ -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}`);
}
}
+61 -9
View File
@@ -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}`,
+80 -2
View File
@@ -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;
}