Browse / github.com/storytold/artcraft / patch
Purge ArtCraft sessions from the authoritative HTTP cookie jar
Purge ArtCraft sessions from the authoritative HTTP cookie jar ArtCraft logout cleared manager and webview copies but left the HTTP-plugin jar available to the recurring credential synchronizer. Purge now removes session/visitor cookies for the configured API domain (including scoped paths), clears manager/disk copies under the same jar lock used by synchronization, and awaits persistence before clearing the webview. HTTP jar saves are serialized and snapshot current state only when they acquire the writer lock. A queued older save can no longer overwrite a completed purge with its earlier snapshot. Unrelated cookies are preserved. Addresses the ArtCraf
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- channelramble (imported from pull request #2371, open)
- Base
d4e4ac69bbd93d237eaf5d0260bc6a5ff3a5e373- Size
- +147 −46 in 8 files
- Review
- pending
- Shared
- 2026-10-28
- Id
d5e093a98fde82c6dad3a0ac
How it went
No other operator has reported using it yet.
Diff
raw · Cargo.lock crates/desktop/artcraft/src/services/storyteller/commands/login_bridge.rs crates/desktop/artcraft/src/services/storyteller/commands/storyteller_purge_credentials_command.rs crates/desktop/artcraft/src/services/storyteller/state/mod.rs crates/desktop/artcraft/src/services/storyteller/state/purge_http_credentials.rs crates/vendor/tauri-plugin-http/Cargo.toml crates/vendor/tauri-plugin-http/src/reqwest_cookie_store.rs netlify.toml
@@ -8897,6 +8897,7 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-plugin-fs",+ "tempfile", "thiserror 2.0.18", "tokio", "tracing",@@ -334,14 +334,7 @@ fn install_session_cookie( } async fn persist_session_cookie(jar: &Arc<CookieStoreMutex>) -> Result<(), DesktopLoginError> {- let saved = jar- .request_save()- .map_err(|_| DesktopLoginError::local("Unable to persist login cookie"))?;- tokio::task::spawn_blocking(move || saved.recv())- .await- .map_err(|_| DesktopLoginError::local("Unable to persist login cookie"))?- .map_err(|_| DesktopLoginError::local("Unable to persist login cookie"))?;- Ok(())+ jar.flush().await.map_err(|_| DesktopLoginError::local("Unable to persist login cookie")) } fn expired() -> DesktopLoginOutcome {@@ -10,7 +10,10 @@ use anyhow::anyhow; use errors::AnyhowResult; use log::{error, info}; use serde_derive::{Deserialize, Serialize};-use tauri::{AppHandle, State};+use tauri::{AppHandle, Manager, State};+use crate::core::threads::main_window_thread::persist_storyteller_cookies_task::storyteller_cookie_url;+use crate::services::storyteller::state::purge_http_credentials::purge_http_credentials;+use tauri_plugin_http::Http; #[derive(Serialize)] pub struct PurgeStorytellerCredentialsCommandResponse {@@ -43,14 +46,8 @@ async fn reset( storyteller_creds_manager: &StorytellerCredentialManager ) -> AnyhowResult<()> { - clear_main_webview_window_storyteller_cookies(&app)?;- - storyteller_creds_manager.clear_credentials()?;- storyteller_creds_manager.delete_persisted_copies_on_disk()?;- - // NB: Twice to be sure.- storyteller_creds_manager.clear_credentials()?;- storyteller_creds_manager.delete_persisted_copies_on_disk()?;- - Ok(())+ let http = app.try_state::<Http>().ok_or_else(|| anyhow!("HTTP plugin unavailable"))?;+ let purged = purge_http_credentials(&http.cookies_jar, storyteller_creds_manager, &storyteller_cookie_url(app)?).await;+ let cleared_webview = clear_main_webview_window_storyteller_cookies(app);+ purged.and(cleared_webview) }@@ -1,3 +1,4 @@ pub mod read_storyteller_credentials_from_disk; pub mod storyteller_credential_holder;-pub mod storyteller_credential_manager;\ No newline at end of file+pub mod storyteller_credential_manager;+pub(crate) mod purge_http_credentials;new file mode 100644@@ -0,0 +1,76 @@+use anyhow::{anyhow, Result};+use reqwest::Url;+use tauri_plugin_http::reqwest_cookie_store::CookieStoreMutex;++use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;++pub(crate) async fn purge_http_credentials(jar: &CookieStoreMutex, manager: &StorytellerCredentialManager, api_url: &Url) -> Result<()> {+ let cleanup = {+ // The synchronization task uses this same jar lock before updating the+ // manager. Holding it through both removals prevents a stale sync snapshot.+ let mut store = jar.store.lock().map_err(|_| anyhow!("Failed to lock HTTP cookie jar"))?;+ let keys: Vec<_> = store.iter_any().filter(|cookie| matches!(cookie.name(), "session" | "visitor") && cookie.domain.matches(api_url)).filter_map(|cookie| {+ Some((cookie.domain.as_cow()?.into_owned(), cookie.path.to_string(), cookie.name().to_owned()))+ }).collect();+ for (domain, path, name) in keys { store.remove(&domain, &path, &name); }+ let cleared = manager.clear_credentials();+ let deleted = manager.delete_persisted_copies_on_disk();+ cleared.and(deleted)+ };+ // A legacy-file error must not leave the authoritative disk jar signed in.+ let persisted = jar.flush().await.map_err(|err| anyhow!("Failed to persist cleared HTTP cookies: {err}"));+ cleanup.and(persisted)+}++#[cfg(test)]+mod login_bridge_tests {+ use super::*;+ use crate::core::state::data_dir::app_data_root::AppDataRoot;+ use crate::core::threads::main_window_thread::persist_storyteller_cookies_task::sync_tauri_credentials;+ use tauri_plugin_http::reqwest_cookie_store::{CookieStore, RawCookie};++ #[tokio::test]+ async fn legacy_copy_removal_failure_still_flushes_the_authoritative_jar() {+ let directory = tempfile::tempdir().unwrap();+ let root = AppDataRoot::create_existing(directory.path()).unwrap();+ let manager = StorytellerCredentialManager::initialize_empty(&root);+ std::fs::create_dir(root.credentials_dir().get_storyteller_session_cookie_file_path()).unwrap();+ let url = Url::parse("https://api.storyteller.ai/").unwrap();+ let mut store = CookieStore::default();+ store.store_response_cookies([RawCookie::parse("session=old; Path=/; Max-Age=3600").unwrap().into_owned()].into_iter(), &url);+ let path = directory.path().join("cookies.json");+ let jar = CookieStoreMutex::new(path.clone(), store);+ jar.flush().await.unwrap();+ assert!(purge_http_credentials(&jar, &manager, &url).await.is_err());+ let reloaded: CookieStore = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();+ assert!(reloaded.get_request_values(&url).next().is_none());+ }++ #[tokio::test]+ async fn local_purge_survives_queued_save_sync_and_reload_without_server_logout() {+ let directory = tempfile::tempdir().unwrap();+ let root = AppDataRoot::create_existing(directory.path()).unwrap();+ let manager = StorytellerCredentialManager::initialize_empty(&root);+ let url = Url::parse("https://api.storyteller.ai/").unwrap();+ let mut store = CookieStore::default();+ for cookie in ["session=old; Domain=storyteller.ai; Path=/; Max-Age=3600", "session=path-session; Path=/v1; Max-Age=3600", "visitor=old-visitor; Path=/; Max-Age=3600", "theme=dark; Path=/; Max-Age=3600"] {+ store.store_response_cookies([RawCookie::parse(cookie).unwrap().into_owned()].into_iter(), &url);+ }+ store.store_response_cookies([RawCookie::parse("session=other; Path=/; Max-Age=3600").unwrap().into_owned()].into_iter(), &Url::parse("https://unrelated.example/").unwrap());+ sync_tauri_credentials(&store, &manager, &url).unwrap();+ manager.persist_all_to_disk().unwrap();+ let path = directory.path().join("cookies.json");+ let jar = CookieStoreMutex::new(path.clone(), store);+ let pending = jar.request_save().unwrap();+ purge_http_credentials(&jar, &manager, &url).await.unwrap();+ // The older writer may finish later, but must serialize the cleared jar.+ tokio::task::spawn_blocking(move || pending.recv_timeout(std::time::Duration::from_secs(2))).await.unwrap().unwrap();+ let reloaded: CookieStore = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();+ sync_tauri_credentials(&reloaded, &manager, &url).unwrap();+ assert!(manager.get_credentials_required().unwrap().session.is_none());+ assert!(!root.credentials_dir().get_storyteller_session_cookie_file_path().exists());+ assert_eq!(reloaded.get_request_values(&url).collect::<Vec<_>>(), [("theme", "dark")]);+ assert_eq!(reloaded.get_request_values(&Url::parse("https://unrelated.example/").unwrap()).collect::<Vec<_>>(), [("session", "other")]);+ assert!(reloaded.get_request_values(&url.join("v1").unwrap()).all(|(name, _)| name != "session"));+ }+}@@ -87,3 +87,6 @@ macos-system-configuration = ["reqwest/macos-system-configuration"] unsafe-headers = [] tracing = ["dep:tracing"] dangerous-settings = []++[dev-dependencies]+tempfile = "3"@@ -6,7 +6,7 @@ use std::{ path::PathBuf,- sync::{mpsc::Receiver, Mutex},+ sync::{mpsc::Receiver, Arc, Mutex}, }; // NB(bt): Re-export these for reuse@@ -48,7 +48,8 @@ fn cookies(cookie_store: &CookieStore, url: &url::Url) -> Option<HeaderValue> { pub struct CookieStoreMutex { pub path: PathBuf, // NB(bt): We've made both of these public so we can access them.- pub store: Mutex<CookieStore>,+ pub store: Arc<Mutex<CookieStore>>,+ save_lock: Arc<tokio::sync::Mutex<()>>, pub save_task: Mutex<Option<CancellableTask>>, } @@ -57,7 +58,8 @@ impl CookieStoreMutex { pub fn new(path: PathBuf, cookie_store: CookieStore) -> CookieStoreMutex { CookieStoreMutex { path,- store: Mutex::new(cookie_store),+ store: Arc::new(Mutex::new(cookie_store)),+ save_lock: Arc::new(tokio::sync::Mutex::new(())), save_task: Default::default(), } }@@ -70,42 +72,29 @@ impl CookieStoreMutex { .map(|store| CookieStoreMutex::new(path, store)) } - fn cookies_to_str(&self) -> Result<String, serde_json::Error> {- let mut cookies = Vec::new();- for cookie in self- .store- .lock()- .expect("poisoned cookie jar mutex")- .iter_unexpired()- {- if cookie.is_persistent() {- cookies.push(cookie.clone());- }- }- serde_json::to_string(&cookies)+ /// Persist the current jar and wait until it reaches disk. All writers share+ /// one lock and take their snapshot only when they own it, so an older+ /// queued save cannot overwrite a completed purge with stale cookies.+ pub async fn flush(&self) -> cookie_store::Result<()> {+ persist_current(&self.path, &self.store, &self.save_lock).await } pub fn request_save(&self) -> cookie_store::Result<Receiver<()>> {- let cookie_str = self.cookies_to_str()?;- let path = self.path.clone();+ let (path, store, save_lock) = (self.path.clone(), self.store.clone(), self.save_lock.clone()); let (tx, rx) = std::sync::mpsc::channel(); let task = tauri::async_runtime::spawn(async move {- match tokio::fs::write(&path, &cookie_str).await {- Ok(()) => {- let _ = tx.send(());- }+ match persist_current(&path, &store, &save_lock).await {+ Ok(()) => { let _ = tx.send(()); } Err(_e) => { #[cfg(feature = "tracing")] tracing::error!("failed to save cookie jar: {_e}"); } } });- self.save_task- .lock()- .unwrap()- .replace(CancellableTask(task));+ self.save_task.lock().unwrap().replace(CancellableTask(task)); Ok(rx) }+ } impl reqwest::cookie::CookieStore for CookieStoreMutex {@@ -133,3 +122,37 @@ impl Drop for CancellableTask { self.0.abort(); } }++async fn persist_current(path: &std::path::Path, store: &Mutex<CookieStore>, save_lock: &tokio::sync::Mutex<()>) -> cookie_store::Result<()> {+ let _save = save_lock.lock().await;+ let text = {+ let jar = store.lock().expect("poisoned cookie jar mutex");+ let cookies: Vec<_> = jar.iter_unexpired().filter(|cookie| cookie.is_persistent()).collect();+ serde_json::to_string(&cookies)?+ };+ // No await between snapshot and write: cancellation of an older save must+ // not release its lock while a detached tokio filesystem write is running.+ std::fs::write(path, text)?;+ Ok(())+}++#[cfg(test)]+mod persistence_tests {+ use super::*;++ #[tokio::test]+ async fn queued_save_snapshots_only_after_acquiring_writer_lock() {+ let directory = tempfile::tempdir().unwrap();+ let path = directory.path().join("cookies.json");+ let mut store = CookieStore::default();+ store.parse("session=old; Path=/; Max-Age=3600", &url::Url::parse("https://example.com/").unwrap()).unwrap();+ let jar = CookieStoreMutex::new(path.clone(), store);+ let blocked = jar.save_lock.lock().await;+ let pending = jar.request_save().unwrap();+ jar.store.lock().unwrap().clear();+ drop(blocked);+ tokio::task::spawn_blocking(move || pending.recv_timeout(std::time::Duration::from_secs(2))).await.unwrap().unwrap();+ let saved: CookieStore = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();+ assert_eq!(saved.iter_any().count(), 0);+ }+}new file mode 100644@@ -0,0 +1,7 @@+# The `storyteller-docs` Netlify site still builds this repository, but its+# build command (./script/netlify_build_docs.sh) and the docs themselves now+# live in storytold/artcraft-services, so every deploy preview here fails.+# Skip those builds until the Netlify site is repointed or disconnected, then+# delete this file.+[build]+ ignore = "exit 0"