From 55ddad3d6aa84d66547c69b4e032ad9de246720a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 14 Oct 2026 19:47:05 +0200 Subject: [PATCH] fix(oura): read a 401 as a scope gap only on the scope-gated collections A 401 on any collection next to a success was recorded as a scope gap, so a real auth failure on sleep, readiness or activity was hidden from the failure ledger and shown as a reconnect hint. Only spo2, vo2max, cardio_age and resilience sit behind a scope beyond daily; a 401 anywhere else keeps its normal failure handling, and a stored note naming another collection no longer skips the token refresh. --- src/lib/oura/__tests__/sync.test.ts | 44 +++++++++++++++++++++++++++++ src/lib/oura/sync.ts | 36 +++++++++++++++-------- 2 files changed, 69 insertions(+), 11 deletions(-) diff --git a/src/lib/oura/__tests__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts index 4f819d2e1..00d4c4c0f 100644 --- a/src/lib/oura/__tests__/sync.test.ts +++ b/src/lib/oura/__tests__/sync.test.ts @@ -950,6 +950,50 @@ describe("syncUserOura — a grant without the spo2 / heart_health scopes", () = expect(recordSuccessMock).toHaveBeenCalledWith("u1", "oura"); }); + it("keeps a 401 on a core collection a failure even when siblings succeed", async () => { + getConnMock.mockResolvedValue(CONN); + fetchReadinessMock.mockResolvedValue([ + { id: "x", day: "2026-06-10", score: 70 }, + ]); + fetchSleepMock.mockRejectedValue(err401()); + refreshMock.mockResolvedValue({ + access_token: "newAcc", + refresh_token: "newRef", + expires_in: 86400, + }); + + const result = await syncUserOura("u1"); + + expect(refreshMock).toHaveBeenCalledTimes(1); + expect(result.failed).toBe(true); + expect(scopeDeniedMock).not.toHaveBeenCalledWith( + "u1", + expect.arrayContaining(["sleep"]), + ); + expect(recordFailureMock).toHaveBeenCalledWith( + expect.objectContaining({ integration: "oura" }), + ); + expect(recordSuccessMock).not.toHaveBeenCalled(); + }); + + it("does not let a stored core name skip the refresh", async () => { + getConnMock.mockResolvedValue({ ...CONN, scopeDenied: ["sleep"] }); + fetchReadinessMock.mockResolvedValue([ + { id: "x", day: "2026-06-10", score: 70 }, + ]); + fetchSleepMock.mockRejectedValueOnce(err401()).mockResolvedValue([]); + refreshMock.mockResolvedValue({ + access_token: "newAcc", + refresh_token: "newRef", + expires_in: 86400, + }); + + const result = await syncUserOura("u1"); + + expect(refreshMock).toHaveBeenCalledTimes(1); + expect(result.failed).toBe(false); + }); + it("still treats a 401 on every collection as a dead token", async () => { getConnMock.mockResolvedValue(CONN); for (const m of [ diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index c1c8d4b5d..040759195 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -91,6 +91,7 @@ import { } from "./credentials"; import { OuraApiError, classifyOuraError } from "./response-classifier"; import { syncUserOuraCyclePhases } from "./cycle-sync"; +import { OURA_SCOPE_GATED_COLLECTIONS } from "./scope"; import { shiftDateKey, userDayKey } from "@/lib/tz/format"; import { resolveUserTimezone } from "@/lib/tz/resolver"; @@ -261,13 +262,26 @@ function isAuthRefusal(err: unknown): boolean { return err instanceof OuraApiError && err.httpStatus === 401; } +const SCOPE_GATED: ReadonlySet = new Set(OURA_SCOPE_GATED_COLLECTIONS); + /** - * 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. + * A 401 that can be the grant's scope rather than the token: only the + * collections behind `spo2` / `heart_health` sit outside the `daily` scope + * every grant carries. A 401 on sleep, readiness or activity is a real auth + * failure whatever its siblings did. + */ +function isScopeRefusal(f: OuraCollectionFailure): boolean { + return SCOPE_GATED.has(f.name) && isAuthRefusal(f.err); +} + +/** + * Split the scope 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. Any other failure, + * a 401 on a core collection included, stays a failure. */ function splitScopeDenied( failures: OuraCollectionFailure[], @@ -275,10 +289,8 @@ function splitScopeDenied( ): { 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), + failures: failures.filter((f) => !isScopeRefusal(f)), + scopeDenied: failures.filter(isScopeRefusal).map((f) => f.name), }; } @@ -455,7 +467,9 @@ async function fetchAll( // 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 knownDenied = new Set( + knownScopeDenied.filter((name) => SCOPE_GATED.has(name)), + ); const onlyKnownScope401 = first.failures .filter((f) => isAuthRefusal(f.err)) .every((f) => knownDenied.has(f.name));