feat(desktop): mirror the login tokens into the OS keychain (#105, step 1)
CI / Build & Quality Checks (pull_request) Successful in 1m46s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 8m44s

Step 1 of moving the desktop app's login out of the webview's plaintext
localStorage: keep a verified copy of the tokens in the OS keychain
(Windows Credential Manager, via cinny-desktop's new secure_session_*
commands). The session is still read from localStorage exactly as before,
so nothing about login changes and a keychain problem can't log anyone out.
Step 2 (a later release, once this has run on real installs) switches reads
to the keychain and drops the tokens from localStorage.

- sessions.ts: onSessionPersisted — listeners told about every session
  write (login, token rotation) and removal (logout); a throwing listener
  can't break the write.
- keychainMirror.ts: desktop only. Mirrors userId/deviceId/accessToken/
  refreshToken (not the rest of the session); reads first and writes only
  when the copy differs, then verifies by reading back; serialized, 5 s
  timeouts; no session → clear (also covers a logout whose reload beat the
  clear). Every failure is a status, never an exception. A desktop build
  without the commands reads as "unsupported", so this can ship before the
  desktop side.
- Settings → General (desktop): "Login in the system keychain" status.

Tested: unit tests (fake keychain: store, no rewrite when current, rotation,
clear, unsupported, missing commands, denied write, read-back mismatch,
timeout); a simulated desktop app with a fake keychain, 14/14 (login
mirrors only the secrets, Settings status, reload verifies without
rewriting, logout clears, an existing session is mirrored after upgrade,
Linux/denied show an honest status and stay logged in); the real Linux
desktop binary (commands answer "unsupported", login unaffected, Settings
says so). Unit 1295 pass, Playwright 20 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
Lotus CI
2026-09-29 00:24:46 -04:00
co-authored by Claude Opus 5.5
parent 91f82d60e3
commit 9b9f33b270
6 changed files with 327 additions and 0 deletions
+17
View File
@@ -432,3 +432,20 @@ test('subscribeSessionChanges ignores unrelated storage keys', () => {
listeners.forEach((cb) => cb({ key: 'some_unrelated_preference' }));
assert.equal(fired, false);
});
test('onSessionPersisted: told about writes and removals; a throwing listener is harmless', async () => {
installStorage();
const { onSessionPersisted } = await import('./sessions');
const seen: (string | null)[] = [];
const offBad = onSessionPersisted(() => {
throw new Error('boom');
});
const off = onSessionPersisted((s) => seen.push(s ? s.accessToken : null));
setFallbackSession('tok-a', 'DEV', '@a:hs', 'https://hs', { refreshToken: 'ref-a' });
removeFallbackSession();
off();
offBad();
setFallbackSession('tok-b', 'DEV', '@a:hs', 'https://hs');
assert.deepEqual(seen, ['tok-a', null]);
assert.equal(getFallbackSession()?.accessToken, 'tok-b', 'write still happened');
});