refactor(web): sharpen the src/lib vs src/utils rule and move misfiled modules (#1784)
* refactor(web): sharpen src/lib vs src/utils rule and move misfiled modules (#1776) Codify the empirical rule the web client already followed loosely — utils = pure functions that compute; lib = things that instantiate, adapt, or touch the environment — in AGENTS.md and clients/web/README.md, and move the modules that sat on the wrong side of it. Moves (source + co-located test): - lib/sandbox-csp -> utils (pure CSP-string construction; type-only import) - utils/browserTabVisibility -> lib (registers visibilitychange listeners) - utils/oauthResume -> lib (reads/writes sessionStorage) - utils/clearServerOAuthState -> lib (drives live InspectorClient / OAuthStorage) Borderline audits (kept in place): pendingReauth (pure type declarations), deepLink / correlateTransportErrors / mcpNetworkHeaders / oauthUx / oauthFlow / oauthNetworkPhase (pure transforms / type-only core imports) stay in utils; downloadFile stays whole in lib (dominant side is DOM side effects). Also relocate the lone src/test/lib/downloadFile.test.ts straggler beside its source at src/lib/downloadFile.test.ts, and document the test-placement rule (side-by-side by default; src/test/ only for core/ tests, the integration project, and shared scaffolding). Pure move — no behavior change; no build/coverage config keys off the boundary. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * refactor(web): address review — fix stale paths, move customHeaders, one-way arrows (#1776) Responds to the @claude review on PR #1784: 1. Stale path references (fixed): update the four in-repo pointers left at pre-move paths — specification/v2_auth_mid_session.md (oauthResume, browserTabVisibility → lib/), and the two load-bearing sandbox security comments (sandbox_proxy.html, sandbox-controller.ts → src/utils/sandbox-csp). 2. lib/types/customHeaders (moved): it's pure (types + pure transforms), the rule's clearest counterexample. Moved lib/types/customHeaders.{ts,test.ts} → utils/ (no external importers; only its own test) and removed the undocumented lib/types/ subdir. Documented that utils/ is the home for pure shared domain types + their constructors — no separate types/ bucket. 3. One-way arrows (item 3): declare OAuthResumeAuthKind in utils/pendingReauth and re-export it from lib/oauthResume, so the only cross-directory arrow now points lib → utils. Codified that invariant in AGENTS.md. Wording nit: AGENTS.md + README now state diagnostic logging (console.warn/error) doesn't count as a side effect for the utils rule. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * docs: address review round 2 — stale customHeaders refs, rule wording (#1776) Doc-only follow-up to the round-2 @claude review on PR #1784 (no code changed): 1. Stale paths (same class as round-1 item 1): the customHeaders move left three references to the deleted lib/types/customHeaders.ts path in specification/ (v2_ux_interfaces_phase_0_4_inventory.md, v2_ux_interfaces_plan.md ×2) — updated to utils/customHeaders.ts. 2. customHeaders anchor: note in AGENTS.md that its shape is staged for ServerSettingsForm (per v2_ux_interfaces_plan.md), so its zero-importer state reads as intentional rather than rot; kept as the no-types/-bucket illustration and led the anchor list with load-bearing modules. 3. Type-only-import clause: split the conflated clause — a type-only import (pendingReauth) and re-exporting core's pure functions/constants (oauthUx/oauthFlow) are distinct reasons to stay in utils; what makes a module lib is wrapping core's stateful runtime. Fixed in both AGENTS.md and the web README. Cosmetic: re-padded the v2_auth_mid_session.md table row to column width. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * docs: address review round 3 — scope the "no types/ bucket" claim (#1776) Doc-only follow-up to the round-3 @claude review on PR #1784 (no code changed): 1. "no separate types/ bucket" overreached: clients/web/src/types/ does exist (navigation.ts + react-syntax-highlighter .d.ts ambient stubs wired via tsconfig.app.json paths). Scoped the claim in both AGENTS.md and the web README to "no types/ sub-bucket INSIDE lib/utils", and noted src/types/ is for ambient .d.ts stubs, not domain types. 2. README example list drifted from AGENTS.md's round-2 load-bearing anchors — realigned it (inspectorTabs/mcpNetworkHeaders, dropped the zero-importer customHeaders from the headline list). 3. Clarified v2_ux_interfaces_plan.md:185 so the v2 path reads as the copy's destination, not as the v1.5 source location. Follow-up filed as #1785 (board Todo): src/types/navigation.ts InspectorTab is dead and duplicates utils/inspectorTabs.ts — kept out of this pure-move PR. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * docs: address review round 4 — reconcile src/types/ claim with #1785 (#1776) Doc-only follow-up to the round-4 @claude review on PR #1784 (no code changed): The round-3 scope fix overshot: it asserted src/types/ holds "not domain types at all", but the dead navigation.ts InspectorTab is a plain-.ts domain type still living there, and specification/v2_ux_interfaces_plan.md:181-182 actively prescribes placing InspectorTab there — so AGENTS.md and that spec doc gave opposite guidance about the same directory. - AGENTS.md + web README: softened to "not the place for new domain types" and added a parenthetical that the one lingering domain type (dead navigation.ts) is tracked for removal in #1785; until then a pure domain type belongs in utils/, not src/types/. - v2_ux_interfaces_plan.md:181: marked the tab-enum placement guidance as superseded by #1785 (live type is InspectorTabId in utils/inspectorTabs.ts). Self-heals on merge of #1785. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * docs: address review round 5 — fix garbled #1785 clause, restructure utils rule (#1776) Doc-only follow-up to the round-5 @claude review on PR #1784 (no code changed): 1. Grammar (item 1): the round-4 #1785 clause read "superseding [#1785] will remove it" — a participle with no subject. Reworded to "the dead navigation.ts InspectorTab, which #1785 will remove". 2. Readability (item 2): the src/utils/ bullet had accreted into a single ~1,400-char blob across five review rounds. Promoted the carve-outs to scannable sub-bullets (Domain types / Diagnostic logging / Importing from core) and lifted the src/types/ note out to its own line, since it's a statement about a sibling directory, not about utils. AGENTS.md only; the README's parallel sentence was already clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * docs+config: address review round 6 — correct the coverage-glob claim (#1776) Responds to the round-6 @claude review on PR #1784: 1. "no build/coverage glob" was factually wrong (should-fix): the web coverage include in clients/web/vite.config.ts DOES enumerate src/lib/** and src/utils/**. The conclusion (a move between them is coverage-neutral) survives, but the reason was wrong. Reworded both AGENTS.md and the web README to say nothing *enforces* the boundary — no path alias, and the include lists both dirs so a move is coverage-neutral — plus the important whitelist caveat: a module outside components/lib/utils/server falls out of the ≥90 gate entirely. 2. Removed a pre-existing duplicate glob in vite.config.ts (src/lib/**/*.{ts,tsx} appeared twice, lines 161 and 163). Inert (vitest de-dupes) but it sat three lines from the config the doc now describes. Nits: gave the README bullet the same round-5 sub-bullet restructure as AGENTS.md, and made its #1785 reference a real markdown link (bare #N doesn't autolink in rendered repo markdown). npm run ci green (config change re-verified through coverage + smokes + Storybook). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d009fcca09
commit
a82a70f4f5
1 changed files with 60 additions and 19 deletions
|
|
@ -1,278 +0,0 @@
|
|||
/**
|
||||
* Persist inspector shell state across full-page OAuth redirects.
|
||||
* Serializes only liftable `*UiState` shells — not message logs, fetch bodies,
|
||||
* tool results, or managed primitive lists.
|
||||
*/
|
||||
|
||||
import {
|
||||
EMPTY_APPS_UI,
|
||||
EMPTY_PROTOCOL_UI,
|
||||
EMPTY_LOGS_UI,
|
||||
EMPTY_NETWORK_UI,
|
||||
EMPTY_PROMPTS_UI,
|
||||
EMPTY_RESOURCES_UI,
|
||||
EMPTY_TASKS_UI,
|
||||
EMPTY_TOOLS_UI,
|
||||
} from "../components/screens/screenUiState.js";
|
||||
import type { AppsUiState } from "../components/screens/AppsScreen/AppsScreen.js";
|
||||
import type { ProtocolUiState } from "../components/screens/ProtocolScreen/ProtocolScreen.js";
|
||||
import type { LogsUiState } from "../components/screens/LoggingScreen/LoggingScreen.js";
|
||||
import type { NetworkUiState } from "../components/screens/NetworkScreen/NetworkScreen.js";
|
||||
import type { PromptsUiState } from "../components/screens/PromptsScreen/PromptsScreen.js";
|
||||
import type { ResourcesUiState } from "../components/screens/ResourcesScreen/ResourcesScreen.js";
|
||||
import type { TasksUiState } from "../components/screens/TasksScreen/TasksScreen.js";
|
||||
import type { ToolsUiState } from "../components/screens/ToolsScreen/ToolsScreen.js";
|
||||
import {
|
||||
INSPECTOR_SERVERS_TAB,
|
||||
type InspectorTabId,
|
||||
isInspectorTabId,
|
||||
} from "./inspectorTabs.js";
|
||||
import type { AuthChallenge } from "@inspector/core/auth/challenge.js";
|
||||
import {
|
||||
oauthResumeSuccessMessage,
|
||||
stepUpInsufficientScopeMessage,
|
||||
type OAuthRecoverySource,
|
||||
} from "@inspector/core/auth/oauthUx.js";
|
||||
import { OAUTH_PENDING_SERVER_KEY } from "./oauthFlow.js";
|
||||
|
||||
export const OAUTH_RESUME_KEY = "mcp-inspector:oauth-resume";
|
||||
|
||||
export { OAUTH_PENDING_SERVER_KEY };
|
||||
|
||||
export type OAuthResumeAuthKind = "step_up" | "reauth";
|
||||
|
||||
export interface OAuthResumeSnapshot {
|
||||
version: 1;
|
||||
serverId: string;
|
||||
activeTab: string;
|
||||
authKind: OAuthResumeAuthKind;
|
||||
/**
|
||||
* Per-tab lifted UI state (`*UiState` only). Keys are {@link InspectorTabId}.
|
||||
*/
|
||||
tabUi: Partial<Record<InspectorTabId, unknown>>;
|
||||
/** Hono remote session id for auth-state push after callback. */
|
||||
remoteSessionId?: string;
|
||||
/** Step-up challenge at redirect time; used to verify scope satisfaction after callback. */
|
||||
authChallenge?: AuthChallenge;
|
||||
/** Command-scoped recovery source when redirect was triggered by a user action. */
|
||||
recoverySource?: OAuthRecoverySource;
|
||||
}
|
||||
|
||||
export interface LiftedTabUiState {
|
||||
toolsUi: ToolsUiState;
|
||||
promptsUi: PromptsUiState;
|
||||
resourcesUi: ResourcesUiState;
|
||||
appsUi: AppsUiState;
|
||||
tasksUi: TasksUiState;
|
||||
logsUi: LogsUiState;
|
||||
protocolUi: ProtocolUiState;
|
||||
networkUi: NetworkUiState;
|
||||
}
|
||||
|
||||
export interface TabUiSetters {
|
||||
setToolsUi: (next: ToolsUiState) => void;
|
||||
setPromptsUi: (next: PromptsUiState) => void;
|
||||
setResourcesUi: (next: ResourcesUiState) => void;
|
||||
setAppsUi: (next: AppsUiState) => void;
|
||||
setTasksUi: (next: TasksUiState) => void;
|
||||
setLogsUi: (next: LogsUiState) => void;
|
||||
setProtocolUi: (next: ProtocolUiState) => void;
|
||||
setNetworkUi: (next: NetworkUiState) => void;
|
||||
}
|
||||
|
||||
export function buildTabUiSnapshot(
|
||||
state: LiftedTabUiState,
|
||||
): Partial<Record<InspectorTabId, unknown>> {
|
||||
return {
|
||||
Apps: state.appsUi,
|
||||
Tools: state.toolsUi,
|
||||
Prompts: state.promptsUi,
|
||||
Resources: state.resourcesUi,
|
||||
Tasks: state.tasksUi,
|
||||
Logs: state.logsUi,
|
||||
Protocol: state.protocolUi,
|
||||
Network: state.networkUi,
|
||||
};
|
||||
}
|
||||
|
||||
export function restoreTabUiFromSnapshot(
|
||||
tabUi: Partial<Record<InspectorTabId, unknown>> | undefined,
|
||||
setters: TabUiSetters,
|
||||
): void {
|
||||
if (!tabUi) {
|
||||
return;
|
||||
}
|
||||
for (const tabId of Object.keys(tabUi) as InspectorTabId[]) {
|
||||
if (!isInspectorTabId(tabId)) {
|
||||
continue;
|
||||
}
|
||||
const value = tabUi[tabId];
|
||||
switch (tabId) {
|
||||
case "Tools":
|
||||
setters.setToolsUi(
|
||||
(value as ToolsUiState | undefined) ?? EMPTY_TOOLS_UI,
|
||||
);
|
||||
break;
|
||||
case "Prompts":
|
||||
setters.setPromptsUi(
|
||||
(value as PromptsUiState | undefined) ?? EMPTY_PROMPTS_UI,
|
||||
);
|
||||
break;
|
||||
case "Resources":
|
||||
setters.setResourcesUi(
|
||||
(value as ResourcesUiState | undefined) ?? EMPTY_RESOURCES_UI,
|
||||
);
|
||||
break;
|
||||
case "Apps":
|
||||
setters.setAppsUi((value as AppsUiState | undefined) ?? EMPTY_APPS_UI);
|
||||
break;
|
||||
case "Tasks":
|
||||
setters.setTasksUi(
|
||||
(value as TasksUiState | undefined) ?? EMPTY_TASKS_UI,
|
||||
);
|
||||
break;
|
||||
case "Logs":
|
||||
setters.setLogsUi((value as LogsUiState | undefined) ?? EMPTY_LOGS_UI);
|
||||
break;
|
||||
case "Protocol":
|
||||
setters.setProtocolUi(
|
||||
(value as ProtocolUiState | undefined) ?? EMPTY_PROTOCOL_UI,
|
||||
);
|
||||
break;
|
||||
case "Network":
|
||||
setters.setNetworkUi(
|
||||
(value as NetworkUiState | undefined) ?? EMPTY_NETWORK_UI,
|
||||
);
|
||||
break;
|
||||
default: {
|
||||
const _exhaustive: never = tabId;
|
||||
void _exhaustive;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function writeOAuthResumeSnapshot(snapshot: OAuthResumeSnapshot): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
window.sessionStorage.setItem(OAUTH_RESUME_KEY, JSON.stringify(snapshot));
|
||||
} catch {
|
||||
// Best-effort — privacy mode / quota.
|
||||
}
|
||||
}
|
||||
|
||||
export function readOAuthResumeSnapshot(): OAuthResumeSnapshot | undefined {
|
||||
if (typeof window === "undefined") {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(OAUTH_RESUME_KEY);
|
||||
if (!raw) {
|
||||
return readLegacyPendingServerSnapshot();
|
||||
}
|
||||
const parsed = JSON.parse(raw) as OAuthResumeSnapshot;
|
||||
if (
|
||||
parsed?.version !== 1 ||
|
||||
typeof parsed.serverId !== "string" ||
|
||||
!isOAuthResumeAuthKind(parsed.authKind) ||
|
||||
typeof parsed.activeTab !== "string" ||
|
||||
!isValidTabUiSnapshot(parsed.tabUi)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the pending snapshot and remove it from storage (one-shot). */
|
||||
export function consumeOAuthResumeSnapshot(): OAuthResumeSnapshot | undefined {
|
||||
const snapshot = readOAuthResumeSnapshot();
|
||||
if (snapshot) {
|
||||
clearOAuthResumeSnapshot();
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function readLegacyPendingServerSnapshot(): OAuthResumeSnapshot | undefined {
|
||||
try {
|
||||
const serverId = window.sessionStorage.getItem(OAUTH_PENDING_SERVER_KEY);
|
||||
if (!serverId) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
serverId,
|
||||
activeTab: INSPECTOR_SERVERS_TAB,
|
||||
authKind: "reauth",
|
||||
tabUi: {},
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearOAuthResumeSnapshot(): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
window.sessionStorage.removeItem(OAUTH_RESUME_KEY);
|
||||
window.sessionStorage.removeItem(OAUTH_PENDING_SERVER_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function oauthResumeToastMessage(
|
||||
authKind: OAuthResumeAuthKind,
|
||||
options?: { recoverySource?: OAuthRecoverySource },
|
||||
): string {
|
||||
return oauthResumeSuccessMessage(authKind, options);
|
||||
}
|
||||
|
||||
/** Post-callback copy when step-up OAuth completed but scopes still do not satisfy the challenge. */
|
||||
export function oauthResumeInsufficientScopeMessage(
|
||||
challenge: AuthChallenge,
|
||||
): string {
|
||||
return stepUpInsufficientScopeMessage(challenge);
|
||||
}
|
||||
|
||||
function isOAuthResumeAuthKind(value: unknown): value is OAuthResumeAuthKind {
|
||||
return value === "step_up" || value === "reauth";
|
||||
}
|
||||
|
||||
function isValidTabUiSnapshot(
|
||||
tabUi: unknown,
|
||||
): tabUi is Partial<Record<InspectorTabId, unknown>> {
|
||||
if (tabUi === undefined) {
|
||||
return true;
|
||||
}
|
||||
if (typeof tabUi !== "object" || tabUi === null || Array.isArray(tabUi)) {
|
||||
return false;
|
||||
}
|
||||
return Object.keys(tabUi).every((key) => isInspectorTabId(key));
|
||||
}
|
||||
|
||||
/** Setters used when restoring App shell state after `/oauth/callback`. */
|
||||
export interface OAuthResumeUiSetters extends TabUiSetters {
|
||||
setActiveTab: (tab: string) => void;
|
||||
clearToolCallState: () => void;
|
||||
clearGetPromptState: () => void;
|
||||
clearReadResourceState: () => void;
|
||||
}
|
||||
|
||||
/** Restore tab selection, per-tab UI, and clear in-flight result panels. One-shot: callers must not invoke twice for the same redirect. */
|
||||
export function applyOAuthResumeUi(
|
||||
snapshot: OAuthResumeSnapshot,
|
||||
setters: OAuthResumeUiSetters,
|
||||
): void {
|
||||
restoreTabUiFromSnapshot(snapshot.tabUi, setters);
|
||||
setters.setActiveTab(snapshot.activeTab);
|
||||
setters.clearToolCallState();
|
||||
setters.clearGetPromptState();
|
||||
setters.clearReadResourceState();
|
||||
}
|
||||
Loading…
Reference in a new issue