Skip to content

fix(auth): revoke explorer credentials even when the epoch write fails - #1660

Closed
dawsontoth wants to merge 7 commits into
stagefrom
claude/explorer-epoch-storage-disabled
Closed

fix(auth): revoke explorer credentials even when the epoch write fails#1660
dawsontoth wants to merge 7 commits into
stagefrom
claude/explorer-epoch-storage-disabled

Conversation

@dawsontoth

@dawsontoth dawsontoth commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #1656, now merged; stage has been merged in, so the diff here is this branch's own work.

A cross-model round on #1656 surfaced this and I deliberately left it out: that PR fixes a flaky test and the ordering races around it, and this needs a change to the shape of the explorer's sign-out generation rather than to its callers.

The problem

The generation lives only in localStorage. When the write throws — storage disabled by policy, or full — writeExplorerInvalidation swallows it and the generation never moves. Everything downstream compares against a number that cannot change, so a sign-out silently does nothing:

  • credentialIsCurrent keeps matching, so the credential is still attached to "Try it out" requests.
  • An in-flight mint's epoch check sees no change and writes its token back.
  • The sign-out subscription compares the same unmoved epoch and never fires, so the form is never cleared.

The credential stays usable for the life of the tab. It is the one situation where the durable record is exactly what can't be relied on, and it was the only thing consulted.

Pre-existing — the swallow predates #1656 — but #1656 made the subscription depend on that comparison too, so it is worth closing now rather than later.

The change

authStore keeps a per-entity count of sign-outs it could not record and adds it to the epoch:

return own + all + fallbackOwn + fallbackAll;

It advances only inside the catch, so the normal path is byte-for-byte unchanged.

The count is memory-only, and on its own that is not enough — which is worth spelling out, because the first version of this PR claimed it was.

A credential stamped under a fallback generation does not compare as stale after a reload; it compares as current again, because the count is gone and the durable generation never moved. pruneStaleEntitySettings compares that same restored epoch at bootstrap, so it does not strip it either. The signed-out credential comes back and is sent.

Three of the four epoch-bump sites happen to be preceded by forgetEntitySettings, which hides this. The fourth is bumpExplorerAuthEpochAll, whose own comment says it exists precisely for entities absent from potentiallyAuthenticated — the ones nothing else purges. That is the path where the fallback was most needed and least sufficient.

So the revocation is not merely recorded, it is enforced: when the durable write fails the credential is destroyed outright. sessionStorage is a separate store and is usually still writable when localStorage is not, and a deleted credential outlives the reload that drops the count.

Verification

The new test drives a real sign-out (signOutLocally) with Storage.prototype.setItem throwing QuotaExceededError, and asserts both that the epoch advanced and that the subscriber fired.

A second test covers the resurrection path directly: an entity with a stored credential that is absent from potentiallyAuthenticated, a global logout with setItem throwing, asserting the token is gone from sessionStorage.

Mutation-checked three ways — dropping the catch bump, dropping the fallback from the epoch sum, and dropping the purge — each fails.

(One trap worth recording: spying on localStorage.setItem is silently inert under jsdom, which proxies through Storage.prototype. Written that way the test fails against the fixed code and looks like the fix does not work.)

tsc -b, oxlint, dprint check clean; 327 files, 2715 passed.

What this does not do

It does not make the explorer work with storage disabled — settings and credentials live in sessionStorage, so the feature is degraded regardless. It only ensures a sign-out is honored rather than silently ignored.

It also assumes sessionStorage is writable when localStorage is not. If both are dead there is no stored credential to revoke in the first place, so the case is moot; if only sessionStorage dies the purge throws and is swallowed by settings.ts, leaving the in-memory count as the sole guard — degraded, but no worse than before this PR.

🤖 Generated with Claude Code

dawsontoth and others added 5 commits August 26, 2026 13:54
…er render

The login form dropped the typed credentials from a `useEffect` keyed on a
`pending` -> `idle` status transition. React flushes passive effects in a
Scheduler task *after* the commit they belong to, and `shouldYieldToHost()`
splits the two into separate macrotasks whenever a commit blows the 5ms frame
budget. In that ordering the clear arrives a commit late: a user who starts
typing the next credentials as soon as the panel says "Credential set" has the
username wiped out from under them, which disables the submit button on an
empty field.

`runMint` now reports how it finished (`applied` / `discarded` / `failed`) and
the form clears off that promise, so the clear batches into the same commit as
the status and credential updates. Only `applied` clears: a `discarded` mint no
longer owns the form, and re-picking the auth type mid-flight supersedes a mint
without unmounting it, so clearing there would blank credentials the user had
already retyped -- the same defect one race narrower.

