fix(auth): address Copilot review round 3 on the secret-store PR

Nine findings (six inline, three suppressed), all taken. Two were serious.

**The memory fallback made the migrations destructive.** Both migrations —
mcp.json in the remote server, client.json in `node-persistence` — lift a
plaintext secret off disk, write it to the secret store, and then delete it
from the file. That trade is safe only while the store is at least as
durable as the disk, which held by construction while `InMemorySecretStore`
was a test double. Making it a production fallback broke it: on an
unmounted container a plain `GET /api/servers` would have moved a user's
existing secrets into RAM and lost them at exit — destroying data on a
read.

`SecretStore` gains an optional `isDurable()`, absent meaning durable, and
both migrations withhold the disk delete when it answers false (the value
is still loaded, so the session behaves normally). The production fallback
is now a named `SessionSecretStore` rather than the bare
`InMemorySecretStore`, so the suite's many doubles keep standing in for a
*working keychain* — which is what they are there to be — and only code
that deliberately chooses RAM in production says so.

**Round 2's per-call descriptor never reached anyone.** Both web servers
resolved it once at startup and baked it into `initialConfig`, which
`/api/config` serves unchanged — so the freshness fix was inert for its
only consumer. The route now takes a `secretStorageResolver` and calls it
per request.

The lock needed three more fixes, and they compound:

- **Release removed whatever directory was at the path.** A holder whose
  work outran the stale threshold would delete the *next* owner's lock,
  letting a third writer into a section two processes believed they held.
  Ownership is now stamped with a random token and release only removes a
  lock still carrying it.
- **The timeout fell through to an unlocked write** — under contention,
  the one moment another writer is provably active. It now throws
  `SecretFileLockTimeoutError`, which `set` is allowed to do.
- **The first write of every process bypassed the lock**, because the
  secrets directory does not exist yet and `mkdir` failed ENOENT. The
  parent is created first. First writes are exactly when two processes are
  most likely to start together.

Fixing the timeout exposed a fourth: with stale (10s) below timeout, a
waiter stole from a holder that was merely *slow* rather than dead —
reinstating the lost update. A holder now heartbeats its lock, so
"stale" means the holder is gone, and the timeout is what a live-but-slow
holder produces. Lock timings are injectable so the tests can exercise
contention, staleness, and timeout without spending the real intervals.

Also: the loose-permission state is carried into `SecretStorageInfo` as
`looseMode` and rendered as the caveat (it outranks the encryption caveat —
a file others can read is a live exposure, while "unencrypted" is a
property of a file only its owner can open), rather than living in a
startup log the browser cannot see; and `usableSecretStorage` validates
`pendingEncryption` and `looseMode` as the optional booleans/numbers they
are (`pendingEncryption: "false"` is truthy and would have printed advice
that is the opposite of the truth) and rejects file-only fields on a
keychain or memory descriptor.

Tests: both migrations against a session store with a durable control
alongside, so the guard is visibly the difference; `/api/config` serving a
changing resolver and falling back without one; lock ownership after a
steal, the timeout under a live holder, no-steal from a slow holder, and a
locked first write; `looseMode` through the descriptor, the caveat
ordering, and the tone; and five more rejected payload shapes. Full
`npm run ci` green — 5,704 tests under the coverage gate.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-22 13:19:56 -04:00
commit b143dd6cbd

View file

@ -301,6 +301,24 @@ const PROBE_ACCOUNT = "__inspector:probe";
* be lost.
*/
export interface SecretStore {
/**
* Do values written here outlive the process?
*
* Optional, and **absent means durable** — every store that predates
* #1950 is, and a custom one is far more likely to be backed by
* something than by RAM.
*
* It exists because the *migrations* need it. Both of them (mcp.json in
* `server.ts`, client.json in `client/node-persistence.ts`) lift a
* plaintext secret off disk, write it to this store, and then strip it
* from the file — a safe trade only while "written to the store" is at
* least as durable as "left on disk". `InMemorySecretStore` used to be
* a test double only, so that held by construction; making it a
* production fallback broke it, and a plain `GET /api/servers` on an
* unmounted container would have moved a user's existing secrets into
* RAM and lost them at exit.
*/
isDurable?(): Promise<boolean>;
get(serverId: string, field: string): Promise<string | null>;
set(serverId: string, field: string, value: string): Promise<void>;
/** No-op if no entry exists. */
@ -439,6 +457,21 @@ export class KeyringSecretStore implements SecretStore {
* server factory. Mirrors the keyring contract exactly so swapping it
* in/out doesn't change behavior beyond persistence.
*/
/**
* Is `store` durable? Absent `isDurable` means yes — see the interface.
*
* A free function rather than a required method so the existing test
* doubles (and any third-party implementation) keep compiling, while the
* one store that is *not* durable has to say so explicitly. Defaulting the
* other way would make every double silently non-durable and quietly
* disable the migrations they exist to exercise.
*/
export async function secretStoreIsDurable(
store: SecretStore,
): Promise<boolean> {
return store.isDurable ? store.isDurable() : true;
}
export class InMemorySecretStore implements SecretStore {
private readonly map = new Map<string, string>();
@ -461,3 +494,26 @@ export class InMemorySecretStore implements SecretStore {
}
}
}
/**
* The in-memory store as a **production** choice — the fallback for a
* container with no keychain and nothing durable mounted (#1950).
*
* Behaviorally identical to {@link InMemorySecretStore}; it exists to
* answer `isDurable()` with `false`, and that one bit is load-bearing.
* Both migrations (mcp.json, client.json) lift a plaintext secret off
* disk, write it to the store, and then delete it from the file — safe
* only while the store outlives the process. Against a session-scoped
* store that trade destroys the secret, and it runs on an ordinary read,
* so merely opening the app would do it.
*
* Kept as a separate class rather than a flag on the base so the test
* suite's many `new InMemorySecretStore()` doubles keep standing in for a
* *working keychain*, which is what they are there to be. Only the code
* that deliberately chooses RAM in production says so.
*/
export class SessionSecretStore extends InMemorySecretStore {
async isDurable(): Promise<boolean> {
return false;
}
}