From 1d6cedae14db488ec7ef86ccf16325823128226c Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 29 Oct 2026 00:18:58 +1100 Subject: [PATCH] =?UTF-8?q?feat(builderlab):=20tenant=20table=20=E2=80=94?= =?UTF-8?q?=20a=20login=20origin=20per=20hosted=20relay,=20granted=20nativ?= =?UTF-8?q?ely=20and=20accepted=20by=20the=20binding=20signer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure configuration plumbing; no first-run page uses it yet. A build-time table `BUZZ_BUILDERLAB_TENANTS` — rows `{ relayHost, self?, loginOrigin }` — says which relays each Builderlab deployment hosts and where its people sign in, so a later first-launch screen can send a typed community URL's sign-in to the right portal instead of one hard-coded Builderlab. Frontend (`src/features/identity/builderlab-tenants.ts`, new): `parseBuilderlabTenants` validates the table at Vite configuration time (hostname shape, ASCII-only folding, 253-byte cap, unique hosts, unique 64-hex `self`, `loginOrigin` equal to its own canonical https origin, no unknown fields) and fails without echoing the value; `discoverAuthentication(found, tenants)` answers `{ kind: "builderlab", origin }` or `{ kind: "none" }`: hostname match (exact, or a suffix at a label boundary — `block.builderlab.xyz` covers `buzz.block.builderlab.xyz` but not `notblock.builderlab.xyz` — default port only, longest host wins), then NIP-11 `self` equality (a known deployment survives a DNS move; it can only select an origin already in the table), else `none`. `vite.config.ts` parses the table and defines `VITE_BUZZ_BUILDERLAB_TENANTS`. `browserCredential(host, signal, bridge, origin?)` (`oauth/browser.ts`) runs the whole OAuth round-trip — `/v1/auth/login`, `/login/exchange`, `/auth/me` — at `oauthTarget(origin)`, the tenant origin going through the same https/no-credential/no-query check as the build URL; `nativeBridge` is exported for the next PR. The Settings card is unchanged and always uses the build URL. Native (`crates/plugin-manager/build.rs`, `src/lib.rs`): `build.rs` loads `BUZZ_BUILDERLAB_TENANTS` beside `BUZZ_BUILDERLAB_URL` through `build_env::load`, validates every row with the same rules as the frontend parser (at most 16 origins), and emits `BUZZ_BUILDERLAB_ORIGINS` (build URL origin first, then each distinct tenant origin); `builderlab_origins()` feeds the `block.builderlab`/`bundled` grant's `network_origins`, and `BUILDERLAB_ORIGIN` stays for `identity.rs`. Malformed input fails the build with a fixed message. `serde_json` joins that crate's `[build-dependencies]` (already in the lockfile; no Cargo.lock change). `src-tauri/src/identity.rs`: the kind-24243 binding signer accepts a challenge for production Builderlab or any login origin the build grants (`builderlab_origins()`) instead of only production or the build URL; unknown, case-changed, trailing-slash, port-changed and empty origins are still refused. Config and docs: `.env.example` and `docs/configuration.md` document the input, the matching rules and the grant, with the three verified rows (`communities.buzz.xyz` → `https://app.builderlab.xyz`, `block.builderlab.xyz` → `https://block.builderlab.xyz`, `test.blockstaging.build` → `https://test.blockstaging.build`, each with its relay key). The repository ships no deployment default. The Builderlab README's two grant sentences name the tenant origins. Tests: `builderlab-tenants.test.ts` (the three seed rows, subdomain, case/final-dot, lookalikes, non-default port, `self` fallback and its limits, nested-row precedence, parser rejections without echo incl. an over-long host and a Kelvin sign); `oauth/browser.test.ts` (an explicit origin for the whole round-trip and the same refusal of `http://`); the tenant lines of `browser-host/vite-config.test.mjs` (the define and the non-echoing rejection); Rust `build.rs` tests (origin union, file-sourced table, malformed rows), `host_request.rs` (every granted origin), `identity/tests.rs` (granted, production, ungranted and malformed origins; the IPC test signs and verifies for every granted origin and refuses a foreign one). Not in this PR: `src-tauri/src/identity.rs` hosted-communities binding for other tenants stays as on main apart from the origin rule; `src/bundled/hosted-communities/native.ts` still hard-codes the production API. Stack: PR 04 of the onboarding stack, on 02 (`builderlab-tenants.ts` imports `type FoundCommunity` from `communities/find-community.ts`); independent of 03. 05–07 build on it. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Matt Toohey --- .env.example | 11 + browser-host/vite-config.test.mjs | 16 ++ crates/plugin-manager/Cargo.toml | 1 + crates/plugin-manager/build.rs | 240 ++++++++++++++++-- crates/plugin-manager/src/lib.rs | 40 +-- docs/configuration.md | 40 +++ src-tauri/src/host_request.rs | 49 ++-- src-tauri/src/identity.rs | 10 +- src-tauri/src/identity/tests.rs | 74 ++++-- src/bundled/builderlab/README.md | 7 +- src/bundled/builderlab/oauth/browser.test.ts | 18 ++ src/bundled/builderlab/oauth/browser.ts | 11 +- .../identity/builderlab-tenants.test.ts | 215 ++++++++++++++++ src/features/identity/builderlab-tenants.ts | 126 +++++++++ vite.config.ts | 5 + 15 files changed, 760 insertions(+), 103 deletions(-) create mode 100644 src/features/identity/builderlab-tenants.test.ts create mode 100644 src/features/identity/builderlab-tenants.ts 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/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/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/builderlab-tenants.test.ts b/src/features/identity/builderlab-tenants.test.ts new file mode 100644 index 0000000000..b942545f92 --- /dev/null +++ b/src/features/identity/builderlab-tenants.test.ts @@ -0,0 +1,215 @@ +import { expect, it } from "vitest"; +import type { FoundCommunity } from "../communities/find-community"; +import { relayOrigin } from "../communities/destination"; +import { + discoverAuthentication, + parseBuilderlabTenants, +} from "./builderlab-tenants"; + +const BLOCK_SELF = + "76aa9daa7edc9c2e1dc6a2a90dbce10c1bd52f060fa6506ba7bad4f50240fc01"; +const TENANTS = JSON.stringify([ + { + relayHost: "communities.buzz.xyz", + self: "12f6870117eff1a6318bd38c82a65d51dd19879b7489f57247114d0ee8a96de3", + loginOrigin: "https://app.builderlab.xyz", + }, + { + relayHost: "block.builderlab.xyz", + self: BLOCK_SELF, + loginOrigin: "https://block.builderlab.xyz", + }, + { + relayHost: "test.blockstaging.build", + self: "f7f4df9401ecf46cc608b0889a61d957b306721cdefe42cb24519cb5a473dcf1", + loginOrigin: "https://test.blockstaging.build", + }, +]); +const tenants = parseBuilderlabTenants(TENANTS); +/** As `findCommunity` reports it: the canonical origin, plus `self` when given. */ +const found = (input: string, self?: string): FoundCommunity => ({ + url: relayOrigin(input), + host: new URL(relayOrigin(input)).host, + self, + policy: null, +}); +const discover = (input: string, self?: string) => + discoverAuthentication(found(input, self), tenants); + +it.each([ + ["wss://communities.buzz.xyz", "https://app.builderlab.xyz"], + ["https://design.communities.buzz.xyz", "https://app.builderlab.xyz"], + ["wss://buzz.block.builderlab.xyz", "https://block.builderlab.xyz"], + [ + "wss://acme.communities.block.builderlab.xyz", + "https://block.builderlab.xyz", + ], + ["wss://buzz.test.blockstaging.build", "https://test.blockstaging.build"], + ["WSS://Design.Communities.BUZZ.xyz.", "https://app.builderlab.xyz"], +])("%s is a Builderlab tenant signing in at %s", (input, origin) => { + expect(discover(input)).toEqual({ kind: "builderlab", origin }); +}); + +it.each([ + "wss://communities.buzz.xyz.evil.com", + "wss://notcommunities.buzz.xyz", + "wss://xcommunities.buzz.xyz", + "wss://buzz.xyz", + "wss://buzz.acme.builderlab.xyz", + "wss://relay.example.com", +])("%s is not a known tenant, so no portal is chosen", (input) => { + expect(discover(input)).toEqual({ kind: "none" }); +}); + +it("matches only the default port: Block's deployments serve on 443", () => { + expect(discover("https://communities.buzz.xyz:8443")).toEqual({ + kind: "none", + }); + expect(discover("https://communities.buzz.xyz:443")).toEqual({ + kind: "builderlab", + origin: "https://app.builderlab.xyz", + }); +}); + +it("falls back to the relay key when the hostname is unknown, never to a new origin", () => { + // A deployment moved to a new domain keeps its relay key. + expect(discover("wss://buzz.block.example", BLOCK_SELF)).toEqual({ + kind: "builderlab", + origin: "https://block.builderlab.xyz", + }); + // The hostname decides first: a listed host's key is not consulted. + expect(discover("wss://buzz.test.blockstaging.build", BLOCK_SELF)).toEqual({ + kind: "builderlab", + origin: "https://test.blockstaging.build", + }); + expect(discover("https://buzz.block.example:8443", BLOCK_SELF)).toEqual({ + kind: "none", + }); + expect(discover("wss://buzz.block.example", "ab".repeat(32))).toEqual({ + kind: "none", + }); + expect(discover("wss://buzz.block.example")).toEqual({ kind: "none" }); +}); + +it("a tenant announced later is one more row, and a nested row wins by suffix length", () => { + const acme = parseBuilderlabTenants( + JSON.stringify([ + { + relayHost: "builderlab.xyz", + loginOrigin: "https://app.builderlab.xyz", + }, + { + relayHost: "buzz.acme.builderlab.xyz", + loginOrigin: "https://acme.builderlab.xyz", + }, + ]), + ); + expect( + discoverAuthentication(found("wss://buzz.acme.builderlab.xyz"), acme), + ).toEqual({ kind: "builderlab", origin: "https://acme.builderlab.xyz" }); + expect( + discoverAuthentication(found("wss://buzz.other.builderlab.xyz"), acme), + ).toEqual({ kind: "builderlab", origin: "https://app.builderlab.xyz" }); + expect( + discoverAuthentication(found("wss://buzz.acme.builderlab.xyz"), []), + ).toEqual({ kind: "none" }); +}); + +it("normalises rows and leaves an unset table empty", () => { + expect(parseBuilderlabTenants()).toEqual([]); + expect(parseBuilderlabTenants(" \n")).toEqual([]); + expect( + parseBuilderlabTenants( + '[{"relayHost":" Relay.Example. ","loginOrigin":"https://login.example"}]', + ), + ).toEqual([ + { + relayHost: "relay.example", + self: undefined, + loginOrigin: "https://login.example", + }, + ]); + expect(tenants.map((tenant) => tenant.relayHost)).toEqual([ + "test.blockstaging.build", + "communities.buzz.xyz", + "block.builderlab.xyz", + ]); + expect(Object.isFrozen(tenants)).toBe(true); +}); + +it.each([ + ["not JSON", "{"], + ["an object", '{"relayHost":"a.example","loginOrigin":"https://a.example"}'], + ["a non-object row", '["a.example"]'], + ["http", '[{"relayHost":"a.example","loginOrigin":"http://secret.example"}]'], + [ + "credentials", + '[{"relayHost":"a.example","loginOrigin":"https://user:secret@a.example"}]', + ], + [ + "a path", + '[{"relayHost":"a.example","loginOrigin":"https://a.example/path"}]', + ], + [ + "a non-canonical origin", + '[{"relayHost":"a.example","loginOrigin":"https://a.example:443"}]', + ], + [ + "a query", + '[{"relayHost":"a.example","loginOrigin":"https://a.example?x"}]', + ], + [ + "a scheme in the host", + '[{"relayHost":"https://a.example","loginOrigin":"https://a.example"}]', + ], + [ + "a wildcard host", + '[{"relayHost":"*.a.example","loginOrigin":"https://a.example"}]', + ], + [ + "a port in the host", + '[{"relayHost":"a.example:8443","loginOrigin":"https://a.example"}]', + ], + [ + // Four 63-byte labels: every label is legal, the 255-byte whole is not. + "an over-long host", + `[{"relayHost":"${["a", "b", "c", "d"].map((l) => l.repeat(63)).join(".")}","loginOrigin":"https://a.example"}]`, + ], + [ + // U+212A KELVIN SIGN: toLowerCase() would fold it to ASCII "k"; build.rs does not. + "a Kelvin sign in the host", + '[{"relayHost":"\u212Aelvin.example","loginOrigin":"https://a.example"}]', + ], + ["a missing host", '[{"loginOrigin":"https://a.example"}]'], + [ + "an unknown field", + '[{"relayHost":"a.example","loginOrigin":"https://a.example","extra":1}]', + ], + [ + "a short key", + '[{"relayHost":"a.example","self":"abcd","loginOrigin":"https://a.example"}]', + ], + [ + "an uppercase key", + `[{"relayHost":"a.example","self":"${"AB".repeat(32)}","loginOrigin":"https://a.example"}]`, + ], + [ + "a duplicate host", + '[{"relayHost":"a.example","loginOrigin":"https://a.example"},{"relayHost":"A.example.","loginOrigin":"https://b.example"}]', + ], + [ + "a duplicate key", + `[{"relayHost":"a.example","self":"${"ab".repeat(32)}","loginOrigin":"https://a.example"},{"relayHost":"b.example","self":"${"ab".repeat(32)}","loginOrigin":"https://b.example"}]`, + ], +])("rejects a table with %s without echoing it", (_label, raw) => { + let error: unknown; + try { + parseBuilderlabTenants(raw); + } catch (thrown) { + error = thrown; + } + expect(error).toBeInstanceOf(Error); + expect(String(error)).toMatch(/^Error: BUZZ_BUILDERLAB_TENANTS must be/); + expect(String(error)).not.toContain("secret"); + expect(String(error)).not.toContain("a.example"); +}); diff --git a/src/features/identity/builderlab-tenants.ts b/src/features/identity/builderlab-tenants.ts new file mode 100644 index 0000000000..50fa3ed53e --- /dev/null +++ b/src/features/identity/builderlab-tenants.ts @@ -0,0 +1,126 @@ +import type { FoundCommunity } from "../communities/find-community.ts"; + +/** + * One Builderlab deployment: the relays it hosts and where its people sign in. + * Public configuration from the `BUZZ_BUILDERLAB_TENANTS` build input, which + * also feeds the bundled plugin's native network grant (plugin-manager build.rs + * applies the same row rules), so the table can only ever name origins the + * native host lets the first-run OAuth client reach. + */ +export type BuilderlabTenant = Readonly<{ + /** Lowercase relay hostname: matched exactly or as a suffix at a label boundary. */ + relayHost: string; + /** The deployment's relay key (NIP-11 `self`), 64 lowercase hex; survives DNS moves. */ + self?: string | undefined; + /** Canonical https origin of the tenant's portal, as `new URL(x).origin` prints it. */ + loginOrigin: string; +}>; + +export type Authentication = + | { kind: "builderlab"; origin: string } + | { kind: "none" }; + +const HOSTNAME = + /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/; +/** The shape relay/native.ts requires of NIP-11 `self`. */ +const RELAY_KEY = /^[a-f0-9]{64}$/; + +export function parseBuilderlabTenants(raw = ""): readonly BuilderlabTenant[] { + if (!raw.trim()) return []; + try { + const rows: unknown = JSON.parse(raw); + if (!Array.isArray(rows)) throw new Error(); + const hosts = new Set(); + const keys = new Set(); + const tenants = rows.map((row: unknown): BuilderlabTenant => { + if (!row || typeof row !== "object" || Array.isArray(row)) + throw new Error(); + const { relayHost, self, loginOrigin, ...rest } = row as Record< + string, + unknown + >; + if ( + Object.keys(rest).length > 0 || + typeof relayHost !== "string" || + typeof loginOrigin !== "string" || + (self !== undefined && typeof self !== "string") + ) + throw new Error(); + // ASCII-only folding and the 253-byte cap mirror build.rs, so a row the + // native grant would refuse fails here, at configuration time, instead. + const host = relayHost + .trim() + .replace(/[A-Z]/g, (c) => c.toLowerCase()) + .replace(/\.$/, ""); + const origin = new URL(loginOrigin); + if ( + !HOSTNAME.test(host) || + host.length > 253 || + hosts.has(host) || + origin.protocol !== "https:" || + origin.username || + origin.password || + origin.origin !== loginOrigin || + (self !== undefined && (!RELAY_KEY.test(self) || keys.has(self))) + ) + throw new Error(); + hosts.add(host); + if (self !== undefined) keys.add(self); + return Object.freeze({ relayHost: host, self, loginOrigin }); + }); + // Longest suffix first, so a nested row (`buzz.x.example` beside `example`) wins. + return Object.freeze( + tenants.sort((a, b) => b.relayHost.length - a.relayHost.length), + ); + } catch { + // Do not echo malformed input: an environment value might contain credentials. + throw new Error( + "BUZZ_BUILDERLAB_TENANTS must be a JSON array of {relayHost, self?, loginOrigin} rows with unique hostnames, unique 64-hex relay keys and canonical https login origins.", + ); + } +} + +export const configuredBuilderlabTenants = parseBuilderlabTenants( + import.meta.env?.VITE_BUZZ_BUILDERLAB_TENANTS, +); + +/** + * Which sign-in a community URL given before setup leads to. + * + * Hostname first (exact or label-boundary suffix: `design.communities.buzz.xyz` + * and the apex match `communities.buzz.xyz`; `notcommunities.buzz.xyz` and + * `communities.buzz.xyz.evil.com` do not), then the relay's NIP-11 `self`, which + * keeps a known deployment recognisable while its DNS is reshuffled and can + * only select an origin already in the table. Block's deployments serve on 443, + * so a non-default port never matches. Anything else is `none`: a host Block + * does not list is not known to be Builderlab-hosted, so no portal is chosen + * for it; the welcome screen instead asks whether to connect at the build + * portal (a key linked to that account and backed up there, which the relay + * itself cannot use) or to bring an existing key. + * + * Precedence if a relay ever publishes an identity-provider hint in NIP-11: the + * table wins outright (a relay cannot redirect a known host); with no table + * match, accept a `builderlab` hint only for a `loginOrigin` already in the + * table, since only those are in the native grant; otherwise `none`. Not parsed + * today — nothing a relay publishes names its identity provider. + */ +export function discoverAuthentication( + found: FoundCommunity, + tenants: readonly BuilderlabTenant[] = configuredBuilderlabTenants, +): Authentication { + // Canonical already: relayOrigin() lower-cased the host and dropped a final dot. + const url = new URL(found.url); + if (url.port !== "") return { kind: "none" }; + const host = url.hostname; + const match = + tenants.find( + (tenant) => + host === tenant.relayHost || host.endsWith(`.${tenant.relayHost}`), + ) ?? + (found.self === undefined + ? undefined + : tenants.find((tenant) => tenant.self === found.self)); + return match + ? { kind: "builderlab", origin: match.loginOrigin } + : { kind: "none" }; +} diff --git a/vite.config.ts b/vite.config.ts index a0b36b7e01..8b4aa6f4e8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,6 +6,7 @@ import { parseCommunityAliases, relayOrigin, } from "./src/features/communities/destination.ts"; +import { parseBuilderlabTenants } from "./src/features/identity/builderlab-tenants.ts"; export default defineConfig(async ({ command, mode }) => { const env = loadEnv(mode, ".", "BUZZ_"); @@ -15,6 +16,9 @@ export default defineConfig(async ({ command, mode }) => { const live = command === "serve" && Boolean(env.BUZZ_DEV_VIEWER?.trim()); const aliases = env.BUZZ_COMMUNITY_ALIASES ?? ""; parseCommunityAliases(aliases); + // Public too: the same table the native build grants to the Builderlab plugin. + const tenants = env.BUZZ_BUILDERLAB_TENANTS ?? ""; + parseBuilderlabTenants(tenants); // Public routing configuration only; the viewer pin and credentials stay in Node. const defaultRelay = env.BUZZ_RELAY_URL?.trim(); const defaultOrigin = defaultRelay ? relayOrigin(defaultRelay) : ""; @@ -88,6 +92,7 @@ export default defineConfig(async ({ command, mode }) => { "import.meta.env.VITE_BUZZ_BUILDERLAB_URL": JSON.stringify( env.BUZZ_BUILDERLAB_URL ?? "", ), + "import.meta.env.VITE_BUZZ_BUILDERLAB_TENANTS": JSON.stringify(tenants), "import.meta.env.VITE_BUZZ_OPEN_RELAY": JSON.stringify(openOrigin), "import.meta.env.VITE_BUZZ_DATABRICKS_HOST": JSON.stringify(databricksHost),