auth: sliding web sessions + stop expiry from eating unsent drafts - #289
Merged
Conversation
Session tokens were minted with a hardcoded 24h TTL and no refresh path, so an actively-used tab was logged out exactly one day after login. The 401 handler then called window.location.reload(), which any background poll could trip while typing. That reload also destroyed unsent work. A new chat is a client-side "virtual" session whose random id lived only in React state, and drafts are keyed by session id, so a reload orphaned the draft; the next loadSessions() called pruneDrafts(), which deleted unrecognized keys on sight. A long prompt written into a new chat was unrecoverable. - auth.jwt_expiry_hours (default 720h), replacing the 24h constant - require_auth re-mints a token past half its life; the gateway returns it as X-Nerve-Token and the client swaps it in, so continuous use never expires and the window becomes an idle timeout - 401 no longer reloads: the app stays mounted and SessionExpiredOverlay takes the password over the top, preserving composer state - the virtual session id is persisted, so a reload restores the unsent chat and its draft - pruneDrafts marks unrecognized drafts and reclaims them after 7 days instead of deleting on first sight - explicit logout still wipes drafts; expiry never does MCP tokens keep their short TTLs — only aud-less session tokens slide.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Three stacked bugs. The third is the one that destroys work.
1. Forced re-login every 24h
gateway/auth.pyminted tokens with a hardcodedJWT_EXPIRY_HOURS = 24and there was no refresh path —/api/auth/exposes onlylogin,statusandcheck. A token was minted at login and died exactly 24h later regardless of whether the tab had been in continuous use. Not a session; an egg timer.2. The 401 handler nuked the page
Any background request — session-list poll, the 15s review-loop poll, a models fetch — that caught the 401 hard-reloaded the tab. No prompt, no state preserved, and it can fire mid-keystroke.
3. …and that reload ate unsent drafts
Drafts are persisted per session (
nerve_draft_<id>), so a draft in an existing chat survived. But a new chat is a virtual session:createSession()mints arandomUUID()that lives only in React state. So:Appmount →loadSessions()→pruneDrafts(keep)withkeep= real server sessions onlylocalStorage.removeItem→ the draft is deleted, not merely lostPrecision-targeted at exactly the case of a long prompt composed in a new chat.
Changes
Backend
auth.jwt_expiry_hours(default720/ 30 days) replaces the 24h constantrequire_authre-mints a token past half its lifetime and stashes it onrequest.state; an http middleware returns it asX-Nerve-Token, and CORS exposes that headerFrontend
SessionExpiredOverlaytakes the password over the top, so the composer, scroll position and half-written prompt survivepruneDraftsmarks unrecognized drafts and reclaims them after a 7-day grace window instead of deleting on first sightTesting
tests/test_auth_session_sliding.py— TTL config, refresh threshold, MCP tokens not sliding, malformed payloadstests/test_auth_session_header.py— real ASGI round-trip assertingX-Nerve-Tokensurvives the middleware boundary, that the replacement authenticates, and that expired/anonymous requests get no headertsc -bclean, production build clean, new/edited files lint-cleanNote
There is no frontend test framework in the repo, so the draft-storage logic — which is the actual data-loss fix — is verified by harness rather than committed tests. Worth adding vitest separately; deliberately not smuggled into this PR.