Skip to content

settings: keep the Voices tab in step with the current session (#227) - #636

Merged
rosscado merged 8 commits into
mainfrom
fix/227-settings-auth-sync
Sep 6, 2026
Merged

settings: keep the Voices tab in step with the current session (#227)#636
rosscado merged 8 commits into
mainfrom
fix/227-settings-auth-sync

Conversation

@rosscado

@rosscado rosscado commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Fixes #227

Why

The settings header has re-rendered on auth changes for a while: src/popup/auth.js listens for the background's AUTH_STATUS_CHANGED broadcast and for a jwtToken write in chrome.storage.local. But it only updates a module-local flag of its own. Nothing on the settings page ever reconciled the page's own JwtManager singleton, 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 a window focus — with a render that short-circuits when renderedAuthenticated already 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 from entrypoints/settings/index.ts before 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's hadToken !== hasToken test ignores entirely). It exposes isSettingsAuthenticated() and onSettingsAuthChange().

It separates reconciling from announcing, which matters more than it first looks. The page's JwtManager picks 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>, or signed-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() from src/AuthStatusSync.ts for both directions. Its authenticated branch is safe here and is reused verbatim — it is only loadFromStorage() 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:

  • it calls JwtManager.clear(), which calls clearRefreshAlarm()browser.alarms.clear('saypi-jwt-refresh');
  • in a content script that is a harmless no-op (no alarms API — hence the try/catch in JwtManager), but the settings page is an extension page: alarms is in the manifest, and alarms are extension-wide. Clearing it from a settings tab deletes the schedule the background service worker owns;
  • and a signed-out broadcast does not always mean a real sign-out. a dead website cookie sends JwtManager.refresh() down its silent-401 branch (reached via cookies.onChanged, and via pollAuthCookie where its interval actually survives — on MV3 the worker is usually evicted first, per doc/codebase-caution-map.md), which nulls jwtToken in storage while deliberately preserving authCookieValue/oauthRefreshToken as the way back in (see the rationale comment at the top of AuthStatusSync.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:529 in refresh()'s catch, and :727 in refreshWithOAuth()'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 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. src/JwtManager.ts and src/auth/ are untouched (founder-gated; path-guard clean).

Ordering is load-bearing and asserted. Reconciliation completes before saypi:auth:status-changed is emitted, because listeners read auth state synchronously while re-rendering (#456).

Voices wiring. VoiceStudioDeps gains onAuthChange alongside onVoiceChange; defaultDeps() takes isAuthenticated from isSettingsAuthenticated(). 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 (ensureData writes 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), so ensureData now 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.js exposes its existing entry point as window.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 across entrypoints/settings/ and src/popup/ (the Voices controller), so nothing else needed bringing along.

Verification

Fail-first TDD throughout. npm test (type-check → Jest → Vitest) is fully green.

before after
Vitest 2843 passed, 1 skipped (247 files) 2862 passed, 1 skipped (248 files)
Jest 2 passed 2 passed
tsc --noEmit clean clean

New: test/settings/shared/auth-sync.spec.ts (14 tests). Like test/AuthStatusSync.spec.ts it drives the real JwtManager singleton (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:

  • sign-out broadcast with the page openisSettingsAuthenticated() flips to false, alarms.clear is never called, the stored oauthRefreshToken survives, and the singleton is deliberately left authenticated. Proved fail-first: swapping the sign-out branch for handleAuthStatusUpdate(false) fails this test on expected "spy" to not be called with arguments: [ 'saypi-jwt-refresh' ].
  • sign-out ordering → the event's listeners already see isSettingsAuthenticated() === false.
  • sign-in broadcast → the singleton holds the new token (so a later API call carries it) and the event was emitted after that: the listener observes getAuthHeader() === "Bearer <new token>" from inside the emit.
  • sign-in seen only as a storage write (no broadcast reached the page).
  • account replacementjwtToken value changes, presence does not → treated as a change, getClaims().userId is 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 real SpeechSynthesisModule, whose auth fingerprint is what drops the stale list).
  • routine token refresh (new token, same 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 []).
  • a sign-out spelled as two events (broadcast first, storage wipe second) → announced once, not twice.
  • no-op storage write and repeat broadcast → no churn; uninstall → listeners removed.
  • the whole chain, joined (the issue's AC 1): a settings page open on a signed-in session + a real background sign-out broadcast + the real reconciler + a real VoicesController reading the real settings-scoped state through the real subscription (only the studio's network-bound deps stubbed) → the rail's copy flips from voicesSpeaksWith to signInForTTS with no reload. Proved fail-first by removing the onAuthChange subscription. This is what a broken defaultDeps wiring would have slipped past, since both halves pass independently.
  • a wiring guard that voices-controller.ts contains no getJwtManagerSync reference.

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 on destroy(); and never lets a previous session's in-flight catalog land in the cache. Proved fail-first: all four subscription tests fail without the onAuthChange wiring, and the race test fails without the epoch guard (expected [ 'marin' ] to deeply equal [ 'ash' ]).

test/AuthStatusSync.spec.ts is untouched and green; the existing Voices specs (211 of them, including the one that flips auth via onShown()) 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 — the storage.onChanged listener fires in every extension context, and every sign-in/sign-out/switch writes jwtToken (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

  • An attended real OAuth round trip. Settings sign-in flow fails to reflect authenticated state #227's AC asks for one, and it is not something an agent session can perform: it needs a human at https://www.saypi.ai/auth/login completing 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.
  • Real browser.alarms behaviour. The alarm-preservation guarantee is proved against a mock. The claim it rests on — that chrome.alarms on 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.
  • Whether the /voices catalog 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:

  • The voice cache was still keyed on the singleton. SpeechSynthesisModule.currentAuthFingerprint() read getJwtManagerSync() directly, so on a sign-out it stayed user:A and 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 at isSettingsAuthenticated. 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).
  • The alarm story was half-told. The real invariant is never clear without re-creating, not never touch it: 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 bare clear() destroys it. The module doc now says that, with its two honest edges (scheduleRefresh is un-awaited, so a tab closed inside its millisecond gap leaves no alarm; a token within a minute of expiry takes performRefresh() instead), and the test assertion moved onto the authenticated path — expect(alarms.create).toHaveBeenCalledWith(JWT_REFRESH_ALARM, …).

Also from the review: planId joins the session signature (a plan upgrade is a change this page must show; quota numbers stay out, since they fall with every transcription); updateQuotaProgress null-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 saveChoice against an auth change (cosmetic — the voice preference is local, not account-scoped, and the reviewer agreed), and dropping the source-text guard that keeps voices-controller.ts off getJwtManagerSync (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

rosscado and others added 5 commits September 6, 2026 16:54
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
@rosscado

rosscado commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

Independent adversarial review — verdict REQUEST CHANGES, now addressed

An independent reviewer subagent went over this at cf95bc9 with five lenses (auth state machine, races/lifecycle, cross-context/cross-browser, test quality, repo conventions). It confirmed the central premise independently — AuthStatusSync.ts:64JwtManager.ts:605:211 alarms.clear('saypi-jwt-refresh'), with alarms in the permission list for both targets (wxt.config.ts:380, not stripped by the Firefox filter at :429-432) and settings.html as options_ui.open_in_tab: true — so the alarms API really is live on this page on MV3 and MV2. It also cleared the dataEpoch guard, the latch/dedupe/queue interaction under interleaved bursts, the tabs.sendMessage-reaches-extension-pages assumption, and the fairness of the alarms mock.

It raised three should-fix items. All three are fixed in e93aed7.

F1 — the alarm claim was half-told (fixed)

The reviewer is right, and the point is sharper than a doc nit: loadFromStorage()scheduleRefresh() (JwtManager.ts:104, un-awaited) → clearRefreshAlarm()alarms.clear(...). So the authenticated path does clear the alarm — it just re-creates it immediately, from the same stored tokenExpiresAt the background computed from.

The real invariant is never clear without re-creating, not never touch it, and the module doc now says so — including the two honest edges that come with it (the un-awaited gap; a token within a minute of expiry taking performRefresh() instead), and the fact that the singleton's constructor already does this on every settings-page load, so it was never true that this page left the alarm untouched.

F9 — the alarm assertion covered the path that can't break (fixed)

Follows from F1 and was the better half of it. The sign-out assertion stays (it is the regression guard, and it fails when the branch is swapped for handleAuthStatusUpdate(false)), and the sign-in test now also asserts expect(alarms.create).toHaveBeenCalledWith(JWT_REFRESH_ALARM, …) — pinning the invariant the code actually holds.

F2 — the headline claim was false for the consumer that matters (fixed properly, not softened)

The best finding here. SpeechSynthesisModule.currentAuthFingerprint() (SpeechSynthesisModule.ts:114-119) reads getJwtManagerSync() directly, so on a sign-out the fingerprint stayed user:A and VoicesController.onAuthChange's catalog re-read was handed user A's cached list — custom voices included — under a "sign in for TTS" label. The file asserted the opposite of what it did.

The reviewer noted correctly that src/tts/ is not a founder-gated path, so "wider blast radius than warranted" was a choice, not a constraint. Reversed: the module now takes a per-instance setAuthStateReader, and the studio's deps point it at isSettingsAuthenticated. Per-instance rather than static because each extension context has its own module instance, so the settings page sets it without any reach into the content scripts' — and a content script's singleton is the honest answer there, so it keeps the default. New fail-first test covers the sign-out direction of AC 3.

F12 — no Fixes #227 in the PR body (fixed)

Correct, and it would have left the issue open. Added.

Nits taken

  • F4 — a plan upgrade produced a new token with the same userId, so the quota panel kept the previous plan's copy until reload. planId now rides in the session signature. Quota numbers deliberately stay out: they fall with every transcription, which would restore the 15-minute repaint and then some.
  • F7 — real, and one this PR would have made recur: the quota bars live in the lazily-mounted General tab, so a deep link straight into Voices ("More voices…") leaves updateQuotaProgress dereferencing DOM that was never built. Null-guarded (this was a latent throw on the module's own load-time path too).
  • F3 — the event payload is what the background said, which can run ahead of the storage write it describes. Documented as advisory; every consumer reads the getter.
  • F5|| token instead of ?? token (an empty id is as unusable as a missing one), plus the missing sentence explaining why a local decoder exists at all: the question is about the token in storage, not the one the singleton holds — comparing the two is the whole point.
  • F13 / F14settingsAuthSyncSettled labelled a test seam; refreshQuotaStatus now says why it is deliberately not folded into updateQuotaDisplayForAuthState (that one runs on every General tab load, where the numbers are already fresh; making it refetch would put an API call on a path that doesn't need one).
  • F8 — PR body citation corrected: refresh()'s backoff alarm is JwtManager.ts:529; :727 is refreshWithOAuth()'s. The pollAuthCookie framing is also softened — on MV3 the worker is usually evicted before its interval fires (per doc/codebase-caution-map.md), so cookies.onChanged is the live route there.
  • F11 — added, but with an honest comment rather than the one the finding implied. I tried to fail it against a deliberately unserialized reconciler and it passed: the signature check-and-set is synchronous within one microtask turn, so it, not the queue, is what makes the back-to-back case safe. The queue's actual job is stopping two reconciliations interleaving inside loadFromStorage. The test comment now says that instead of overclaiming.

Declined, with reasons

  • F6 (fence an in-flight saveChoice against an auth change) — the reviewer marks it cosmetic and I agree: the voice preference is local, not account-scoped, so the worst case is a stale status line. Not worth an epoch check in a second place.
  • F10 (drop the source-text guard on getJwtManagerSync) — the brittleness is real, but it guards a future call site anywhere in a 2800-line controller, which the behavioural test would only catch if that call site happened to affect rendering. Keeping the coarse guard alongside the behavioural one.

Tests: 2843 → 2862 Vitest (247 → 248 files), Jest 2, tsc --noEmit clean.

@rosscado

rosscado commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

Independent review verdict: REQUEST CHANGES

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

The 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 (alarms.clear not called, getJwtManagerSync().isAuthenticated() still true after a signed-out broadcast).

But the Voices tab's new onAuthChange handler now responds to a sign-out by bumping the epoch, clearing the cache and re-rendering, which is new behaviour: previously the tab stayed frozen until reload, now it eagerly re-fetches. And while deps.isAuthenticated and the speech-cache fingerprint both correctly read the settings-scoped state, the fetch itself does not. SpeechSynthesisModule.getVoices()TextToSpeechService.getVoices()callApi() builds its Authorization header from (await getJwtManager()).getAuthHeader() — the same never-cleared singleton. Nothing in that chain consults isSettingsAuthenticated().

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 getVoices to resolve empty, commented as "mapped from 401" — an assumption about server-side revocation on sign-out that nothing in this repo verifies, and one in tension with this codebase's stateless-bearer, client-expiry model. If it does not hold, the test validates the assumption rather than the behaviour.

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 getVoices() to empty when its auth reader says signed out, the way the quota path already does; or have auth-sync.ts set a page-scoped suppress-credential flag that getAuthHeader()/callApi honour — distinct from clear(), so the alarm and recovery credentials stay untouched but no header is ever sent while the page has been told it is signed out.

Everything else checks out

  • Dedup by session signature is right, and the same-user sign-out-then-sign-in case is not suppressed (the signed-out sentinel breaks equality). Quota-only refreshes are deliberately excluded, avoiding a repaint every fifteen minutes.
  • The reconciliation queue chains each call with its own catch, so a throwing reconciliation cannot wedge it; ordering is preserved.
  • Ordering holds on every path — the state flips before the emit.
  • Teardown is idempotent, so a double init cannot double-register.
  • No founder-gated path is touched.
  • Test quality is genuinely strong: the specs drive the real JwtManager with only storage and alarms mocked, and the "clear() is not called" assertion is a real spy that can fail.

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

rosscado commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

Must-fix addressed in 044ce65 — with one correction to the mechanism

Verified the trace before touching anything, and the finding holds. One detail in it is a step off, and it changes which of the two options is honest, so it is worth stating.

What the trace actually shows

The settings page is not a service-worker context, and api.saypi.ai is in getAllowedSayPiHosts(), so shouldRouteViaBackground() is true for /voices here: callApi proxies via API_REQUEST, and handleApiRequest (background.ts:860) attaches the background's jwtManager.getAuthHeader(), not the page's. The background broadcast false off its own isAuthenticated(), so on the usual path the header is already gone and what goes out is a pointless unauthenticated request — not the previous user's catalog.

The leak is one step further down, and it is real. callApi falls back to callApiDirect whenever the proxy failschrome.runtime.lastError, a malformed response, or a network error inside handleApiRequest, which answers {success: false} and rejects. callApiDirect reads (await getJwtManager()).getAuthHeader() from the page's singleton: the one this design deliberately never clears. A transient blip is enough to put user A's live bearer on the wire from a page that has just been told the session is over. And the request itself is new behaviour from this PR either way — before it the tab froze until reload; now the sign-out clears the cache, the anonymous fingerprint misses, and something is fetched.

So: right conclusion, and the request should not be made.

Option 1, at the module rather than the call site

I took the short-circuit, not the suppress-credential flag, for two reasons.

The decisive one is that option 2 could not have delivered the guarantee here. A page-scoped flag honoured by getAuthHeader()/callApi only reaches the direct path; the settings page's requests go through the background, which attaches its own header from state the page has no business overriding. Suppressing at the request layer would have left the proxied request going out and bought a flag for the fallback only. Not making the request is the only version of "no authenticated request leaves this page" that is actually true. (Option 2 also can't touch src/JwtManager.tspath-guard — so it would have had to live in ApiClient.ts as a second page-scoped-state-in-a-shared-module mechanism, on top of the reader this PR already added.)

The second is placement. I put it on SpeechSynthesisModule's injected authStateReader rather than on the studio's getVoices dep, so a future caller of speech.getVoices() on this page can't walk around it — the concern about a guard at one call site is fair, and this is the closest to the credential source that doesn't leak page state into a shared module. It reuses the reader this PR already introduced; no new concept.

It is deliberately scoped to contexts that injected a reader. Injecting one is an assertion that the singleton is not the truth here. Without one, nothing changes: 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 behaviour that would let the client answer on the server's behalf. Which is the same assumption you flagged in the test — so acting on it in shared code would have been the same mistake in a worse place.

The test, and the assumption it used to encode

New: "sends NO outbound request from a page that has been told it is signed out". Real TextToSpeechService, real SpeechSynthesisModule, real reconciler, real JwtManager — only callApi is a spy, because it is the request layer and the assertion is about the request.

Before (e93aed7):

× sends NO outbound request from a page that has been told it is signed out (AC 3)
  → expected "spy" to not be called at all, but actually been called 1 times
    1st spy call: [ "https://api.saypi.ai/voices?app=claude" ]

After (044ce65): passes — callApi not called, while getJwtManagerSync().getAuthHeader() still matches /^Bearer /, which is the assertion that says why it matters.

The existing AC 3 spec no longer encodes the 401 assumption. The // mapped from 401 comment is gone, and instead of re-arming the stub to resolve empty it now re-arms it with user A's voices — so an empty result can only mean the cache was dropped and the fetch was not made, plus an explicit expect(ttsService.getVoices).not.toHaveBeenCalled(). Both tests failed against the branch head first.

Docs on setAuthStateReader, auth-sync.ts and defaultDeps() updated: the reader now gates the request, not just the fingerprint, and the modules say so.

npm test green: tsc --noEmit clean, Jest 2/2, Vitest 2863 passed | 1 skipped (248 files). No founder-gated path touched.

Untouched, as asked

Dedup by session signature, the serialized queue, ordering, idempotent teardown, and the specs driving the real JwtManager are all unchanged.

`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
@rosscado

rosscado commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

Note: merged main in (960a7c7) — the branch was silently un-CI'd

e716a41 (#644, voice playback speed/volume) landed on main ~90 seconds before the fix above was pushed, appending a describe block to the end of test/settings/tabs/voices-controller.spec.tsx — the same place #227's session-change block goes. Append/append at EOF; both blocks kept, in order.

Flagging it because the symptom was quiet: GitHub stops scheduling pull_request workflows once a PR can't be merged, so the conflict wasn't just blocking merge, it was blocking the PR's CI verdict entirelygh pr checks reported "no checks reported" rather than a failure. Checks are running again now.

One oddity worth recording: the automatic merge also dropped a line from e2e/README.md (#637's mobile-tts-controls.e2e.ts row) even though this branch has never touched that file — caught by #638's new inventory guard, restored from main. The merged tree now differs from main in this PR's seven files and nothing else.

npm test green on the merge: tsc --noEmit clean, Jest 2/2, Vitest 2978 passed | 1 skipped (259 files).

…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
@rosscado

rosscado commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

Correction: my first cut of the guard was too broad — Layer 3 caught it (91a1a4d)

The e2e check went red on 960a7c7, and it was mine, not the runner (that job also got restarted twice by lost runners, which masked it for a while). Attributed by re-running the specs locally against both conditions rather than reasoning about 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. callApi would 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 test48 passed locally.

@rosscado

rosscado commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

Merging — the review's must-fix is closed, and closing it surfaced a second defect

The 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 callApi proxies through the background, which attaches its own header — already gone after a sign-out. But callApi falls back to a direct call whenever the proxy fails, and that path reads the page's deliberately-never-cleared singleton. A transient blip would have put the previous user's live bearer on the wire. The request itself was new behaviour from this PR either way, so the fix is not to sanitize it but to not make it.

The guard sits at the module's injected auth reader rather than the studio's dependency, so a future caller of getVoices() cannot walk around it.

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. getAuthHeader() rather than isAuthenticated(), because the question is literally "would something be sent" — true even for a token past its client-side expiry. A test pins each direction, and the new one fails against the broad condition, so a future tightening cannot quietly empty the rail again.

Also worth recording, and filed separately: the Layer-3 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 client's own comments rest on. Neither is verified against the real API.

Verification: full suite green, Layer 3 run locally (48 passed) as well as in CI, and all five checks green.

@rosscado
rosscado merged commit a909676 into main Sep 6, 2026
5 checks passed
@rosscado
rosscado deleted the fix/227-settings-auth-sync branch September 6, 2026 20:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Settings sign-in flow fails to reflect authenticated state

1 participant