diff --git a/prisma/migrations/0398_integration_plan_and_scope_state/migration.sql b/prisma/migrations/0398_integration_plan_and_scope_state/migration.sql new file mode 100644 index 000000000..7a889de3e --- /dev/null +++ b/prisma/migrations/0398_integration_plan_and_scope_state/migration.sql @@ -0,0 +1,18 @@ +-- v1.42.3 — remember what an integration's upstream refuses by contract. +-- +-- Withings split its public API into plans on 2026-10-12. A self-hoster's own +-- developer app sits on the free plan, and a request that names a meastype +-- outside that plan is refused whole with status 277. The sync now drops the +-- gated types and keeps the rest; this column remembers which ones per +-- connection so later runs skip them without a refused round trip, plus when +-- that was last checked so a plan upgrade is noticed within a week. +-- +-- Oura's current authorization server enforces scopes per collection. A grant +-- issued before HealthLog asked for `spo2` and `heart_health` answers 401 on +-- the collections behind them while every other collection succeeds. This +-- column holds those collection names so the Oura card can ask for a single +-- reconnect instead of painting the connection as broken. +-- +-- Additive only: two nullable columns, no default, no rewrite, no data change. +ALTER TABLE "withings_connections" ADD COLUMN IF NOT EXISTS "api_plan_state" JSONB; +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "oura_scope_denied" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 20b178b71..9925b8ecf 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -264,6 +264,13 @@ model User { // `src/lib/crypto.ts`, matching every other `*Encrypted` column. ouraClientIdEncrypted String? @map("oura_client_id_encrypted") ouraClientSecretEncrypted String? @map("oura_client_secret_encrypted") + /// v1.42.3 — Oura collections the current grant does not cover, as a JSON + /// string array of collection names (`spo2`, `vo2max`, `cardio_age`, + /// `resilience`). Written by the sync when a collection answers 401/403 while + /// its siblings succeed with the same token, which is a scope the grant never + /// carried, not a dead token. The Oura card reads it to ask for one + /// reconnect. Cleared on connect, disconnect and credential removal. + ouraScopeDenied Json? @map("oura_scope_denied") // v1.28.x — Strava OAuth credentials (per-user BYO-app, encrypted at app // level). Strava caps every newly-created API app at single-player mode @@ -4149,6 +4156,13 @@ model WithingsConnection { /// earliest transient/pending category retry for indexed hourly discovery. webhookSubscriptionState Json? @map("webhook_subscription_state") webhookSubscriptionRetryAt DateTime? @map("webhook_subscription_retry_at") + /// v1.42.3 — what this connection's Withings API plan refuses (status 277), + /// per request family: `{ measures, sleepSummary, ecg }`, each + /// `{ unavailable, checkedAt }`. Lets the sync skip gated meastypes without + /// paying a refused round trip each time, re-probe them weekly, and lets the + /// Withings card name what the plan withholds. Versioned and validated on + /// read (`src/lib/withings/api-plan.ts`); reset on reconnect. + apiPlanState Json? @map("api_plan_state") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/src/app/api/withings/callback/route.ts b/src/app/api/withings/callback/route.ts index ffabfb882..122d8b6af 100644 --- a/src/app/api/withings/callback/route.ts +++ b/src/app/api/withings/callback/route.ts @@ -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, diff --git a/src/lib/jobs/reminder/withings-sync.ts b/src/lib/jobs/reminder/withings-sync.ts index 4d36d02bb..c80d322c6 100644 --- a/src/lib/jobs/reminder/withings-sync.ts +++ b/src/lib/jobs/reminder/withings-sync.ts @@ -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 }, }); diff --git a/src/lib/withings/__tests__/sync-measure-watermark.test.ts b/src/lib/withings/__tests__/sync-measure-watermark.test.ts index e094d9af4..c929b7633 100644 --- a/src/lib/withings/__tests__/sync-measure-watermark.test.ts +++ b/src/lib/withings/__tests__/sync-measure-watermark.test.ts @@ -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(), diff --git a/src/lib/withings/api-plan.ts b/src/lib/withings/api-plan.ts new file mode 100644 index 000000000..75453a0cd --- /dev/null +++ b/src/lib/withings/api-plan.ts @@ -0,0 +1,456 @@ +/** + * 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", +]; + +/** 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"; + +// ── 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 { + /** 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(opts: { + keys: readonly K[]; + knownGated: readonly K[]; + fetch: (keys: K[]) => Promise; + maxRequests?: number; +}): Promise> { + 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 => { + 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(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 { + /** 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; + sleepSummary?: WithingsPlanLegState; + /** `unavailable` holds `"heart"` when the Heart endpoints are refused. */ + ecg?: WithingsPlanLegState; +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseLeg( + value: unknown, + isKey: (v: unknown) => v is K, +): WithingsPlanLegState | 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 | 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( + leg: WithingsPlanLegState | 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 | WithingsPlanLegState, +): Promise { + const value = JSON.stringify({ + unavailable: [...new Set(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" = NOW() + 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; +} + +// ── what the settings card names ─────────────────────────────────────────── + +/** + * 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 = { + 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 = { + 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(); + 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)); +} + +// ── 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 { + 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; + } +} diff --git a/src/lib/withings/client.ts b/src/lib/withings/client.ts index e14cb0f23..cdd763601 100644 --- a/src/lib/withings/client.ts +++ b/src/lib/withings/client.ts @@ -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 { const baseParams: Record = { 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)); diff --git a/src/lib/withings/response-classifier.ts b/src/lib/withings/response-classifier.ts index 448d48047..cff9e84dc 100644 --- a/src/lib/withings/response-classifier.ts +++ b/src/lib/withings/response-classifier.ts @@ -100,12 +100,20 @@ const TRANSIENT_CODES = new Set([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([293, 294]); +const PERSISTENT_CODES = new Set([277, 293, 294]); /** * Classify a single Withings API response. diff --git a/src/lib/withings/sync-activity.ts b/src/lib/withings/sync-activity.ts index 18ca9c4b5..49a92cc29 100644 --- a/src/lib/withings/sync-activity.ts +++ b/src/lib/withings/sync-activity.ts @@ -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) { diff --git a/src/lib/withings/sync-ecg.ts b/src/lib/withings/sync-ecg.ts index 5213bbb83..0e6812e46 100644 --- a/src/lib/withings/sync-ecg.ts +++ b/src/lib/withings/sync-ecg.ts @@ -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 { + try { + await persistWithingsPlanLeg(userId, "ecg", { + unavailable, + checkedAt: now.toISOString(), + }); + } catch (err) { + getEvent()?.addWarning(`withings ecg: plan state write failed: ${err}`); + } +} diff --git a/src/lib/withings/sync-sleep.ts b/src/lib/withings/sync-sleep.ts index db58a44b9..0f7609221 100644 --- a/src/lib/withings/sync-sleep.ts +++ b/src/lib/withings/sync-sleep.ts @@ -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 { 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}`, diff --git a/src/lib/withings/sync.ts b/src/lib/withings/sync.ts index 8ec95fdd4..67392e7b6 100644 --- a/src/lib/withings/sync.ts +++ b/src/lib/withings/sync.ts @@ -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>; + // 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; 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 { + // 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; }