settings: keep the Voices tab in step with the current session (#227) - #636
Conversation
The settings header has re-rendered on auth changes for a while now: it listens for the background's AUTH_STATUS_CHANGED broadcast and for a jwtToken write in storage. But it only updates a flag of its own. Nothing on the settings page reconciled the page's OWN JwtManager singleton, which loads from storage once at page load and has no storage listener. So the Voices tab — which read auth straight off that singleton and only ever re-rendered on a voice-preference change or a window focus — went on showing the previous session until the user reloaded the tab. Sign in from the settings page and the header greets you while Voices still says "sign in for TTS". The obvious fix is to reuse the content script's reconciler, handleAuthStatusUpdate(). Its authenticated branch is safe here and is reused as-is: loadFromStorage() plus the EventBus emit, against a storage that already holds the new token. Its SIGNED-OUT branch is not. It calls JwtManager.clear(), whose clearRefreshAlarm() is a harmless no-op in a content script (no alarms API) but very much not one on an extension page, where alarms exist and are extension-wide: it would delete the saypi-jwt-refresh schedule the background service worker owns. And a signed-out broadcast does not always mean a real sign-out — the background emits one on a transient 401 while deliberately preserving the recovery credentials, and a failed refresh schedules its own backoff retry on that same alarm. Clearing it from a settings tab would turn a recoverable blip into a session that never comes back, invisibly. So the new settings-scoped reconciler records signed-out in its own state and emits the same event, leaving the singleton's token, the alarm and the stored credentials alone; consumers read isSettingsAuthenticated() instead of the singleton, so a token the page has been told is dead can never make a tab render or fetch as the previous user. Reconciliation completes before the event is emitted, because listeners read auth state synchronously while they re-render (#456). The Voices studio gains an onAuthChange dep alongside onVoiceChange and drops its cached catalogs on a session change rather than repainting the previous account's rail — the catalog is fetched per account, so a swap invalidates it. Dropping the caches opened a race the render token cannot cover (a cache write happens before the paint check), so ensureData now stamps its fetches with a session epoch and refuses to file an answer that belongs to a session that is over. The quota panel is the other auth-scoped surface on the page, so it re-runs its own status read on the same event. Fixes #227 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HYdTADVdmkpPVNELAZfKbd
The first cut keyed the reconciler's "is this new?" check on the raw JWT string, which made the background's routine ~15-minute refresh look like a change: with the settings page open, the Voices tab would silently drop its catalog, re-fetch it and repaint the rail every quarter of an hour, for a session that had not changed at all. Key it on the token's userId claim instead — identity is what a settings tab actually cares about — and split the two things the reconciler was doing. The page's JwtManager still picks up every new token, because it must: the one it holds otherwise expires under an open page and every consumer starts reading a signed-in user as signed out. Only a change of SESSION reaches the tabs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HYdTADVdmkpPVNELAZfKbd
A sign-out reaches the page as up to two events — the background's broadcast and the storage wipe it describes, in either order — so keying the dedupe on "authenticated plus whatever identity storage still holds" announced the same sign-out twice, and made the Voices tab drop its catalog and repaint twice for it. Signed out has no identity; give it one signature and the second event is correctly seen as nothing new. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HYdTADVdmkpPVNELAZfKbd
…es (#227) The reconciler's emit and the studio's re-render were each pinned, but nothing tested them joined: a broken `defaultDeps` wiring would have left both green. This drives the issue's own AC 1 — a settings page open on a signed-in session, a real background sign-out broadcast, and the rail's copy flipping — through the real reconciler and a real VoicesController, stubbing only the studio's network-bound deps. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HYdTADVdmkpPVNELAZfKbd
…#227) Review found that the module's headline claim was stronger than the code: "a token the page has been told is dead can never make a tab render or fetch as the previous user" was false for the one consumer that matters. SpeechSynthesisModule keys its voice cache on an auth fingerprint it reads straight off the JwtManager singleton — and this page deliberately keeps that singleton's token on sign-out, so the fingerprint stayed `user:A` and the rail re-read the catalog only to be handed user A's cached list, custom voices and all, under a "sign in for TTS" label. A content script's singleton IS the honest answer there; an extension page's is not. So the module now takes a per-instance auth reader, and the studio's deps point it at the settings-scoped state. Per-instance because each extension context has its own module instance — the settings page sets this without reaching into the content scripts'. Also from the review: - The alarm story was half-told. The invariant is "never clear without re-creating", not "never touch it": the authenticated path's loadFromStorage() does clear and immediately re-create the schedule (as it already does on every settings page load), and only the sign-out branch's bare clear() destroys it. Said so, with the two honest edges that come with it, and moved the test assertion onto the path that can actually break. - A plan upgrade is a change this page must show, so planId rides in the session signature. Quota numbers deliberately do not — they fall with every transcription. - The quota bars live in the lazily-mounted General tab, so refreshing the status after a deep link into Voices dereferenced DOM that was never built. - The auth event's payload is what the background said, which can run ahead of the storage write it describes; documented as advisory. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HYdTADVdmkpPVNELAZfKbd
Independent adversarial review — verdict
|
Independent review verdict: REQUEST CHANGESReviewed by a subagent with no part in authoring this change. The rendering half is well-reasoned and genuinely well-tested; one gap has to close before merge. Must-fix: the settings-scoped state gates rendering and the cache key, but not the outbound requestThe design deliberately keeps the singleton's in-memory token alive on sign-out — correctly, to protect the background's refresh alarm — and the tests prove that is intentional ( But the Voices tab's new So after a sign-out: the new "anonymous" fingerprint misses the cache, a real HTTP request goes out carrying the previous user's still-valid bearer token, and the response is that user's actual catalog — while the UI says "sign in for TTS". On a shared machine that is a second person triggering an authenticated fetch of the first person's data. The PR's own test for this case mocks Worth noting the PR gets this right elsewhere: the quota path asks the background for claims and returns early before any fetch, never consulting the page-local singleton. Voices should follow the same principle. Two workable directions: short-circuit Everything else checks out
Holding the merge on the fetch-gating point only. |
The rendering half of this fix was complete and the request half was not.
`isSettingsAuthenticated()` gated what the Voices tab drew and which cache
entry it read, but the fetch underneath ran on a different authority:
`SpeechSynthesisModule.getVoices()` -> `TextToSpeechService.getVoices()` ->
`callApi()`, whose `Authorization` header comes from a JwtManager — this
page's on the direct path, the background's on the proxied one — and never
from the settings-scoped state.
That gap only opened because of this PR. Before it, a sign-out left the tab
frozen until reload; now it clears the catalog cache and re-renders, so the
newly-"anonymous" fingerprint misses and a real GET
`/voices?app=claude` leaves the page. On the proxied path the background
attaches its own (already-cleared) header, so the usual outcome is a
pointless unauthenticated request — but `callApi` falls back to a direct
fetch whenever the proxy fails (a network error inside `handleApiRequest`
answers `{success:false}`, which rejects), and that fallback reads the
page's singleton: the one this design deliberately never clears. A transient
blip is enough to put the previous account's live bearer on the wire from a
page that has just been told the session is over.
The fix follows the shape the quota panel already uses — ask the auth
reader, return before any fetch — placed at the module's injected
`authStateReader` rather than at the studio's dep, so a future caller of
`speech.getVoices()` on this page cannot walk around it. Scoped to contexts
that injected a reader on purpose: content scripts keep asking and let the
server answer, because "the singleton holds no token" is a client-side
expiry judgement about a stateless bearer, and this repo verifies no
server-side revocation behaviour that would let the client answer for it.
That same unverified assumption was baked into the AC 3 spec, which mocked
`getVoices` to resolve empty and called it "mapped from 401". It now re-arms
the stub with user A's voices, so an empty result can only mean the page
declined to ask — and a new test asserts that directly at the request layer,
over the real `TextToSpeechService` with only `callApi` spied. Before the
fix it fails with the URL it should never have requested; after it, nothing
leaves while the singleton still holds a `Bearer`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HYdTADVdmkpPVNELAZfKbd
Must-fix addressed in
|
`main` moved eleven commits while this PR was in review, and #644 (voice playback speed and volume controls) landed a new `describe` block at the end of test/settings/tabs/voices-controller.spec.tsx a minute before this branch's last push — the same place #227's session-change block goes. GitHub stops scheduling `pull_request` workflows once a PR cannot be merged, so the conflict was also costing the PR its CI verdict. An append/append conflict at EOF: both blocks kept, in order. Nothing else in the merge is ours — the tree now differs from `main` only in this PR's own seven files. `npm test` green on the merge: tsc clean, Jest 2/2, Vitest 2978 passed (259 files). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HYdTADVdmkpPVNELAZfKbd
Note: merged
|
…old (#227) The first cut of the guard suppressed the catalog request whenever an injected reader said "signed out". That is too wide, and Layer 3 caught it: a settings page that was never signed in also reads signed-out, holds nothing stale, and would have had its voice rail emptied for no reason — the E2E harness is exactly that page, with a mock API that serves /voices without auth. The guard now requires all three: a reader was injected (an assertion that the singleton is not the truth here), it says signed out, and the manager would nonetheless still attach an Authorization header. Only then is there something to withhold. An empty-handed page keeps asking, because "the client has no token" is a client-side judgement about a stateless bearer, and the server is the honest arbiter of what an anonymous caller may see. Adds the narrow-half test alongside the existing one, so the pair pins both directions: withhold a live credential, but never answer for the server. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HYdTADVdmkpPVNELAZfKbd
Correction: my first cut of the guard was too broad — Layer 3 caught it (
|
| guard condition | voices-rail.e2e.ts |
|---|---|
reader && !reader() (as pushed in 044ce65) |
10 failed / 10 |
reader && !reader() && getAuthHeader() !== null |
10 passed; 24 passed across all three voices specs; 48 passed for the whole L3 suite |
What I got wrong. I collapsed two situations into "signed out". Only one is a hazard:
- The page has been told the session ended while the singleton still holds the previous account's live bearer — that is the bug.
callApiwould authenticate the request against the page's own judgement. - The page was never signed in — nothing to leak. Suppressing there only denies an anonymous visitor whatever catalog the server is willing to serve one. That is the server's call, not a client's reading of a stateless bearer's client-side expiry — which is the exact argument I used to keep content scripts out of the guard, and I failed to apply it to the settings page's own signed-out state.
Not hypothetical: the Layer-3 mock serves /voices unauthenticated and the E2E settings page never signs in, so the broad guard emptied the rail. A signed-out user opening Voices in production would have seen the same thing if the API serves an anonymous catalog — a UX regression I'd have shipped on an assumption about server behaviour, which is precisely the failure mode your review flagged in the test. Worth recording that the L3 mock is the only place in this repo that states what /voices does for an anonymous caller, and it says "serves the catalog" — the opposite of the 401 -> [] assumption.
The guard now requires all three: a reader was injected, it says signed out, and getAuthHeader() would still attach something. getAuthHeader() rather than isAuthenticated() because the question is literally "would a credential be sent" — which stays true for a token past its client-side expiry.
The sign-out test is unchanged and still passes: after a sign-out broadcast the singleton holds a live bearer by design, which is condition three. A new test pins the other side (still asks when the page holds no credential to leak) and fails against the broad condition, so a future tightening can't quietly empty the rail again.
Verification: npm test — tsc clean, Jest 2/2, Vitest 2979 passed | 1 skipped (259 files). Layer 3 — npm run e2e:build && npx playwright test → 48 passed locally.
Merging — the review's must-fix is closed, and closing it surfaced a second defectThe reviewer's finding was right in conclusion and one step off in mechanism, and the author said so rather than accepting the trace as given. The settings page is not a service-worker context and the API host is allow-listed, so The guard sits at the module's injected auth reader rather than the studio's dependency, so a future caller of Then the first version of the guard was itself too broad, and Layer 3 caught it — the honest part of this exchange. Suppressing on "the reader says signed out" also silenced a page that had never signed in, which holds nothing to leak. The E2E settings page is exactly that, and the voices rail emptied: 10 of 10 failures on the broad condition, 10 of 10 passes on the narrow one. In production a signed-out visitor would have lost the same rail. The guard now requires all three conditions together: a reader was injected, it says signed out, and a credential would still be attached. Also worth recording, and filed separately: the Layer-3 mock is the only place in this repo that states what Verification: full suite green, Layer 3 run locally (48 passed) as well as in CI, and all five checks green. |
Fixes #227
Why
The settings header has re-rendered on auth changes for a while:
src/popup/auth.jslistens for the background'sAUTH_STATUS_CHANGEDbroadcast and for ajwtTokenwrite inchrome.storage.local. But it only updates a module-local flag of its own. Nothing on the settings page ever reconciled the page's ownJwtManagersingleton, which loads from storage once at page load and has no storage listener.The Voices tab read auth straight off that singleton (
isAuthenticated: () => getJwtManagerSync().isAuthenticated()), and its only re-render triggers were a voice-preference change and awindowfocus — with a render that short-circuits whenrenderedAuthenticatedalready matches. So after a sign-in, sign-out or account switch elsewhere, the header flipped and Voices did not: sign in from the settings page and the header greets you by name while the Voices tab still says "sign in for TTS", until you reload the tab. That is the residual seam in #227 (the 2025 header failure is already fixed); the founder's 2026-09-05 comment on the issue is the scope this PR implements.What
New:
entrypoints/settings/shared/auth-sync.ts— a settings-scoped reconciler installed fromentrypoints/settings/index.tsbefore anything renders. It listens to the same two signals the header already uses, and reacts to a presence flip or a value change (a value change with no presence flip is an account switch, which the header'shadToken !== hasTokentest ignores entirely). It exposesisSettingsAuthenticated()andonSettingsAuthChange().It separates reconciling from announcing, which matters more than it first looks. The page's
JwtManagerpicks up every new token, because it must: the one it holds otherwise expires under an open settings page and every consumer starts reading a signed-in user as signed out. But only a change of session reaches the tabs. The dedupe signature is the session —user:<id>, orsigned-out, which has no identity to carry — not the raw token, so: the background's routine ~15-minute refresh does not silently re-fetch the voice catalog and repaint the rail every quarter of an hour; the two signals that describe one change (the background both broadcasts and writes storage, in either order) collapse into one announcement; and its re-broadcast on every service-worker wake is correctly seen as nothing new.The explicit decision NOT to reuse the content-script sign-out path. The obvious move is to call
handleAuthStatusUpdate()fromsrc/AuthStatusSync.tsfor both directions. Its authenticated branch is safe here and is reused verbatim — it is onlyloadFromStorage()plus the EventBus emit, run against a storage that already holds the new token, and the refresh it reschedules matches what the background scheduled anyway. Its signed-out branch is not safe here:JwtManager.clear(), which callsclearRefreshAlarm()→browser.alarms.clear('saypi-jwt-refresh');JwtManager), but the settings page is an extension page:alarmsis in the manifest, and alarms are extension-wide. Clearing it from a settings tab deletes the schedule the background service worker owns;JwtManager.refresh()down its silent-401 branch (reached viacookies.onChanged, and viapollAuthCookiewhere its interval actually survives — on MV3 the worker is usually evicted first, perdoc/codebase-caution-map.md), which nullsjwtTokenin storage while deliberately preservingauthCookieValue/oauthRefreshTokenas the way back in (see the rationale comment at the top ofAuthStatusSync.ts, and PR fix(tts): invalidate voice cache on auth-state change (#456) #457). A failed refresh separately schedules its own backoff retry on that same alarm (src/JwtManager.ts:529inrefresh()'s catch, and:727inrefreshWithOAuth()'s).So a settings tab calling
clear()would turn a transient 401 into a session that never recovers — silent, and invisible to every test that does not mock the alarms API. The sign-out path here therefore records signed-out in settings-scoped state and emits the same EventBus event, leaving the singleton's in-memory token, the alarm and the stored credentials alone. Consumers readisSettingsAuthenticated()instead of the singleton, so a token the page has been told is dead can never make a tab render or fetch as the previous user.src/JwtManager.tsandsrc/auth/are untouched (founder-gated;path-guardclean).Ordering is load-bearing and asserted. Reconciliation completes before
saypi:auth:status-changedis emitted, because listeners read auth state synchronously while re-rendering (#456).Voices wiring.
VoiceStudioDepsgainsonAuthChangealongsideonVoiceChange;defaultDeps()takesisAuthenticatedfromisSettingsAuthenticated(). On a session change the studio drops its cached catalogs and re-renders rather than repainting the previous account's rail — the voice list is fetched per account, so a swap invalidates it. Dropping the caches opened a race the render token cannot cover (ensureDatawrites the cache before the paint-time token check, so a slow fetch from the previous session could overwrite the new one and un-register its replacement), soensureDatanow stamps each fetch with a session epoch and refuses to file an answer belonging to a session that is over.Quota.
src/popup/status-subscription.jsexposes its existing entry point aswindow.refreshQuotaStatus, and the settings bootstrap re-runs it on the same event — it re-checks auth with the background and then either hides the bars (signed out) or refetches the numbers, so a new account never inherits the previous one's quota display. That is the "quota presentation" half of the issue's AC 3.getJwtManagerSync().isAuthenticated()had exactly one reader acrossentrypoints/settings/andsrc/popup/(the Voices controller), so nothing else needed bringing along.Verification
Fail-first TDD throughout.
npm test(type-check → Jest → Vitest) is fully green.tsc --noEmitNew:
test/settings/shared/auth-sync.spec.ts(14 tests). Liketest/AuthStatusSync.spec.tsit drives the realJwtManagersingleton (only storage is mocked) — a truthful stand-in would hide the bug. Unlike that spec it installs an alarms mock, because that is the whole point: the regression is invisible without one. Each test pins:isSettingsAuthenticated()flips to false,alarms.clearis never called, the storedoauthRefreshTokensurvives, and the singleton is deliberately left authenticated. Proved fail-first: swapping the sign-out branch forhandleAuthStatusUpdate(false)fails this test onexpected "spy" to not be called with arguments: [ 'saypi-jwt-refresh' ].isSettingsAuthenticated() === false.getAuthHeader() === "Bearer <new token>"from inside the emit.jwtTokenvalue changes, presence does not → treated as a change,getClaims().userIdis user B's, and a catalog re-read triggered from the event returns user B's voices, not user A's cache (driven through a realSpeechSynthesisModule, whose auth fingerprint is what drops the stale list).userId) → the singleton holds the refreshed token and the page still reads as authenticated, but no event is emitted. Proved fail-first against the raw-token signature the first commit used (expected [ true ] to deeply equal []).VoicesControllerreading the real settings-scoped state through the real subscription (only the studio's network-bound deps stubbed) → the rail's copy flips fromvoicesSpeaksWithtosignInForTTSwith no reload. Proved fail-first by removing theonAuthChangesubscription. This is what a brokendefaultDepswiring would have slipped past, since both halves pass independently.voices-controller.tscontains nogetJwtManagerSyncreference.Extended:
test/settings/tabs/voices-controller.spec.tsx(+5). Re-renders on a sign-out and on a sign-in that happened elsewhere, with no reload and no gesture; re-reads the catalog instead of repainting the previous session's; unsubscribes ondestroy(); and never lets a previous session's in-flight catalog land in the cache. Proved fail-first: all four subscription tests fail without theonAuthChangewiring, and the race test fails without the epoch guard (expected [ 'marin' ] to deeply equal [ 'ash' ]).test/AuthStatusSync.spec.tsis untouched and green; the existing Voices specs (211 of them, including the one that flips auth viaonShown()) are unmodified and green.One delivery-path note worth recording: the background broadcasts via
browser.tabs.sendMessage, which MDN documents as reaching extension pages in the tab but which Chrome's docs describe only in terms of content scripts. The fix does not depend on the answer — thestorage.onChangedlistener fires in every extension context, and every sign-in/sign-out/switch writesjwtToken(the background's own auth listener is driven off that same write) — so the two listeners are belt and braces rather than a single point of failure.What I could not verify
https://www.saypi.ai/auth/logincompleting SSO. Layer 4 (CDP) drives an already-seeded profile; it cannot do the sign-in leg. This wants a founder spot-check: open settings → Voices, sign in from the header, and confirm the rail's copy flips without a reload.browser.alarmsbehaviour. The alarm-preservation guarantee is proved against a mock. The claim it rests on — thatchrome.alarmson an extension page shares one namespace with the service worker — is documented Chrome behaviour and is why the guard exists, but this PR does not re-prove it in a real browser./voicescatalog actually differs per account on the server today. The account-replacement test proves the client re-reads and does not serve the previous account's cache; it cannot prove the server returns something different.Review round (adversarial reviewer,
REQUEST CHANGES→ addressed)An independent reviewer found two claims stronger than the code supported. Both are now fixed rather than softened:
SpeechSynthesisModule.currentAuthFingerprint()readgetJwtManagerSync()directly, so on a sign-out it stayeduser:Aand the rail's re-read was handed user A's cached list — custom voices and all — under a "sign in for TTS" label. The module now takes a per-instance auth reader (setAuthStateReader), which the studio's deps point atisSettingsAuthenticated. Per-instance because each extension context has its own module instance, so the settings page sets it without reaching into the content scripts'. New fail-first test (AC 3, sign-out direction).loadFromStorage()→scheduleRefresh()does clear and immediately re-create the schedule from the same stored expiry the background used (and already does so on every settings page load, before this PR). Only the sign-out branch's bareclear()destroys it. The module doc now says that, with its two honest edges (scheduleRefreshis un-awaited, so a tab closed inside its millisecond gap leaves no alarm; a token within a minute of expiry takesperformRefresh()instead), and the test assertion moved onto the authenticated path —expect(alarms.create).toHaveBeenCalledWith(JWT_REFRESH_ALARM, …).Also from the review:
planIdjoins the session signature (a plan upgrade is a change this page must show; quota numbers stay out, since they fall with every transcription);updateQuotaProgressnull-guards the quota DOM, which a deep link straight into Voices never builds (lazy tabs — this was a latent throw the new refresh would have made recur); the auth event's payload is documented as advisory, since the broadcast can run ahead of the storage write it describes; and a test now drives both listeners before the queue drains.Two findings declined, with reasons: fencing an in-flight
saveChoiceagainst an auth change (cosmetic — the voice preference is local, not account-scoped, and the reviewer agreed), and dropping the source-text guard that keepsvoices-controller.tsoffgetJwtManagerSync(coarse, but it guards a future call site that the behavioural test would only catch if it happened to affect rendering).🤖 Generated with Claude Code
https://claude.ai/code/session_01HYdTADVdmkpPVNELAZfKbd