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

+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,
},
});