inspector/core/storage/store-io.ts
cliffhall 1e6a2825d4 fix(auth): keep oauth-persist browser-safe (move Node file backend out)
The refactor routed the browser's RemoteOAuthStorage through
`oauth-persist.ts`, which value-imported the Node-only `store-io.js`
(`atomically` -> `stubborn-fs` -> `node:process.getuid`, plus
`node:fs/promises`). That dragged Node built-ins into the browser bundle
and the web app crashed to a blank page at runtime — a regression from
v2/main, where browser OAuth storage used a Zustand JSON adapter with no
Node imports. Unit/integration tests run in node/happy-dom and
`smoke:web` only checks the served HTML, so none of them execute the app
in a real browser and the break slipped through.

Fix, following the existing `store-id.ts` precedent:
- Split the pure JSON (de)serializers into Node-free
  `core/storage/store-serialize.ts`; `store-io.ts` re-exports them so
  existing importers are unaffected.
- Move the Node-only `createFileOAuthPersistBackend` into
  `core/auth/node/oauth-persist-file.ts`; update `storage-node.ts` and
  the storage adapters test to import from there.
- `oauth-persist.ts` now imports only the Node-free serializers, so the
  browser bundle no longer pulls in `node:fs`/`atomically`.

Tests:
- oauth-persist-browser-safe.test.ts: source-level guard that
  `oauth-persist.ts` never imports store-io/node:/atomically.
- Direct unit tests for the isomorphic remote + session backends
  (restoring oauth-persist.ts coverage after the file backend moved out).
- store.test.ts: OAuthMemoryStore unit coverage incl. replace() fallbacks.
- storage.test.ts: clearAllOAuthClientState null-snapshot (no file) path.

Verified end-to-end in a real browser (Playwright): DCR OAuth flow
completes, tokens persist via the remote store, and after a full page
reload the server reconnects with no re-authentication.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 16:20:31 -04:00

126 lines
4.1 KiB
TypeScript

/**
* Shared storage path resolution, validation, and atomic file I/O.
* Used by OAuth file persistence and the remote server's /api/storage routes.
*/
import * as path from "node:path";
import * as fs from "node:fs/promises";
import { readFile, writeFile } from "atomically";
// Re-export the pure id validator so existing `store-io` importers are
// unaffected; the implementation lives in the Node-free `store-id` module so
// isomorphic code can reuse it without pulling Node deps into the browser.
export { validateStoreId } from "./store-id.js";
// Likewise re-export the pure JSON (de)serializers from the Node-free
// `store-serialize` module so existing `store-io` importers are unaffected.
export { serializeStore, parseStore } from "./store-serialize.js";
/**
* Default storage directory (~/.mcp-inspector/storage or %USERPROFILE%\.mcp-inspector\storage on Windows).
*/
export function getDefaultStorageDir(): string {
const homeDir = process.env.HOME || process.env.USERPROFILE || ".";
return path.join(homeDir, ".mcp-inspector", "storage");
}
/**
* Default path for the user's server list file
* (~/.mcp-inspector/mcp.json or %USERPROFILE%\.mcp-inspector\mcp.json on Windows).
*/
export function getDefaultMcpConfigPath(): string {
const homeDir = process.env.HOME || process.env.USERPROFILE || ".";
return path.join(homeDir, ".mcp-inspector", "mcp.json");
}
/**
* Path for a store ID under the given storage directory.
* Callers must pass a validated storeId.
*/
export function getStoreFilePath(storageDir: string, storeId: string): string {
return path.join(storageDir, `${storeId}.json`);
}
/**
* Read store file atomically. Returns null if the file does not exist (ENOENT).
* @throws on other read errors or parse errors (caller may use parseStore on the string).
*/
export async function readStoreFile(filePath: string): Promise<string | null> {
try {
const data = await readFile(filePath, { encoding: "utf-8" });
return data;
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code === "ENOENT") {
return null;
}
throw error;
}
}
/**
* In-flight writeStoreFile() promises, keyed by resolved path. Lets callers
* await persistence completion via flushStoreFileWrites() instead of polling
* the file. Entries are removed once their write settles.
*/
const pendingWrites = new Map<string, Promise<void>>();
/**
* Write store file atomically (temp file + rename). Ensures parent directory exists.
* Uses mode 0o600 for the file.
*
* The returned promise is also tracked per path so flushStoreFileWrites() can
* await it. Writes to the same path are chained so a flush awaits every queued
* write (and a later write's completion implies all earlier ones settled).
*/
export async function writeStoreFile(
filePath: string,
data: string,
): Promise<void> {
const key = path.resolve(filePath);
const run = async (): Promise<void> => {
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true });
await writeFile(filePath, data, {
encoding: "utf-8",
mode: 0o600,
});
};
const prior = pendingWrites.get(key);
const tracked = (prior ?? Promise.resolve()).catch(() => {}).then(run);
pendingWrites.set(key, tracked);
try {
await tracked;
} finally {
if (pendingWrites.get(key) === tracked) {
pendingWrites.delete(key);
}
}
}
/**
* Await pending writeStoreFile() writes — those for `filePath` if given, else
* all of them. Use in tests after triggering persistence instead of polling
* the file, and for graceful shutdown. Resolves immediately when nothing is in flight.
*/
export async function flushStoreFileWrites(filePath?: string): Promise<void> {
if (filePath !== undefined) {
await pendingWrites.get(path.resolve(filePath));
return;
}
await Promise.all(pendingWrites.values());
}
/**
* Delete store file. Ignores ENOENT (already deleted).
*/
export async function deleteStoreFile(filePath: string): Promise<void> {
try {
await fs.unlink(filePath);
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code !== "ENOENT") {
throw error;
}
}
}