diff --git a/src-tauri/src/launcher.rs b/src-tauri/src/launcher.rs index 60895b6..da2d9ca 100644 --- a/src-tauri/src/launcher.rs +++ b/src-tauri/src/launcher.rs @@ -2016,7 +2016,16 @@ impl Launcher { if let Some(url) = config.web_url() { self.log(app, "info", format!("The web client is at {url}")); - if let Err(e) = app.opener().open_url(url.as_str(), None::<&str>) { + // Signed in the way the .exe is, so live mode reaches the browser too. The token + // rides only in the address handed to the browser, never in the line logged above. + let opened = match stack::mint_web_token(&self.http, &config).await { + Ok(token) => config.web_url_signed(&token).unwrap_or(url), + Err(e) => { + self.log(app, "warn", format!("The web client opens without a session ({e}), so it shows this PC's data only.")); + url + } + }; + if let Err(e) = app.opener().open_url(opened.as_str(), None::<&str>) { // Not a failure: the address is in the log and in the panel, and a developer can // paste it. Refusing the whole launch over a browser that would not open would be. self.log(app, "warn", format!("Could not open a browser: {e}. Open the address above yourself.")); diff --git a/src-tauri/src/stack.rs b/src-tauri/src/stack.rs index 7be7ef4..941a228 100644 --- a/src-tauri/src/stack.rs +++ b/src-tauri/src/stack.rs @@ -282,6 +282,17 @@ impl StackConfig { }) } + /// `web_url` with a session token for the web client, in the FRAGMENT. + /// + /// The local API relays to the DHP only a request carrying a token it minted itself - + /// the forwarder's guard against being an open proxy - so a tokenless web client stays + /// on this PC's database while the .exe beside it is live. A fragment never leaves the + /// browser: not in the request line, not in Vite's log. Never log this address; + /// `web_url` is the one to print. + pub fn web_url_signed(&self, token: &str) -> Option { + self.web_url().map(|url| format!("{url}#token={}", url_value(token))) + } + /// Everything this run takes from the checkout rather than the install, for the log. pub fn dev_parts(&self) -> Vec { match &self.dev_engine { @@ -655,6 +666,31 @@ async fn start_web( Ok(()) } +/// A session token from THIS stack's API for the web client, minted with the secret the +/// .exe signs in with and for the same operator - so both clients reach the DHP alike. +pub async fn mint_web_token(http: &reqwest::Client, config: &StackConfig) -> Result { + #[derive(Deserialize)] + struct Minted { + access_token: String, + } + let response = http + .post(format!("http://127.0.0.1:{API_PORT}/auth/mint")) + .header("x-mint-secret", &config.mint_secret) + .json(&serde_json::json!({ "username": config.operator })) + .timeout(Duration::from_secs(10)) + .send() + .await + .map_err(|e| format!("the local API did not answer: {e}"))?; + if !response.status().is_success() { + return Err(format!("the local API refused it (HTTP {})", response.status().as_u16())); + } + response + .json::() + .await + .map(|minted| minted.access_token) + .map_err(|e| format!("the local API's answer made no sense: {e}")) +} + fn failed(emit: Emit<'_>, component: Component, message: String) -> String { emit(StackEvent { component, state: ComponentState::Failed, message: Some(message.clone()) }); message @@ -1337,6 +1373,22 @@ mod tests { assert!(cfg.web_url().unwrap().ends_with("&user=m2op")); } + #[test] + fn the_web_token_rides_in_the_fragment_and_never_in_the_printed_address() { + let mut cfg = config(&[("dark", "protomaps_20260423")]); + cfg.dev_engine = Some(PathBuf::from(r"E:\dev\ODIAN_Engine")); + cfg.dev_target = DevTarget::Web; + let plain = cfg.web_url().unwrap(); + let signed = cfg.web_url_signed("a+b/c=").unwrap(); + // The query is untouched - the token is after `#`, so the browser never sends it. + assert_eq!(signed, format!("{plain}#token=a%2Bb%2Fc%3D")); + assert!(!plain.contains("token"), "the address the launcher logs carries no token"); + + // No web client this run, no address at all. + cfg.dev_target = DevTarget::Client; + assert_eq!(cfg.web_url_signed("t"), None); + } + #[test] fn the_web_server_is_started_on_the_port_the_launcher_advertises() { // Checked as ARGUMENTS rather than by reading the binary: these are short literals that