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>, + /// Pack persona members. `Option` is PERMANENT wire semantics, not a + /// transitional shim: `None` = publisher predates always-publish (its + /// true membership is unknown — reconcile must preserve local), while + /// `Some(vec![])` = explicitly emptied. New clients always publish + /// `Some(...)`, even when empty. "Cleaning up" the Option later + /// reintroduces the bug where an old client's event silently wipes team + /// membership (see the Sietch Tabr incident). #[serde(default, skip_serializing_if = "Option::is_none")] - pub instructions: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub persona_ids: Vec, + pub persona_ids: Option>, } /// Project a `TeamRecord` onto the content fields published in team events. @@ -37,8 +54,11 @@ pub fn team_event_content(record: &TeamRecord) -> TeamEventContent { TeamEventContent { name: record.name.clone(), description: record.description.clone(), - instructions: record.instructions.clone(), - persona_ids: record.persona_ids.clone(), + // Always `Some`, even when the inner value is absent — `None` is + // reserved for events from clients that predate always-publish (see + // the struct doc comments). + instructions: Some(record.instructions.clone()), + persona_ids: Some(record.persona_ids.clone()), } } @@ -152,6 +172,75 @@ mod tests { assert_eq!(restored, event_content); } + // ── persona_ids wire semantics (omission must not wipe membership) ──── + + #[test] + fn content_from_old_clients_without_persona_ids_parses_none() { + // Events published before always-publish must still parse. The + // field reads back `None` — NOT an empty list — so the reconcile + // can distinguish "publisher predates always-publish" from + // "explicitly emptied" and preserve local membership (see + // apply_inbound_team). This is the exact shape of the Sietch Tabr + // wipe event. + let legacy = r#"{"name":"Old Team"}"#; + let restored: TeamEventContent = serde_json::from_str(legacy).unwrap(); + assert_eq!(restored.name, "Old Team"); + assert_eq!(restored.persona_ids, None); + } + + #[test] + fn content_publishes_some_even_when_persona_ids_empty() { + // New clients must always publish `Some`, even for an empty list — + // `Some(vec![])` is the explicit "no persona members" signal that a + // pre-fix client can never produce. + let mut team = sample_team(); + team.persona_ids = vec![]; + let event_content = team_event_content(&team); + assert_eq!(event_content.persona_ids, Some(vec![])); + let json = serde_json::to_string(&event_content).unwrap(); + assert!(json.contains("\"persona_ids\":[]")); + } + + // ── instructions wire semantics (tri-state: absent/null/value) ──────── + // + // Deserialized from raw JSON strings, not constructed enum states + // directly — the bug is specifically in how serde maps JSON shapes onto + // the tri-state, so the test must exercise that mapping (mirrors + // `double_option_tristate` in `util.rs`). + + #[test] + fn content_from_old_clients_without_instructions_parses_none() { + let legacy = r#"{"name":"Old Team","persona_ids":["p1"]}"#; + let restored: TeamEventContent = serde_json::from_str(legacy).unwrap(); + assert_eq!(restored.instructions, None); + } + + #[test] + fn content_with_explicit_null_instructions_parses_some_none() { + let json = r#"{"name":"Team","persona_ids":["p1"],"instructions":null}"#; + let restored: TeamEventContent = serde_json::from_str(json).unwrap(); + assert_eq!(restored.instructions, Some(None)); + } + + #[test] + fn content_with_instructions_value_parses_some_some() { + let json = r#"{"name":"Team","persona_ids":["p1"],"instructions":"Coordinate."}"#; + let restored: TeamEventContent = serde_json::from_str(json).unwrap(); + assert_eq!(restored.instructions, Some(Some("Coordinate.".to_string()))); + } + + #[test] + fn content_publishes_some_none_when_instructions_absent_locally() { + // New clients must always publish the field — `null` is the explicit + // "no instructions" signal that a pre-fix client can never produce. + let mut team = sample_team(); + team.instructions = None; + let event_content = team_event_content(&team); + assert_eq!(event_content.instructions, Some(None)); + let json = serde_json::to_string(&event_content).unwrap(); + assert!(json.contains("\"instructions\":null")); + } + #[test] fn build_team_delete_has_single_a_tag_no_e_tag() { const OWNER: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index dbdd0a2c6..ac92ffee8 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -28,6 +28,10 @@ import { ResetFailedScreen } from "@/features/onboarding/ui/ResetFailedScreen"; import { useCommunityInit } from "@/features/communities/useCommunityInit"; import { useNestNotifications } from "@/features/communities/useNestNotifications"; import { useCommunities } from "@/features/communities/useCommunities"; +import { + onAddCommunityPrefillAvailable, + requestAddCommunityPrefill, +} from "@/features/communities/addCommunityPrefill"; import { WelcomeSetup } from "@/features/communities/ui/WelcomeSetup"; import { CommunityApplyErrorScreen } from "@/features/communities/ui/CommunityApplyErrorScreen"; import { CommunityChangeOverlay } from "@/features/communities/ui/CommunityChangeOverlay"; @@ -396,6 +400,8 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) { useEffect(() => { const unlisten = listenForDeepLinks({ startCommunityOnboarding: communityOnboarding.start, + openAddCommunity: requestAddCommunityPrefill, + onAddCommunityAvailable: onAddCommunityPrefillAvailable, }); return () => { void unlisten.then((fn) => fn()); diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index eadb84d9d..44cedc9d4 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -54,7 +54,7 @@ import { } from "@/features/user-status/hooks"; import { useCommunityEmojiLiveUpdates } from "@/features/custom-emoji/hooks"; import { useArchiveSync } from "@/features/local-archive/archiveSyncManager"; -import { useObserverArchiveSeed } from "@/features/local-archive/useObserverArchiveSeed"; +import { useObserverArchiveReconciliation } from "@/features/local-archive/useObserverArchiveSeed"; import { useAgentMetricArchiveSeed } from "@/features/local-archive/useAgentMetricArchiveSeed"; import { useProfileQuery } from "@/features/profile/hooks"; import { SendFeedbackController } from "@/features/settings/ui/SendFeedbackController"; @@ -72,6 +72,7 @@ import { CommunityRail } from "@/features/sidebar/ui/CommunityRail"; import { useChannelMutes } from "@/features/sidebar/lib/useChannelMutes"; import { useChannelStars } from "@/features/sidebar/lib/useChannelStars"; import { useCommunities } from "@/features/communities/useCommunities"; +import { useAddCommunityDialogState } from "@/features/communities/addCommunityPrefill"; import { useApplyTemplate } from "@/features/channel-templates/useApplyTemplate"; import { relayClient } from "@/shared/api/relayClient"; import { useFeatureEnabled } from "@/shared/features"; @@ -103,7 +104,7 @@ export function AppShell() { const communitiesHook = useCommunities(); const communityRailEnabled = useFeatureEnabled("workspaceRail"); - const [isAddCommunityOpen, setIsAddCommunityOpen] = React.useState(false); + const addCommunityDialog = useAddCommunityDialogState(); const [isChannelManagementOpen, setIsChannelManagementOpen] = React.useState(false); const [managedChannelId, setManagedChannelId] = React.useState( @@ -186,15 +187,18 @@ export function AppShell() { // relay-owned agents join automatically once identity arrives. Adding a // guard here would drop managed-agent coverage during startup. useAgentObserverIngestion(); - useArchiveSync(); - // Defer the archive *seeds* until startup is idle: they're first-run catch-up - // config (a one-shot mergeSaveSubscriptionKinds), not live-ingest — that's - // useArchiveSync's job, which stays eager above. Passing deferredPubkey makes - // each seed hook wait on its own `if (!pubkey) return` guard until the shell - // is interactive, so their IPC + sqlite archive open doesn't compete with - // first paint. The explicit-choice guard inside each hook is unchanged. + // Kind 24200 is relay-ephemeral, so reconciliation runs eagerly (not + // deferred) and unconditionally repairs the DB subscription on internal + // builds — otherwise frames emitted before the listener opens are lost. + const observerReconciled = useObserverArchiveReconciliation( + identityQuery.data?.pubkey, + ); + // useArchiveSync must wait for reconciliation, or listeners could open + // before kind 24200 is guaranteed present in the subscription. + useArchiveSync(observerReconciled); + // Kind 44200 is relay-persisted (durable) and stays deferred: missed + // startup frames can be replayed, so there's no ordering constraint. const deferredPubkey = startupReady ? identityQuery.data?.pubkey : undefined; - useObserverArchiveSeed(deferredPubkey); useAgentMetricArchiveSeed(deferredPubkey); const profileQuery = useProfileQuery(); useRelayAutoHeal(); @@ -763,7 +767,7 @@ export function AppShell() { activeCommunityId={ communitiesHook.activeCommunity?.id ?? null } - onAddCommunity={() => setIsAddCommunityOpen(true)} + onAddCommunity={addCommunityDialog.openDialog} onRemoveCommunity={communitiesHook.removeCommunity} onSwitchCommunity={handleSwitchCommunity} onUpdateCommunity={communitiesHook.updateCommunity} @@ -834,7 +838,8 @@ export function AppShell() { errorMessage={channelsErrorMessage} fallbackDisplayName={identityQuery.data?.displayName} homeBadgeCount={homeBadgeCount + dueReminderBadge} - isAddCommunityOpen={isAddCommunityOpen} + addCommunityPrefill={addCommunityDialog.prefill} + isAddCommunityOpen={addCommunityDialog.open} relayConnectionCard={relayConnectionCard} isCreatingChannel={createChannelMutation.isPending} isCreatingForum={createForumMutation.isPending} @@ -845,10 +850,12 @@ export function AppShell() { const id = communitiesHook.addCommunity(community); handleSwitchCommunity(id); }} - onAddCommunityOpenChange={setIsAddCommunityOpen} + onAddCommunityOpenChange={ + addCommunityDialog.onOpenChange + } onNewMessage={handleOpenNewDm} onCreateChannelOpenChange={setIsCreateChannelOpen} - onOpenAddCommunity={() => setIsAddCommunityOpen(true)} + onOpenAddCommunity={addCommunityDialog.openDialog} onSendFeedback={() => setIsSendFeedbackOpen(true)} onUpdateCommunity={communitiesHook.updateCommunity} onRemoveCommunity={communitiesHook.removeCommunity} diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 4523e69d7..8b319edb2 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -57,7 +57,10 @@ import { Button } from "@/shared/ui/button"; import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel"; import { useRenderScopedReactionHydration } from "@/features/messages/lib/useRenderScopedReactionHydration"; import type { TimelineMessage } from "@/features/messages/types"; -import { isWelcomeChannel } from "@/features/onboarding/welcome"; +import { + isWelcomeChannel, + isWelcomeExperienceChannel as isWelcomeExperience, +} from "@/features/onboarding/welcome"; import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile"; import { channelChrome } from "@/shared/layout/chromeLayout"; @@ -203,7 +206,7 @@ export const ChannelPane = React.memo(function ChannelPane({ const huddleMemberPubkeysPending = agentPubkeysPending && hasOtherDmParticipant(activeChannel, currentPubkey); const isActiveWelcomeChannel = - activeChannel !== null && isWelcomeChannel(activeChannel); + activeChannel !== null && isWelcomeExperience(activeChannel); useComposerHeightPadding( timelineScrollRef, composerWrapperRef, @@ -438,7 +441,7 @@ export const ChannelPane = React.memo(function ChannelPane({ } const actions = []; - if (isWelcomeChannel(activeChannel)) { + if (isWelcomeExperience(activeChannel)) { if (onBrowseChannels) { actions.push({ icon: , @@ -447,7 +450,6 @@ export const ChannelPane = React.memo(function ChannelPane({ testId: "welcome-intro-action-browse-channels", }); } - if (onCreateChannel) { actions.push({ icon: , @@ -456,7 +458,6 @@ export const ChannelPane = React.memo(function ChannelPane({ testId: "welcome-intro-action-create-channel", }); } - if (onAddAgent) { actions.push({ icon: , @@ -469,12 +470,15 @@ export const ChannelPane = React.memo(function ChannelPane({ testId: "welcome-intro-action-create-agent", }); } - return { actions, - channelKindLabel: "private welcome channel", + channelKindLabel: isWelcomeChannel(activeChannel) + ? "private welcome channel" + : getChannelIntroKind(activeChannel), channelName: activeChannel.name, - description: null, + description: isWelcomeChannel(activeChannel) + ? null + : getChannelIntroDescription(activeChannel), icon: , }; } @@ -514,9 +518,8 @@ export const ChannelPane = React.memo(function ChannelPane({ onCreateChannel, onOpenMembers, ]); - const visibleMessages = React.useMemo(() => { - if (!isWelcomeChannel(activeChannel)) { + if (!isWelcomeExperience(activeChannel)) { return messages; } diff --git a/desktop/src/features/channels/useWelcomeAgentCreate.ts b/desktop/src/features/channels/useWelcomeAgentCreate.ts index a9c19e6a2..cf102b9fb 100644 --- a/desktop/src/features/channels/useWelcomeAgentCreate.ts +++ b/desktop/src/features/channels/useWelcomeAgentCreate.ts @@ -2,7 +2,7 @@ import * as React from "react"; import { requestOpenCreateAgent } from "@/features/agents/openCreateAgentEvent"; import { useSendMessageMutation } from "@/features/messages/hooks"; -import { isWelcomeChannel } from "@/features/onboarding/welcome"; +import { isWelcomeExperienceChannel } from "@/features/onboarding/welcome"; import type { Channel, Identity } from "@/shared/api/types"; type WelcomeGuideAgent = { @@ -31,7 +31,7 @@ export function useWelcomeAgentCreate({ const openAddAgent = React.useCallback( (openRegularPicker: () => void, options?: { beforeSend?: () => void }) => { setError(null); - if (isWelcomeChannel(activeChannel)) { + if (isWelcomeExperienceChannel(activeChannel)) { beforeSendRef.current = options?.beforeSend ?? null; setIsOpen(true); return; diff --git a/desktop/src/features/communities/addCommunityPrefill.ts b/desktop/src/features/communities/addCommunityPrefill.ts new file mode 100644 index 000000000..375a85d59 --- /dev/null +++ b/desktop/src/features/communities/addCommunityPrefill.ts @@ -0,0 +1,64 @@ +import * as React from "react"; + +import type { AddCommunityDeepLinkPayload } from "@/shared/deep-link"; + +export type AddCommunityPrefillRequest = AddCommunityDeepLinkPayload & { + requestId: string; +}; + +let currentRequest: AddCommunityPrefillRequest | null = null; +const listeners = new Set<() => void>(); +const availableListeners = new Set<() => void>(); + +export function requestAddCommunityPrefill( + request: AddCommunityPrefillRequest, +): boolean { + if (currentRequest) return false; + currentRequest = request; + for (const listener of listeners) listener(); + return true; +} + +export function clearAddCommunityPrefill(requestId: string): void { + if (!currentRequest || currentRequest.requestId !== requestId) return; + currentRequest = null; + for (const listener of listeners) listener(); + for (const listener of availableListeners) listener(); +} + +export function onAddCommunityPrefillAvailable( + listener: () => void, +): () => void { + availableListeners.add(listener); + return () => availableListeners.delete(listener); +} + +function useAddCommunityPrefill(): AddCommunityPrefillRequest | null { + return React.useSyncExternalStore( + (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + () => currentRequest, + () => null, + ); +} + +export function useAddCommunityDialogState() { + const prefill = useAddCommunityPrefill(); + const [open, setOpen] = React.useState(false); + + React.useEffect(() => { + if (prefill) setOpen(true); + }, [prefill]); + + const onOpenChange = React.useCallback( + (nextOpen: boolean) => { + setOpen(nextOpen); + if (!nextOpen && prefill) clearAddCommunityPrefill(prefill.requestId); + }, + [prefill], + ); + + return { prefill, open, onOpenChange, openDialog: () => setOpen(true) }; +} diff --git a/desktop/src/features/communities/ui/AddCommunityDialog.tsx b/desktop/src/features/communities/ui/AddCommunityDialog.tsx index 00986205f..227804e7b 100644 --- a/desktop/src/features/communities/ui/AddCommunityDialog.tsx +++ b/desktop/src/features/communities/ui/AddCommunityDialog.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import type { AddCommunityPrefillRequest } from "@/features/communities/addCommunityPrefill"; import { deriveCommunityName, expandTilde, @@ -30,6 +31,7 @@ const POLICY_DISCOVERY_DELAY_MS = 250; const POLICY_REVEAL_EASE = [0.23, 1, 0.32, 1] as const; type AddCommunityDialogProps = { + prefill?: AddCommunityPrefillRequest | null; onSubmit?: ( community: import("@/features/communities/types").Community, ) => void; @@ -38,6 +40,7 @@ type AddCommunityDialogProps = { }; export function AddCommunityDialog({ + prefill, open, onOpenChange, }: AddCommunityDialogProps) { @@ -53,6 +56,18 @@ export function AddCommunityDialog({ const communityOnboarding = useCommunityOnboarding(); const [reposDirError, setReposDirError] = React.useState(null); const shouldReduceMotion = useReducedMotion(); + const appliedPrefillId = React.useRef(null); + + React.useEffect(() => { + if (!prefill || appliedPrefillId.current === prefill.requestId) return; + appliedPrefillId.current = prefill.requestId; + setName(prefill.name ?? deriveCommunityName(prefill.relayUrl)); + setRelayUrl(prefill.relayUrl); + setToken(""); + setInviteCode(""); + setReposDir(""); + setReposDirError(null); + }, [prefill]); React.useEffect(() => { if (!open || !relayUrl.trim()) return; @@ -181,7 +196,13 @@ export function AddCommunityDialog({ ); return ( - + { + if (!nextOpen) handleClose(); + else onOpenChange(true); + }} + open={open} + > Add Community diff --git a/desktop/src/features/community-members/ui/InviteLinkSection.tsx b/desktop/src/features/community-members/ui/InviteLinkSection.tsx index fc0eddca4..9852b6881 100644 --- a/desktop/src/features/community-members/ui/InviteLinkSection.tsx +++ b/desktop/src/features/community-members/ui/InviteLinkSection.tsx @@ -1,4 +1,5 @@ import { Check, Copy, Link2 } from "lucide-react"; +import { QRCodeSVG } from "qrcode.react"; import * as React from "react"; import { toast } from "sonner"; @@ -155,11 +156,34 @@ export function InviteLinkSection() { ) : null} -

