mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat: add invite QR and mobile direct join (#1957)
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf
npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm
npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
parent
adb48311e6
commit
648cbf3610
@@ -631,6 +631,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
|
||||
@@ -673,7 +678,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
|
||||
|
||||
Generated
+1
@@ -1241,6 +1241,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"sqlx",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.29.0",
|
||||
|
||||
@@ -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 =
|
||||
<Hmac<Sha256> 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<std::collections::HashSet<[u8; 32]>>,
|
||||
}
|
||||
|
||||
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<dyn std::future::Future<Output = Result<bool, buzz_auth::AuthError>> + 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(
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<sqlx::Postgres> {
|
||||
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() {
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{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."}
|
||||
</p>
|
||||
{invite ? (
|
||||
<div className="flex flex-col gap-3 rounded-md border border-border/70 bg-background/70 p-3 sm:flex-row sm:items-center">
|
||||
<div className="shrink-0 self-center rounded-md bg-white p-2 text-black">
|
||||
<QRCodeSVG
|
||||
aria-label="Invite QR code"
|
||||
data-testid="invite-link-qr-code"
|
||||
level="M"
|
||||
size={128}
|
||||
value={invite.url}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
<p>
|
||||
Scan this QR code or share the link above to invite someone to
|
||||
this relay.
|
||||
</p>
|
||||
<p>
|
||||
Anyone with this link or QR code can join as a member until{" "}
|
||||
{formatExpiry(invite.expiresAt)}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Create a shareable link that lets anyone join this relay as a member
|
||||
until it expires.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+4
-1
@@ -75,7 +75,10 @@ class App extends HookConsumerWidget {
|
||||
AuthStatus.authenticated => const DeepLinkDispatcher(
|
||||
child: HomePage(),
|
||||
),
|
||||
_ => const PairingPage(),
|
||||
_ => const DeepLinkDispatcher(
|
||||
dispatchMessageLinks: false,
|
||||
child: PairingPage(),
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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<DeepLinkDispatcher> {
|
||||
bool _preparingInvite = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -44,18 +50,25 @@ class _DeepLinkDispatcherState extends ConsumerState<DeepLinkDispatcher> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Re-evaluate dispatch when either a new link arrives or channels load.
|
||||
ref.listen<MessageDeepLink?>(pendingDeepLinkProvider, (_, link) {
|
||||
ref.listen<BuzzDeepLink?>(pendingDeepLinkProvider, (_, link) {
|
||||
_maybeDispatch(link);
|
||||
});
|
||||
ref.listen<AsyncValue<List<Channel>>>(channelsProvider, (_, _) {
|
||||
_maybeDispatch(ref.read(pendingDeepLinkProvider));
|
||||
});
|
||||
if (widget.dispatchMessageLinks) {
|
||||
ref.listen<AsyncValue<List<Channel>>>(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<DeepLinkDispatcher> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<http.Client>((ref) {
|
||||
final client = http.Client();
|
||||
ref.onDispose(client.close);
|
||||
return client;
|
||||
});
|
||||
|
||||
final inviteKeyGeneratorProvider = Provider<InviteKeyGenerator>((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<InviteJoinState> {
|
||||
@override
|
||||
InviteJoinState build() => const InviteJoinState();
|
||||
|
||||
Future<void> 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<void> 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<String, dynamic>.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, InviteJoinState>(
|
||||
InviteJoinNotifier.new,
|
||||
);
|
||||
|
||||
class InviteClaimException implements Exception {
|
||||
final String message;
|
||||
|
||||
const InviteClaimException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
Community? _existingCommunity(List<Community> 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<String, dynamic> 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';
|
||||
}
|
||||
@@ -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<void> showInviteJoinSheet(BuildContext context, WidgetRef ref) {
|
||||
return showModalBottomSheet<void>(
|
||||
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<void>(
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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://<relay>/invite/<code>`. The custom
|
||||
/// `buzz://join?relay=<ws(s)://relay>&code=<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://<relay>/invite/<code>` -> `wss://<relay>` + code
|
||||
/// - `http://<relay>/invite/<code>` -> `ws://<relay>` + code
|
||||
/// - `buzz://join?relay=<ws(s)://relay>&code=<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);
|
||||
|
||||
@@ -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<MessageDeepLink?> {
|
||||
class PendingDeepLinkNotifier extends Notifier<BuzzDeepLink?> {
|
||||
@visibleForTesting
|
||||
static Stream<Uri>? debugUriStreamOverride;
|
||||
|
||||
StreamSubscription<Uri>? _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<MessageDeepLink?> {
|
||||
/// 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<MessageDeepLink?> {
|
||||
}
|
||||
|
||||
final pendingDeepLinkProvider =
|
||||
NotifierProvider<PendingDeepLinkNotifier, MessageDeepLink?>(
|
||||
NotifierProvider<PendingDeepLinkNotifier, BuzzDeepLink?>(
|
||||
PendingDeepLinkNotifier.new,
|
||||
);
|
||||
|
||||
@@ -143,7 +143,7 @@ class RelaySessionNotifier extends Notifier<SessionState> {
|
||||
.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<int> bodyBytes,
|
||||
|
||||
@@ -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<MessageDeepLink>(),
|
||||
);
|
||||
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<List<Community>> loadAll() async {
|
||||
loadCalls++;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
class _ThrowingCommunityStorage extends CommunityStorage {
|
||||
@override
|
||||
Future<List<Community>> 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 {
|
||||
|
||||
@@ -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 = <String>[];
|
||||
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<Community> authenticatedCommunities = [];
|
||||
|
||||
@override
|
||||
Future<AuthState> build() async =>
|
||||
const AuthState(status: AuthStatus.unauthenticated);
|
||||
|
||||
@override
|
||||
Future<void> authenticateWithCommunity(Community community) async {
|
||||
authenticatedCommunities.add(community);
|
||||
state = AsyncData(
|
||||
AuthState(status: AuthStatus.authenticated, community: community),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user