From 93c3a09cd066c7b0daf9c677fa94dec2464354bb Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 25 Aug 2026 18:01:04 +0200 Subject: [PATCH] Expose exact campaign exports through Eve --- crates/ghostlight-dungeon/src/app_session.rs | 171 ++++++++++ crates/ghostlight-dungeon/src/main.rs | 333 ++++++++++++++++++- crates/ghostlight-dungeon/src/surface.rs | 8 + vendor/eve | 2 +- 4 files changed, 511 insertions(+), 3 deletions(-) diff --git a/crates/ghostlight-dungeon/src/app_session.rs b/crates/ghostlight-dungeon/src/app_session.rs index 0e3846b..96709b1 100644 --- a/crates/ghostlight-dungeon/src/app_session.rs +++ b/crates/ghostlight-dungeon/src/app_session.rs @@ -15,6 +15,27 @@ pub struct AppSessionState { pub account_preferences: BTreeMap, #[serde(default)] pub command_receipts: BTreeMap, + #[serde(default)] + pub campaign_export_grants: BTreeMap, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct CampaignExportGrant { + pub schema: String, + pub token_hash: String, + pub account_subject_hash: String, + pub campaign_id: uuid::Uuid, + pub export_path: std::path::PathBuf, + pub filename: String, + pub created_at: DateTime, + pub expires_at: DateTime, + pub consumed_at: Option>, +} + +pub struct CampaignExportResource { + pub campaign_id: uuid::Uuid, + pub export_path: std::path::PathBuf, + pub filename: String, } #[derive(Clone, Serialize, Deserialize)] @@ -106,6 +127,7 @@ impl AppSessionOwner { sessions: BTreeMap::new(), account_preferences: BTreeMap::new(), command_receipts: BTreeMap::new(), + campaign_export_grants: BTreeMap::new(), }; let row = store.insert( "app_session_store.v1", @@ -390,6 +412,84 @@ impl AppSessionOwner { self.commit(next) } + pub fn issue_campaign_export_grant( + &mut self, + account_subject_hash: &str, + campaign_id: uuid::Uuid, + export_path: std::path::PathBuf, + filename: String, + now: DateTime, + lifetime: chrono::Duration, + ) -> anyhow::Result { + if lifetime <= chrono::Duration::zero() { + bail!("campaign export grant lifetime must be positive"); + } + if filename.is_empty() + || filename.contains('/') + || filename.contains('\\') + || filename.chars().any(char::is_control) + { + bail!("campaign export filename is invalid"); + } + let mut token_bytes = [0_u8; 32]; + rand::rng().fill_bytes(&mut token_bytes); + let raw_token = URL_SAFE_NO_PAD.encode(token_bytes); + let token_hash = secret_hash(&format!("campaign-export:{raw_token}")); + let mut next = self.state.clone(); + next.campaign_export_grants.retain(|_, grant| { + grant.expires_at > now + || grant + .consumed_at + .is_some_and(|at| at > now - chrono::Duration::days(1)) + }); + next.campaign_export_grants.insert( + token_hash.clone(), + CampaignExportGrant { + schema: "ghostlight.campaign_export_grant.v1".into(), + token_hash, + account_subject_hash: account_subject_hash.into(), + campaign_id, + export_path, + filename, + created_at: now, + expires_at: now + lifetime, + consumed_at: None, + }, + ); + self.commit(next)?; + Ok(raw_token) + } + + pub fn consume_campaign_export_grant( + &mut self, + raw_token: &str, + account_subject_hash: &str, + now: DateTime, + ) -> anyhow::Result> { + let token_hash = secret_hash(&format!("campaign-export:{raw_token}")); + let Some(grant) = self.state.campaign_export_grants.get(&token_hash) else { + return Ok(None); + }; + if grant.account_subject_hash != account_subject_hash + || grant.expires_at <= now + || grant.consumed_at.is_some() + { + return Ok(None); + } + let resource = CampaignExportResource { + campaign_id: grant.campaign_id, + export_path: grant.export_path.clone(), + filename: grant.filename.clone(), + }; + let mut next = self.state.clone(); + next.campaign_export_grants + .get_mut(&token_hash) + .context("campaign export grant vanished during consumption")? + .consumed_at = Some(now); + self.commit(next)?; + Ok(Some(resource)) + } + fn commit(&mut self, next: AppSessionState) -> anyhow::Result<()> { let next_row = self .store @@ -544,6 +644,77 @@ mod tests { assert!(owner.state.account_preferences.contains_key(account)); } + #[test] + fn campaign_export_grant_is_hashed_account_bound_expiring_and_single_use() { + let directory = tempdir().unwrap(); + let key = directory.path().join("session.key"); + std::fs::write(&key, [7_u8; 32]).unwrap(); + let store = directory.path().join("app-sessions.cc"); + let mut owner = AppSessionOwner::open(&store, &key).unwrap(); + let campaign_id = uuid::Uuid::new_v4(); + let export_path = directory.path().join("campaign.cc"); + let now = Utc::now(); + let token = owner + .issue_campaign_export_grant( + "sha256:owner", + campaign_id, + export_path.clone(), + "campaign.cc".into(), + now, + chrono::Duration::minutes(15), + ) + .unwrap(); + + assert!( + owner + .consume_campaign_export_grant(&token, "sha256:intruder", now) + .unwrap() + .is_none() + ); + let resource = owner + .consume_campaign_export_grant(&token, "sha256:owner", now) + .unwrap() + .unwrap(); + assert_eq!(resource.campaign_id, campaign_id); + assert_eq!(resource.export_path, export_path); + assert!( + owner + .consume_campaign_export_grant(&token, "sha256:owner", now) + .unwrap() + .is_none() + ); + drop(owner); + let bytes = std::fs::read(store).unwrap(); + assert!( + !bytes + .windows(token.len()) + .any(|window| window == token.as_bytes()) + ); + + let mut reopened = + AppSessionOwner::open(directory.path().join("app-sessions-expired.cc"), &key).unwrap(); + let expired = reopened + .issue_campaign_export_grant( + "sha256:owner", + campaign_id, + directory.path().join("expired.cc"), + "expired.cc".into(), + now, + chrono::Duration::seconds(1), + ) + .unwrap(); + assert!( + reopened + .consume_campaign_export_grant( + &expired, + "sha256:owner", + now + chrono::Duration::seconds(2), + ) + .unwrap() + .is_none() + ); + } + #[test] fn refresh_is_exact_session_cas_and_rotates_private_claim() { let directory = tempdir().unwrap(); diff --git a/crates/ghostlight-dungeon/src/main.rs b/crates/ghostlight-dungeon/src/main.rs index f5adbc4..f17512f 100644 --- a/crates/ghostlight-dungeon/src/main.rs +++ b/crates/ghostlight-dungeon/src/main.rs @@ -1,6 +1,7 @@ use anyhow::Context; use axum::{ Json, Router, + body::Body, extract::{Path, Query, State}, http::{HeaderMap, HeaderValue, StatusCode, header}, response::{ @@ -69,6 +70,7 @@ use native_cultmesh::NativeAuthCompletionReceipt; #[derive(Clone)] struct AppState { registry: CampaignRegistry, + exports_root: PathBuf, session_zeros: SessionZeroRegistry, session_zero_director: Option>, entitlements: Arc, @@ -404,6 +406,7 @@ async fn main() -> anyhow::Result<()> { }); let state = AppState { registry, + exports_root: runtime_root.join("exports"), session_zeros, session_zero_director, entitlements: Arc::new(FixtureEntitlementPort), @@ -473,6 +476,7 @@ fn app_router(state: AppState, web_root: PathBuf) -> Router { .route("/api/eve/provider", get(eve_provider)) .route("/api/eve/surfaces/{surface_id}", get(eve_surface)) .route("/api/eve/commands", post(eve_command)) + .route("/api/eve/resources/{token}", get(eve_resource)) .route("/api/eve/events", get(revision_events)) .fallback_service(ServeDir::new(web_root).append_index_html_on_directories(true)) .with_state(state) @@ -786,17 +790,31 @@ async fn persist_reserved_eve_result( .into_response(); } }; + let persisted_result = persistable_eve_result(operation_id, &result); if let Err(error) = state.auth.lock().await.record_command_result( account_hash, idempotency_key, operation_id, - &result, + &persisted_result, ) { tracing::error!(%error, %operation_id, "Eve command committed but its reserved result could not be finalized; duplicate execution remains blocked"); } (status, Json(result)).into_response() } +fn persistable_eve_result(operation_id: &str, result: &serde_json::Value) -> serde_json::Value { + let mut persisted = result.clone(); + if matches!( + operation_id, + "session_zero.invites.create" | "campaign.export" + ) { + if let Some(value) = persisted.as_object_mut() { + value.remove("transientProjection"); + } + } + persisted +} + async fn current_eve_surface_version(state: &AppState, account_hash: &str) -> anyhow::Result { if let Some(id) = state .session_zeros @@ -1076,6 +1094,7 @@ fn eve_operation_schema(operation: &str) -> Option<&'static str> { "session_zero.decision.resolve" => "ghostlight.session_zero_decision_resolve.v1", "campaign.entry" => "ghostlight.campaign_entry.v1", "campaign.select" => "ghostlight.campaign_select.v1", + "campaign.export" => "ghostlight.campaign_export_request.v1", "campaign.contract_review.begin" => "ghostlight.contract_review_begin.v1", "world.speak" => "ghostlight.world_speak.v1", "world.assess" => "ghostlight.player_action_assess.v1", @@ -1470,6 +1489,69 @@ async fn dispatch_eve_product_command( } } } + "campaign.export" => { + let runtime = match session_runtime(state, account_hash).await { + Ok(Some(value)) => value, + Ok(None) => { + return invalid_eve_payload( + &invocation, + anyhow::anyhow!("No campaign is selected."), + ); + } + Err(error) => return invalid_eve_payload(&invocation, error), + }; + let campaign = match load_campaign(&runtime.store) { + Ok(value) => value, + Err(error) => return invalid_eve_payload(&invocation, error), + }; + if let Err(error) = campaign_member_for_account(&runtime.store, &campaign, account_hash) + { + return invalid_eve_payload(&invocation, error); + } + let path = match state + .registry + .export(campaign.id, &state.exports_root) + .await + { + Ok(value) => value, + Err(error) => return invalid_eve_payload(&invocation, error), + }; + let filename = match path + .file_name() + .and_then(|value| value.to_str()) + .map(str::to_owned) + { + Some(value) => value, + None => { + return invalid_eve_payload( + &invocation, + anyhow::anyhow!("Campaign export did not produce a portable filename."), + ); + } + }; + let size_bytes = std::fs::metadata(&path) + .map(|value| value.len()) + .unwrap_or(0); + let token = match state.auth.lock().await.issue_campaign_export_grant( + account_hash, + campaign.id, + path, + filename.clone(), + Utc::now(), + chrono::Duration::minutes(15), + ) { + Ok(value) => value, + Err(error) => return invalid_eve_payload(&invocation, error), + }; + Json(serde_json::json!({ + "message":"Campaign snapshot is ready. The download grant expires in 15 minutes and can be used once.", + "download_url":format!("/ghostlight/api/eve/resources/{token}"), + "filename":filename, + "size_bytes":size_bytes, + "campaign_revision":campaign.revision, + })) + .into_response() + } "world.speak" | "world.assess" | "world.attempt" | "world.wait" => { let (campaign, actor_id) = match current_player_context(state, account_hash).await { Ok(value) => value, @@ -1796,6 +1878,29 @@ fn transient_result_projection( let text = (!links.is_empty()) .then(|| format!("Single-use invitations:\n{}", links.join("\n")))?; children.push(serde_json::json!({"id":"ghostlight.command-result.text","kind":"text","props":{"value":text},"children":[]})); + } else if operation == "campaign.export" { + let uri = value.get("download_url")?.as_str()?; + let filename = value.get("filename")?.as_str()?; + let size = value + .get("size_bytes") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let revision = value + .get("campaign_revision") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + children.push(serde_json::json!({ + "id":"ghostlight.campaign-export.summary", + "kind":"text", + "props":{"value":format!("Campaign revision {revision} · {size} bytes · CultCache .cc")}, + "children":[] + })); + children.push(serde_json::json!({ + "id":"ghostlight.campaign-export.download", + "kind":"resource.download", + "props":{"label":"Download campaign export","uri":uri,"filename":filename}, + "children":[] + })); } else if operation == "world.assess" || value.get("kind").and_then(serde_json::Value::as_str) == Some("assessed") { @@ -1993,6 +2098,64 @@ async fn health(State(state): State) -> Response { } } +async fn eve_resource( + Path(token): Path, + headers: HeaderMap, + State(state): State, +) -> Response { + let Some(account_hash) = authenticated_session(&headers, &state).await else { + return StatusCode::UNAUTHORIZED.into_response(); + }; + let grant = match state.auth.lock().await.consume_campaign_export_grant( + &token, + &account_hash, + Utc::now(), + ) { + Ok(Some(value)) => value, + Ok(None) => return StatusCode::NOT_FOUND.into_response(), + Err(error) => { + tracing::warn!(%error, "campaign export grant consumption failed"); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + let exports_root = match std::fs::canonicalize(&state.exports_root) { + Ok(value) => value, + Err(error) => { + tracing::warn!(%error, "campaign export root is unavailable"); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + let export_path = match std::fs::canonicalize(&grant.export_path) { + Ok(value) if value.starts_with(&exports_root) => value, + Ok(_) => { + tracing::error!(campaign_id=%grant.campaign_id, "campaign export grant escaped its owned root"); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + Err(error) => { + tracing::warn!(%error, campaign_id=%grant.campaign_id, "campaign export file is unavailable"); + return StatusCode::NOT_FOUND.into_response(); + } + }; + let bytes = match tokio::fs::read(export_path).await { + Ok(value) => value, + Err(error) => { + tracing::warn!(%error, campaign_id=%grant.campaign_id, "campaign export read failed"); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/vnd.gamecult.cultcache") + .header(header::CONTENT_LENGTH, bytes.len().to_string()) + .header( + header::CONTENT_DISPOSITION, + format!("attachment; filename=\"{}\"", grant.filename), + ) + .header("cache-control", "private, no-store") + .body(Body::from(bytes)) + .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()) +} + async fn begin_session_zero( headers: HeaderMap, State(state): State, @@ -6266,6 +6429,7 @@ mod tests { let auth = AppSessionOwner::open(root.join("app-sessions.cc"), &wrapping_key).unwrap(); AppState { registry, + exports_root: root.join("exports"), session_zeros: SessionZeroRegistry::new(root.join("session-zero")).unwrap(), session_zero_director: None, entitlements: Arc::new(FixtureEntitlementPort), @@ -6443,6 +6607,21 @@ mod tests { assert!(first.starts_with("refresh:heimdall-session:")); } + #[test] + fn persisted_idempotency_results_do_not_store_one_time_capability_urls() { + let result = serde_json::json!({ + "schema":"gamecult.eve.command_result.v1", + "receipt":{"state":"accepted"}, + "transientProjection":{"surface":{"root":{"children":[{"props":{"uri":"/secret/token"}}]}}} + }); + for operation in ["session_zero.invites.create", "campaign.export"] { + let persisted = persistable_eve_result(operation, &result); + assert!(persisted.get("transientProjection").is_none()); + assert_eq!(persisted["receipt"]["state"], "accepted"); + } + assert_eq!(persistable_eve_result("world.assess", &result), result); + } + #[test] fn app_session_cookie_survives_browser_relaunch_until_refresh_expiry() { let now = Utc::now(); @@ -6727,7 +6906,6 @@ mod tests { .create(campaign.clone(), vec![], vec![]) .await .unwrap(); - process_due_ticks( &state, &runtime, @@ -6880,6 +7058,157 @@ mod tests { ); } + #[tokio::test] + async fn campaign_export_is_an_exact_membership_bound_single_use_snapshot() { + let dir = tempfile::tempdir().unwrap(); + let state = empty_app_state(dir.path()); + let (owner_cookie, account_hash) = fixture_session(&state, "export-owner").await; + let (intruder_cookie, _) = fixture_session(&state, "export-intruder").await; + let campaign = seed("Export witness"); + let runtime = state + .registry + .create(campaign.clone(), vec![], vec![]) + .await + .unwrap(); + let expected_export = load_campaign(&runtime.store).unwrap(); + runtime + .store + .insert( + "campaign_membership.v1", + "ghostlight.campaign_membership.v1", + &campaign.id.to_string(), + &ghostlight_dungeon::session_zero::CampaignMembership { + schema: "ghostlight.campaign_membership.v1".into(), + campaign_id: campaign.id, + governance_epoch: 0, + host_member_id: "member:owner".into(), + members: BTreeMap::from([( + "member:owner".into(), + ghostlight_dungeon::session_zero::CampaignMember { + member_id: "member:owner".into(), + account_hash: account_hash.clone(), + display_name: "Owner".into(), + actor_id: "player".into(), + is_host: true, + active: true, + cell_allowance: 8, + }, + )]), + extraordinary_permissions: BTreeMap::new(), + }, + ) + .unwrap(); + select_campaign(&state, &account_hash, campaign.id) + .await + .unwrap(); + let app = app_router(state.clone(), dir.path().join("web")); + let invocation = serde_json::json!({ + "schema":"gamecult.eve.command_invocation.v1", + "providerId":EVE_PROVIDER_ID, + "surfaceId":EVE_SURFACE_ID, + "operation":{ + "operationId":"campaign.export", + "schemaId":"ghostlight.campaign_export_request.v1", + "idempotencyKey":"export-command-1", + "routeHint":{ + "sourceVersion":campaign_interface_version(&expected_export), + "transport":"https-json" + } + }, + "payload":{}, + "issuedAt":Utc::now().to_rfc3339(), + "clientId":"export-test", + "commandBoundary":EVE_COMMAND_BOUNDARY, + "receiptSchema":EVE_RESULT_SCHEMA + }); + let command_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/eve/commands") + .header("content-type", "application/json") + .header(header::COOKIE, format!("ghostlight_session={owner_cookie}")) + .body(Body::from(serde_json::to_vec(&invocation).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(command_response.status(), StatusCode::OK); + let command_bytes = axum::body::to_bytes(command_response.into_body(), 1024 * 1024) + .await + .unwrap(); + let result: serde_json::Value = serde_json::from_slice(&command_bytes).unwrap(); + assert_eq!(result["receipt"]["state"], "accepted"); + let download = &result["transientProjection"]["surface"]["root"]["children"][1]; + assert_eq!(download["kind"], "resource.download"); + let uri = download["props"]["uri"].as_str().unwrap(); + let token = uri.rsplit('/').next().unwrap(); + let app_session_bytes = std::fs::read(dir.path().join("app-sessions.cc")).unwrap(); + assert!( + !app_session_bytes + .windows(token.len()) + .any(|window| window == token.as_bytes()) + ); + + let intruder = app + .clone() + .oneshot( + Request::builder() + .uri(uri) + .header( + header::COOKIE, + format!("ghostlight_session={intruder_cookie}"), + ) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(intruder.status(), StatusCode::NOT_FOUND); + + let download_response = app + .clone() + .oneshot( + Request::builder() + .uri(uri) + .header(header::COOKIE, format!("ghostlight_session={owner_cookie}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(download_response.status(), StatusCode::OK); + assert_eq!( + download_response.headers()[header::CONTENT_TYPE], + "application/vnd.gamecult.cultcache" + ); + let exported = axum::body::to_bytes(download_response.into_body(), 32 * 1024 * 1024) + .await + .unwrap(); + let downloaded_path = dir.path().join("downloaded.cc"); + std::fs::write(&downloaded_path, &exported).unwrap(); + let exported_store = CampaignStore::open(downloaded_path).unwrap(); + let exported_campaign = exported_store + .load::("campaign.v1", &campaign.id.to_string()) + .unwrap() + .unwrap() + .1; + assert_eq!(exported_campaign, expected_export); + + let replay = app + .oneshot( + Request::builder() + .uri(uri) + .header(header::COOKIE, format!("ghostlight_session={owner_cookie}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(replay.status(), StatusCode::NOT_FOUND); + } + #[test] fn authority_fields_are_rejected_recursively_at_eve_ingress() { assert!(contains_authority_field( diff --git a/crates/ghostlight-dungeon/src/surface.rs b/crates/ghostlight-dungeon/src/surface.rs index a20f23d..ef41ce2 100644 --- a/crates/ghostlight-dungeon/src/surface.rs +++ b/crates/ghostlight-dungeon/src/surface.rs @@ -266,6 +266,13 @@ pub fn player_surface_for_actor(campaign: &Campaign, viewer_actor_id: &str) -> V json!({}), &["destination"], )); + children.push(command_control( + "dungeon.campaign.export", + "Export campaign (.cc)", + "campaign.export", + json!({}), + &[], + )); json!({ "type":"surface-state", "schema":"gamecult.eve.surface.v1", "providerId":"gamecult.ghostlight.dungeon", "providerKind":"narrative.simulation", "title":campaign.name, "version":interface_version, @@ -296,6 +303,7 @@ pub fn player_surface_for_actor(campaign: &Campaign, viewer_actor_id: &str) -> V eve_command("world.destination.compile","ghostlight.destination_compile.v1", &["destination"], "WorldCompiler"), eve_command("world.destination.approve","ghostlight.destination_approval.v1", &[], "WorldKernel"), eve_command("campaign.entry","ghostlight.campaign_entry.v1", &[], "ghostlight.account_preferences.v1"), + eve_command("campaign.export","ghostlight.campaign_export_request.v1", &[], "CampaignRegistry"), eve_command("campaign.contract_review.begin","ghostlight.contract_review_begin.v1", &[], "SessionZeroKernel"), eve_command("app.auth.logout","ghostlight.app_logout.v1", &[], "ghostlight.app_session.v1") ] diff --git a/vendor/eve b/vendor/eve index 19c3dcf..672c0c1 160000 --- a/vendor/eve +++ b/vendor/eve @@ -1 +1 @@ -Subproject commit 19c3dcf9173dce848a6253e975324ea239a02d24 +Subproject commit 672c0c1e9fc828b63752345385727b06c50491d0