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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-14 19:47:05 +02:00
1 parent c7164be0d3
commit 55ddad3d6a
2 files changed
+69 -11

No files matched your search

+44
View File
@@ -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 [
+25 -11
View File
@@ -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<string> = 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));