fix(auth): order /api/config loads so an older response cannot win

Round 15, one finding — in the refresh path added one commit ago.

`refresh()` fires after each secret-bearing save, and nothing ordered the
responses. Two debounced settings saves landing close together issue two
refreshes; if the earlier request resolves last, it commits the descriptor
from *before* the write and the footer reverts to "Plaintext file" for a file
that is now encrypted. Reverting a security statement to a stale value is the
worst direction for this particular field to be wrong in, and it needed no
adverse network conditions — just two saves in quick succession.

Loads now claim a monotonic token and may commit only while they still hold
the current one. That subsumes the liveness ref the previous commit added:
bumping the token on effect teardown covers unmount *and* a `baseUrl` /
`authToken` change, and bumping it on every load covers the overlap.

The test drives it by holding the `fetch` promises and resolving them out of
order — newer first, then older — which fails against the previous
implementation and passes here.

The remaining suppressed comment is the hand-off's check-to-delete race,
which is the cross-process guarantee tracked in #2082.

Full `npm run ci` green.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-22 20:14:23 -04:00
commit 0ab542cbee
2 changed files with 108 additions and 52 deletions

View file

@ -11,6 +11,18 @@ import { describe, it, expect, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react"; import { act, renderHook, waitFor } from "@testing-library/react";
import { useInitialConfig } from "@inspector/core/react/useInitialConfig"; import { useInitialConfig } from "@inspector/core/react/useInitialConfig";
function payloadWithPlaintext(plaintext: boolean): Response {
return jsonResponse({
secretStorage: {
kind: "file",
reason: "fallback",
durable: true,
plaintext,
path: "/tmp/secrets.json",
},
});
}
function jsonResponse(body: unknown, ok = true): Response { function jsonResponse(body: unknown, ok = true): Response {
return { return {
ok, ok,
@ -506,6 +518,46 @@ describe("useInitialConfig", () => {
expect(fetchFn).toHaveBeenCalledTimes(2); expect(fetchFn).toHaveBeenCalledTimes(2);
}); });
it("commits only the newest load when refreshes overlap", async () => {
// Two debounced settings saves landing close together fire two
// refreshes, and nothing orders the responses. Before the request
// token, an earlier request resolving last put the footer back to the
// descriptor it had *before* the write — reverting a security
// statement to a stale value, which is the worst direction for this
// particular field to be wrong in.
const resolvers: Array<(v: Response) => void> = [];
const fetchFn = vi
.fn()
.mockImplementation(
() => new Promise<Response>((resolve) => resolvers.push(resolve)),
);
const { result } = renderHook(() =>
useInitialConfig({ baseUrl: "http://test.local", fetchFn }),
);
// Mount fetch is resolvers[0]; settle it so `loading` clears.
await act(async () => {
resolvers[0]?.(payloadWithPlaintext(true));
});
await waitFor(() => expect(result.current.loading).toBe(false));
// Two overlapping refreshes: the first reports the pre-write state,
// the second the post-write state.
act(() => result.current.refresh()); // resolvers[1] — older
act(() => result.current.refresh()); // resolvers[2] — newer
// Resolve the NEWER one first, then the older one.
await act(async () => {
resolvers[2]?.(payloadWithPlaintext(false));
});
await act(async () => {
resolvers[1]?.(payloadWithPlaintext(true));
});
// The late-arriving older response must not win.
expect(result.current.secretStorage?.plaintext).toBe(false);
});
it("drops a refresh that resolves after unmount", async () => { it("drops a refresh that resolves after unmount", async () => {
const fetchFn = vi.fn().mockResolvedValue(jsonResponse({})); const fetchFn = vi.fn().mockResolvedValue(jsonResponse({}));
const { result, unmount } = renderHook(() => const { result, unmount } = renderHook(() =>

View file

@ -67,8 +67,10 @@ export interface UseInitialConfigResult {
* about a file that is no longer plaintext. The backend already re-derives * about a file that is no longer plaintext. The backend already re-derives
* the descriptor per request; this is the client half of that. * the descriptor per request; this is the client half of that.
* *
* Safe to call at any time: the GET is idempotent, and a response that * Safe to call at any time and safe to overlap: the GET is idempotent, and
* arrives after unmount is dropped. * loads are ordered by a request token, so only the most recent one may
* commit — a response that arrives after unmount, after a re-fetch, or out
* of order behind a newer refresh is dropped.
*/ */
refresh: () => void; refresh: () => void;
} }
@ -179,63 +181,65 @@ export function useInitialConfig(
>(undefined); >(undefined);
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
// `isCancelled` lets the effect drop a response that resolves after unmount or /**
// after a re-run (baseUrl/authToken change), so a stale payload can't overwrite * Monotonic request token. Every load claims the next value; a load may
// current state. React 18 no longer warns on setState-after-unmount — it's a * commit only while it still holds the current one.
// silent no-op — so the guard is about correctness, not the warning. *
const load = useCallback( * This replaces a per-caller "am I cancelled" flag, which was enough for
async (isCancelled: () => boolean): Promise<void> => { * the mount fetch and *not* enough once `refresh()` existed. Two overlapping
const headers: Record<string, string> = {}; * refreshes — two debounced settings saves landing close together — are
if (authToken) headers["x-mcp-remote-auth"] = `Bearer ${authToken}`; * unordered, so the earlier request could resolve last and put the footer
try { * back to the descriptor it had before the write. Stale-response ordering
const res = await doFetch(`${base}/api/config`, { * matters more here than in most places precisely because the field it
method: "GET", * would revert is a security statement.
headers, *
}); * Bumping on effect teardown covers the other two cases for free: unmount,
if (isCancelled() || !res.ok) return; * and a `baseUrl`/`authToken` change that re-runs the effect.
const body = (await res.json()) as ConfigPayload; */
if (isCancelled()) return; const generation = useRef(0);
// Tolerate missing/non-usable fields — a legacy backend that omits any
// of them leaves that value at its "unavailable" default rather than const load = useCallback(async (): Promise<void> => {
// showing a bogus value. const mine = ++generation.current;
setVersion(usableString(body.version)); const stale = () => generation.current !== mine;
setSandboxUrl(usableString(body.sandboxUrl)); const headers: Record<string, string> = {};
// Only an explicit `false` makes the list read-only; a missing field if (authToken) headers["x-mcp-remote-auth"] = `Bearer ${authToken}`;
// (legacy backend) stays writable. try {
setWritable(body.writable !== false); const res = await doFetch(`${base}/api/config`, {
setSecretStorage(usableSecretStorage(body.secretStorage)); method: "GET",
} catch { headers,
// Network error / aborted fetch: leave every field at its default });
// (version/sandboxUrl undefined, writable true). if (stale() || !res.ok) return;
} finally { const body = (await res.json()) as ConfigPayload;
if (!isCancelled()) setLoading(false); if (stale()) return;
} // Tolerate missing/non-usable fields — a legacy backend that omits any
}, // of them leaves that value at its "unavailable" default rather than
[base, authToken, doFetch], // showing a bogus value.
); setVersion(usableString(body.version));
setSandboxUrl(usableString(body.sandboxUrl));
// Only an explicit `false` makes the list read-only; a missing field
// (legacy backend) stays writable.
setWritable(body.writable !== false);
setSecretStorage(usableSecretStorage(body.secretStorage));
} catch {
// Network error / aborted fetch: leave every field at its default
// (version/sandboxUrl undefined, writable true).
} finally {
if (!stale()) setLoading(false);
}
}, [base, authToken, doFetch]);
useEffect(() => { useEffect(() => {
let cancelled = false; void load();
void load(() => cancelled);
return () => { return () => {
cancelled = true; // Invalidate whatever is in flight: this fires on unmount and on a
// `baseUrl`/`authToken` change, and in both cases a response for the
// old inputs must not commit.
generation.current++;
}; };
}, [load]); }, [load]);
// A manual re-fetch shares `load`'s cancellation contract, but has no
// effect teardown to key off — so it tracks its own liveness through a ref
// that the unmount effect flips. Without that, a refresh fired just before
// unmount would still call `setState` on a dead component.
const alive = useRef(true);
useEffect(() => {
alive.current = true;
return () => {
alive.current = false;
};
}, []);
const refresh = useCallback(() => { const refresh = useCallback(() => {
void load(() => !alive.current); void load();
}, [load]); }, [load]);
return { version, sandboxUrl, writable, secretStorage, loading, refresh }; return { version, sandboxUrl, writable, secretStorage, loading, refresh };