- {invite - ? `Anyone with this link can join as a member until ${formatExpiry(invite.expiresAt)}.` - : "Create a shareable link that lets anyone join this relay as a member until it expires."} -

+ {invite ? ( +
+
+ +
+
+

+ Scan this QR code or share the link above to invite someone to + this relay. +

+

+ Anyone with this link or QR code can join as a member until{" "} + {formatExpiry(invite.expiresAt)}. +

+
+
+ ) : ( +

+ Create a shareable link that lets anyone join this relay as a member + until it expires. +

+ )} ); } diff --git a/desktop/src/features/local-archive/archiveSyncManager.ts b/desktop/src/features/local-archive/archiveSyncManager.ts index 1361f548b..c97364169 100644 --- a/desktop/src/features/local-archive/archiveSyncManager.ts +++ b/desktop/src/features/local-archive/archiveSyncManager.ts @@ -303,16 +303,19 @@ export class ArchiveSyncManager { import * as React from "react"; /** - * Starts the ArchiveSyncManager at app-shell mount and tears it down when the - * component unmounts (community switch). No return value needed — the manager - * runs entirely in the background. + * Starts the ArchiveSyncManager once `ready` is true and tears it down on + * unmount. The `ready` gate ensures observer reconciliation completes before + * any relay listeners open — kind 24200 is relay-ephemeral, so frames emitted + * before the listener opens are permanently lost. */ -export function useArchiveSync(): void { +export function useArchiveSync(ready: boolean): void { React.useEffect(() => { + if (!ready) return; + const manager = new ArchiveSyncManager(); void manager.start(); return () => { manager.destroy(); }; - }, []); + }, [ready]); } diff --git a/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx b/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx index 883c51987..a3e26dca7 100644 --- a/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx +++ b/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx @@ -25,6 +25,7 @@ import { SettingsOptionRow, } from "@/features/settings/ui/SettingsOptionGroup"; import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader"; +import { observerArchiveDefaultEnabled } from "@/shared/api/tauriArchive"; import { setExplicitObserverArchiveChoice } from "../observerArchivePreference"; import { setExplicitAgentMetricArchiveChoice } from "../agentMetricArchivePreference"; @@ -66,15 +67,18 @@ function kindSummary(kinds: number[]): string { type ObserverSectionProps = { enabled: boolean; + policy: boolean | undefined; toggling: boolean; onToggle: (checked: boolean) => void; }; function ObserverArchiveSection({ enabled, + policy, toggling, onToggle, }: ObserverSectionProps) { + const toggleDisabled = toggling || policy === undefined || policy === true; return (

