Expose exact campaign exports through Eve

This commit is contained in:
Meta Vens 2026-08-25 18:01:04 +02:00
commit 93c3a09cd0
4 changed files with 511 additions and 3 deletions

View file

@ -15,6 +15,27 @@ pub struct AppSessionState {
pub account_preferences: BTreeMap<String, AccountPreferences>,
#[serde(default)]
pub command_receipts: BTreeMap<String, EveCommandCacheEntry>,
#[serde(default)]
pub campaign_export_grants: BTreeMap<String, CampaignExportGrant>,
}
#[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<Utc>,
pub expires_at: DateTime<Utc>,
pub consumed_at: Option<DateTime<Utc>>,
}
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<Utc>,
lifetime: chrono::Duration,
) -> anyhow::Result<String> {
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<Utc>,
) -> anyhow::Result<Option<CampaignExportResource>> {
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();

View file

@ -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<Arc<SessionZeroDirector>>,
entitlements: Arc<dyn EntitlementPort>,
@ -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<u64> {
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<AppState>) -> Response {
}
}
async fn eve_resource(
Path(token): Path<String>,
headers: HeaderMap,
State(state): State<AppState>,
) -> 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<AppState>,
@ -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>("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(

View file

@ -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")
]

2
vendor/eve vendored

@ -1 +1 @@
Subproject commit 19c3dcf9173dce848a6253e975324ea239a02d24
Subproject commit 672c0c1e9fc828b63752345385727b06c50491d0