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
|
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