From d20010ae4e0a8fe61f6d5088b2673100b23f1791 Mon Sep 17 00:00:00 2001
From: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf
 <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Date: Thu, 16 Jul 2026 10:56:04 -0400
Subject: [PATCH 1/6] feat: add invite QR and mobile direct join

Render existing community invite links as QR codes in desktop, parse invite deep links on mobile, and add a fresh-key invite claim flow with same-relay overwrite protection and raw-host confirmation.

Add live relay e2e coverage for invite mint and claim paths and wire the suite into CI.

Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
---
 .github/workflows/ci.yml                      |   2 +-
 Cargo.lock                                    |   1 +
 crates/buzz-test-client/Cargo.toml            |   1 +
 crates/buzz-test-client/tests/e2e_relay.rs    | 219 +++++++++++++++
 .../ui/InviteLinkSection.tsx                  |  34 ++-
 .../channels/deep_link_dispatcher.dart        |  29 +-
 .../invites/invite_join_provider.dart         | 254 ++++++++++++++++++
 .../features/invites/invite_join_sheet.dart   | 189 +++++++++++++
 mobile/lib/shared/deeplink/deep_link.dart     |  94 ++++++-
 .../deeplink/pending_deep_link_provider.dart  |  10 +-
 mobile/lib/shared/relay/relay_session.dart    |   4 +-
 .../channels/deep_link_dispatcher_test.dart   |   4 +-
 .../invites/invite_join_provider_test.dart    | 157 +++++++++++
 .../test/shared/deeplink/deep_link_test.dart  | 101 +++++++
 web/src/features/invite/ui/InvitePage.tsx     |   2 +-
 15 files changed, 1082 insertions(+), 19 deletions(-)
 create mode 100644 mobile/lib/features/invites/invite_join_provider.dart
 create mode 100644 mobile/lib/features/invites/invite_join_sheet.dart
 create mode 100644 mobile/test/features/invites/invite_join_provider_test.dart

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 44dfe5ef2e..d0eb47d6f7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -669,7 +669,7 @@ 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_relay --test e2e_persona --test e2e_nostr_interop -- --ignored --nocapture
         env:
           RELAY_URL: ws://localhost:3000
           GIT_CREDENTIAL_NOSTR_BIN: ${{ github.workspace }}/target/ci/git-credential-nostr
