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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -439,6 +439,7 @@ export const USER_RESET = {
|
||||
ouraRefreshTokenEncrypted: null,
|
||||
ouraClientIdEncrypted: null,
|
||||
ouraClientSecretEncrypted: null,
|
||||
ouraScopeDenied: Prisma.DbNull,
|
||||
stravaClientIdEncrypted: null,
|
||||
stravaClientSecretEncrypted: null,
|
||||
stravaAccessTokenEncrypted: null,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."),
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user