From 53f3f0cc3165bef6d68a6edc4d4ffbbc16dc7fd8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 18 Aug 2026 23:36:41 +0200 Subject: [PATCH] Recover campaign selection from stale auth state --- crates/ghostlight-dungeon/src/main.rs | 76 +++++++++++++++++++++++---- 1 file changed, 66 insertions(+), 10 deletions(-) diff --git a/crates/ghostlight-dungeon/src/main.rs b/crates/ghostlight-dungeon/src/main.rs index f03c328..9ecedab 100644 --- a/crates/ghostlight-dungeon/src/main.rs +++ b/crates/ghostlight-dungeon/src/main.rs @@ -151,6 +151,16 @@ impl AuthOwner { self.state = next_state; Ok(()) } + + fn reload(&mut self) -> anyhow::Result<()> { + let (row, state) = self + .store + .load::("auth_state.v1", "primary")? + .ok_or_else(|| anyhow::anyhow!("canonical auth state is missing"))?; + self.row = row; + self.state = state; + Ok(()) + } } #[tokio::main] @@ -2328,16 +2338,29 @@ async fn select_campaign( if !auth.state.session_hashes.contains(session_hash) { return Err(anyhow::anyhow!("session is no longer authorized")); } - let mut next_state = auth.state.clone(); - next_state - .session_campaigns - .insert(session_hash.to_owned(), campaign_id); - next_state - .session_campaign_ids - .entry(session_hash.to_owned()) - .or_default() - .insert(campaign_id); - auth.commit(next_state) + let selection = |current: &AuthState| { + let mut next_state = current.clone(); + next_state + .session_campaigns + .insert(session_hash.to_owned(), campaign_id); + next_state + .session_campaign_ids + .entry(session_hash.to_owned()) + .or_default() + .insert(campaign_id); + next_state + }; + let current = auth.state.clone(); + if auth.commit(selection(¤t)).is_ok() { + return Ok(()); + } + + auth.reload()?; + if !auth.state.session_hashes.contains(session_hash) { + return Err(anyhow::anyhow!("session is no longer authorized")); + } + let current = auth.state.clone(); + auth.commit(selection(¤t)) } fn secret_hash(value: &str) -> String { @@ -2849,6 +2872,39 @@ mod tests { assert_eq!(owner.row, row); } + #[tokio::test] + async fn campaign_selection_reloads_stale_auth_without_losing_concurrent_state() { + let dir = tempfile::tempdir().unwrap(); + let state = empty_app_state(dir.path()); + let campaign = seed("Fresh branch"); + state + .registry + .create(campaign.clone(), vec![], vec![]) + .await + .unwrap(); + + let (store, row, mut externally_committed) = { + let mut auth = state.auth.lock().await; + let mut authorized = auth.state.clone(); + authorized.session_hashes.insert("owner".into()); + auth.commit(authorized).unwrap(); + (auth.store.clone(), auth.row.clone(), auth.state.clone()) + }; + externally_committed + .session_hashes + .insert("concurrent".into()); + store + .replace(&row, "ghostlight.auth_state.v1", &externally_committed) + .unwrap(); + + select_campaign(&state, "owner", campaign.id).await.unwrap(); + + let auth = state.auth.lock().await; + assert!(auth.state.session_hashes.contains("concurrent")); + assert_eq!(auth.state.session_campaigns["owner"], campaign.id); + assert!(auth.state.session_campaign_ids["owner"].contains(&campaign.id)); + } + #[test] fn player_http_boundary_cannot_invoke_internal_world_commands_or_npcs() { assert!(player_http_command_allowed(