diff --git a/Cargo.lock b/Cargo.lock
index d54fc64c0b..8c36de1d38 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1239,6 +1239,7 @@ dependencies = [
  "serde",
  "serde_json",
  "sha2 0.11.0",
+ "sqlx",
  "thiserror 2.0.18",
  "tokio",
  "tokio-tungstenite 0.29.0",
diff --git a/crates/buzz-test-client/Cargo.toml b/crates/buzz-test-client/Cargo.toml
index 7ce3e2bc31..40a08f3d19 100644
--- a/crates/buzz-test-client/Cargo.toml
+++ b/crates/buzz-test-client/Cargo.toml
@@ -33,6 +33,7 @@ base64 = "0.22"
 hex = { workspace = true }
 rand = { workspace = true }
 sha2 = { workspace = true }
+sqlx = { workspace = true }
 chrono = { workspace = true }
 s3 = { version = "0.37", package = "rust-s3", default-features = false, features = ["tokio-rustls-tls", "fail-on-err", "tags"] }
 buzz-sdk = { workspace = true }
diff --git a/crates/buzz-test-client/tests/e2e_relay.rs b/crates/buzz-test-client/tests/e2e_relay.rs
index 4b5913bf47..7fd421ff5a 100644
--- a/crates/buzz-test-client/tests/e2e_relay.rs
+++ b/crates/buzz-test-client/tests/e2e_relay.rs
@@ -20,8 +20,12 @@
 
 use std::time::Duration;
 
+use base64::engine::general_purpose::STANDARD as BASE64;
+use base64::Engine;
 use buzz_test_client::{BuzzTestClient, RelayMessage, TestClientError};
 use nostr::{Alphabet, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag};
+use sha2::{Digest, Sha256};
+use uuid::Uuid;
 
 fn relay_url() -> String {
     std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string())
@@ -39,6 +43,124 @@ fn relay_http_url() -> String {
         .to_string()
 }
 
+fn test_owner_keys() -> Keys {
+    std::env::var("BUZZ_TEST_OWNER_PRIVATE_KEY")
+        .ok()
+        .and_then(|secret| Keys::parse(&secret).ok())
+        .unwrap_or_else(Keys::generate)
+}
+
+fn sha256_hex(bytes: &[u8]) -> String {
+    let digest = Sha256::digest(bytes);
+    hex::encode(digest)
+}
+
+fn nip98_post_header(keys: &Keys, url: &str, body: &str) -> String {
+    let event = EventBuilder::new(Kind::Custom(27_235), "")
+        .tags(vec![
+            Tag::parse(["u", url]).unwrap(),
+            Tag::parse(["method", "POST"]).unwrap(),
+            Tag::parse(["payload", &sha256_hex(body.as_bytes())]).unwrap(),
+            Tag::parse(["nonce", &uuid::Uuid::new_v4().to_string()]).unwrap(),
+        ])
+        .sign_with_keys(keys)
+        .expect("sign NIP-98 event");
+    format!(
+        "Nostr {}",
+        BASE64.encode(serde_json::to_string(&event).expect("serialize NIP-98 event"))
+    )
+}
+
+async fn e2e_db_pool() -> sqlx::Pool<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() {
diff --git a/desktop/src/features/community-members/ui/InviteLinkSection.tsx b/desktop/src/features/community-members/ui/InviteLinkSection.tsx
index fc0eddca4e..9852b6881f 100644
--- a/desktop/src/features/community-members/ui/InviteLinkSection.tsx
+++ b/desktop/src/features/community-members/ui/InviteLinkSection.tsx
@@ -1,4 +1,5 @@
 import { Check, Copy, Link2 } from "lucide-react";
+import { QRCodeSVG } from "qrcode.react";
 import * as React from "react";
 import { toast } from "sonner";
 
@@ -155,11 +156,34 @@ export function InviteLinkSection() {
           </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>
   );
 }
diff --git a/mobile/lib/features/channels/deep_link_dispatcher.dart b/mobile/lib/features/channels/deep_link_dispatcher.dart
index 2a5fc822d0..4e43803ab4 100644
--- a/mobile/lib/features/channels/deep_link_dispatcher.dart
+++ b/mobile/lib/features/channels/deep_link_dispatcher.dart
@@ -3,6 +3,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
 
 import '../../shared/deeplink/deep_link.dart';
 import '../../shared/deeplink/pending_deep_link_provider.dart';
+import '../invites/invite_join_provider.dart';
+import '../invites/invite_join_sheet.dart';
 import 'channel.dart';
 import 'channel_detail_page.dart';
 import 'channels_provider.dart';
@@ -44,7 +46,7 @@ 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, (_, _) {
@@ -54,8 +56,13 @@ class _DeepLinkDispatcherState extends ConsumerState<DeepLinkDispatcher> {
     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) return;
 
     final channels = ref.read(channelsProvider).asData?.value;
     // Channels not loaded yet — keep the link parked; the channelsProvider
@@ -92,4 +99,22 @@ class _DeepLinkDispatcherState extends ConsumerState<DeepLinkDispatcher> {
       ),
     );
   }
+
+  void _maybeDispatchInvite(InviteDeepLink link) {
+    ref.read(pendingDeepLinkProvider.notifier).consume();
+    final navigatorContext = context;
+    final messenger = ScaffoldMessenger.maybeOf(context);
+    Future.microtask(() async {
+      await ref.read(inviteJoinProvider.notifier).prepare(link);
+      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')),
+        );
+      }
+    });
+  }
 }
