fix(web): scope the OAuth recovery restores to the attempt that made them

Copilot round 2, both findings valid:

- `resumePendingReauth`'s restore could resurrect a challenge whose
  session ended during the awaits — a server switch or disconnect clears
  the slot on purpose. It is now gated on the attempt still targeting
  the live session, client identity included (reconnecting to the same
  server builds a replacement client an id-only check would admit), and
  uses a functional update so a newer deferral queued in the same batch
  wins rather than being read past.
- `prepareOAuthRedirect` cleared whichever resume snapshot was current,
  not the one it wrote. Nothing single-flights that callback, so a
  redirect failing before navigation could delete a later attempt's
  callback-routing state. `writeOAuthResumeSnapshot` now returns the
  serialization it stored, and `clearOwnOAuthResumeSnapshot` removes it
  only while it is still the current one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Quk1hBUtyhkXowwMXzAnAY
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-29 02:12:47 -04:00
commit 76b75d3759

View file

@ -11,6 +11,7 @@ import {
readOAuthResumeSnapshot,
restoreTabUiFromSnapshot,
writeOAuthResumeSnapshot,
clearOwnOAuthResumeSnapshot,
type OAuthResumeSnapshot,
} from "./oauthResume.js";
import {
@ -349,6 +350,43 @@ describe("oauthResume", () => {
}
});
it("clearOwnOAuthResumeSnapshot removes only the snapshot it was given", () => {
const first = writeOAuthResumeSnapshot({
version: 1,
serverId: "a",
activeTab: "tools",
authKind: "reauth",
tabUi: {},
});
// A later attempt replaces it before the first one's failure lands.
writeOAuthResumeSnapshot({
version: 1,
serverId: "b",
activeTab: "prompts",
authKind: "reauth",
tabUi: {},
});
expect(clearOwnOAuthResumeSnapshot(first)).toBe(false);
expect(readOAuthResumeSnapshot()?.serverId).toBe("b");
const second = window.sessionStorage.getItem(OAUTH_RESUME_KEY)!;
expect(clearOwnOAuthResumeSnapshot(second)).toBe(true);
expect(window.sessionStorage.getItem(OAUTH_RESUME_KEY)).toBeNull();
});
it("clearOwnOAuthResumeSnapshot is a no-op without a token", () => {
writeOAuthResumeSnapshot({
version: 1,
serverId: "a",
activeTab: "tools",
authKind: "reauth",
tabUi: {},
});
expect(clearOwnOAuthResumeSnapshot(undefined)).toBe(false);
expect(readOAuthResumeSnapshot()?.serverId).toBe("a");
});
it("writeOAuthResumeSnapshot swallows setItem failures", () => {
vi.stubGlobal("sessionStorage", {
getItem: () => null,
@ -368,6 +406,17 @@ describe("oauthResume", () => {
).not.toThrow();
});
it("clearOwnOAuthResumeSnapshot swallows storage failures", () => {
vi.stubGlobal("sessionStorage", {
getItem: () => {
throw new Error("blocked");
},
setItem: () => {},
removeItem: () => {},
});
expect(clearOwnOAuthResumeSnapshot("{}")).toBe(false);
});
it("clearOAuthResumeSnapshot swallows removeItem failures", () => {
vi.stubGlobal("sessionStorage", {
getItem: () => null,
@ -422,6 +471,10 @@ describe("oauthResume", () => {
it("clearOAuthResumeSnapshot is a no-op", () => {
expect(() => clearOAuthResumeSnapshot()).not.toThrow();
});
it("clearOwnOAuthResumeSnapshot is a no-op", () => {
expect(clearOwnOAuthResumeSnapshot("{}")).toBe(false);
});
});
it("restoreTabUiFromSnapshot returns early when tabUi is undefined", () => {