refactor(auth): remove Zustand from OAuth persistence, clean up storage IO (#1549)#1612
Conversation
…mentations, and usage.
|
@claude review |
|
Claude finished @BobDickinson's task in 2m 38s —— View job Code review — remove Zustand from OAuth persistence (#1612)Reviewed against 1. Remote backend has no write serialization → possible lost update (medium)
If Since the fix belongs at a backend-agnostic layer, consider serializing 2.
|
Serialize persist() in OAuthStorageBase so overlapping remote POSTs cannot reorder and clobber state. Regenerate launcher package-lock.json to drop stale zustand entries. Let parseOAuthPersistBlob accept parsed objects from res.json() without a redundant JSON round-trip.
|
Addressed review items 1–3:
Deferred
|
…age-no-zustand # Conflicts: # core/auth/store.ts
|
@claude review |
|
Claude finished @cliffhall's task in 2m 37s —— View job Code review — remove Zustand from OAuth persistence (#1612)Re-reviewed against Verification of prior review items
New observations1. Redundant
const store = await res.json();
if (Object.keys(store as object).length === 0) {
return null;
}
return parseOAuthPersistBlob(store);This guard is now redundant and slightly fragile:
Dropping the guard and passing 2.
Confirmed correct (spot checks)
Nice cleanup. Item 1 is the only thing I'd consider before merge, and it's minor — everything from the first review is resolved. |
parseOAuthPersistBlob already returns null for {} (the server's
missing-file response) and for a literal null body, so the
Object.keys(store).length === 0 guard was redundant — and would throw a
TypeError on a null body before parseOAuthPersistBlob could handle it.
Pass the parsed res.json() straight through.
Addresses Claude review item #1 on #1612.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Addressed review item #1 (redundant empty-object guard in remote
#2 ( @claude review |
|
Claude finished @cliffhall's task in 52s —— View job Code review — remove Zustand from OAuth persistence (#1612)Re-reviewed commit
Verification#1 — Redundant empty-object guard removed ✅
The doc comment accurately explains the reasoning. Good, focused fix. #2 — Note (non-blocking)The All actionable items from both prior reviews are now resolved. The refactor is clean, the async/auto-load contract is applied uniformly, and this last change makes the remote read path both simpler and more defensive. LGTM. ✅ |
Document the contract the remote read() path leans on — the server's
missing-file response is `c.json({}, 200)`, and the backend now passes
that empty object straight to parseOAuthPersistBlob. Addresses the
optional note on Claude review of #1612.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Added the optional test from your note ( @claude review |
|
Claude finished @cliffhall's task in 49s —— View job Code review — remove Zustand from OAuth persistence (#1612)Re-reviewed commit
VerificationOptional note from prior review addressed ✅ I traced the assertion against the implementation (
This is the precise fall-through that Regression scanNo production code touched — the commit is test-only (+7 lines). No impact on the async/auto-load contract, the All items from all three prior reviews are now resolved:
The refactor is clean, well-scoped, and the storage layer is materially simpler than the Zustand persist stack. LGTM. ✅ |
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>
|
Ran a browser smoke test of OAuth persistence (Playwright + headless Chromium, against a local DCR OAuth MCP test server) and it surfaced a runtime regression: the browser's Fixed in
Tests added: a source-level guard that Verified end-to-end in a real browser: DCR + PKCE flow completes, tokens persist to the remote store, and after a full page reload the server reconnects with no re-authentication ( @claude review |
|
Claude finished @cliffhall's task in 1m 25s —— View job Code review — keep oauth-persist browser-safe (
|
Extend the Node-free guard beyond oauth-persist.ts to every module reachable from the browser's RemoteOAuthStorage (storage-remote, oauth-storage, store, oauth-persist, store-serialize), so a forbidden store-io/node:/atomically import added one hop away is caught too — not just a direct import in oauth-persist.ts. Addresses the non-blocking hardening note on Claude review of #1612. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Addressed the non-blocking notes:
@claude review |
|
Claude finished @cliffhall's task in 1m 32s —— View job Code review — transitive browser-safe guard (
|
| Module | Value imports reachable from it |
|---|---|
auth/remote/storage-remote.ts |
oauth-storage.js, store.js, oauth-persist.js |
auth/oauth-storage.ts |
none (storage.js, store.js, oauth-persist.js are all type-only) |
auth/store.ts |
none (only a type import of oauth-persist.js) |
auth/oauth-persist.ts |
../storage/store-serialize.js |
storage/store-serialize.ts |
none (leaf; pure JSON.stringify/JSON.parse, no imports) |
Every value-reachable module is in the list, and store-serialize.ts is genuinely Node-free. The it.each runs each file through the FORBIDDEN matchers (store-io, ^node:, ^atomically), and the extra assertion pins that oauth-persist.ts sources its (de)serializers from store-serialize.js. The regex matches import type … from too, so it's conservative (a type-only Node import — erased at build — would still be flagged), which is fine.
Regression scan
No production code touched — no impact on the async/auto-load contract, persistQueue, or the remote read path.
One observation (non-blocking, matches the note being addressed)
The guard is transitive by manual enumeration, not automatically transitive. It walks a hardcoded list that someone traced from the current graph — it does not follow imports itself. So if a new value import is added to, say, oauth-storage.ts pointing at a new foo.ts that pulls node:fs, the guard wouldn't catch it because foo.ts isn't in BROWSER_REACHABLE. That's the same class of gap pushed one hop further out, and it's an inherent limitation of a static list rather than a real defect. As noted, the true category fix is a headless-browser boot smoke in CI (the planned follow-up) — a static list can't replace actually loading the bundle. Fine to leave; the e2e Playwright verification is the real backstop today.
If you ever want the list to be self-maintaining, the guard could instead start from storage-remote.ts, parse its value imports (skipping import type), and recurse — but that's more machinery than this hardening step warrants.
All items from all four prior review passes are now resolved:
- Progress notifications #1 Remote write serialization →
persistQueue✅ - Request logging #2 Launcher lockfile zustand entries removed ✅
- Completion requests #3 Remote read double round-trip eliminated ✅
- Progress notifications #1 (2nd) Redundant empty-object guard removed ✅
parseOAuthPersistBlob({})contract test added ✅- Browser-safety regression (
1e6a2825) fixed + now guarded transitively ✅
Clean, well-scoped refactor. LGTM. ✅
· branch v2/1549-oauth-storage-no-zustand
`tsc -b` (run in CI's validate step, but not by vitest locally) rejected `ok.mock.calls[0][1].body` — under the project's strict indexing the untyped mock's call tuple is `[]`, so `[0]` is possibly-undefined and `[1]` is out of range. Capture the request body inside a `vi.fn<typeof fetch>` implementation instead of indexing into `mock.calls`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Pushed @claude review |
|
Claude finished @cliffhall's task in 51s —— View job Code review — fix type error in remote backend
|
- Don't fail on benign console.error (CDN font loads, React warnings) — the #1612 class arrives as an uncaught pageerror. console errors are now diagnostics, hard-failing only when they carry the externalization signature. - Broaden the externalization regex to non-prefixed built-ins ("fs" as well as "node:fs") and label the signature on both the pageerror and console channels. - Rename the port override to SMOKE_WEB_BROWSER_PORT so it doesn't collide with smoke:web's SMOKE_WEB_PORT. - Add a settle window (networkidle + 500ms) so a late-evaluating lazy chunk that throws is caught, and race the render against launcher death so a mid-load crash reports the real cause instead of a render timeout. - Print an actionable hint when Playwright/chromium can't launch (missing --with-deps system libs). - Extract the shared spawn/readiness boilerplate into scripts/lib/prod-web-server.mjs so smoke-web and smoke-web-browser can't drift. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
Third-review findings.
1. The `Module "…" has been externalized` string can't fire against a Vite 8
*prod* bundle — verified end-to-end against the installed vite@8.0.0: a
node: import reaching the browser graph is a BUILD-time warning, and the
shipped stub is a silent `module.exports = {}`. The real #1612 manifestation
is the first *call* into that empty stub throwing a TypeError at module init
(confirmed: injecting a used `node:fs` import fails the smoke with
"readFileSync is not a function"; an unused one ships {} and is harmless).
So the signature machinery was dead against the bundle it runs on — dropped
it. The smoke now asserts exactly what it verifies: the prod bundle boots and
paints with no uncaught page error. Reworded the script header, the
AGENTS.md bullet, and the README row to match. Console errors stay
diagnostic-only.
2. The helper's child 'error' handler set `exited = true`, but Node emits
'error' for a failed kill/send too (child still alive) — so `stop()`
(guarded on `exited`) would skip the SIGTERM and orphan the launcher on the
port. Track the error separately (`childError`); `bootFailed()` = exited ||
childError, while `stop()` still SIGTERMs a live child.
Docs: fixed the stale AGENTS.md claim that `cd clients/web` makes
`import("playwright")` resolve (createRequire does, cwd-independently; the cd is
only for `npx playwright install`), and noted the shared
scripts/lib/prod-web-server.mjs helper on both smoke bullets.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
page.on("pageerror") fires for uncaught SYNCHRONOUS exceptions only. The
async twin of the #1612 shape — the same empty-stub TypeError reached
through an await/.then(), or a failed dynamic import (this app lazy-loads
chunks) — lands on the console channel as `Uncaught (in promise) …` /
`Failed to fetch dynamically imported module`, which were diagnostic-only,
so the smoke would have passed. Hard-fail console errors matching
/^Uncaught\b|Failed to fetch dynamically imported module/; every other
console error stays a printed non-fatal note. The prefixes are unambiguous
(a font/CDN miss reads "Failed to load resource: net::ERR_…"; React
warnings never start with "Uncaught"), so this can't reintroduce the flake
the earlier round fixed. Verified: an unhandled rejection at module init
now fails the smoke where it previously wouldn't.
Also prod-scope the AGENTS.md note that the "externalized" string isn't a
runtime message (under `npm run dev` Vite's stub is a Proxy that
console.warns it), and document both the sync and async failure signals.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* ci: add headless-browser boot smoke for the web client (#1615) smoke:web only asserts GET / serves the SPA HTML with the injected token; it never runs the React app, so a Node built-in reaching the browser bundle (which crashes the app to a blank page at runtime, e.g. #1612) slips through. Add smoke:web:browser, which boots the same prod --web server and runs the bundle in headless Chromium (Playwright, already a clients/web devDependency), asserting the app renders its first frame (the "Add Servers" control) with no uncaught page errors — in particular no `Module "node:*" has been externalized`. This catches "Node code reached the browser bundle" as a class, not one import at a time. Wire it into the root smoke chain (and thus npm run ci / GitHub CI), and move the Playwright install/cache steps ahead of the smoke step so the new smoke reuses the cache. Update AGENTS.md and README. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * ci: address review on the headless-browser boot smoke - Don't fail on benign console.error (CDN font loads, React warnings) — the #1612 class arrives as an uncaught pageerror. console errors are now diagnostics, hard-failing only when they carry the externalization signature. - Broaden the externalization regex to non-prefixed built-ins ("fs" as well as "node:fs") and label the signature on both the pageerror and console channels. - Rename the port override to SMOKE_WEB_BROWSER_PORT so it doesn't collide with smoke:web's SMOKE_WEB_PORT. - Add a settle window (networkidle + 500ms) so a late-evaluating lazy chunk that throws is caught, and race the render against launcher death so a mid-load crash reports the real cause instead of a render timeout. - Print an actionable hint when Playwright/chromium can't launch (missing --with-deps system libs). - Extract the shared spawn/readiness boilerplate into scripts/lib/prod-web-server.mjs so smoke-web and smoke-web-browser can't drift. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * ci: fix playwright resolution in the browser smoke (CI failure) The bare `import("playwright")` resolved relative to the script's own directory (scripts/), not the cwd — so `cd clients/web` in the npm script never made it resolvable. It only passed locally because an ancestor node_modules happened to carry playwright; CI has none, so it failed with "Cannot find package 'playwright'". Resolve it explicitly from clients/web via createRequire, independent of cwd and ancestor dirs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * ci: address second review on the prod-web-server helper - waitForReady now records the last non-ok status and includes it in the timeout message, so a server that boots but persistently answers 500 (broken dist / injection failure) reports "server did not start … (last response: HTTP 500)" instead of a bare timeout that points at the wrong thing. Keeps polling on non-ok (a warming server may answer 503). - Add a child 'error' handler so a spawn-level failure surfaces as the smoke's FAILED line instead of an uncaught raw stack; readiness and the child-exit race both report it. - Drop the unused isExited/exitCode/child exports from the helper API. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * ci: reframe browser smoke around uncaught pageerror; fix stop() edge Third-review findings. 1. The `Module "…" has been externalized` string can't fire against a Vite 8 *prod* bundle — verified end-to-end against the installed vite@8.0.0: a node: import reaching the browser graph is a BUILD-time warning, and the shipped stub is a silent `module.exports = {}`. The real #1612 manifestation is the first *call* into that empty stub throwing a TypeError at module init (confirmed: injecting a used `node:fs` import fails the smoke with "readFileSync is not a function"; an unused one ships {} and is harmless). So the signature machinery was dead against the bundle it runs on — dropped it. The smoke now asserts exactly what it verifies: the prod bundle boots and paints with no uncaught page error. Reworded the script header, the AGENTS.md bullet, and the README row to match. Console errors stay diagnostic-only. 2. The helper's child 'error' handler set `exited = true`, but Node emits 'error' for a failed kill/send too (child still alive) — so `stop()` (guarded on `exited`) would skip the SIGTERM and orphan the launcher on the port. Track the error separately (`childError`); `bootFailed()` = exited || childError, while `stop()` still SIGTERMs a live child. Docs: fixed the stale AGENTS.md claim that `cd clients/web` makes `import("playwright")` resolve (createRequire does, cwd-independently; the cd is only for `npx playwright install`), and noted the shared scripts/lib/prod-web-server.mjs helper on both smoke bullets. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * ci: also hard-fail the async half of the crash class (fourth review) page.on("pageerror") fires for uncaught SYNCHRONOUS exceptions only. The async twin of the #1612 shape — the same empty-stub TypeError reached through an await/.then(), or a failed dynamic import (this app lazy-loads chunks) — lands on the console channel as `Uncaught (in promise) …` / `Failed to fetch dynamically imported module`, which were diagnostic-only, so the smoke would have passed. Hard-fail console errors matching /^Uncaught\b|Failed to fetch dynamically imported module/; every other console error stays a printed non-fatal note. The prefixes are unambiguous (a font/CDN miss reads "Failed to load resource: net::ERR_…"; React warnings never start with "Uncaught"), so this can't reintroduce the flake the earlier round fixed. Verified: an unhandled rejection at module init now fails the smoke where it previously wouldn't. Also prod-scope the AGENTS.md note that the "externalized" string isn't a runtime message (under `npm run dev` Vite's stub is a Proxy that console.warns it), and document both the sync and async failure signals. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * docs: reword smoke success line + README to two-channel wording (fifth review) After the async hard-failure signature, the assertion spans both the pageerror (sync) and console (unhandled rejection / failed dynamic import) channels. The OK console line and the README smoke row still said "page error"; match the two-channel wording already in the AGENTS.md bullet. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * ci: correct the playwright-package-resolution hint (sixth review) The catch around requireFromWeb("playwright") advised `playwright install --with-deps chromium`, but that installs browser binaries, not the npm package — and the npm script already ran `playwright install` a step earlier. A failure there means the package isn't resolvable from clients/web, so the actionable fix is `npm install` at the repo root (the postinstall cascade installs clients/web's devDependencies). The chromium.launch() catch keeps its --with-deps advice (correct for the missing-system-libraries case). 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>
Depends on #1609 / Blocked by #1609 (retarget to v2/main when #1609 merged)
Closes #1549
Summary
Replace the Zustand
persist+ adapter stack for OAuth runtime state with a directOAuthStorageBase+ persist backend model. OAuth state is written as plain JSON{ servers, idpSessions }to~/.mcp-inspector/storage/oauth.json(file, remote HTTP, or sessionStorage for tests). AllOAuthStoragegetters are async and auto-load; setters auto-persist.load()remains optional preload (OAuth callback fail-fast on web). Removeszustandfrom the repo and deletescore/storage/adapters/*.Why
Zustand persist added an extra in-memory layer,
{ state, version }envelopes on write, and awkward semantics (sync getters vs async HTTP/file I/O) that complicated sign-out, callback resume, and multi-consumer sharing. #1548 wired web throughRemoteOAuthStorage; this PR completes the storage refactor so production code no longer depends on Zustand for OAuth at all.Architecture (after)
{ servers, idpSessions }{ state, version }envelope (old Zustand persist); promotes inner payload; migrate-on-write on next saveBaseOAuthClientProvider.prepareForAuth()caches scope for syncclientMetadata.scope;codeVerifier()is async (SDK already awaits it)getWebRemoteOAuthStorage()→RemoteOAuthStorage→/api/storage/oauth→oauth.jsonNodeOAuthStorage→oauth.jsonKey changes
Core
core/auth/oauth-persist.ts— parse/serialize blob, file / remote / session backendsOAuthStorageBase— all getters async +ensureLoaded(); mutators awaitpersist()OAuthMemoryStore— plain mutable store (no Zustand)BaseOAuthClientProvider—prepareForAuth(), asynccodeVerifier()/getServerMetadata()oauthManager,connection-state,idpOidc, EMA paths —awaitstorage gettersgetOAuthStore()from public API; add@internalresetNodeOAuthStorageCache()for testsclearAllOAuthClientState()reads server URLs from file backend (not Zustand store)core/storage/adapters/{file-storage,remote-storage,index}.tsDependencies / build
zustandfrom root, web, cli, tuipackage.jsonand lockfilesvitest.shared.mts, tsup/tsconfig pathsTests
oauth-persist.test.ts(legacy envelope promotion, plain JSON serialize)getOAuthStoremockResolvedValueload()pattern)Docs
AGENTS.md— OAuth persist backends, no Zustand in treespecification/v2_auth_ema.md— OAuth persistence (Remove Zustand file-store wrappers; auto-convert to plain JSON on read #1549 done), checklist updatedspecification/v2_storage.md,v2_servers_file.md,v2_scope.md, and related auth specs alignedAPI notes for reviewers
getCodeVerifier,getScope, …Promise<…>; callawaitawait load()before sync readsload()optional at boundariesgetOAuthStore()NodeOAuthStorage+resetNodeOAuthStorageCache){ state, version }on writeWeb OAuth callback still calls
await webOAuthStorage.load()beforeresumeAfterOAuthfor fail-fast if/api/storage/oauthis unreachable — not because getters require preload.Migration / compatibility
oauth.jsonfiles with{ state, version }continue to work; first save rewrites as plain JSON