fix(web): guard the auth-recovery retry, and say what the banner-only arms do

Review round 1.

The satisfied-challenge arm re-connects from inside the outer `catch` with two
unguarded awaits, so a rejection there escaped `onToggleConnection` entirely —
an unhandled rejection with no toast, no red border and no sidebar, which is
this issue's failure mode in its most invisible form. Surface it as the failed
connect attempt it is. A throw from `checkAuthChallengeSatisfied` lands there
too rather than falling through to the redirect: it is not the same as the
challenge being unsatisfied, and navigating the page away on the strength of an
error would bury it.

The provider-error and OAuth-storage-load arms return before a client is
rebuilt, so they raise the red border and deliberately not the sidebar — the
diagnostic is the provider's `error` param (already in the banner) or a local
storage failure, neither of which is in the network log. Said so at both sites
and on the test, which asserts the flag and nothing more on purpose.

Also drops the three `as unknown as` casts on the test-only arming hooks for a
single intersection cast that keeps the module's real shape checked, and turns
the armed connect rejection into a FIFO queue so a test can arm the two
consecutive connects the recovery retry makes.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-24 20:23:26 -04:00
commit 1998724634

View file

@ -2841,6 +2841,12 @@ function App() {
if (!params.successful) {
const pendingId = resumeSnapshot?.serverId;
if (pendingId) {
// Red border only (#1621), not a sidebar. This arm returns before a
// client is rebuilt, so the persisted `auth` entries are never
// restored and the content-gated column stays shut — correctly: the
// provider's own `error` param is the whole diagnostic, and the
// re-auth banner below is already showing it. The flag is still right,
// because the attempt did fail.
setFailedServerId(pendingId);
queueMicrotask(() => {
showReAuthBanner(pendingId, generateOAuthErrorDescription(params));
@ -2877,6 +2883,9 @@ function App() {
await webOAuthStorage.load();
} catch (err) {
connectStartRef.current = undefined;
// Red border only, for the same reason as the provider-error arm above
// — and here the network log would not help anyway: this is a failure
// to read local OAuth storage, not a request that went out.
setFailedServerId(server.id);
queueMicrotask(() => {
showReAuthBanner(server.id, err instanceof Error ? err : String(err));
@ -3057,9 +3066,33 @@ function App() {
// against the right client. The redirect unloads this page, so there's
// nothing to do after the await on the success path.
if (err instanceof AuthRecoveryRequiredError) {
if (await client.checkAuthChallengeSatisfied(err.authChallenge)) {
connectStartRef.current = Date.now();
await client.connect();
try {
if (await client.checkAuthChallengeSatisfied(err.authChallenge)) {
connectStartRef.current = Date.now();
await client.connect();
return;
}
} catch (recoveryErr) {
// Both awaits above are unguarded connect work sitting inside a
// `catch`, so a rejection escapes `onToggleConnection` altogether:
// no toast, no red border, no sidebar — the #2108 failure mode in
// its most invisible form. Surface it as the failed connect attempt
// it is. A throw from `checkAuthChallengeSatisfied` lands here too
// rather than falling through to `prepareOAuthRedirect`: it is not
// the same as the challenge being *unsatisfied*, and navigating the
// whole page away on the strength of an error would bury it.
connectStartRef.current = undefined;
setFailedServerId(id);
const message =
recoveryErr instanceof Error
? recoveryErr.message
: String(recoveryErr);
setConnectErrorMessage(message);
notifications.show({
title: `Failed to connect to "${target.name}"`,
message,
color: "red",
});
return;
}
prepareOAuthRedirect({