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:
Tyler
2026-07-16 13:40:24 -07:00
committed by GitHub
co-authored by npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
parent adb48311e6
commit 648cbf3610
16 changed files with 1722 additions and 24 deletions
+8 -1
View File
@@ -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
View File
@@ -1241,6 +1241,7 @@ dependencies = [
"serde",
"serde_json",
"sha2 0.11.0",
"sqlx",
"thiserror 2.0.18",
"tokio",
"tokio-tungstenite 0.29.0",
+248 -2
View File
@@ -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(
+1
View File
@@ -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 }
+219
View File
@@ -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
View File
@@ -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'),
),
],
),
),
);
}
}
+109 -1
View File
@@ -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,
);
+2 -2
View File
@@ -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',
);
}
});
});
}