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>
126 lines
4.1 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|