diff --git a/.env.example b/.env.example index 3905dc2c8f..dbbe1960cf 100644 --- a/.env.example +++ b/.env.example @@ -30,3 +30,14 @@ BUZZ_DEV_VIEWER= # Packaging embeds the value; changing the launched app's environment has no effect. # Restart Vite for development changes; rebuild/repackage for distributed apps. # BUZZ_BUILDERLAB_URL=https://builderlab.example.com + +# Optional Builderlab tenant table: which relays each Builderlab deployment hosts +# and where its people sign in. A community URL typed on the first-launch screen +# matches a row by hostname (exact, or a suffix at a label boundary, default port +# only), then by the relay's NIP-11 `self` key; an unmatched URL asks whether to +# connect with Builderlab at BUZZ_BUILDERLAB_URL or bring an existing key. +# Read by Vite (import.meta.env.VITE_BUZZ_BUILDERLAB_TENANTS) and by the native +# plugin-manager build, which grants every loginOrigin to the bundled Builderlab +# plugin, so set it where BUZZ_BUILDERLAB_URL is set. Public configuration; no +# deployment table is supplied by the repo. Rows: {"relayHost","self"?,"loginOrigin"}. +# BUZZ_BUILDERLAB_TENANTS='[{"relayHost":"communities.buzz.xyz","self":"12f6870117eff1a6318bd38c82a65d51dd19879b7489f57247114d0ee8a96de3","loginOrigin":"https://app.builderlab.xyz"},{"relayHost":"block.builderlab.xyz","self":"76aa9daa7edc9c2e1dc6a2a90dbce10c1bd52f060fa6506ba7bad4f50240fc01","loginOrigin":"https://block.builderlab.xyz"},{"relayHost":"test.blockstaging.build","self":"f7f4df9401ecf46cc608b0889a61d957b306721cdefe42cb24519cb5a473dcf1","loginOrigin":"https://test.blockstaging.build"}]' diff --git a/browser-host/vite-config.test.mjs b/browser-host/vite-config.test.mjs index 78406b929e..ab0d68c8d6 100644 --- a/browser-host/vite-config.test.mjs +++ b/browser-host/vite-config.test.mjs @@ -154,6 +154,21 @@ it("loads the broker's Vite config without native-compatibility warnings", () => ); assert.equal(result.config.define['import.meta.env.VITE_BUZZ_BUILDERLAB_URL'], JSON.stringify(expected)); } + // The tenant table is passed through verbatim once it parses, and + // a malformed table fails at configuration time without being echoed. + const tenants = '[{"relayHost":"communities.buzz.xyz","loginOrigin":"https://app.builderlab.xyz"}]'; + writeFileSync('.env.local', "BUZZ_BUILDERLAB_TENANTS='" + tenants + "'"); + delete process.env.BUZZ_BUILDERLAB_TENANTS; + const tenantTable = await loadConfigFromFile( + { command: 'build', mode: 'production' }, configFile, + ); + assert.equal(tenantTable.config.define['import.meta.env.VITE_BUZZ_BUILDERLAB_TENANTS'], JSON.stringify(tenants)); + process.env.BUZZ_BUILDERLAB_TENANTS = '[{"relayHost":"a.example","loginOrigin":"http://secret.example"}]'; + await assert.rejects( + loadConfigFromFile({ command: 'build', mode: 'production' }, configFile), + (error) => /BUZZ_BUILDERLAB_TENANTS must be/.test(error.message) && !error.message.includes('secret'), + ); + process.env.BUZZ_BUILDERLAB_TENANTS = ''; // Without a viewer pin nothing consumes the seed, so it is neither exposed nor required. process.env.BUZZ_DEV_OPEN_RELAY = '1'; process.env.BUZZ_RELAY_URL = ''; @@ -186,6 +201,7 @@ it("loads the broker's Vite config without native-compatibility warnings", () => BUZZ_DEV_VIEWER: "a".repeat(64), BUZZ_RELAY_URL: "", BUZZ_COMMUNITY_ALIASES: "", + BUZZ_BUILDERLAB_TENANTS: "", VITE_CONFIG_NATIVE_IGNORE_WARNING: "", }, encoding: "utf8", diff --git a/crates/plugin-manager/Cargo.toml b/crates/plugin-manager/Cargo.toml index b2c54e617c..1e426ac985 100644 --- a/crates/plugin-manager/Cargo.toml +++ b/crates/plugin-manager/Cargo.toml @@ -11,6 +11,7 @@ path = "src/main.rs" [build-dependencies] dotenvy = "0.15" +serde_json = "1" url = "2" [dev-dependencies] diff --git a/crates/plugin-manager/build.rs b/crates/plugin-manager/build.rs index 73647367d8..33e48def0e 100644 --- a/crates/plugin-manager/build.rs +++ b/crates/plugin-manager/build.rs @@ -4,71 +4,184 @@ use std::path::{Path, PathBuf}; mod build_env; const BUILDERLAB_URL_KEY: &str = "BUZZ_BUILDERLAB_URL"; +const BUILDERLAB_TENANTS_KEY: &str = "BUZZ_BUILDERLAB_TENANTS"; +const KEYS: [&str; 2] = [BUILDERLAB_URL_KEY, BUILDERLAB_TENANTS_KEY]; +/// `Manifest::validate` refuses more than 16 network origins; one is kept for +/// the build URL's Apps Platform control plane (`apps_control_plane`). +const MAX_ORIGINS: usize = 15; -fn builderlab_origin(local: &Path, process: Option) -> Result { - let values = build_env::load(local, &[BUILDERLAB_URL_KEY], |_| process.clone())?; - let raw = values - .get(BUILDERLAB_URL_KEY) - .map(String::as_str) - .unwrap_or(""); - if raw.is_empty() { - return Ok(String::new()); - } - let url = url::Url::parse(raw).map_err(|_| "Invalid Builderlab build URL")?; +fn secure_origin(raw: &str, error: &'static str) -> Result { + let url = url::Url::parse(raw).map_err(|_| error)?; if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() || url.query().is_some() || url.fragment().is_some() { - return Err("Builderlab build URL must use HTTPS without credentials, query, or fragment"); + return Err(error); + } + Ok(url) +} + +/// `BUZZ_BUILDERLAB_URL`'s origin, or empty when unconfigured. +fn builderlab_origin(raw: &str) -> Result { + if raw.is_empty() { + return Ok(String::new()); } + let url = secure_origin( + raw, + "Builderlab build URL must use HTTPS without credentials, query, or fragment", + )?; Ok(url.origin().ascii_serialization()) } +fn valid_hostname(host: &str) -> bool { + !host.is_empty() + && host.len() <= 253 + && host.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && !label.starts_with('-') + && !label.ends_with('-') + && label + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + }) +} + +/// The distinct login origins of the `BUZZ_BUILDERLAB_TENANTS` rows, in order. +/// Rows follow the frontend parser's rules (`src/features/identity/builderlab-tenants.ts`): +/// `{relayHost, self?, loginOrigin}`, a lowercase hostname, an optional 64-hex +/// relay key, and a login origin that is exactly its own canonical https origin. +fn tenant_origins(raw: &str) -> Result, &'static str> { + const ERROR: &str = "Builderlab tenant table must be a JSON array of {relayHost, self?, loginOrigin} rows with unique hostnames, unique 64-hex relay keys and canonical HTTPS login origins"; + if raw.trim().is_empty() { + return Ok(vec![]); + } + let rows: serde_json::Value = serde_json::from_str(raw).map_err(|_| ERROR)?; + let rows = rows.as_array().ok_or(ERROR)?; + let mut hosts = std::collections::HashSet::new(); + let mut keys = std::collections::HashSet::new(); + let mut origins = Vec::new(); + for row in rows { + let row = row.as_object().ok_or(ERROR)?; + if !row + .keys() + .all(|key| matches!(key.as_str(), "relayHost" | "self" | "loginOrigin")) + { + return Err(ERROR); + } + let host = row.get("relayHost").and_then(|v| v.as_str()).ok_or(ERROR)?; + let host = host.trim().to_ascii_lowercase(); + let host = host.strip_suffix('.').unwrap_or(&host); + if !valid_hostname(host) || !hosts.insert(host.to_owned()) { + return Err(ERROR); + } + if let Some(key) = row.get("self") { + let key = key.as_str().ok_or(ERROR)?; + if key.len() != 64 + || !key + .bytes() + .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) + || !keys.insert(key.to_owned()) + { + return Err(ERROR); + } + } + let login = row + .get("loginOrigin") + .and_then(|v| v.as_str()) + .ok_or(ERROR)?; + let origin = secure_origin(login, ERROR)?.origin().ascii_serialization(); + if origin != login { + return Err(ERROR); + } + if !origins.contains(&origin) { + origins.push(origin); + } + } + Ok(origins) +} + +/// Every origin the bundled Builderlab plugin may reach: the build URL's, then +/// each tenant login origin, deduplicated and comma-joined for `env!`. +fn builderlab_origins( + local: &Path, + process: impl Fn(&str) -> Option, +) -> Result<(String, String), &'static str> { + let values = build_env::load(local, &KEYS, process)?; + let get = |key: &str| values.get(key).map(String::as_str).unwrap_or(""); + let origin = builderlab_origin(get(BUILDERLAB_URL_KEY))?; + let mut origins: Vec = if origin.is_empty() { + vec![] + } else { + vec![origin.clone()] + }; + for tenant in tenant_origins(get(BUILDERLAB_TENANTS_KEY))? { + if !origins.contains(&tenant) { + origins.push(tenant); + } + } + if origins.len() > MAX_ORIGINS { + return Err("Too many Builderlab login origins"); + } + Ok((origin, origins.join(","))) +} + #[cfg_attr(test, allow(dead_code))] fn main() { let root = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()).join("../.."); let local = root.join(".env.local"); println!("cargo:rerun-if-changed={}", local.display()); - println!("cargo:rerun-if-env-changed={BUILDERLAB_URL_KEY}"); + for key in KEYS { + println!("cargo:rerun-if-env-changed={key}"); + } println!( "cargo:rerun-if-changed={}", root.join("crates/build_env.rs").display() ); - let process = match std::env::var(BUILDERLAB_URL_KEY) { + let process = |key: &str| match std::env::var(key) { Ok(value) => Some(value), Err(std::env::VarError::NotPresent) => None, Err(std::env::VarError::NotUnicode(_)) => panic!("Native build inputs must be UTF-8"), }; - let origin = - builderlab_origin(&local, process).expect("Invalid Builderlab build configuration"); + let (origin, origins) = + builderlab_origins(&local, process).expect("Invalid Builderlab build configuration"); println!("cargo:rustc-env=BUZZ_BUILDERLAB_ORIGIN={origin}"); + println!("cargo:rustc-env=BUZZ_BUILDERLAB_ORIGINS={origins}"); } #[cfg(test)] mod tests { use super::*; + fn with_url(local: &Path, url: Option<&str>) -> Result { + let url = url.map(str::to_owned); + builderlab_origins(local, |key| { + (key == BUILDERLAB_URL_KEY).then(|| url.clone()).flatten() + }) + .map(|(origin, _)| origin) + } + #[test] fn local_url_and_process_overrides_produce_an_exact_origin() { let directory = tempfile::tempdir().unwrap(); let local = directory.path().join(".env.local"); - assert_eq!(builderlab_origin(&local, None).unwrap(), ""); + assert_eq!(with_url(&local, None).unwrap(), ""); std::fs::write( &local, "\u{feff}export BUZZ_BUILDERLAB_URL=\"https://APP.BUILDERLAB.XYZ:443/deployment/\" # comment\n", ) .unwrap(); assert_eq!( - builderlab_origin(&local, None).unwrap(), + with_url(&local, None).unwrap(), "https://app.builderlab.xyz" ); assert_eq!( - builderlab_origin(&local, Some("https://login.example:8443/path/".into())).unwrap(), + with_url(&local, Some("https://login.example:8443/path/")).unwrap(), "https://login.example:8443" ); - assert_eq!(builderlab_origin(&local, Some(String::new())).unwrap(), ""); + assert_eq!(with_url(&local, Some("")).unwrap(), ""); } #[test] @@ -82,16 +195,16 @@ mod tests { "https://login.example#", "invalid", ] { - let error = builderlab_origin(&local, Some(configured.into())).unwrap_err(); + let error = with_url(&local, Some(configured)).unwrap_err(); assert!(!error.contains(configured)); } std::fs::write(&local, "BUZZ_BUILDERLAB_URL='unterminated").unwrap(); assert_eq!( - builderlab_origin(&local, None).unwrap_err(), + with_url(&local, None).unwrap_err(), "Invalid native build .env.local" ); // Explicit process input also overrides malformed same-key assignments. - assert_eq!(builderlab_origin(&local, Some(String::new())).unwrap(), ""); + assert_eq!(with_url(&local, Some("")).unwrap(), ""); } #[test] @@ -99,7 +212,7 @@ mod tests { let directory = tempfile::tempdir().unwrap(); let local = directory.path().join(".env.local"); std::fs::write(&local, "PRIVATE_VALUE='unterminated").unwrap(); - assert_eq!(builderlab_origin(&local, None).unwrap(), ""); + assert_eq!(with_url(&local, None).unwrap(), ""); for quote in ["'", "\"", "`"] { std::fs::write( @@ -110,7 +223,7 @@ mod tests { ) .unwrap(); assert_eq!( - builderlab_origin(&local, None).unwrap(), + with_url(&local, None).unwrap(), "https://app.builderlab.xyz" ); } @@ -122,7 +235,84 @@ mod tests { ), ) .unwrap(); - assert_eq!(builderlab_origin(&local, None).unwrap(), ""); + assert_eq!(with_url(&local, None).unwrap(), ""); + } + } + + const TENANTS: &str = r#"[ + {"relayHost":"communities.buzz.xyz","self":"12f6870117eff1a6318bd38c82a65d51dd19879b7489f57247114d0ee8a96de3","loginOrigin":"https://app.builderlab.xyz"}, + {"relayHost":"block.builderlab.xyz","self":"76aa9daa7edc9c2e1dc6a2a90dbce10c1bd52f060fa6506ba7bad4f50240fc01","loginOrigin":"https://block.builderlab.xyz"}, + {"relayHost":"test.blockstaging.build","self":"f7f4df9401ecf46cc608b0889a61d957b306721cdefe42cb24519cb5a473dcf1","loginOrigin":"https://test.blockstaging.build"} + ]"#; + + #[test] + fn tenant_table_widens_the_grant_to_every_distinct_login_origin() { + let directory = tempfile::tempdir().unwrap(); + let local = directory.path().join(".env.local"); + let inputs = |url: &str, tenants: &str| { + let (url, tenants) = (url.to_owned(), tenants.to_owned()); + builderlab_origins(&local, move |key| { + Some(if key == BUILDERLAB_URL_KEY { + url.clone() + } else { + tenants.clone() + }) + }) + }; + assert_eq!( + inputs("https://app.builderlab.xyz/", TENANTS).unwrap(), + ( + "https://app.builderlab.xyz".to_owned(), + "https://app.builderlab.xyz,https://block.builderlab.xyz,https://test.blockstaging.build".to_owned() + ) + ); + // Without the build URL the tenants alone are granted; without rows nothing changes. + assert_eq!( + inputs( + "", + r#"[{"relayHost":"Relay.Example.","loginOrigin":"https://login.example"}]"# + ) + .unwrap(), + (String::new(), "https://login.example".to_owned()) + ); + assert_eq!(inputs("", " ").unwrap(), (String::new(), String::new())); + // The file supplies the table when the process does not. + std::fs::write( + &local, + format!("BUZZ_BUILDERLAB_TENANTS='{}'\n", TENANTS.replace('\n', "")), + ) + .unwrap(); + let (_, origins) = builderlab_origins(&local, |_| None).unwrap(); + assert_eq!( + origins, + "https://app.builderlab.xyz,https://block.builderlab.xyz,https://test.blockstaging.build" + ); + } + + #[test] + fn malformed_tenant_rows_fail_without_echoing_their_contents() { + let directory = tempfile::tempdir().unwrap(); + let local = directory.path().join(".env.local"); + for tenants in [ + "{", + r#"{"relayHost":"a.example","loginOrigin":"https://a.example"}"#, + r#"[{"relayHost":"a.example","loginOrigin":"http://secret.example"}]"#, + r#"[{"relayHost":"a.example","loginOrigin":"https://user:secret@a.example"}]"#, + r#"[{"relayHost":"a.example","loginOrigin":"https://a.example/path"}]"#, + r#"[{"relayHost":"a.example","loginOrigin":"https://a.example:443"}]"#, + r#"[{"relayHost":"https://a.example","loginOrigin":"https://a.example"}]"#, + r#"[{"relayHost":"*.a.example","loginOrigin":"https://a.example"}]"#, + r#"[{"relayHost":"a.example:8443","loginOrigin":"https://a.example"}]"#, + r#"[{"relayHost":"a.example","self":"ABCD","loginOrigin":"https://a.example"}]"#, + r#"[{"relayHost":"a.example","loginOrigin":"https://a.example","extra":1}]"#, + r#"[{"relayHost":"a.example","loginOrigin":"https://a.example"},{"relayHost":"A.example","loginOrigin":"https://b.example"}]"#, + ] { + let error = builderlab_origins(&local, |key| { + (key == BUILDERLAB_TENANTS_KEY).then(|| tenants.to_owned()) + }) + .unwrap_err(); + assert!(!error.contains("secret"), "{error}"); + assert!(!error.contains("a.example"), "{error}"); } } } diff --git a/crates/plugin-manager/src/lib.rs b/crates/plugin-manager/src/lib.rs index cdcb27a3a6..f6e3813812 100644 --- a/crates/plugin-manager/src/lib.rs +++ b/crates/plugin-manager/src/lib.rs @@ -238,22 +238,26 @@ pub fn apps_control_plane(origin: &str) -> Option<&'static str> { _ => None, } } +/// Every Builderlab login origin the bundled plugin may reach: `BUILDERLAB_ORIGIN` +/// and each distinct `BUZZ_BUILDERLAB_TENANTS` login origin, as build.rs joined them. +pub fn builderlab_origins() -> Vec { + env!("BUZZ_BUILDERLAB_ORIGINS") + .split(',') + .filter(|origin| !origin.is_empty()) + .map(str::to_owned) + .collect() +} pub fn bundled_manifests() -> Vec { let mut builderlab: Manifest = serde_json::from_str(include_str!( "../../../src/bundled/builderlab/manifest.json" )) .expect("builderlab manifest"); - let origin = BUILDERLAB_ORIGIN; builderlab.host = Some(HostGrants { - files: apps_control_plane(origin).is_some(), - network_origins: if origin.is_empty() { - vec![] - } else { - std::iter::once(origin) - .chain(apps_control_plane(origin)) - .map(Into::into) - .collect() - }, + files: apps_control_plane(BUILDERLAB_ORIGIN).is_some(), + network_origins: builderlab_origins() + .into_iter() + .chain(apps_control_plane(BUILDERLAB_ORIGIN).map(Into::into)) + .collect(), ..HostGrants::default() }); vec![ @@ -2028,15 +2032,15 @@ mod tests { ); assert_eq!( grants.network_origins, - if env!("BUZZ_BUILDERLAB_ORIGIN").is_empty() { - vec![] - } else { - std::iter::once(env!("BUZZ_BUILDERLAB_ORIGIN")) - .chain(super::apps_control_plane(env!("BUZZ_BUILDERLAB_ORIGIN"))) - .map(str::to_owned) - .collect() - } + super::builderlab_origins() + .into_iter() + .chain(super::apps_control_plane(super::BUILDERLAB_ORIGIN).map(str::to_owned)) + .collect::>() ); + // The build URL's origin, when configured, is always granted first. + if !super::BUILDERLAB_ORIGIN.is_empty() { + assert_eq!(grants.network_origins[0], super::BUILDERLAB_ORIGIN); + } assert!(grants.commands.is_empty()); assert!(manager .host_grants("block.builderlab", "wrong-revision") diff --git a/docs/configuration.md b/docs/configuration.md index f212a03e97..edd9052459 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -9,6 +9,7 @@ not in source. Build defaults are readable binary data, **never secret storage** | Input | Consumer and boundary | | --- | --- | | `BUZZ_BUILDERLAB_URL` | Shared frontend/native build: public login URL and the bundled plugin's exact HTTPS host grant; no runtime override. | +| `BUZZ_BUILDERLAB_TENANTS` | Shared frontend/native build: which relays each Builderlab deployment hosts and its login origin, for the first-launch community URL; every login origin joins the bundled plugin's host grant. No runtime override. | | `BUZZ_DEFAULT_COMMUNITY_URL` | Packaged frontend build: secure relay origin to select once, after identity setup, only when this viewer has no saved community record. Saved choices, including Personal space, always win. Unset in OSS builds. | | `BUZZ_DATABRICKS_HOST` | Frontend build and dev server: `https://` Databricks workspace URL passed as `DATABRICKS_HOST` to every bundled `goose-acp`, which then prefers it to the host in goose's config. Unset in OSS builds. See [Databricks host](#databricks-host). | | `BUZZ_RELAY_URL` | Live development broker's default community, not an agent relay override or a packaged default. | @@ -96,6 +97,45 @@ web and desktop frontend builds substitute the value into compiled code: changin or the launched app's environment afterward cannot override it. Rebuild and redistribute to change a packaged value. +## Builderlab tenant table build input + +`BUZZ_BUILDERLAB_TENANTS` tells the first-launch screen which Builderlab +deployment, if any, hosts a community URL the person types, so sign-in can go to +that deployment's portal ([identity](identity.md)). It is read exactly like +`BUZZ_BUILDERLAB_URL`: Vite exposes it as `VITE_BUZZ_BUILDERLAB_TENANTS`, and the +native plugin-manager build reads the same `.env.local`/process value. Set both +inputs in the same place; the repository supplies no deployment table. + +The value is a JSON array of rows, each `{"relayHost", "self"?, "loginOrigin"}`: + +```sh +BUZZ_BUILDERLAB_TENANTS='[{"relayHost":"communities.buzz.xyz","self":"12f6870117eff1a6318bd38c82a65d51dd19879b7489f57247114d0ee8a96de3","loginOrigin":"https://app.builderlab.xyz"},{"relayHost":"block.builderlab.xyz","self":"76aa9daa7edc9c2e1dc6a2a90dbce10c1bd52f060fa6506ba7bad4f50240fc01","loginOrigin":"https://block.builderlab.xyz"},{"relayHost":"test.blockstaging.build","self":"f7f4df9401ecf46cc608b0889a61d957b306721cdefe42cb24519cb5a473dcf1","loginOrigin":"https://test.blockstaging.build"}]' +``` + +- `relayHost`: a lowercase hostname (no scheme, port, path or wildcard). A + community URL matches it exactly or as a suffix at a label boundary, so + `block.builderlab.xyz` covers `buzz.block.builderlab.xyz` and + `acme.communities.block.builderlab.xyz` but not `notblock.builderlab.xyz`. + Only the default port matches. Rows must not repeat a host; the longest + matching host wins. +- `self` (optional): the deployment's relay key as NIP-11 `self` publishes it, + 64 lowercase hex. When no host matches, a relay publishing a listed key still + matches its row, so a known deployment survives a DNS move; it can only select + a login origin already in the table. +- `loginOrigin`: the deployment's portal, written exactly as its canonical + `https://` origin (no credentials, port `443` omitted, no path, query or + fragment). The native build grants every distinct `loginOrigin`, together with + `BUZZ_BUILDERLAB_URL`'s origin, to the bundled Builderlab plugin, up to 15 + login origins (the manifest limit of 16 keeps one for that origin's Apps + Platform); the first-launch OAuth client can reach nothing else. The native + kind-24243 binding signer accepts a challenge for these origins and production + Builderlab only. + +A community URL that matches no row chooses no portal: the welcome screen asks +whether to connect with Builderlab at `BUZZ_BUILDERLAB_URL` or bring an existing key. +Malformed rows fail the Vite configuration and the native build without echoing +the value. The same restart and rebuild rules as `BUZZ_BUILDERLAB_URL` apply. + ## Whole-video limit The desktop app reads relay video from its origin one bounded `Range` at a diff --git a/docs/identity.md b/docs/identity.md index 3a3680f96a..6a7308bbd1 100644 --- a/docs/identity.md +++ b/docs/identity.md @@ -102,12 +102,23 @@ events retry them, including a coalesced follow-up when an event arrives during active pass. Upload success requires a matching stored receipt. Backup failure never replaces or deletes the local identity. +The main WebView can observe the uploader through `identity.backup`, which +registers on first read and stays out of identity snapshots. Its status says +whether Builderlab backup can run in this build and whether the latest pass is +idle, running, complete, waiting for consent, a session or a key binding, or +failed with a named kind; no service response body reaches the renderer. A login +handoff or retry resolves to the number of the pass it starts, or to none, so a +caller can wait for that pass's own outcome. A pass superseded by a key install +reports nothing; reloading the window mid-pass reports idle. + When secure storage confirms absence, native recovery commands support explicit browser sign-in. The session and recovered keys remain native; the renderer sees only validated public identities and attempt-scoped handles. Recovery prefers the account's current associated identity. A previous identity requires explicit selection and does not change the account association. Failed mapping requests, -unavailable endpoints, failed backups and an empty successful result remain distinct. +unavailable endpoints, an account refused recovery (outside a private +organization, or without Buzz access), failed backups and an empty successful +result remain distinct. Installation uses the existing create-only secure store; cancel, reload, signout or a competing create/import prevents stale installation. Remembering recovered completion is best effort; retry-file failure does not block diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 89c54b79c0..8aeaef62ce 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -72,6 +72,8 @@ fn main() { "nsec_backup_available", "nsec_backup_session", "nsec_backup_retry", + "nsec_backup_state", + "nsec_backup_watch", "nsec_restore_begin", "nsec_restore_wait", "nsec_restore_install", diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 35b56db814..a52f685161 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -25,6 +25,8 @@ "allow-nsec-backup-available", "allow-nsec-backup-session", "allow-nsec-backup-retry", + "allow-nsec-backup-state", + "allow-nsec-backup-watch", "allow-nsec-restore-begin", "allow-nsec-restore-wait", "allow-nsec-restore-install", diff --git a/src-tauri/src/browser_permissions_tests.rs b/src-tauri/src/browser_permissions_tests.rs index e7be18869e..d861b26372 100644 --- a/src-tauri/src/browser_permissions_tests.rs +++ b/src-tauri/src/browser_permissions_tests.rs @@ -81,6 +81,8 @@ fn native_command_permissions_isolate_webviews() { "nsec_backup_available", "nsec_backup_session", "nsec_backup_retry", + "nsec_backup_state", + "nsec_backup_watch", "nsec_restore_begin", "nsec_restore_wait", "nsec_restore_install", diff --git a/src-tauri/src/host_request.rs b/src-tauri/src/host_request.rs index c7453b400e..84f3e73d32 100644 --- a/src-tauri/src/host_request.rs +++ b/src-tauri/src/host_request.rs @@ -474,29 +474,34 @@ mod tests { archive ); } - let origin = grants - .network_origins - .first() - .map(String::as_str) - .unwrap_or("https://app.builderlab.xyz"); - let (url, _, _) = validate_request(&request(&format!( - "{origin}/api/goose/v1/auth/login/exchange" - ))) - .unwrap(); - assert_eq!( - allows_origin(&grants, &url), - !grants.network_origins.is_empty() - ); - let mut other = url; - other - .set_port(Some(if other.port() == Some(8443) { - 9443 - } else { - 8443 - })) + // The build URL, every tenant login origin and the Apps control plane are + // granted exactly; an unconfigured build grants nothing, not even + // production Builderlab. + let origins: Vec<&str> = if grants.network_origins.is_empty() { + vec!["https://app.builderlab.xyz"] + } else { + grants.network_origins.iter().map(String::as_str).collect() + }; + for origin in origins { + let (url, _, _) = validate_request(&request(&format!( + "{origin}/api/goose/v1/auth/login/exchange" + ))) .unwrap(); - let (url, _, _) = validate_request(&request(other.as_str())).unwrap(); - assert!(!allows_origin(&grants, &url)); + assert_eq!( + allows_origin(&grants, &url), + !grants.network_origins.is_empty() + ); + let mut other = url; + other + .set_port(Some(if other.port() == Some(8443) { + 9443 + } else { + 8443 + })) + .unwrap(); + let (url, _, _) = validate_request(&request(other.as_str())).unwrap(); + assert!(!allows_origin(&grants, &url)); + } } #[test] diff --git a/src-tauri/src/identity.rs b/src-tauri/src/identity.rs index 3bb9706f14..7ebb20a91b 100644 --- a/src-tauri/src/identity.rs +++ b/src-tauri/src/identity.rs @@ -1105,8 +1105,9 @@ pub struct BuilderlabChallenge { } impl BuilderlabChallenge { /// block/buzz's `nostr_bind` checks and kind 24243 tags, pinned to production - /// Builderlab or the `BUZZ_BUILDERLAB_URL` build origin; never another origin. - fn template(self, now: u64, configured: &str) -> Result { + /// Builderlab or a login origin the build grants (`builderlab_origins()`: the + /// `BUZZ_BUILDERLAB_URL` origin and each tenant's); never another origin. + fn template(self, now: u64, granted: &[String]) -> Result { let nonce_ok = self.nonce.len() == 43 && self .nonce @@ -1120,8 +1121,7 @@ impl BuilderlabChallenge { || !nonce_ok || self.verification_code.len() != 6 || !self.verification_code.bytes().all(|b| b.is_ascii_digit()) - || (self.origin != BUILDERLAB_ORIGIN - && (configured.is_empty() || self.origin != configured)) + || (self.origin != BUILDERLAB_ORIGIN && !granted.contains(&self.origin)) || expires <= i64::try_from(now).unwrap_or(i64::MAX) { return Err("Invalid Nostr identity challenge".into()); @@ -1155,7 +1155,7 @@ pub async fn identity_sign_builderlab_binding( .duration_since(std::time::UNIX_EPOCH) .map_err(|_| "System clock is before 1970")? .as_secs(); - host.sign(challenge.template(now, buzzodz_plugins::BUILDERLAB_ORIGIN)?) + host.sign(challenge.template(now, &buzzodz_plugins::builderlab_origins())?) .await } #[tauri::command] diff --git a/src-tauri/src/identity/tests.rs b/src-tauri/src/identity/tests.rs index 56927d9f4d..a511637ca2 100644 --- a/src-tauri/src/identity/tests.rs +++ b/src-tauri/src/identity/tests.rs @@ -405,7 +405,7 @@ fn parse_challenge(value: serde_json::Value) -> BuilderlabChallenge { #[test] fn builderlab_binding_signs_the_reference_kind_24243_shape() { let template = parse_challenge(builderlab_challenge()) - .template(1_700_000_000, "") + .template(1_700_000_000, &[]) .unwrap(); let event: Event = serde_json::from_value(fixture().sign(template).unwrap()).unwrap(); event.verify().unwrap(); @@ -448,7 +448,7 @@ fn builderlab_binding_rejects_invalid_or_foreign_challenges() { challenge[field] = value.into(); assert_eq!( parse_challenge(challenge) - .template(now, "") + .template(now, &[]) .err() .as_deref(), Some("Invalid Nostr identity challenge"), @@ -461,7 +461,7 @@ fn builderlab_binding_rejects_invalid_or_foreign_challenges() { } #[test] -fn builderlab_binding_accepts_only_production_or_the_configured_origin() { +fn builderlab_binding_accepts_only_production_or_a_granted_origin() { let now = 1_700_000_000; let staging = "https://test.blockstaging.build"; let with_origin = |origin: &str| { @@ -469,27 +469,33 @@ fn builderlab_binding_accepts_only_production_or_the_configured_origin() { challenge["origin"] = origin.into(); parse_challenge(challenge) }; - let template = with_origin(staging).template(now, staging).unwrap(); - assert!(template - .tags - .contains(&vec!["origin".to_owned(), staging.to_owned()])); - assert!(with_origin("https://app.builderlab.xyz") - .template(now, staging) - .is_ok()); - for (origin, configured) in [ - (staging, ""), - ("https://example.com", staging), - ("https://test.blockstaging.build/", staging), - ("https://test.blockstaging.build:8443", staging), - ("", ""), + let tenant = "https://block.builderlab.xyz"; + let granted = [staging.to_owned(), tenant.to_owned()]; + // The build URL's origin and every tenant login origin sign alike. + for origin in [staging, tenant] { + let template = with_origin(origin).template(now, &granted).unwrap(); + assert!(template + .tags + .contains(&vec!["origin".to_owned(), origin.to_owned()])); + } + for list in [&granted[..], &[]] { + assert!(with_origin("https://app.builderlab.xyz") + .template(now, list) + .is_ok()); + } + for (origin, list) in [ + (staging, &[][..]), + (tenant, &granted[..1]), + ("https://example.com", &granted[..]), + ("https://test.blockstaging.build/", &granted[..]), + ("https://test.blockstaging.build:8443", &granted[..]), + ("https://Block.builderlab.xyz", &granted[..]), + ("", &granted[..]), ] { assert_eq!( - with_origin(origin) - .template(now, configured) - .err() - .as_deref(), + with_origin(origin).template(now, list).err().as_deref(), Some("Invalid Nostr identity challenge"), - "{origin} with {configured:?}" + "{origin} with {list:?}" ); } } @@ -519,13 +525,25 @@ fn builderlab_binding_reaches_signer_through_production_ipc() { }, ) }; - let event: Event = invoke(builderlab_challenge()) - .unwrap() - .deserialize() - .unwrap(); - event.verify().unwrap(); - assert_eq!(event.kind.as_u16(), 24243); - assert_eq!(event.pubkey.to_hex(), fixture().viewer().unwrap()); + // Production, and every login origin this build grants the bundled plugin. + let granted = buzzodz_plugins::builderlab_origins(); + for origin in + std::iter::once("https://app.builderlab.xyz").chain(granted.iter().map(String::as_str)) + { + let mut challenge = builderlab_challenge(); + challenge["origin"] = origin.into(); + let event: Event = invoke(challenge) + .unwrap_or_else(|_| panic!("{origin} was refused")) + .deserialize() + .unwrap(); + event.verify().unwrap(); + assert_eq!(event.kind.as_u16(), 24243); + assert_eq!(event.pubkey.to_hex(), fixture().viewer().unwrap()); + assert!(event + .tags + .iter() + .any(|tag| tag.clone().to_vec() == ["origin", origin])); + } let mut foreign = builderlab_challenge(); foreign["origin"] = "https://example.com".into(); assert!(invoke(foreign).is_err()); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ae40edb003..1b3a8b4bdb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -45,8 +45,9 @@ mod nsec_backup; mod nsec_backup_choice; mod nsec_backup_config; use nsec_backup::{ - nsec_backup_available, nsec_backup_retry, nsec_backup_session, nsec_restore_begin, - nsec_restore_cancel, nsec_restore_install, nsec_restore_wait, NsecBackupHost, + nsec_backup_available, nsec_backup_retry, nsec_backup_session, nsec_backup_state, + nsec_backup_watch, nsec_restore_begin, nsec_restore_cancel, nsec_restore_install, + nsec_restore_wait, NsecBackupHost, }; use nsec_backup_choice::{nsec_backup_choice, nsec_backup_set_choice}; mod image_clipboard; @@ -583,6 +584,8 @@ fn commands() -> impl Fn(tauri::ipc::Invoke) -> bool + Sen nsec_backup_available, nsec_backup_session, nsec_backup_retry, + nsec_backup_state, + nsec_backup_watch, nsec_restore_begin, nsec_restore_wait, nsec_restore_install, diff --git a/src-tauri/src/nsec_backup.rs b/src-tauri/src/nsec_backup.rs index 53156097cc..6a12d39c92 100644 --- a/src-tauri/src/nsec_backup.rs +++ b/src-tauri/src/nsec_backup.rs @@ -19,14 +19,17 @@ use std::{ sync::{Arc, Mutex}, time::{Duration, SystemTime, UNIX_EPOCH}, }; -use tauri::async_runtime::JoinHandle; +use tauri::{async_runtime::JoinHandle, ipc::Channel}; use tauri_plugin_opener::OpenerExt; use tokio::sync::oneshot; use zeroize::Zeroizing; const RETRY: &str = "Could not restore your identity. Try again."; const CANCELED: &str = "Restore was canceled. Try again."; +// The renderer matches these two to offer manual key entry instead of a retry +// (`recoveryOff` in `src/features/identity/service.ts`). const UNAVAILABLE: &str = "Backup recovery is unavailable. Try again later."; +const REFUSED: &str = "Backup recovery is not offered for this account."; type Result = std::result::Result; #[derive(Clone, Copy, Deserialize, PartialEq, Eq)] @@ -91,6 +94,143 @@ impl Drop for Restore { let _ = self.callback.cancel(&self.callback_id); } } +/// What the uploader last did for this device's key. `seq` orders snapshots +/// against pushed messages; `pass` answers a handoff's ticket. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct BackupStatus { + seq: u64, + pass: u64, + /// Storage root present and `source_allowed(Builderlab)`. + builderlab: bool, + #[serde(flatten)] + progress: Progress, +} +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +enum Progress { + #[default] + Idle, + Running, + Waiting { + reason: Waiting, + }, + Complete, + Failed { + kind: Failure, + }, +} +/// A pass that stopped before upload; the next wake (consent, a session, a bind's +/// retry) resolves it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +enum Waiting { + NoConsent, + NoSession, + NotLinked, +} +/// Why a pass failed. Every kind is retried on the next wake; which ones the UI +/// offers to retry, and their copy, belong to the renderer. No body crosses IPC. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +enum Failure { + Busy, + SignedOut, + NotOffered, + OtherKeyLinked, + InvalidService, + Refused, + Unreachable, + Unconfirmed, + Storage, +} +#[derive(Debug, PartialEq)] +enum Outcome { + Idle, + Complete, + Waiting(Waiting), +} +#[derive(Debug, PartialEq)] +enum Stop { + /// Retired or closed; the pass reports nothing. + Canceled, + Failed(Failure), +} +impl From<&str> for Stop { + fn from(message: &str) -> Self { + match message { + CANCELED => Self::Canceled, + crate::nsec_backup_choice::BUSY => Self::Failed(Failure::Busy), + // Shared helpers fail with fixed strings for consent, record and key I/O. + _ => Self::Failed(Failure::Storage), + } + } +} +impl From for Stop { + fn from(message: String) -> Self { + message.as_str().into() + } +} +/// Why an `Api` read failed. Recovery shows a refused account as `REFUSED` and +/// anything else as `RETRY`; a backup pass reports a rejected session as signed +/// out, a refused account as not offered and anything else as unreachable. +#[derive(Debug)] +enum ReadError { + /// kgoose answers 401 for every expired, revoked, unknown or missing session + /// credential on `me` and `current`. + SignedOut, + /// A 403 on `current` (`me` only ever answers 401): the session lacks the + /// Buzz user capability or the account its USER role (kgoose's + /// `unauthorized`), which no retry or new sign-in changes. + NotOffered, + Other, +} +impl From<&str> for ReadError { + fn from(_: &str) -> Self { + Self::Other + } +} +impl From for ReadError { + fn from(_: String) -> Self { + Self::Other + } +} +impl From for String { + fn from(error: ReadError) -> Self { + match error { + ReadError::NotOffered => REFUSED, + // A new sign-in may clear a rejected session. + ReadError::SignedOut | ReadError::Other => RETRY, + } + .into() + } +} +impl From for Stop { + fn from(error: ReadError) -> Self { + Self::Failed(match error { + ReadError::SignedOut => Failure::SignedOut, + ReadError::NotOffered => Failure::NotOffered, + ReadError::Other => Failure::Unreachable, + }) + } +} +/// Enrollment or upload errors, which keep the HTTP status but never the body. +fn classify(error: buzz_nsec_backup::Error, upload: bool) -> Failure { + use buzz_nsec_backup::Error::*; + match error { + HttpStatus(401) => Failure::SignedOut, + HttpStatus(403) if upload => Failure::OtherKeyLinked, + HttpStatus(403 | 404) => Failure::NotOffered, + Transport | HttpStatus(429 | 500..) => Failure::Unreachable, + HttpStatus(400..) => Failure::Refused, + HttpStatus(_) => Failure::Unreachable, + InvalidResponse | BodyTooLarge(_) if upload => Failure::Unconfirmed, + InvalidServiceUrl | InvalidCredential | InvalidResponse | BodyTooLarge(_) => { + Failure::InvalidService + } + } +} + #[derive(Default)] struct State { closed: bool, @@ -101,6 +241,12 @@ struct State { rerun: bool, restore: Option, restore_epoch: u64, + /// Wake counter: every ticket handed out, and the number of the pass it starts. + requested: u64, + seq: u64, + pass: u64, + progress: Progress, + watcher: Option>, } impl State { fn retire(&mut self) { @@ -160,6 +306,10 @@ impl NsecBackupHost { state.sessions.clear(); // Source counters restart with each renderer document. state.generations = [None, None]; + // The watcher stays: a stale channel drops sends, and the new document registers. + if state.progress == Progress::Running { + self.publish(&mut state, Progress::Idle); + } } pub(crate) fn close(&self) { let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); @@ -183,6 +333,34 @@ impl NsecBackupHost { Ok(()) } } + fn builderlab(&self) -> bool { + self.root.is_some() && self.source_allowed(Source::Builderlab) + } + fn status(&self) -> BackupStatus { + let state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + self.snapshot(&state) + } + fn snapshot(&self, state: &State) -> BackupStatus { + BackupStatus { + seq: state.seq, + pass: state.pass, + builderlab: self.builderlab(), + progress: state.progress.clone(), + } + } + /// Callers hold the state lock, so pushed messages leave in `seq` order. + fn publish(&self, state: &mut State, progress: Progress) { + state.seq += 1; + state.progress = progress; + let status = self.snapshot(state); + if let Some(watcher) = &state.watcher { + // A reloaded webview's stale channel drops the message; the new page reads the snapshot. + let _ = watcher.send(status); + } + } + fn watch(&self, channel: Channel) { + self.state.lock().unwrap_or_else(|e| e.into_inner()).watcher = Some(channel); + } fn source_allowed(&self, source: Source) -> bool { let Some(config) = self.config.as_ref() else { return false; @@ -203,116 +381,150 @@ impl NsecBackupHost { generation: u64, credential: Option, identity: IdentityHost, - ) -> Result<()> { + ) -> Result> { // Reject a foreign service before copying a caller's already-held credential. let credential = credential.map(Zeroizing::new); if !self.source_allowed(source) { - return Ok(()); + return Ok(None); } if credential.as_ref().is_some_and(|c| !valid_credential(c)) { return Err("Invalid backup session".into()); } let mut state = self.state.lock().map_err(|_| "Backup unavailable")?; if state.closed { - return Ok(()); + return Ok(None); } let previous = state.generations[source.index()]; if previous.is_some_and(|seen| generation <= seen) { - return Ok(()); + return Ok(None); } state.generations[source.index()] = Some(generation); let active = state.sessions.last().is_some_and(|s| s.source == source); state.sessions.retain(|s| s.source != source); if credential.is_none() && !active { - return Ok(()); + return Ok(None); } state.retire(); if let Some(credential) = credential { state.sessions.push(Session { source, credential }); } drop(state); - self.trigger(identity); - Ok(()) + Ok(self.trigger(identity)) } - fn retry(&self, source: Source, identity: IdentityHost) { - let Ok(mut state) = self.state.lock() else { - return; - }; - let Some(index) = state.sessions.iter().position(|s| s.source == source) else { - return; - }; + fn retry(&self, source: Source, identity: IdentityHost) -> Option { + let mut state = self.state.lock().ok()?; + let index = state.sessions.iter().position(|s| s.source == source)?; if index + 1 != state.sessions.len() { state.retire(); let session = state.sessions.remove(index); state.sessions.push(session); } drop(state); - self.trigger(identity); + self.trigger(identity) } - fn trigger(&self, identity: IdentityHost) { + /// Wake the uploader. The ticket is the number of a pass that starts after this + /// call, or `None` when no pass will. + fn trigger(&self, identity: IdentityHost) -> Option { if self.config.is_none() || self.root.is_none() { - return; + return None; } - let Ok(mut state) = self.state.lock() else { - return; - }; + let mut state = self.state.lock().ok()?; if state.closed { - return; + return None; } if state .worker .as_ref() .is_some_and(|task| !task.inner().is_finished()) { + state.requested += 1; state.rerun = true; - return; + return Some(state.requested); } let Some(session) = state.sessions.last().cloned() else { - return; + // A pass retired by a null handoff ends here, with no outcome of its own; + // numbering it as the latest ticket settles a queued rerun's ticket too. + if state.progress == Progress::Running { + state.pass = state.requested; + self.publish( + &mut state, + Progress::Waiting { + reason: Waiting::NoSession, + }, + ); + } + return None; }; let epoch = state.epoch; state.rerun = false; + state.requested += 1; + state.pass = state.requested; + self.publish(&mut state, Progress::Running); let host = self.clone(); state.worker = Some(tauri::async_runtime::spawn(async move { loop { - if host.backup(&identity, &session, epoch).await.is_err() { + let outcome = host.backup(&identity, &session, epoch).await; + if outcome.is_err() { eprintln!("Automatic identity backup deferred"); } let Ok(mut state) = host.state.lock() else { return; }; + // A retired pass never reports; the next pass's `running` replaces it. if state.closed || state.epoch != epoch { return; } + let progress = match outcome { + Ok(Outcome::Complete) => Progress::Complete, + Ok(Outcome::Waiting(reason)) => Progress::Waiting { reason }, + Err(Stop::Failed(kind)) => Progress::Failed { kind }, + Ok(Outcome::Idle) | Err(Stop::Canceled) => Progress::Idle, + }; + host.publish(&mut state, progress); // Consume overlapping lifecycle events in this worker, without a timer. if std::mem::take(&mut state.rerun) { + state.pass = state.requested; + host.publish(&mut state, Progress::Running); continue; } state.worker = None; return; } })); + Some(state.pass) } - async fn backup(&self, identity: &IdentityHost, session: &Session, epoch: u64) -> Result<()> { + async fn backup( + &self, + identity: &IdentityHost, + session: &Session, + epoch: u64, + ) -> std::result::Result { + let unreachable = |_| Stop::Failed(Failure::Unreachable); let config = self.config.as_ref().as_ref().ok_or(UNAVAILABLE)?; self.valid(epoch)?; - let pubkey = identity.viewer().await?; + // Without a local key there is nothing to describe. + let Ok(pubkey) = identity.viewer().await else { + return Ok(Outcome::Idle); + }; let consent = crate::nsec_backup_choice::read( &self.root.as_ref().ok_or(UNAVAILABLE)?.join("choice.json"), config, &pubkey, )?; if consent.automatic != Some(true) { - return Ok(()); + return Ok(Outcome::Waiting(Waiting::NoConsent)); } // Hold consent's shared lease until this pass ends, including persisted retries. self.valid(epoch)?; - let api = Api::new(&config.service_url, &session.credential)?; + let api = Api::new(&config.service_url, &session.credential).map_err(unreachable)?; let account = api.me().await?; self.valid(epoch)?; - let current = api.current().await?; - if current.as_deref() != Some(&pubkey) { - return Err("Backup identity mismatch".into()); + match api.current().await? { + None => return Ok(Outcome::Waiting(Waiting::NotLinked)), + Some(current) if current != pubkey => { + return Err(Stop::Failed(Failure::OtherKeyLinked)) + } + Some(_) => {} } let locked = { let state = self.state.lock().map_err(|_| RETRY)?; @@ -331,18 +543,27 @@ impl NsecBackupHost { if let Some(record) = record.as_ref() { record.matches_identity(config, &account.subject, &pubkey)?; if record.envelope.is_none() { - return Ok(()); + return Ok(Outcome::Complete); } } - let client = Client::new(&config.service_url, &session.credential).map_err(|_| RETRY)?; - let enrollment = client.enroll().await.map_err(|_| RETRY)?; + let client = Client::new(&config.service_url, &session.credential) + .map_err(|error| Stop::Failed(classify(error, false)))?; + let enrollment = match client.enroll().await { + Ok(enrollment) => enrollment, + // Enrollment refuses an account with no binding; a later bind resolves it. + Err(buzz_nsec_backup::Error::HttpStatus(409)) => { + return Ok(Outcome::Waiting(Waiting::NotLinked)) + } + Err(error) => return Err(Stop::Failed(classify(error, false))), + }; self.valid(epoch)?; crypto::validate_enrollment( &enrollment, &config.namespace, &enrollment.owner_id, &pubkey, - )?; + ) + .map_err(|_| Stop::Failed(Failure::InvalidService))?; let mut record = match record { Some(record) if record.matches(config, &account.subject, &enrollment)? => record, _ => { @@ -355,9 +576,10 @@ impl NsecBackupHost { if local != expected { return Err("Backup identity mismatch".into()); } - crypto::seal(&enrollment, &namespace, &owner, local, secret) + Ok(crypto::seal(&enrollment, &namespace, &owner, local, secret)) }) - .await?; + .await? + .map_err(|_| Stop::Failed(Failure::InvalidService))?; record.envelope = Some(String::from_utf8(bytes).map_err(|_| RETRY)?); let state = self.state.lock().map_err(|_| RETRY)?; if state.closed || state.epoch != epoch { @@ -369,15 +591,15 @@ impl NsecBackupHost { } }; let Some(envelope) = record.envelope.as_ref() else { - return Ok(()); + return Ok(Outcome::Complete); }; self.valid(epoch)?; let receipt = client .upload(envelope.as_bytes()) .await - .map_err(|_| RETRY)?; + .map_err(|error| Stop::Failed(classify(error, true)))?; if !receipt.stored || receipt.backup_id != record.backup_id { - return Err("Backup receipt mismatch".into()); + return Err(Stop::Failed(Failure::Unconfirmed)); } let state = self.state.lock().map_err(|_| RETRY)?; if state.closed || state.epoch != epoch { @@ -385,7 +607,7 @@ impl NsecBackupHost { } record.envelope = None; locked.write(&record)?; - Ok(()) + Ok(Outcome::Complete) } fn valid_restore(&self, epoch: u64) -> Result<()> { let state = self.state.lock().map_err(|_| CANCELED)?; @@ -511,6 +733,8 @@ impl NsecBackupHost { let client = Client::new(&config.service_url, &credential).map_err(|_| RETRY)?; let recovered = client.recover().await.map_err(|error| match error { buzz_nsec_backup::Error::HttpStatus(404) => UNAVAILABLE.to_owned(), + // Not a private org, or no Buzz user role: no retry changes it. + buzz_nsec_backup::Error::HttpStatus(403) => REFUSED.to_owned(), _ => RETRY.to_owned(), })?; let (candidates, failed_count) = candidates(recovered); @@ -775,7 +999,11 @@ impl Api { serde_json::from_slice(&Self::body(response).await?).map_err(|_| RETRY)?; Ok(value.session_credential) } - async fn request(&self, path: &str, post: bool) -> Result>> { + async fn request( + &self, + path: &str, + post: bool, + ) -> std::result::Result>, ReadError> { let url = format!("{}{path}", self.root); let request = if post { self.http @@ -790,7 +1018,12 @@ impl Api { .send() .await .map_err(|_| RETRY)?; - Self::body(response).await + match response.status() { + reqwest::StatusCode::UNAUTHORIZED => return Err(ReadError::SignedOut), + reqwest::StatusCode::FORBIDDEN => return Err(ReadError::NotOffered), + _ => {} + } + Ok(Self::body(response).await?) } async fn body(mut response: reqwest::Response) -> Result>> { if !response.status().is_success() @@ -807,7 +1040,7 @@ impl Api { } Ok(body) } - async fn me(&self) -> Result { + async fn me(&self) -> std::result::Result { let account: Account = serde_json::from_slice(&self.request("/v1/auth/me", false).await?) .map_err(|_| RETRY)?; let expiry = chrono::DateTime::parse_from_rfc3339(&account.expires_at) @@ -826,7 +1059,7 @@ impl Api { } Ok(account) } - async fn current(&self) -> Result> { + async fn current(&self) -> std::result::Result, ReadError> { let value: serde_json::Value = serde_json::from_slice( &self .request("/v1/buzz/nostr-identities/current", true) @@ -842,13 +1075,12 @@ impl Api { .is_some_and(|id| !id.is_empty()); match value.get("identity") { Some(serde_json::Value::Null) if known => Ok(None), - Some(identity) => public_key( + Some(identity) => Ok(Some(public_key( identity .get("pubkey_hex") .and_then(|key| key.as_str()) .ok_or(RETRY)?, - ) - .map(Some), + )?)), None if known => Ok(None), _ => Err(RETRY.into()), } @@ -859,6 +1091,7 @@ impl Api { pub(crate) fn nsec_backup_available(host: tauri::State<'_, NsecBackupHost>) -> bool { host.config.is_some() } +/// The ticket of the pass this handoff starts, if any. #[tauri::command] pub(crate) fn nsec_backup_session( host: tauri::State<'_, NsecBackupHost>, @@ -866,7 +1099,7 @@ pub(crate) fn nsec_backup_session( source: Source, generation: u64, credential: Option, -) -> Result<()> { +) -> Result> { host.session(source, generation, credential, identity.inner().clone()) } #[tauri::command] @@ -874,8 +1107,26 @@ pub(crate) fn nsec_backup_retry( host: tauri::State<'_, NsecBackupHost>, identity: tauri::State<'_, IdentityHost>, source: Source, -) { - host.retry(source, identity.inner().clone()); +) -> Option { + host.retry(source, identity.inner().clone()) +} +#[tauri::command] +pub(crate) fn nsec_backup_state(host: tauri::State<'_, NsecBackupHost>) -> BackupStatus { + host.status() +} +/// Replace the status watcher. Register before reading `nsec_backup_state`, then keep +/// whichever status has the higher `seq`. +#[tauri::command] +pub(crate) fn nsec_backup_watch( + window: tauri::WebviewWindow, + host: tauri::State<'_, NsecBackupHost>, + on_event: Channel, +) -> Result<()> { + if window.label() != "main" { + return Err("Backup status belongs to the main window".into()); + } + host.watch(on_event); + Ok(()) } #[tauri::command] pub(crate) async fn nsec_restore_begin( diff --git a/src-tauri/src/nsec_backup/persistence.rs b/src-tauri/src/nsec_backup/persistence.rs index 3a96ce9781..c35b5d3058 100644 --- a/src-tauri/src/nsec_backup/persistence.rs +++ b/src-tauri/src/nsec_backup/persistence.rs @@ -91,7 +91,7 @@ impl Record { } } pub(super) struct LockedRecord { - _lock: File, + _lock: crate::nsec_backup_choice::Lease, root: PathBuf, path: PathBuf, } @@ -122,9 +122,10 @@ impl LockedRecord { .truncate(false) .open(root.join(format!("{name}.lock"))) .map_err(|_| FAILED)?; - fs2::FileExt::try_lock_exclusive(&lock).map_err(|_| FAILED)?; Ok(Self { - _lock: lock, + // The file opened, so a refused lock is another pass holding this key's record. + _lock: crate::nsec_backup_choice::lease(lock, true) + .map_err(|_| crate::nsec_backup_choice::BUSY)?, root: root.to_owned(), path: root.join(format!("{name}.json")), }) @@ -194,3 +195,28 @@ pub(super) fn remember_restored( }; slot.write(&record) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_released_record_lock_is_free_even_while_a_spawned_child_still_shares_it() { + let root = tempfile::tempdir().unwrap(); + let config = Config { + service_url: "https://backup.example".into(), + namespace: "buzz-test".into(), + }; + let open = || LockedRecord::open(root.path(), &config, "subject", "pubkey"); + let held = open().unwrap(); + let child = held._lock.inherited(); + // Another copy still holding the record is refused at once, as busy. + assert_eq!( + open().err().as_deref(), + Some(crate::nsec_backup_choice::BUSY) + ); + drop(held); + assert!(open().is_ok()); + drop(child); + } +} diff --git a/src-tauri/src/nsec_backup/tests.rs b/src-tauri/src/nsec_backup/tests.rs index 14db4fb51b..6fcf3d2098 100644 --- a/src-tauri/src/nsec_backup/tests.rs +++ b/src-tauri/src/nsec_backup/tests.rs @@ -3,6 +3,7 @@ use std::{ collections::HashMap, sync::atomic::{AtomicBool, Ordering}, }; +use tauri::ipc::InvokeResponseBody; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, net::{TcpListener, TcpStream}, @@ -24,10 +25,13 @@ fn recovery(keys: &[[u8; 32]], failed: usize) -> serde_json::Value { })).collect::>(), "failed_backup_ids":(0..failed).map(|_|uuid::Uuid::new_v4().to_string()).collect::>()}) } struct FixtureState { + me_status: u16, mapping: Option, mapping_status: u16, mapping_body: Option, owner: String, + namespace: String, + enrollment_status: u16, recipient_id: String, recipient_key: Option, subject: String, @@ -59,10 +63,13 @@ impl Service { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let root = format!("http://{}", listener.local_addr().unwrap()); let state = Arc::new(Mutex::new(FixtureState { + me_status: 200, mapping: Some(pubkey([1; 32])), mapping_status: 200, mapping_body: None, owner: "subject:synthetic-account".into(), + namespace: "backup/staging".into(), + enrollment_status: 200, recipient_id: "backup-key-2026-09".into(), recipient_key: None, subject: "synthetic-account".into(), @@ -190,12 +197,12 @@ async fn serve( *state.requests.entry(path.clone()).or_default() += 1; match path.as_str() { "/v1/auth/login/exchange" => (200, serde_json::json!({"session_credential":"synthetic-cli-credential"})), - "/v1/auth/me" => (200, serde_json::json!({"subject":state.subject,"expires_at":"2999-01-01T00:00:00Z"})), + "/v1/auth/me" => (state.me_status, serde_json::json!({"subject":state.subject,"expires_at":"2999-01-01T00:00:00Z"})), "/v1/buzz/nostr-identities/current" => (state.mapping_status,state.mapping_body.clone().unwrap_or_else(||serde_json::json!({"correlation_id":"synthetic-correlation","identity":state.mapping.as_ref().map(|key|serde_json::json!({"pubkey_hex":key}))}))), "/v1/buzz/nsec-backups/enrollment" => { let fixture: serde_json::Value = serde_json::from_str(include_str!("fixtures/hpke_nsec_backup_v1.json")).unwrap(); - (200, serde_json::json!({"version":1,"suite":{"kem_id":16,"kdf_id":1,"aead_id":2}, - "recipient_key_id":state.recipient_id,"service_namespace":"backup/staging","owner_id":state.owner, + (state.enrollment_status, serde_json::json!({"version":1,"suite":{"kem_id":16,"kdf_id":1,"aead_id":2}, + "recipient_key_id":state.recipient_id,"service_namespace":state.namespace,"owner_id":state.owner, "backup_id":uuid::Uuid::new_v4().to_string(),"nostr_pubkey":state.mapping, "recipient_public_key":state.recipient_key.clone().unwrap_or_else(||URL_SAFE_NO_PAD.encode(hex::decode(fixture["recipient_public_key_sec1_hex"].as_str().unwrap()).unwrap()))})) }, @@ -317,10 +324,11 @@ async fn restart_retry_reuses_persisted_ciphertext_and_completion_prevents_more_ let host = service.host(directory.path()); service.enable(&host).await; service.state.lock().unwrap().upload_status = 503; - assert!(host - .backup(&IdentityHost::fixture(), &service.session(), 0) - .await - .is_err()); + assert_eq!( + host.backup(&IdentityHost::fixture(), &service.session(), 0) + .await, + Err(Stop::Failed(Failure::Unreachable)) + ); let first = service.state.lock().unwrap().uploads[0].clone(); service.state.lock().unwrap().upload_status = 200; let restarted = service.host(directory.path()); @@ -329,20 +337,22 @@ async fn restart_retry_reuses_persisted_ciphertext_and_completion_prevents_more_ .await .unwrap(); let baseline = service.state.lock().unwrap().requests.clone(); - restarted - .backup(&IdentityHost::fixture(), &service.session(), 0) - .await - .unwrap(); + assert_eq!( + restarted + .backup(&IdentityHost::fixture(), &service.session(), 0) + .await, + Ok(Outcome::Waiting(Waiting::NoConsent)) + ); assert_eq!(service.state.lock().unwrap().requests, baseline); service.enable(&restarted).await; - restarted - .backup(&IdentityHost::fixture(), &service.session(), 0) - .await - .unwrap(); - restarted - .backup(&IdentityHost::fixture(), &service.session(), 0) - .await - .unwrap(); + for _ in 0..2 { + assert_eq!( + restarted + .backup(&IdentityHost::fixture(), &service.session(), 0) + .await, + Ok(Outcome::Complete) + ); + } let state = service.state.lock().unwrap(); assert_eq!(state.uploads, vec![first.clone(), first]); assert_eq!(state.rows.len(), 1); @@ -390,10 +400,11 @@ async fn mismatched_mapping_and_unwritable_pending_state_never_upload() { let host = service.host(directory.path()); service.enable(&host).await; service.state.lock().unwrap().mapping = Some(pubkey([2; 32])); - assert!(host - .backup(&IdentityHost::fixture(), &service.session(), 0) - .await - .is_err()); + assert_eq!( + host.backup(&IdentityHost::fixture(), &service.session(), 0) + .await, + Err(Stop::Failed(Failure::OtherKeyLinked)) + ); service.state.lock().unwrap().mapping = Some(pubkey([1; 32])); service.pause_current.store(true, Ordering::SeqCst); let task_host = host.clone(); @@ -411,7 +422,7 @@ async fn mismatched_mapping_and_unwritable_pending_state_never_upload() { let result = backup.await; std::fs::remove_file(directory.path()).unwrap(); std::fs::rename(&retained, directory.path()).unwrap(); - assert!(result.unwrap().is_err()); + assert_eq!(result.unwrap(), Err(Stop::Failed(Failure::Storage))); assert!(service.state.lock().unwrap().uploads.is_empty()); } @@ -430,14 +441,17 @@ async fn competing_workers_and_signout_keep_an_admitted_upload_pending() { .await }); service.upload_started.notified().await; - assert!(service - .host(directory.path()) - .backup(&IdentityHost::fixture(), &service.session(), 0) - .await - .is_err()); + // Another copy holding this key's record lock is `busy`, retried on the next wake. + assert_eq!( + service + .host(directory.path()) + .backup(&IdentityHost::fixture(), &service.session(), 0) + .await, + Err(Stop::Failed(Failure::Busy)) + ); host.close(); service.release_upload.add_permits(1); - assert!(upload.await.unwrap().is_err()); + assert_eq!(upload.await.unwrap(), Err(Stop::Canceled)); service.pause_upload.store(false, Ordering::SeqCst); service .host(directory.path()) @@ -533,12 +547,13 @@ async fn recover_with(service: &Service, host: &NsecBackupHost) -> Result Arc>> { + let seen = Arc::new(Mutex::new(Vec::new())); + let output = seen.clone(); + host.watch(Channel::new(move |body| { + if let InvokeResponseBody::Json(json) = body { + output + .lock() + .unwrap() + .push(serde_json::from_str(&json).unwrap()); + } + if delivered { + Ok(()) + } else { + Err(tauri::Error::WebviewNotFound) + } + })); + seen +} +/// Each message as `" "`, with `:` or `:` when present. +fn statuses(seen: &Mutex>) -> Vec { + seen.lock() + .unwrap() + .iter() + .map(|status| { + let name = status["status"].as_str().unwrap(); + let pass = status["pass"].as_u64().unwrap(); + match status.get("reason").or(status.get("kind")) { + Some(detail) => format!("{pass} {name}:{}", detail.as_str().unwrap()), + None => format!("{pass} {name}"), + } + }) + .collect() +} +fn worker(host: &NsecBackupHost) -> tokio::task::AbortHandle { + host.state + .lock() + .unwrap() + .worker + .as_ref() + .unwrap() + .inner() + .abort_handle() +} +async fn retired(worker: &tokio::task::AbortHandle) { + tokio::time::timeout(Duration::from_secs(5), async { + while !worker.is_finished() { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); +} +/// A pass that stops before uploading fails here with what it reported, not a hang. +async fn upload_started(service: &Service, seen: &Mutex>) { + let started = service.upload_started.notified(); + if tokio::time::timeout(Duration::from_secs(5), started) + .await + .is_err() + { + panic!("No upload started; the pass reported {:?}", statuses(seen)); + } +} +fn production() -> Arc> { + Arc::new(Some(Config { + service_url: "https://app.builderlab.xyz/api/goose".into(), + namespace: "backup/staging".into(), + })) +} + +#[tokio::test] +async fn a_pass_publishes_running_then_its_outcome_under_the_returned_ticket() { + let service = Service::new().await; + let directory = tempfile::tempdir().unwrap(); + let host = service.host(directory.path()); + let seen = record(&host, true); + service.enable(&host).await; + host.state.lock().unwrap().sessions.push(service.session()); + assert_eq!(host.trigger(IdentityHost::fixture()), Some(1)); + finish_background(&host).await; + assert_eq!(statuses(&seen), ["1 running", "1 complete"]); + let messages = seen.lock().unwrap().clone(); + assert!(messages[0]["seq"].as_u64() < messages[1]["seq"].as_u64()); + assert_eq!(serde_json::to_value(host.status()).unwrap(), messages[1]); + // A later wake finds the record complete and uploads nothing more. + assert_eq!(host.trigger(IdentityHost::fixture()), Some(2)); + finish_background(&host).await; + assert_eq!( + statuses(&seen), + ["1 running", "1 complete", "2 running", "2 complete"] + ); + let messages = seen.lock().unwrap().clone(); + assert!(messages + .windows(2) + .all(|pair| pair[0]["seq"].as_u64() < pair[1]["seq"].as_u64())); + assert_eq!(service.state.lock().unwrap().uploads.len(), 1); +} + +#[tokio::test] +async fn each_stopped_pass_publishes_its_waiting_reason_or_failure_kind() { + type Knob = fn(&mut FixtureState); + let cases: [(Knob, &str); 23] = [ + (|s| s.mapping = None, "waiting:not_linked"), + ( + |s| s.mapping = Some(pubkey([2; 32])), + "failed:other_key_linked", + ), + (|s| s.mapping_status = 500, "failed:unreachable"), + // kgoose's answer to an expired, revoked or unknown session credential. + (|s| s.me_status = 401, "failed:signed_out"), + (|s| s.mapping_status = 401, "failed:signed_out"), + // A missing capability or USER role on `current`, which no retry or + // sign-in changes; kgoose's own refusal for the role is `unauthorized`. + (|s| s.mapping_status = 403, "failed:not_offered"), + ( + |s| { + s.mapping_status = 403; + s.mapping_body = Some(serde_json::json!({ + "error": {"code": "unauthorized", "message": "synthetic"}, + "correlation_id": "synthetic-correlation", + })); + }, + "failed:not_offered", + ), + // Every other status stays unreachable. + (|s| s.me_status = 500, "failed:unreachable"), + (|s| s.enrollment_status = 409, "waiting:not_linked"), + (|s| s.enrollment_status = 403, "failed:not_offered"), + (|s| s.enrollment_status = 404, "failed:not_offered"), + (|s| s.enrollment_status = 401, "failed:signed_out"), + (|s| s.enrollment_status = 503, "failed:unreachable"), + ( + |s| s.recipient_key = Some("malformed".into()), + "failed:invalid_service", + ), + ( + |s| s.namespace = "backup/other".into(), + "failed:invalid_service", + ), + (|s| s.upload_status = 400, "failed:refused"), + (|s| s.upload_status = 409, "failed:refused"), + (|s| s.upload_status = 413, "failed:refused"), + (|s| s.upload_status = 403, "failed:other_key_linked"), + (|s| s.upload_status = 404, "failed:not_offered"), + (|s| s.upload_status = 503, "failed:unreachable"), + (|s| s.receipt_matches = false, "failed:unconfirmed"), + (|s| s.receipt_stored = false, "failed:unconfirmed"), + ]; + for (index, (knob, expected)) in cases.into_iter().enumerate() { + let service = Service::new().await; + let directory = tempfile::tempdir().unwrap(); + let host = service.host(directory.path()); + let seen = record(&host, true); + service.enable(&host).await; + knob(&mut service.state.lock().unwrap()); + host.state.lock().unwrap().sessions.push(service.session()); + host.trigger(IdentityHost::fixture()); + finish_background(&host).await; + assert_eq!( + statuses(&seen), + ["1 running".to_owned(), format!("1 {expected}")], + "case {index}" + ); + } + // Another app copy holding the consent lock is `busy`, not a storage failure. + let service = Service::new().await; + let directory = tempfile::tempdir().unwrap(); + let host = service.host(directory.path()); + service.enable(&host).await; + let lock = std::fs::OpenOptions::new() + .write(true) + .open(directory.path().join("choice.lock")) + .unwrap(); + fs2::FileExt::try_lock_exclusive(&lock).unwrap(); + assert_eq!( + host.backup(&IdentityHost::fixture(), &service.session(), 0) + .await, + Err(Stop::Failed(Failure::Busy)) + ); + assert!(service.state.lock().unwrap().requests.is_empty()); +} + +#[tokio::test] +async fn wakes_during_a_pass_queue_one_rerun_whose_pass_covers_their_tickets() { + let service = Service::new().await; + let directory = tempfile::tempdir().unwrap(); + let host = service.host(directory.path()); + let seen = record(&host, true); + let identity = IdentityHost::fixture(); + service.enable(&host).await; + service.pause_upload.store(true, Ordering::SeqCst); + host.state.lock().unwrap().sessions.push(service.session()); + assert_eq!(host.trigger(identity.clone()), Some(1)); + upload_started(&service, &seen).await; + assert_eq!(host.trigger(identity.clone()), Some(2)); + assert_eq!(host.retry(Source::Hosted, identity), Some(3)); + service.pause_upload.store(false, Ordering::SeqCst); + service.release_upload.add_permits(1); + finish_background(&host).await; + assert_eq!( + statuses(&seen), + ["1 running", "1 complete", "3 running", "3 complete"] + ); + assert_eq!(service.state.lock().unwrap().uploads.len(), 1); +} + +#[tokio::test] +async fn a_key_install_supersedes_a_pass_that_then_reports_nothing() { + let service = Service::new().await; + let directory = tempfile::tempdir().unwrap(); + let host = service.host(directory.path()); + let seen = record(&host, true); + service.enable(&host).await; + service.pause_upload.store(true, Ordering::SeqCst); + host.state.lock().unwrap().sessions.push(service.session()); + host.trigger(IdentityHost::fixture()); + upload_started(&service, &seen).await; + let first = worker(&host); + // Hold the next pass until the retired one has released its record lock. + service.pause_current.store(true, Ordering::SeqCst); + host.local_key_installed(IdentityHost::fixture()); + retired(&first).await; + upload_started(&service, &seen).await; + service.pause_current.store(false, Ordering::SeqCst); + service.pause_upload.store(false, Ordering::SeqCst); + service.release_upload.add_permits(2); + finish_background(&host).await; + assert_eq!(statuses(&seen), ["1 running", "2 running", "2 complete"]); +} + +#[tokio::test] +async fn a_null_handoff_reports_no_session_only_for_a_running_pass() { + let service = Service::new().await; + let directory = tempfile::tempdir().unwrap(); + let host = service.host(directory.path()); + // Admit hosted handoffs on the production origin; HTTP stays on the loopback fixture. + let login = NsecBackupHost { + state: host.state.clone(), + root: Some(directory.path().to_owned()), + config: production(), + }; + let seen = record(&host, true); + service.enable(&host).await; + service.pause_upload.store(true, Ordering::SeqCst); + host.state.lock().unwrap().sessions.push(service.session()); + host.trigger(IdentityHost::fixture()); + upload_started(&service, &seen).await; + let first = worker(&host); + // A wake during the pass holds a rerun ticket, which the retirement settles. + assert_eq!(host.trigger(IdentityHost::fixture()), Some(2)); + assert_eq!( + login.session(Source::Hosted, 1, None, IdentityHost::fixture()), + Ok(None) + ); + retired(&first).await; + assert_eq!(statuses(&seen), ["1 running", "2 waiting:no_session"]); + service.pause_upload.store(false, Ordering::SeqCst); + service.release_upload.add_permits(1); + host.state.lock().unwrap().sessions.push(service.session()); + host.trigger(IdentityHost::fixture()); + finish_background(&host).await; + assert_eq!( + login.session(Source::Hosted, 2, None, IdentityHost::fixture()), + Ok(None) + ); + assert_eq!( + statuses(&seen), + [ + "1 running", + "2 waiting:no_session", + "3 running", + "3 complete" + ] + ); +} + +#[tokio::test] +async fn a_document_load_mid_pass_reports_idle_and_the_new_watcher_sees_later_passes() { + let service = Service::new().await; + let directory = tempfile::tempdir().unwrap(); + let host = service.host(directory.path()); + let stale = record(&host, false); + service.enable(&host).await; + service.pause_upload.store(true, Ordering::SeqCst); + host.state.lock().unwrap().sessions.push(service.session()); + host.trigger(IdentityHost::fixture()); + upload_started(&service, &stale).await; + let first = worker(&host); + host.document_load(); + retired(&first).await; + assert_eq!(statuses(&stale), ["1 running", "1 idle"]); + let current = record(&host, true); + service.pause_upload.store(false, Ordering::SeqCst); + service.release_upload.add_permits(1); + host.state.lock().unwrap().sessions.push(service.session()); + host.trigger(IdentityHost::fixture()); + finish_background(&host).await; + assert_eq!(statuses(&stale).len(), 2); + assert_eq!(statuses(¤t), ["2 running", "2 complete"]); +} + +#[tokio::test] +async fn a_pass_without_consent_waits_without_a_request() { + let service = Service::new().await; + let directory = tempfile::tempdir().unwrap(); + let host = service.host(directory.path()); + let seen = record(&host, true); + host.state.lock().unwrap().sessions.push(service.session()); + assert_eq!(host.trigger(IdentityHost::fixture()), Some(1)); + finish_background(&host).await; + assert_eq!(statuses(&seen), ["1 running", "1 waiting:no_consent"]); + assert!(service.state.lock().unwrap().requests.is_empty()); +} + +#[tokio::test] +async fn an_unlinked_pass_waits_until_a_retry_after_binding_completes_it() { + let service = Service::new().await; + let directory = tempfile::tempdir().unwrap(); + let host = service.host(directory.path()); + let seen = record(&host, true); + service.enable(&host).await; + service.state.lock().unwrap().mapping = None; + host.state.lock().unwrap().sessions.push(service.session()); + host.trigger(IdentityHost::fixture()); + finish_background(&host).await; + assert_eq!(statuses(&seen), ["1 running", "1 waiting:not_linked"]); + service.state.lock().unwrap().mapping = Some(pubkey([1; 32])); + assert_eq!(host.retry(Source::Hosted, IdentityHost::fixture()), Some(2)); + finish_background(&host).await; + assert_eq!( + statuses(&seen), + [ + "1 running", + "1 waiting:not_linked", + "2 running", + "2 complete" + ] + ); + assert_eq!(service.state.lock().unwrap().uploads.len(), 1); +} + +#[tokio::test] +async fn handoffs_and_retries_return_a_ticket_only_when_a_pass_will_start() { + // No consent is saved, so admitted passes stop before any request. + let directory = tempfile::tempdir().unwrap(); + let host = NsecBackupHost { + state: Default::default(), + root: Some(directory.path().to_owned()), + config: production(), + }; + let foreign = NsecBackupHost { + state: Default::default(), + root: Some(directory.path().to_owned()), + config: Arc::new(Some(Config { + service_url: "https://backup.example/api/goose".into(), + namespace: "backup/staging".into(), + })), + }; + let identity = IdentityHost::fixture(); + let credential = || Some("synthetic-session".to_owned()); + assert_eq!(host.retry(Source::Hosted, identity.clone()), None); + assert_eq!( + host.session(Source::Hosted, 1, None, identity.clone()), + Ok(None) + ); + assert_eq!( + host.session(Source::Hosted, 2, credential(), identity.clone()), + Ok(Some(1)) + ); + assert_eq!( + host.session(Source::Hosted, 2, credential(), identity.clone()), + Ok(None) + ); + assert_eq!(host.retry(Source::Hosted, identity.clone()), Some(2)); + assert_eq!( + foreign.session(Source::Hosted, 3, credential(), identity.clone()), + Ok(None) + ); + finish_background(&host).await; + assert_eq!( + host.session(Source::Hosted, 4, None, identity.clone()), + Ok(None) + ); + host.close(); + assert_eq!( + host.session(Source::Hosted, 5, credential(), identity.clone()), + Ok(None) + ); + assert_eq!(host.retry(Source::Hosted, identity), None); + assert_eq!(host.status().pass, 2); +} + +#[test] +fn statuses_serialize_flat_and_report_whether_builderlab_backup_can_run() { + let status = |progress| { + serde_json::to_value(BackupStatus { + seq: 7, + pass: 3, + builderlab: true, + progress, + }) + .unwrap() + }; + let wire = |fields: serde_json::Value| { + let mut value = serde_json::json!({"seq":7,"pass":3,"builderlab":true}); + value + .as_object_mut() + .unwrap() + .extend(fields.as_object().unwrap().clone()); + value + }; + for (progress, name) in [ + (Progress::Idle, "idle"), + (Progress::Running, "running"), + (Progress::Complete, "complete"), + ] { + assert_eq!(status(progress), wire(serde_json::json!({"status":name}))); + } + for (reason, name) in [ + (Waiting::NoConsent, "no_consent"), + (Waiting::NoSession, "no_session"), + (Waiting::NotLinked, "not_linked"), + ] { + assert_eq!( + status(Progress::Waiting { reason }), + wire(serde_json::json!({"status":"waiting","reason":name})) + ); + } + for (kind, name) in [ + (Failure::Busy, "busy"), + (Failure::SignedOut, "signed_out"), + (Failure::NotOffered, "not_offered"), + (Failure::OtherKeyLinked, "other_key_linked"), + (Failure::InvalidService, "invalid_service"), + (Failure::Refused, "refused"), + (Failure::Unreachable, "unreachable"), + (Failure::Unconfirmed, "unconfirmed"), + (Failure::Storage, "storage"), + ] { + assert_eq!( + status(Progress::Failed { kind }), + wire(serde_json::json!({"status":"failed","kind":name})) + ); + } + let directory = tempfile::tempdir().unwrap(); + let granted = format!("{}/api/goose", buzzodz_plugins::BUILDERLAB_ORIGIN); + for (root, service_url, builderlab) in [ + (None, granted.as_str(), false), + ( + Some(directory.path()), + "https://backup.example/api/goose", + false, + ), + ( + Some(directory.path()), + granted.as_str(), + !buzzodz_plugins::BUILDERLAB_ORIGIN.is_empty(), + ), + ] { + let host = NsecBackupHost { + state: Default::default(), + root: root.map(ToOwned::to_owned), + config: Arc::new(Some(Config { + service_url: service_url.into(), + namespace: "backup/staging".into(), + })), + }; + assert_eq!( + serde_json::to_value(host.status()).unwrap(), + serde_json::json!({"seq":0,"pass":0,"builderlab":builderlab,"status":"idle"}), + "{service_url}" + ); + } +} diff --git a/src-tauri/src/nsec_backup_choice.rs b/src-tauri/src/nsec_backup_choice.rs index 1eecf21c3a..30b502bae4 100644 --- a/src-tauri/src/nsec_backup_choice.rs +++ b/src-tauri/src/nsec_backup_choice.rs @@ -9,6 +9,9 @@ use std::{ type Result = std::result::Result; const FAILED: &str = "Could not read or save your identity backup choice."; +/// Another pass holds the consent lock or a key's record lock; the uploader +/// reports it as `busy`. +pub(crate) const BUSY: &str = "A backup is in progress. Wait for it to finish and try again."; #[derive(Serialize)] pub(crate) struct BackupChoice { @@ -17,7 +20,33 @@ pub(crate) struct BackupChoice { pub(crate) automatic: Option, // Retain this value through an upload. A successful choice change cannot race it. #[serde(skip)] - _lease: Option, + _lease: Option, +} + +/// A held advisory lock, released when dropped. +pub(crate) struct Lease(fs::File); +impl Drop for Lease { + fn drop(&mut self) { + // fork/dup can retain the open-file description after our descriptor + // closes. Explicitly release ownership when the operation finishes. + let _ = fs2::FileExt::unlock(&self.0); + } +} +#[cfg(test)] +impl Lease { + /// The descriptor a child spawned while this is held inherits until it execs. + pub(crate) fn inherited(&self) -> fs::File { + self.0.try_clone().unwrap() + } +} + +pub(crate) fn lease(file: fs::File, exclusive: bool) -> std::io::Result { + if exclusive { + fs2::FileExt::try_lock_exclusive(&file)?; + } else { + fs2::FileExt::try_lock_shared(&file)?; + } + Ok(Lease(file)) } #[derive(Deserialize, Serialize)] @@ -60,7 +89,7 @@ fn config() -> Result> { ) } -fn lock(path: &Path, exclusive: bool) -> Result { +fn lock(path: &Path, exclusive: bool) -> Result { fs::create_dir_all(path.parent().ok_or(FAILED)?).map_err(|_| FAILED)?; let file = fs::OpenOptions::new() .read(true) @@ -69,13 +98,7 @@ fn lock(path: &Path, exclusive: bool) -> Result { .truncate(false) .open(path.with_extension("lock")) .map_err(|_| FAILED)?; - let result = if exclusive { - fs2::FileExt::try_lock_exclusive(&file) - } else { - fs2::FileExt::try_lock_shared(&file) - }; - result.map_err(|_| "A backup is in progress. Wait for it to finish and try again.")?; - Ok(file) + lease(file, exclusive).map_err(|_| BUSY.into()) } /// Only `Some(true)` authorizes backup. Keep the returned value alive until completion. diff --git a/src-tauri/src/nsec_backup_choice/tests.rs b/src-tauri/src/nsec_backup_choice/tests.rs index ad79d12052..c3af4ab268 100644 --- a/src-tauri/src/nsec_backup_choice/tests.rs +++ b/src-tauri/src/nsec_backup_choice/tests.rs @@ -123,6 +123,19 @@ async fn choice_changes_serialize_with_admitted_readers_across_copies() { ); } +#[test] +fn a_released_lease_is_free_even_while_a_spawned_child_still_shares_it() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("choice.json"); + let held = lock(&path, true).unwrap(); + let child = held.inherited(); + // Another copy still holding its lease is busy at once. + assert_eq!(lock(&path, false).err().as_deref(), Some(BUSY)); + drop(held); + assert!(lock(&path, false).is_ok()); + drop(child); +} + #[tokio::test] async fn failed_storage_does_not_report_saved_consent() { let temp = tempfile::tempdir().unwrap(); diff --git a/src/bundled/builderlab/README.md b/src/bundled/builderlab/README.md index b42cf5598f..89ef009835 100644 --- a/src/bundled/builderlab/README.md +++ b/src/bundled/builderlab/README.md @@ -62,7 +62,8 @@ Beekeeper accepts an owner proof only from the Buzz key bound to the signed-in Builderlab account. After sign-in, and again before each activation, the plugin checks `/v1/buzz/nostr-identities/current` and binds an unbound account to this device's key through the native kind 24243 signer, which accepts only -production Builderlab or this build's `BUZZ_BUILDERLAB_URL` origin. An account +production Builderlab or a login origin this build grants the plugin +(`BUZZ_BUILDERLAB_URL`'s and each tenant's). An account bound to another key is reported and never unbound. The agent's Identity tab reads its current instructions from Builderlab and saves changes directly to the provider. Loading and saving use the same login; @@ -135,7 +136,9 @@ agents are not migrated into this type. [communities](../../../docs/communities.md#known-communities). The plugin uses the `BUZZ_BUILDERLAB_URL` [build input](../../../docs/configuration.md#builderlab-url-build-input) -as its server address and appends `/api/goose`. +as its server address and appends `/api/goose`. Its bundled native grant also +covers every `loginOrigin` in the `BUZZ_BUILDERLAB_TENANTS` table, which only +the first-launch sign-in uses. On desktop, the plugin supplies `/v1/auth/login` with `type=cli` and `product=builderlab` to the shared native diff --git a/src/bundled/builderlab/oauth/browser.test.ts b/src/bundled/builderlab/oauth/browser.test.ts index f415c6c887..e949d3a6f9 100644 --- a/src/bundled/builderlab/oauth/browser.test.ts +++ b/src/bundled/builderlab/oauth/browser.test.ts @@ -337,6 +337,24 @@ it.each([ expect(() => oauthTarget(value)).toThrow("does not support"); }); +it("signs in at an explicit tenant origin for the whole round-trip, under the same rules", async () => { + const origin = "https://tenant.example"; + const { host, bridge, controller } = fixture(); + await browserCredential(host, controller.signal, bridge, origin); + const options = vi.mocked(bridge.begin).mock.calls[0]?.[0]; + expect(options?.authorizationUrl).toMatch( + /^https:\/\/tenant\.example\/api\/goose\/v1\/auth\/login\?type=cli&product=builderlab&/, + ); + expect(vi.mocked(host.request).mock.calls.map(([{ url }]) => url)).toEqual([ + `${origin}/api/goose/v1/auth/login/exchange`, + `${origin}/api/goose/v1/auth/me`, + ]); + await expect( + browserCredential(host, controller.signal, bridge, "http://tenant.example"), + ).rejects.toThrow("does not support"); + expect(bridge.begin).toHaveBeenCalledTimes(1); +}); + it.each([ [ ["error", "access_denied"], diff --git a/src/bundled/builderlab/oauth/browser.ts b/src/bundled/builderlab/oauth/browser.ts index d84cbf1037..ec321c3b76 100644 --- a/src/bundled/builderlab/oauth/browser.ts +++ b/src/bundled/builderlab/oauth/browser.ts @@ -49,7 +49,7 @@ export type BrowserBridge = { wait(id: string): Promise; cancel(id: string): Promise; }; -const nativeBridge: BrowserBridge = { +export const nativeBridge: BrowserBridge = { begin: (options) => invoke("oauth_callback_begin", options), wait: (id) => invoke("oauth_callback_wait", { id }), cancel: (id) => invoke("oauth_callback_cancel", { id }), @@ -62,14 +62,19 @@ function base64url(bytes: Uint8Array) { .replace(/=+$/, ""); } -/** Acquire and verify a credential. No UI, storage, or authenticated API consumers. */ +/** + * Acquire and verify a credential. No UI, storage, or authenticated API consumers. + * `origin` selects another Builderlab deployment's https origin (a tenant's + * portal) for the whole round-trip; the configured build URL is the default. + */ export async function browserCredential( host: Host, signal: AbortSignal, bridge: BrowserBridge = nativeBridge, + origin?: string, ): Promise { signal.throwIfAborted(); - const target = oauthTarget(); + const target = oauthTarget(origin); let id: string | undefined; const path = `/callback/${crypto.randomUUID()}`; const login = new URL(`${target}/v1/auth/login`); diff --git a/src/features/identity/RestoreIdentity.tsx b/src/features/identity/RestoreIdentity.tsx index 3dd9e63a9d..b4b86f6c64 100644 --- a/src/features/identity/RestoreIdentity.tsx +++ b/src/features/identity/RestoreIdentity.tsx @@ -4,7 +4,7 @@ import { publicKeyLabels } from "../../shared/identity/public-key"; import { Button } from "../../shared/design-system/ui/Button"; import { Field } from "../../shared/design-system/ui/Field"; import { Radio, RadioGroup } from "../../shared/design-system/ui/RadioGroup"; -import type { Identity, RecoveryState } from "./service"; +import { type Identity, type RecoveryState, recoveryOff } from "./service"; export function RestoreIdentity({ identity, @@ -104,7 +104,7 @@ export function RestoreIdentity({ > Restore this identity - ) : recovery.status === "error" || + ) : (recovery.status === "error" && !recoveryOff(recovery)) || (recovery.status === "choosing" && candidates.length === 0) ? (