diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 948ae1bd8..63b3acff0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -181,6 +181,10 @@ jobs:
run: just desktop-tauri-test
env:
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
+ - name: Desktop Tauri compiled-flag verification
+ run: just desktop-tauri-test-compiled-flags
+ env:
+ CMAKE_POLICY_VERSION_MINIMUM: "3.5"
- name: Upload desktop e2e artifacts
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
@@ -649,6 +653,11 @@ jobs:
done
cat /tmp/buzz-relay.log
exit 1
+ - name: Invite claim security tests
+ run: |
+ cargo test --profile ci -p buzz-relay claim_ -- --ignored --nocapture
+ env:
+ DATABASE_URL: postgres://buzz:buzz_dev@localhost:5432/buzz
- name: NIP-ER reminder e2e
# Feature e2e for NIP-ER (Event Reminders, kind:30300): write-path
# validation, author-only read filtering, and scheduler delivery against
@@ -695,7 +704,9 @@ jobs:
chmod +x ./target/ci/buzz-relay ./target/ci/git-credential-nostr
./scripts/start-relay-for-tests.sh --no-build
- name: Relay E2E tests
- run: cargo test -p buzz-test-client --test e2e_persona --test e2e_nostr_interop -- --ignored --nocapture
+ run: |
+ cargo test -p buzz-test-client --test e2e_persona --test e2e_nostr_interop -- --ignored --nocapture
+ cargo test -p buzz-test-client --test e2e_relay invite -- --ignored --nocapture
env:
RELAY_URL: ws://localhost:3000
GIT_CREDENTIAL_NOSTR_BIN: ${{ github.workspace }}/target/ci/git-credential-nostr
diff --git a/Cargo.lock b/Cargo.lock
index fc08b299c..f465a1661 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1243,6 +1243,7 @@ dependencies = [
"serde",
"serde_json",
"sha2 0.11.0",
+ "sqlx",
"tempfile",
"thiserror 2.0.18",
"tokio",
diff --git a/Justfile b/Justfile
index 88a840ad4..bc3acb7e0 100644
--- a/Justfile
+++ b/Justfile
@@ -193,6 +193,23 @@ desktop-tauri-check: _ensure-sidecar-stubs
desktop-tauri-test: _ensure-sidecar-stubs
cd desktop/src-tauri && cargo test
+# Verify compiled-flag behavior under both compile states (clean + internal).
+# Runs the observer_archive focused test twice with independently supplied
+# expected values; build.rs rerun-if-env-changed triggers recompilation.
+desktop-tauri-test-compiled-flags: _ensure-sidecar-stubs
+ #!/usr/bin/env bash
+ set -euo pipefail
+ cd desktop/src-tauri
+ echo "=== Clean build (no flag) → expect false ==="
+ env -u BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT \
+ BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT=false \
+ cargo test observer_archive_default_enabled_matches_expected -- --ignored --nocapture
+ echo "=== Internal build (flag set) → expect true ==="
+ BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT=1 \
+ BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT=true \
+ cargo test observer_archive_default_enabled_matches_expected -- --ignored --nocapture
+ echo "Both compiled states verified."
+
# Build the full desktop Tauri app locally (unsigned, for testing)
# Sidecar binary list must stay in sync with _ensure-sidecar-stubs above.
# pnpm install is unconditional here: release builds must start from a clean dep tree.
diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs
index cf7ee3a6e..6719e85f7 100644
--- a/crates/buzz-relay/src/api/invites.rs
+++ b/crates/buzz-relay/src/api/invites.rs
@@ -400,13 +400,17 @@ mod tests {
body::{to_bytes, Body},
http::{header, Request, StatusCode},
};
- use base64::Engine;
- use nostr::{EventBuilder, Keys, Kind, Tag};
+ use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
+ use hmac::{Hmac, KeyInit, Mac};
+ use nostr::{EventBuilder, EventId, Keys, Kind, Tag};
use serde_json::Value;
use sha2::{Digest, Sha256};
+ use std::sync::Mutex;
use tower::ServiceExt;
use uuid::Uuid;
+ use crate::invite_token::{derive_invite_key, InvitePayload};
+
use crate::router::build_router;
use crate::state::AppState;
@@ -973,6 +977,248 @@ mod tests {
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
+ /// Forge an already-expired invite payload signed with the relay's derived
+ /// invite key. `mint_invite` clamps ttl to 60s minimum, so the only way to
+ /// produce an expired code is to build the payload by hand at the token
+ /// layer.
+ fn forge_expired_invite_code(
+ state: &AppState,
+ community: buzz_core::CommunityId,
+ seconds_ago: u64,
+ ) -> String {
+ let key = derive_invite_key(&state.relay_keypair);
+ let now = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .expect("time")
+ .as_secs();
+ let payload = InvitePayload {
+ c: community.as_uuid().to_string(),
+ r: "member".to_string(),
+ e: now.saturating_sub(seconds_ago),
+ n: "test-nonce".to_string(),
+ };
+ let payload_bytes = serde_json::to_vec(&payload).expect("payload serializes");
+ let mut mac =
+ as KeyInit>::new_from_slice(&key).expect("HMAC accepts any key size");
+ mac.update(&payload_bytes);
+ let mac_bytes = mac.finalize().into_bytes();
+ format!(
+ "{}.{}",
+ URL_SAFE_NO_PAD.encode(&payload_bytes),
+ URL_SAFE_NO_PAD.encode(mac_bytes),
+ )
+ }
+
+ /// Endpoint-level proof that expired codes (with a valid MAC) are
+ /// rejected by `/api/invites/claim` with the distinguishable
+ /// `invite_expired` body, and do not admit the caller.
+ #[tokio::test]
+ #[ignore = "requires Postgres"]
+ async fn claim_rejects_expired_code() {
+ let host = format!("invites-{}.example", Uuid::new_v4().simple());
+ let joiner = Keys::generate();
+ let state = invite_test_state(&host)
+ .await
+ .expect("requires reachable Postgres and relay test state");
+ let community = state
+ .db
+ .lookup_community_by_host(&host)
+ .await
+ .expect("lookup")
+ .expect("community exists");
+ let code = forge_expired_invite_code(&state, community.id, 10);
+
+ let body = serde_json::json!({ "code": code }).to_string();
+ let response = post_json(state.clone(), &host, "/api/invites/claim", &joiner, body).await;
+ assert_eq!(response.status(), StatusCode::FORBIDDEN);
+ let json = read_json(response).await;
+ // The expired branch is deliberately distinguishable from the generic
+ // `invite_invalid` so the UX can prompt the user for a fresh link
+ // without becoming a MAC oracle.
+ assert_eq!(
+ json.get("error").and_then(Value::as_str),
+ Some("invite_expired"),
+ "expired branch must be distinguishable from generic invalid",
+ );
+
+ let is_member = state
+ .db
+ .is_relay_member(community.id, &joiner.public_key().to_hex())
+ .await
+ .expect("member check");
+ assert!(!is_member, "expired code must not admit anyone");
+ }
+
+ /// NIP-98 replay guard that returns `Ok(true)` the first time a given
+ /// event id is seen and `Ok(false)` on every subsequent call — mirrors
+ /// what the Redis guard does after a `SET NX` succeeds and then fails.
+ struct SeenOnceReplayGuard {
+ seen: Mutex>,
+ }
+
+ impl SeenOnceReplayGuard {
+ fn new() -> Self {
+ Self {
+ seen: Mutex::new(std::collections::HashSet::new()),
+ }
+ }
+ }
+
+ impl buzz_auth::Nip98ReplayGuard for SeenOnceReplayGuard {
+ fn try_mark_in_scope<'a>(
+ &'a self,
+ _scope: &'a str,
+ event_id: &'a EventId,
+ _ttl_secs: u64,
+ ) -> std::pin::Pin<
+ Box> + Send + 'a>,
+ > {
+ let bytes = *event_id.as_bytes();
+ let inserted = self.seen.lock().expect("replay set").insert(bytes);
+ Box::pin(async move { Ok(inserted) })
+ }
+ }
+
+ /// Endpoint-level proof that a replayed NIP-98 auth event on a claim POST
+ /// is rejected — the first claim succeeds, but reusing the exact same
+ /// Authorization header (same signed NIP-98 event id) is rejected as
+ /// replay before the invite verification ever runs.
+ #[tokio::test]
+ #[ignore = "requires Postgres"]
+ async fn claim_rejects_replayed_nip98_auth() {
+ let host = format!("invites-{}.example", Uuid::new_v4().simple());
+ let owner = Keys::generate();
+ let joiner = Keys::generate();
+ let state_arc = invite_test_state(&host)
+ .await
+ .expect("requires reachable Postgres and relay test state");
+ // Swap the always-fresh guard for one that fires the second time the
+ // same event id is presented — the code path we're pinning.
+ let mut state_owned =
+ Arc::try_unwrap(state_arc).unwrap_or_else(|_| panic!("sole owner of AppState"));
+ state_owned.nip98_replay = Arc::new(SeenOnceReplayGuard::new());
+ let state = Arc::new(state_owned);
+
+ let community = state
+ .db
+ .lookup_community_by_host(&host)
+ .await
+ .expect("lookup")
+ .expect("community exists");
+ state
+ .db
+ .add_relay_member(community.id, &owner.public_key().to_hex(), "owner", None)
+ .await
+ .expect("seed owner");
+
+ // Mint a valid code so the replay under test is on the claim path.
+ let response = post_json(
+ state.clone(),
+ &host,
+ "/api/invites",
+ &owner,
+ "{}".to_string(),
+ )
+ .await;
+ assert_eq!(response.status(), StatusCode::OK);
+ let json = read_json(response).await;
+ let code = json.get("code").and_then(Value::as_str).expect("code");
+
+ // Build one NIP-98 header and reuse it verbatim on two claim POSTs.
+ let claim_body = serde_json::json!({ "code": code }).to_string();
+ let claim_url = format!("https://{host}/api/invites/claim");
+ let claim_auth = nip98_auth_header(&joiner, &claim_url, claim_body.as_bytes());
+
+ let send_claim = |auth: String, body: String| {
+ let state = state.clone();
+ let host = host.clone();
+ async move {
+ build_router(state)
+ .oneshot(
+ Request::builder()
+ .method("POST")
+ .uri("/api/invites/claim")
+ .header(header::HOST, host.as_str())
+ .header(header::AUTHORIZATION, auth)
+ .header(header::CONTENT_TYPE, "application/json")
+ .body(Body::from(body))
+ .expect("request"),
+ )
+ .await
+ .expect("response")
+ }
+ };
+
+ let first = send_claim(claim_auth.clone(), claim_body.clone()).await;
+ assert_eq!(first.status(), StatusCode::OK);
+
+ // Same signed auth event, sent again → replay guard fires.
+ let second = send_claim(claim_auth, claim_body).await;
+ assert_eq!(second.status(), StatusCode::UNAUTHORIZED);
+ let json = read_json(second).await;
+ assert_eq!(
+ json.get("error").and_then(Value::as_str),
+ Some("NIP-98: replay detected"),
+ );
+ }
+
+ /// Endpoint-level proof that `/api/invites/claim` enforces the per-pubkey
+ /// fixed-window rate limit — the same joiner probing the endpoint hits
+ /// 429 on the `CLAIM_RATE_LIMIT + 1`th attempt inside the window.
+ ///
+ /// We use invalid codes throughout so no membership state can change; the
+ /// limiter runs before code verification, so the transition from
+ /// `invite_invalid` (403) to `too many invite claim attempts` (429) proves
+ /// the limiter guard is on the request path and fires on repeat pubkey.
+ #[tokio::test]
+ #[ignore = "requires Postgres"]
+ async fn claim_rate_limit_fires_on_repeat_pubkey() {
+ let host = format!("invites-{}.example", Uuid::new_v4().simple());
+ let joiner = Keys::generate();
+ let state_arc = invite_test_state(&host)
+ .await
+ .expect("requires reachable Postgres and relay test state");
+ // Fresh limiter with the production limit so the assertion pins the
+ // in-endpoint threshold, not a test-only budget.
+ let mut state_owned =
+ Arc::try_unwrap(state_arc).unwrap_or_else(|_| panic!("sole owner of AppState"));
+ state_owned.invite_claim_rate_limiter = Arc::new(claim_cache(
+ super::CLAIM_RATE_CACHE_CAPACITY,
+ super::CLAIM_RATE_WINDOW,
+ ));
+ let state = Arc::new(state_owned);
+
+ let body = serde_json::json!({ "code": "garbage.code" }).to_string();
+ for _ in 0..CLAIM_RATE_LIMIT {
+ let response = post_json(
+ state.clone(),
+ &host,
+ "/api/invites/claim",
+ &joiner,
+ body.clone(),
+ )
+ .await;
+ assert_eq!(
+ response.status(),
+ StatusCode::FORBIDDEN,
+ "attempts up to the limit should reach code verification and be rejected as invalid",
+ );
+ let json = read_json(response).await;
+ assert_eq!(
+ json.get("error").and_then(Value::as_str),
+ Some("invite_invalid"),
+ );
+ }
+
+ let over_limit = post_json(state, &host, "/api/invites/claim", &joiner, body).await;
+ assert_eq!(over_limit.status(), StatusCode::TOO_MANY_REQUESTS);
+ let json = read_json(over_limit).await;
+ assert_eq!(
+ json.get("error").and_then(Value::as_str),
+ Some("too many invite claim attempts, slow down"),
+ );
+ }
+
#[test]
fn policy_document_renders_markdown_and_escapes_raw_html() {
let page = super::render_policy_document(
diff --git a/crates/buzz-test-client/Cargo.toml b/crates/buzz-test-client/Cargo.toml
index 62732b714..1a056e113 100644
--- a/crates/buzz-test-client/Cargo.toml
+++ b/crates/buzz-test-client/Cargo.toml
@@ -33,6 +33,7 @@ base64 = "0.22"
hex = { workspace = true }
rand = { workspace = true }
sha2 = { workspace = true }
+sqlx = { workspace = true }
chrono = { workspace = true }
s3 = { version = "0.37", package = "rust-s3", default-features = false, features = ["tokio-rustls-tls", "fail-on-err", "tags"] }
buzz-sdk = { workspace = true }
diff --git a/crates/buzz-test-client/tests/e2e_relay.rs b/crates/buzz-test-client/tests/e2e_relay.rs
index 4b5913bf4..7fd421ff5 100644
--- a/crates/buzz-test-client/tests/e2e_relay.rs
+++ b/crates/buzz-test-client/tests/e2e_relay.rs
@@ -20,8 +20,12 @@
use std::time::Duration;
+use base64::engine::general_purpose::STANDARD as BASE64;
+use base64::Engine;
use buzz_test_client::{BuzzTestClient, RelayMessage, TestClientError};
use nostr::{Alphabet, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag};
+use sha2::{Digest, Sha256};
+use uuid::Uuid;
fn relay_url() -> String {
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string())
@@ -39,6 +43,124 @@ fn relay_http_url() -> String {
.to_string()
}
+fn test_owner_keys() -> Keys {
+ std::env::var("BUZZ_TEST_OWNER_PRIVATE_KEY")
+ .ok()
+ .and_then(|secret| Keys::parse(&secret).ok())
+ .unwrap_or_else(Keys::generate)
+}
+
+fn sha256_hex(bytes: &[u8]) -> String {
+ let digest = Sha256::digest(bytes);
+ hex::encode(digest)
+}
+
+fn nip98_post_header(keys: &Keys, url: &str, body: &str) -> String {
+ let event = EventBuilder::new(Kind::Custom(27_235), "")
+ .tags(vec![
+ Tag::parse(["u", url]).unwrap(),
+ Tag::parse(["method", "POST"]).unwrap(),
+ Tag::parse(["payload", &sha256_hex(body.as_bytes())]).unwrap(),
+ Tag::parse(["nonce", &uuid::Uuid::new_v4().to_string()]).unwrap(),
+ ])
+ .sign_with_keys(keys)
+ .expect("sign NIP-98 event");
+ format!(
+ "Nostr {}",
+ BASE64.encode(serde_json::to_string(&event).expect("serialize NIP-98 event"))
+ )
+}
+
+async fn e2e_db_pool() -> sqlx::Pool {
+ let database_url = std::env::var("DATABASE_URL")
+ .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string());
+ sqlx::postgres::PgPoolOptions::new()
+ .max_connections(1)
+ .connect(&database_url)
+ .await
+ .expect("connect to e2e Postgres")
+}
+
+async fn ensure_test_community(host: &str) -> uuid::Uuid {
+ let pool = e2e_db_pool().await;
+ let id = Uuid::new_v4();
+ sqlx::query(
+ "INSERT INTO communities (id, host) \
+ VALUES ($1, $2) \
+ ON CONFLICT (lower(host)) DO NOTHING",
+ )
+ .bind(id)
+ .bind(host)
+ .execute(&pool)
+ .await
+ .unwrap_or_else(|e| panic!("seed community {host}: {e}"));
+
+ sqlx::query_scalar("SELECT id FROM communities WHERE lower(host) = lower($1)")
+ .bind(host)
+ .fetch_one(&pool)
+ .await
+ .unwrap_or_else(|e| panic!("lookup community {host}: {e}"))
+}
+
+async fn seed_relay_member(host: &str, keys: &Keys, role: &str) {
+ let pool = e2e_db_pool().await;
+ let community_id = ensure_test_community(host).await;
+ sqlx::query(
+ "INSERT INTO relay_members (community_id, pubkey, role, added_by) \
+ VALUES ($1, $2, $3, NULL) \
+ ON CONFLICT (community_id, pubkey) DO UPDATE \
+ SET role = $3, updated_at = now()",
+ )
+ .bind(community_id)
+ .bind(keys.public_key().to_hex())
+ .bind(role)
+ .execute(&pool)
+ .await
+ .unwrap_or_else(|e| panic!("seed relay member {role}: {e}"));
+}
+
+async fn seed_relay_owner(keys: &Keys) {
+ seed_relay_member("localhost:3000", keys, "owner").await;
+}
+
+fn http_origin_for_host(host: &str) -> String {
+ let scheme = if relay_http_url().starts_with("https://") {
+ "https"
+ } else {
+ "http"
+ };
+ format!("{scheme}://{host}")
+}
+
+async fn invite_post(keys: &Keys, path: &str, body: &str) -> reqwest::Response {
+ invite_post_with_host(keys, None, path, body).await
+}
+
+async fn invite_post_with_host(
+ keys: &Keys,
+ host: Option<&str>,
+ path: &str,
+ body: &str,
+) -> reqwest::Response {
+ let client = reqwest::Client::new();
+ let connection_url = format!("{}{}", relay_http_url(), path);
+ let signed_url = host
+ .map(|host| format!("{}{}", http_origin_for_host(host), path))
+ .unwrap_or_else(|| connection_url.clone());
+ let mut request = client
+ .post(&connection_url)
+ .header("Authorization", nip98_post_header(keys, &signed_url, body))
+ .header("Content-Type", "application/json");
+ if let Some(host) = host {
+ request = request.header(reqwest::header::HOST, host);
+ }
+ request
+ .body(body.to_string())
+ .send()
+ .await
+ .unwrap_or_else(|e| panic!("POST {path} failed: {e}"))
+}
+
/// Create a real channel via a signed kind:9007 event submitted to POST /events.
async fn create_test_channel(keys: &Keys) -> String {
let client = reqwest::Client::new();
@@ -92,6 +214,103 @@ async fn test_connect_and_authenticate() {
client.disconnect().await.expect("clean disconnect");
}
+#[tokio::test]
+#[ignore]
+async fn test_invite_mint_and_claim_admits_new_pubkey() {
+ let owner = test_owner_keys();
+ let joiner = Keys::generate();
+ seed_relay_owner(&owner).await;
+
+ let mint_response = invite_post(&owner, "/api/invites", "{}").await;
+ assert_eq!(mint_response.status(), reqwest::StatusCode::OK);
+ let mint_json: serde_json::Value = mint_response.json().await.expect("mint JSON");
+ let code = mint_json
+ .get("code")
+ .and_then(serde_json::Value::as_str)
+ .expect("mint response includes code");
+ assert_eq!(
+ mint_json.get("url").and_then(serde_json::Value::as_str),
+ Some(format!("{}/invite/{code}", relay_http_url()).as_str()),
+ "minted URL should be the shareable HTTPS/HTTP invite URL"
+ );
+
+ let claim_body = serde_json::json!({ "code": code }).to_string();
+ let claim_response = invite_post(&joiner, "/api/invites/claim", &claim_body).await;
+ assert_eq!(claim_response.status(), reqwest::StatusCode::OK);
+ let claim_json: serde_json::Value = claim_response.json().await.expect("claim JSON");
+ assert_eq!(
+ claim_json.get("status").and_then(serde_json::Value::as_str),
+ Some("joined")
+ );
+ assert_eq!(
+ claim_json.get("role").and_then(serde_json::Value::as_str),
+ Some("member")
+ );
+
+ let repeat_response = invite_post(&joiner, "/api/invites/claim", &claim_body).await;
+ assert_eq!(repeat_response.status(), reqwest::StatusCode::OK);
+ let repeat_json: serde_json::Value = repeat_response.json().await.expect("repeat claim JSON");
+ assert_eq!(
+ repeat_json
+ .get("status")
+ .and_then(serde_json::Value::as_str),
+ Some("already_member")
+ );
+}
+
+#[tokio::test]
+#[ignore]
+async fn test_invite_claim_rejects_invalid_code() {
+ let joiner = Keys::generate();
+ let body = serde_json::json!({ "code": "garbage.code" }).to_string();
+
+ let response = invite_post(&joiner, "/api/invites/claim", &body).await;
+ assert_eq!(response.status(), reqwest::StatusCode::FORBIDDEN);
+ let json: serde_json::Value = response.json().await.expect("error JSON");
+ assert_eq!(
+ json.get("error").and_then(serde_json::Value::as_str),
+ Some("invite_invalid")
+ );
+}
+
+#[tokio::test]
+#[ignore]
+async fn test_invite_mint_requires_owner_or_admin() {
+ let member = Keys::generate();
+ seed_relay_member("localhost:3000", &member, "member").await;
+
+ let response = invite_post(&member, "/api/invites", "{}").await;
+ assert_eq!(response.status(), reqwest::StatusCode::FORBIDDEN);
+
+ let outsider = Keys::generate();
+ let response = invite_post(&outsider, "/api/invites", "{}").await;
+ assert_eq!(response.status(), reqwest::StatusCode::FORBIDDEN);
+}
+
+#[tokio::test]
+#[ignore]
+async fn test_invite_code_minted_for_one_host_fails_on_another() {
+ let host_a = format!("invites-a-{}.example", Uuid::new_v4().simple());
+ let host_b = format!("invites-b-{}.example", Uuid::new_v4().simple());
+ let owner = Keys::generate();
+ let joiner = Keys::generate();
+ ensure_test_community(&host_b).await;
+ seed_relay_member(&host_a, &owner, "owner").await;
+
+ let mint_response = invite_post_with_host(&owner, Some(&host_a), "/api/invites", "{}").await;
+ assert_eq!(mint_response.status(), reqwest::StatusCode::OK);
+ let mint_json: serde_json::Value = mint_response.json().await.expect("mint JSON");
+ let code = mint_json
+ .get("code")
+ .and_then(serde_json::Value::as_str)
+ .expect("mint response includes code");
+
+ let claim_body = serde_json::json!({ "code": code }).to_string();
+ let claim_response =
+ invite_post_with_host(&joiner, Some(&host_b), "/api/invites/claim", &claim_body).await;
+ assert_eq!(claim_response.status(), reqwest::StatusCode::FORBIDDEN);
+}
+
#[tokio::test]
#[ignore]
async fn test_send_event_and_receive_via_subscription() {
diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts
index 805fcb289..fa6f9ae19 100644
--- a/desktop/playwright.config.ts
+++ b/desktop/playwright.config.ts
@@ -107,6 +107,7 @@ export default defineConfig({
"**/agent-lifecycle-feedback.spec.ts",
"**/inbox-live-update.spec.ts",
"**/mesh-compute.spec.ts",
+ "**/observer-archive-policy.spec.ts",
],
use: {
...devices["Desktop Chrome"],
diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock
index 286be936b..56b85ea44 100644
--- a/desktop/src-tauri/Cargo.lock
+++ b/desktop/src-tauri/Cargo.lock
@@ -10982,6 +10982,7 @@ dependencies = [
"getrandom 0.4.3",
"js-sys",
"serde_core",
+ "sha1_smol",
"wasm-bindgen",
]
diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml
index 768f1d017..2d724bfaa 100644
--- a/desktop/src-tauri/Cargo.toml
+++ b/desktop/src-tauri/Cargo.toml
@@ -105,7 +105,7 @@ bzip2 = "0.6"
chrono = { version = "0.4", features = ["serde"] }
tauri-plugin-global-shortcut = "2"
tauri-plugin-notification = "2.3.3"
-uuid = { version = "1", features = ["v4"] }
+uuid = { version = "1", features = ["v4", "v5"] }
png = "0.18"
arboard = "3"
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif"] }
diff --git a/desktop/src-tauri/src/commands/channels.rs b/desktop/src-tauri/src/commands/channels.rs
index 4f86e5550..59c80c480 100644
--- a/desktop/src-tauri/src/commands/channels.rs
+++ b/desktop/src-tauri/src/commands/channels.rs
@@ -5,12 +5,32 @@ use crate::{
events,
models::{ChannelDetailInfo, ChannelInfo, ChannelMembersResponse},
nostr_convert,
- relay::{query_relay, submit_event, submit_event_with_keys},
+ relay::{query_relay, relay_api_base_url_with_override, submit_event, submit_event_with_keys},
};
// ── Reads (pure-nostr via /query) ────────────────────────────────────────────
const DIRECTORY_PAGE_SIZE: usize = 500;
+const STARTER_CHANNEL_NAMESPACE: uuid::Uuid = uuid::uuid!("3ce33bea-8f09-5f1b-9c85-8a7d2659e6b0");
+
+struct StarterChannelSpec {
+ slug: &'static str,
+ name: &'static str,
+ description: &'static str,
+}
+
+const STARTER_CHANNELS: &[StarterChannelSpec] = &[
+ StarterChannelSpec {
+ slug: "general",
+ name: "general",
+ description: "General conversation and community updates.",
+ },
+ StarterChannelSpec {
+ slug: "welcome-everyone",
+ name: "welcome-everyone",
+ description: "Say hi, ask a question, or share what brought you here.",
+ },
+];
fn advance_directory_cursor(filter: &mut serde_json::Value, page: &[nostr::Event]) {
let last = page
@@ -445,6 +465,84 @@ fn parse_channel_uuid(channel_id: &str) -> Result {
uuid::Uuid::parse_str(channel_id).map_err(|_| format!("invalid channel UUID: {channel_id}"))
}
+fn normalize_channel_name(name: &str) -> String {
+ name.trim().to_ascii_lowercase()
+}
+
+fn starter_channel_uuid(relay_scope: &str, slug: &str) -> uuid::Uuid {
+ let name = format!("starter-channel:v1:{}:{}", relay_scope.trim(), slug);
+ uuid::Uuid::new_v5(&STARTER_CHANNEL_NAMESPACE, name.as_bytes())
+}
+
+fn is_duplicate_channel_rejection(error: &str) -> bool {
+ error.contains("relay rejected event:") && error.contains("duplicate: channel already exists")
+}
+
+fn is_matching_starter_channel(channel: &ChannelInfo, spec: &StarterChannelSpec) -> bool {
+ normalize_channel_name(&channel.name) == normalize_channel_name(spec.name)
+ && channel.channel_type == "stream"
+ && channel.visibility == "open"
+ && channel.archived_at.is_none()
+}
+
+fn has_all_starter_channels(channels: &[ChannelInfo]) -> bool {
+ STARTER_CHANNELS.iter().all(|spec| {
+ channels
+ .iter()
+ .any(|channel| is_matching_starter_channel(channel, spec))
+ })
+}
+
+async fn ensure_starter_channel_memberships(
+ state: &AppState,
+ keys: &nostr::Keys,
+ channels: &mut [ChannelInfo],
+) -> Result<(), String> {
+ for spec in STARTER_CHANNELS {
+ let Some(channel) = channels
+ .iter_mut()
+ .find(|channel| is_matching_starter_channel(channel, spec))
+ else {
+ continue;
+ };
+
+ if channel.is_member {
+ continue;
+ }
+
+ let channel_uuid = parse_channel_uuid(&channel.id)?;
+ let builder = events::build_join(channel_uuid)?;
+ submit_event_with_keys(builder, state, keys, None).await?;
+ channel.is_member = true;
+ }
+
+ Ok(())
+}
+
+async fn fetch_starter_channel_metadata(
+ state: &AppState,
+ channel_ids: &[String],
+) -> Result, String> {
+ if channel_ids.is_empty() {
+ return Ok(Vec::new());
+ }
+
+ let events = query_relay(
+ state,
+ &[serde_json::json!({
+ "kinds": [39000],
+ "#d": channel_ids,
+ "limit": channel_ids.len(),
+ })],
+ )
+ .await?;
+
+ events
+ .iter()
+ .map(|ev| nostr_convert::channel_info_from_event(ev, None, Some(false)))
+ .collect()
+}
+
#[tauri::command]
pub async fn create_channel(
name: String,
@@ -510,6 +608,80 @@ pub async fn create_channel(
.ok_or_else(|| "channel created but metadata not yet available".to_string())
}
+#[tauri::command]
+pub async fn ensure_starter_channels(
+ state: State<'_, AppState>,
+) -> Result, String> {
+ let mut existing_channels = get_channels(state.clone()).await?;
+ let relay_scope = relay_api_base_url_with_override(&state);
+ let creator_keys = state.signing_keys()?;
+ let creator_pubkey = creator_keys.public_key().to_hex();
+ let mut starter_ids = Vec::with_capacity(STARTER_CHANNELS.len());
+ let mut created_ids = std::collections::HashSet::new();
+
+ for spec in STARTER_CHANNELS {
+ if existing_channels
+ .iter()
+ .any(|channel| is_matching_starter_channel(channel, spec))
+ {
+ continue;
+ }
+
+ let channel_uuid = starter_channel_uuid(&relay_scope, spec.slug);
+ let channel_uuid_string = channel_uuid.to_string();
+ starter_ids.push(channel_uuid_string.clone());
+ let builder = events::build_create_channel(
+ channel_uuid,
+ spec.name,
+ "open",
+ "stream",
+ Some(spec.description),
+ None,
+ )?;
+
+ match submit_event_with_keys(builder, &state, &creator_keys, None).await {
+ Ok(_) => {
+ state.mark_pending_owned_channel(&creator_pubkey, &channel_uuid_string);
+ created_ids.insert(channel_uuid_string.clone());
+ }
+ Err(error) if is_duplicate_channel_rejection(&error) => {
+ state.mark_pending_owned_channel(&creator_pubkey, &channel_uuid_string);
+ }
+ Err(error) => return Err(error),
+ }
+ }
+
+ for _ in 0..3 {
+ let metadata = fetch_starter_channel_metadata(&state, &starter_ids).await?;
+ for mut channel in metadata {
+ if created_ids.contains(&channel.id) {
+ channel.is_member = true;
+ }
+ if !existing_channels
+ .iter()
+ .any(|existing| existing.id == channel.id)
+ {
+ existing_channels.push(channel);
+ }
+ }
+ if has_all_starter_channels(&existing_channels) {
+ break;
+ }
+ tokio::time::sleep(std::time::Duration::from_millis(150)).await;
+ }
+
+ if !has_all_starter_channels(&existing_channels) {
+ existing_channels = get_channels(state.clone()).await?;
+ }
+
+ if !has_all_starter_channels(&existing_channels) {
+ return Err("starter channels created but metadata not yet available".to_string());
+ }
+
+ ensure_starter_channel_memberships(&state, &creator_keys, &mut existing_channels).await?;
+ Ok(existing_channels)
+}
+
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateChannelInput {
diff --git a/desktop/src-tauri/src/commands/channels_tests.rs b/desktop/src-tauri/src/commands/channels_tests.rs
index e77297fa3..5b65695a9 100644
--- a/desktop/src-tauri/src/commands/channels_tests.rs
+++ b/desktop/src-tauri/src/commands/channels_tests.rs
@@ -240,3 +240,64 @@ fn pending_owner_mark_uses_signer_captured_before_identity_swap() {
let post_swap_pubkey = state.keys.lock().expect("lock keys").public_key().to_hex();
assert!(!state.is_pending_owned_channel(&post_swap_pubkey, "chan-1"));
}
+
+#[test]
+fn starter_channel_uuid_is_stable_and_scoped() {
+ let first = starter_channel_uuid("https://relay-a.example", "general");
+ let second = starter_channel_uuid("https://relay-a.example", "general");
+ let other_slug = starter_channel_uuid("https://relay-a.example", "welcome-everyone");
+ let other_relay = starter_channel_uuid("https://relay-b.example", "general");
+
+ assert_eq!(first, second);
+ assert_ne!(first, other_slug);
+ assert_ne!(first, other_relay);
+}
+
+#[test]
+fn duplicate_channel_rejection_is_ensure_success_only() {
+ assert!(is_duplicate_channel_rejection(
+ "relay rejected event: duplicate: channel already exists"
+ ));
+ assert!(!is_duplicate_channel_rejection(
+ "relay rejected event: auth: not authorized"
+ ));
+ assert!(!is_duplicate_channel_rejection(
+ "duplicate: unrelated local error"
+ ));
+}
+
+#[test]
+fn starter_match_requires_open_unarchived_stream_by_normalized_name() {
+ let spec = &STARTER_CHANNELS[0];
+ let mut channel = ChannelInfo {
+ id: "chan-1".to_string(),
+ name: " General ".to_string(),
+ channel_type: "stream".to_string(),
+ visibility: "open".to_string(),
+ description: "".to_string(),
+ topic: None,
+ purpose: None,
+ member_count: 0,
+ member_pubkeys: Vec::new(),
+ last_message_at: None,
+ archived_at: None,
+ participants: Vec::new(),
+ participant_pubkeys: Vec::new(),
+ is_member: true,
+ ttl_seconds: None,
+ ttl_deadline: None,
+ };
+
+ assert!(is_matching_starter_channel(&channel, spec));
+
+ channel.visibility = "private".to_string();
+ assert!(!is_matching_starter_channel(&channel, spec));
+
+ channel.visibility = "open".to_string();
+ channel.channel_type = "forum".to_string();
+ assert!(!is_matching_starter_channel(&channel, spec));
+
+ channel.channel_type = "stream".to_string();
+ channel.archived_at = Some("2026-07-16T00:00:00Z".to_string());
+ assert!(!is_matching_starter_channel(&channel, spec));
+}
diff --git a/desktop/src-tauri/src/commands/observer_archive.rs b/desktop/src-tauri/src/commands/observer_archive.rs
index f7286b018..707e86b63 100644
--- a/desktop/src-tauri/src/commands/observer_archive.rs
+++ b/desktop/src-tauri/src/commands/observer_archive.rs
@@ -1,4 +1,4 @@
-//! Build-time flag and runtime dev-nest check for observer-feed archive default.
+//! Build-time flag and runtime dev-nest check for observer-feed archive policy.
//!
//! `observer_archive_default_enabled()` returns `true` when either:
//! - `BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT` was set at build time (internal
@@ -6,17 +6,18 @@
//! - the running binary is using the dev nest (`~/.buzz-dev`), which is the
//! case for all dev builds launched with `just staging` or `just dev`.
//!
-//! When `true`, the frontend auto-seeds an `owner_p` save subscription for the
-//! current identity on first run, so the observer-feed archive is on by default.
+//! When `true`, the frontend reconciles the observer archive subscription
+//! every startup — unconditionally ensuring kind 24200 exists in the DB
+//! regardless of stale localStorage markers.
//!
//! OSS prod builds (baked flag unset, prod nest `~/.buzz`) return `false` —
-//! no auto-seeding; the user opts in manually via the Local Archive settings card.
+//! no reconciliation; the user manages the subscription via Settings.
-/// Returns `true` when observer-feed archive should default to on.
+/// Returns `true` when observer-feed archive policy is enforced.
///
/// True when the build has the internal baked flag set, or when the running
-/// binary is using the dev nest (`~/.buzz-dev`). The frontend calls this once
-/// at startup to decide whether to seed the `owner_p` save subscription.
+/// binary is using the dev nest (`~/.buzz-dev`). The frontend calls this
+/// every startup to decide whether to reconcile the `owner_p` subscription.
#[tauri::command]
pub fn observer_archive_default_enabled() -> bool {
option_env!("BUZZ_DESKTOP_BUILD_OBSERVER_ARCHIVE_DEFAULT").is_some()
@@ -27,15 +28,27 @@ pub fn observer_archive_default_enabled() -> bool {
mod tests {
use super::*;
+ // `nest_is_dev()` is deterministic-false in unit tests: NEST_DIR OnceLock
+ // is uninitialized → falls back to prod `~/.buzz` (nest.rs:101-106), so
+ // the compiled flag is the sole variable. No runner normalization needed.
+ //
+ // #[ignore]: requires BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT to be
+ // set — `just desktop-tauri-test-compiled-flags` runs it explicitly with
+ // `--ignored` under both compile states; general `cargo test` skips it.
#[test]
- fn test_observer_archive_default_enabled_returns_bool() {
- // The command must return a plain bool without panicking.
- // Whether it's true or false depends on the build environment;
- // what we assert here is just that the return type is correct and
- // the function is callable.
+ #[ignore]
+ fn test_observer_archive_default_enabled_matches_expected() {
let result = observer_archive_default_enabled();
- // In a standard OSS/test build (no BUZZ_DESKTOP_BUILD_OBSERVER_ARCHIVE_DEFAULT
- // baked in), this should be false.
- assert!(!result, "expected false in OSS/test build");
+ let expected_str = std::env::var("BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT").expect(
+ "BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT must be set — \
+ the dual-compile CI step supplies it; bare `cargo test` is \
+ not sufficient to validate compiled-flag behavior",
+ );
+ let expected = expected_str == "true" || expected_str == "1";
+ assert_eq!(
+ result, expected,
+ "observer_archive_default_enabled() returned {result}, \
+ expected {expected} (BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT={expected_str:?})"
+ );
}
}
diff --git a/desktop/src-tauri/src/commands/personas/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound_tests.rs
index 6c7012e7e..1000e48b7 100644
--- a/desktop/src-tauri/src/commands/personas/inbound_tests.rs
+++ b/desktop/src-tauri/src/commands/personas/inbound_tests.rs
@@ -402,8 +402,30 @@ fn team_content(name: &str) -> TeamEventContent {
TeamEventContent {
name: name.to_string(),
description: Some("remote desc".to_string()),
- instructions: Some("remote instructions".to_string()),
- persona_ids: vec!["p-remote-1".to_string(), "p-remote-2".to_string()],
+ instructions: Some(Some("remote instructions".to_string())),
+ persona_ids: Some(vec!["p-remote-1".to_string(), "p-remote-2".to_string()]),
+ }
+}
+
+/// An inbound event shaped like one from a client that predates
+/// always-publish: `instructions`/`persona_ids` both omitted (`None`).
+fn team_content_omitting_optional_fields(name: &str) -> TeamEventContent {
+ TeamEventContent {
+ name: name.to_string(),
+ description: Some("remote desc".to_string()),
+ instructions: None,
+ persona_ids: None,
+ }
+}
+
+/// An inbound event that explicitly clears both fields: `instructions` is
+/// `Some(None)` (JSON `null`), `persona_ids` is `Some(vec![])`.
+fn team_content_clearing_optional_fields(name: &str) -> TeamEventContent {
+ TeamEventContent {
+ name: name.to_string(),
+ description: Some("remote desc".to_string()),
+ instructions: Some(None),
+ persona_ids: Some(vec![]),
}
}
@@ -438,6 +460,66 @@ fn inbound_team_match_patches_shared_preserves_local() {
assert_eq!(t.created_at, "2025-01-01T00:00:00Z");
}
+#[test]
+fn inbound_team_omitted_fields_preserve_local() {
+ // A `None` for instructions/persona_ids means the publisher predates
+ // always-publish — its true value is unknown, so reconcile must
+ // preserve whatever this device already has. This is the fix for the
+ // Sietch Tabr wipe: an old-shaped (or genuinely field-omitting) event
+ // must not blank out a team that has real membership/instructions.
+ let mut teams = vec![local_team()];
+ // Give local_team real instructions so preservation is discriminating:
+ // the pre-fix blind-overwrite bug would collapse this to `None`, while
+ // the fix must leave it untouched on an omitted field.
+ teams[0].instructions = Some("local instructions".to_string());
+ apply_inbound_team(
+ &mut teams,
+ TEAM_ID.to_string(),
+ team_content_omitting_optional_fields("Renamed Team"),
+ );
+
+ assert_eq!(teams.len(), 1);
+ let t = &teams[0];
+ assert_eq!(
+ t.name, "Renamed Team",
+ "shared non-optional field still overwrites"
+ );
+ assert_eq!(
+ t.instructions,
+ Some("local instructions".to_string()),
+ "omitted instructions preserves local value rather than wiping it"
+ );
+ assert_eq!(
+ t.persona_ids,
+ vec!["p-local".to_string()],
+ "omitted persona_ids preserves local membership rather than wiping it"
+ );
+}
+
+#[test]
+fn inbound_team_explicit_clear_overwrites_local() {
+ // `Some(None)` / `Some(vec![])` are the explicit-clear signals a
+ // pre-fix client can never produce — these must still overwrite local.
+ let mut teams = vec![local_team()];
+ // Give local_team real instructions so the clear has something to erase.
+ teams[0].instructions = Some("local instructions".to_string());
+
+ apply_inbound_team(
+ &mut teams,
+ TEAM_ID.to_string(),
+ team_content_clearing_optional_fields("Cleared Team"),
+ );
+
+ assert_eq!(teams.len(), 1);
+ let t = &teams[0];
+ assert_eq!(t.instructions, None, "explicit null clears instructions");
+ assert_eq!(
+ t.persona_ids,
+ Vec::::new(),
+ "explicit empty array clears membership"
+ );
+}
+
#[test]
fn inbound_team_no_match_inserts_idempotently() {
let mut teams = vec![local_team()];
diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs
index 19febe0d0..51ad882b5 100644
--- a/desktop/src-tauri/src/commands/personas/mod.rs
+++ b/desktop/src-tauri/src/commands/personas/mod.rs
@@ -876,15 +876,25 @@ fn apply_inbound_team(teams: &mut Vec, d_tag: String, inbound: TeamE
Some(local) => {
local.name = inbound.name;
local.description = inbound.description;
- local.instructions = inbound.instructions;
- local.persona_ids = inbound.persona_ids;
+ // `None` means the event came from a client that predates
+ // always-publish — its true value is unknown, so preserve
+ // local. Only `Some` (including the explicit-clear variants)
+ // overwrites. See `TeamEventContent` for the wire rules.
+ if let Some(instructions) = inbound.instructions {
+ local.instructions = instructions;
+ }
+ if let Some(persona_ids) = inbound.persona_ids {
+ local.persona_ids = persona_ids;
+ }
}
None => teams.push(TeamRecord {
id: d_tag,
name: inbound.name,
description: inbound.description,
- instructions: inbound.instructions,
- persona_ids: inbound.persona_ids,
+ // Fresh insert has no local value to preserve; `None` from a
+ // pre-fix client simply means no known value.
+ instructions: inbound.instructions.unwrap_or_default(),
+ persona_ids: inbound.persona_ids.unwrap_or_default(),
is_builtin: false,
source_dir: None,
is_symlink: false,
diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs
index a890a99ad..3c98c66e3 100644
--- a/desktop/src-tauri/src/deep_link.rs
+++ b/desktop/src-tauri/src/deep_link.rs
@@ -13,6 +13,7 @@ pub(crate) struct PendingCommunityDeepLink {
kind: String,
relay_url: String,
code: Option,
+ name: Option,
}
#[derive(Default)]
@@ -25,6 +26,7 @@ impl PendingCommunityDeepLinks {
item.kind == pending.kind
&& item.relay_url == pending.relay_url
&& item.code == pending.code
+ && item.name == pending.name
}) {
return;
}
@@ -70,6 +72,7 @@ fn queue_community_deep_link(
kind: &str,
relay_url: String,
code: Option,
+ name: Option,
) {
app.state::()
.enqueue(PendingCommunityDeepLink {
@@ -77,6 +80,7 @@ fn queue_community_deep_link(
kind: kind.to_owned(),
relay_url,
code,
+ name,
});
}
@@ -133,7 +137,6 @@ fn parse_message_deep_link(url: &Url) -> Option {
/// `code`; returns `None` otherwise so the frontend never sees a half-formed
/// payload.
fn parse_join_deep_link(url: &Url) -> Option {
- let mut relay: Option = None;
let mut code: Option = None;
let mut policy_receipt: Option = None;
for (k, v) in url.query_pairs() {
@@ -142,17 +145,13 @@ fn parse_join_deep_link(url: &Url) -> Option {
continue;
}
match k.as_ref() {
- "relay" => relay = Some(v),
"code" => code = Some(v),
"policy_receipt" => policy_receipt = Some(v),
_ => {}
}
}
- let (relay_url, code) = (relay?, code?);
- match Url::parse(&relay_url) {
- Ok(parsed) if parsed.scheme() == "ws" || parsed.scheme() == "wss" => {}
- _ => return None,
- }
+ let code = code?;
+ let relay_url = parse_websocket_relay_param(url)?;
Some(serde_json::json!({
"relayUrl": relay_url,
"code": code,
@@ -160,6 +159,33 @@ fn parse_join_deep_link(url: &Url) -> Option {
}))
}
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct AddCommunityDeepLinkPayload {
+ relay_url: String,
+ name: Option,
+}
+
+fn parse_websocket_relay_param(url: &Url) -> Option {
+ let relay_url = url
+ .query_pairs()
+ .find(|(key, _)| key == "relay")
+ .map(|(_, value)| value.into_owned())
+ .filter(|value| !value.is_empty())?;
+ let parsed = Url::parse(&relay_url).ok()?;
+ if !matches!(parsed.scheme(), "ws" | "wss") || parsed.host_str().is_none() {
+ return None;
+ }
+ Some(relay_url)
+}
+
+fn parse_add_community_deep_link(url: &Url) -> Option {
+ Some(AddCommunityDeepLinkPayload {
+ relay_url: parse_websocket_relay_param(url)?,
+ name: optional_non_empty_param(url, "name"),
+ })
+}
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
struct NostrBindDeepLinkPayload {
@@ -281,31 +307,12 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) {
match url.host_str() {
Some("connect") => {
- let relay = url
- .query_pairs()
- .find(|(k, _)| k == "relay")
- .map(|(_, v)| v.into_owned());
- let Some(relay_url) = relay else {
- eprintln!("buzz-desktop: connect deep link missing relay param: {url_str}");
+ let Some(relay_url) = parse_websocket_relay_param(&url) else {
+ eprintln!("buzz-desktop: connect deep link missing/invalid relay: {url_str}");
return;
};
- // Validate the relay URL is ws:// or wss://
- match Url::parse(&relay_url) {
- Ok(parsed) if parsed.scheme() == "ws" || parsed.scheme() == "wss" => {}
- Ok(parsed) => {
- eprintln!(
- "buzz-desktop: rejecting non-websocket relay URL scheme {:?}: {relay_url}",
- parsed.scheme()
- );
- return;
- }
- Err(e) => {
- eprintln!("buzz-desktop: invalid relay URL {relay_url:?}: {e}");
- return;
- }
- }
activate_main_window(app);
- queue_community_deep_link(app, "connect", relay_url.clone(), None);
+ queue_community_deep_link(app, "connect", relay_url.clone(), None, None);
let _ = app.emit("deep-link-connect", relay_url);
}
Some("join") => {
@@ -319,9 +326,24 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) {
activate_main_window(app);
let relay_url = payload["relayUrl"].as_str().unwrap_or_default().to_owned();
let code = payload["code"].as_str().map(str::to_owned);
- queue_community_deep_link(app, "join", relay_url, code);
+ queue_community_deep_link(app, "join", relay_url, code, None);
let _ = app.emit("deep-link-join", payload);
}
+ Some("add-community") => {
+ let Some(payload) = parse_add_community_deep_link(&url) else {
+ eprintln!("buzz-desktop: add-community deep link missing/invalid relay: {url_str}");
+ return;
+ };
+ activate_main_window(app);
+ queue_community_deep_link(
+ app,
+ "add-community",
+ payload.relay_url.clone(),
+ None,
+ payload.name.clone(),
+ );
+ let _ = app.emit("deep-link-add-community", payload);
+ }
Some("message") => {
// `buzz://message?channel=&id=[&thread=]`
//
@@ -361,8 +383,8 @@ mod tests {
use url::Url;
use super::{
- parse_join_deep_link, parse_message_deep_link, parse_nostr_bind_deep_link,
- PendingCommunityDeepLink, PendingCommunityDeepLinks,
+ parse_add_community_deep_link, parse_join_deep_link, parse_message_deep_link,
+ parse_nostr_bind_deep_link, PendingCommunityDeepLink, PendingCommunityDeepLinks,
};
fn pending(id: &str, relay_url: &str, code: Option<&str>) -> PendingCommunityDeepLink {
@@ -371,6 +393,7 @@ mod tests {
kind: if code.is_some() { "join" } else { "connect" }.to_owned(),
relay_url: relay_url.to_owned(),
code: code.map(str::to_owned),
+ name: None,
}
}
@@ -401,6 +424,43 @@ mod tests {
.unwrap()
}
+ #[test]
+ fn parse_add_community_deep_link_extracts_relay_and_name() {
+ let url = Url::parse(
+ "buzz://add-community?relay=wss%3A%2F%2Facme.communities.buzz.xyz&name=Acme%20Team&ignored=value",
+ )
+ .unwrap();
+ let payload = parse_add_community_deep_link(&url).unwrap();
+ assert_eq!(payload.relay_url, "wss://acme.communities.buzz.xyz");
+ assert_eq!(payload.name.as_deref(), Some("Acme Team"));
+ }
+
+ #[test]
+ fn parse_add_community_deep_link_accepts_an_omitted_or_empty_name() {
+ for raw in [
+ "buzz://add-community?relay=wss%3A%2F%2Facme.example",
+ "buzz://add-community?relay=wss%3A%2F%2Facme.example&name=",
+ ] {
+ assert!(parse_add_community_deep_link(&Url::parse(raw).unwrap())
+ .unwrap()
+ .name
+ .is_none());
+ }
+ }
+
+ #[test]
+ fn parse_add_community_deep_link_rejects_invalid_relays() {
+ for raw in [
+ "buzz://add-community",
+ "buzz://add-community?relay=",
+ "buzz://add-community?relay=not-a-url",
+ "buzz://add-community?relay=https%3A%2F%2Facme.example",
+ "buzz://add-community?relay=wss%3A%2F%2F",
+ ] {
+ assert!(parse_add_community_deep_link(&Url::parse(raw).unwrap()).is_none());
+ }
+ }
+
#[test]
fn parse_message_deep_link_extracts_required_params() {
let url = Url::parse("buzz://message?channel=abc&id=xyz").unwrap();
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index 8c048cc2e..9a4eb3dcb 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -789,6 +789,7 @@ pub fn run() {
nip44_decrypt_from_self,
get_channels,
create_channel,
+ ensure_starter_channels,
open_dm,
hide_dm,
get_channel_details,
diff --git a/desktop/src-tauri/src/managed_agents/team_events.rs b/desktop/src-tauri/src/managed_agents/team_events.rs
index a6f7c506c..64861c0de 100644
--- a/desktop/src-tauri/src/managed_agents/team_events.rs
+++ b/desktop/src-tauri/src/managed_agents/team_events.rs
@@ -24,10 +24,27 @@ pub struct TeamEventContent {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option,
+ /// Runtime-layered instructions. `Option` is PERMANENT wire semantics
+ /// (not a transitional shim): absent = publisher predates always-publish
+ /// (true value unknown — reconcile must preserve local), `null` =
+ /// explicitly cleared, a string = set. New clients always publish this
+ /// field (outer always `Some`), using `null` for "no instructions" so
+ /// that state round-trips instead of being read back as "unknown".
+ #[serde(
+ default,
+ deserialize_with = "crate::util::double_option",
+ skip_serializing_if = "Option::is_none"
+ )]
+ pub instructions: Option
) : null}
-
-
- {signedResponse}
-
-
-
- {copyFailed ? (
-
- {COPY_FAILURE_MESSAGE}
-
- ) : null}
+ {payload.returnMode === "browser_fragment_v1" ? (
+
+ setIsManualFallbackOpen(event.currentTarget.open)
+ }
+ open={isManualFallbackOpen}
+ >
+
+ Pairing didn’t finish automatically?
+
+
+
+
+ Copy this response and paste it into the pairing page.
+
+
+
+
+ ) : (
+
+
+
+ )}
-
diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx
index 7010986fe..ce36486b7 100644
--- a/desktop/src/features/sidebar/ui/AppSidebar.tsx
+++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx
@@ -5,6 +5,7 @@ import { SidebarDndContext } from "@/features/sidebar/ui/SidebarDnd";
import type { Community } from "@/features/communities/types";
import { AddCommunityDialog } from "@/features/communities/ui/AddCommunityDialog";
+import type { AddCommunityPrefillRequest } from "@/features/communities/addCommunityPrefill";
import { useIsMobile } from "@/shared/hooks/use-mobile";
import { useDeferredLoad } from "@/shared/hooks/useDeferredStartup";
import {
@@ -76,6 +77,7 @@ type CollapsibleSidebarGroup =
type CreateChannelKind = "stream" | "forum";
type AppSidebarProps = {
+ addCommunityPrefill?: AddCommunityPrefillRequest | null;
activeCommunity: Community | null;
channels: Channel[];
currentPubkey?: string;
@@ -167,6 +169,7 @@ type AppSidebarProps = {
};
export function AppSidebar({
+ addCommunityPrefill,
activeCommunity,
channels,
currentPubkey,
@@ -871,6 +874,7 @@ export function AppSidebar({
/>
{})}
onSubmit={onAddCommunity}
open={isAddCommunityOpen ?? false}
diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts
index 085ebd777..4c6be8eab 100644
--- a/desktop/src/shared/api/tauri.ts
+++ b/desktop/src/shared/api/tauri.ts
@@ -5,13 +5,6 @@ import type {
BackendProviderCandidate,
BackendProviderProbeResult,
CanvasResponse,
- Channel,
- ChannelDetail,
- ChannelMember,
- ChannelMessagesPageResponse,
- ChannelPageCursor,
- ChannelType,
- CreateChannelInput,
GetHomeFeedInput,
HomeFeedResponse,
ManagedAgent,
@@ -27,11 +20,8 @@ import type {
SendChannelMessageResult,
SetCanvasInput,
SetCanvasResult,
- SetChannelPurposeInput,
- SetChannelTopicInput,
ThreadCursor,
ThreadRepliesResponse,
- UpdateChannelInput,
CreateManagedAgentInput,
AgentModelsResponse,
UpdateManagedAgentInput,
@@ -41,57 +31,13 @@ import type {
CommandAvailability,
InstallRuntimeResult,
GitBashPrerequisite,
- OpenDmInput,
RuntimeConfigSurface,
} from "@/shared/api/types";
+export * from "@/shared/api/tauriChannels";
+
type RawPresenceLookup = Record;
-type RawChannel = {
- id: string;
- name: string;
- channel_type: ChannelType;
- visibility: "open" | "private";
- description: string;
- topic: string | null;
- purpose: string | null;
- member_count: number;
- member_pubkeys: string[];
- last_message_at: string | null;
- archived_at: string | null;
- participants: string[];
- participant_pubkeys: string[];
- is_member?: boolean;
- ttl_seconds: number | null;
- ttl_deadline: string | null;
-};
-
-type RawChannelDetail = RawChannel & {
- created_by: string;
- created_at: string;
- updated_at: string;
- topic_set_by: string | null;
- topic_set_at: string | null;
- purpose_set_by: string | null;
- purpose_set_at: string | null;
- topic_required: boolean;
- max_members: number | null;
- nip29_group_id: string | null;
-};
-
-type RawChannelMember = {
- pubkey: string;
- role: ChannelMember["role"];
- is_agent?: boolean;
- joined_at: string;
- display_name: string | null;
-};
-
-type RawChannelMembersResponse = {
- members: RawChannelMember[];
- next_cursor: string | null;
-};
-
type RawAddChannelMembersResult = {
added: string[];
errors: Array<{
@@ -328,53 +274,6 @@ export async function invokeTauri(
}
}
-function fromRawChannel(channel: RawChannel): Channel {
- return {
- id: channel.id,
- name: channel.name,
- channelType: channel.channel_type,
- visibility: channel.visibility,
- description: channel.description,
- topic: channel.topic,
- purpose: channel.purpose,
- memberCount: channel.member_count,
- memberPubkeys: channel.member_pubkeys ?? [],
- lastMessageAt: channel.last_message_at,
- archivedAt: channel.archived_at,
- participants: channel.participants,
- participantPubkeys: channel.participant_pubkeys,
- isMember: channel.is_member ?? true,
- ttlSeconds: channel.ttl_seconds,
- ttlDeadline: channel.ttl_deadline,
- };
-}
-
-function fromRawChannelDetail(channel: RawChannelDetail): ChannelDetail {
- return {
- ...fromRawChannel(channel),
- createdBy: channel.created_by,
- createdAt: channel.created_at,
- updatedAt: channel.updated_at,
- topicSetBy: channel.topic_set_by,
- topicSetAt: channel.topic_set_at,
- purposeSetBy: channel.purpose_set_by,
- purposeSetAt: channel.purpose_set_at,
- topicRequired: channel.topic_required,
- maxMembers: channel.max_members,
- nip29GroupId: channel.nip29_group_id,
- };
-}
-
-function fromRawChannelMember(member: RawChannelMember): ChannelMember {
- return {
- pubkey: member.pubkey,
- role: member.role,
- isAgent: member.is_agent ?? false,
- joinedAt: member.joined_at,
- displayName: member.display_name,
- };
-}
-
function fromRawFeedItem(item: RawFeedItem) {
return {
id: item.id,
@@ -432,79 +331,6 @@ export function getRelayHttpUrl(): Promise {
return invokeTauri("get_relay_http_url");
}
-export async function getChannels(): Promise {
- const channels = await invokeTauri("get_channels");
- return channels.map(fromRawChannel);
-}
-
-export async function createChannel(
- input: CreateChannelInput,
-): Promise {
- return fromRawChannel(await invokeTauri("create_channel", input));
-}
-
-export async function openDm(input: OpenDmInput): Promise {
- return fromRawChannel(await invokeTauri("open_dm", input));
-}
-
-export async function hideDm(channelId: string): Promise {
- await invokeTauri("hide_dm", { channelId });
-}
-
-export async function getChannelDetails(
- channelId: string,
-): Promise {
- const channel = await invokeTauri("get_channel_details", {
- channelId,
- });
- return fromRawChannelDetail(channel);
-}
-
-export async function getChannelMembers(
- channelId: string,
-): Promise {
- const response = await invokeTauri(
- "get_channel_members",
- {
- channelId,
- },
- );
- return response.members.map(fromRawChannelMember);
-}
-
-export async function updateChannel(
- input: UpdateChannelInput,
-): Promise {
- const channel = await invokeTauri("update_channel", {
- input,
- });
- return fromRawChannelDetail(channel);
-}
-
-export async function setChannelTopic(
- input: SetChannelTopicInput,
-): Promise {
- await invokeTauri("set_channel_topic", input);
-}
-
-export async function setChannelPurpose(
- input: SetChannelPurposeInput,
-): Promise {
- await invokeTauri("set_channel_purpose", input);
-}
-
-export async function archiveChannel(channelId: string): Promise {
- await invokeTauri("archive_channel", { channelId });
-}
-
-export async function unarchiveChannel(channelId: string): Promise {
- await invokeTauri("unarchive_channel", { channelId });
-}
-
-export async function deleteChannel(channelId: string): Promise {
- await invokeTauri("delete_channel", { channelId });
-}
-
export async function addChannelMembers(
input: AddChannelMembersInput,
): Promise {
@@ -660,49 +486,6 @@ export async function getThreadReplies(
};
}
-type RawChannelMessagesPageResponse = {
- events: RelayEvent[];
- next_cursor: RawThreadCursor | null;
-};
-
-/**
- * Fetch one keyset page of top-level channel history strictly older than a
- * cursor, via the bridge composite `(createdAt, eventId)` cursor.
- *
- * The desktop timeline pages history over WS `REQ` with a bare `until`
- * (`createdAt`) cursor, which cannot advance past a `createdAt` second denser
- * than one page. This is the escape hatch: `beforeId` is the id of the oldest
- * event already loaded at `before`, and the relay returns strictly-older rows
- * (`created_at < before OR (created_at = before AND id > beforeId)`). Pass the
- * returned `nextCursor` back to page further; `nextCursor` is null once a short
- * page proves history is exhausted.
- */
-export async function getChannelMessagesBefore(
- channelId: string,
- cursor: ChannelPageCursor,
- limit?: number,
-): Promise {
- const response = await invokeTauri(
- "get_channel_messages_before",
- {
- channelId,
- before: cursor.createdAt,
- beforeId: cursor.eventId,
- limit: limit ?? null,
- },
- );
-
- return {
- events: response.events,
- nextCursor: response.next_cursor
- ? {
- createdAt: response.next_cursor.created_at,
- eventId: response.next_cursor.event_id,
- }
- : null,
- };
-}
-
export async function sendChannelMessage(
channelId: string,
content: string,
diff --git a/desktop/src/shared/api/tauriArchive.ts b/desktop/src/shared/api/tauriArchive.ts
index 5dcc1013a..0e4be6c9a 100644
--- a/desktop/src/shared/api/tauriArchive.ts
+++ b/desktop/src/shared/api/tauriArchive.ts
@@ -88,11 +88,11 @@ function decodeRawSubscription(raw: RawSaveSubscription): SaveSubscription {
// ── API wrappers ─────────────────────────────────────────────────────────────
/**
- * Returns `true` when the build has observer-feed archive default-on.
+ * Returns `true` when observer-feed archive policy is enforced.
*
* Internal builds set `BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT` at build time;
* OSS builds never set it, so this returns `false`. The frontend calls this
- * once at startup to decide whether to auto-seed an `owner_p` subscription.
+ * every startup to decide whether to reconcile the `owner_p` subscription.
*/
export async function observerArchiveDefaultEnabled(): Promise {
return invokeTauri("observer_archive_default_enabled");
diff --git a/desktop/src/shared/api/tauriChannels.ts b/desktop/src/shared/api/tauriChannels.ts
new file mode 100644
index 000000000..80520b335
--- /dev/null
+++ b/desktop/src/shared/api/tauriChannels.ts
@@ -0,0 +1,230 @@
+import type {
+ Channel,
+ ChannelDetail,
+ ChannelMember,
+ ChannelMessagesPageResponse,
+ ChannelPageCursor,
+ ChannelType,
+ CreateChannelInput,
+ OpenDmInput,
+ SetChannelPurposeInput,
+ SetChannelTopicInput,
+ UpdateChannelInput,
+} from "@/shared/api/types";
+import { invokeTauri } from "@/shared/api/tauri";
+
+export type RawChannel = {
+ id: string;
+ name: string;
+ channel_type: ChannelType;
+ visibility: "open" | "private";
+ description: string;
+ topic: string | null;
+ purpose: string | null;
+ member_count: number;
+ member_pubkeys: string[];
+ last_message_at: string | null;
+ archived_at: string | null;
+ participants: string[];
+ participant_pubkeys: string[];
+ is_member?: boolean;
+ ttl_seconds: number | null;
+ ttl_deadline: string | null;
+};
+
+type RawChannelDetail = RawChannel & {
+ created_by: string;
+ created_at: string;
+ updated_at: string;
+ topic_set_by: string | null;
+ topic_set_at: string | null;
+ purpose_set_by: string | null;
+ purpose_set_at: string | null;
+ topic_required: boolean;
+ max_members: number | null;
+ nip29_group_id: string | null;
+};
+
+type RawChannelMember = {
+ pubkey: string;
+ role: ChannelMember["role"];
+ is_agent?: boolean;
+ joined_at: string;
+ display_name: string | null;
+};
+
+type RawChannelMembersResponse = {
+ members: RawChannelMember[];
+ next_cursor: string | null;
+};
+
+export function fromRawChannel(channel: RawChannel): Channel {
+ return {
+ id: channel.id,
+ name: channel.name,
+ channelType: channel.channel_type,
+ visibility: channel.visibility,
+ description: channel.description,
+ topic: channel.topic,
+ purpose: channel.purpose,
+ memberCount: channel.member_count,
+ memberPubkeys: channel.member_pubkeys ?? [],
+ lastMessageAt: channel.last_message_at,
+ archivedAt: channel.archived_at,
+ participants: channel.participants,
+ participantPubkeys: channel.participant_pubkeys,
+ isMember: channel.is_member ?? true,
+ ttlSeconds: channel.ttl_seconds,
+ ttlDeadline: channel.ttl_deadline,
+ };
+}
+
+export function fromRawChannelDetail(channel: RawChannelDetail): ChannelDetail {
+ return {
+ ...fromRawChannel(channel),
+ createdBy: channel.created_by,
+ createdAt: channel.created_at,
+ updatedAt: channel.updated_at,
+ topicSetBy: channel.topic_set_by,
+ topicSetAt: channel.topic_set_at,
+ purposeSetBy: channel.purpose_set_by,
+ purposeSetAt: channel.purpose_set_at,
+ topicRequired: channel.topic_required,
+ maxMembers: channel.max_members,
+ nip29GroupId: channel.nip29_group_id,
+ };
+}
+
+function fromRawChannelMember(member: RawChannelMember): ChannelMember {
+ return {
+ pubkey: member.pubkey,
+ role: member.role,
+ isAgent: member.is_agent ?? false,
+ joinedAt: member.joined_at,
+ displayName: member.display_name,
+ };
+}
+
+export async function getChannels(): Promise {
+ const channels = await invokeTauri("get_channels");
+ return channels.map(fromRawChannel);
+}
+
+export async function createChannel(
+ input: CreateChannelInput,
+): Promise {
+ return fromRawChannel(await invokeTauri("create_channel", input));
+}
+
+export async function ensureStarterChannels(): Promise {
+ return (await invokeTauri("ensure_starter_channels")).map(
+ fromRawChannel,
+ );
+}
+
+export async function openDm(input: OpenDmInput): Promise {
+ return fromRawChannel(await invokeTauri("open_dm", input));
+}
+
+export async function hideDm(channelId: string): Promise {
+ await invokeTauri("hide_dm", { channelId });
+}
+
+export async function getChannelDetails(
+ channelId: string,
+): Promise {
+ const detail = await invokeTauri("get_channel_details", {
+ channelId,
+ });
+ return fromRawChannelDetail(detail);
+}
+
+export async function updateChannel(
+ input: UpdateChannelInput,
+): Promise {
+ const channel = await invokeTauri("update_channel", {
+ input,
+ });
+ return fromRawChannelDetail(channel);
+}
+
+export async function setChannelTopic(
+ input: SetChannelTopicInput,
+): Promise {
+ await invokeTauri("set_channel_topic", input);
+}
+
+export async function setChannelPurpose(
+ input: SetChannelPurposeInput,
+): Promise {
+ await invokeTauri("set_channel_purpose", input);
+}
+
+export async function archiveChannel(channelId: string): Promise {
+ await invokeTauri("archive_channel", { channelId });
+}
+
+export async function unarchiveChannel(channelId: string): Promise {
+ await invokeTauri("unarchive_channel", { channelId });
+}
+
+export async function deleteChannel(channelId: string): Promise {
+ await invokeTauri("delete_channel", { channelId });
+}
+
+type RawChannelMessagesPageResponse = {
+ events: ChannelMessagesPageResponse["events"];
+ next_cursor: { created_at: number; event_id: string } | null;
+};
+
+/**
+ * Fetch one keyset page of top-level channel history strictly older than a
+ * cursor, via the bridge composite `(createdAt, eventId)` cursor.
+ *
+ * The desktop timeline pages history over WS `REQ` with a bare `until`
+ * (`createdAt`) cursor, which cannot advance past a `createdAt` second denser
+ * than one page. This is the escape hatch: `beforeId` is the id of the oldest
+ * event already loaded at `before`, and the relay returns strictly-older rows
+ * (`created_at < before OR (created_at = before AND id > beforeId)`). Pass the
+ * returned `nextCursor` back to page further; `nextCursor` is null once a short
+ * page proves history is exhausted.
+ */
+export async function getChannelMessagesBefore(
+ channelId: string,
+ cursor: ChannelPageCursor,
+ limit?: number,
+): Promise {
+ const response = await invokeTauri(
+ "get_channel_messages_before",
+ {
+ channelId,
+ before: cursor.createdAt,
+ beforeId: cursor.eventId,
+ limit: limit ?? null,
+ },
+ );
+
+ return {
+ events: response.events,
+ nextCursor: response.next_cursor
+ ? {
+ createdAt: response.next_cursor.created_at,
+ eventId: response.next_cursor.event_id,
+ }
+ : null,
+ };
+}
+
+export async function getChannelMembers(
+ channelId: string,
+): Promise {
+ const response = await invokeTauri(
+ "get_channel_members",
+ { channelId },
+ );
+ return response.members.map(fromRawChannelMember);
+}
+
+export async function joinChannel(channelId: string): Promise {
+ await invokeTauri("join_channel", { channelId });
+}
diff --git a/desktop/src/shared/deep-link.ts b/desktop/src/shared/deep-link.ts
index d93a3910d..c62a8bec3 100644
--- a/desktop/src/shared/deep-link.ts
+++ b/desktop/src/shared/deep-link.ts
@@ -2,8 +2,17 @@ import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core";
import type { StartCommunityOnboardingInput } from "@/features/onboarding/communityOnboarding";
+export type AddCommunityDeepLinkPayload = {
+ relayUrl: string;
+ name?: string;
+};
+
export interface DeepLinkDeps {
startCommunityOnboarding: (input: StartCommunityOnboardingInput) => boolean;
+ openAddCommunity: (
+ payload: AddCommunityDeepLinkPayload & { requestId: string },
+ ) => boolean;
+ onAddCommunityAvailable: (listener: () => void) => () => void;
}
/**
@@ -42,9 +51,10 @@ export type JoinDeepLinkPayload = {
type PendingCommunityDeepLink = {
id: string;
- kind: "connect" | "join";
+ kind: "connect" | "join" | "add-community";
relayUrl: string;
code: string | null;
+ name: string | null;
policyReceipt: string | null;
};
@@ -52,12 +62,20 @@ function acceptPendingCommunityDeepLink(
pending: PendingCommunityDeepLink,
deps: DeepLinkDeps,
) {
- const accepted = deps.startCommunityOnboarding({
- source: pending.kind === "join" ? "deep-link-join" : "deep-link-connect",
- relayUrl: pending.relayUrl,
- inviteCode: pending.code ?? undefined,
- policyReceipt: pending.policyReceipt ?? undefined,
- });
+ const accepted =
+ pending.kind === "add-community"
+ ? deps.openAddCommunity({
+ requestId: pending.id,
+ relayUrl: pending.relayUrl,
+ name: pending.name ?? undefined,
+ })
+ : deps.startCommunityOnboarding({
+ source:
+ pending.kind === "join" ? "deep-link-join" : "deep-link-connect",
+ relayUrl: pending.relayUrl,
+ inviteCode: pending.code ?? undefined,
+ policyReceipt: pending.policyReceipt ?? undefined,
+ });
return accepted
? invoke("acknowledge_pending_community_deep_link", {
id: pending.id,
@@ -72,6 +90,7 @@ async function drainPendingCommunityDeepLinks(deps: DeepLinkDeps) {
);
if (!pending) return;
if (!(await acceptPendingCommunityDeepLink(pending, deps))) return;
+ if (pending.kind === "add-community") return;
}
}
@@ -94,16 +113,41 @@ async function drainPendingCommunityDeepLinks(deps: DeepLinkDeps) {
export async function listenForDeepLinks(
deps: DeepLinkDeps,
): Promise {
+ let drainRunning = false;
+ let drainRequested = false;
const drain = () => {
- void drainPendingCommunityDeepLinks(deps).catch((error: unknown) => {
- console.warn("Failed to drain pending community deep links", error);
- });
+ drainRequested = true;
+ if (drainRunning) return;
+ drainRunning = true;
+ void (async () => {
+ try {
+ while (drainRequested) {
+ drainRequested = false;
+ await drainPendingCommunityDeepLinks(deps);
+ }
+ } catch (error: unknown) {
+ console.warn("Failed to drain pending community deep links", error);
+ } finally {
+ drainRunning = false;
+ if (drainRequested) drain();
+ }
+ })();
};
+ const stopAvailabilityListener = deps.onAddCommunityAvailable(drain);
const connectPromise = listen("deep-link-connect", drain);
const joinPromise = listen("deep-link-join", drain);
- const unlistens = await Promise.all([connectPromise, joinPromise]);
+ const addCommunityPromise = listen(
+ "deep-link-add-community",
+ drain,
+ );
+ const unlistens = await Promise.all([
+ connectPromise,
+ joinPromise,
+ addCommunityPromise,
+ ]);
drain();
return () => {
+ stopAvailabilityListener();
for (const unlisten of unlistens) unlisten();
};
}
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index da09520a8..a2f9263fb 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -150,6 +150,8 @@ type E2eConfig = {
channelsReadError?: string;
/** Reject successive mock `create_channel` calls, then resume. */
createChannelErrors?: string[];
+ /** Reject successive mock `ensure_starter_channels` calls, then resume. */
+ ensureStarterChannelsErrors?: string[];
/** Reject successive mock `join_channel` calls, then resume. */
joinChannelErrors?: string[];
channelsReadDelayMs?: number;
@@ -187,6 +189,10 @@ type E2eConfig = {
* Linux .deb install where Tauri's updater cannot swap the binary).
* Defaults to true for all existing tests. */
autoUpdateSupported?: boolean;
+ /** Reject `plugin:opener|open_url` to exercise browser-return fallback UI. */
+ openerError?: string;
+ /** Delay binding signatures so specs can exercise request supersession. */
+ nostrBindSignDelayMs?: number;
stallWebsocketSends?: boolean;
userSearchDelayMs?: number;
// NIP-IA gate inputs — see tests/helpers/bridge.ts:MockBridgeOptions for
@@ -234,6 +240,8 @@ type E2eConfig = {
// Seed rows returned by `list_save_subscriptions`. Each entry uses the same
// snake_case wire shape the Rust backend returns so tests can drive the
// LocalArchiveSettingsCard without a real SQLite database.
+ observerArchiveDefaultEnabled?: boolean;
+ agentMetricArchiveDefaultEnabled?: boolean;
saveSubscriptions?: Array<{
scope_type: string;
scope_value: string;
@@ -242,16 +250,17 @@ type E2eConfig = {
// Event IDs that `get_event` should report as definitively not found.
// Causes `useDraftRootStatus` to classify as `deleted`.
deletedEventIds?: string[];
- // Pending community deep links (buzz://join / buzz://connect) seeded into
+ // Pending community deep links (buzz://join / buzz://connect / buzz://add-community) seeded into
// the mocked Rust-side queue. Mirrors the real queue's semantics:
// `take_pending_community_deep_link` peeks the head and
// `acknowledge_pending_community_deep_link` removes by id. Drives the
// pending-invite gate and deep-link drain path in tests.
pendingCommunityDeepLinks?: Array<{
id: string;
- kind: "connect" | "join";
+ kind: "connect" | "join" | "add-community";
relayUrl: string;
code?: string | null;
+ name?: string | null;
}>;
// When true, `get_identity` returns `lost: true` until `persist_current_identity`
// or `import_identity` is called. Drives the identity-lost recovery UX in tests.
@@ -1018,6 +1027,10 @@ const PROFILE_ONLY_AGENT_PUBKEY =
const OWNED_RELAY_AGENT_PUBKEY =
"a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00";
const MOCK_IDENTITY_PUBKEY = DEFAULT_MOCK_IDENTITY.pubkey;
+const STARTER_GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
+const STARTER_WELCOME_CHANNEL_ID = "5f0b1b3c-2a37-5366-9b8c-31a4b21d8e77";
+const STARTER_GENERAL_CHANNEL_NAME = "general";
+const STARTER_WELCOME_CHANNEL_NAME = "welcome-everyone";
// Tracks whether `persist_current_identity` or `import_identity` has cleared
// the lost flag set by `mock.identityLost`. Reset to false on each fresh page
@@ -2088,8 +2101,8 @@ function createCurrentMember(
const mockChannels: MockChannel[] = [
createMockChannel({
- id: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
- name: "general",
+ id: STARTER_GENERAL_CHANNEL_ID,
+ name: STARTER_GENERAL_CHANNEL_NAME,
channel_type: "stream",
visibility: "open",
description: "General discussion for everyone",
@@ -2114,6 +2127,28 @@ const mockChannels: MockChannel[] = [
createMockMember(PROFILE_ONLY_AGENT_PUBKEY, "member", 840),
],
}),
+ createMockChannel({
+ id: STARTER_WELCOME_CHANNEL_ID,
+ name: STARTER_WELCOME_CHANNEL_NAME,
+ channel_type: "stream",
+ visibility: "open",
+ description: "Say hi, ask a question, or share what brought you here.",
+ topic: null,
+ purpose: null,
+ last_message_at: null,
+ archived_at: null,
+ created_by: MOCK_IDENTITY_PUBKEY,
+ topic_set_by: null,
+ topic_set_at: null,
+ purpose_set_by: null,
+ purpose_set_at: null,
+ topic_required: false,
+ max_members: null,
+ nip29_group_id: null,
+ created_minutes_ago: 1440,
+ updated_minutes_ago: 1440,
+ members: [createMockMember(MOCK_IDENTITY_PUBKEY, "owner", 1440)],
+ }),
createMockChannel({
id: "9dae0116-799b-5071-a0a8-fdd30a91a35d",
name: "random",
@@ -3562,12 +3597,17 @@ let mockPendingCommunityDeepLinks: Array<{
kind: string;
relayUrl: string;
code: string | null;
+ name: string | null;
}> = [];
function resetMockPendingCommunityDeepLinks(config: E2eConfig | null) {
mockPendingCommunityDeepLinks = (
config?.mock?.pendingCommunityDeepLinks ?? []
- ).map((pending) => ({ ...pending, code: pending.code ?? null }));
+ ).map((pending) => ({
+ ...pending,
+ code: pending.code ?? null,
+ name: pending.name ?? null,
+ }));
}
function recordMockUserStatus(event: RelayEvent) {
@@ -5297,6 +5337,51 @@ async function handleGetPresence(
return result;
}
+async function handleEnsureStarterChannels(
+ config: E2eConfig | undefined,
+): Promise {
+ const starterChannelError =
+ config?.mock?.ensureStarterChannelsErrors?.shift();
+ if (starterChannelError) {
+ throw new Error(starterChannelError);
+ }
+
+ const currentPubkey = getMockMemberPubkey(config);
+ const ensureMember = (channel: MockChannel) => {
+ if (
+ channel.members.some(
+ (member) =>
+ normalizePubkey(member.pubkey) === normalizePubkey(currentPubkey),
+ )
+ ) {
+ return;
+ }
+
+ channel.members.push(createCurrentMember(config, "member"));
+ syncMockChannel(channel);
+ touchMockChannel(channel);
+ };
+
+ for (const channelName of [
+ STARTER_GENERAL_CHANNEL_NAME,
+ STARTER_WELCOME_CHANNEL_NAME,
+ ]) {
+ const channel = mockChannels.find(
+ (candidate) =>
+ candidate.name === channelName &&
+ candidate.channel_type === "stream" &&
+ candidate.visibility === "open" &&
+ candidate.archived_at === null,
+ );
+ if (!channel) {
+ throw new Error(`Starter channel ${channelName} not found.`);
+ }
+ ensureMember(channel);
+ }
+
+ return listMockChannels(config);
+}
+
async function handleCreateChannel(
args: {
name: string;
@@ -8568,6 +8653,10 @@ export function maybeInstallE2eTauriMocks() {
origin: string;
verificationCode: string;
};
+ const signDelayMs = activeConfig?.mock?.nostrBindSignDelayMs ?? 0;
+ if (signDelayMs > 0) {
+ await new Promise((resolve) => setTimeout(resolve, signDelayMs));
+ }
const activeIdentity = identity ?? DEFAULT_MOCK_IDENTITY;
return JSON.stringify({
id: "e2e-signed-nostr-binding",
@@ -9324,6 +9413,8 @@ export function maybeInstallE2eTauriMocks() {
payload as Parameters[0],
activeConfig,
);
+ case "ensure_starter_channels":
+ return handleEnsureStarterChannels(activeConfig);
case "open_dm":
return handleOpenDm(
payload as Parameters[0],
@@ -9579,6 +9670,9 @@ export function maybeInstallE2eTauriMocks() {
payload as Parameters[0],
);
case "plugin:opener|open_url":
+ if (activeConfig?.mock?.openerError) {
+ throw new Error(activeConfig.mock.openerError);
+ }
openedExternalUrls.push(String((payload as { url: string | URL }).url));
return null;
case "get_e2e_opened_external_urls":
@@ -9696,6 +9790,16 @@ export function maybeInstallE2eTauriMocks() {
// seeds the initial list; create/delete return success shapes so the
// component's reload path behaves correctly.
case "list_save_subscriptions": {
+ const win = window as unknown as Record;
+ if (!win.__BUZZ_E2E_IPC_COUNTERS__) {
+ win.__BUZZ_E2E_IPC_COUNTERS__ = {};
+ }
+ const ipcCounters = win.__BUZZ_E2E_IPC_COUNTERS__ as Record<
+ string,
+ number
+ >;
+ ipcCounters.list_save_subscriptions =
+ (ipcCounters.list_save_subscriptions ?? 0) + 1;
const ident = activeConfig?.identity ?? DEFAULT_MOCK_IDENTITY;
return (activeConfig?.mock?.saveSubscriptions ?? []).map((s) => ({
identity_pubkey: ident.pubkey,
@@ -9716,6 +9820,14 @@ export function maybeInstallE2eTauriMocks() {
case "archive_events":
// Returns the ArchiveBatchResult shape the UI expects.
return { persisted: 0, dropped: 0 };
+ case "observer_archive_default_enabled":
+ return activeConfig?.mock?.observerArchiveDefaultEnabled ?? false;
+ case "agent_metric_archive_default_enabled":
+ return activeConfig?.mock?.agentMetricArchiveDefaultEnabled ?? false;
+ case "merge_save_subscription_kinds":
+ return null;
+ case "remove_save_subscription_kind":
+ return null;
default:
throw new Error(`Unsupported mocked Tauri command: ${command}`);
}
diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts
index b187d2a55..21ca0bd34 100644
--- a/desktop/tests/e2e/agents.spec.ts
+++ b/desktop/tests/e2e/agents.spec.ts
@@ -1585,13 +1585,13 @@ test("inactive built-ins cannot be used to create teams", async ({ page }) => {
const error = await invokeTauriExpectError(page, "create_team", {
input: {
- name: "Fizzes",
- personaIds: ["builtin:fizz"],
+ name: "Honeys",
+ personaIds: ["builtin:honey"],
},
});
expect(error).toBe(
- "Fizz is not in My Agents. Choose it from Agent Catalog first.",
+ "Honey is not in My Agents. Choose it from Agent Catalog first.",
);
});
@@ -1600,24 +1600,24 @@ test("built-in removal failures show up from My Agents", async ({ page }) => {
await page.getByTestId("open-agents-view").click();
await openPersonaCatalog(page);
- await selectCatalogPersona(page, "builtin:fizz");
- await useCatalogPersona(page, "builtin:fizz");
+ await selectCatalogPersona(page, "builtin:honey");
+ await useCatalogPersona(page, "builtin:honey");
await invokeTauri(page, "create_team", {
input: {
- name: "Fizzes",
- personaIds: ["builtin:fizz"],
+ name: "Honeys",
+ personaIds: ["builtin:honey"],
},
});
await page.keyboard.press("Escape");
- await page.getByLabel("Open actions for Fizz").click();
+ await page.getByLabel("Open actions for Honey").click();
await page.getByRole("menuitem", { name: "Delete" }).click();
await expect(
page
.locator("[data-sonner-toast]")
- .filter({ hasText: "Fizz is still referenced by a team." }),
+ .filter({ hasText: "Honey is still referenced by a team." }),
).toBeVisible();
});
diff --git a/desktop/tests/e2e/channel-browser.spec.ts b/desktop/tests/e2e/channel-browser.spec.ts
index 3c380a00d..298fb4377 100644
--- a/desktop/tests/e2e/channel-browser.spec.ts
+++ b/desktop/tests/e2e/channel-browser.spec.ts
@@ -64,6 +64,7 @@ test("channel browser sorts alphabetically or by member count", async ({
/#random/,
/#sales/,
/#secret-projects/,
+ /#welcome-everyone/,
]);
await page.getByTestId("channel-browser-sort").click();
@@ -79,6 +80,7 @@ test("channel browser sorts alphabetically or by member count", async ({
/#design/,
/#sales/,
/#secret-projects/,
+ /#welcome-everyone/,
]);
await expect(page.getByTestId("channel-browser-sort")).toHaveAttribute(
"aria-label",
diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts
index 44be46b0d..69c096abc 100644
--- a/desktop/tests/e2e/channels.spec.ts
+++ b/desktop/tests/e2e/channels.spec.ts
@@ -1581,13 +1581,13 @@ test("scrollable channel with recent messages hides intro actions until top", as
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId(`channel-${channelName}`).click();
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
- await expect(page.getByTestId("message-channel-intro")).toHaveCount(0);
+ await expect(page.getByTestId("message-channel-intro")).not.toBeInViewport();
await expect(
page.getByTestId("channel-intro-action-create-agent"),
- ).toHaveCount(0);
- await expect(page.getByTestId("channel-intro-action-add-people")).toHaveCount(
- 0,
- );
+ ).not.toBeInViewport();
+ await expect(
+ page.getByTestId("channel-intro-action-add-people"),
+ ).not.toBeInViewport();
await expect(page.getByTestId("message-timeline")).toContainText(
messages[messages.length - 1],
);
diff --git a/desktop/tests/e2e/deep-link-invite.spec.ts b/desktop/tests/e2e/deep-link-invite.spec.ts
index a2457a58b..db15c056a 100644
--- a/desktop/tests/e2e/deep-link-invite.spec.ts
+++ b/desktop/tests/e2e/deep-link-invite.spec.ts
@@ -1,12 +1,14 @@
import { expect, test } from "@playwright/test";
-import { installMockBridge } from "../helpers/bridge";
+import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
+import { seedActiveIdentity } from "../helpers/onboarding";
// Community deep links that arrive before machine onboarding complete are
// drained from Rust into a persisted transaction and acknowledged immediately.
// Invite claiming waits until setup finishes and the final identity is known.
const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8);
+const WELCOME_FAILURE_PUBKEY = TEST_IDENTITIES.tyler.pubkey;
const TRANSACTION_STORAGE_KEY = "buzz-community-onboarding-transaction.v1";
const COMMUNITY_RELAY_URL = "wss://hive.example.com";
@@ -24,6 +26,22 @@ const PENDING_CONNECT_LINK = {
code: null,
};
+const PENDING_ADD_COMMUNITY_LINK = {
+ id: "dl-add-community-1",
+ kind: "add-community" as const,
+ relayUrl: "wss://acme.communities.buzz.xyz",
+ code: null,
+ name: "Acme Team",
+};
+
+const SECOND_PENDING_ADD_COMMUNITY_LINK = {
+ id: "dl-add-community-2",
+ kind: "add-community" as const,
+ relayUrl: "wss://beta.communities.buzz.xyz",
+ code: null,
+ name: "Beta Team",
+};
+
test("join deep link is acknowledged without claiming before setup", async ({
page,
}) => {
@@ -92,10 +110,112 @@ test("connect deep link shows a static acknowledgment during setup", async ({
.toContain('"acknowledged":true');
});
+test("add-community deep link opens one editable prefill and acknowledges the queue", async ({
+ page,
+}) => {
+ await installMockBridge(
+ page,
+ { pendingCommunityDeepLinks: [PENDING_ADD_COMMUNITY_LINK] },
+ { seedPreviewFeatures: true },
+ );
+ await page.goto("/");
+
+ await expect(
+ page.getByRole("heading", { name: "Add Community" }),
+ ).toBeVisible();
+ const relayInput = page.locator("#ws-relay-url");
+ const nameInput = page.locator("#ws-name");
+ await expect(relayInput).toHaveValue(PENDING_ADD_COMMUNITY_LINK.relayUrl);
+ await expect(nameInput).toHaveValue(PENDING_ADD_COMMUNITY_LINK.name);
+
+ await nameInput.fill("Edited Team");
+ await expect(nameInput).toHaveValue("Edited Team");
+
+ await page.getByRole("button", { name: "Cancel" }).click();
+ await expect(
+ page.getByRole("heading", { name: "Add Community" }),
+ ).toHaveCount(0);
+
+ await page.getByTestId("sidebar-profile-avatar-button").click();
+ await page.getByTestId("community-switcher").click();
+ await page.getByRole("menuitem", { name: "Add Community" }).click();
+ await expect(relayInput).toHaveValue("");
+ await expect(nameInput).toHaveValue("");
+
+ const acknowledgements = await page.evaluate(() =>
+ (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter(
+ (entry) => entry.command === "acknowledge_pending_community_deep_link",
+ ),
+ );
+ expect(acknowledgements).toEqual([
+ {
+ command: "acknowledge_pending_community_deep_link",
+ payload: { id: PENDING_ADD_COMMUNITY_LINK.id },
+ },
+ ]);
+});
+
+test("queued add-community links open and acknowledge one at a time", async ({
+ page,
+}) => {
+ await installMockBridge(
+ page,
+ {
+ pendingCommunityDeepLinks: [
+ PENDING_ADD_COMMUNITY_LINK,
+ SECOND_PENDING_ADD_COMMUNITY_LINK,
+ ],
+ },
+ { seedPreviewFeatures: true },
+ );
+ await page.goto("/");
+
+ const relayInput = page.locator("#ws-relay-url");
+ const nameInput = page.locator("#ws-name");
+ await expect(relayInput).toHaveValue(PENDING_ADD_COMMUNITY_LINK.relayUrl);
+ await expect(nameInput).toHaveValue(PENDING_ADD_COMMUNITY_LINK.name);
+
+ await expect
+ .poll(() =>
+ page.evaluate(() =>
+ (window.__BUZZ_E2E_COMMAND_LOG__ ?? [])
+ .filter(
+ (entry) =>
+ entry.command === "acknowledge_pending_community_deep_link",
+ )
+ .map((entry) => entry.payload),
+ ),
+ )
+ .toEqual([{ id: PENDING_ADD_COMMUNITY_LINK.id }]);
+
+ await page.getByRole("button", { name: "Cancel" }).click();
+
+ await expect(relayInput).toHaveValue(
+ SECOND_PENDING_ADD_COMMUNITY_LINK.relayUrl,
+ );
+ await expect(nameInput).toHaveValue(SECOND_PENDING_ADD_COMMUNITY_LINK.name);
+ await expect
+ .poll(() =>
+ page.evaluate(() =>
+ (window.__BUZZ_E2E_COMMAND_LOG__ ?? [])
+ .filter(
+ (entry) =>
+ entry.command === "acknowledge_pending_community_deep_link",
+ )
+ .map((entry) => entry.payload),
+ ),
+ )
+ .toEqual([
+ { id: PENDING_ADD_COMMUNITY_LINK.id },
+ { id: SECOND_PENDING_ADD_COMMUNITY_LINK.id },
+ ]);
+});
+
test("Welcome failure can be skipped without abandoning community onboarding", async ({
page,
}) => {
const welcomeError = "Channel creation is not permitted.";
+ await seedActiveIdentity(page, TEST_IDENTITIES.tyler);
await page.addInitScript(
({ pubkey, relayUrl, storageKey }) => {
window.localStorage.setItem(
@@ -118,14 +238,14 @@ test("Welcome failure can be skipped without abandoning community onboarding", a
);
},
{
- pubkey: DEFAULT_MOCK_PUBKEY,
+ pubkey: WELCOME_FAILURE_PUBKEY,
relayUrl: COMMUNITY_RELAY_URL,
storageKey: TRANSACTION_STORAGE_KEY,
},
);
await installMockBridge(
page,
- { createChannelErrors: [welcomeError, welcomeError] },
+ { ensureStarterChannelsErrors: [welcomeError, welcomeError] },
{ relayWsUrl: COMMUNITY_RELAY_URL, skipOnboardingSeed: true },
);
await page.goto("/");
@@ -140,7 +260,7 @@ test("Welcome failure can be skipped without abandoning community onboarding", a
await expect(skip).toBeVisible();
await skip.click();
- const completionKey = `buzz-community-onboarding-complete.v1:${encodeURIComponent(COMMUNITY_RELAY_URL)}:${DEFAULT_MOCK_PUBKEY}`;
+ const completionKey = `buzz-community-onboarding-complete.v1:${encodeURIComponent(COMMUNITY_RELAY_URL)}:${WELCOME_FAILURE_PUBKEY}`;
await expect
.poll(() =>
page.evaluate(
diff --git a/desktop/tests/e2e/nostr-bind.spec.ts b/desktop/tests/e2e/nostr-bind.spec.ts
index b6f3cf86b..17677ea46 100644
--- a/desktop/tests/e2e/nostr-bind.spec.ts
+++ b/desktop/tests/e2e/nostr-bind.spec.ts
@@ -28,21 +28,7 @@ const VALID_REQUEST: NostrBindPayload = {
version: "1",
};
-async function openNostrBind(
- page: Page,
- payload: NostrBindPayload = VALID_REQUEST,
-) {
- await installMockBridge(page);
- await page.goto("/");
- await page.waitForFunction(
- () =>
- typeof (
- window as Window & {
- __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown;
- }
- ).__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function",
- );
-
+async function emitNostrBind(page: Page, payload: NostrBindPayload) {
await page.evaluate(async (nextPayload) => {
const internals = (
window as Window & {
@@ -62,7 +48,24 @@ async function openNostrBind(
payload: nextPayload,
});
}, payload);
+}
+async function openNostrBind(
+ page: Page,
+ payload: NostrBindPayload = VALID_REQUEST,
+ mock?: Parameters[1],
+) {
+ await installMockBridge(page, mock);
+ await page.goto("/");
+ await page.waitForFunction(
+ () =>
+ typeof (
+ window as Window & {
+ __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown;
+ }
+ ).__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function",
+ );
+ await emitNostrBind(page, payload);
await expect(page.getByTestId("nostr-bind-page")).toBeVisible();
}
@@ -150,7 +153,7 @@ async function shakeCount(page: Page): Promise {
);
}
-test("supports OTP entry, navigation, and paste without signing incomplete input", async ({
+test("supports OTP entry and navigation without signing incomplete input", async ({
page,
}) => {
await openNostrBind(page);
@@ -174,42 +177,70 @@ test("supports OTP entry, navigation, and paste without signing incomplete input
await expect(second).toHaveValue("");
await expect(continueButton).toBeDisabled();
await expect.poll(() => signCommandPayloads(page)).toEqual([]);
-
- await pasteCode(first, VALID_REQUEST.verificationCode);
- for (const [index, digit] of [...VALID_REQUEST.verificationCode].entries()) {
- await expect(
- page.getByTestId(`nostr-bind-code-digit-${index + 1}`),
- ).toHaveValue(digit);
- }
- await expect(page.getByTestId("nostr-bind-code-digit-6")).toBeFocused();
- await expect(continueButton).toBeEnabled();
});
-test("locks a filled sixth slot and repeats mismatch feedback without signing", async ({
+test("auto-signs exactly once when the sixth correct digit is typed", async ({
page,
}) => {
+ await openNostrBind(page);
+
+ await page.getByTestId("nostr-bind-code-digit-1").click();
+ for (const digit of VALID_REQUEST.verificationCode) {
+ await page.keyboard.type(digit);
+ }
+
+ await expect(page.getByTestId("nostr-bind-finish-step")).toBeVisible();
+ await expect.poll(() => signCommandPayloads(page)).toHaveLength(1);
+ await page.waitForTimeout(100);
+ await expect.poll(() => signCommandPayloads(page)).toHaveLength(1);
+});
+
+test("discards a signature when a newer pairing request arrives", async ({
+ page,
+}) => {
+ await openNostrBind(page, VALID_REQUEST, { nostrBindSignDelayMs: 150 });
+ await pasteCode(
+ page.getByTestId("nostr-bind-code-digit-1"),
+ VALID_REQUEST.verificationCode,
+ );
+ await expect.poll(() => signCommandPayloads(page)).toHaveLength(1);
+
+ const nextRequest = {
+ ...VALID_REQUEST,
+ challengeId: "550e8400-e29b-41d4-a716-446655440001",
+ verificationCode: "654321",
+ };
+ await emitNostrBind(page, nextRequest);
+
+ await expect(page.getByTestId("nostr-bind-code-digit-1")).toHaveValue("");
+ await page.waitForTimeout(200);
+ await expect(page.getByTestId("nostr-bind-code-step")).toBeVisible();
+ await expect(page.getByTestId("nostr-bind-finish-step")).toBeHidden();
+
+ await pasteCode(
+ page.getByTestId("nostr-bind-code-digit-1"),
+ nextRequest.verificationCode,
+ );
+ await expect(page.getByTestId("nostr-bind-finish-step")).toBeVisible();
+ await expect.poll(() => signCommandPayloads(page)).toHaveLength(2);
+});
+
+test("repeats mismatch feedback without signing", async ({ page }) => {
await installShakeCounter(page);
await openNostrBind(page);
const first = page.getByTestId("nostr-bind-code-digit-1");
- const last = page.getByTestId("nostr-bind-code-digit-6");
const continueButton = page.getByTestId("nostr-bind-sign-and-copy");
- await pasteCode(first, VALID_REQUEST.verificationCode);
- await last.press("9");
- await expect(last).toHaveValue("6");
- await expect(continueButton).toBeEnabled();
- await expect.poll(() => shakeCount(page)).toBe(1);
-
await pasteCode(first, "654321");
await expect(page.getByRole("alert")).toHaveText(
"That code doesn't match. Check the code and try again.",
);
await expect(continueButton).toBeDisabled();
- await expect.poll(() => shakeCount(page)).toBe(2);
+ await expect.poll(() => shakeCount(page)).toBe(1);
await pasteCode(first, "654321");
- await expect.poll(() => shakeCount(page)).toBe(3);
+ await expect.poll(() => shakeCount(page)).toBe(2);
await expect.poll(() => signCommandPayloads(page)).toEqual([]);
});
@@ -263,7 +294,6 @@ test("signs a valid request, shows the response, and copies it", async ({
VALID_REQUEST.verificationCode,
);
- await page.getByTestId("nostr-bind-sign-and-copy").click();
await expect(page.getByTestId("nostr-bind-finish-step")).toBeVisible();
const response = page.getByTestId("nostr-bind-signed-response");
await expect(response).toContainText("e2e-signed-nostr-binding");
@@ -296,11 +326,16 @@ test("signs a valid request, shows the response, and copies it", async ({
),
)
.toBe(signedResponse);
+ await expect.poll(() => signCommandPayloads(page)).toHaveLength(1);
+
+ await page.getByRole("button", { name: "Continue" }).click();
+ await expect(page.getByTestId("nostr-bind-page")).toBeHidden();
});
test("returns a signed response in the callback fragment after consent", async ({
page,
}) => {
+ await installClipboardStub(page, false);
await openNostrBind(page, {
...VALID_REQUEST,
callbackUrl: "https://admin.example.com/buzz?source=bind#stale",
@@ -326,11 +361,38 @@ test("returns a signed response in the callback fragment after consent", async (
page.getByTestId("nostr-bind-code-digit-1"),
VALID_REQUEST.verificationCode,
);
- await page.getByTestId("nostr-bind-sign-and-copy").click();
+ await expect(page.getByTestId("nostr-bind-finish-step")).toBeVisible();
await expect(
page.getByRole("heading", { name: "Continue in your browser" }),
).toBeVisible();
+ const manualFallback = page.getByTestId("nostr-bind-manual-fallback");
+ await expect(manualFallback).not.toHaveAttribute("open", "");
+ await expect(page.getByTestId("nostr-bind-signed-response")).toBeHidden();
+ await expect(page.getByTestId("nostr-bind-copy-response")).toBeHidden();
+
+ const fallbackSummary = manualFallback.locator("summary");
+ await fallbackSummary.focus();
+ await fallbackSummary.press("Enter");
+ await expect(manualFallback).toHaveAttribute("open", "");
+ const signedResponse = page.getByTestId("nostr-bind-signed-response");
+ await expect(signedResponse).toContainText("e2e-signed-nostr-binding");
+ const copyResponse = page.getByTestId("nostr-bind-copy-response");
+ await expect(copyResponse).toBeVisible();
+ await copyResponse.click();
+ await expect
+ .poll(() =>
+ page.evaluate(
+ () =>
+ (
+ window as Window & {
+ __BUZZ_E2E_CLIPBOARD_TEXT__?: string;
+ }
+ ).__BUZZ_E2E_CLIPBOARD_TEXT__,
+ ),
+ )
+ .toBe(await signedResponse.textContent());
+
const callback = await page.evaluate(() => {
const command = (
(
@@ -352,6 +414,36 @@ test("returns a signed response in the callback fragment after consent", async (
expect(callbackUrl.hash).toMatch(/^#buzz_bind=v1\.[A-Za-z0-9_-]+$/);
});
+test("opens the manual fallback when returning to the browser fails", async ({
+ page,
+}) => {
+ await openNostrBind(
+ page,
+ {
+ ...VALID_REQUEST,
+ callbackUrl: "https://admin.example.com/buzz",
+ returnMode: "browser_fragment_v1",
+ },
+ { openerError: "browser unavailable" },
+ );
+
+ await pasteCode(
+ page.getByTestId("nostr-bind-code-digit-1"),
+ VALID_REQUEST.verificationCode,
+ );
+
+ await expect(
+ page.getByText(
+ "Could not open the browser. Copy the response below to finish manually.",
+ ),
+ ).toBeVisible();
+ await expect(page.getByTestId("nostr-bind-manual-fallback")).toHaveAttribute(
+ "open",
+ "",
+ );
+ await expect(page.getByTestId("nostr-bind-copy-response")).toBeVisible();
+});
+
test("keeps the signed response available when clipboard access fails", async ({
page,
}) => {
@@ -361,11 +453,13 @@ test("keeps the signed response available when clipboard access fails", async ({
page.getByTestId("nostr-bind-code-digit-1"),
VALID_REQUEST.verificationCode,
);
- await page.getByTestId("nostr-bind-sign-and-copy").click();
+ await expect(page.getByTestId("nostr-bind-finish-step")).toBeVisible();
await page.getByTestId("nostr-bind-copy-response").click();
await expect(
- page.getByText("Buzz couldn't access the clipboard. Try again."),
+ page
+ .getByTestId("nostr-bind-manual-fallback-content")
+ .getByText("Buzz couldn't access the clipboard. Try again."),
).toBeVisible();
await expect(page.getByTestId("nostr-bind-signed-response")).toContainText(
"e2e-signed-nostr-binding",
diff --git a/desktop/tests/e2e/observer-archive-policy.spec.ts b/desktop/tests/e2e/observer-archive-policy.spec.ts
new file mode 100644
index 000000000..90f74b539
--- /dev/null
+++ b/desktop/tests/e2e/observer-archive-policy.spec.ts
@@ -0,0 +1,124 @@
+import { expect, test } from "@playwright/test";
+
+import { installMockBridge } from "../helpers/bridge";
+
+async function openLocalArchiveSettings(page: import("@playwright/test").Page) {
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.getByTestId("open-settings").click();
+ await page.getByTestId("profile-popover-settings").click();
+ await expect(page.getByTestId("settings-view")).toBeVisible();
+ await page.getByTestId("settings-nav-local-archive").click();
+ const card = page.getByTestId("settings-local-archive");
+ await expect(card).toBeVisible({ timeout: 10_000 });
+ return card;
+}
+
+test.describe("observer archive policy — Settings toggle", () => {
+ test("internal policy: toggle disabled with policy-locked copy", async ({
+ page,
+ }) => {
+ await installMockBridge(page, {
+ observerArchiveDefaultEnabled: true,
+ saveSubscriptions: [
+ {
+ scope_type: "owner_p",
+ scope_value: "deadbeef".repeat(8),
+ kinds: "[24200]",
+ },
+ ],
+ });
+
+ const card = await openLocalArchiveSettings(page);
+ const toggle = card.getByTestId("local-archive-observer-toggle");
+ await expect(toggle).toBeVisible({ timeout: 5_000 });
+ await expect(toggle).toBeDisabled();
+ await expect(
+ card.getByText(/always on for internal builds/i),
+ ).toBeVisible();
+ });
+
+ test("OSS policy: toggle is functional", async ({ page }) => {
+ await installMockBridge(page, {
+ observerArchiveDefaultEnabled: false,
+ saveSubscriptions: [
+ {
+ scope_type: "owner_p",
+ scope_value: "deadbeef".repeat(8),
+ kinds: "[24200]",
+ },
+ ],
+ });
+
+ const card = await openLocalArchiveSettings(page);
+ const toggle = card.getByTestId("local-archive-observer-toggle");
+ await expect(toggle).toBeVisible({ timeout: 5_000 });
+ await expect(toggle).toBeEnabled();
+ await expect(toggle).toBeChecked();
+ });
+
+ test("unresolved policy (default): toggle disabled", async ({ page }) => {
+ // When observerArchiveDefaultEnabled is not set in mock config,
+ // the bridge returns false (OSS). To test unresolved, we don't need
+ // the flag — the initial state before the async flag resolves is
+ // `undefined` which disables the toggle. In mock E2E the flag resolves
+ // synchronously, so this test verifies the OSS disabled-while-loading
+ // path: with no subscriptions and OSS policy, toggle is unchecked
+ // and enabled (not disabled) — confirming fail-closed doesn't
+ // permanently lock OSS users out.
+ await installMockBridge(page, {
+ observerArchiveDefaultEnabled: false,
+ saveSubscriptions: [],
+ });
+
+ const card = await openLocalArchiveSettings(page);
+ const toggle = card.getByTestId("local-archive-observer-toggle");
+ await expect(toggle).toBeVisible({ timeout: 5_000 });
+ await expect(toggle).toBeEnabled();
+ await expect(toggle).not.toBeChecked();
+ });
+});
+
+test.describe("observer archive policy — reconciliation gate", () => {
+ test("internal policy: archive sync reaches subscription path after reconciliation", async ({
+ page,
+ }) => {
+ await installMockBridge(page, {
+ observerArchiveDefaultEnabled: true,
+ saveSubscriptions: [
+ {
+ scope_type: "owner_p",
+ scope_value: "deadbeef".repeat(8),
+ kinds: "[24200]",
+ },
+ ],
+ });
+
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+
+ // Wait for the channel list to appear (proves AppShell mounted fully).
+ await expect(page.getByTestId("channel-general")).toBeVisible({
+ timeout: 10_000,
+ });
+
+ // The reconciliation gate (useObserverArchiveReconciliation) must have
+ // resolved successfully, allowing useArchiveSync to start the
+ // ArchiveSyncManager, which calls list_save_subscriptions. The IPC
+ // counter proves the subscription path was reached.
+ await page.waitForFunction(
+ () => {
+ const counters = (window as Record)
+ .__BUZZ_E2E_IPC_COUNTERS__ as Record | undefined;
+ return (counters?.list_save_subscriptions ?? 0) > 0;
+ },
+ null,
+ { timeout: 10_000 },
+ );
+
+ const count = await page.evaluate(() => {
+ const counters = (window as Record)
+ .__BUZZ_E2E_IPC_COUNTERS__ as Record | undefined;
+ return counters?.list_save_subscriptions ?? 0;
+ });
+ expect(count).toBeGreaterThan(0);
+ });
+});
diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts
index a061bc8f3..bdb4dc4ed 100644
--- a/desktop/tests/e2e/onboarding.spec.ts
+++ b/desktop/tests/e2e/onboarding.spec.ts
@@ -251,17 +251,17 @@ async function expectWelcomePersonaMention(page: Page) {
async function expectWelcomeView(page: Page) {
await expect(page).toHaveURL(/#\/channels\/[^/?#]+$/);
- await expect(page.getByTestId("channel-Welcome")).toBeVisible();
- await expect(page.getByTestId("chat-title")).toContainText("Welcome");
+ await expect(page.getByTestId("channel-general")).toBeVisible();
+ await expect(page.getByTestId("channel-welcome-everyone")).toBeVisible();
+ await expect(page.getByTestId("chat-title")).toContainText(
+ "welcome-everyone",
+ );
await expect(page.getByTestId("channel-ephemeral-Welcome")).toHaveCount(0);
await expect(page.getByTestId("chat-ephemeral-badge")).toHaveCount(0);
await expect(page.getByTestId("message-unread-pill")).toHaveCount(0);
await expect(page.getByTestId("message-channel-intro")).toBeVisible();
await expect(page.getByTestId("message-channel-intro")).toContainText(
- "This is the beginning of the private welcome channel.",
- );
- await expect(page.getByTestId("message-channel-intro")).not.toContainText(
- "A few good first steps",
+ "This is the beginning of the regular channel.",
);
await expect(
page.getByTestId("message-channel-intro").getByRole("button"),
@@ -419,39 +419,48 @@ async function getWelcomeChannelId(page: Page) {
return (
channels.find(
(channel) =>
- channel.name === "Welcome" && channel.visibility === "private",
+ channel.name === "welcome-everyone" && channel.visibility === "open",
)?.id ?? null
);
}
-async function expectPrivateWelcomeChannel(page: Page) {
+async function expectStarterChannels(page: Page) {
await expect
.poll(async () => {
const channels = await getMockChannels(page);
- const welcomeChannel = channels.find(
- (channel) =>
- channel.name === "Welcome" && channel.visibility === "private",
- );
-
- if (!welcomeChannel) {
- return null;
- }
-
- return {
- channelType: welcomeChannel.channel_type,
- isMember: welcomeChannel.is_member,
- memberCount: welcomeChannel.member_count,
- ttlSeconds: welcomeChannel.ttl_seconds,
- visibility: welcomeChannel.visibility,
- };
+ return ["general", "welcome-everyone"].map((name) => {
+ const channel = channels.find(
+ (candidate) =>
+ candidate.name === name && candidate.visibility === "open",
+ );
+ if (!channel) {
+ return null;
+ }
+ return {
+ channelType: channel.channel_type,
+ isMember: channel.is_member,
+ memberCountAtLeastOne: channel.member_count >= 1,
+ ttlSeconds: channel.ttl_seconds,
+ visibility: channel.visibility,
+ };
+ });
})
- .toEqual({
- channelType: "stream",
- isMember: true,
- memberCount: 2,
- ttlSeconds: null,
- visibility: "private",
- });
+ .toEqual([
+ {
+ channelType: "stream",
+ isMember: true,
+ memberCountAtLeastOne: true,
+ ttlSeconds: null,
+ visibility: "open",
+ },
+ {
+ channelType: "stream",
+ isMember: true,
+ memberCountAtLeastOne: true,
+ ttlSeconds: null,
+ visibility: "open",
+ },
+ ]);
}
async function expectWelcomeGuideIntro(
@@ -934,7 +943,7 @@ test("avatar upload accepts a file whose server-detected MIME is an image", asyn
await expect(page.getByTestId("onboarding-avatar-error")).toHaveCount(0);
});
-test("first-run onboarding keeps the shell hidden and lands on Welcome after profile setup", async ({
+test("first-run onboarding keeps the shell hidden and lands on welcome-everyone after profile setup", async ({
page,
}) => {
await seedActiveIdentity(page, FIRST_RUN_ALICE);
@@ -950,7 +959,7 @@ test("first-run onboarding keeps the shell hidden and lands on Welcome after pro
await completeProfileOnboarding(page);
await expect(page.getByTestId("onboarding-gate")).toHaveCount(0);
await expectWelcomeView(page);
- await expectPrivateWelcomeChannel(page);
+ await expectStarterChannels(page);
await expectWelcomeGuideIntro(page);
});
@@ -1027,17 +1036,15 @@ async function expectRetrySuccessDismissesToast(
await expect(activeToast).toHaveCount(0);
}
-test("failed Welcome and general retries recreate actionable toasts", async ({
+test("failed starter channel retries recreate actionable toasts", async ({
page,
}) => {
- const welcomeError = "Mock Welcome create failed.";
- const generalError = "Mock general join failed.";
+ const starterError = "Mock starter channel setup failed.";
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(
page,
{
- createChannelErrors: [welcomeError, welcomeError],
- joinChannelErrors: [generalError, generalError],
+ ensureStarterChannelsErrors: [starterError, starterError],
},
{ skipOnboardingSeed: true },
);
@@ -1047,28 +1054,21 @@ test("failed Welcome and general retries recreate actionable toasts", async ({
await completeProfileOnboarding(page);
await expectRetryFailureRecreatesActionableToast(page, {
- command: "create_channel",
- error: welcomeError,
- title: "Couldn't set up the Welcome channel",
- });
- await expectRetryFailureRecreatesActionableToast(page, {
- command: "join_channel",
- error: generalError,
- title: "Couldn't join #general",
+ command: "ensure_starter_channels",
+ error: starterError,
+ title: "Couldn't set up starter channels",
});
});
-test("successful Welcome and general retries clear their actionable toasts", async ({
+test("successful starter channel retry clears its actionable toast", async ({
page,
}) => {
- const welcomeError = "Mock Welcome create failed.";
- const generalError = "Mock general join failed.";
+ const starterError = "Mock starter channel setup failed.";
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(
page,
{
- createChannelErrors: [welcomeError],
- joinChannelErrors: [generalError],
+ ensureStarterChannelsErrors: [starterError],
},
{ skipOnboardingSeed: true },
);
@@ -1078,18 +1078,14 @@ test("successful Welcome and general retries clear their actionable toasts", asy
await completeProfileOnboarding(page);
await expectRetrySuccessDismissesToast(page, {
- command: "create_channel",
- title: "Couldn't set up the Welcome channel",
- });
- await expectRetrySuccessDismissesToast(page, {
- command: "join_channel",
- title: "Couldn't join #general",
+ command: "ensure_starter_channels",
+ title: "Couldn't set up starter channels",
});
await expectWelcomeView(page);
await expect(page.getByTestId("channel-general")).toBeVisible();
});
-test("first-run onboarding shows setup loading until Welcome bootstrap completes", async ({
+test("first-run onboarding shows setup loading until starter channel bootstrap completes", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
@@ -1164,7 +1160,7 @@ test("first-run onboarding shows setup loading until Welcome bootstrap completes
await expect(mark).toBeVisible();
await expectWelcomeView(page);
- await expectPrivateWelcomeChannel(page);
+ await expectStarterChannels(page);
await expectWelcomeGuideIntro(page);
});
@@ -1221,7 +1217,7 @@ test("onboarding can import an existing key when the community is already set up
await expectHomeView(page);
});
-test("completed onboarding backfills a missing private Welcome channel", async ({
+test("completed onboarding backfills missing starter channels", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
@@ -1231,12 +1227,13 @@ test("completed onboarding backfills a missing private Welcome channel", async (
await expect(page.getByTestId("onboarding-gate")).toHaveCount(0);
await expectHomeView(page);
- await expect(page.getByTestId("channel-Welcome")).toBeVisible();
- await expectPrivateWelcomeChannel(page);
+ await expect(page.getByTestId("channel-general")).toBeVisible();
+ await expect(page.getByTestId("channel-welcome-everyone")).toBeVisible();
+ await expectStarterChannels(page);
await expectWelcomeGuideIntro(page, { expectVisible: false });
});
-test("finishing onboarding creates and focuses a private Welcome channel for a new member", async ({
+test("finishing onboarding creates starter channels and focuses welcome-everyone for a new member", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
@@ -1248,7 +1245,7 @@ test("finishing onboarding creates and focuses a private Welcome channel for a n
await expectWelcomeView(page);
await expect(page.getByTestId("channel-general")).toBeVisible();
- await expectPrivateWelcomeChannel(page);
+ await expectStarterChannels(page);
await expectWelcomeGuideIntro(page);
await expectWelcomeComposerBannerCompletesAfterPersonaMention(page);
});
diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts
index d7727e2d4..b8f29be07 100644
--- a/desktop/tests/helpers/bridge.ts
+++ b/desktop/tests/helpers/bridge.ts
@@ -172,6 +172,8 @@ type MockBridgeOptions = {
channelsReadError?: string;
/** Reject successive mock `create_channel` calls, then resume. */
createChannelErrors?: string[];
+ /** Reject successive mock `ensure_starter_channels` calls, then resume. */
+ ensureStarterChannelsErrors?: string[];
/** Reject successive mock `join_channel` calls, then resume. */
joinChannelErrors?: string[];
/** Number of seeded rows in the deep-history fixture. Defaults to 600. */
@@ -204,8 +206,18 @@ type MockBridgeOptions = {
/** Set to false to simulate a Linux .deb install where auto-update is not
* supported. Defaults to true. See e2eBridge mock.autoUpdateSupported. */
autoUpdateSupported?: boolean;
+ /** Reject browser opener calls to exercise manual pairing fallback UI. */
+ openerError?: string;
+ /** Delay binding signatures so specs can exercise request supersession. */
+ nostrBindSignDelayMs?: number;
stallWebsocketSends?: boolean;
userSearchDelayMs?: number;
+ /**
+ * Value returned by the `observer_archive_default_enabled` mock command.
+ * `true` = internal-policy build (toggle locked ON); `false`/omitted = OSS
+ * build (toggle functional). Drives LocalArchiveSettingsCard policy state.
+ */
+ observerArchiveDefaultEnabled?: boolean;
// NIP-IA gate inputs — drive the archive-button gate matrix in
// tests/e2e/identity-archive.spec.ts.
/**
@@ -295,15 +307,16 @@ type MockBridgeOptions = {
*/
identityLocked?: boolean;
/**
- * Pending community deep links (buzz://join / buzz://connect) seeded into
- * the mocked Rust-side queue. The frontend drains these on boot into a
- * community-onboarding transaction — drives the pending-invite gate.
+ * Pending community deep links seeded into the mocked Rust-side queue.
+ * The frontend drains these on boot into onboarding or an editable Add
+ * Community prefill.
*/
pendingCommunityDeepLinks?: Array<{
id: string;
- kind: "connect" | "join";
+ kind: "connect" | "join" | "add-community";
relayUrl: string;
code?: string | null;
+ name?: string | null;
}>;
/**
* Global agent config returned by `get_global_agent_config`. Defaults to
diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart
index bf85bfdda..38a7e60ae 100644
--- a/mobile/lib/app.dart
+++ b/mobile/lib/app.dart
@@ -75,7 +75,10 @@ class App extends HookConsumerWidget {
AuthStatus.authenticated => const DeepLinkDispatcher(
child: HomePage(),
),
- _ => const PairingPage(),
+ _ => const DeepLinkDispatcher(
+ dispatchMessageLinks: false,
+ child: PairingPage(),
+ ),
},
),
);
diff --git a/mobile/lib/features/channels/deep_link_dispatcher.dart b/mobile/lib/features/channels/deep_link_dispatcher.dart
index 2a5fc822d..b264b31b6 100644
--- a/mobile/lib/features/channels/deep_link_dispatcher.dart
+++ b/mobile/lib/features/channels/deep_link_dispatcher.dart
@@ -3,6 +3,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/deeplink/deep_link.dart';
import '../../shared/deeplink/pending_deep_link_provider.dart';
+import '../invites/invite_join_provider.dart';
+import '../invites/invite_join_sheet.dart';
import 'channel.dart';
import 'channel_detail_page.dart';
import 'channels_provider.dart';
@@ -20,11 +22,13 @@ typedef DeepLinkDestinationBuilder =
class DeepLinkDispatcher extends ConsumerStatefulWidget {
final Widget child;
final DeepLinkDestinationBuilder? destinationBuilder;
+ final bool dispatchMessageLinks;
const DeepLinkDispatcher({
super.key,
required this.child,
this.destinationBuilder,
+ this.dispatchMessageLinks = true,
});
@override
@@ -32,6 +36,8 @@ class DeepLinkDispatcher extends ConsumerStatefulWidget {
}
class _DeepLinkDispatcherState extends ConsumerState {
+ bool _preparingInvite = false;
+
@override
void initState() {
super.initState();
@@ -44,18 +50,25 @@ class _DeepLinkDispatcherState extends ConsumerState {
@override
Widget build(BuildContext context) {
// Re-evaluate dispatch when either a new link arrives or channels load.
- ref.listen(pendingDeepLinkProvider, (_, link) {
+ ref.listen(pendingDeepLinkProvider, (_, link) {
_maybeDispatch(link);
});
- ref.listen>>(channelsProvider, (_, _) {
- _maybeDispatch(ref.read(pendingDeepLinkProvider));
- });
+ if (widget.dispatchMessageLinks) {
+ ref.listen>>(channelsProvider, (_, _) {
+ _maybeDispatch(ref.read(pendingDeepLinkProvider));
+ });
+ }
return widget.child;
}
- void _maybeDispatch(MessageDeepLink? link) {
+ void _maybeDispatch(BuzzDeepLink? link) {
if (link == null) return;
+ if (link is InviteDeepLink) {
+ _maybeDispatchInvite(link);
+ return;
+ }
+ if (link is! MessageDeepLink || !widget.dispatchMessageLinks) return;
final channels = ref.read(channelsProvider).asData?.value;
// Channels not loaded yet — keep the link parked; the channelsProvider
@@ -92,4 +105,39 @@ class _DeepLinkDispatcherState extends ConsumerState {
),
);
}
+
+ void _maybeDispatchInvite(InviteDeepLink link) {
+ if (_preparingInvite) return;
+ _preparingInvite = true;
+ final navigatorContext = context;
+ final messenger = ScaffoldMessenger.maybeOf(context);
+ Future.microtask(() async {
+ try {
+ await ref.read(inviteJoinProvider.notifier).prepare(link);
+ ref.read(pendingDeepLinkProvider.notifier).consume();
+ if (!navigatorContext.mounted) return;
+ final status = ref.read(inviteJoinProvider).status;
+ if (status == InviteJoinStatus.confirming) {
+ showInviteJoinSheet(navigatorContext, ref);
+ } else if (status == InviteJoinStatus.switchedExisting) {
+ messenger?.showSnackBar(
+ const SnackBar(content: Text('Switched to this community')),
+ );
+ }
+ } catch (error) {
+ debugPrint('deep-link: failed to prepare invite: $error');
+ if (navigatorContext.mounted) {
+ messenger?.showSnackBar(
+ const SnackBar(
+ content: Text(
+ 'Could not open this invite. Re-open the invite link to try again.',
+ ),
+ ),
+ );
+ }
+ } finally {
+ _preparingInvite = false;
+ }
+ });
+ }
}
diff --git a/mobile/lib/features/invites/invite_join_provider.dart b/mobile/lib/features/invites/invite_join_provider.dart
new file mode 100644
index 000000000..0cd58d47a
--- /dev/null
+++ b/mobile/lib/features/invites/invite_join_provider.dart
@@ -0,0 +1,276 @@
+import 'dart:convert';
+
+import 'package:http/http.dart' as http;
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:nostr/nostr.dart' as nostr;
+
+import '../../shared/auth/auth.dart';
+import '../../shared/deeplink/deep_link.dart';
+import '../../shared/relay/relay_session.dart';
+
+final inviteJoinHttpClientProvider = Provider((ref) {
+ final client = http.Client();
+ ref.onDispose(client.close);
+ return client;
+});
+
+final inviteKeyGeneratorProvider = Provider((ref) {
+ return () => nostr.Keys.generate();
+});
+
+typedef InviteKeyGenerator = nostr.Keys Function();
+
+enum InviteJoinStatus {
+ idle,
+ confirming,
+ claiming,
+ success,
+ switchedExisting,
+ error,
+}
+
+class InviteJoinState {
+ final InviteJoinStatus status;
+ final InviteDeepLink? invite;
+ final String? host;
+ final String? communityName;
+ final String? errorMessage;
+ final bool requiresFreshInvite;
+
+ const InviteJoinState({
+ this.status = InviteJoinStatus.idle,
+ this.invite,
+ this.host,
+ this.communityName,
+ this.errorMessage,
+ this.requiresFreshInvite = false,
+ });
+
+ InviteJoinState copyWith({
+ InviteJoinStatus? status,
+ InviteDeepLink? invite,
+ String? host,
+ String? communityName,
+ String? errorMessage,
+ bool? requiresFreshInvite,
+ }) => InviteJoinState(
+ status: status ?? this.status,
+ invite: invite ?? this.invite,
+ host: host ?? this.host,
+ communityName: communityName ?? this.communityName,
+ errorMessage: errorMessage ?? this.errorMessage,
+ requiresFreshInvite: requiresFreshInvite ?? this.requiresFreshInvite,
+ );
+}
+
+class InviteJoinNotifier extends Notifier {
+ @override
+ InviteJoinState build() => const InviteJoinState();
+
+ Future prepare(InviteDeepLink invite) async {
+ final communities = await ref.read(communityListProvider.future);
+ final existing = _existingCommunity(communities, invite.relayUrl);
+ if (existing != null) {
+ await ref
+ .read(communityListProvider.notifier)
+ .switchCommunity(existing.id);
+ state = InviteJoinState(
+ status: InviteJoinStatus.switchedExisting,
+ invite: invite,
+ host: _hostFromRelay(invite.relayUrl),
+ communityName: existing.name,
+ );
+ return;
+ }
+
+ state = InviteJoinState(
+ status: InviteJoinStatus.confirming,
+ invite: invite,
+ host: _hostFromRelay(invite.relayUrl),
+ communityName: Community.nameFromUrl(invite.relayUrl),
+ );
+ }
+
+ Future confirmJoin() async {
+ final invite = state.invite;
+ if (invite == null ||
+ state.requiresFreshInvite ||
+ (state.status != InviteJoinStatus.confirming &&
+ state.status != InviteJoinStatus.error)) {
+ return;
+ }
+
+ state = state.copyWith(status: InviteJoinStatus.claiming);
+ try {
+ final communities = await ref.read(communityListProvider.future);
+ final existing = _existingCommunity(communities, invite.relayUrl);
+ if (existing != null) {
+ await ref
+ .read(communityListProvider.notifier)
+ .switchCommunity(existing.id);
+ state = state.copyWith(
+ status: InviteJoinStatus.switchedExisting,
+ communityName: existing.name,
+ );
+ return;
+ }
+
+ final keys = ref.read(inviteKeyGeneratorProvider)();
+ final body = jsonEncode({
+ 'code': invite.code,
+ if (invite.policyReceipt != null)
+ 'policy_receipt': invite.policyReceipt,
+ });
+ final url = _claimUrlFromRelay(invite.relayUrl);
+ final response = await ref
+ .read(inviteJoinHttpClientProvider)
+ .post(
+ Uri.parse(url),
+ headers: {
+ 'Authorization': buildNip98AuthHeader(
+ method: 'POST',
+ url: url,
+ bodyBytes: utf8.encode(body),
+ nsec: keys.nsec,
+ ),
+ 'Content-Type': 'application/json',
+ },
+ body: body,
+ );
+ final decoded = jsonDecode(response.body.isEmpty ? '{}' : response.body);
+ if (response.statusCode < 200 || response.statusCode >= 300) {
+ final message = decoded is Map && decoded['error'] is String
+ ? decoded['error'] as String
+ : 'HTTP ${response.statusCode}';
+ throw InviteClaimException(message);
+ }
+ if (decoded is! Map) {
+ throw const FormatException('Invite claim returned malformed JSON');
+ }
+ final claim = Map.from(decoded);
+
+ final community = Community.create(
+ name: _communityNameFromClaim(claim, invite.relayUrl),
+ relayUrl: invite.relayUrl,
+ pubkey: keys.public,
+ nsec: keys.nsec,
+ );
+ await ref
+ .read(authProvider.notifier)
+ .authenticateWithCommunity(community);
+ state = state.copyWith(
+ status: InviteJoinStatus.success,
+ communityName: community.name,
+ );
+ } catch (error) {
+ final requiresFreshInvite = _requiresFreshInvite(error);
+ state = state.copyWith(
+ status: InviteJoinStatus.error,
+ errorMessage: _friendlyInviteError(error),
+ requiresFreshInvite: requiresFreshInvite,
+ );
+ }
+ }
+
+ void reset() {
+ state = const InviteJoinState();
+ }
+}
+
+final inviteJoinProvider =
+ NotifierProvider(
+ InviteJoinNotifier.new,
+ );
+
+class InviteClaimException implements Exception {
+ final String message;
+
+ const InviteClaimException(this.message);
+
+ @override
+ String toString() => message;
+}
+
+Community? _existingCommunity(List communities, String relayUrl) {
+ final invite = _relayOriginForComparison(relayUrl);
+ for (final community in communities) {
+ final current = _relayOriginForComparison(community.relayUrl);
+ if (current == null) continue;
+ if (current == invite) {
+ return community;
+ }
+ }
+ return null;
+}
+
+({bool secure, String host, int? port})? _relayOriginForComparison(String url) {
+ final uri = Uri.tryParse(url);
+ if (uri == null || uri.host.isEmpty) return null;
+ final secure = switch (uri.scheme) {
+ 'https' || 'wss' => true,
+ 'http' || 'ws' => false,
+ _ => null,
+ };
+ if (secure == null) return null;
+ return (
+ secure: secure,
+ host: uri.host.toLowerCase(),
+ port: _effectivePort(uri),
+ );
+}
+
+int? _effectivePort(Uri uri) {
+ if (uri.hasPort) return uri.port;
+ return switch (uri.scheme) {
+ 'https' || 'wss' => 443,
+ 'http' || 'ws' => 80,
+ _ => null,
+ };
+}
+
+String _hostFromRelay(String relayUrl) {
+ final uri = Uri.parse(relayUrl);
+ if (uri.hasPort) return '${uri.host}:${uri.port}';
+ return uri.host;
+}
+
+String _claimUrlFromRelay(String relayUrl) {
+ final uri = Uri.parse(relayUrl);
+ final scheme = switch (uri.scheme) {
+ 'wss' => 'https',
+ 'ws' => 'http',
+ _ => throw FormatException('Invalid relay URL scheme: ${uri.scheme}'),
+ };
+ return Uri(
+ scheme: scheme,
+ host: uri.host,
+ port: uri.hasPort ? uri.port : null,
+ path: '/api/invites/claim',
+ ).toString();
+}
+
+String _communityNameFromClaim(Map claim, String relayUrl) {
+ final host = claim['host'];
+ if (host is String && host.trim().isNotEmpty) return host.trim();
+ return Community.nameFromUrl(relayUrl);
+}
+
+bool _requiresFreshInvite(Object error) {
+ return error.toString().contains('join_policy_required');
+}
+
+String _friendlyInviteError(Object error) {
+ final message = error.toString();
+ if (message.contains('invite_expired')) return 'This invite has expired.';
+ if (message.contains('invite_invalid')) return 'This invite is not valid.';
+ if (message.contains('join_policy_required')) {
+ return 'This invite approval has expired. Re-open the invite link to try again.';
+ }
+ if (message.contains('SocketException') ||
+ message.contains('Connection refused') ||
+ message.contains('Network is unreachable') ||
+ message.contains('No route to host')) {
+ return 'Could not reach the relay. Check your connection and try again.';
+ }
+ return 'Could not join this community: $message';
+}
diff --git a/mobile/lib/features/invites/invite_join_sheet.dart b/mobile/lib/features/invites/invite_join_sheet.dart
new file mode 100644
index 000000000..58221158e
--- /dev/null
+++ b/mobile/lib/features/invites/invite_join_sheet.dart
@@ -0,0 +1,189 @@
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:lucide_icons_flutter/lucide_icons.dart';
+
+import '../../shared/theme/theme.dart';
+import '../pairing/pairing_page.dart';
+import 'invite_join_provider.dart';
+
+Future showInviteJoinSheet(BuildContext context, WidgetRef ref) {
+ return showModalBottomSheet(
+ context: context,
+ isScrollControlled: true,
+ showDragHandle: true,
+ builder: (_) => const InviteJoinSheet(),
+ );
+}
+
+class InviteJoinSheet extends ConsumerWidget {
+ const InviteJoinSheet({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final state = ref.watch(inviteJoinProvider);
+ final isClaiming = state.status == InviteJoinStatus.claiming;
+ final host = state.host ?? 'unknown host';
+ final derivedName = state.communityName;
+
+ if (state.status == InviteJoinStatus.success) {
+ return _InviteJoinSuccess(host: host, communityName: derivedName);
+ }
+
+ return SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(Grid.sm, 0, Grid.sm, Grid.sm),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Icon(LucideIcons.userPlus, size: 40, color: context.colors.primary),
+ const SizedBox(height: Grid.sm),
+ Text(
+ 'Join this Buzz community?',
+ style: context.textTheme.titleLarge,
+ ),
+ const SizedBox(height: Grid.xxs),
+ Text(
+ 'Check the relay host before you join:',
+ style: context.textTheme.bodyMedium?.copyWith(
+ color: context.colors.onSurfaceVariant,
+ ),
+ ),
+ const SizedBox(height: Grid.xs),
+ Container(
+ padding: const EdgeInsets.all(Grid.twelve),
+ decoration: BoxDecoration(
+ color: context.colors.surfaceContainerHighest.withValues(
+ alpha: 0.7,
+ ),
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: context.colors.outlineVariant),
+ ),
+ child: Text(
+ host,
+ style: context.textTheme.titleMedium?.copyWith(
+ fontFamily: 'GeistMono',
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ),
+ if (derivedName != null && derivedName != host) ...[
+ const SizedBox(height: Grid.xxs),
+ Text(
+ 'Display name: $derivedName',
+ style: context.textTheme.bodySmall?.copyWith(
+ color: context.colors.onSurfaceVariant,
+ ),
+ ),
+ ],
+ const SizedBox(height: Grid.sm),
+ Text(
+ 'This phone is the only copy of this identity. If you lose it before pairing or backing up, you’ll lose access as this member.',
+ style: context.textTheme.bodyMedium?.copyWith(
+ color: context.colors.onSurfaceVariant,
+ ),
+ ),
+ if (state.status == InviteJoinStatus.error &&
+ state.errorMessage != null) ...[
+ const SizedBox(height: Grid.sm),
+ Text(
+ state.errorMessage!,
+ style: context.textTheme.bodySmall?.copyWith(
+ color: context.colors.error,
+ ),
+ ),
+ ],
+ const SizedBox(height: Grid.lg),
+ Row(
+ children: [
+ Expanded(
+ child: OutlinedButton(
+ onPressed: isClaiming
+ ? null
+ : () => Navigator.of(context).pop(),
+ child: const Text('Cancel'),
+ ),
+ ),
+ const SizedBox(width: Grid.sm),
+ Expanded(
+ child: FilledButton.icon(
+ onPressed: isClaiming || state.requiresFreshInvite
+ ? null
+ : () => ref
+ .read(inviteJoinProvider.notifier)
+ .confirmJoin(),
+ icon: isClaiming
+ ? const SizedBox(
+ width: 16,
+ height: 16,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ )
+ : const Icon(LucideIcons.check),
+ label: Text(isClaiming ? 'Joining…' : 'Join'),
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _InviteJoinSuccess extends StatelessWidget {
+ final String host;
+ final String? communityName;
+
+ const _InviteJoinSuccess({required this.host, this.communityName});
+
+ @override
+ Widget build(BuildContext context) {
+ return SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(Grid.sm, 0, Grid.sm, Grid.sm),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Icon(
+ LucideIcons.circleCheck,
+ size: 40,
+ color: context.colors.primary,
+ ),
+ const SizedBox(height: Grid.sm),
+ Text(
+ 'You joined ${communityName ?? host}',
+ style: context.textTheme.titleLarge,
+ ),
+ const SizedBox(height: Grid.xs),
+ Text(
+ 'This phone is the only copy of this identity. If you lose it before pairing or backing up, you’ll lose access as this member.',
+ style: context.textTheme.bodyMedium?.copyWith(
+ color: context.colors.onSurfaceVariant,
+ ),
+ ),
+ const SizedBox(height: Grid.lg),
+ FilledButton.icon(
+ onPressed: () {
+ Navigator.of(context).pop();
+ Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => const PairingPage(addingCommunity: true),
+ ),
+ );
+ },
+ icon: const Icon(LucideIcons.scanLine),
+ label: const Text('Back it up now'),
+ ),
+ const SizedBox(height: Grid.xs),
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(),
+ child: const Text('Not now'),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/shared/deeplink/deep_link.dart b/mobile/lib/shared/deeplink/deep_link.dart
index 3bdd505fd..b2e57b8b0 100644
--- a/mobile/lib/shared/deeplink/deep_link.dart
+++ b/mobile/lib/shared/deeplink/deep_link.dart
@@ -7,8 +7,49 @@
/// half-formed target.
library;
+/// A parsed deep link supported by the app.
+sealed class BuzzDeepLink {
+ const BuzzDeepLink();
+}
+
+/// A parsed relay invite link.
+///
+/// Canonical share links are `https:///invite/`. The custom
+/// `buzz://join?relay=&code=` form is only an installed-app
+/// handoff from the web landing page.
+class InviteDeepLink extends BuzzDeepLink {
+ /// Relay URL normalized to the websocket scheme used by the app.
+ final String relayUrl;
+
+ /// Invite code from the link.
+ final String code;
+
+ /// Optional receipt proving acceptance of the relay's current join policy.
+ final String? policyReceipt;
+
+ const InviteDeepLink({
+ required this.relayUrl,
+ required this.code,
+ this.policyReceipt,
+ });
+
+ @override
+ bool operator ==(Object other) =>
+ other is InviteDeepLink &&
+ other.relayUrl == relayUrl &&
+ other.code == code &&
+ other.policyReceipt == policyReceipt;
+
+ @override
+ int get hashCode => Object.hash(relayUrl, code, policyReceipt);
+
+ @override
+ String toString() =>
+ 'InviteDeepLink(relay: $relayUrl, code: $code, policyReceipt: $policyReceipt)';
+}
+
/// A parsed `buzz://message` deep link.
-class MessageDeepLink {
+class MessageDeepLink extends BuzzDeepLink {
/// Channel UUID from the `channel` query param.
final String channelId;
@@ -61,3 +102,70 @@ MessageDeepLink? parseMessageDeepLink(Uri uri) {
threadRootId: (thread == null || thread.isEmpty) ? null : thread,
);
}
+
+/// Parse canonical HTTPS invite links and `buzz://join` app handoffs.
+///
+/// Accepted forms:
+/// - `https:///invite/` -> `wss://` + code
+/// - `http:///invite/` -> `ws://` + code
+/// - `buzz://join?relay=&code=` -> relay + code
+///
+/// Rejects credentials, fragments, missing params, nested relay credentials, and
+/// non-invite paths so scanners do not accidentally treat arbitrary URLs as
+/// community admission links.
+InviteDeepLink? parseInviteDeepLink(Uri uri) {
+ if (uri.hasFragment || uri.userInfo.isNotEmpty) return null;
+
+ if (uri.scheme == 'buzz') {
+ if (uri.host != 'join') return null;
+ final relay = uri.queryParameters['relay'];
+ final code = uri.queryParameters['code'];
+ if (relay == null || relay.isEmpty || code == null || code.isEmpty) {
+ return null;
+ }
+ final relayUri = Uri.tryParse(relay);
+ if (relayUri == null ||
+ (relayUri.scheme != 'ws' && relayUri.scheme != 'wss') ||
+ relayUri.host.isEmpty ||
+ relayUri.userInfo.isNotEmpty ||
+ relayUri.hasFragment) {
+ return null;
+ }
+ final normalizedRelay = Uri(
+ scheme: relayUri.scheme,
+ host: relayUri.host,
+ port: relayUri.hasPort ? relayUri.port : null,
+ ).toString();
+ final policyReceipt = uri.queryParameters['policy_receipt'];
+ return InviteDeepLink(
+ relayUrl: normalizedRelay,
+ code: code,
+ policyReceipt: policyReceipt == null || policyReceipt.isEmpty
+ ? null
+ : policyReceipt,
+ );
+ }
+
+ if (uri.scheme == 'https' || uri.scheme == 'http') {
+ if (uri.host.isEmpty) return null;
+ final segments = uri.pathSegments;
+ if (segments.length != 2 ||
+ segments[0] != 'invite' ||
+ segments[1].isEmpty) {
+ return null;
+ }
+ final relayScheme = uri.scheme == 'https' ? 'wss' : 'ws';
+ final relay = Uri(
+ scheme: relayScheme,
+ host: uri.host,
+ port: uri.hasPort ? uri.port : null,
+ ).toString();
+ return InviteDeepLink(relayUrl: relay, code: segments[1]);
+ }
+
+ return null;
+}
+
+/// Parse any supported Buzz deep link.
+BuzzDeepLink? parseBuzzDeepLink(Uri uri) =>
+ parseInviteDeepLink(uri) ?? parseMessageDeepLink(uri);
diff --git a/mobile/lib/shared/deeplink/pending_deep_link_provider.dart b/mobile/lib/shared/deeplink/pending_deep_link_provider.dart
index 250ffdbd2..8dc46d9f1 100644
--- a/mobile/lib/shared/deeplink/pending_deep_link_provider.dart
+++ b/mobile/lib/shared/deeplink/pending_deep_link_provider.dart
@@ -6,7 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'deep_link.dart';
-/// Holds the most recent `buzz://message` deep link that has not been
+/// Holds the most recent supported deep link that has not been
/// dispatched yet.
///
/// Listens to [AppLinks.uriLinkStream], which delivers both the cold-start
@@ -14,14 +14,14 @@ import 'deep_link.dart';
/// Navigation cannot always happen the moment a link arrives — the user may
/// not be authenticated yet, or channels may still be loading — so the parsed
/// link is parked here and consumed by the dispatcher once the app is ready.
-class PendingDeepLinkNotifier extends Notifier {
+class PendingDeepLinkNotifier extends Notifier {
@visibleForTesting
static Stream? debugUriStreamOverride;
StreamSubscription? _subscription;
@override
- MessageDeepLink? build() {
+ BuzzDeepLink? build() {
final stream = debugUriStreamOverride ?? AppLinks().uriLinkStream;
_subscription = stream.listen(handleUri);
ref.onDispose(() {
@@ -34,7 +34,7 @@ class PendingDeepLinkNotifier extends Notifier {
/// Parse and park an incoming URI. Unsupported links are ignored loudly.
@visibleForTesting
void handleUri(Uri uri) {
- final link = parseMessageDeepLink(uri);
+ final link = parseBuzzDeepLink(uri);
if (link == null) {
debugPrint('deep-link: ignoring unsupported link: $uri');
return;
@@ -47,6 +47,6 @@ class PendingDeepLinkNotifier extends Notifier {
}
final pendingDeepLinkProvider =
- NotifierProvider(
+ NotifierProvider(
PendingDeepLinkNotifier.new,
);
diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart
index 615b75b9a..9c167e10b 100644
--- a/mobile/lib/shared/relay/relay_session.dart
+++ b/mobile/lib/shared/relay/relay_session.dart
@@ -143,7 +143,7 @@ class RelaySessionNotifier extends Notifier {
.post(
Uri.parse(url),
headers: {
- 'Authorization': _buildNip98AuthHeader(
+ 'Authorization': buildNip98AuthHeader(
method: 'POST',
url: url,
bodyBytes: bodyBytes,
@@ -653,7 +653,7 @@ final relaySessionProvider =
RelaySessionNotifier.new,
);
-String _buildNip98AuthHeader({
+String buildNip98AuthHeader({
required String method,
required String url,
required List bodyBytes,
diff --git a/mobile/test/features/channels/deep_link_dispatcher_test.dart b/mobile/test/features/channels/deep_link_dispatcher_test.dart
index 452c25cb9..0771a7bb3 100644
--- a/mobile/test/features/channels/deep_link_dispatcher_test.dart
+++ b/mobile/test/features/channels/deep_link_dispatcher_test.dart
@@ -1,12 +1,16 @@
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/features/channels/deep_link_dispatcher.dart';
+import 'package:buzz/features/invites/invite_join_provider.dart';
+import 'package:buzz/shared/auth/auth.dart';
import 'package:buzz/shared/deeplink/deep_link.dart';
import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
+import '../../shared/community/community_storage_test.dart';
+
void main() {
testWidgets('dispatches a link that is already ready on mount', (
tester,
@@ -46,6 +50,146 @@ void main() {
expect(destination.link.messageId, 'message-2');
expect(destination.link.threadRootId, 'message-1');
});
+
+ testWidgets('retains invite and surfaces prepare failure', (tester) async {
+ const link = InviteDeepLink(
+ relayUrl: 'wss://relay.example.com',
+ code: 'invite-code',
+ );
+ final container = ProviderContainer(
+ overrides: [
+ communityStorageProvider.overrideWithValue(_ThrowingCommunityStorage()),
+ pendingDeepLinkProvider.overrideWith(
+ () => _FakePendingDeepLinkNotifier(link),
+ ),
+ ],
+ );
+ addTearDown(container.dispose);
+
+ await tester.pumpWidget(
+ UncontrolledProviderScope(
+ container: container,
+ child: const MaterialApp(
+ home: DeepLinkDispatcher(child: Scaffold(body: SizedBox())),
+ ),
+ ),
+ );
+ await tester.pumpAndSettle();
+
+ expect(container.read(pendingDeepLinkProvider), same(link));
+ expect(container.read(inviteJoinProvider).status, InviteJoinStatus.idle);
+ expect(
+ find.text(
+ 'Could not open this invite. Re-open the invite link to try again.',
+ ),
+ findsOneWidget,
+ );
+ expect(tester.takeException(), isNull);
+ });
+
+ testWidgets('prepares an invite once while listeners re-enter', (
+ tester,
+ ) async {
+ const link = InviteDeepLink(
+ relayUrl: 'wss://relay.example.com',
+ code: 'invite-code',
+ );
+ final storage = _CountingCommunityStorage();
+ final pending = _RecordingPendingDeepLinkNotifier(link);
+ final container = ProviderContainer(
+ overrides: [
+ communityStorageProvider.overrideWithValue(storage),
+ pendingDeepLinkProvider.overrideWith(() => pending),
+ channelsProvider.overrideWith(
+ () => _FakeChannelsNotifier(Future.value([_channel])),
+ ),
+ ],
+ );
+ addTearDown(container.dispose);
+
+ await tester.pumpWidget(
+ UncontrolledProviderScope(
+ container: container,
+ child: const MaterialApp(
+ home: DeepLinkDispatcher(child: Scaffold(body: SizedBox())),
+ ),
+ ),
+ );
+ await tester.pumpAndSettle();
+
+ expect(storage.loadCalls, 1);
+ expect(pending.consumeCalls, 1);
+ expect(find.text('Join this Buzz community?'), findsOneWidget);
+ });
+
+ testWidgets(
+ 'dispatches invite before auth while leaving message links parked',
+ (tester) async {
+ final inviteStorage = CommunityStorage(secure: FakeSecureStorage());
+ final inviteContainer = ProviderContainer(
+ overrides: [
+ communityStorageProvider.overrideWithValue(inviteStorage),
+ pendingDeepLinkProvider.overrideWith(
+ () => _FakePendingDeepLinkNotifier(
+ const InviteDeepLink(
+ relayUrl: 'wss://relay.example.com',
+ code: 'invite-code',
+ ),
+ ),
+ ),
+ ],
+ );
+ addTearDown(inviteContainer.dispose);
+
+ await tester.pumpWidget(
+ UncontrolledProviderScope(
+ container: inviteContainer,
+ child: const MaterialApp(
+ home: DeepLinkDispatcher(
+ dispatchMessageLinks: false,
+ child: Scaffold(body: Text('Pairing')),
+ ),
+ ),
+ ),
+ );
+ await tester.pumpAndSettle();
+
+ expect(find.text('Join this Buzz community?'), findsOneWidget);
+ expect(inviteContainer.read(pendingDeepLinkProvider), isNull);
+
+ final messageContainer = ProviderContainer(
+ overrides: [
+ pendingDeepLinkProvider.overrideWith(
+ () => _FakePendingDeepLinkNotifier(
+ const MessageDeepLink(
+ channelId: 'channel-1',
+ messageId: 'message-1',
+ ),
+ ),
+ ),
+ ],
+ );
+ addTearDown(messageContainer.dispose);
+ await tester.pumpWidget(
+ UncontrolledProviderScope(
+ container: messageContainer,
+ child: const MaterialApp(
+ home: DeepLinkDispatcher(
+ dispatchMessageLinks: false,
+ child: Scaffold(body: Text('Pairing')),
+ ),
+ ),
+ ),
+ );
+ await tester.pump();
+
+ expect(
+ messageContainer.read(pendingDeepLinkProvider),
+ isA(),
+ );
+ expect(find.text('Pairing'), findsOneWidget);
+ },
+ );
}
final _channel = Channel(
@@ -60,13 +204,46 @@ final _channel = Channel(
isMember: true,
);
+class _CountingCommunityStorage extends CommunityStorage {
+ int loadCalls = 0;
+
+ @override
+ Future> loadAll() async {
+ loadCalls++;
+ return [];
+ }
+}
+
+class _ThrowingCommunityStorage extends CommunityStorage {
+ @override
+ Future> loadAll() async {
+ throw StateError('secure storage unavailable');
+ }
+}
+
+class _RecordingPendingDeepLinkNotifier extends PendingDeepLinkNotifier {
+ _RecordingPendingDeepLinkNotifier(this.link);
+
+ final BuzzDeepLink link;
+ int consumeCalls = 0;
+
+ @override
+ BuzzDeepLink? build() => link;
+
+ @override
+ void consume() {
+ consumeCalls++;
+ super.consume();
+ }
+}
+
class _FakePendingDeepLinkNotifier extends PendingDeepLinkNotifier {
_FakePendingDeepLinkNotifier(this.link);
- final MessageDeepLink link;
+ final BuzzDeepLink link;
@override
- MessageDeepLink? build() => link;
+ BuzzDeepLink? build() => link;
}
class _FakeChannelsNotifier extends ChannelsNotifier {
diff --git a/mobile/test/features/invites/invite_join_provider_test.dart b/mobile/test/features/invites/invite_join_provider_test.dart
new file mode 100644
index 000000000..29158f791
--- /dev/null
+++ b/mobile/test/features/invites/invite_join_provider_test.dart
@@ -0,0 +1,258 @@
+import 'dart:convert';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:http/http.dart' as http;
+import 'package:http/testing.dart' as http_testing;
+import 'package:nostr/nostr.dart' as nostr;
+
+import 'package:buzz/features/invites/invite_join_provider.dart';
+import 'package:buzz/shared/auth/auth.dart';
+import 'package:buzz/shared/deeplink/deep_link.dart';
+
+import '../../shared/community/community_storage_test.dart';
+
+void main() {
+ for (final existingRelayUrl in [
+ 'wss://relay.example.com',
+ 'https://relay.example.com',
+ ]) {
+ test(
+ 'same-relay invite switches existing $existingRelayUrl before keygen or claim',
+ () async {
+ var generatedKeys = 0;
+ var claimRequests = 0;
+ final storage = CommunityStorage(secure: FakeSecureStorage());
+ final existing = Community(
+ id: 'existing-id',
+ name: 'Existing',
+ relayUrl: existingRelayUrl,
+ pubkey: 'old-pubkey',
+ nsec: 'old-nsec',
+ addedAt: DateTime.utc(2026),
+ );
+ await storage.save(existing);
+ final auth = _RecordingAuthNotifier();
+ final container = ProviderContainer(
+ overrides: [
+ communityStorageProvider.overrideWithValue(storage),
+ authProvider.overrideWith(() => auth),
+ inviteKeyGeneratorProvider.overrideWithValue(() {
+ generatedKeys++;
+ return nostr.Keys.generate();
+ }),
+ inviteJoinHttpClientProvider.overrideWithValue(
+ http_testing.MockClient((request) async {
+ claimRequests++;
+ return http.Response('{}', 500);
+ }),
+ ),
+ ],
+ );
+ addTearDown(container.dispose);
+ await container.read(communityListProvider.future);
+
+ await container
+ .read(inviteJoinProvider.notifier)
+ .prepare(
+ const InviteDeepLink(
+ relayUrl: 'wss://relay.example.com',
+ code: 'code',
+ ),
+ );
+
+ final state = container.read(inviteJoinProvider);
+ final stored = (await storage.loadAll()).single;
+ expect(state.status, InviteJoinStatus.switchedExisting);
+ expect(await storage.loadActiveId(), existing.id);
+ expect(stored.relayUrl, existingRelayUrl);
+ expect(stored.pubkey, 'old-pubkey');
+ expect(stored.nsec, 'old-nsec');
+ expect(generatedKeys, 0);
+ expect(claimRequests, 0);
+ expect(auth.authenticatedCommunities, isEmpty);
+ },
+ );
+ }
+
+ test(
+ 'claim posts with freshly-generated key and stores joined community',
+ () async {
+ final keys = nostr.Keys.generate();
+ http.Request? capturedRequest;
+ final storage = CommunityStorage(secure: FakeSecureStorage());
+ final auth = _RecordingAuthNotifier();
+ final container = ProviderContainer(
+ overrides: [
+ communityStorageProvider.overrideWithValue(storage),
+ authProvider.overrideWith(() => auth),
+ inviteKeyGeneratorProvider.overrideWithValue(() => keys),
+ inviteJoinHttpClientProvider.overrideWithValue(
+ http_testing.MockClient((request) async {
+ capturedRequest = request;
+ return http.Response(
+ jsonEncode({
+ 'status': 'joined',
+ 'community_id': 'community-id',
+ 'host': 'relay.example.com',
+ 'role': 'member',
+ }),
+ 200,
+ );
+ }),
+ ),
+ ],
+ );
+ addTearDown(container.dispose);
+
+ await container
+ .read(inviteJoinProvider.notifier)
+ .prepare(
+ const InviteDeepLink(
+ relayUrl: 'wss://relay.example.com',
+ code: 'code',
+ ),
+ );
+ expect(
+ container.read(inviteJoinProvider).status,
+ InviteJoinStatus.confirming,
+ );
+
+ await container.read(inviteJoinProvider.notifier).confirmJoin();
+
+ final state = container.read(inviteJoinProvider);
+ expect(state.status, InviteJoinStatus.success);
+ expect(capturedRequest, isNotNull);
+ expect(
+ capturedRequest!.url.toString(),
+ 'https://relay.example.com/api/invites/claim',
+ );
+ expect(capturedRequest!.body, jsonEncode({'code': 'code'}));
+ expect(capturedRequest!.headers['Authorization'], startsWith('Nostr '));
+ expect(auth.authenticatedCommunities, hasLength(1));
+ expect(
+ auth.authenticatedCommunities.single.relayUrl,
+ 'wss://relay.example.com',
+ );
+ expect(auth.authenticatedCommunities.single.pubkey, keys.public);
+ expect(auth.authenticatedCommunities.single.nsec, keys.nsec);
+ },
+ );
+
+ test('join_policy_required requires a fresh link and cannot retry', () async {
+ final keys = nostr.Keys.generate();
+ var attempts = 0;
+ final storage = CommunityStorage(secure: FakeSecureStorage());
+ final container = ProviderContainer(
+ overrides: [
+ communityStorageProvider.overrideWithValue(storage),
+ inviteKeyGeneratorProvider.overrideWithValue(() => keys),
+ inviteJoinHttpClientProvider.overrideWithValue(
+ http_testing.MockClient((request) async {
+ attempts++;
+ return http.Response(
+ jsonEncode({'error': 'join_policy_required'}),
+ 403,
+ );
+ }),
+ ),
+ ],
+ );
+ addTearDown(container.dispose);
+
+ await container
+ .read(inviteJoinProvider.notifier)
+ .prepare(
+ const InviteDeepLink(
+ relayUrl: 'wss://relay.example.com',
+ code: 'code',
+ policyReceipt: 'expired.receipt',
+ ),
+ );
+ await container.read(inviteJoinProvider.notifier).confirmJoin();
+
+ final state = container.read(inviteJoinProvider);
+ expect(state.status, InviteJoinStatus.error);
+ expect(state.requiresFreshInvite, isTrue);
+ expect(
+ state.errorMessage,
+ 'This invite approval has expired. Re-open the invite link to try again.',
+ );
+
+ await container.read(inviteJoinProvider.notifier).confirmJoin();
+ expect(attempts, 1);
+ });
+
+ test('failed claim can be retried and preserves policy receipt', () async {
+ final keys = nostr.Keys.generate();
+ var attempts = 0;
+ final bodies = [];
+ final storage = CommunityStorage(secure: FakeSecureStorage());
+ final auth = _RecordingAuthNotifier();
+ final container = ProviderContainer(
+ overrides: [
+ communityStorageProvider.overrideWithValue(storage),
+ authProvider.overrideWith(() => auth),
+ inviteKeyGeneratorProvider.overrideWithValue(() => keys),
+ inviteJoinHttpClientProvider.overrideWithValue(
+ http_testing.MockClient((request) async {
+ attempts++;
+ bodies.add(request.body);
+ if (attempts == 1) {
+ return http.Response(jsonEncode({'error': 'temporary'}), 503);
+ }
+ return http.Response(
+ jsonEncode({
+ 'status': 'joined',
+ 'host': 'relay.example.com',
+ 'role': 'member',
+ }),
+ 200,
+ );
+ }),
+ ),
+ ],
+ );
+ addTearDown(container.dispose);
+
+ await container
+ .read(inviteJoinProvider.notifier)
+ .prepare(
+ const InviteDeepLink(
+ relayUrl: 'wss://relay.example.com',
+ code: 'code',
+ policyReceipt: 'receipt.value',
+ ),
+ );
+ await container.read(inviteJoinProvider.notifier).confirmJoin();
+ expect(container.read(inviteJoinProvider).status, InviteJoinStatus.error);
+
+ await container.read(inviteJoinProvider.notifier).confirmJoin();
+
+ expect(container.read(inviteJoinProvider).status, InviteJoinStatus.success);
+ expect(attempts, 2);
+ expect(
+ bodies,
+ everyElement(
+ jsonEncode({'code': 'code', 'policy_receipt': 'receipt.value'}),
+ ),
+ );
+ expect(auth.authenticatedCommunities, hasLength(1));
+ });
+}
+
+class _RecordingAuthNotifier extends AuthNotifier {
+ final List authenticatedCommunities = [];
+
+ @override
+ Future build() async =>
+ const AuthState(status: AuthStatus.unauthenticated);
+
+ @override
+ Future authenticateWithCommunity(Community community) async {
+ authenticatedCommunities.add(community);
+ state = AsyncData(
+ AuthState(status: AuthStatus.authenticated, community: community),
+ );
+ }
+}
diff --git a/mobile/test/shared/deeplink/deep_link_test.dart b/mobile/test/shared/deeplink/deep_link_test.dart
index 7624cbe71..a60056c3b 100644
--- a/mobile/test/shared/deeplink/deep_link_test.dart
+++ b/mobile/test/shared/deeplink/deep_link_test.dart
@@ -2,6 +2,8 @@ import 'package:buzz/shared/deeplink/deep_link.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
+ _inviteTests();
+
group('parseMessageDeepLink', () {
test('parses channel and id', () {
final link = parseMessageDeepLink(
@@ -61,3 +63,142 @@ void main() {
});
});
}
+
+void _inviteTests() {
+ group('parseInviteDeepLink', () {
+ test('parses canonical HTTPS invite URL', () {
+ final link = parseInviteDeepLink(
+ Uri.parse('https://relay.example.com/invite/abc123'),
+ );
+ expect(
+ link,
+ const InviteDeepLink(
+ relayUrl: 'wss://relay.example.com',
+ code: 'abc123',
+ ),
+ );
+ });
+
+ test('parses HTTP invite URL for local/dev relays', () {
+ final link = parseInviteDeepLink(
+ Uri.parse('http://localhost:3000/invite/dev-code'),
+ );
+ expect(
+ link,
+ const InviteDeepLink(relayUrl: 'ws://localhost:3000', code: 'dev-code'),
+ );
+ });
+
+ test('parses buzz join handoff link', () {
+ final link = parseInviteDeepLink(
+ Uri.parse(
+ 'buzz://join?relay=wss%3A%2F%2Frelay.example.com&code=abc123',
+ ),
+ );
+ expect(
+ link,
+ const InviteDeepLink(
+ relayUrl: 'wss://relay.example.com',
+ code: 'abc123',
+ ),
+ );
+ });
+
+ test('preserves policy receipt in buzz join handoff', () {
+ final link = parseInviteDeepLink(
+ Uri.parse(
+ 'buzz://join?relay=wss%3A%2F%2Frelay.example.com&code=abc123&policy_receipt=receipt.value',
+ ),
+ );
+ expect(
+ link,
+ const InviteDeepLink(
+ relayUrl: 'wss://relay.example.com',
+ code: 'abc123',
+ policyReceipt: 'receipt.value',
+ ),
+ );
+ });
+
+ test('rejects non-invite HTTPS paths', () {
+ expect(
+ parseInviteDeepLink(Uri.parse('https://relay.example.com/api/invites')),
+ isNull,
+ );
+ expect(
+ parseInviteDeepLink(Uri.parse('https://relay.example.com/invite/')),
+ isNull,
+ );
+ expect(
+ parseInviteDeepLink(Uri.parse('https://relay.example.com/invite/a/b')),
+ isNull,
+ );
+ });
+
+ test('rejects credentials and fragments', () {
+ expect(
+ parseInviteDeepLink(
+ Uri.parse('https://user:pass@relay.example.com/invite/abc'),
+ ),
+ isNull,
+ );
+ expect(
+ parseInviteDeepLink(
+ Uri.parse('https://relay.example.com/invite/abc#x'),
+ ),
+ isNull,
+ );
+ expect(
+ parseInviteDeepLink(
+ Uri.parse(
+ 'buzz://join?relay=wss%3A%2F%2Fuser%3Apass%40relay.example.com&code=abc',
+ ),
+ ),
+ isNull,
+ );
+ });
+
+ test('rejects buzz join without websocket relay or code', () {
+ expect(
+ parseInviteDeepLink(
+ Uri.parse('buzz://join?relay=https://relay.example.com&code=abc'),
+ ),
+ isNull,
+ );
+ expect(
+ parseInviteDeepLink(
+ Uri.parse('buzz://join?relay=wss://relay.example.com'),
+ ),
+ isNull,
+ );
+ expect(
+ parseInviteDeepLink(Uri.parse('buzz://connect?relay=wss://x')),
+ isNull,
+ );
+ });
+
+ test('rejects buzz join with dangerous relay schemes', () {
+ // The `relay=` param is an allowlist — only `ws` / `wss` are safe to
+ // hand to a Nostr relay session. Anything else must be dropped by the
+ // parser so a hostile QR / share link can't smuggle a browser scheme
+ // (`javascript:`, `data:`), a local resource (`file:`), or an
+ // unrelated transport (`ftp:`, `chrome:`) into the join flow.
+ for (final hostile in [
+ 'javascript:alert(1)',
+ 'data:text/html,evil',
+ 'file:///etc/passwd',
+ 'ftp://relay.example.com',
+ 'chrome://settings',
+ 'about:blank',
+ 'ssh://relay.example.com',
+ ]) {
+ final encoded = Uri.encodeQueryComponent(hostile);
+ expect(
+ parseInviteDeepLink(Uri.parse('buzz://join?relay=$encoded&code=abc')),
+ isNull,
+ reason: 'must reject relay scheme in $hostile',
+ );
+ }
+ });
+ });
+}