mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
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:
1 parent
39b0cabb43
commit
044f9008ba
29 files changed
+824
-101
No files matched your search
@@ -35,7 +35,19 @@ import {
|
||||
resolveSyncVerdict,
|
||||
type SyncHealth,
|
||||
} from "@/lib/integrations/sync-verdict";
|
||||
import { getOuraClientCredentials } from "@/lib/oura/credentials";
|
||||
import {
|
||||
getOuraClientCredentials,
|
||||
readOuraScopeDenied,
|
||||
} from "@/lib/oura/credentials";
|
||||
import {
|
||||
ouraScopeReconnectList,
|
||||
type OuraScopeGatedCollection,
|
||||
} from "@/lib/oura/scope";
|
||||
import { parseWithingsApiPlanState } from "@/lib/withings/api-plan";
|
||||
import {
|
||||
withingsPlanUnavailableFeatures,
|
||||
type WithingsPlanFeature,
|
||||
} from "@/lib/withings/plan-features";
|
||||
import { getPolarClientCredentials } from "@/lib/polar/credentials";
|
||||
import { getStravaClientCredentials } from "@/lib/strava/credentials";
|
||||
import { hasActivityScope } from "@/lib/withings/client";
|
||||
@@ -92,6 +104,7 @@ export const GET = apiHandler(async () => {
|
||||
ouraAccessTokenEncrypted: true,
|
||||
ouraClientIdEncrypted: true,
|
||||
ouraClientSecretEncrypted: true,
|
||||
ouraScopeDenied: true,
|
||||
stravaAccessTokenEncrypted: true,
|
||||
stravaClientIdEncrypted: true,
|
||||
stravaClientSecretEncrypted: true,
|
||||
@@ -109,6 +122,7 @@ export const GET = apiHandler(async () => {
|
||||
lastSyncedAt: true,
|
||||
createdAt: true,
|
||||
scope: true,
|
||||
apiPlanState: true,
|
||||
},
|
||||
}),
|
||||
prisma.whoopConnection.findUnique({
|
||||
@@ -280,6 +294,13 @@ export const GET = apiHandler(async () => {
|
||||
// Null `scope` = legacy connection that predates activity-scope reads.
|
||||
scope: withingsConn?.scope ?? null,
|
||||
hasActivityScope: hasActivityScope(withingsConn?.scope ?? null),
|
||||
// What this connection's Withings API plan withholds (status 277),
|
||||
// resolved here so the card only renders it.
|
||||
planUnavailable: withingsConn
|
||||
? withingsPlanUnavailableFeatures(
|
||||
parseWithingsApiPlanState(withingsConn.apiPlanState),
|
||||
)
|
||||
: [],
|
||||
...resolve("withings", withingsStatus, {
|
||||
connected: !!withingsConn,
|
||||
configured: withingsConfigured,
|
||||
@@ -374,11 +395,17 @@ export const GET = apiHandler(async () => {
|
||||
hasOwnCredentials:
|
||||
!!dbUser?.ouraClientIdEncrypted &&
|
||||
!!dbUser?.ouraClientSecretEncrypted,
|
||||
// Collections the stored grant does not cover; one reconnect asks for
|
||||
// the missing scopes. Not a failure, so it never colours the pill.
|
||||
scopeReconnect: ouraConnected
|
||||
? ouraScopeReconnectList(readOuraScopeDenied(dbUser?.ouraScopeDenied))
|
||||
: [],
|
||||
...resolve("oura", ouraStatus, {
|
||||
connected: ouraConnected,
|
||||
configured: ouraConnected,
|
||||
}),
|
||||
} satisfies IntegrationViewModel & OAuthProviderExtras,
|
||||
} satisfies IntegrationViewModel &
|
||||
OAuthProviderExtras & { scopeReconnect: OuraScopeGatedCollection[] },
|
||||
{
|
||||
...publish(stravaStatus),
|
||||
connected: stravaConnected,
|
||||
@@ -442,6 +469,7 @@ interface WithingsExtras {
|
||||
tokenExpired: boolean | null;
|
||||
scope: string | null;
|
||||
hasActivityScope: boolean;
|
||||
planUnavailable: WithingsPlanFeature[];
|
||||
}
|
||||
|
||||
interface WhoopExtras {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
@@ -87,6 +88,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
data: {
|
||||
ouraAccessTokenEncrypted: encrypt(tokens.access_token),
|
||||
ouraRefreshTokenEncrypted: encrypt(tokens.refresh_token),
|
||||
// A new grant carries the scopes asked for now; whatever the old one
|
||||
// lacked is found out again by the first sync.
|
||||
ouraScopeDenied: Prisma.DbNull,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiSuccess,
|
||||
@@ -96,6 +97,7 @@ export const DELETE = apiHandler(async () => {
|
||||
data: {
|
||||
ouraAccessTokenEncrypted: null,
|
||||
ouraRefreshTokenEncrypted: null,
|
||||
ouraScopeDenied: Prisma.DbNull,
|
||||
},
|
||||
});
|
||||
await clearOuraClientCredentials(user.id);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
@@ -37,6 +38,7 @@ export const POST = apiHandler(async () => {
|
||||
data: {
|
||||
ouraAccessTokenEncrypted: null,
|
||||
ouraRefreshTokenEncrypted: null,
|
||||
ouraScopeDenied: Prisma.DbNull,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user