diff --git a/mobile/lib/features/invites/invite_join_provider.dart b/mobile/lib/features/invites/invite_join_provider.dart
new file mode 100644
index 0000000000..12aaab34a8
--- /dev/null
+++ b/mobile/lib/features/invites/invite_join_provider.dart
@@ -0,0 +1,254 @@
+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;
+
+  const InviteJoinState({
+    this.status = InviteJoinStatus.idle,
+    this.invite,
+    this.host,
+    this.communityName,
+    this.errorMessage,
+  });
+
+  InviteJoinState copyWith({
+    InviteJoinStatus? status,
+    InviteDeepLink? invite,
+    String? host,
+    String? communityName,
+    String? errorMessage,
+  }) => InviteJoinState(
+    status: status ?? this.status,
+    invite: invite ?? this.invite,
+    host: host ?? this.host,
+    communityName: communityName ?? this.communityName,
+    errorMessage: errorMessage ?? this.errorMessage,
+  );
+}
+
+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.status != InviteJoinStatus.confirming) 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});
+      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) {
+      state = state.copyWith(
+        status: InviteJoinStatus.error,
+        errorMessage: _friendlyInviteError(error),
+      );
+    }
+  }
+
+  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);
+}
+
+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('SocketException') ||
+      message.contains('Connection refused') ||
+      message.contains('Network is unreachable') ||
+      message.contains('No route to host')) {
+    return 'Could not reach the relay. Check your connection and try again.';
+  }
+  return 'Could not join this community: $message';
+}
diff --git a/mobile/lib/features/invites/invite_join_sheet.dart b/mobile/lib/features/invites/invite_join_sheet.dart
new file mode 100644
index 0000000000..f5ac96f046
--- /dev/null
+++ b/mobile/lib/features/invites/invite_join_sheet.dart
@@ -0,0 +1,189 @@
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:lucide_icons_flutter/lucide_icons.dart';
+
+import '../../shared/theme/theme.dart';
+import '../pairing/pairing_page.dart';
+import 'invite_join_provider.dart';
+
+Future<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
+                        ? 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'),
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+}
diff --git a/mobile/lib/shared/deeplink/deep_link.dart b/mobile/lib/shared/deeplink/deep_link.dart
index 3bdd505fd6..18fd6df50b 100644
--- a/mobile/lib/shared/deeplink/deep_link.dart
+++ b/mobile/lib/shared/deeplink/deep_link.dart
@@ -7,8 +7,40 @@
 /// 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;
+
+  const InviteDeepLink({required this.relayUrl, required this.code});
+
+  @override
+  bool operator ==(Object other) =>
+      other is InviteDeepLink &&
+      other.relayUrl == relayUrl &&
+      other.code == code;
+
+  @override
+  int get hashCode => Object.hash(relayUrl, code);
+
+  @override
+  String toString() => 'InviteDeepLink(relay: $relayUrl, code: $code)';
+}
+
 /// A parsed `buzz://message` deep link.
-class MessageDeepLink {
+class MessageDeepLink extends BuzzDeepLink {
   /// Channel UUID from the `channel` query param.
   final String channelId;
 
@@ -61,3 +93,63 @@ 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();
+    return InviteDeepLink(relayUrl: normalizedRelay, code: code);
+  }
+
+  if (uri.scheme == 'https' || uri.scheme == 'http') {
+    if (uri.host.isEmpty) return null;
+    final segments = uri.pathSegments;
+    if (segments.length != 2 ||
+        segments[0] != 'invite' ||
+        segments[1].isEmpty) {
+      return null;
+    }
+    final relayScheme = uri.scheme == 'https' ? 'wss' : 'ws';
+    final relay = Uri(
+      scheme: relayScheme,
+      host: uri.host,
+      port: uri.hasPort ? uri.port : null,
+    ).toString();
+    return InviteDeepLink(relayUrl: relay, code: segments[1]);
+  }
+
+  return null;
+}
+
+/// Parse any supported Buzz deep link.
+BuzzDeepLink? parseBuzzDeepLink(Uri uri) =>
+    parseInviteDeepLink(uri) ?? parseMessageDeepLink(uri);
diff --git a/mobile/lib/shared/deeplink/pending_deep_link_provider.dart b/mobile/lib/shared/deeplink/pending_deep_link_provider.dart
index 250ffdbd27..8dc46d9f10 100644
--- a/mobile/lib/shared/deeplink/pending_deep_link_provider.dart
+++ b/mobile/lib/shared/deeplink/pending_deep_link_provider.dart
@@ -6,7 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
 
 import 'deep_link.dart';
 
-/// Holds the most recent `buzz://message` deep link that has not been
+/// Holds the most recent supported deep link that has not been
 /// dispatched yet.
 ///
 /// Listens to [AppLinks.uriLinkStream], which delivers both the cold-start
@@ -14,14 +14,14 @@ import 'deep_link.dart';
 /// Navigation cannot always happen the moment a link arrives — the user may
 /// not be authenticated yet, or channels may still be loading — so the parsed
 /// link is parked here and consumed by the dispatcher once the app is ready.
-class PendingDeepLinkNotifier extends Notifier<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,
     );
diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart
index 615b75b9ae..9c167e10b1 100644
--- a/mobile/lib/shared/relay/relay_session.dart
+++ b/mobile/lib/shared/relay/relay_session.dart
@@ -143,7 +143,7 @@ class RelaySessionNotifier extends Notifier<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,
diff --git a/mobile/test/features/channels/deep_link_dispatcher_test.dart b/mobile/test/features/channels/deep_link_dispatcher_test.dart
index 452c25cb9c..6d6a1b19ea 100644
--- a/mobile/test/features/channels/deep_link_dispatcher_test.dart
+++ b/mobile/test/features/channels/deep_link_dispatcher_test.dart
@@ -63,10 +63,10 @@ final _channel = Channel(
 class _FakePendingDeepLinkNotifier extends PendingDeepLinkNotifier {
   _FakePendingDeepLinkNotifier(this.link);
 
-  final MessageDeepLink link;
+  final BuzzDeepLink link;
 
   @override
-  MessageDeepLink? build() => link;
+  BuzzDeepLink? build() => link;
 }
 
 class _FakeChannelsNotifier extends ChannelsNotifier {
diff --git a/mobile/test/features/invites/invite_join_provider_test.dart b/mobile/test/features/invites/invite_join_provider_test.dart
new file mode 100644
index 0000000000..a390380526
--- /dev/null
+++ b/mobile/test/features/invites/invite_join_provider_test.dart
@@ -0,0 +1,157 @@
+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);
+    },
+  );
+}
+
+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),
+    );
+  }
+}
diff --git a/mobile/test/shared/deeplink/deep_link_test.dart b/mobile/test/shared/deeplink/deep_link_test.dart
index 7624cbe71d..b1ee707e0c 100644
--- a/mobile/test/shared/deeplink/deep_link_test.dart
+++ b/mobile/test/shared/deeplink/deep_link_test.dart
@@ -2,6 +2,8 @@ import 'package:buzz/shared/deeplink/deep_link.dart';
 import 'package:flutter_test/flutter_test.dart';
 
 void main() {
+  _inviteTests();
+
   group('parseMessageDeepLink', () {
     test('parses channel and id', () {
       final link = parseMessageDeepLink(
@@ -61,3 +63,102 @@ 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('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,
+      );
+    });
+  });
+}
diff --git a/web/src/features/invite/ui/InvitePage.tsx b/web/src/features/invite/ui/InvitePage.tsx
index 3a460a1ba8..1c590fd09f 100644
--- a/web/src/features/invite/ui/InvitePage.tsx
+++ b/web/src/features/invite/ui/InvitePage.tsx
@@ -14,7 +14,7 @@ const DOWNLOAD_URL = "https://github.com/block/buzz/releases/latest";
  */
 export function InvitePage({ code }: { code: string }) {
   const relay = relayWsUrl();
-  const host = relay.replace(/^wss?:\/\//, "");
+  const host = new URL(relay).host;
   const deepLink = `buzz://join?relay=${encodeURIComponent(relay)}&code=${encodeURIComponent(code)}`;
 
   return (

From 32627b29cc1b4cfc53ffa0d7cd1e330c3c53216f Mon Sep 17 00:00:00 2001
From: npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm
 <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co>
Date: Thu, 16 Jul 2026 11:23:30 -0400
Subject: [PATCH 2/6] test(invites): cover expired code, NIP-98 replay, rate
 limit, and mobile relay scheme allowlist

Adds the four redteam cases called out in the PR review focus for #1957,
all as endpoint-level assertions against the invite claim surface.

- `claim_rejects_expired_code`: forges a payload with `e < now` and a
  valid MAC (mint_invite clamps ttl to 60s, so the token layer is the
  only way to produce this), then asserts 403 `invite_expired` and no
  member row. Pins the deliberately distinguishable expiry branch.
- `claim_rejects_replayed_nip98_auth`: swaps in a `SeenOnceReplayGuard`
  test double and reuses the same NIP-98 Authorization header on two
  claim POSTs. Proves `bridge::check_nip98_replay` is wired on the
  claim path and honors `Ok(false)` with 401 `NIP-98: replay detected`
  before invite verification runs.
- `claim_rate_limit_fires_on_repeat_pubkey`: fresh moka cache with the
  production `CLAIM_RATE_LIMIT`; hits the endpoint 11 times with the
  same joiner pubkey and asserts the first 10 reach code verification
  (403 `invite_invalid`) and the 11th flips to 429
  `too many invite claim attempts, slow down`. Proves the guard fires
  on repeat pubkey, not that it can be bypassed.
- `parseInviteDeepLink rejects buzz join with dangerous relay schemes`:
  extends the mobile deep-link parser tests with an allowlist proof
  covering `javascript:`, `data:`, `file:`, `ftp:`, `chrome:`,
  `about:`, and `ssh:`. Mari's existing `https://` rejection remains.

All three Rust tests carry `#[ignore = "requires Postgres"]` and run in
the same CI lane as Mari's earlier invite e2e additions.

Co-authored-by: npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co>
---
 crates/buzz-relay/src/api/invites.rs          | 250 +++++++++++++++++-
 .../test/shared/deeplink/deep_link_test.dart  |  24 ++
 2 files changed, 272 insertions(+), 2 deletions(-)

diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs
index 3b09e2a388..fefbcb1705 100644
--- a/crates/buzz-relay/src/api/invites.rs
+++ b/crates/buzz-relay/src/api/invites.rs
@@ -250,13 +250,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;
 
@@ -610,4 +614,246 @@ mod tests {
         let response = post_json(state, &host_b, "/api/invites/claim", &joiner, body).await;
         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 Some(state) = invite_test_state(&host).await else {
+            return;
+        };
+        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 Some(state_arc) = invite_test_state(&host).await else {
+            return;
+        };
+        // 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 Some(state_arc) = invite_test_state(&host).await else {
+            return;
+        };
+        // 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"),
+        );
+    }
 }
diff --git a/mobile/test/shared/deeplink/deep_link_test.dart b/mobile/test/shared/deeplink/deep_link_test.dart
index b1ee707e0c..9b111690d0 100644
--- a/mobile/test/shared/deeplink/deep_link_test.dart
+++ b/mobile/test/shared/deeplink/deep_link_test.dart
@@ -160,5 +160,29 @@ void _inviteTests() {
         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',
+        );
+      }
+    });
   });
 }