The one clear that *should* beat live typing is a sign-out, which the deleted
effect covered incidentally (revocation drove status back to `idle`). A
revocation counter now keys the form instead, so typed credentials cannot
outlive the session they were meant for whether or not a mint was in flight.
The counter advances only on a newly-observed epoch for this entity:
`onExplorerAuthInvalidated` names no entity and fires on any sign-out, so an
unconditional bump would blank a form belonging to an instance nobody signed
out of.

Guards are mutation-checked, none by timeout: restoring the effect fails the
re-auth test in 64ms on `expected 'dave' to be ''`; widening the clear to
`discarded` fails the superseded-mint test on `expected '' to be 'bob'`;
dropping the revocation key fails the sign-out test on `expected 'alice' to
be ''`; bumping revocation unconditionally fails the cross-entity test on
`expected '' to be 'alice'`.

Verified: 20 consecutive full-suite runs green (it reproduced twice in 12 runs
before this).

Refs #1655

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…render

`useRef(authStore.getExplorerAuthEpoch(entityId))` evaluates its initializer on
every render, not just the first, and reading the epoch does a
`localStorage.getItem` plus a `JSON.parse`. `useResizableSidebar` keeps the
sidebar width in `useState` and calls `setWidth` from `mousemove`, so a sidebar
drag re-renders `ApiExplorer` continuously -- putting that parse on exactly the
path whose own comment says writing localStorage per `mousemove` "would
stutter".

The effect below already baselines the ref on mount and on an `entityId`
change, which is the only point the value is read, so the eager initializer was
redundant as well as costly.

That baseline is load-bearing and was untested: an entity that mounts on a
non-zero epoch would otherwise read the next unrelated sign-out as a change to
itself and blank the form. Covered now, and mutation-checked -- dropping the
baseline fails the new test on `expected '' to be 'alice'`.

Refs #1655

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three review findings on #1656, all sharing one cause: the explorer's
sign-out signal names no entity, so every explorer acted on every sign-out.

`onExplorerAuthInvalidated` now compares the epoch for the `id` it was given —
the parameter existed but was ignored (`_id`) — so an unrelated instance's
sign-out no longer increments this entity's `attemptRef`, which was silently
discarding its in-flight mint. It also notifies in-process subscribers on a
same-tab write, which fires no `storage` event of its own, so a same-tab
sign-out now empties the form directly rather than waiting for the cancelled
mint to settle. The mint's epoch-mismatch branch no longer bumps revocation:
the subscription owns that now, and a second bump would remount the form again
and take any credentials retyped since with it.

`credentialIsCurrent` re-read the epoch during render, so the localStorage
parse was still on the sidebar-resize path that 81a642f claimed to clear --
that commit removed a `useRef` initializer and missed this one. The epoch is
held in state and advanced by the subscription instead.

An `applied` mint no longer clears unconditionally. The inputs stay live while
a mint runs, so a user can submit Alice's credentials and start on Bob's before
a slow mint returns; clearing on Alice's success erased a draft it never saw.
The form counts edits and compares at settle, so the clear stays tied to the
values actually submitted.

Each guard is mutation-checked: ignoring the edit generation fails on
`expected '' to be 'bob'`; dropping the store's epoch comparison fails three
unrelated-entity tests; dropping the same-tab notification fails on
`expected 'alice' to be ''`.

Refs #1655

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`onExplorerAuthInvalidated` read its baseline epoch, then registered its
listener. A sign-out dispatched between the two reached nobody -- the event was
gone, and no later one was coming -- so the subscriber sat on a revoked
generation it believed was current. `ApiExplorer` had the same shape one level
up, seeding its cached epoch before subscribing.

Both now re-check once subscribed. The store compares against its baseline
after registering, which is a no-op unless the gap actually swallowed an event,
and the explorer reads its epoch after the subscription is live.

Reordering the baseline to after `addEventListener` was the first attempt and
is worse: `fire` closes over it, so an event arriving in that window throws a
TDZ `ReferenceError` rather than being missed quietly. The new store test
caught that, which is the reason it exists.

Covers the store's subscription directly for the first time -- entity scoping,
the global-logout wildcard, unsubscribe, and the race above. The race test
stages the sign-out *before* `addEventListener` registers, because staging it
after lets the ordinary event path satisfy it: written the obvious way it
passes against the unfixed code, and only fails on `expected 1 times, got 0`
in this order.

Refs #1655

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The explorer's sign-out generation lives only in localStorage, so when the
write throws -- storage disabled by policy, or full -- the generation never
moves. Every later comparison then reads the signed-out credential as current:
`credentialIsCurrent` matches, an in-flight mint's epoch check sees no change
and writes its token back, and the subscription installed for sign-outs sees no
change either, so nothing clears the form. The credential stays usable for the
life of the tab, which is the one case where the durable record is exactly what
cannot be relied on.

