diff --git a/crates/ghostlight-dungeon/src/eve.rs b/crates/ghostlight-dungeon/src/eve.rs index 62e38d6..c049509 100644 --- a/crates/ghostlight-dungeon/src/eve.rs +++ b/crates/ghostlight-dungeon/src/eve.rs @@ -213,10 +213,16 @@ fn jurisdiction_label(world: &WorldSnapshot, jurisdiction: JurisdictionKey) -> S /// carries no play-turn state of its own. The play card below reads `play` /// alone, never the operator's own unscoped log of every committed event: /// invariant 8's projection, question, and refusal, nothing else. +/// +/// `seed_available` is whether the seed lane's vault is configured on this +/// server (`runtime::seed_vault_root`, the one reader of that configuration). +/// The seed controls and the `world.seed` descriptor are offered only where +/// pressing the button could do something. pub(crate) fn authenticated_surface( account: &str, snapshot: Option<&WorldSnapshot>, play: Option<&PlayTurnView>, + seed_available: bool, ) -> anyhow::Result { // The document's own `version` names the world's `surface_version` // alone — the one meaning `routeHint.sourceVersion` and the kernel's @@ -369,7 +375,7 @@ pub(crate) fn authenticated_surface( "WorldMailbox", )); } - if world.owner == ghostlight::PrincipalId::new(account) { + if seed_available && world.owner == ghostlight::PrincipalId::new(account) { children.extend([ json!({ "id":"world.seed.vault_scope", @@ -604,7 +610,12 @@ pub(crate) fn authenticated_surface( json!({ "id":"world.play.text", "kind":"control.input.textarea", - "props":{"label":"What do you do?","rows":3,"placeholder":"Write freely"}, + // PA.f214: an authored `value`, as `world.create.brief` above: + // the lowering only captures a `value`, so a box left + // untouched must still submit `text: ""`, which continues + // an existing Running turn (the running hint tells players + // to click Play with the box empty). + "props":{"label":"What do you do?","rows":3,"value":"","placeholder":"Write freely"}, "stateBindings":[local_draft("text", "string")], "children":[] }), @@ -913,7 +924,7 @@ mod tests { #[test] fn empty_authenticated_surface_has_create_without_session_zero() { - let surface = authenticated_surface("sha256:owner", None, None).unwrap(); + let surface = authenticated_surface("sha256:owner", None, None, true).unwrap(); let encoded = serde_json::to_string(&surface).unwrap(); assert!(encoded.contains("world.create")); assert!(encoded.contains("narrative_persona_label")); diff --git a/crates/ghostlight-dungeon/src/fixtures/eve_client_bridge_intents.json b/crates/ghostlight-dungeon/src/fixtures/eve_client_bridge_intents.json index f35d617..4a3b212 100644 --- a/crates/ghostlight-dungeon/src/fixtures/eve_client_bridge_intents.json +++ b/crates/ghostlight-dungeon/src/fixtures/eve_client_bridge_intents.json @@ -54,30 +54,6 @@ }, "label": "world.approve" }, - { - "intent": { - "clientId": "eve.bridge.test", - "commandBoundary": "ghostlight.eve.commands", - "issuedAt": "2026-09-22T23:30:07.195Z", - "operation": { - "idempotencyKey": "1d6a441e-f173-41af-a798-a9123039d5e0", - "operationId": "world.seed", - "routeHint": { - "sourceVersion": 2, - "transport": "https-json" - }, - "schemaId": "ghostlight.world_seed.v1" - }, - "payload": { - "bindings": {} - }, - "providerId": "gamecult.ghostlight.dungeon", - "receiptSchema": "gamecult.eve.command_result.v1", - "schema": "gamecult.eve.command_invocation.v1", - "surfaceId": "ghostlight.play" - }, - "label": "world.seed" - }, { "intent": { "clientId": "eve.bridge.test", diff --git a/crates/ghostlight-dungeon/src/play.rs b/crates/ghostlight-dungeon/src/play.rs index a36ce06..e38a15f 100644 --- a/crates/ghostlight-dungeon/src/play.rs +++ b/crates/ghostlight-dungeon/src/play.rs @@ -3745,7 +3745,7 @@ pub(crate) mod tests { // card that once read it) — the leaking text above is durable and // real, and this call has structurally no way to reach it. let surface = - crate::eve::authenticated_surface("player-projection-only", Some(&world), Some(&view)) + crate::eve::authenticated_surface("player-projection-only", Some(&world), Some(&view), true) .unwrap(); let play_card = find_surface_node(&surface, "world.play.card").expect("the play card"); let play_card_encoded = serde_json::to_string(play_card).unwrap(); @@ -3812,6 +3812,7 @@ pub(crate) mod tests { "player-interrupted-turn", Some(&world), Some(&running_view), + true, ) .unwrap(); let running_hint = find_surface_node(&surface, "world.play.running") @@ -3837,6 +3838,7 @@ pub(crate) mod tests { "player-interrupted-turn", Some(&world), Some(&awaiting_view), + true, ) .unwrap(); assert!( @@ -3877,6 +3879,7 @@ pub(crate) mod tests { "player-interrupted-turn-busy", Some(&world), Some(&busy_view), + true, ) .unwrap(); assert!( @@ -3919,6 +3922,7 @@ pub(crate) mod tests { "player-round-in-flight", Some(&world), Some(&busy_view), + true, ) .unwrap(); assert!( @@ -3941,6 +3945,7 @@ pub(crate) mod tests { "player-round-in-flight", Some(&world), Some(&idle_view), + true, ) .unwrap(); assert!( @@ -3976,6 +3981,7 @@ pub(crate) mod tests { "player-stalled-turn-text", Some(&world), Some(&running_view), + true, ) .unwrap(); let running_hint = find_surface_node(&surface, "world.play.running") @@ -4048,6 +4054,7 @@ pub(crate) mod tests { "player-card-question", Some(&world_before), Some(&asked_view), + true, ) .unwrap(); @@ -4096,6 +4103,7 @@ pub(crate) mod tests { "player-card-question", Some(&world_after), Some(&closed_view), + true, ) .unwrap(); assert!( @@ -8222,7 +8230,7 @@ pub(crate) mod tests { let view = table.current_turn_view().await.unwrap(); assert!(view.fault); let surface = - crate::eve::authenticated_surface("player-fault-card", Some(&world), Some(&view)).unwrap(); + crate::eve::authenticated_surface("player-fault-card", Some(&world), Some(&view), true).unwrap(); let row = find_surface_node(&surface, "world.play.fault") .expect("a fault-closed turn must show the fault row"); let encoded = serde_json::to_string(row).unwrap(); @@ -8230,7 +8238,7 @@ pub(crate) mod tests { let healthy = PlayTurnView { fault: false, ..view }; let surface = - crate::eve::authenticated_surface("player-fault-card", Some(&world), Some(&healthy)).unwrap(); + crate::eve::authenticated_surface("player-fault-card", Some(&world), Some(&healthy), true).unwrap(); assert!(find_surface_node(&surface, "world.play.fault").is_none()); } @@ -8294,7 +8302,7 @@ pub(crate) mod tests { let world = fixture.world.snapshot().await.unwrap(); let view = table.current_turn_view().await.unwrap(); assert!(view.fault, "the endpoint failure must close the turn on a fault"); - let surface = crate::eve::authenticated_surface(account, Some(&world), Some(&view)).unwrap(); + let surface = crate::eve::authenticated_surface(account, Some(&world), Some(&view), true).unwrap(); let row = serde_json::to_string(find_surface_node(&surface, "world.play.fault").unwrap()).unwrap(); let stored = table.store.lock().await.current().unwrap().fault.clone().unwrap(); let log = logs.text(); diff --git a/crates/ghostlight-dungeon/src/runtime.rs b/crates/ghostlight-dungeon/src/runtime.rs index fbd1db6..e9f163e 100644 --- a/crates/ghostlight-dungeon/src/runtime.rs +++ b/crates/ghostlight-dungeon/src/runtime.rs @@ -808,6 +808,7 @@ async fn eve_surface( principal.account_subject_hash(), snapshot.as_ref(), play_view.as_ref(), + seed_vault_root().is_some(), ) }) { Ok(surface) => Json(surface).into_response(), @@ -1700,6 +1701,16 @@ async fn maintain_mesh_projection(state: AppState) { /// scope inside it and never the root. const SEED_VAULT_ROOT_ENVIRONMENT: &str = "GHOSTLIGHT_SEED_VAULT_ROOT"; +/// The configured vault root, if any. The one reader of +/// `SEED_VAULT_ROOT_ENVIRONMENT`: the surface offers the seed controls on +/// whether this answers, and `seed_once` reads its root from it, so the two +/// cannot disagree about whether seeding exists on this server. +fn seed_vault_root() -> Option { + std::env::var_os(SEED_VAULT_ROOT_ENVIRONMENT) + .filter(|root| !root.is_empty()) + .map(PathBuf::from) +} + /// One seeding session, inside the request that asked for it. There is no /// background task and no stop channel: a spawned sweep would need the owner's /// verified evidence to outlive the request that carried it, and then two new @@ -1736,14 +1747,14 @@ async fn seed_once( // `SEED_VAULT_ROOT_ENVIRONMENT` to the player taught them a fact about // this process's own environment, not about the world. The detail is // still logged, for the operator who can actually act on it. - let root = std::env::var(SEED_VAULT_ROOT_ENVIRONMENT).map_err(|_| { + let root = seed_vault_root().ok_or_else(|| { tracing::warn!( environment_variable = SEED_VAULT_ROOT_ENVIRONMENT, "seeding was requested but the vault root is not configured" ); RuntimeCommandError::Payload("seeding is not available on this world".into()) })?; - let vault = VaultEvidenceSource::open(std::path::Path::new(&root), &payload.vault_scope) + let vault = VaultEvidenceSource::open(&root, &payload.vault_scope) .map_err(|error| RuntimeCommandError::Payload(error.to_string()))?; let runner = controllers.seeder( SeedPort::new(state.world.clone(), verified_principal.clone()), @@ -3166,26 +3177,14 @@ mod tests { let approved = post(&fixture.state, &fixture.cookie, approve_intents.into_iter().next().unwrap()).await; assert_eq!(approved["receipt"]["state"], "accepted", "world.approve via the real client: {approved}"); + // No test fixture in this crate configures `GHOSTLIGHT_SEED_VAULT_ROOT`, + // so the Draft surface offers no seeding to click at all (PA.f213). let approved_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - let seed_intents = client_intents(&approved_surface, &provider, json!([{"click": "world.seed"}])); - assert_eq!(seed_intents.len(), 1); - let seeded = post(&fixture.state, &fixture.cookie, seed_intents.into_iter().next().unwrap()).await; - // No test fixture in this crate configures `GHOSTLIGHT_SEED_VAULT_ROOT` - // (`world_seed_is_owner_only_and_draft_only_before_it_spends_anything` - // asserts the same denial), so a real vault read is out of reach - // here regardless of payload shape. The proof this leg carries is - // narrower: the payload itself was accepted and reached the vault - // lookup at all — `SeedPayload` has no required field, so - // `captureBindings: []` (the click alone) is this operation's own - // envelope-only proof — never refused as a payload/binding shape - // problem the way every operation was before this cut. - assert_eq!( - seeded["receipt"]["message"], "invalid command payload: seeding is not available on this world", - "world.seed via the real client must reach the vault lookup, not refuse the payload shape: {seeded}" + assert!( + !serde_json::to_string(&approved_surface).unwrap().contains("world.seed"), + "a server with no vault offered seeding to the real client" ); - - let seeded_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - let activate_intents = client_intents(&seeded_surface, &provider, json!([{"click": "world.activate"}])); + let activate_intents = client_intents(&approved_surface, &provider, json!([{"click": "world.activate"}])); assert_eq!(activate_intents.len(), 1); let activated = post(&fixture.state, &fixture.cookie, activate_intents.into_iter().next().unwrap()).await; assert_eq!(activated["receipt"]["state"], "accepted", "world.activate via the real client: {activated}"); @@ -3194,12 +3193,16 @@ mod tests { let play_intents = client_intents( &active_surface, &provider, - json!([ - {"set": {"world.play.text": "The new owner looks around."}}, - {"click": "world.play"} - ]), + // PA.f214: the box is never touched, so the intent carries the + // control's authored `text: ""`, not a missing binding. + json!([{"click": "world.play"}]), ); assert_eq!(play_intents.len(), 1); + assert_eq!( + play_intents[0]["payload"]["bindings"]["text"], "", + "an untouched Play box must reach the route as an empty continue: {}", + play_intents[0] + ); let played = post(&fixture.state, &fixture.cookie, play_intents.into_iter().next().unwrap()).await; assert_eq!(played["receipt"]["state"], "accepted", "world.play via the real client: {played}"); @@ -3272,14 +3275,6 @@ mod tests { let approved = post(&fixture.state, &fixture.cookie, approve.intent).await; assert_eq!(approved["receipt"]["state"], "accepted", "{approved}"); - let seed = steps.next().unwrap(); - assert_eq!(seed.label, "world.seed"); - let seeded = post(&fixture.state, &fixture.cookie, seed.intent).await; - assert_eq!( - seeded["receipt"]["message"], "invalid command payload: seeding is not available on this world", - "{seeded}" - ); - let activate = steps.next().unwrap(); assert_eq!(activate.label, "world.activate"); let activated = post(&fixture.state, &fixture.cookie, activate.intent).await; @@ -3289,7 +3284,7 @@ mod tests { assert_eq!(play.label, "world.play"); let played = post(&fixture.state, &fixture.cookie, play.intent).await; assert_eq!(played["receipt"]["state"], "accepted", "{played}"); - assert!(steps.next().is_none(), "the fixture must carry exactly these five steps"); + assert!(steps.next().is_none(), "the fixture must carry exactly these four steps"); let table = fixture.state.play.clone().unwrap(); let observed = tokio::time::timeout(Duration::from_secs(5), async move { @@ -3811,14 +3806,7 @@ mod tests { post(&fixture.state, &fixture.cookie, approve_intent.clone()).await; let approved_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - let seed_intent = client_intents(&approved_surface, &provider, json!([{"click": "world.seed"}])) - .into_iter() - .next() - .unwrap(); - post(&fixture.state, &fixture.cookie, seed_intent.clone()).await; - - let seeded_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - let activate_intent = client_intents(&seeded_surface, &provider, json!([{"click": "world.activate"}])) + let activate_intent = client_intents(&approved_surface, &provider, json!([{"click": "world.activate"}])) .into_iter() .next() .unwrap(); @@ -3838,7 +3826,7 @@ mod tests { .unwrap(); post(&fixture.state, &fixture.cookie, play_intent.clone()).await; - let regenerated = [create_intent, approve_intent, seed_intent, activate_intent, play_intent]; + let regenerated = [create_intent, approve_intent, activate_intent, play_intent]; let committed = bridge_fixture().steps; assert_eq!(regenerated.len(), committed.len(), "step count drifted"); for (regenerated_intent, committed_step) in regenerated.into_iter().zip(committed) { @@ -6050,7 +6038,7 @@ mod tests { .account_subject_hash() .to_owned(); let stranger = "someone-else"; - let mut surfaces = vec![eve::authenticated_surface(&owner, None, None).unwrap()]; + let mut surfaces = vec![eve::authenticated_surface(&owner, None, None, true).unwrap()]; two_cell_world( &fixture.state, &fixture.cookie, @@ -6065,7 +6053,7 @@ mod tests { let draft = fixture.state.world.snapshot().await.unwrap(); assert_eq!(draft.phase, WorldPhase::Draft); for account in [owner.as_str(), stranger] { - surfaces.push(eve::authenticated_surface(account, Some(&draft), None).unwrap()); + surfaces.push(eve::authenticated_surface(account, Some(&draft), None, true).unwrap()); } for body in [CommandBody::ApproveDraft, CommandBody::ActivateWorld] { let snapshot = fixture.state.world.snapshot().await.unwrap(); @@ -6097,7 +6085,7 @@ mod tests { let play_view = current_play_view(&fixture.state).await; for account in [owner.as_str(), stranger] { surfaces.push( - eve::authenticated_surface(account, Some(&active), play_view.as_ref()).unwrap(), + eve::authenticated_surface(account, Some(&active), play_view.as_ref(), true).unwrap(), ); } surfaces.push(eve::anonymous_surface()); @@ -6164,6 +6152,7 @@ mod tests { let draft = fixture.state.world.snapshot().await.unwrap(); // The vault root is deliberately unset: neither refusal may reach it. + let _env = SEED_ENV_LOCK.lock().await; unsafe { std::env::remove_var(SEED_VAULT_ROOT_ENVIRONMENT) }; let denied = post( &fixture.state, @@ -6257,6 +6246,7 @@ mod tests { .await; let draft = fixture.state.world.snapshot().await.unwrap(); + let _env = SEED_ENV_LOCK.lock().await; unsafe { std::env::remove_var(SEED_VAULT_ROOT_ENVIRONMENT) }; let denied = post( &fixture.state, @@ -6309,7 +6299,7 @@ mod tests { ) .await; let draft = fixture.state.world.snapshot().await.unwrap(); - let surface = eve::authenticated_surface(&owner, Some(&draft), None).unwrap(); + let surface = eve::authenticated_surface(&owner, Some(&draft), None, true).unwrap(); let encoded = serde_json::to_string(&surface).unwrap(); let card = surface["surface"]["root"]["children"] .as_array() @@ -6339,6 +6329,231 @@ mod tests { assert!(encoded.contains("world.seed"), "the button is missing"); } + /// `GHOSTLIGHT_SEED_VAULT_ROOT` is process-global: a test that sets or + /// clears it holds this for its whole body so no other such test sees the + /// change mid-flight. + static SEED_ENV_LOCK: Mutex<()> = Mutex::const_new(()); + + /// The control node a surface binds to `binding`: what the vendored + /// lowering's `findAuthoredBindingValue` looks up when a button is clicked. + fn control_for_binding<'a>(node: &'a Value, binding: &str) -> Option<&'a Value> { + let bound = node["stateBindings"] + .as_array() + .into_iter() + .flatten() + .any(|state| state["bindingName"] == binding); + if bound { + return Some(node); + } + node["children"] + .as_array() + .into_iter() + .flatten() + .find_map(|child| control_for_binding(child, binding)) + } + + fn button_for_command<'a>(node: &'a Value, command: &str) -> Option<&'a Value> { + if node["kind"] == "control.button" && node["props"]["command"] == command { + return Some(node); + } + node["children"] + .as_array() + .into_iter() + .flatten() + .find_map(|child| button_for_command(child, command)) + } + + /// What the vendored lowering sends when `command`'s button is clicked and + /// the player has typed only into the controls named in `typed`: the + /// button's own action fields beside `bindings`, where a binding takes the + /// typed text, else the control's authored `value`, else is omitted. Every + /// input is read off the served surface, so the payload is whatever the + /// production surface would make a real client send. + fn untouched_intent(surface: &Value, command: &str, typed: &[(&str, Value)]) -> Value { + let root = &surface["surface"]["root"]; + let button = button_for_command(root, command) + .unwrap_or_else(|| panic!("the surface carries no {command} button")); + let mut payload = button["props"]["action"].as_object().cloned().unwrap_or_default(); + payload.remove("command"); + let mut bindings = serde_json::Map::new(); + for binding in button["props"]["captureBindings"].as_array().into_iter().flatten() { + let binding = binding.as_str().unwrap(); + if let Some((_, value)) = typed.iter().find(|(name, _)| *name == binding) { + bindings.insert(binding.to_owned(), value.clone()); + } else if let Some(authored) = control_for_binding(root, binding) + .and_then(|control| control["props"].get("value")) + { + bindings.insert(binding.to_owned(), authored.clone()); + } + } + payload.insert("bindings".into(), Value::Object(bindings)); + let descriptor = surface["commands"] + .as_array() + .unwrap() + .iter() + .find(|descriptor| descriptor["command"] == command) + .unwrap_or_else(|| panic!("the surface advertises no {command} descriptor")); + invocation( + command, + descriptor["payloadSchema"].as_str().unwrap(), + surface["version"].as_u64().unwrap(), + Value::Object(payload), + &uuid::Uuid::new_v4().to_string(), + ) + } + + /// PA.f214, at the layer where it failed: a form left untouched must still + /// submit a payload the route admits. The player types only where an empty + /// answer has no meaning (a world's title and first name, how many minutes + /// to advance); every other control is left exactly as the surface served + /// it, and every bound command must then be admitted, not refused as a + /// missing field. Play with an untouched box sends `text: ""`. + #[tokio::test] + async fn every_bound_command_admits_the_payload_an_untouched_form_submits() { + let fixture = fixture().await; + let mut exercised = Vec::new(); + let steps: [(&str, Vec<(&str, Value)>); 5] = [ + ( + "world.create", + vec![ + ("title", json!("Untouched Form World")), + ("subject_label", json!("The Operator")), + ], + ), + ("world.approve", vec![]), + ("world.activate", vec![]), + ("world.advance_time", vec![("minutes", json!(1))]), + ("world.play", vec![]), + ]; + for (command, typed) in steps { + let surface = + get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; + let intent = untouched_intent(&surface, command, &typed); + let sent = intent["payload"].clone(); + let result = post(&fixture.state, &fixture.cookie, intent).await; + assert_eq!( + result["receipt"]["state"], "accepted", + "{command} with an untouched form ({sent}) was not admitted: {result}" + ); + exercised.push(command); + if command == "world.play" { + assert_eq!( + sent["bindings"]["text"], "", + "an untouched Play box must submit an empty continue" + ); + } + } + // The walk reaches every button that captures a binding on the Active + // surface, so a control added later without an authored value or a + // place in this walk fails here rather than in a player's hands. + let active = + get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; + let mut buttons = Vec::new(); + surface_buttons(&active["surface"]["root"], &mut buttons); + for command in buttons { + let bound = button_for_command(&active["surface"]["root"], &command).unwrap()["props"] + ["captureBindings"] + .as_array() + .unwrap() + .len(); + assert!( + bound == 0 || exercised.contains(&command.as_str()), + "{command} captures bindings but the untouched-form walk never submitted it" + ); + } + } + + /// PA.f213: the seed controls, card and descriptor exist only where a vault + /// is configured; with none, pressing the button could only be refused. + #[tokio::test] + async fn the_seed_controls_are_offered_only_where_a_vault_is_configured() { + let fixture = fixture().await; + let owner = fixture + .state + .sessions + .lock() + .await + .account_for_cookie(&fixture.cookie, Utc::now()) + .unwrap() + .unwrap() + .account_subject_hash() + .to_owned(); + two_cell_world( + &fixture.state, + &fixture.cookie, + BTreeMap::from([(SubjectKind::Person, 4)]), + vec![CreateJurisdictionIntent { + handle: "sere".into(), + label: "The Low Sere".into(), + permille: 1000, + }], + ) + .await; + let draft = fixture.state.world.snapshot().await.unwrap(); + let seed_nodes = [ + "world.seed.vault_scope", + "world.seed.brief", + "world.seed", + "world.seed.card", + ]; + let node_ids = |surface: &Value| -> Vec { + fn walk(node: &Value, into: &mut Vec) { + if let Some(id) = node["id"].as_str() { + into.push(id.to_owned()); + } + for child in node["children"].as_array().into_iter().flatten() { + walk(child, into); + } + } + let mut ids = Vec::new(); + walk(&surface["surface"]["root"], &mut ids); + ids + }; + let described = |surface: &Value| { + surface["commands"] + .as_array() + .unwrap() + .iter() + .any(|descriptor| descriptor["command"] == "world.seed") + }; + + let without = eve::authenticated_surface(&owner, Some(&draft), None, false).unwrap(); + let ids = node_ids(&without); + for id in seed_nodes { + assert!(!ids.iter().any(|node| node == id), "{id} offered with no vault"); + } + assert!(!described(&without), "world.seed advertised with no vault"); + + let with = eve::authenticated_surface(&owner, Some(&draft), None, true).unwrap(); + let ids = node_ids(&with); + for id in seed_nodes { + assert!(ids.iter().any(|node| node == id), "{id} missing with a vault"); + } + assert!(described(&with), "world.seed not advertised with a vault"); + + // The served surface reads the configuration through `seed_vault_root`: + // nothing configured offers nothing, and a configured root offers the + // lane without the root's own text ever reaching the card. + let _env = SEED_ENV_LOCK.lock().await; + unsafe { std::env::remove_var(SEED_VAULT_ROOT_ENVIRONMENT) }; + let served = + get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; + assert!(!described(&served), "the served surface offered seeding with no vault"); + assert!(!node_ids(&served).iter().any(|id| id == "world.seed")); + + let canary = "/canary-vault-root-4f9a1c"; + unsafe { std::env::set_var(SEED_VAULT_ROOT_ENVIRONMENT, canary) }; + let served = + get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; + unsafe { std::env::remove_var(SEED_VAULT_ROOT_ENVIRONMENT) }; + assert!(described(&served), "the served surface hid seeding with a vault"); + assert!(node_ids(&served).iter().any(|id| id == "world.seed")); + assert!( + !serde_json::to_string(&served).unwrap().contains("canary-vault-root"), + "the configured root reached the surface" + ); + } + /// `SeedPort` holds one `VerifiedPrincipalEvidence` for the whole session, /// and a multi-round session can run long past the moment the evidence was /// minted. `VerifiedPrincipalEvidence` now carries `valid_until`, minted