refactor: one shared reporter for the terminal refusal (review round 4)

In round 2 I claimed a single wrapper "stops a future path omitting the banner
clear". That was only true inside useOAuthRecovery. useConnectionLifecycle
hand-wrote the notice-plus-scoped-clear pair three times, so the exact failure
mode I said was impossible was one careless edit away — in the PR that claimed
it.

reportTerminalInsecureTokenEndpoint now lives in lib/ and both hooks route
through it; zero hand-written pairs remain. It is generic over the banner shape
rather than importing ReAuthBannerState, since useOAuthRecovery already imports
this module and naming its type would close a cycle. All it needs is a serverId
to compare.

The helper's header also still said "the three OAuth failure paths". There are
seven. Rather than update the count I removed the enumeration and recorded why:
a list of callers is a comment that rots on the next round, which is what just
happened to it.

Also fixes "a /etc/hosts" -> "an /etc/hosts".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018MvNbmAtQuWhDvzi4yXDju
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-07 10:59:56 -04:00
commit c2d566ad8d
3 changed files with 91 additions and 44 deletions

View file

@ -30,7 +30,7 @@ import {
getActiveEnterpriseManagedAuthIdp,
} from "@inspector/core/client/types.js";
import { isEmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientConfigError.js";
import { showInsecureTokenEndpointNotice } from "../lib/insecureTokenEndpointNotice";
import { reportTerminalInsecureTokenEndpoint } from "../lib/insecureTokenEndpointNotice";
import { findInsecureTokenEndpoint } from "@inspector/core/auth/insecureTokenEndpoint.js";
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
import type { RemoteInspectorClientStorage } from "@inspector/core/mcp/remote/index.js";
@ -654,14 +654,12 @@ export function useConnectionLifecycle({
// arm below already disconnects for the same reason.
if (findInsecureTokenEndpoint(err)) {
await client.disconnect().catch(() => {});
showInsecureTokenEndpointNotice(err, target.name);
// Clear a banner left by an earlier failure: its Re-authenticate
// button is just as dead as the one this arm declines to offer, and
// the user cannot tell which failure it belongs to. Scoped to this
// server, so an async continuation cannot erase another's.
setReAuthBanner((prev) =>
prev && prev.serverId === id ? null : prev,
);
reportTerminalInsecureTokenEndpoint({
err,
serverId: id,
serverName: target.name,
setReAuthBanner,
});
return;
}
@ -706,12 +704,14 @@ export function useConnectionLifecycle({
// the user the authorization *worked*. Placed after the teardown
// above, which this arm needs for the same reason the generic one
// does, and before the flag it must not set.
if (showInsecureTokenEndpointNotice(recoveryErr, target.name)) {
// Only this server's banner — an async continuation must not
// erase one raised for a server the user has since switched to.
setReAuthBanner((prev) =>
prev && prev.serverId === id ? null : prev,
);
if (
reportTerminalInsecureTokenEndpoint({
err: recoveryErr,
serverId: id,
serverName: target.name,
setReAuthBanner,
})
) {
return;
}
setFailedServerId(id);
@ -767,10 +767,14 @@ export function useConnectionLifecycle({
}
// See the note on the handshake arm above (#2280). The
// disconnect already happened at the top of this catch.
if (showInsecureTokenEndpointNotice(authErr, target.name)) {
setReAuthBanner((prev) =>
prev && prev.serverId === id ? null : prev,
);
if (
reportTerminalInsecureTokenEndpoint({
err: authErr,
serverId: id,
serverName: target.name,
setReAuthBanner,
})
) {
return;
}
// The connect attempt failed, same as any other handshake error —
@ -1021,7 +1025,14 @@ export function useConnectionLifecycle({
// worst place to lose the guidance, since they have just been told
// retrying is the fix. No banner clear is needed: this callback
// already cleared it before starting.
if (showInsecureTokenEndpointNotice(err, server?.name)) {
if (
reportTerminalInsecureTokenEndpoint({
err,
serverId,
serverName: server?.name,
setReAuthBanner,
})
) {
return;
}
const message = err instanceof Error ? err.message : String(err);

View file

@ -31,7 +31,7 @@ import {
emaStepUpSuccessMessage,
} from "@inspector/core/auth/oauthUx.js";
import { isEmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientConfigError.js";
import { showInsecureTokenEndpointNotice } from "../lib/insecureTokenEndpointNotice";
import { reportTerminalInsecureTokenEndpoint as reportTerminalInsecureTokenEndpointNotice } from "../lib/insecureTokenEndpointNotice";
import type { OAuthDetails } from "../components/groups/ConnectionInfoContent/ConnectionInfoContent";
import { oauthDetailsFromConnectionState } from "../components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState";
import { getWebRemoteOAuthStorage } from "../lib/remoteOAuthStorage";
@ -408,21 +408,13 @@ export function useOAuthRecovery({
err: unknown,
serverId: string | undefined,
serverName?: string,
): boolean => {
if (!showInsecureTokenEndpointNotice(err, serverName)) {
return false;
}
// Clear only *this* server's banner. The command and deferred-resume
// paths are asynchronous, so server A can reject long after the user
// switched away and server B raised a banner of its own; an unconditional
// clear would then erase B's, which is still valid and still actionable.
// Functional so it sees the queued state rather than the render-time
// value, matching how `setPendingReauth` guards its own late restore.
setReAuthBanner((prev) =>
prev && prev.serverId === serverId ? null : prev,
);
return true;
},
): boolean =>
reportTerminalInsecureTokenEndpointNotice({
err,
serverId,
serverName,
setReAuthBanner,
}),
[setReAuthBanner],
);

View file

@ -1,17 +1,18 @@
/**
* Surfaces the SDK's terminal token-endpoint refusal to post credentials to a non-TLS token
* endpoint as the terminal configuration error it is (#2280).
* Surfaces the SDK's refusal to post credentials to a non-TLS token endpoint as
* the terminal configuration error it is (#2280).
*
* Lives in `lib/` rather than `utils/` because showing a notification is a side
* effect; the copy it renders is pure and lives in `@inspector/core/auth`.
*
* Shaped as a claim-or-decline predicate rather than a plain `show(...)` so the
* three OAuth failure paths that need it — the connect handshake, the post-
* redirect callback, and the re-auth banner funnel — can each spend one line on
* it and keep their existing fall-through intact:
* Shaped as a claim-or-decline predicate rather than a plain `show(...)` so
* every OAuth failure path that needs it can spend one line and keep its own
* fall-through intact. Deliberately not enumerating the callers here: they have
* gone from three to seven over this PR's review, and a list is a comment that
* rots on the next one.
*
* ```ts
* if (showInsecureTokenEndpointNotice(err, server.name)) return;
* if (reportTerminalInsecureTokenEndpoint({ err, serverId: id, serverName, setReAuthBanner })) return;
* ```
*
* `autoClose: false` matches the other non-recoverable OAuth notices (issuer
@ -22,6 +23,7 @@
* reading. Don't describe this as non-dismissible.
*/
import type { Dispatch, SetStateAction } from "react";
import { notifications } from "@mantine/notifications";
import { findInsecureTokenEndpoint } from "@inspector/core/auth/insecureTokenEndpoint.js";
import {
@ -35,6 +37,48 @@ import {
* @returns `true` when it was handled (the caller should stop), `false` when
* `err` is some other failure and the caller's normal handling applies.
*/
/**
* Report the refusal **and** clear the re-auth banner for that server.
*
* This is the form every caller should use. The notice and the scoped banner
* clear are one invariant, not two steps: a banner left behind carries a
* Re-authenticate button as dead as the one this declines to offer, and the
* user cannot tell which failure it belongs to. Keeping them together in one
* place is what stops a future path doing half of it — an earlier revision made
* that claim while hand-writing the pair at three call sites in a second hook,
* which is exactly how it goes wrong.
*
* The clear is scoped to `serverId` and applied as a functional update: these
* paths are asynchronous, so a late continuation for server A must not erase a
* banner server B raised in the meantime.
*
* Generic over the banner shape rather than importing `ReAuthBannerState`:
* `useOAuthRecovery` already imports this module, so naming its type here would
* close a cycle. All this needs is a `serverId` to compare.
*
* @returns `true` when it was handled (the caller should stop), `false` when
* `err` is some other failure and the caller's normal handling applies.
*/
export function reportTerminalInsecureTokenEndpoint<
TBanner extends { serverId: string },
>({
err,
serverId,
serverName,
setReAuthBanner,
}: {
err: unknown;
serverId: string | undefined;
serverName?: string;
setReAuthBanner: Dispatch<SetStateAction<TBanner | null>>;
}): boolean {
if (!showInsecureTokenEndpointNotice(err, serverName)) {
return false;
}
setReAuthBanner((prev) => (prev && prev.serverId === serverId ? null : prev));
return true;
}
export function showInsecureTokenEndpointNotice(
err: unknown,
serverName?: string,