From e560056caaada5a83c7148d96997774e2e4d5fe5 Mon Sep 17 00:00:00 2001
From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Date: Thu, 16 Jul 2026 13:25:16 -0400
Subject: [PATCH 3/6] ci: scope relay e2e to invite coverage

Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
---
 .github/workflows/ci.yml | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d0eb47d6f7..d1a7ffcbeb 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -669,7 +669,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_relay --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

From 7b8bf71766e90d2b198087ea49f69fa4b60dfb1c Mon Sep 17 00:00:00 2001
From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Date: Thu, 16 Jul 2026 14:43:01 -0400
Subject: [PATCH 4/6] fix: complete mobile invite join flow

Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
---
 .github/workflows/ci.yml                      |  7 ++
 crates/buzz-relay/src/api/invites.rs          | 18 ++---
 mobile/lib/app.dart                           |  5 +-
 .../channels/deep_link_dispatcher.dart        | 12 ++--
 .../invites/invite_join_provider.dart         | 12 +++-
 mobile/lib/shared/deeplink/deep_link.dart     | 26 +++++--
 .../channels/deep_link_dispatcher_test.dart   | 72 +++++++++++++++++++
 .../invites/invite_join_provider_test.dart    | 57 +++++++++++++++
 .../test/shared/deeplink/deep_link_test.dart  | 16 +++++
 9 files changed, 204 insertions(+), 21 deletions(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d1a7ffcbeb..5e3a87b547 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -627,6 +627,13 @@ jobs:
           done
           cat /tmp/buzz-relay.log
           exit 1
+      - name: Invite claim security tests
+        run: |
+          cargo test --profile ci -p buzz-relay claim_rejects_expired_code -- --ignored --nocapture
+          cargo test --profile ci -p buzz-relay claim_rejects_replayed_nip98_auth -- --ignored --nocapture
+          cargo test --profile ci -p buzz-relay claim_rate_limit_fires_on_repeat_pubkey -- --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
diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs
index 15e9211952..6291f1b817 100644
--- a/crates/buzz-relay/src/api/invites.rs
+++ b/crates/buzz-relay/src/api/invites.rs
@@ -945,9 +945,9 @@ mod tests {
     async fn claim_rejects_expired_code() {
         let host = format!("invites-{}.example", Uuid::new_v4().simple());
         let joiner = Keys::generate();
-        let Some(state) = invite_test_state(&host).await else {
-            return;
-        };
+        let state = invite_test_state(&host)
+            .await
+            .expect("requires reachable Postgres and relay test state");
         let community = state
             .db
             .lookup_community_by_host(&host)
@@ -1017,9 +1017,9 @@ mod tests {
         let host = format!("invites-{}.example", Uuid::new_v4().simple());
         let owner = Keys::generate();
         let joiner = Keys::generate();
-        let Some(state_arc) = invite_test_state(&host).await else {
-            return;
-        };
+        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 =
@@ -1103,9 +1103,9 @@ mod tests {
     async fn claim_rate_limit_fires_on_repeat_pubkey() {
         let host = format!("invites-{}.example", Uuid::new_v4().simple());
         let joiner = Keys::generate();
-        let Some(state_arc) = invite_test_state(&host).await else {
-            return;
-        };
+        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 =
diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart
index bf85bfdda1..38a7e60aee 100644
--- a/mobile/lib/app.dart
+++ b/mobile/lib/app.dart
@@ -75,7 +75,10 @@ class App extends HookConsumerWidget {
           AuthStatus.authenticated => const DeepLinkDispatcher(
             child: HomePage(),
           ),
-          _ => const PairingPage(),
+          _ => const DeepLinkDispatcher(
+            dispatchMessageLinks: false,
+            child: PairingPage(),
+          ),
         },
       ),
     );
diff --git a/mobile/lib/features/channels/deep_link_dispatcher.dart b/mobile/lib/features/channels/deep_link_dispatcher.dart
index 4e43803ab4..d26b2a9179 100644
--- a/mobile/lib/features/channels/deep_link_dispatcher.dart
+++ b/mobile/lib/features/channels/deep_link_dispatcher.dart
@@ -22,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
@@ -49,9 +51,11 @@ class _DeepLinkDispatcherState extends ConsumerState<DeepLinkDispatcher> {
     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;
   }
@@ -62,7 +66,7 @@ class _DeepLinkDispatcherState extends ConsumerState<DeepLinkDispatcher> {
       _maybeDispatchInvite(link);
       return;
     }
-    if (link is! MessageDeepLink) 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
diff --git a/mobile/lib/features/invites/invite_join_provider.dart b/mobile/lib/features/invites/invite_join_provider.dart
index 12aaab34a8..4bdc553040 100644
--- a/mobile/lib/features/invites/invite_join_provider.dart
+++ b/mobile/lib/features/invites/invite_join_provider.dart
@@ -89,7 +89,11 @@ class InviteJoinNotifier extends Notifier<InviteJoinState> {
 
   Future<void> confirmJoin() async {
     final invite = state.invite;
-    if (invite == null || state.status != InviteJoinStatus.confirming) return;
+    if (invite == null ||
+        (state.status != InviteJoinStatus.confirming &&
+            state.status != InviteJoinStatus.error)) {
+      return;
+    }
 
     state = state.copyWith(status: InviteJoinStatus.claiming);
     try {
@@ -107,7 +111,11 @@ class InviteJoinNotifier extends Notifier<InviteJoinState> {
       }
 
       final keys = ref.read(inviteKeyGeneratorProvider)();
-      final body = jsonEncode({'code': invite.code});
+      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)
diff --git a/mobile/lib/shared/deeplink/deep_link.dart b/mobile/lib/shared/deeplink/deep_link.dart
index 18fd6df50b..b2e57b8b00 100644
--- a/mobile/lib/shared/deeplink/deep_link.dart
+++ b/mobile/lib/shared/deeplink/deep_link.dart
@@ -24,19 +24,28 @@ class InviteDeepLink extends BuzzDeepLink {
   /// Invite code from the link.
   final String code;
 
-  const InviteDeepLink({required this.relayUrl, required this.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.code == code &&
+      other.policyReceipt == policyReceipt;
 
   @override
-  int get hashCode => Object.hash(relayUrl, code);
+  int get hashCode => Object.hash(relayUrl, code, policyReceipt);
 
   @override
-  String toString() => 'InviteDeepLink(relay: $relayUrl, code: $code)';
+  String toString() =>
+      'InviteDeepLink(relay: $relayUrl, code: $code, policyReceipt: $policyReceipt)';
 }
 
 /// A parsed `buzz://message` deep link.
@@ -127,7 +136,14 @@ InviteDeepLink? parseInviteDeepLink(Uri uri) {
       host: relayUri.host,
       port: relayUri.hasPort ? relayUri.port : null,
     ).toString();
-    return InviteDeepLink(relayUrl: normalizedRelay, code: code);
+    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') {
diff --git a/mobile/test/features/channels/deep_link_dispatcher_test.dart b/mobile/test/features/channels/deep_link_dispatcher_test.dart
index 6d6a1b19ea..cf5eed0de7 100644
--- a/mobile/test/features/channels/deep_link_dispatcher_test.dart
+++ b/mobile/test/features/channels/deep_link_dispatcher_test.dart
@@ -1,12 +1,15 @@
 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/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 +49,75 @@ void main() {
     expect(destination.link.messageId, 'message-2');
     expect(destination.link.threadRootId, 'message-1');
   });
+
+  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(
diff --git a/mobile/test/features/invites/invite_join_provider_test.dart b/mobile/test/features/invites/invite_join_provider_test.dart
index a390380526..11dc0bd8e2 100644
--- a/mobile/test/features/invites/invite_join_provider_test.dart
+++ b/mobile/test/features/invites/invite_join_provider_test.dart
@@ -138,6 +138,63 @@ void main() {
       expect(auth.authenticatedCommunities.single.nsec, keys.nsec);
     },
   );
+
+  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 {
diff --git a/mobile/test/shared/deeplink/deep_link_test.dart b/mobile/test/shared/deeplink/deep_link_test.dart
index 9b111690d0..a60056c3bc 100644
--- a/mobile/test/shared/deeplink/deep_link_test.dart
+++ b/mobile/test/shared/deeplink/deep_link_test.dart
@@ -104,6 +104,22 @@ void _inviteTests() {
       );
     });
 
+    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')),

From 7a8af80ada025a64cbca4adc68e851da0dd8470e Mon Sep 17 00:00:00 2001
From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Date: Thu, 16 Jul 2026 15:25:42 -0400
Subject: [PATCH 5/6] ci: run invite claim security tests together

Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
---
 .github/workflows/ci.yml | 4 +---
 1 file changed, 1 insertion(+), 3 deletions(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5e3a87b547..4ec0c87081 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -629,9 +629,7 @@ jobs:
           exit 1
       - name: Invite claim security tests
         run: |
-          cargo test --profile ci -p buzz-relay claim_rejects_expired_code -- --ignored --nocapture
-          cargo test --profile ci -p buzz-relay claim_rejects_replayed_nip98_auth -- --ignored --nocapture
-          cargo test --profile ci -p buzz-relay claim_rate_limit_fires_on_repeat_pubkey -- --ignored --nocapture
+          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

From b9519f3408b6ad64310646944bf06e1004986652 Mon Sep 17 00:00:00 2001
From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Date: Thu, 16 Jul 2026 15:49:30 -0400
Subject: [PATCH 6/6] fix(mobile): retain invites when preparation fails

Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>

Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
---
 .../channels/deep_link_dispatcher.dart        |  39 +++++--
 .../invites/invite_join_provider.dart         |  14 +++
 .../features/invites/invite_join_sheet.dart   |   2 +-
 .../channels/deep_link_dispatcher_test.dart   | 105 ++++++++++++++++++
 .../invites/invite_join_provider_test.dart    |  44 ++++++++
 5 files changed, 193 insertions(+), 11 deletions(-)

diff --git a/mobile/lib/features/channels/deep_link_dispatcher.dart b/mobile/lib/features/channels/deep_link_dispatcher.dart
index d26b2a9179..b264b31b69 100644
--- a/mobile/lib/features/channels/deep_link_dispatcher.dart
+++ b/mobile/lib/features/channels/deep_link_dispatcher.dart
@@ -36,6 +36,8 @@ class DeepLinkDispatcher extends ConsumerStatefulWidget {
 }
 
 class _DeepLinkDispatcherState extends ConsumerState<DeepLinkDispatcher> {
+  bool _preparingInvite = false;
+
   @override
   void initState() {
     super.initState();
@@ -105,19 +107,36 @@ class _DeepLinkDispatcherState extends ConsumerState<DeepLinkDispatcher> {
   }
 
   void _maybeDispatchInvite(InviteDeepLink link) {
-    ref.read(pendingDeepLinkProvider.notifier).consume();
+    if (_preparingInvite) return;
+    _preparingInvite = true;
     final navigatorContext = context;
     final messenger = ScaffoldMessenger.maybeOf(context);
     Future.microtask(() async {
-      await ref.read(inviteJoinProvider.notifier).prepare(link);
-      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')),
-        );
+      try {
+        await ref.read(inviteJoinProvider.notifier).prepare(link);
+        ref.read(pendingDeepLinkProvider.notifier).consume();
+        if (!navigatorContext.mounted) return;
+        final status = ref.read(inviteJoinProvider).status;
+        if (status == InviteJoinStatus.confirming) {
+          showInviteJoinSheet(navigatorContext, ref);
+        } else if (status == InviteJoinStatus.switchedExisting) {
+          messenger?.showSnackBar(
+            const SnackBar(content: Text('Switched to this community')),
+          );
+        }
+      } catch (error) {
+        debugPrint('deep-link: failed to prepare invite: $error');
+        if (navigatorContext.mounted) {
+          messenger?.showSnackBar(
+            const SnackBar(
+              content: Text(
+                'Could not open this invite. Re-open the invite link to try again.',
+              ),
+            ),
+          );
+        }
+      } finally {
+        _preparingInvite = false;
       }
     });
   }
diff --git a/mobile/lib/features/invites/invite_join_provider.dart b/mobile/lib/features/invites/invite_join_provider.dart
index 4bdc553040..0cd58d47a7 100644
--- a/mobile/lib/features/invites/invite_join_provider.dart
+++ b/mobile/lib/features/invites/invite_join_provider.dart
@@ -35,6 +35,7 @@ class InviteJoinState {
   final String? host;
   final String? communityName;
   final String? errorMessage;
+  final bool requiresFreshInvite;
 
   const InviteJoinState({
     this.status = InviteJoinStatus.idle,
@@ -42,6 +43,7 @@ class InviteJoinState {
     this.host,
     this.communityName,
     this.errorMessage,
+    this.requiresFreshInvite = false,
   });
 
   InviteJoinState copyWith({
@@ -50,12 +52,14 @@ class InviteJoinState {
     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,
   );
 }
 
@@ -90,6 +94,7 @@ class InviteJoinNotifier extends Notifier<InviteJoinState> {
   Future<void> confirmJoin() async {
     final invite = state.invite;
     if (invite == null ||
+        state.requiresFreshInvite ||
         (state.status != InviteJoinStatus.confirming &&
             state.status != InviteJoinStatus.error)) {
       return;
@@ -158,9 +163,11 @@ class InviteJoinNotifier extends Notifier<InviteJoinState> {
         communityName: community.name,
       );
     } catch (error) {
+      final requiresFreshInvite = _requiresFreshInvite(error);
       state = state.copyWith(
         status: InviteJoinStatus.error,
         errorMessage: _friendlyInviteError(error),
+        requiresFreshInvite: requiresFreshInvite,
       );
     }
   }
@@ -248,10 +255,17 @@ String _communityNameFromClaim(Map<String, dynamic> claim, String relayUrl) {
   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') ||
diff --git a/mobile/lib/features/invites/invite_join_sheet.dart b/mobile/lib/features/invites/invite_join_sheet.dart
index f5ac96f046..58221158e5 100644
--- a/mobile/lib/features/invites/invite_join_sheet.dart
+++ b/mobile/lib/features/invites/invite_join_sheet.dart
@@ -107,7 +107,7 @@ class InviteJoinSheet extends ConsumerWidget {
                 const SizedBox(width: Grid.sm),
                 Expanded(
                   child: FilledButton.icon(
-                    onPressed: isClaiming
+                    onPressed: isClaiming || state.requiresFreshInvite
                         ? null
                         : () => ref
                               .read(inviteJoinProvider.notifier)
diff --git a/mobile/test/features/channels/deep_link_dispatcher_test.dart b/mobile/test/features/channels/deep_link_dispatcher_test.dart
index cf5eed0de7..0771a7bb38 100644
--- a/mobile/test/features/channels/deep_link_dispatcher_test.dart
+++ b/mobile/test/features/channels/deep_link_dispatcher_test.dart
@@ -1,6 +1,7 @@
 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';
@@ -50,6 +51,77 @@ void main() {
     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 {
@@ -132,6 +204,39 @@ 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);
 
diff --git a/mobile/test/features/invites/invite_join_provider_test.dart b/mobile/test/features/invites/invite_join_provider_test.dart
index 11dc0bd8e2..29158f7910 100644
--- a/mobile/test/features/invites/invite_join_provider_test.dart
+++ b/mobile/test/features/invites/invite_join_provider_test.dart
@@ -139,6 +139,50 @@ void main() {
     },
   );
 
+  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;
