fix(oura): ask for the spo2 and heart_health scopes, explain a missing scope calmly

Oura's current authorization server enforces scopes per collection.
daily_spo2 needs spo2; vO2_max, daily_cardiovascular_age and
daily_resilience need heart_health. The authorize request now asks for
both.

A grant issued before keeps answering 401 on those four collections
while every other one succeeds. Such a 401 next to a success is now
recorded as a scope gap rather than a failure: it no longer counts
toward the reconnect threshold, it no longer forces a token refresh once
known, and the Oura card asks for one reconnect instead. The Withings
card names the readings the account's API plan withholds.
This commit is contained in:
Marc-André Bombeck committed 2026-10-14 19:01:02 +02:00
1 parent 39b0cabb43
commit 044f9008ba
29 files changed
+824 -101

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",
+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,
},
});
@@ -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",
+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",
]);
});
});
+4
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({}));
+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
-88
View File
@@ -69,10 +69,6 @@ export const WITHINGS_ADVANCED_SLEEP_FIELDS: readonly string[] = [
"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 ──────────────────────────────────────────────────────
/**
@@ -327,90 +323,6 @@ export function shouldPersistPlanLeg(opts: {
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<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));
}
// ── lifting parks an earlier build left ──────────────────────────────────────
/**
+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));
}