feat(wave1): stop the bleeding — data integrity, security, destructive guards#12
Merged
Merged
Conversation
…e guards First of five rebuild waves from .gstack/qa-reports/PLAN.md. The audits (ux-audit.md, tech-audit.md) found 117 issues; this wave closes the ones that can silently corrupt user data, leak files, or ship a blank-screen production build. Each change is load-bearing. ## Data integrity - app-store: messages now carry a stable client id; updateMessageAt-by-index is replaced with updateMessage-by-id so streaming writes survive notebook switches, aborts, and setMessages swaps without landing in the wrong slot or silently dropping. Store also grows newChat(), resetMessagesOnly(), and setMessages() so the previous 4 callers of clearMessages() can each say what they actually meant. - useChat: user-initiated abort no longer falls through to the non-streaming fallback (stop button used to deliver the full reply anyway), and no longer leaves a partial assistant message in history to be replayed on the next send. Empty aborted turns are removed; partial ones are marked aborted. Switching notebooks tears down the in-flight stream. - useConversations.loadConversation fetches BEFORE clearing, so a network error no longer wipes the user's current context. Messages are swapped in one setMessages() call and sources are restored from the last message that actually had them, with document_name populated the same way the streaming path does. - ChatView retry path replaces clearMessages + N addMessage calls with a single setMessages slice — no incremental render flicker. - notebook_store.complete_ingestion now accumulates source_count / chunk_count instead of overwriting with per-job values. Previously a 6th upload would set the counter to 1. Test added. ## Security - Backend CORS drops allow_origin_regex=".*" + allow_credentials=True (a localhost-CSRF footgun that let any browser tab on the machine exfiltrate notebooks). Only the explicit dev origins + Electron's "null" origin now. - Document preview path resolution is locked to the notebook's scoped uploads directory. The old cross-notebook filename-match fallback let notebook A serve files from notebook B. Response now also sets Content-Disposition: attachment. - Electron app:openExternal now validates URL scheme — http(s) and mailto only. A renderer-side XSS via markdown can't pivot into file:// or javascript: handlers. ## Reliability / cleanup - DELETE /notebooks/:id now also drops the Chroma chunk + summary collections and rm -rfs the uploads directory. Prior behavior left both behind, leaking disk indefinitely. - Vite config sets base: './' so the packaged Electron renderer actually loads assets from file:// (previously: blank white screen in prod). - ConfirmDialog component wired to notebook delete and conversation delete. Neither destructive action previously had any guard; a misclick on a sidebar overflow menu would permanently destroy the notebook and all its documents, embeddings, and conversations with no undo. - CommandPalette useState for selectedIndex moved above the early return — Rules of Hooks compliance, future-proofs against React compiler / StrictMode. Everything lives behind a green CI: backend ruff + pytest clean, desktop tsc + vite build clean. Waves 2-5 build on this. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced Apr 18, 2026
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.
Context
This is Wave 1 of 5 from the UX/UI/Tech rebuild plan. Full plan and audit context:
The audits found 117 issues. Wave 1 closes the 13 that can silently corrupt data, leak files, compromise security, or ship a blank-screen production build. Nothing cosmetic in this PR — every change is load-bearing.
What changes
Data integrity
updateMessageAt(index, …)was a silent-corruption bug: mid-stream notebook switch + array mutation could write the assistant delta into the wrong slot. Messages now carry a stable UUID assigned at add time; store exposesupdateMessage(id, patch),removeMessage(id),setMessages(msgs).controller.signal.aborted+ an explicituserAbortedflag.useChataborts the controller whenactiveNotebookIdchanges. Previously a mid-stream notebook switch would keep streaming into the old conversation while the UI showed a new notebook.clearMessagesis decomposed. The single action was called for 4 different intents — new chat, clear chat, notebook switch, conversation load — each with identical but mostly wrong side effects (e.g., always wipingactiveSources). Replaced withnewChat(),resetMessagesOnly(),setMessages(). Callers now say what they mean.setMessages()call (no N re-renders that looked like streaming). Historical sources are restored from the last message that actually had them, withdocument_namepopulated the same way the stream path does.source_countaccumulates.complete_ingestionusedSET source_count = ?with per-job count → a 6th upload set the counter to 1. Nowsource_count = source_count + ?. Regression test added.Security
allow_origin_regex=\".*\"+allow_credentials=Truewas a localhost-CSRF footgun (any browser tab on the machine could hit/api/*with credentials and exfiltrate notebooks). Only the explicit dev origins + Electron'snullorigin now, credentials off.uploads/{notebook_id}/, withPath.relative_to()as the final guard. Response also setsContent-Disposition: attachment.app:openExternalvalidates scheme. Onlyhttps?://andmailto:pass; a renderer-side XSS through markdown can't pivot tofile://orjavascript:handlers.Reliability / cleanup
DELETE /notebooks/:idpreviously removed the SQLite row but left the Chroma chunk + summary collections and the uploads directory behind — storage grew without bound. Now drops all three. NewVectorStoreManager.delete_notebook_collections()helper./assets/...paths fromfile://and renders a blank white screen. Bug that only manifested in production.useState(selectedIndex)was below an early return. Worked today; would break under React compiler or StrictMode.Verification
What's intentionally NOT in this PR
Each wave is its own PR on green CI. This one is the foundation.
🤖 Generated with Claude Code