@@ -90,15 +94,15 @@ function ObserverArchiveSection({ Archive my agents' observer frames

- Saves kind {KIND_AGENT_OBSERVER_FRAME} observer frames addressed - to your pubkey. These are ephemeral — not stored by the relay — so - local archiving is the only way to retain them. + {policy === true + ? `Always on for internal builds. Kind ${KIND_AGENT_OBSERVER_FRAME} observer frames are ephemeral — not stored by the relay — so local archiving is the only way to retain them.` + : `Saves kind ${KIND_AGENT_OBSERVER_FRAME} observer frames addressed to your pubkey. These are ephemeral — not stored by the relay — so local archiving is the only way to retain them.`}

@@ -389,6 +393,17 @@ export function LocalArchiveSettingsCard() { const [isAddingOpen, setIsAddingOpen] = React.useState(false); const [observerToggling, setObserverToggling] = React.useState(false); const [metricToggling, setMetricToggling] = React.useState(false); + const [observerPolicy, setObserverPolicy] = React.useState< + boolean | undefined + >(undefined); + + React.useEffect(() => { + observerArchiveDefaultEnabled() + .then((on) => setObserverPolicy(on)) + .catch(() => { + // Fail closed: leave as undefined so toggle stays disabled. + }); + }, []); const pubkey = identityQuery.data?.pubkey ?? ""; @@ -451,6 +466,7 @@ export function LocalArchiveSettingsCard() { const handleObserverToggle = React.useCallback( async (checked: boolean) => { if (!pubkey) return; + if (!checked && observerPolicy !== false) return; setObserverToggling(true); try { if (checked) { @@ -475,7 +491,7 @@ export function LocalArchiveSettingsCard() { setObserverToggling(false); } }, - [pubkey, reload], + [pubkey, observerPolicy, reload], ); const handleMetricToggle = React.useCallback( @@ -525,6 +541,7 @@ export function LocalArchiveSettingsCard() { void handleObserverToggle(checked)} + policy={observerPolicy} toggling={observerToggling} /> diff --git a/desktop/src/features/local-archive/useObserverArchiveSeed.test.mjs b/desktop/src/features/local-archive/useObserverArchiveSeed.test.mjs index 54128282d..4c96b14f8 100644 --- a/desktop/src/features/local-archive/useObserverArchiveSeed.test.mjs +++ b/desktop/src/features/local-archive/useObserverArchiveSeed.test.mjs @@ -1,156 +1,317 @@ -/** - * Tests for useObserverArchiveSeed seeding logic. - * - * The hook is a thin React wrapper around an async seed routine. We test the - * behaviour by driving the deps-injection interface directly — no React needed. - * This mirrors the pattern used in archiveSyncManager.test.mjs. - */ - import assert from "node:assert/strict"; import test from "node:test"; -// We call the seeding function directly via deps-injection rather than -// mounting the hook, so we import the seed-coordination logic by -// re-implementing a minimal version of `maybeSeed` driven by the same deps -// interface. The test boundary is the async coordination logic, not React. +import { + isReconciledFor, + reconcileObserverArchive, +} from "./useObserverArchiveSeed.ts"; +import { ArchiveSyncManager } from "./archiveSyncManager.ts"; // ── Fake deps factory ──────────────────────────────────────────────────────── function makeDeps({ - defaultOn = false, - hasExplicitChoice = false, + policyOn = false, mergeShouldFail = false, + flagShouldFail = false, } = {}) { - const calls = { mergeSaveSubscriptionKinds: [], setExplicitChoice: [] }; + const calls = { merge: [], setChoice: [] }; return { calls, - observerArchiveDefaultEnabled: async () => defaultOn, + observerArchiveDefaultEnabled: async () => { + if (flagShouldFail) throw new Error("flag check failed"); + return policyOn; + }, mergeSaveSubscriptionKinds: async (kind) => { if (mergeShouldFail) throw new Error("merge failed"); - calls.mergeSaveSubscriptionKinds.push({ kind }); + calls.merge.push({ kind }); }, - hasExplicitChoice: (_pubkey) => hasExplicitChoice, setExplicitChoice: (pubkey, enabled) => { - calls.setExplicitChoice.push({ pubkey, enabled }); + calls.setChoice.push({ pubkey, enabled }); }, }; } -// Minimal re-implementation of the seeding logic from useObserverArchiveSeed.ts. -// Kept in sync with the source by structural mirroring (not bytewise copy) — -// change the source and update this accordingly. -const KIND_AGENT_OBSERVER_FRAME = 24200; - -async function runSeed(pubkey, deps) { - if (!pubkey) return; - if (deps.hasExplicitChoice(pubkey)) return; - - let defaultOn; - try { - defaultOn = await deps.observerArchiveDefaultEnabled(); - } catch { - return; - } - - if (!defaultOn) return; - - try { - await deps.mergeSaveSubscriptionKinds(KIND_AGENT_OBSERVER_FRAME); - } catch { - return; // transient failure — do NOT set explicit choice - } - - deps.setExplicitChoice(pubkey, true); +/** Helper: wait for microtasks/promises to settle */ +function tick() { + return new Promise((r) => setTimeout(r, 0)); } -// ── Tests ──────────────────────────────────────────────────────────────────── +// ── Internal policy build ──────────────────────────────────────────────────── -test("test_internal_build_unset_seeds_owner_p_subscription", async () => { - const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false }); - await runSeed("pubkey123", deps); +test("test_internal_policy_marker_null_seeds_24200", async () => { + const deps = makeDeps({ policyOn: true }); + await reconcileObserverArchive("pk1", deps); - assert.equal( - deps.calls.mergeSaveSubscriptionKinds.length, - 1, - "should call mergeSaveSubscriptionKinds once", - ); - const call = deps.calls.mergeSaveSubscriptionKinds[0]; - assert.equal(call.kind, 24200); + assert.equal(deps.calls.merge.length, 1); + assert.equal(deps.calls.merge[0].kind, 24200); + assert.deepEqual(deps.calls.setChoice, [{ pubkey: "pk1", enabled: true }]); }); -test("test_internal_build_unset_persists_explicit_choice_after_seed", async () => { - const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false }); - await runSeed("pubkey123", deps); +test("test_internal_policy_marker_0_still_seeds_24200", async () => { + const deps = makeDeps({ policyOn: true }); + await reconcileObserverArchive("pk1", deps); - assert.equal( - deps.calls.setExplicitChoice.length, - 1, - "should persist explicit choice after successful seed", - ); - assert.equal(deps.calls.setExplicitChoice[0].pubkey, "pubkey123"); - assert.equal(deps.calls.setExplicitChoice[0].enabled, true); + assert.equal(deps.calls.merge.length, 1, "must merge even with stale marker"); + assert.equal(deps.calls.merge[0].kind, 24200); }); -test("test_explicit_choice_set_does_not_reseed", async () => { - const deps = makeDeps({ defaultOn: true, hasExplicitChoice: true }); - await runSeed("pubkey123", deps); +test("test_internal_policy_marker_1_still_seeds_24200", async () => { + const deps = makeDeps({ policyOn: true }); + await reconcileObserverArchive("pk1", deps); - assert.equal( - deps.calls.mergeSaveSubscriptionKinds.length, - 0, - "should not call mergeSaveSubscriptionKinds when explicit choice is already set", - ); - assert.equal( - deps.calls.setExplicitChoice.length, - 0, - "should not update explicit choice when already set", - ); + assert.equal(deps.calls.merge.length, 1, "must reconcile even with marker 1"); }); -test("test_oss_build_does_not_seed", async () => { - const deps = makeDeps({ defaultOn: false, hasExplicitChoice: false }); - await runSeed("pubkey123", deps); +// ── OSS build — policy-off is a pure no-op ────────────────────────────────── - assert.equal( - deps.calls.mergeSaveSubscriptionKinds.length, - 0, - "should not call mergeSaveSubscriptionKinds in OSS build", - ); - assert.equal( - deps.calls.setExplicitChoice.length, - 0, - "should not persist explicit choice in OSS build", - ); +test("test_oss_marker_null_no_merge", async () => { + const deps = makeDeps({ policyOn: false }); + await reconcileObserverArchive("pk1", deps); + + assert.equal(deps.calls.merge.length, 0, "OSS must not merge"); + assert.equal(deps.calls.setChoice.length, 0, "OSS must not write marker"); }); -test("test_merge_failure_does_not_persist_explicit_choice", async () => { - const deps = makeDeps({ - defaultOn: true, - hasExplicitChoice: false, - mergeShouldFail: true, +test("test_oss_marker_0_no_merge", async () => { + const deps = makeDeps({ policyOn: false }); + await reconcileObserverArchive("pk1", deps); + + assert.equal(deps.calls.merge.length, 0, "OSS must not merge"); + assert.equal(deps.calls.setChoice.length, 0, "OSS must not write marker"); +}); + +test("test_oss_marker_1_no_merge", async () => { + const deps = makeDeps({ policyOn: false }); + await reconcileObserverArchive("pk1", deps); + + assert.equal(deps.calls.merge.length, 0, "OSS must not merge"); + assert.equal(deps.calls.setChoice.length, 0, "OSS must not write marker"); +}); + +// ── Failure behavior ───────────────────────────────────────────────────────── + +test("test_merge_failure_rejects_no_marker_persisted", async () => { + const deps = makeDeps({ policyOn: true, mergeShouldFail: true }); + + await assert.rejects(() => reconcileObserverArchive("pk1", deps), { + message: "merge failed", }); - await runSeed("pubkey123", deps); - assert.equal( - deps.calls.setExplicitChoice.length, + deps.calls.setChoice.length, 0, - "should NOT persist explicit choice after a transient merge failure", + "must not persist marker on merge failure", ); }); -test("test_empty_pubkey_does_nothing", async () => { - const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false }); - await runSeed("", deps); +test("test_flag_check_failure_rejects", async () => { + const deps = makeDeps({ flagShouldFail: true }); - assert.equal(deps.calls.mergeSaveSubscriptionKinds.length, 0); - assert.equal(deps.calls.setExplicitChoice.length, 0); + await assert.rejects(() => reconcileObserverArchive("pk1", deps), { + message: "flag check failed", + }); + assert.equal(deps.calls.merge.length, 0); + assert.equal(deps.calls.setChoice.length, 0); }); -test("test_undefined_pubkey_does_nothing", async () => { - const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false }); - await runSeed(undefined, deps); +// ── Startup ordering (real ArchiveSyncManager + real reconciler) ───────────── - assert.equal(deps.calls.mergeSaveSubscriptionKinds.length, 0); - assert.equal(deps.calls.setExplicitChoice.length, 0); +test("test_archive_sync_blocked_until_reconciliation", async () => { + let resolveFlag; + const flagPromise = new Promise((resolve) => { + resolveFlag = resolve; + }); + + const subscribeCalls = []; + const fakeRelay = { + subscribeLive(filter, _callback) { + subscribeCalls.push(filter); + return Promise.resolve(async () => {}); + }, + }; + + const reconcilerDeps = { + observerArchiveDefaultEnabled: () => flagPromise, + mergeSaveSubscriptionKinds: async () => {}, + setExplicitChoice: () => {}, + }; + + const manager = new ArchiveSyncManager({ + relayClient: fakeRelay, + listSaveSubscriptions: async () => [ + { + scopeType: "owner_p", + scopeValue: "pk1", + kinds: [24200], + identityPubkey: "pk1", + relayUrl: "wss://r", + createdAt: 0, + }, + ], + archiveEvents: async () => ({ persisted: 0, dropped: 0 }), + onSubscriptionChange: () => () => {}, + }); + + // Start reconciliation (pending — flag check not yet resolved). + const reconciling = reconcileObserverArchive("pk1", reconcilerDeps); + + // Before reconciliation resolves, manager must not have been started. + await tick(); + assert.equal( + subscribeCalls.length, + 0, + "subscribeLive must not run before reconciliation", + ); + + // Resolve reconciliation — now start the manager (simulating the gate). + resolveFlag(true); + await reconciling; + await manager.start(); + + assert.ok( + subscribeCalls.length > 0, + "subscribeLive must run after gate opens", + ); + const hasOwnerP = subscribeCalls.some((f) => f["#p"]?.length > 0); + assert.ok(hasOwnerP, "subscription must use owner_p (#p) filter"); + const hasKind24200 = subscribeCalls.some((f) => f.kinds?.includes(24200)); + assert.ok(hasKind24200, "subscription filter must include kind 24200"); + + manager.destroy(); +}); + +test("test_archive_sync_blocked_on_reconciliation_rejection", async () => { + const reconcilerDeps = makeDeps({ policyOn: true, mergeShouldFail: true }); + + const subscribeCalls = []; + const fakeRelay = { + subscribeLive(filter) { + subscribeCalls.push(filter); + return Promise.resolve(async () => {}); + }, + }; + + const manager = new ArchiveSyncManager({ + relayClient: fakeRelay, + listSaveSubscriptions: async () => [ + { + scopeType: "owner_p", + scopeValue: "pk1", + kinds: [24200], + identityPubkey: "pk1", + relayUrl: "wss://r", + createdAt: 0, + }, + ], + archiveEvents: async () => ({ persisted: 0, dropped: 0 }), + onSubscriptionChange: () => () => {}, + }); + + // Reconciliation rejects — gate must remain closed. + let rejected = false; + try { + await reconcileObserverArchive("pk1", reconcilerDeps); + } catch { + rejected = true; + } + assert.ok(rejected, "reconciliation must reject on merge failure"); + + // Manager must NOT start after failed reconciliation. + assert.equal( + subscribeCalls.length, + 0, + "subscribeLive must not run after failed reconciliation", + ); + + manager.destroy(); +}); + +// ── Identity-scoped readiness (exercises exported isReconciledFor) ────────── + +test("test_isReconciledFor_null_returns_false", () => { + assert.equal(isReconciledFor(null, "pk1"), false); + assert.equal(isReconciledFor(null, undefined), false); +}); + +test("test_isReconciledFor_undefined_pubkey_returns_false", () => { + assert.equal(isReconciledFor("pk1", undefined), false); +}); + +test("test_isReconciledFor_matching_returns_true", () => { + assert.equal(isReconciledFor("pk1", "pk1"), true); +}); + +test("test_isReconciledFor_mismatch_returns_false", () => { + assert.equal(isReconciledFor("pkA", "pkB"), false); +}); + +test("test_identity_change_resets_readiness", async () => { + let reconciledPubkey = null; + + // Identity A reconciles successfully. + const depsA = makeDeps({ policyOn: true }); + await reconcileObserverArchive("pkA", depsA); + reconciledPubkey = "pkA"; + assert.equal( + isReconciledFor(reconciledPubkey, "pkA"), + true, + "A is reconciled", + ); + + // Identity changes to B — gate must be false before B reconciles. + assert.equal( + isReconciledFor(reconciledPubkey, "pkB"), + false, + "gate must be false for new identity before reconciliation", + ); + + // B reconciles successfully. + const depsB = makeDeps({ policyOn: true }); + await reconcileObserverArchive("pkB", depsB); + reconciledPubkey = "pkB"; + assert.equal( + isReconciledFor(reconciledPubkey, "pkB"), + true, + "B is reconciled", + ); + assert.equal( + isReconciledFor(reconciledPubkey, "pkA"), + false, + "gate must be false for previous identity", + ); +}); + +test("test_identity_change_b_failure_stays_closed", async () => { + let reconciledPubkey = null; + + // Identity A reconciles successfully. + const depsA = makeDeps({ policyOn: true }); + await reconcileObserverArchive("pkA", depsA); + reconciledPubkey = "pkA"; + + // Identity changes to B — B's reconciliation fails. + const depsB = makeDeps({ policyOn: true, mergeShouldFail: true }); + try { + await reconcileObserverArchive("pkB", depsB); + reconciledPubkey = "pkB"; + } catch { + // B failed — reconciledPubkey stays "pkA" (stale). + } + + // Gate for B must be false (stale A pubkey !== current B). + assert.equal( + isReconciledFor(reconciledPubkey, "pkB"), + false, + "gate must be false when B reconciliation fails", + ); +}); + +// ── Metric seed independence ───────────────────────────────────────────────── + +test("test_metric_seed_remains_independently_deferrable", async () => { + const deps = makeDeps({ policyOn: true }); + await reconcileObserverArchive("pk1", deps); + + assert.equal(deps.calls.merge.length, 1); + assert.equal(deps.calls.merge[0].kind, 24200, "must only touch kind 24200"); }); diff --git a/desktop/src/features/local-archive/useObserverArchiveSeed.ts b/desktop/src/features/local-archive/useObserverArchiveSeed.ts index f547493da..7d5fd621a 100644 --- a/desktop/src/features/local-archive/useObserverArchiveSeed.ts +++ b/desktop/src/features/local-archive/useObserverArchiveSeed.ts @@ -1,20 +1,3 @@ -/** - * First-run seeding for observer-feed archive. - * - * When an internal build has `BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT` set and the - * current identity has not yet made an explicit choice, this hook - * auto-creates an `owner_p` save subscription including kind 24200 observer - * frames, scoped to the current identity's pubkey. - * - * Uses `mergeSaveSubscriptionKinds` (atomic DB-side merge) so a concurrently - * running metric seed (44200) cannot clobber this kind — the union happens - * under a single SQLite transaction regardless of await ordering. - * - * OSS builds return `false` from `observer_archive_default_enabled` → no-op. - * After any explicit user action (seeding or opt-out), the localStorage flag - * prevents re-seeding on subsequent starts. - */ - import * as React from "react"; import { KIND_AGENT_OBSERVER_FRAME } from "@/shared/constants/kinds"; @@ -22,93 +5,94 @@ import { mergeSaveSubscriptionKinds, observerArchiveDefaultEnabled, } from "@/shared/api/tauriArchive"; -import { - hasExplicitObserverArchiveChoice, - setExplicitObserverArchiveChoice, -} from "./observerArchivePreference"; +import { setExplicitObserverArchiveChoice } from "./observerArchivePreference"; -/** - * Deps interface for testing. Production callers pass nothing. - */ export interface ObserverArchiveSeedDeps { observerArchiveDefaultEnabled: () => Promise; mergeSaveSubscriptionKinds: (kind: number) => Promise; - hasExplicitChoice: (pubkey: string) => boolean; setExplicitChoice: (pubkey: string, enabled: boolean) => void; } const defaultDeps: ObserverArchiveSeedDeps = { observerArchiveDefaultEnabled, mergeSaveSubscriptionKinds, - hasExplicitChoice: hasExplicitObserverArchiveChoice, setExplicitChoice: setExplicitObserverArchiveChoice, }; /** - * Seed the observer-feed archive subscription for `pubkey` once per identity - * per device on internal builds. + * Reconcile observer-feed archive state for `pubkey`. * - * @param pubkey - current identity pubkey. When undefined (identity not yet - * loaded), the hook waits until it becomes available. - * @param deps - optional dep-injection for tests. + * Internal builds (policy flag ON): unconditionally ensure kind 24200 exists + * in the DB subscription, regardless of localStorage marker state. + * + * OSS builds (policy flag OFF): no-op. The Settings toggle is the only + * mutation path for OSS users. + * + * Rejects on failure — callers must not open archive listeners against + * unreconciled state. */ -export function useObserverArchiveSeed( +export async function reconcileObserverArchive( + pubkey: string, + deps: ObserverArchiveSeedDeps = defaultDeps, +): Promise { + const policyOn = await deps.observerArchiveDefaultEnabled(); + if (!policyOn) return; + + await deps.mergeSaveSubscriptionKinds(KIND_AGENT_OBSERVER_FRAME); + deps.setExplicitChoice(pubkey, true); +} + +/** + * Pure gate: `true` iff `reconciledPubkey` matches `currentPubkey`. + * Exported for direct unit testing without React mount infra. + */ +export function isReconciledFor( + reconciledPubkey: string | null, + currentPubkey: string | undefined, +): boolean { + return ( + reconciledPubkey !== null && + currentPubkey !== undefined && + reconciledPubkey === currentPubkey + ); +} + +/** + * Runs observer archive reconciliation eagerly when `pubkey` resolves. + * Returns `true` only after successful reconciliation for the current + * pubkey — archive sync must not start until this is `true`. + * + * Identity-scoped: changing pubkey resets readiness so the old manager + * tears down before the new identity's reconciliation completes. + * + * On failure: stays `false`; the reconciler retries on next app startup + * since no success marker is persisted. + */ +export function useObserverArchiveReconciliation( pubkey: string | undefined, deps: ObserverArchiveSeedDeps = defaultDeps, -): void { +): boolean { + const [reconciledPubkey, setReconciledPubkey] = React.useState( + null, + ); + React.useEffect(() => { if (!pubkey) return; - // Already made an explicit choice for this identity — never re-seed. - if (deps.hasExplicitChoice(pubkey)) return; - let cancelled = false; - async function maybeSeed(): Promise { - // pubkey is checked above but TypeScript doesn't narrow across the async - // boundary — re-guard here so the call below is type-safe. - if (!pubkey) return; - - let defaultOn: boolean; - try { - defaultOn = await deps.observerArchiveDefaultEnabled(); - } catch (err) { - console.warn("[useObserverArchiveSeed] flag check failed:", err); - return; - } - - if (cancelled) return; - - if (!defaultOn) { - // OSS build (flag off): don't persist a choice — leave null so seeding - // can still fire if this identity later runs an internal build. - return; - } - - // Internal build + no prior choice → auto-seed via atomic DB merge. - try { - await deps.mergeSaveSubscriptionKinds(KIND_AGENT_OBSERVER_FRAME); - } catch (err) { - console.warn( - "[useObserverArchiveSeed] mergeSaveSubscriptionKinds failed:", - err, - ); - // Do NOT set the localStorage flag — a transient failure (relay - // unreachable, archive DB not yet initialized) should retry on next - // startup rather than permanently suppress seeding. - return; - } - - if (cancelled) return; - - // Persist the explicit choice so this never re-fires. - deps.setExplicitChoice(pubkey, true); - } - - void maybeSeed(); + reconcileObserverArchive(pubkey, deps) + .then(() => { + if (!cancelled) setReconciledPubkey(pubkey); + }) + .catch((err) => { + console.warn("[useObserverArchiveReconciliation] failed:", err); + }); return () => { cancelled = true; }; }, [pubkey, deps]); + + return isReconciledFor(reconciledPubkey, pubkey); } diff --git a/desktop/src/features/onboarding/hooks.ts b/desktop/src/features/onboarding/hooks.ts index 22aeb91ad..b8a14fb07 100644 --- a/desktop/src/features/onboarding/hooks.ts +++ b/desktop/src/features/onboarding/hooks.ts @@ -8,58 +8,24 @@ import { } from "@/features/agents/hooks"; import { channelsQueryKey } from "@/features/channels/hooks"; import { - ensureWelcomeChannel, + ensureStarterChannels, hasEnsuredWelcomeChannel, markWelcomeChannelEnsured, notifyWelcomeChannelReady, rememberPendingWelcomeChannel, } from "@/features/onboarding/welcome"; -import { - ensureWelcomeGuideIntro, - getWelcomeGuideAgentPubkeys, -} from "@/features/onboarding/welcomeGuide"; +import { ensureWelcomeGuideIntro } from "@/features/onboarding/welcomeGuide"; import { useProfileQuery } from "@/features/profile/hooks"; import { useCommunities } from "@/features/communities/useCommunities"; import { useIdentityQuery } from "@/shared/api/hooks"; import { - createChannel, - getChannelMembers, + ensureStarterChannels as ensureStarterChannelsCommand, getChannels, - joinChannel, - updateChannel, } from "@/shared/api/tauri"; -const DEFAULT_AUTO_JOIN_CHANNEL_NAME = "general"; - export type ChannelInitResult = { ok: true } | { ok: false; reason: string }; -async function autoJoinDefaultChannel( - queryClient: ReturnType, -): Promise { - try { - const channels = await getChannels(); - const target = channels.find( - (channel) => - channel.name === DEFAULT_AUTO_JOIN_CHANNEL_NAME && !channel.isMember, - ); - if (!target) { - return { ok: true }; - } - await joinChannel(target.id); - await queryClient.invalidateQueries({ queryKey: channelsQueryKey }); - return { ok: true }; - } catch (error) { - return { - ok: false, - reason: - error instanceof Error - ? error.message - : "Failed to join the general channel", - }; - } -} - -export async function initializeWelcomeChannel( +export async function initializeStarterChannels( queryClient: ReturnType, { focus, @@ -72,24 +38,21 @@ export async function initializeWelcomeChannel( }, ): Promise { try { - const allowedMemberPubkeys = await getWelcomeGuideAgentPubkeys( - communityScope, - ).catch(() => []); - const welcomeChannel = await ensureWelcomeChannel( - { - createChannel, - getChannelMembers, - getChannels, - updateChannel, - }, - { - allowedMemberPubkeys, - }, - ); - let didInitializeWelcomeGuide = false; + const starterChannels = await ensureStarterChannels({ + ensureStarterChannels: ensureStarterChannelsCommand, + getChannels, + }); + + markWelcomeChannelEnsured(pubkey, communityScope); + if (focus) { + rememberPendingWelcomeChannel(starterChannels.welcomeChannel.id); + } + queryClient.setQueryData(channelsQueryKey, starterChannels.channels); try { - await ensureWelcomeGuideIntro(welcomeChannel.id, communityScope); - didInitializeWelcomeGuide = true; + await ensureWelcomeGuideIntro( + starterChannels.welcomeChannel.id, + communityScope, + ); await Promise.all([ queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }), queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey }), @@ -97,25 +60,19 @@ export async function initializeWelcomeChannel( } catch (error) { console.warn("Failed to initialize Welcome guide.", error); } - if (didInitializeWelcomeGuide) { - markWelcomeChannelEnsured(pubkey, communityScope); - } - if (focus) { - rememberPendingWelcomeChannel(welcomeChannel.id); - } await queryClient.invalidateQueries({ queryKey: channelsQueryKey }); if (focus) { - notifyWelcomeChannelReady(welcomeChannel.id); + notifyWelcomeChannelReady(starterChannels.welcomeChannel.id); } return { ok: true }; } catch (error) { - console.warn("Failed to initialize Welcome channel.", error); + console.warn("Failed to initialize starter channels.", error); return { ok: false, reason: error instanceof Error ? error.message - : "Failed to create the Welcome channel", + : "Failed to set up starter channels", }; } } @@ -419,11 +376,11 @@ export function useAppOnboardingState(isSharedIdentity: boolean) { const identityQuery = useIdentityQuery(); const identity = identityQuery.data; const currentPubkey = identity?.pubkey ?? null; - const welcomeChannelCommunityScope = activeCommunity?.relayUrl ?? null; - const welcomeChannelInitPromisesRef = React.useRef( + const starterChannelsCommunityScope = activeCommunity?.relayUrl ?? null; + const starterChannelsInitPromisesRef = React.useRef( new Map>(), ); - const [isCompletingWelcomeSetup, setIsCompletingWelcomeSetup] = + const [isCompletingStarterSetup, setIsCompletingStarterSetup] = React.useState(false); const identityLost = identity?.lost === true; // Keyring unreachable at boot — the real key is still in the OS keyring but @@ -464,16 +421,18 @@ export function useAppOnboardingState(isSharedIdentity: boolean) { profileStatus: profileQuery.status, }); const gateComplete = onboardingGate.complete; - const welcomeChannelFocusIntentRef = React.useRef(new Map()); - const requestWelcomeChannel = React.useCallback( + const starterChannelsFocusIntentRef = React.useRef( + new Map(), + ); + const requestStarterChannels = React.useCallback( (focus: boolean): Promise => { - if (!currentPubkey || !welcomeChannelCommunityScope) { + if (!currentPubkey || !starterChannelsCommunityScope) { return Promise.resolve({ ok: true }); } - const welcomeChannelInitKey = `${welcomeChannelCommunityScope}:${currentPubkey}`; - const currentPromise = welcomeChannelInitPromisesRef.current.get( - welcomeChannelInitKey, + const starterChannelsInitKey = `${starterChannelsCommunityScope}:${currentPubkey}`; + const currentPromise = starterChannelsInitPromisesRef.current.get( + starterChannelsInitKey, ); if (currentPromise) { // A focus=true request must not be swallowed behind an in-flight @@ -481,15 +440,18 @@ export function useAppOnboardingState(isSharedIdentity: boolean) { // promise resolves, chain a focus-only follow-up. if ( focus && - !welcomeChannelFocusIntentRef.current.get(welcomeChannelInitKey) + !starterChannelsFocusIntentRef.current.get(starterChannelsInitKey) ) { - welcomeChannelFocusIntentRef.current.set(welcomeChannelInitKey, true); + starterChannelsFocusIntentRef.current.set( + starterChannelsInitKey, + true, + ); return currentPromise.then((result) => { if (!result.ok) return result; - return initializeWelcomeChannel(queryClient, { + return initializeStarterChannels(queryClient, { focus: true, pubkey: currentPubkey, - communityScope: welcomeChannelCommunityScope, + communityScope: starterChannelsCommunityScope, }); }); } @@ -497,51 +459,54 @@ export function useAppOnboardingState(isSharedIdentity: boolean) { } if (focus) { - welcomeChannelFocusIntentRef.current.set(welcomeChannelInitKey, true); + starterChannelsFocusIntentRef.current.set(starterChannelsInitKey, true); } - const promise = initializeWelcomeChannel(queryClient, { + const promise = initializeStarterChannels(queryClient, { focus, pubkey: currentPubkey, - communityScope: welcomeChannelCommunityScope, + communityScope: starterChannelsCommunityScope, }); - welcomeChannelInitPromisesRef.current.set(welcomeChannelInitKey, promise); + starterChannelsInitPromisesRef.current.set( + starterChannelsInitKey, + promise, + ); void promise.finally(() => { - welcomeChannelInitPromisesRef.current.delete(welcomeChannelInitKey); - welcomeChannelFocusIntentRef.current.delete(welcomeChannelInitKey); + starterChannelsInitPromisesRef.current.delete(starterChannelsInitKey); + starterChannelsFocusIntentRef.current.delete(starterChannelsInitKey); }); return promise; }, - [currentPubkey, queryClient, welcomeChannelCommunityScope], + [currentPubkey, queryClient, starterChannelsCommunityScope], ); React.useEffect(() => { if ( onboardingGate.stage !== "ready" || !currentPubkey || - !welcomeChannelCommunityScope || + !starterChannelsCommunityScope || !readOnboardingCompletion(currentPubkey) || - hasEnsuredWelcomeChannel(currentPubkey, welcomeChannelCommunityScope) + hasEnsuredWelcomeChannel(currentPubkey, starterChannelsCommunityScope) ) { return; } - void requestWelcomeChannel(false); + void requestStarterChannels(false); }, [ currentPubkey, onboardingGate.stage, - requestWelcomeChannel, - welcomeChannelCommunityScope, + requestStarterChannels, + starterChannelsCommunityScope, ]); - const showWelcomeRetryToast = React.useCallback( + const showStarterRetryToast = React.useCallback( (reason: string) => { - toast.error("Couldn't set up the Welcome channel", { + toast.error("Couldn't set up starter channels", { action: { label: "Retry", onClick: () => { - void requestWelcomeChannel(true).then((result) => { + void requestStarterChannels(true).then((result) => { if (!result.ok) { - showWelcomeRetryToast(result.reason); + showStarterRetryToast(result.reason); } }); }, @@ -549,53 +514,27 @@ export function useAppOnboardingState(isSharedIdentity: boolean) { description: reason, }); }, - [requestWelcomeChannel], - ); - - const showGeneralRetryToast = React.useCallback( - (reason: string) => { - toast.error("Couldn't join #general", { - action: { - label: "Retry", - onClick: () => { - void autoJoinDefaultChannel(queryClient).then((result) => { - if (!result.ok) { - showGeneralRetryToast(result.reason); - } - }); - }, - }, - description: reason, - }); - }, - [queryClient], + [requestStarterChannels], ); const completeAndShowWelcome = React.useCallback(() => { - setIsCompletingWelcomeSetup(true); + setIsCompletingStarterSetup(true); gateComplete(); - void Promise.all([ - requestWelcomeChannel(true), - autoJoinDefaultChannel(queryClient), - ]) - .then(([welcomeResult, autoJoinResult]) => { - if (!welcomeResult.ok) { - showWelcomeRetryToast(welcomeResult.reason); - } - if (!autoJoinResult.ok) { - showGeneralRetryToast(autoJoinResult.reason); + void requestStarterChannels(true) + .then((starterResult) => { + if (!starterResult.ok) { + showStarterRetryToast(starterResult.reason); } return refreshChannelsCache(queryClient); }) .finally(() => { - setIsCompletingWelcomeSetup(false); + setIsCompletingStarterSetup(false); }); }, [ gateComplete, queryClient, - requestWelcomeChannel, - showGeneralRetryToast, - showWelcomeRetryToast, + requestStarterChannels, + showStarterRetryToast, ]); const flow = { actions: { @@ -629,7 +568,7 @@ export function useAppOnboardingState(isSharedIdentity: boolean) { ? ("keyring-locked" as const) : relaunchRequired ? ("relaunch-required" as const) - : isCompletingWelcomeSetup + : isCompletingStarterSetup ? ("blocking" as const) : onboardingGate.stage, }; diff --git a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx index 2fc1ae671..a48926902 100644 --- a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx @@ -6,7 +6,7 @@ import { markCommunityOnboardingComplete, useCommunityOnboarding, } from "@/features/onboarding/communityOnboarding"; -import { initializeWelcomeChannel } from "@/features/onboarding/hooks"; +import { initializeStarterChannels } from "@/features/onboarding/hooks"; import { useClaimInvite } from "@/features/onboarding/useClaimInvite"; import { AvatarUpload } from "@/features/profile/ui/AvatarUpload"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; @@ -69,7 +69,7 @@ export function CommunityOnboardingFlow({ update({ stage: "finalizing", error: undefined }); try { const identity = await getIdentity(); - const result = await initializeWelcomeChannel(queryClient, { + const result = await initializeStarterChannels(queryClient, { focus: true, pubkey: identity.pubkey, communityScope: relayUrl, diff --git a/desktop/src/features/onboarding/welcome.test.mjs b/desktop/src/features/onboarding/welcome.test.mjs index c3e46615b..b09ceb094 100644 --- a/desktop/src/features/onboarding/welcome.test.mjs +++ b/desktop/src/features/onboarding/welcome.test.mjs @@ -3,9 +3,11 @@ import test from "node:test"; import { consumePendingWelcomeChannel, + ensureStarterChannels, ensureWelcomeChannel, findPrivateWelcomeChannel, hasEnsuredWelcomeChannel, + isWelcomeExperienceChannel, markWelcomeChannelEnsured, rememberPendingWelcomeChannel, WELCOME_CHANNEL_DESCRIPTION, @@ -253,3 +255,86 @@ test("Welcome ensured marker is scoped to the current identity and community", ( restore(); } }); + +test("ensureStarterChannels reuses existing open starter channels", async () => { + const general = makeChannel({ + id: "general-channel", + name: "general", + visibility: "open", + }); + const welcomeEveryone = makeChannel({ + id: "welcome-everyone-channel", + name: "welcome-everyone", + visibility: "open", + }); + let ensureCalls = 0; + + const result = await ensureStarterChannels({ + getChannels: async () => [general, welcomeEveryone], + ensureStarterChannels: async () => { + ensureCalls += 1; + return []; + }, + }); + + assert.equal(result.generalChannel, general); + assert.equal(result.welcomeChannel, welcomeEveryone); + assert.deepEqual(result.channels, [general, welcomeEveryone]); + assert.equal(ensureCalls, 0); +}); + +test("ensureStarterChannels resumes when one starter channel is missing", async () => { + const general = makeChannel({ + id: "general-channel", + name: "general", + visibility: "open", + }); + const welcomeEveryone = makeChannel({ + id: "welcome-everyone-channel", + name: "welcome-everyone", + visibility: "open", + }); + let ensureCalls = 0; + + const result = await ensureStarterChannels({ + getChannels: async () => [general], + ensureStarterChannels: async () => { + ensureCalls += 1; + return [general, welcomeEveryone]; + }, + }); + + assert.equal(result.generalChannel, general); + assert.equal(result.welcomeChannel, welcomeEveryone); + assert.deepEqual(result.channels, [general, welcomeEveryone]); + assert.equal(ensureCalls, 1); +}); + +test("isWelcomeExperienceChannel matches legacy Welcome and starter welcome-everyone", () => { + assert.equal(isWelcomeExperienceChannel(makeChannel()), true); + assert.equal( + isWelcomeExperienceChannel( + makeChannel({ name: "welcome-everyone", visibility: "open" }), + ), + true, + ); + assert.equal( + isWelcomeExperienceChannel( + makeChannel({ name: "Welcome-Everyone", visibility: "open" }), + ), + true, + ); + assert.equal( + isWelcomeExperienceChannel( + makeChannel({ name: "welcome-everyone", visibility: "private" }), + ), + false, + ); + assert.equal( + isWelcomeExperienceChannel( + makeChannel({ name: "general", visibility: "open" }), + ), + false, + ); + assert.equal(isWelcomeExperienceChannel(null), false); +}); diff --git a/desktop/src/features/onboarding/welcome.ts b/desktop/src/features/onboarding/welcome.ts index 48d22a145..a60135e1d 100644 --- a/desktop/src/features/onboarding/welcome.ts +++ b/desktop/src/features/onboarding/welcome.ts @@ -8,6 +8,12 @@ import type { export const WELCOME_CHANNEL_NAME = "Welcome"; export const WELCOME_CHANNEL_DESCRIPTION = "A private channel for getting oriented in this community."; +export const STARTER_GENERAL_CHANNEL_NAME = "general"; +export const STARTER_GENERAL_CHANNEL_DESCRIPTION = + "General conversation and community updates."; +export const STARTER_WELCOME_CHANNEL_NAME = "welcome-everyone"; +export const STARTER_WELCOME_CHANNEL_DESCRIPTION = + "Say hi, ask a question, or share what brought you here."; export const WELCOME_CHANNEL_READY_EVENT = "buzz:onboarding-welcome-channel-ready"; @@ -25,6 +31,17 @@ type WelcomeChannelClient = { updateChannel?: (input: UpdateChannelInput) => Promise; }; +type StarterChannelsClient = { + ensureStarterChannels: () => Promise; + getChannels: () => Promise; +}; + +export type StarterChannelsResult = { + channels: Channel[]; + generalChannel: Channel; + welcomeChannel: Channel; +}; + type WelcomeChannelOptions = { allowedMemberPubkeys?: readonly string[]; }; @@ -41,6 +58,46 @@ const welcomeChannelInput = { description: WELCOME_CHANNEL_DESCRIPTION, } satisfies CreateChannelInput; +function normalizeChannelName(name: string) { + return name.trim().toLowerCase(); +} + +function isOpenStreamStarterChannel(channel: Channel, name: string) { + return ( + normalizeChannelName(channel.name) === normalizeChannelName(name) && + channel.channelType === "stream" && + channel.visibility === "open" && + channel.archivedAt === null && + channel.isMember + ); +} + +function findStarterChannel(channels: Channel[], name: string) { + return ( + channels.find((channel) => isOpenStreamStarterChannel(channel, name)) ?? + null + ); +} + +export function findStarterChannels( + channels: Channel[], +): Omit | null { + const generalChannel = findStarterChannel( + channels, + STARTER_GENERAL_CHANNEL_NAME, + ); + const welcomeChannel = findStarterChannel( + channels, + STARTER_WELCOME_CHANNEL_NAME, + ); + + if (!generalChannel || !welcomeChannel) { + return null; + } + + return { generalChannel, welcomeChannel }; +} + function hasOnlyCurrentOrAllowedMembers( channel: Channel, allowedMemberPubkeys: readonly string[] = [], @@ -72,6 +129,27 @@ export function isWelcomeChannel(channel: Channel | null | undefined) { ); } +export function isStarterWelcomeChannel(channel: Channel | null | undefined) { + return ( + channel !== null && + channel !== undefined && + normalizeChannelName(channel.name) === STARTER_WELCOME_CHANNEL_NAME && + channel.channelType === "stream" && + channel.visibility === "open" + ); +} + +/** + * Channels that get the welcome experience (intro action cards, guide + * composer banner, chat-first agent creation): the starter + * #welcome-everyone channel, plus the legacy private Welcome channel. + */ +export function isWelcomeExperienceChannel( + channel: Channel | null | undefined, +) { + return isWelcomeChannel(channel) || isStarterWelcomeChannel(channel); +} + function isPrivateWelcomeChannelCandidate(channel: Channel) { return ( isWelcomeChannel(channel) && channel.archivedAt === null && channel.isMember @@ -160,6 +238,30 @@ export async function ensureWelcomeChannel( return client.createChannel(welcomeChannelInput); } +export async function ensureStarterChannels( + client: StarterChannelsClient, +): Promise { + const existingChannels = await client.getChannels(); + const existingStarters = findStarterChannels(existingChannels); + if (existingStarters) { + return { + channels: existingChannels, + ...existingStarters, + }; + } + + const ensuredChannels = await client.ensureStarterChannels(); + const ensuredStarters = findStarterChannels(ensuredChannels); + if (!ensuredStarters) { + throw new Error("Starter channels were not available after setup"); + } + + return { + channels: ensuredChannels, + ...ensuredStarters, + }; +} + export function welcomeChannelEnsuredStorageKey( pubkey: string, communityScope: string, diff --git a/desktop/src/features/profile/ui/NostrBindConsentDialog.tsx b/desktop/src/features/profile/ui/NostrBindConsentDialog.tsx index b56b34858..3d32c756f 100644 --- a/desktop/src/features/profile/ui/NostrBindConsentDialog.tsx +++ b/desktop/src/features/profile/ui/NostrBindConsentDialog.tsx @@ -1,5 +1,6 @@ import * as DialogPrimitive from "@radix-ui/react-dialog"; import { openUrl } from "@tauri-apps/plugin-opener"; +import { ChevronDown } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import * as React from "react"; import { toast } from "sonner"; @@ -88,6 +89,11 @@ function normalizeVerificationCode(value: string): string[] { .map((character) => character.trim()); } +function isNostrBindRequestExpired(payload: NostrBindDeepLinkPayload): boolean { + const expiry = new Date(payload.expiresAt).getTime(); + return Number.isNaN(expiry) || expiry <= Date.now(); +} + function formatError(error: unknown): string { if (error instanceof Error) { return error.message; @@ -135,6 +141,74 @@ async function returnSignedResponseToBrowser( } } +function SignedResponseControls({ + copyFailed, + copyLabel, + onCopy, + signedResponse, +}: { + copyFailed: boolean; + copyLabel: string; + onCopy: () => void; + signedResponse: string; +}) { + return ( +
+
+        
+          {signedResponse}
+        
+      
+ + {copyFailed ? ( +

+ {COPY_FAILURE_MESSAGE} +

+ ) : null} + + +
+ ); +} + export function NostrBindConsentDialog() { const isPreview = isNostrBindPreviewEnabled(); const [payload, setPayload] = React.useState( @@ -154,12 +228,15 @@ export function NostrBindConsentDialog() { const [hasCodeMismatch, setHasCodeMismatch] = React.useState(false); const [copyFailed, setCopyFailed] = React.useState(false); const [error, setError] = React.useState(null); + const [isManualFallbackOpen, setIsManualFallbackOpen] = React.useState(false); const codeInputRefs = React.useRef>([]); const codeShakeRef = React.useRef(null); const codeShakeAnimationRef = React.useRef(null); const copiedTimerRef = React.useRef | null>( null, ); + const autoSignAttemptRef = React.useRef(null); + const activeSignAttemptRef = React.useRef(null); const systemColorScheme = useSystemColorScheme(); const shouldReduceMotion = useReducedMotion(); const enteredVerificationCode = verificationCode.join(""); @@ -204,13 +281,17 @@ export function NostrBindConsentDialog() { const unlistenPromise = listenForNostrBindDeepLinks((nextPayload) => { clearCopiedState(); + autoSignAttemptRef.current = null; + activeSignAttemptRef.current = null; setPayload(nextPayload); setIdentity(null); + setIsSigning(false); setSignedResponse(null); setVerificationCode(createEmptyVerificationCode()); setHasCodeMismatch(false); setCopyFailed(false); setError(null); + setIsManualFallbackOpen(false); getIdentity() .then(setIdentity) .catch((error) => { @@ -225,22 +306,19 @@ export function NostrBindConsentDialog() { }; }, [clearCopiedState, isPreview]); - const isExpired = React.useMemo(() => { - if (!payload) { - return false; - } - const expiry = new Date(payload.expiresAt).getTime(); - return Number.isNaN(expiry) || expiry <= Date.now(); - }, [payload]); + const isExpired = payload !== null && isNostrBindRequestExpired(payload); const resetDialog = React.useCallback(() => { clearCopiedState(); + autoSignAttemptRef.current = null; + activeSignAttemptRef.current = null; setPayload(null); setSignedResponse(null); setVerificationCode(createEmptyVerificationCode()); setHasCodeMismatch(false); setCopyFailed(false); setError(null); + setIsManualFallbackOpen(false); setIdentity(null); setIsSigning(false); }, [clearCopiedState]); @@ -297,6 +375,7 @@ export function NostrBindConsentDialog() { (index: number, value: string) => { const nextDigits = value.replace(/\D/g, ""); const next = [...verificationCode]; + autoSignAttemptRef.current = null; if (!nextDigits) { next[index] = ""; @@ -363,6 +442,7 @@ export function NostrBindConsentDialog() { } event.preventDefault(); + autoSignAttemptRef.current = null; const next = normalizeVerificationCode(pastedCode); setVerificationCode(next); if ( @@ -402,6 +482,7 @@ export function NostrBindConsentDialog() { if (event.key === "Backspace") { event.preventDefault(); + autoSignAttemptRef.current = null; const targetIndex = verificationCode[index] ? index : Math.max(index - 1, 0); @@ -429,10 +510,10 @@ export function NostrBindConsentDialog() { ); const handleSign = React.useCallback(async () => { - if (!payload) { + if (activeSignAttemptRef.current || !payload) { return; } - if (isExpired) { + if (isNostrBindRequestExpired(payload)) { setError(EXPIRED_LINK_MESSAGE); return; } @@ -447,6 +528,8 @@ export function NostrBindConsentDialog() { return; } + const attempt = Symbol("nostr-bind-sign"); + activeSignAttemptRef.current = attempt; setIsSigning(true); clearCopiedState(); setError(null); @@ -461,21 +544,35 @@ export function NostrBindConsentDialog() { origin: payload.origin, expiresAt: payload.expiresAt, }); + if (activeSignAttemptRef.current !== attempt) { + return; + } + setSignedResponse(signed); if (payload.returnMode === "browser_fragment_v1" && payload.callbackUrl) { - setError( - await returnSignedResponseToBrowser(payload.callbackUrl, signed), + const callbackError = await returnSignedResponseToBrowser( + payload.callbackUrl, + signed, ); + if (activeSignAttemptRef.current !== attempt) { + return; + } + setError(callbackError); + setIsManualFallbackOpen(callbackError !== null); } } catch (error) { - setError(formatError(error) || "Failed to sign binding response."); + if (activeSignAttemptRef.current === attempt) { + setError(formatError(error) || "Failed to sign binding response."); + } } finally { - setIsSigning(false); + if (activeSignAttemptRef.current === attempt) { + activeSignAttemptRef.current = null; + setIsSigning(false); + } } }, [ clearCopiedState, enteredVerificationCode, - isExpired, isPreview, isVerificationCodeComplete, isVerificationCodeValid, @@ -484,6 +581,35 @@ export function NostrBindConsentDialog() { verificationCode, ]); + React.useEffect(() => { + if ( + !payload || + identity === null || + isExpired || + isSigning || + signedResponse !== null || + !isVerificationCodeValid + ) { + return; + } + + const attemptKey = `${payload.challengeId}:${enteredVerificationCode}`; + if (autoSignAttemptRef.current === attemptKey) { + return; + } + autoSignAttemptRef.current = attemptKey; + void handleSign(); + }, [ + enteredVerificationCode, + handleSign, + identity, + isExpired, + isSigning, + isVerificationCodeValid, + payload, + signedResponse, + ]); + const handleCopyAgain = React.useCallback(async () => { if (!signedResponse) { return; @@ -548,7 +674,7 @@ export function NostrBindConsentDialog() { id="nostr-bind-description" > {payload.returnMode === "browser_fragment_v1" - ? "Buzz opened your browser to finish verification. If it did not open, copy the response below." + ? "Buzz opened your browser to finish verification." : "Copy the response below, then paste it into the Buzz website to finish verification."} @@ -558,65 +684,50 @@ export function NostrBindConsentDialog() {

) : 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', + ); + } + }); + }); +}