`authStore` now keeps a per-entity count of sign-outs it could not record, and
adds it to the epoch. It advances only in the `catch`, so nothing changes on
the normal path. Being memory-only it is lost on reload, which fails safe: a
credential stamped under a fallback generation compares as stale afterwards and
is withheld rather than resurrected.

Found by a cross-model review round on #1656 and deliberately left out of it --
that PR fixes a flaky test and the races around it, and this needed a change to
the epoch's shape rather than to its callers.

Mutation-checked both ways: dropping the `catch` bump and dropping the fallback
from the epoch sum each fail the new test on `expected +0 to be 1`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces an in-memory fallback mechanism (explorerEpochFallback) in AuthStore to ensure that explorer credentials are still revoked within the current tab when a durable write to localStorage fails (e.g., due to storage being disabled or full). A corresponding unit test has been added to verify this behavior. The review feedback suggests initializing the fallback record with Object.create(null) instead of {} to prevent potential issues with dynamic keys matching built-in prototype properties.

Comment thread src/features/auth/store/authStore.ts Outdated
Base automatically changed from claude/apiexplorer-flaky-test-login-9ce001 to stage August 28, 2026 15:21
dawsontoth and others added 2 commits August 28, 2026 11:23
Recording the revocation in memory is not enough on its own. `signOutAllLocally`
bumps the global epoch precisely for entities absent from
`potentiallyAuthenticated`, which are therefore never reached by
`forgetEntitySettings` -- so when the durable write also fails, the sequence is:
the fallback count withholds the credential, the tab reloads, the count is gone,
the durable generation never moved, and the stamped `authGeneration` matches
again. The signed-out credential comes back and is sent.

So drop the credential outright rather than only noting that it was revoked.
sessionStorage is a separate store and is usually still writable when
localStorage is not, and a deleted credential outlives the reload that loses the
count.

This corrects the claim in the previous commit's message: the memory-only
fallback is fail-safe on the paths that purge alongside the bump, not on the
global-logout path, which is the one it was most needed for.

Also switches the fallback map to a null prototype, per review: unlike the
durable map beside it, which type-guards every read, this one used `?? 0` --
which an inherited `toString` would sail straight through.

Mutation-checked: dropping the purge leaves the token in sessionStorage and
fails the new test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#1656 merged by rebase, so stage carries rebased copies of this branch's
parent commits. Both conflicts are that duplication: stage holds the #1656
versions of `authStore.ts` and the explorer-invalidation test, this branch
holds the same content plus the epoch fallback and its coverage. Verified
`git diff stage` over both files is exactly this branch's additions and nothing
of stage's is lost, so each was resolved to this branch's side.

Also corrects the `explorerEpochFallback` doc comment, which still claimed the
memory-only count fails safe across a reload -- the claim the previous commit
disproved and replaced with destroying the credential outright.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 59.67% 8039 / 13472
🔵 Statements 60.13% 8618 / 14332
🔵 Functions 52.48% 2030 / 3868
🔵 Branches 53.49% 5752 / 10753
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/features/auth/store/authStore.ts 69.62% 59.57% 75.92% 69.92% 108, 205-226, 242-251, 261, 282, 306-317, 324-333, 388-391, 535-548, 596-602, 636, 640-647, 659, 672-700
Generated in workflow #1825 for commit 49a91cf by the Vitest Coverage Report Action

@dawsontoth

Copy link
Copy Markdown
Contributor Author

Closing unmerged — the premise is wrong. Filed the real bug as #1662 (P1).

A cross-model round on 49a91cff found that this fix cannot run on the path it targets. flagKeyAsSignedOut does an unguarded localStorage.setItem and executes before forgetEntitySettings and bumpExplorerAuthEpoch in signOutLocally — so on a full store, sign-out throws there and never reaches the catch this PR adds. Confirmed with a probe: signOutLocally threw QuotaExceededError and the epoch never moved.

It is worse one level up: that throw propagates out of clearAuthStateLocally, which calls signOutAllLocally() first, so the query-cache clear and both storage wipes never run either. That is the actual defect, and it is what #1662 covers.

Three things worth recording, since they are why this looked finished:

  • The tests here were green because they dodged the crash. Neither seeded the entity into potentiallyAuthenticated, and flagKeyAsSignedOut is a no-op when it is absent — so the throw never fired.
  • They also proved less than their names claimed. The single-entity test never seeded a credential, so forgetEntitySettings short-circuited at Object.hasOwn and only the counter was exercised; the purge test passed solely through the '*' removeItem path, proving nothing about per-entity purging, where writeMap's setItem failure is swallowed in settings.ts.
  • "Storage disabled by policy" was wrong throughout this PR's comments and description. A policy-disabled localStorage throws in the AuthStore constructor, so only quota exhaustion ever reaches that catch.

The branch is left in place if any of it is useful to #1662, but it should not be merged as-is.

@dawsontoth dawsontoth closed this Aug 28, 2026
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.

1 participant