From 64e77a81fb2fe269f80f0b920184a927ba5b18ac Mon Sep 17 00:00:00 2001
From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Date: Tue, 14 Jul 2026 22:16:45 -0400
Subject: [PATCH 01/16] Gate relay invites on optional terms acceptance

Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
---
 .env.example                                 |   4 +
 crates/buzz-db/src/lib.rs                    |  20 ++++
 crates/buzz-db/src/migration.rs              |  12 ++-
 crates/buzz-db/src/relay_members.rs          |  38 +++++++
 crates/buzz-relay/src/api/invites.rs         |  83 ++++++++++++++
 crates/buzz-relay/src/config.rs              |  56 ++++++++++
 crates/buzz-relay/src/invite_token.rs        |  83 ++++++++++++++
 crates/buzz-relay/src/router.rs              |   5 +
 desktop/src-tauri/src/deep_link.rs           |  14 +++
 desktop/src/shared/api/invites.ts            |   3 +-
 desktop/src/shared/deep-link.ts              |   5 +-
 migrations/0019_invite_terms_acceptances.sql |  11 ++
 web/src/features/invite/ui/InvitePage.tsx    | 108 +++++++++++++++----
 13 files changed, 420 insertions(+), 22 deletions(-)
 create mode 100644 migrations/0019_invite_terms_acceptances.sql

diff --git a/.env.example b/.env.example
index 696d3a0617..715c422dfe 100644
--- a/.env.example
+++ b/.env.example
@@ -197,3 +197,7 @@ RUST_LOG=buzz_relay=debug,buzz_db=debug,buzz_auth=debug,buzz_pubsub=debug,tower_
 # These are accepted for backward compatibility but the canonical names above
 # are preferred:
 #   BUZZ_ACP_PRIVATE_KEY  → BUZZ_PRIVATE_KEY
+
+# Optional invite terms gate. Set both to show and enforce terms acceptance.
+# BUZZ_INVITE_TERMS_URL=https://example.com/terms
+# BUZZ_INVITE_TERMS_VERSION=2026-07-15
diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs
index c0b6bf78c6..defdbd214d 100644
--- a/crates/buzz-db/src/lib.rs
+++ b/crates/buzz-db/src/lib.rs
@@ -2651,6 +2651,26 @@ impl Db {
         relay_members::add_relay_member(&self.pool, community, pubkey, role, added_by).await
     }
 
+    /// Durably records an invite terms acceptance receipt.
+    pub async fn record_invite_terms_acceptance(
+        &self,
+        receipt_id: uuid::Uuid,
+        community: CommunityId,
+        pubkey: &str,
+        policy_version: &str,
+        accepted_at: chrono::DateTime<chrono::Utc>,
+    ) -> Result<()> {
+        relay_members::record_invite_terms_acceptance(
+            &self.pool,
+            receipt_id,
+            community,
+            pubkey,
+            policy_version,
+            accepted_at,
+        )
+        .await
+    }
+
     /// Removes a relay member from `community` atomically, refusing to delete the owner.
     pub async fn remove_relay_member(
         &self,
diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs
index faa0b428e4..54e5cab3c2 100644
--- a/crates/buzz-db/src/migration.rs
+++ b/crates/buzz-db/src/migration.rs
@@ -549,7 +549,7 @@ mod tests {
         let mut migrations: Vec<_> = MIGRATOR.iter().collect();
         migrations.sort_by_key(|migration| migration.version);
 
-        assert_eq!(migrations.len(), 18);
+        assert_eq!(migrations.len(), 19);
         assert_eq!(migrations[0].version, 1);
         assert_eq!(&*migrations[0].description, "initial schema");
         assert!(migrations[0]
@@ -798,6 +798,16 @@ mod tests {
         assert!(matcher.contains("AFTER INSERT ON events"));
         assert!(matcher.contains("NEW.kind IN (7, 9, 1059, 40007, 46010)"));
         assert!(!migrations[0].sql.as_str().contains("push_match_queue"));
+
+        assert_eq!(migrations[18].version, 19);
+        assert!(migrations[18]
+            .sql
+            .as_str()
+            .contains("CREATE TABLE invite_terms_acceptances"));
+        assert!(!migrations[0]
+            .sql
+            .as_str()
+            .contains("invite_terms_acceptances"));
     }
 
     #[test]
diff --git a/crates/buzz-db/src/relay_members.rs b/crates/buzz-db/src/relay_members.rs
index d354b057bd..4efcd0edd3 100644
--- a/crates/buzz-db/src/relay_members.rs
+++ b/crates/buzz-db/src/relay_members.rs
@@ -492,6 +492,44 @@ pub async fn backfill_from_allowlist(pool: &PgPool, community: CommunityId) -> R
     Ok(result.rows_affected())
 }
 
+/// Durably records which invite policy revision a joining pubkey accepted.
+pub async fn record_invite_terms_acceptance(
+    pool: &PgPool,
+    receipt_id: uuid::Uuid,
+    community: CommunityId,
+    pubkey: &str,
+    policy_version: &str,
+    accepted_at: chrono::DateTime<chrono::Utc>,
+) -> Result<()> {
+    let recorded_for_pubkey: Option<bool> = sqlx::query_scalar(
+        "WITH inserted AS (
+             INSERT INTO invite_terms_acceptances
+                 (receipt_id, community_id, pubkey, policy_version, accepted_at)
+             VALUES ($1, $2, $3, $4, $5)
+             ON CONFLICT (community_id, receipt_id) DO NOTHING
+             RETURNING true AS matches
+         )
+         SELECT matches FROM inserted
+         UNION ALL
+         SELECT community_id = $2 AND pubkey = $3 AND policy_version = $4
+         FROM invite_terms_acceptances WHERE community_id = $2 AND receipt_id = $1
+         LIMIT 1",
+    )
+    .bind(receipt_id)
+    .bind(community.as_uuid())
+    .bind(pubkey)
+    .bind(policy_version)
+    .bind(accepted_at)
+    .fetch_optional(pool)
+    .await?;
+    if recorded_for_pubkey != Some(true) {
+        return Err(crate::error::DbError::AccessDenied(
+            "invite terms receipt was already claimed by another member".to_string(),
+        ));
+    }
+    Ok(())
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs
index 3b09e2a388..b7734991c4 100644
--- a/crates/buzz-relay/src/api/invites.rs
+++ b/crates/buzz-relay/src/api/invites.rs
@@ -54,6 +54,58 @@ pub struct MintInviteRequest {
 pub struct ClaimInviteRequest {
     /// The invite code to redeem.
     pub code: String,
+    /// Relay-issued proof of accepting the configured terms, when required.
+    #[serde(default)]
+    pub terms_receipt: Option<String>,
+}
+
+/// Body for `POST /api/invites/accept-terms`.
+#[derive(Debug, Deserialize)]
+pub struct AcceptTermsRequest {
+    /// Invite code the acceptance receipt will be bound to.
+    pub code: String,
+    /// Policy revision displayed by the browser.
+    pub policy_version: String,
+    /// Explicit checkbox acceptance; false is always rejected.
+    pub accepted: bool,
+}
+
+/// Public invite-page configuration. Empty when the operator has no policy.
+pub async fn invite_config(State(state): State<Arc<AppState>>) -> Json<Value> {
+    match &state.config.invite_terms {
+        Some(policy) => Json(serde_json::json!({
+            "terms": { "url": policy.url, "version": policy.version }
+        })),
+        None => Json(serde_json::json!({})),
+    }
+}
+
+/// Exchange an explicit browser acceptance for a short-lived, invite-bound receipt.
+pub async fn accept_terms(
+    State(state): State<Arc<AppState>>,
+    body: axum::body::Bytes,
+) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
+    let Some(policy) = &state.config.invite_terms else {
+        return Err(api_error(
+            StatusCode::NOT_FOUND,
+            "invite_terms_not_configured",
+        ));
+    };
+    let request: AcceptTermsRequest = serde_json::from_slice(&body).map_err(|e| {
+        api_error(
+            StatusCode::BAD_REQUEST,
+            &format!("invalid acceptance JSON: {e}"),
+        )
+    })?;
+    if !request.accepted || request.policy_version != policy.version {
+        return Err(api_error(
+            StatusCode::BAD_REQUEST,
+            "invite_terms_not_accepted",
+        ));
+    }
+    let key = invite_token::derive_invite_key(&state.relay_keypair);
+    let receipt = invite_token::mint_terms_acceptance(&key, &request.code, &policy.version);
+    Ok(Json(serde_json::json!({ "receipt": receipt })))
 }
 
 /// Shared prelude: bind the tenant from the Host header and verify the NIP-98
@@ -186,6 +238,37 @@ pub async fn claim_invite(
     )?;
 
     let claimer_hex = pubkey.to_hex();
+    let terms_acceptance = if let Some(policy) = &state.config.invite_terms {
+        let receipt = request
+            .terms_receipt
+            .as_deref()
+            .ok_or_else(|| api_error(StatusCode::FORBIDDEN, "invite_terms_required"))?;
+        Some(
+            invite_token::verify_terms_acceptance(&key, receipt, &request.code, &policy.version)
+                .map_err(|_| api_error(StatusCode::FORBIDDEN, "invite_terms_required"))?,
+        )
+    } else {
+        None
+    };
+
+    if let Some(acceptance) = &terms_acceptance {
+        let receipt_id = uuid::Uuid::parse_str(&acceptance.j)
+            .map_err(|_| api_error(StatusCode::FORBIDDEN, "invite_terms_required"))?;
+        let accepted_at = chrono::DateTime::from_timestamp(acceptance.a as i64, 0)
+            .ok_or_else(|| api_error(StatusCode::FORBIDDEN, "invite_terms_required"))?;
+        state
+            .db
+            .record_invite_terms_acceptance(
+                receipt_id,
+                tenant.community(),
+                &claimer_hex,
+                &acceptance.v,
+                accepted_at,
+            )
+            .await
+            .map_err(|e| internal_error(&format!("invite terms acceptance insert: {e}")))?;
+    }
+
     let was_inserted = state
         .db
         .add_relay_member(tenant.community(), &claimer_hex, &payload.r, Some("invite"))
diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs
index b4f6633028..a051ec835f 100644
--- a/crates/buzz-relay/src/config.rs
+++ b/crates/buzz-relay/src/config.rs
@@ -23,6 +23,15 @@ pub enum ConfigError {
     InvalidValue(String),
 }
 
+/// Relay runtime configuration, loaded from environment variables.
+#[derive(Debug, Clone)]
+pub struct InviteTermsConfig {
+    /// Public HTTPS URL containing the operator's terms.
+    pub url: url::Url,
+    /// Stable operator-defined identifier for the exact terms revision.
+    pub version: String,
+}
+
 /// Relay runtime configuration, loaded from environment variables.
 #[derive(Debug, Clone)]
 pub struct Config {
@@ -197,6 +206,11 @@ pub struct Config {
     /// Hard timeout for one gateway delivery request.
     pub push_gateway_timeout: Duration,
 
+    /// Optional terms policy shown and enforced for relay invite acceptance.
+    /// Disabled unless both `BUZZ_INVITE_TERMS_URL` and
+    /// `BUZZ_INVITE_TERMS_VERSION` are set.
+    pub invite_terms: Option<InviteTermsConfig>,
+
     /// Optional path to the web UI `dist/` directory.
     /// When set, the relay serves the SPA from this directory for browser requests.
     /// When unset, no static file serving happens (relay behaves as before).
@@ -599,6 +613,47 @@ impl Config {
         };
         let push_gateway_timeout = Duration::from_millis(push_gateway_timeout_millis);
 
+        let invite_terms_url = std::env::var("BUZZ_INVITE_TERMS_URL")
+            .ok()
+            .map(|v| v.trim().to_string())
+            .filter(|v| !v.is_empty());
+        let invite_terms_version = std::env::var("BUZZ_INVITE_TERMS_VERSION")
+            .ok()
+            .map(|v| v.trim().to_string())
+            .filter(|v| !v.is_empty());
+        let invite_terms = match (invite_terms_url, invite_terms_version) {
+            (None, None) => None,
+            (Some(raw_url), Some(version)) => {
+                if version.len() > 128 {
+                    return Err(ConfigError::InvalidValue(
+                        "BUZZ_INVITE_TERMS_VERSION must contain at most 128 bytes".to_string(),
+                    ));
+                }
+                let url = url::Url::parse(&raw_url).map_err(|e| {
+                    ConfigError::InvalidValue(format!(
+                        "BUZZ_INVITE_TERMS_URL must be a valid HTTPS URL: {e}"
+                    ))
+                })?;
+                if url.scheme() != "https"
+                    || url.host_str().is_none()
+                    || url.username() != ""
+                    || url.password().is_some()
+                {
+                    return Err(ConfigError::InvalidValue(
+                        "BUZZ_INVITE_TERMS_URL must be an HTTPS URL without credentials"
+                            .to_string(),
+                    ));
+                }
+                Some(InviteTermsConfig { url, version })
+            }
+            _ => {
+                return Err(ConfigError::InvalidValue(
+                    "BUZZ_INVITE_TERMS_URL and BUZZ_INVITE_TERMS_VERSION must be set together"
+                        .to_string(),
+                ))
+            }
+        };
+
         // Web UI static file serving
         let web_dir = std::env::var("BUZZ_WEB_DIR")
             .ok()
@@ -667,6 +722,7 @@ impl Config {
             push_executor_key_id,
             push_gateway_delivery_url,
             push_gateway_timeout,
+            invite_terms,
             web_dir,
         })
     }
diff --git a/crates/buzz-relay/src/invite_token.rs b/crates/buzz-relay/src/invite_token.rs
index dda3c495ee..4f2337edb8 100644
--- a/crates/buzz-relay/src/invite_token.rs
+++ b/crates/buzz-relay/src/invite_token.rs
@@ -327,3 +327,86 @@ mod tests {
         assert_eq!(verify_invite(&key, c, &code), Err(InviteError::InvalidRole));
     }
 }
+
+/// Short-lived proof that the browser accepted the configured invite terms.
+#[derive(Debug, Serialize, Deserialize)]
+pub struct TermsAcceptancePayload {
+    /// SHA-256 of the invite code this acceptance is bound to.
+    pub c: String,
+    /// Configured policy version.
+    pub v: String,
+    /// Unique receipt identifier.
+    pub j: String,
+    /// Acceptance timestamp (unix seconds).
+    pub a: u64,
+    /// Receipt expiry (unix seconds).
+    pub e: u64,
+}
+
+/// Mint a relay-authenticated, invite-bound terms acceptance receipt.
+pub fn mint_terms_acceptance(key: &[u8; 32], code: &str, version: &str) -> String {
+    let accepted_at = now_unix();
+    let payload = TermsAcceptancePayload {
+        c: hex::encode(Sha256::digest(code.as_bytes())),
+        v: version.to_string(),
+        j: uuid::Uuid::new_v4().to_string(),
+        a: accepted_at,
+        e: accepted_at + 10 * 60,
+    };
+    let bytes = serde_json::to_vec(&payload).expect("terms acceptance serializes");
+    format!(
+        "{}.{}",
+        URL_SAFE_NO_PAD.encode(&bytes),
+        URL_SAFE_NO_PAD.encode(sign_payload(key, &bytes))
+    )
+}
+
+/// Verify a terms receipt and bind it to the submitted invite and current policy.
+pub fn verify_terms_acceptance(
+    key: &[u8; 32],
+    receipt: &str,
+    code: &str,
+    version: &str,
+) -> Result<TermsAcceptancePayload, InviteError> {
+    if receipt.len() > 2048 {
+        return Err(InviteError::Malformed);
+    }
+    let (payload, signature) = receipt.split_once('.').ok_or(InviteError::Malformed)?;
+    let bytes = URL_SAFE_NO_PAD
+        .decode(payload)
+        .map_err(|_| InviteError::Malformed)?;
+    let signature = URL_SAFE_NO_PAD
+        .decode(signature)
+        .map_err(|_| InviteError::Malformed)?;
+    let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key size");
+    mac.update(&bytes);
+    mac.verify_slice(&signature)
+        .map_err(|_| InviteError::BadSignature)?;
+    let payload: TermsAcceptancePayload =
+        serde_json::from_slice(&bytes).map_err(|_| InviteError::Malformed)?;
+    if payload.e < now_unix() {
+        return Err(InviteError::Expired);
+    }
+    let expected_code = hex::encode(Sha256::digest(code.as_bytes()));
+    if payload.c != expected_code || payload.v != version {
+        return Err(InviteError::Malformed);
+    }
+    Ok(payload)
+}
+
+#[cfg(test)]
+mod terms_acceptance_tests {
+    use super::*;
+
+    #[test]
+    fn terms_receipt_is_bound_to_invite_and_version() {
+        let key = [7_u8; 32];
+        let receipt = mint_terms_acceptance(&key, "invite-a", "v1");
+        let payload =
+            verify_terms_acceptance(&key, &receipt, "invite-a", "v1").expect("valid receipt");
+        assert_eq!(payload.v, "v1");
+        assert!(verify_terms_acceptance(&key, &receipt, "invite-b", "v1").is_err());
+        assert!(verify_terms_acceptance(&key, &receipt, "invite-a", "v2").is_err());
+        assert!(verify_terms_acceptance(&[8_u8; 32], &receipt, "invite-a", "v1").is_err());
+    }
+}
diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs
index f26a3703f3..cb1cba7bcd 100644
--- a/crates/buzz-relay/src/router.rs
+++ b/crates/buzz-relay/src/router.rs
@@ -78,6 +78,11 @@ pub fn build_router(state: Arc<AppState>) -> Router {
         )
         // Relay invites: mint (owner/admin) + claim (membership-gate exempt)
         .route("/api/invites", post(api::invites::mint_invite))
+        .route("/api/invites/config", get(api::invites::invite_config))
+        .route(
+            "/api/invites/accept-terms",
+            post(api::invites::accept_terms),
+        )
         .route("/api/invites/claim", post(api::invites::claim_invite))
         // Moderation queue reads (NIP-98 auth + mod-authz gate, L6)
         .route("/moderation/reports", get(api::bridge::moderation_reports))
diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs
index fc0065ee12..29ae3237d0 100644
--- a/desktop/src-tauri/src/deep_link.rs
+++ b/desktop/src-tauri/src/deep_link.rs
@@ -59,6 +59,7 @@ fn parse_message_deep_link(url: &Url) -> Option<serde_json::Value> {
 fn parse_join_deep_link(url: &Url) -> Option<serde_json::Value> {
     let mut relay: Option<String> = None;
     let mut code: Option<String> = None;
+    let mut terms_receipt: Option<String> = None;
     for (k, v) in url.query_pairs() {
         let v = v.into_owned();
         if v.is_empty() {
@@ -67,6 +68,7 @@ fn parse_join_deep_link(url: &Url) -> Option<serde_json::Value> {
         match k.as_ref() {
             "relay" => relay = Some(v),
             "code" => code = Some(v),
+            "terms_receipt" => terms_receipt = Some(v),
             _ => {}
         }
     }
@@ -78,6 +80,7 @@ fn parse_join_deep_link(url: &Url) -> Option<serde_json::Value> {
     Some(serde_json::json!({
         "relayUrl": relay_url,
         "code": code,
+        "termsReceipt": terms_receipt,
     }))
 }
 
@@ -302,6 +305,17 @@ mod tests {
         let payload = parse_join_deep_link(&url).expect("required params present");
         assert_eq!(payload["relayUrl"], "wss://relay.example");
         assert_eq!(payload["code"], "abc.def");
+        assert!(payload["termsReceipt"].is_null());
+    }
+
+    #[test]
+    fn parse_join_deep_link_extracts_terms_receipt() {
+        let url = Url::parse(
+            "buzz://join?relay=wss%3A%2F%2Frelay.example&code=abc.def&terms_receipt=receipt.value",
+        )
+        .unwrap();
+        let payload = parse_join_deep_link(&url).expect("required params present");
+        assert_eq!(payload["termsReceipt"], "receipt.value");
     }
 
     #[test]
diff --git a/desktop/src/shared/api/invites.ts b/desktop/src/shared/api/invites.ts
index f0161d6205..46014fa44f 100644
--- a/desktop/src/shared/api/invites.ts
+++ b/desktop/src/shared/api/invites.ts
@@ -103,9 +103,10 @@ export async function mintInvite(ttlSecs?: number): Promise<MintedInvite> {
 export async function claimInvite(
   relayWsUrl: string,
   code: string,
+  termsReceipt?: string,
 ): Promise<ClaimResult> {
   const base = relayHttpFromWs(relayWsUrl);
-  const body = JSON.stringify({ code });
+  const body = JSON.stringify({ code, terms_receipt: termsReceipt });
   const raw = await invitePost<{
     status: "joined" | "already_member";
     community_id: string;
diff --git a/desktop/src/shared/deep-link.ts b/desktop/src/shared/deep-link.ts
index 47a5554e05..3f8ab562a1 100644
--- a/desktop/src/shared/deep-link.ts
+++ b/desktop/src/shared/deep-link.ts
@@ -48,6 +48,7 @@ export type NostrBindDeepLinkPayload = {
 export type JoinDeepLinkPayload = {
   relayUrl: string;
   code: string;
+  termsReceipt: string | null;
 };
 
 /**
@@ -89,8 +90,8 @@ export function listenForDeepLinks(deps: DeepLinkDeps): Promise<UnlistenFn> {
   });
 
   const joinPromise = listen<JoinDeepLinkPayload>("deep-link-join", (event) => {
-    const { relayUrl, code } = event.payload;
-    void claimInvite(relayUrl, code)
+    const { relayUrl, code, termsReceipt } = event.payload;
+    void claimInvite(relayUrl, code, termsReceipt ?? undefined)
       .then((result) => {
         const name = addAndSwitch(relayUrl);
         toast.success(
diff --git a/migrations/0019_invite_terms_acceptances.sql b/migrations/0019_invite_terms_acceptances.sql
new file mode 100644
index 0000000000..6ad555b029
--- /dev/null
+++ b/migrations/0019_invite_terms_acceptances.sql
@@ -0,0 +1,11 @@
+CREATE TABLE invite_terms_acceptances (
+    receipt_id UUID NOT NULL,
+    community_id UUID NOT NULL REFERENCES communities(id),
+    pubkey TEXT NOT NULL,
+    policy_version TEXT NOT NULL,
+    accepted_at TIMESTAMPTZ NOT NULL,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    PRIMARY KEY (community_id, receipt_id)
+);
+CREATE INDEX idx_invite_terms_acceptances_member
+    ON invite_terms_acceptances (community_id, pubkey, accepted_at DESC);
diff --git a/web/src/features/invite/ui/InvitePage.tsx b/web/src/features/invite/ui/InvitePage.tsx
index 3a460a1ba8..bbde39ec31 100644
--- a/web/src/features/invite/ui/InvitePage.tsx
+++ b/web/src/features/invite/ui/InvitePage.tsx
@@ -1,22 +1,57 @@
 import buzzAppIcon from "@/assets/app-icon@3x.png";
 import { relayWsUrl } from "@/shared/lib/relay-url";
 import { Button } from "@/shared/ui/button";
+import * as React from "react";
 
 const DOWNLOAD_URL = "https://github.com/block/buzz/releases/latest";
+type Terms = { url: string; version: string };
 
-/**
- * Landing page for a community invite link (`/invite/<code>`).
- *
- * The code is not validated here — validation happens in the desktop app when
- * the invite is claimed against `POST /api/invites/claim`, signed by the
- * joining key. This page only hands the code off via the `buzz://join` deep
- * link (or tells the visitor where to get the app first).
- */
+/** Landing page for a community invite link (`/invite/<code>`). */
 export function InvitePage({ code }: { code: string }) {
   const relay = relayWsUrl();
   const host = relay.replace(/^wss?:\/\//, "");
-  const deepLink = `buzz://join?relay=${encodeURIComponent(relay)}&code=${encodeURIComponent(code)}`;
+  const [terms, setTerms] = React.useState<Terms | null | undefined>(undefined);
+  const [accepted, setAccepted] = React.useState(false);
+  const [opening, setOpening] = React.useState(false);
 
+  React.useEffect(() => {
+    fetch("/api/invites/config")
+      .then(async (response) => {
+        if (!response.ok) throw new Error(`HTTP ${response.status}`);
+        const config = (await response.json()) as { terms?: Terms };
+        setTerms(config.terms ?? null);
+      })
+      .catch(() => setTerms(undefined));
+  }, []);
+
+  const openInvite = async () => {
+    if (terms && !accepted) return;
+    setOpening(true);
+    try {
+      let receipt: string | undefined;
+      if (terms) {
+        const response = await fetch("/api/invites/accept-terms", {
+          method: "POST",
+          headers: { "Content-Type": "application/json" },
+          body: JSON.stringify({
+            code,
+            policy_version: terms.version,
+            accepted: true,
+          }),
+        });
+        if (!response.ok) throw new Error(`HTTP ${response.status}`);
+        receipt = ((await response.json()) as { receipt: string }).receipt;
+      }
+      const query = new URLSearchParams({ relay, code });
+      if (receipt) query.set("terms_receipt", receipt);
+      window.location.href = `buzz://join?${query.toString()}`;
+    } finally {
+      setOpening(false);
+    }
+  };
+
+  const disabled =
+    terms === undefined || opening || (terms !== null && !accepted);
   return (
     <div
       className="flex flex-1 flex-col items-center justify-center px-4 py-16 text-center"
@@ -42,16 +77,53 @@ export function InvitePage({ code }: { code: string }) {
         </h1>
         <p className="mt-1 font-mono text-lg text-black/70">{host}</p>
 
-        <div className="mt-8">
-          <Button
-            asChild
-            className="bg-black text-white hover:bg-black/90 focus-visible:ring-black"
-            size="lg"
-          >
-            <a href={deepLink}>Accept invite in Buzz</a>
-          </Button>
-        </div>
+        {terms && (
+          <label className="mt-8 flex max-w-md cursor-pointer items-start gap-3 text-left text-sm text-black/70">
+            <input
+              className="mt-0.5 h-4 w-4 accent-black"
+              type="checkbox"
+              checked={accepted}
+              onChange={(event) => setAccepted(event.target.checked)}
+            />
+            <span>
+              I agree to this relay operator&apos;s{" "}
+              <a
+                className="font-medium text-black underline"
+                href={terms.url}
+                target="_blank"
+                rel="noreferrer"
+              >
+                Terms of Service
+              </a>
+              .
+            </span>
+          </label>
+        )}
 
+        <div className={terms ? "mt-6" : "mt-8"}>
+          {terms === null ? (
+            <Button
+              asChild
+              className="bg-black text-white hover:bg-black/90 focus-visible:ring-black"
+              size="lg"
+            >
+              <a
+                href={`buzz://join?relay=${encodeURIComponent(relay)}&code=${encodeURIComponent(code)}`}
+              >
+                Accept invite in Buzz
+              </a>
+            </Button>
+          ) : (
+            <Button
+              className="bg-black text-white hover:bg-black/90 focus-visible:ring-black disabled:cursor-not-allowed disabled:bg-black/30 disabled:text-white/70"
+              size="lg"
+              disabled={disabled}
+              onClick={openInvite}
+            >
+              Accept invite in Buzz
+            </Button>
+          )}
+        </div>
         <p className="mt-6 text-sm text-black/60">
           Don&apos;t have the app?{" "}
           <a

From 7f3f41c58066c8e49d1a9ca46e2266bfa0046f73 Mon Sep 17 00:00:00 2001
From: klopez4212 <klopez4212@gmail.com>
Date: Wed, 15 Jul 2026 14:37:37 +0100
Subject: [PATCH 02/16] Refine invite terms layout (#1900)

Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
---
 web/src/features/invite/ui/InvitePage.tsx | 104 +++++++++-------------
 1 file changed, 44 insertions(+), 60 deletions(-)

diff --git a/web/src/features/invite/ui/InvitePage.tsx b/web/src/features/invite/ui/InvitePage.tsx
index bbde39ec31..212d2b9b44 100644
--- a/web/src/features/invite/ui/InvitePage.tsx
+++ b/web/src/features/invite/ui/InvitePage.tsx
@@ -11,7 +11,6 @@ export function InvitePage({ code }: { code: string }) {
   const relay = relayWsUrl();
   const host = relay.replace(/^wss?:\/\//, "");
   const [terms, setTerms] = React.useState<Terms | null | undefined>(undefined);
-  const [accepted, setAccepted] = React.useState(false);
   const [opening, setOpening] = React.useState(false);
 
   React.useEffect(() => {
@@ -25,7 +24,6 @@ export function InvitePage({ code }: { code: string }) {
   }, []);
 
   const openInvite = async () => {
-    if (terms && !accepted) return;
     setOpening(true);
     try {
       let receipt: string | undefined;
@@ -50,8 +48,7 @@ export function InvitePage({ code }: { code: string }) {
     }
   };
 
-  const disabled =
-    terms === undefined || opening || (terms !== null && !accepted);
+  const disabled = terms === undefined || opening;
   return (
     <div
       className="flex flex-1 flex-col items-center justify-center px-4 py-16 text-center"
@@ -59,36 +56,48 @@ export function InvitePage({ code }: { code: string }) {
         backgroundImage: "linear-gradient(180deg, #D7D72E 0%, #D7E7F6 100%)",
       }}
     >
-      <div
-        className="flex w-full max-w-xl flex-col items-center rounded-3xl bg-white px-6 py-10 sm:px-12 sm:py-12"
-        style={{
-          boxShadow:
-            "0 0 0 1px rgba(0, 0, 0, 0.04), 0 8px 24px rgba(0, 0, 0, 0.04)",
-        }}
-      >
-        <div
-          className="h-16 w-16 overflow-hidden bg-black"
-          style={{ borderRadius: "22.37%" }}
-        >
-          <img alt="Buzz" className="h-full w-full" src={buzzAppIcon} />
-        </div>
-        <h1 className="mt-6 text-2xl font-semibold tracking-tight text-black">
-          You&apos;re invited to join
-        </h1>
-        <p className="mt-1 font-mono text-lg text-black/70">{host}</p>
+      <div className="w-full max-w-xl space-y-4">
+        <div className="flex w-full flex-col items-center rounded-3xl bg-white px-6 py-10 sm:px-12 sm:py-12">
+          <div
+            className="h-12 w-12 overflow-hidden bg-black"
+            style={{ borderRadius: "22.37%" }}
+          >
+            <img alt="Buzz" className="h-full w-full" src={buzzAppIcon} />
+          </div>
+          <h1 className="mt-4 text-2xl font-semibold tracking-tight text-black">
+            You&apos;re invited to
+          </h1>
+          <p className="mt-9 font-mono text-lg text-black/70">{host}</p>
 
-        {terms && (
-          <label className="mt-8 flex max-w-md cursor-pointer items-start gap-3 text-left text-sm text-black/70">
-            <input
-              className="mt-0.5 h-4 w-4 accent-black"
-              type="checkbox"
-              checked={accepted}
-              onChange={(event) => setAccepted(event.target.checked)}
-            />
-            <span>
-              I agree to this relay operator&apos;s{" "}
+          <div className="mt-9">
+            {terms === null ? (
+              <Button
+                asChild
+                className="bg-black text-white hover:bg-black/90 focus-visible:ring-black"
+                size="lg"
+              >
+                <a
+                  href={`buzz://join?relay=${encodeURIComponent(relay)}&code=${encodeURIComponent(code)}`}
+                >
+                  Accept invite in Buzz
+                </a>
+              </Button>
+            ) : (
+              <Button
+                className="bg-black text-white hover:bg-black/90 focus-visible:ring-black disabled:cursor-not-allowed disabled:bg-black/30 disabled:text-white/70"
+                size="lg"
+                disabled={disabled}
+                onClick={openInvite}
+              >
+                Accept invite in Buzz
+              </Button>
+            )}
+          </div>
+          {terms && (
+            <p className="mt-4 max-w-md text-xs text-black/60">
+              By accepting this invite, you agree to the{" "}
               <a
-                className="font-medium text-black underline"
+                className="text-black underline-offset-4 hover:text-black/70 hover:decoration-current hover:underline focus-visible:underline"
                 href={terms.url}
                 target="_blank"
                 rel="noreferrer"
@@ -96,38 +105,13 @@ export function InvitePage({ code }: { code: string }) {
                 Terms of Service
               </a>
               .
-            </span>
-          </label>
-        )}
-
-        <div className={terms ? "mt-6" : "mt-8"}>
-          {terms === null ? (
-            <Button
-              asChild
-              className="bg-black text-white hover:bg-black/90 focus-visible:ring-black"
-              size="lg"
-            >
-              <a
-                href={`buzz://join?relay=${encodeURIComponent(relay)}&code=${encodeURIComponent(code)}`}
-              >
-                Accept invite in Buzz
-              </a>
-            </Button>
-          ) : (
-            <Button
-              className="bg-black text-white hover:bg-black/90 focus-visible:ring-black disabled:cursor-not-allowed disabled:bg-black/30 disabled:text-white/70"
-              size="lg"
-              disabled={disabled}
-              onClick={openInvite}
-            >
-              Accept invite in Buzz
-            </Button>
+            </p>
           )}
         </div>
-        <p className="mt-6 text-sm text-black/60">
+        <p className="flex h-[3.125rem] items-center justify-center rounded-2xl bg-white text-sm text-black/60">
           Don&apos;t have the app?{" "}
           <a
-            className="font-medium text-black underline-offset-4 hover:text-black/70 hover:decoration-current hover:underline focus-visible:underline"
+            className="ml-1 font-medium text-black underline-offset-4 hover:text-black/70 hover:decoration-current hover:underline focus-visible:underline"
             href={DOWNLOAD_URL}
             rel="noreferrer"
             target="_blank"

From d8a8a36cb22bcaaa41ff5b64f60b799c3bf6edca Mon Sep 17 00:00:00 2001
From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Date: Wed, 15 Jul 2026 09:47:11 -0400
Subject: [PATCH 03/16] Simplify invite policy acceptance

Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
---
 .env.example                                 |  5 +-
 crates/buzz-db/src/lib.rs                    | 20 -------
 crates/buzz-db/src/migration.rs              | 12 +----
 crates/buzz-db/src/relay_members.rs          | 38 -------------
 crates/buzz-relay/src/api/invites.rs         | 38 ++++---------
 crates/buzz-relay/src/config.rs              | 57 ++++++++++++++------
 crates/buzz-relay/src/invite_token.rs        |  9 +---
 migrations/0019_invite_terms_acceptances.sql | 11 ----
 web/src/features/invite/ui/InvitePage.tsx    | 35 ++++++++++--
 9 files changed, 87 insertions(+), 138 deletions(-)
 delete mode 100644 migrations/0019_invite_terms_acceptances.sql

diff --git a/.env.example b/.env.example
index 715c422dfe..a826d0d750 100644
--- a/.env.example
+++ b/.env.example
@@ -198,6 +198,9 @@ RUST_LOG=buzz_relay=debug,buzz_db=debug,buzz_auth=debug,buzz_pubsub=debug,tower_
 # are preferred:
 #   BUZZ_ACP_PRIVATE_KEY  → BUZZ_PRIVATE_KEY
 
-# Optional invite terms gate. Set both to show and enforce terms acceptance.
+# Optional invite policy. Set the terms URL and version to show the terms
+# disclosure, require the 18+ assertion, and bind acceptance to the invite click.
+# The privacy-policy URL is optional.
 # BUZZ_INVITE_TERMS_URL=https://example.com/terms
+# BUZZ_INVITE_PRIVACY_URL=https://example.com/privacy
 # BUZZ_INVITE_TERMS_VERSION=2026-07-15
diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs
index defdbd214d..c0b6bf78c6 100644
--- a/crates/buzz-db/src/lib.rs
+++ b/crates/buzz-db/src/lib.rs
@@ -2651,26 +2651,6 @@ impl Db {
         relay_members::add_relay_member(&self.pool, community, pubkey, role, added_by).await
     }
 
-    /// Durably records an invite terms acceptance receipt.
-    pub async fn record_invite_terms_acceptance(
-        &self,
-        receipt_id: uuid::Uuid,
-        community: CommunityId,
-        pubkey: &str,
-        policy_version: &str,
-        accepted_at: chrono::DateTime<chrono::Utc>,
-    ) -> Result<()> {
-        relay_members::record_invite_terms_acceptance(
-            &self.pool,
-            receipt_id,
-            community,
-            pubkey,
-            policy_version,
-            accepted_at,
-        )
-        .await
-    }
-
     /// Removes a relay member from `community` atomically, refusing to delete the owner.
     pub async fn remove_relay_member(
         &self,
diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs
index 54e5cab3c2..faa0b428e4 100644
--- a/crates/buzz-db/src/migration.rs
+++ b/crates/buzz-db/src/migration.rs
@@ -549,7 +549,7 @@ mod tests {
         let mut migrations: Vec<_> = MIGRATOR.iter().collect();
         migrations.sort_by_key(|migration| migration.version);
 
-        assert_eq!(migrations.len(), 19);
+        assert_eq!(migrations.len(), 18);
         assert_eq!(migrations[0].version, 1);
         assert_eq!(&*migrations[0].description, "initial schema");
         assert!(migrations[0]
@@ -798,16 +798,6 @@ mod tests {
         assert!(matcher.contains("AFTER INSERT ON events"));
         assert!(matcher.contains("NEW.kind IN (7, 9, 1059, 40007, 46010)"));
         assert!(!migrations[0].sql.as_str().contains("push_match_queue"));
-
-        assert_eq!(migrations[18].version, 19);
-        assert!(migrations[18]
-            .sql
-            .as_str()
-            .contains("CREATE TABLE invite_terms_acceptances"));
-        assert!(!migrations[0]
-            .sql
-            .as_str()
-            .contains("invite_terms_acceptances"));
     }
 
     #[test]
diff --git a/crates/buzz-db/src/relay_members.rs b/crates/buzz-db/src/relay_members.rs
index 4efcd0edd3..d354b057bd 100644
--- a/crates/buzz-db/src/relay_members.rs
+++ b/crates/buzz-db/src/relay_members.rs
@@ -492,44 +492,6 @@ pub async fn backfill_from_allowlist(pool: &PgPool, community: CommunityId) -> R
     Ok(result.rows_affected())
 }
 
-/// Durably records which invite policy revision a joining pubkey accepted.
-pub async fn record_invite_terms_acceptance(
-    pool: &PgPool,
-    receipt_id: uuid::Uuid,
-    community: CommunityId,
-    pubkey: &str,
-    policy_version: &str,
-    accepted_at: chrono::DateTime<chrono::Utc>,
-) -> Result<()> {
-    let recorded_for_pubkey: Option<bool> = sqlx::query_scalar(
-        "WITH inserted AS (
-             INSERT INTO invite_terms_acceptances
-                 (receipt_id, community_id, pubkey, policy_version, accepted_at)
-             VALUES ($1, $2, $3, $4, $5)
-             ON CONFLICT (community_id, receipt_id) DO NOTHING
-             RETURNING true AS matches
-         )
-         SELECT matches FROM inserted
-         UNION ALL
-         SELECT community_id = $2 AND pubkey = $3 AND policy_version = $4
-         FROM invite_terms_acceptances WHERE community_id = $2 AND receipt_id = $1
-         LIMIT 1",
-    )
-    .bind(receipt_id)
-    .bind(community.as_uuid())
-    .bind(pubkey)
-    .bind(policy_version)
-    .bind(accepted_at)
-    .fetch_optional(pool)
-    .await?;
-    if recorded_for_pubkey != Some(true) {
-        return Err(crate::error::DbError::AccessDenied(
-            "invite terms receipt was already claimed by another member".to_string(),
-        ));
-    }
-    Ok(())
-}
-
 #[cfg(test)]
 mod tests {
     use super::*;
diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs
index b7734991c4..296a3459d3 100644
--- a/crates/buzz-relay/src/api/invites.rs
+++ b/crates/buzz-relay/src/api/invites.rs
@@ -66,15 +66,19 @@ pub struct AcceptTermsRequest {
     pub code: String,
     /// Policy revision displayed by the browser.
     pub policy_version: String,
-    /// Explicit checkbox acceptance; false is always rejected.
-    pub accepted: bool,
+    /// Required minimum-age assertion from the invite page.
+    pub age_confirmed: bool,
 }
 
 /// Public invite-page configuration. Empty when the operator has no policy.
 pub async fn invite_config(State(state): State<Arc<AppState>>) -> Json<Value> {
     match &state.config.invite_terms {
         Some(policy) => Json(serde_json::json!({
-            "terms": { "url": policy.url, "version": policy.version }
+            "terms": {
+                "url": policy.url,
+                "privacy_url": policy.privacy_url,
+                "version": policy.version
+            }
         })),
         None => Json(serde_json::json!({})),
     }
@@ -97,7 +101,7 @@ pub async fn accept_terms(
             &format!("invalid acceptance JSON: {e}"),
         )
     })?;
-    if !request.accepted || request.policy_version != policy.version {
+    if !request.age_confirmed || request.policy_version != policy.version {
         return Err(api_error(
             StatusCode::BAD_REQUEST,
             "invite_terms_not_accepted",
@@ -238,35 +242,13 @@ pub async fn claim_invite(
     )?;
 
     let claimer_hex = pubkey.to_hex();
-    let terms_acceptance = if let Some(policy) = &state.config.invite_terms {
+    if let Some(policy) = &state.config.invite_terms {
         let receipt = request
             .terms_receipt
             .as_deref()
             .ok_or_else(|| api_error(StatusCode::FORBIDDEN, "invite_terms_required"))?;
-        Some(
-            invite_token::verify_terms_acceptance(&key, receipt, &request.code, &policy.version)
-                .map_err(|_| api_error(StatusCode::FORBIDDEN, "invite_terms_required"))?,
-        )
-    } else {
-        None
-    };
-
-    if let Some(acceptance) = &terms_acceptance {
-        let receipt_id = uuid::Uuid::parse_str(&acceptance.j)
+        invite_token::verify_terms_acceptance(&key, receipt, &request.code, &policy.version)
             .map_err(|_| api_error(StatusCode::FORBIDDEN, "invite_terms_required"))?;
-        let accepted_at = chrono::DateTime::from_timestamp(acceptance.a as i64, 0)
-            .ok_or_else(|| api_error(StatusCode::FORBIDDEN, "invite_terms_required"))?;
-        state
-            .db
-            .record_invite_terms_acceptance(
-                receipt_id,
-                tenant.community(),
-                &claimer_hex,
-                &acceptance.v,
-                accepted_at,
-            )
-            .await
-            .map_err(|e| internal_error(&format!("invite terms acceptance insert: {e}")))?;
     }
 
     let was_inserted = state
diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs
index a051ec835f..1de91d08f6 100644
--- a/crates/buzz-relay/src/config.rs
+++ b/crates/buzz-relay/src/config.rs
@@ -28,6 +28,8 @@ pub enum ConfigError {
 pub struct InviteTermsConfig {
     /// Public HTTPS URL containing the operator's terms.
     pub url: url::Url,
+    /// Optional public HTTPS URL containing the operator's privacy policy.
+    pub privacy_url: Option<url::Url>,
     /// Stable operator-defined identifier for the exact terms revision.
     pub version: String,
 }
@@ -267,6 +269,21 @@ fn parse_push_gateway_delivery_url(raw: &str) -> Result<url::Url, ConfigError> {
     Ok(url)
 }
 
+fn parse_invite_policy_url(name: &str, raw: &str) -> Result<url::Url, ConfigError> {
+    let url = url::Url::parse(raw)
+        .map_err(|e| ConfigError::InvalidValue(format!("{name} must be a valid HTTPS URL: {e}")))?;
+    if url.scheme() != "https"
+        || url.host_str().is_none()
+        || url.username() != ""
+        || url.password().is_some()
+    {
+        return Err(ConfigError::InvalidValue(format!(
+            "{name} must be an HTTPS URL without credentials"
+        )));
+    }
+    Ok(url)
+}
+
 fn ensure_git_repo_path(
     raw: impl Into<std::path::PathBuf>,
 ) -> Result<std::path::PathBuf, ConfigError> {
@@ -621,30 +638,36 @@ impl Config {
             .ok()
             .map(|v| v.trim().to_string())
             .filter(|v| !v.is_empty());
+        let invite_privacy_url = std::env::var("BUZZ_INVITE_PRIVACY_URL")
+            .ok()
+            .map(|v| v.trim().to_string())
+            .filter(|v| !v.is_empty());
         let invite_terms = match (invite_terms_url, invite_terms_version) {
-            (None, None) => None,
-            (Some(raw_url), Some(version)) => {
-                if version.len() > 128 {
+            (None, None) => {
+                if invite_privacy_url.is_some() {
                     return Err(ConfigError::InvalidValue(
-                        "BUZZ_INVITE_TERMS_VERSION must contain at most 128 bytes".to_string(),
+                        "BUZZ_INVITE_PRIVACY_URL requires BUZZ_INVITE_TERMS_URL and BUZZ_INVITE_TERMS_VERSION"
+                            .to_string(),
                     ));
                 }
-                let url = url::Url::parse(&raw_url).map_err(|e| {
-                    ConfigError::InvalidValue(format!(
-                        "BUZZ_INVITE_TERMS_URL must be a valid HTTPS URL: {e}"
-                    ))
-                })?;
-                if url.scheme() != "https"
-                    || url.host_str().is_none()
-                    || url.username() != ""
-                    || url.password().is_some()
-                {
+                None
+            }
+            (Some(raw_url), Some(version)) => {
+                if version.len() > 128 {
                     return Err(ConfigError::InvalidValue(
-                        "BUZZ_INVITE_TERMS_URL must be an HTTPS URL without credentials"
-                            .to_string(),
+                        "BUZZ_INVITE_TERMS_VERSION must contain at most 128 bytes".to_string(),
                     ));
                 }
-                Some(InviteTermsConfig { url, version })
+                let url = parse_invite_policy_url("BUZZ_INVITE_TERMS_URL", &raw_url)?;
+                let privacy_url = invite_privacy_url
+                    .as_deref()
+                    .map(|raw| parse_invite_policy_url("BUZZ_INVITE_PRIVACY_URL", raw))
+                    .transpose()?;
+                Some(InviteTermsConfig {
+                    url,
+                    privacy_url,
+                    version,
+                })
             }
             _ => {
                 return Err(ConfigError::InvalidValue(
diff --git a/crates/buzz-relay/src/invite_token.rs b/crates/buzz-relay/src/invite_token.rs
index 4f2337edb8..dbad282809 100644
--- a/crates/buzz-relay/src/invite_token.rs
+++ b/crates/buzz-relay/src/invite_token.rs
@@ -335,23 +335,16 @@ pub struct TermsAcceptancePayload {
     pub c: String,
     /// Configured policy version.
     pub v: String,
-    /// Unique receipt identifier.
-    pub j: String,
-    /// Acceptance timestamp (unix seconds).
-    pub a: u64,
     /// Receipt expiry (unix seconds).
     pub e: u64,
 }
 
 /// Mint a relay-authenticated, invite-bound terms acceptance receipt.
 pub fn mint_terms_acceptance(key: &[u8; 32], code: &str, version: &str) -> String {
-    let accepted_at = now_unix();
     let payload = TermsAcceptancePayload {
         c: hex::encode(Sha256::digest(code.as_bytes())),
         v: version.to_string(),
-        j: uuid::Uuid::new_v4().to_string(),
-        a: accepted_at,
-        e: accepted_at + 10 * 60,
+        e: now_unix() + 10 * 60,
     };
     let bytes = serde_json::to_vec(&payload).expect("terms acceptance serializes");
     format!(
diff --git a/migrations/0019_invite_terms_acceptances.sql b/migrations/0019_invite_terms_acceptances.sql
deleted file mode 100644
index 6ad555b029..0000000000
--- a/migrations/0019_invite_terms_acceptances.sql
+++ /dev/null
@@ -1,11 +0,0 @@
-CREATE TABLE invite_terms_acceptances (
-    receipt_id UUID NOT NULL,
-    community_id UUID NOT NULL REFERENCES communities(id),
-    pubkey TEXT NOT NULL,
-    policy_version TEXT NOT NULL,
-    accepted_at TIMESTAMPTZ NOT NULL,
-    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-    PRIMARY KEY (community_id, receipt_id)
-);
-CREATE INDEX idx_invite_terms_acceptances_member
-    ON invite_terms_acceptances (community_id, pubkey, accepted_at DESC);
diff --git a/web/src/features/invite/ui/InvitePage.tsx b/web/src/features/invite/ui/InvitePage.tsx
index 212d2b9b44..27eea3007d 100644
--- a/web/src/features/invite/ui/InvitePage.tsx
+++ b/web/src/features/invite/ui/InvitePage.tsx
@@ -4,13 +4,14 @@ import { Button } from "@/shared/ui/button";
 import * as React from "react";
 
 const DOWNLOAD_URL = "https://github.com/block/buzz/releases/latest";
-type Terms = { url: string; version: string };
+type Terms = { url: string; privacy_url?: string; version: string };
 
 /** Landing page for a community invite link (`/invite/<code>`). */
 export function InvitePage({ code }: { code: string }) {
   const relay = relayWsUrl();
   const host = relay.replace(/^wss?:\/\//, "");
   const [terms, setTerms] = React.useState<Terms | null | undefined>(undefined);
+  const [ageConfirmed, setAgeConfirmed] = React.useState(false);
   const [opening, setOpening] = React.useState(false);
 
   React.useEffect(() => {
@@ -34,7 +35,7 @@ export function InvitePage({ code }: { code: string }) {
           body: JSON.stringify({
             code,
             policy_version: terms.version,
-            accepted: true,
+            age_confirmed: ageConfirmed,
           }),
         });
         if (!response.ok) throw new Error(`HTTP ${response.status}`);
@@ -48,7 +49,8 @@ export function InvitePage({ code }: { code: string }) {
     }
   };
 
-  const disabled = terms === undefined || opening;
+  const disabled =
+    terms === undefined || opening || (terms !== null && !ageConfirmed);
   return (
     <div
       className="flex flex-1 flex-col items-center justify-center px-4 py-16 text-center"
@@ -69,7 +71,18 @@ export function InvitePage({ code }: { code: string }) {
           </h1>
           <p className="mt-9 font-mono text-lg text-black/70">{host}</p>
 
-          <div className="mt-9">
+          {terms && (
+            <label className="mt-9 flex max-w-md cursor-pointer items-start gap-3 text-left text-sm text-black/70">
+              <input
+                className="mt-0.5 h-4 w-4 accent-black"
+                type="checkbox"
+                checked={ageConfirmed}
+                onChange={(event) => setAgeConfirmed(event.target.checked)}
+              />
+              <span>I confirm that I am at least 18 years old.</span>
+            </label>
+          )}
+          <div className={terms ? "mt-5" : "mt-9"}>
             {terms === null ? (
               <Button
                 asChild
@@ -104,6 +117,20 @@ export function InvitePage({ code }: { code: string }) {
               >
                 Terms of Service
               </a>
+              {terms.privacy_url && (
+                <>
+                  {" "}
+                  and{" "}
+                  <a
+                    className="text-black underline-offset-4 hover:text-black/70 hover:decoration-current hover:underline focus-visible:underline"
+                    href={terms.privacy_url}
+                    target="_blank"
+                    rel="noreferrer"
+                  >
+                    Privacy Policy
+                  </a>
+                </>
+              )}
               .
             </p>
           )}

From 4d5fe5886ef1c798fb5405b4ff038c6c57f95de1 Mon Sep 17 00:00:00 2001
From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Date: Wed, 15 Jul 2026 11:14:59 -0400
Subject: [PATCH 04/16] Apply relay join policy across client flows

Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
---
 .env.example                                  |  12 +-
 crates/buzz-relay/src/api/invites.rs          |  54 ++++---
 crates/buzz-relay/src/config.rs               | 148 ++++++++++--------
 crates/buzz-relay/src/invite_token.rs         |  34 ++--
 crates/buzz-relay/src/router.rs               |   6 +-
 desktop/src-tauri/src/deep_link.rs            |  14 +-
 .../communities/ui/AddCommunityDialog.tsx     |  99 ++++++++++--
 .../communities/ui/CommunityEditForm.tsx      |  69 +++++++-
 .../features/communities/ui/WelcomeSetup.tsx  |   5 +-
 .../onboarding/ui/InviteRedeemForm.tsx        | 124 +++++++++++++--
 .../onboarding/ui/JoinPolicyNotice.tsx        |  89 +++++++++++
 .../onboarding/ui/MembershipDenied.tsx        |   4 +-
 desktop/src/shared/api/invites.test.mjs       |  54 +++++++
 desktop/src/shared/api/invites.ts             |  62 +++++++-
 desktop/src/shared/deep-link.ts               |   6 +-
 web/src/features/invite/ui/InvitePage.tsx     | 133 +++++++++++-----
 16 files changed, 708 insertions(+), 205 deletions(-)
 create mode 100644 desktop/src/features/onboarding/ui/JoinPolicyNotice.tsx
 create mode 100644 desktop/src/shared/api/invites.test.mjs

diff --git a/.env.example b/.env.example
index a826d0d750..16b47d0a91 100644
--- a/.env.example
+++ b/.env.example
@@ -198,9 +198,9 @@ RUST_LOG=buzz_relay=debug,buzz_db=debug,buzz_auth=debug,buzz_pubsub=debug,tower_
 # are preferred:
 #   BUZZ_ACP_PRIVATE_KEY  → BUZZ_PRIVATE_KEY
 
-# Optional invite policy. Set the terms URL and version to show the terms
-# disclosure, require the 18+ assertion, and bind acceptance to the invite click.
-# The privacy-policy URL is optional.
-# BUZZ_INVITE_TERMS_URL=https://example.com/terms
-# BUZZ_INVITE_PRIVACY_URL=https://example.com/privacy
-# BUZZ_INVITE_TERMS_VERSION=2026-07-15
+# Optional relay join policy. Markdown is served by the relay so every join
+# surface can present the same documents. Each document and the independent age
+# attestation are optional; configuring any one enables policy acceptance.
+# BUZZ_TERMS_OF_SERVICE_MARKDOWN="# Terms of Service\n\nFull terms here."
+# BUZZ_PRIVACY_POLICY_MARKDOWN="# Privacy Policy\n\nFull policy here."
+# BUZZ_AGE_ATTESTATION_REQUIRED=true
diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs
index 296a3459d3..b841194a8e 100644
--- a/crates/buzz-relay/src/api/invites.rs
+++ b/crates/buzz-relay/src/api/invites.rs
@@ -56,27 +56,29 @@ pub struct ClaimInviteRequest {
     pub code: String,
     /// Relay-issued proof of accepting the configured terms, when required.
     #[serde(default)]
-    pub terms_receipt: Option<String>,
+    pub policy_receipt: Option<String>,
 }
 
-/// Body for `POST /api/invites/accept-terms`.
+/// Body for `POST /api/invites/accept-policy`.
 #[derive(Debug, Deserialize)]
-pub struct AcceptTermsRequest {
+pub struct AcceptPolicyRequest {
     /// Invite code the acceptance receipt will be bound to.
     pub code: String,
-    /// Policy revision displayed by the browser.
+    /// Policy revision displayed by the client.
     pub policy_version: String,
-    /// Required minimum-age assertion from the invite page.
+    /// Minimum-age assertion, required only when configured by the operator.
+    #[serde(default)]
     pub age_confirmed: bool,
 }
 
-/// Public invite-page configuration. Empty when the operator has no policy.
-pub async fn invite_config(State(state): State<Arc<AppState>>) -> Json<Value> {
-    match &state.config.invite_terms {
+/// Public join policy shared by every client-side join surface.
+pub async fn join_policy(State(state): State<Arc<AppState>>) -> Json<Value> {
+    match &state.config.join_policy {
         Some(policy) => Json(serde_json::json!({
-            "terms": {
-                "url": policy.url,
-                "privacy_url": policy.privacy_url,
+            "policy": {
+                "terms_markdown": policy.terms_markdown,
+                "privacy_markdown": policy.privacy_markdown,
+                "age_attestation_required": policy.age_attestation_required,
                 "version": policy.version
             }
         })),
@@ -84,31 +86,33 @@ pub async fn invite_config(State(state): State<Arc<AppState>>) -> Json<Value> {
     }
 }
 
-/// Exchange an explicit browser acceptance for a short-lived, invite-bound receipt.
-pub async fn accept_terms(
+/// Exchange explicit policy acceptance for a short-lived, invite-bound receipt.
+pub async fn accept_policy(
     State(state): State<Arc<AppState>>,
     body: axum::body::Bytes,
 ) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
-    let Some(policy) = &state.config.invite_terms else {
+    let Some(policy) = &state.config.join_policy else {
         return Err(api_error(
             StatusCode::NOT_FOUND,
-            "invite_terms_not_configured",
+            "join_policy_not_configured",
         ));
     };
-    let request: AcceptTermsRequest = serde_json::from_slice(&body).map_err(|e| {
+    let request: AcceptPolicyRequest = serde_json::from_slice(&body).map_err(|e| {
         api_error(
             StatusCode::BAD_REQUEST,
-            &format!("invalid acceptance JSON: {e}"),
+            &format!("invalid policy acceptance JSON: {e}"),
         )
     })?;
-    if !request.age_confirmed || request.policy_version != policy.version {
+    if request.policy_version != policy.version
+        || (policy.age_attestation_required && !request.age_confirmed)
+    {
         return Err(api_error(
             StatusCode::BAD_REQUEST,
-            "invite_terms_not_accepted",
+            "join_policy_not_accepted",
         ));
     }
     let key = invite_token::derive_invite_key(&state.relay_keypair);
-    let receipt = invite_token::mint_terms_acceptance(&key, &request.code, &policy.version);
+    let receipt = invite_token::mint_policy_acceptance(&key, &request.code, &policy.version);
     Ok(Json(serde_json::json!({ "receipt": receipt })))
 }
 
@@ -242,13 +246,13 @@ pub async fn claim_invite(
     )?;
 
     let claimer_hex = pubkey.to_hex();
-    if let Some(policy) = &state.config.invite_terms {
+    if let Some(policy) = &state.config.join_policy {
         let receipt = request
-            .terms_receipt
+            .policy_receipt
             .as_deref()
-            .ok_or_else(|| api_error(StatusCode::FORBIDDEN, "invite_terms_required"))?;
-        invite_token::verify_terms_acceptance(&key, receipt, &request.code, &policy.version)
-            .map_err(|_| api_error(StatusCode::FORBIDDEN, "invite_terms_required"))?;
+            .ok_or_else(|| api_error(StatusCode::FORBIDDEN, "join_policy_required"))?;
+        invite_token::verify_policy_acceptance(&key, receipt, &request.code, &policy.version)
+            .map_err(|_| api_error(StatusCode::FORBIDDEN, "join_policy_required"))?;
     }
 
     let was_inserted = state
diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs
index 1de91d08f6..67ede28c9b 100644
--- a/crates/buzz-relay/src/config.rs
+++ b/crates/buzz-relay/src/config.rs
@@ -3,6 +3,7 @@
 use std::net::SocketAddr;
 use std::time::Duration;
 
+use sha2::{Digest, Sha256};
 use thiserror::Error;
 use tracing::warn;
 
@@ -23,14 +24,16 @@ pub enum ConfigError {
     InvalidValue(String),
 }
 
-/// Relay runtime configuration, loaded from environment variables.
+/// Relay-hosted policy content presented on join surfaces.
 #[derive(Debug, Clone)]
-pub struct InviteTermsConfig {
-    /// Public HTTPS URL containing the operator's terms.
-    pub url: url::Url,
-    /// Optional public HTTPS URL containing the operator's privacy policy.
-    pub privacy_url: Option<url::Url>,
-    /// Stable operator-defined identifier for the exact terms revision.
+pub struct JoinPolicyConfig {
+    /// Operator-provided Terms of Service document in Markdown.
+    pub terms_markdown: Option<String>,
+    /// Operator-provided Privacy Policy document in Markdown.
+    pub privacy_markdown: Option<String>,
+    /// Whether join surfaces must collect an 18+ attestation.
+    pub age_attestation_required: bool,
+    /// Content-derived identifier binding receipts to the exact policy revision.
     pub version: String,
 }
 
@@ -208,10 +211,9 @@ pub struct Config {
     /// Hard timeout for one gateway delivery request.
     pub push_gateway_timeout: Duration,
 
-    /// Optional terms policy shown and enforced for relay invite acceptance.
-    /// Disabled unless both `BUZZ_INVITE_TERMS_URL` and
-    /// `BUZZ_INVITE_TERMS_VERSION` are set.
-    pub invite_terms: Option<InviteTermsConfig>,
+    /// Optional relay-hosted policy shown on join surfaces. Disabled when no
+    /// documents or age attestation are configured.
+    pub join_policy: Option<JoinPolicyConfig>,
 
     /// Optional path to the web UI `dist/` directory.
     /// When set, the relay serves the SPA from this directory for browser requests.
@@ -269,19 +271,20 @@ fn parse_push_gateway_delivery_url(raw: &str) -> Result<url::Url, ConfigError> {
     Ok(url)
 }
 
-fn parse_invite_policy_url(name: &str, raw: &str) -> Result<url::Url, ConfigError> {
-    let url = url::Url::parse(raw)
-        .map_err(|e| ConfigError::InvalidValue(format!("{name} must be a valid HTTPS URL: {e}")))?;
-    if url.scheme() != "https"
-        || url.host_str().is_none()
-        || url.username() != ""
-        || url.password().is_some()
-    {
-        return Err(ConfigError::InvalidValue(format!(
-            "{name} must be an HTTPS URL without credentials"
-        )));
+fn parse_optional_bool(name: &str) -> Result<bool, ConfigError> {
+    match std::env::var(name) {
+        Err(std::env::VarError::NotPresent) => Ok(false),
+        Err(error) => Err(ConfigError::InvalidValue(format!(
+            "{name} must be valid UTF-8: {error}"
+        ))),
+        Ok(value) => match value.trim().to_ascii_lowercase().as_str() {
+            "true" | "1" | "on" => Ok(true),
+            "false" | "0" | "off" | "" => Ok(false),
+            _ => Err(ConfigError::InvalidValue(format!(
+                "{name} must be true or false"
+            ))),
+        },
     }
-    Ok(url)
 }
 
 fn ensure_git_repo_path(
@@ -630,51 +633,42 @@ impl Config {
         };
         let push_gateway_timeout = Duration::from_millis(push_gateway_timeout_millis);
 
-        let invite_terms_url = std::env::var("BUZZ_INVITE_TERMS_URL")
-            .ok()
-            .map(|v| v.trim().to_string())
-            .filter(|v| !v.is_empty());
-        let invite_terms_version = std::env::var("BUZZ_INVITE_TERMS_VERSION")
-            .ok()
-            .map(|v| v.trim().to_string())
-            .filter(|v| !v.is_empty());
-        let invite_privacy_url = std::env::var("BUZZ_INVITE_PRIVACY_URL")
-            .ok()
-            .map(|v| v.trim().to_string())
-            .filter(|v| !v.is_empty());
-        let invite_terms = match (invite_terms_url, invite_terms_version) {
-            (None, None) => {
-                if invite_privacy_url.is_some() {
-                    return Err(ConfigError::InvalidValue(
-                        "BUZZ_INVITE_PRIVACY_URL requires BUZZ_INVITE_TERMS_URL and BUZZ_INVITE_TERMS_VERSION"
-                            .to_string(),
-                    ));
-                }
-                None
-            }
-            (Some(raw_url), Some(version)) => {
-                if version.len() > 128 {
-                    return Err(ConfigError::InvalidValue(
-                        "BUZZ_INVITE_TERMS_VERSION must contain at most 128 bytes".to_string(),
-                    ));
-                }
-                let url = parse_invite_policy_url("BUZZ_INVITE_TERMS_URL", &raw_url)?;
-                let privacy_url = invite_privacy_url
-                    .as_deref()
-                    .map(|raw| parse_invite_policy_url("BUZZ_INVITE_PRIVACY_URL", raw))
-                    .transpose()?;
-                Some(InviteTermsConfig {
-                    url,
-                    privacy_url,
-                    version,
-                })
-            }
-            _ => {
-                return Err(ConfigError::InvalidValue(
-                    "BUZZ_INVITE_TERMS_URL and BUZZ_INVITE_TERMS_VERSION must be set together"
-                        .to_string(),
-                ))
+        const MAX_POLICY_MARKDOWN_BYTES: usize = 256 * 1024;
+        let read_policy_markdown = |name: &str| -> Result<Option<String>, ConfigError> {
+            let value = std::env::var(name)
+                .ok()
+                .map(|value| value.trim().to_string())
+                .filter(|value| !value.is_empty());
+            if value
+                .as_ref()
+                .is_some_and(|value| value.len() > MAX_POLICY_MARKDOWN_BYTES)
+            {
+                return Err(ConfigError::InvalidValue(format!(
+                    "{name} must contain at most {MAX_POLICY_MARKDOWN_BYTES} bytes"
+                )));
             }
+            Ok(value)
+        };
+        let terms_markdown = read_policy_markdown("BUZZ_TERMS_OF_SERVICE_MARKDOWN")?;
+        let privacy_markdown = read_policy_markdown("BUZZ_PRIVACY_POLICY_MARKDOWN")?;
+        let age_attestation_required = parse_optional_bool("BUZZ_AGE_ATTESTATION_REQUIRED")?;
+        let join_policy = if terms_markdown.is_none()
+            && privacy_markdown.is_none()
+            && !age_attestation_required
+        {
+            None
+        } else {
+            let mut hasher = Sha256::new();
+            hasher.update(terms_markdown.as_deref().unwrap_or_default().as_bytes());
+            hasher.update([0]);
+            hasher.update(privacy_markdown.as_deref().unwrap_or_default().as_bytes());
+            hasher.update([0, u8::from(age_attestation_required)]);
+            Some(JoinPolicyConfig {
+                terms_markdown,
+                privacy_markdown,
+                age_attestation_required,
+                version: hex::encode(hasher.finalize()),
+            })
         };
 
         // Web UI static file serving
@@ -745,7 +739,7 @@ impl Config {
             push_executor_key_id,
             push_gateway_delivery_url,
             push_gateway_timeout,
-            invite_terms,
+            join_policy,
             web_dir,
         })
     }
@@ -797,6 +791,24 @@ mod tests {
         );
     }
 
+    #[test]
+    fn join_policy_age_attestation_rejects_invalid_boolean() {
+        let _guard = ENV_MUTEX.lock().unwrap();
+        let previous = std::env::var_os("BUZZ_AGE_ATTESTATION_REQUIRED");
+        std::env::set_var("BUZZ_AGE_ATTESTATION_REQUIRED", "sometimes");
+        let result = parse_optional_bool("BUZZ_AGE_ATTESTATION_REQUIRED");
+        if let Some(value) = previous {
+            std::env::set_var("BUZZ_AGE_ATTESTATION_REQUIRED", value);
+        } else {
+            std::env::remove_var("BUZZ_AGE_ATTESTATION_REQUIRED");
+        }
+        assert!(matches!(
+            result,
+            Err(ConfigError::InvalidValue(ref message))
+                if message.contains("BUZZ_AGE_ATTESTATION_REQUIRED")
+        ));
+    }
+
     #[test]
     fn relay_operator_pubkeys_parse_dedupe_and_normalize() {
         let _guard = ENV_MUTEX.lock().unwrap();
diff --git a/crates/buzz-relay/src/invite_token.rs b/crates/buzz-relay/src/invite_token.rs
index dbad282809..436040c32a 100644
--- a/crates/buzz-relay/src/invite_token.rs
+++ b/crates/buzz-relay/src/invite_token.rs
@@ -328,9 +328,9 @@ mod tests {
     }
 }
 
-/// Short-lived proof that the browser accepted the configured invite terms.
+/// Short-lived proof that the browser accepted the configured join policy.
 #[derive(Debug, Serialize, Deserialize)]
-pub struct TermsAcceptancePayload {
+pub struct PolicyAcceptancePayload {
     /// SHA-256 of the invite code this acceptance is bound to.
     pub c: String,
     /// Configured policy version.
@@ -339,14 +339,14 @@ pub struct TermsAcceptancePayload {
     pub e: u64,
 }
 
-/// Mint a relay-authenticated, invite-bound terms acceptance receipt.
-pub fn mint_terms_acceptance(key: &[u8; 32], code: &str, version: &str) -> String {
-    let payload = TermsAcceptancePayload {
+/// Mint a relay-authenticated, invite-bound policy acceptance receipt.
+pub fn mint_policy_acceptance(key: &[u8; 32], code: &str, version: &str) -> String {
+    let payload = PolicyAcceptancePayload {
         c: hex::encode(Sha256::digest(code.as_bytes())),
         v: version.to_string(),
         e: now_unix() + 10 * 60,
     };
-    let bytes = serde_json::to_vec(&payload).expect("terms acceptance serializes");
+    let bytes = serde_json::to_vec(&payload).expect("policy acceptance serializes");
     format!(
         "{}.{}",
         URL_SAFE_NO_PAD.encode(&bytes),
@@ -354,13 +354,13 @@ pub fn mint_terms_acceptance(key: &[u8; 32], code: &str, version: &str) -> Strin
     )
 }
 
-/// Verify a terms receipt and bind it to the submitted invite and current policy.
-pub fn verify_terms_acceptance(
+/// Verify a policy receipt and bind it to the submitted invite and current policy.
+pub fn verify_policy_acceptance(
     key: &[u8; 32],
     receipt: &str,
     code: &str,
     version: &str,
-) -> Result<TermsAcceptancePayload, InviteError> {
+) -> Result<PolicyAcceptancePayload, InviteError> {
     if receipt.len() > 2048 {
         return Err(InviteError::Malformed);
     }
@@ -375,7 +375,7 @@ pub fn verify_terms_acceptance(
     mac.update(&bytes);
     mac.verify_slice(&signature)
         .map_err(|_| InviteError::BadSignature)?;
-    let payload: TermsAcceptancePayload =
+    let payload: PolicyAcceptancePayload =
         serde_json::from_slice(&bytes).map_err(|_| InviteError::Malformed)?;
     if payload.e < now_unix() {
         return Err(InviteError::Expired);
@@ -388,18 +388,18 @@ pub fn verify_terms_acceptance(
 }
 
 #[cfg(test)]
-mod terms_acceptance_tests {
+mod policy_acceptance_tests {
     use super::*;
 
     #[test]
-    fn terms_receipt_is_bound_to_invite_and_version() {
+    fn policy_receipt_is_bound_to_invite_and_version() {
         let key = [7_u8; 32];
-        let receipt = mint_terms_acceptance(&key, "invite-a", "v1");
+        let receipt = mint_policy_acceptance(&key, "invite-a", "v1");
         let payload =
-            verify_terms_acceptance(&key, &receipt, "invite-a", "v1").expect("valid receipt");
+            verify_policy_acceptance(&key, &receipt, "invite-a", "v1").expect("valid receipt");
         assert_eq!(payload.v, "v1");
-        assert!(verify_terms_acceptance(&key, &receipt, "invite-b", "v1").is_err());
-        assert!(verify_terms_acceptance(&key, &receipt, "invite-a", "v2").is_err());
-        assert!(verify_terms_acceptance(&[8_u8; 32], &receipt, "invite-a", "v1").is_err());
+        assert!(verify_policy_acceptance(&key, &receipt, "invite-b", "v1").is_err());
+        assert!(verify_policy_acceptance(&key, &receipt, "invite-a", "v2").is_err());
+        assert!(verify_policy_acceptance(&[8_u8; 32], &receipt, "invite-a", "v1").is_err());
     }
 }
diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs
index cb1cba7bcd..a2add8f061 100644
--- a/crates/buzz-relay/src/router.rs
+++ b/crates/buzz-relay/src/router.rs
@@ -78,10 +78,10 @@ pub fn build_router(state: Arc<AppState>) -> Router {
         )
         // Relay invites: mint (owner/admin) + claim (membership-gate exempt)
         .route("/api/invites", post(api::invites::mint_invite))
-        .route("/api/invites/config", get(api::invites::invite_config))
+        .route("/api/join-policy", get(api::invites::join_policy))
         .route(
-            "/api/invites/accept-terms",
-            post(api::invites::accept_terms),
+            "/api/invites/accept-policy",
+            post(api::invites::accept_policy),
         )
         .route("/api/invites/claim", post(api::invites::claim_invite))
         // Moderation queue reads (NIP-98 auth + mod-authz gate, L6)
diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs
index 29ae3237d0..6bcde76a82 100644
--- a/desktop/src-tauri/src/deep_link.rs
+++ b/desktop/src-tauri/src/deep_link.rs
@@ -59,7 +59,7 @@ fn parse_message_deep_link(url: &Url) -> Option<serde_json::Value> {
 fn parse_join_deep_link(url: &Url) -> Option<serde_json::Value> {
     let mut relay: Option<String> = None;
     let mut code: Option<String> = None;
-    let mut terms_receipt: Option<String> = None;
+    let mut policy_receipt: Option<String> = None;
     for (k, v) in url.query_pairs() {
         let v = v.into_owned();
         if v.is_empty() {
@@ -68,7 +68,7 @@ fn parse_join_deep_link(url: &Url) -> Option<serde_json::Value> {
         match k.as_ref() {
             "relay" => relay = Some(v),
             "code" => code = Some(v),
-            "terms_receipt" => terms_receipt = Some(v),
+            "policy_receipt" => policy_receipt = Some(v),
             _ => {}
         }
     }
@@ -80,7 +80,7 @@ fn parse_join_deep_link(url: &Url) -> Option<serde_json::Value> {
     Some(serde_json::json!({
         "relayUrl": relay_url,
         "code": code,
-        "termsReceipt": terms_receipt,
+        "policyReceipt": policy_receipt,
     }))
 }
 
@@ -305,17 +305,17 @@ mod tests {
         let payload = parse_join_deep_link(&url).expect("required params present");
         assert_eq!(payload["relayUrl"], "wss://relay.example");
         assert_eq!(payload["code"], "abc.def");
-        assert!(payload["termsReceipt"].is_null());
+        assert!(payload["policyReceipt"].is_null());
     }
 
     #[test]
-    fn parse_join_deep_link_extracts_terms_receipt() {
+    fn parse_join_deep_link_extracts_policy_receipt() {
         let url = Url::parse(
-            "buzz://join?relay=wss%3A%2F%2Frelay.example&code=abc.def&terms_receipt=receipt.value",
+            "buzz://join?relay=wss%3A%2F%2Frelay.example&code=abc.def&policy_receipt=receipt.value",
         )
         .unwrap();
         let payload = parse_join_deep_link(&url).expect("required params present");
-        assert_eq!(payload["termsReceipt"], "receipt.value");
+        assert_eq!(payload["policyReceipt"], "receipt.value");
     }
 
     #[test]
diff --git a/desktop/src/features/communities/ui/AddCommunityDialog.tsx b/desktop/src/features/communities/ui/AddCommunityDialog.tsx
index b3ac2116ec..9c2b8f0913 100644
--- a/desktop/src/features/communities/ui/AddCommunityDialog.tsx
+++ b/desktop/src/features/communities/ui/AddCommunityDialog.tsx
@@ -10,7 +10,12 @@ import {
   inviteErrorMessage,
   isInviteExpiredError,
 } from "@/shared/api/inviteHelpers";
-import { claimInvite } from "@/shared/api/invites";
+import {
+  acceptJoinPolicy,
+  claimInvite,
+  getJoinPolicy,
+  type JoinPolicy,
+} from "@/shared/api/invites";
 import { validateReposDir } from "@/shared/api/tauri";
 import { Button } from "@/shared/ui/button";
 import {
@@ -21,6 +26,7 @@ import {
   DialogTitle,
 } from "@/shared/ui/dialog";
 import { Input } from "@/shared/ui/input";
+import { JoinPolicyNotice } from "@/features/onboarding/ui/JoinPolicyNotice";
 
 type AddCommunityDialogProps = {
   open: boolean;
@@ -38,6 +44,8 @@ export function AddCommunityDialog({
   const [token, setToken] = React.useState("");
   const [inviteCode, setInviteCode] = React.useState("");
   const [inviteError, setInviteError] = React.useState<string | null>(null);
+  const [joinPolicy, setJoinPolicy] = React.useState<JoinPolicy | null>(null);
+  const [ageConfirmed, setAgeConfirmed] = React.useState(false);
   const [reposDir, setReposDir] = React.useState("");
   const [reposDirError, setReposDirError] = React.useState<string | null>(null);
 
@@ -48,6 +56,8 @@ export function AddCommunityDialog({
     setToken("");
     setInviteCode("");
     setInviteError(null);
+    setJoinPolicy(null);
+    setAgeConfirmed(false);
     setReposDir("");
     setReposDirError(null);
   }, [onOpenChange]);
@@ -71,21 +81,45 @@ export function AddCommunityDialog({
         return;
       }
 
-      // If the relay handed out an invite code, claim it before saving the
-      // community — a closed relay would otherwise reject the connection.
       const normalizedRelayUrl = normalizeRelayUrl(relayUrl.trim());
-      if (inviteCode.trim()) {
-        try {
-          await claimInvite(normalizedRelayUrl, inviteCode.trim());
-        } catch (error) {
-          const message = inviteErrorMessage(error);
-          setInviteError(
-            isInviteExpiredError(error)
-              ? "This invite code has expired — ask for a new one."
-              : `Invite rejected: ${message}`,
-          );
+      try {
+        const policy = await getJoinPolicy(normalizedRelayUrl);
+        if (policy && (!joinPolicy || joinPolicy.version !== policy.version)) {
+          setJoinPolicy(policy);
+          setAgeConfirmed(false);
+          setInviteError("Review this relay's join policy below.");
+          return;
+        }
+        if (policy?.ageAttestationRequired && !ageConfirmed) {
+          setInviteError("Confirm that you are at least 18 years old.");
           return;
         }
+
+        // If the relay handed out an invite code, claim it before saving the
+        // community — a closed relay would otherwise reject the connection.
+        if (inviteCode.trim()) {
+          const policyReceipt = policy
+            ? await acceptJoinPolicy(
+                normalizedRelayUrl,
+                inviteCode.trim(),
+                policy.version,
+                ageConfirmed,
+              )
+            : undefined;
+          await claimInvite(
+            normalizedRelayUrl,
+            inviteCode.trim(),
+            policyReceipt,
+          );
+        }
+      } catch (error) {
+        const message = inviteErrorMessage(error);
+        setInviteError(
+          isInviteExpiredError(error)
+            ? "This invite code has expired — ask for a new one."
+            : `Community rejected: ${message}`,
+        );
+        return;
       }
 
       const community: Community = {
@@ -100,7 +134,17 @@ export function AddCommunityDialog({
       onSubmit(community);
       handleClose();
     },
-    [name, relayUrl, token, inviteCode, reposDir, onSubmit, handleClose],
+    [
+      name,
+      relayUrl,
+      token,
+      inviteCode,
+      reposDir,
+      joinPolicy,
+      ageConfirmed,
+      onSubmit,
+      handleClose,
+    ],
   );
 
   return (
@@ -127,7 +171,12 @@ export function AddCommunityDialog({
             <Input
               autoFocus
               id="ws-relay-url"
-              onChange={(e) => setRelayUrl(e.target.value)}
+              onChange={(e) => {
+                setRelayUrl(e.target.value);
+                setInviteError(null);
+                setJoinPolicy(null);
+                setAgeConfirmed(false);
+              }}
               placeholder="wss://relay.example.com"
               type="text"
               value={relayUrl}
@@ -184,6 +233,8 @@ export function AddCommunityDialog({
               onChange={(e) => {
                 setInviteCode(e.target.value);
                 setInviteError(null);
+                setJoinPolicy(null);
+                setAgeConfirmed(false);
               }}
               placeholder="Paste an invite code for a members-only relay"
               type="text"
@@ -192,6 +243,16 @@ export function AddCommunityDialog({
             {inviteError ? (
               <p className="text-xs text-destructive">{inviteError}</p>
             ) : null}
+            {joinPolicy ? (
+              <JoinPolicyNotice
+                ageConfirmed={ageConfirmed}
+                onAgeConfirmedChange={(confirmed) => {
+                  setAgeConfirmed(confirmed);
+                  setInviteError(null);
+                }}
+                policy={joinPolicy}
+              />
+            ) : null}
           </div>
           <div className="flex flex-col gap-1.5">
             <label
@@ -230,7 +291,13 @@ export function AddCommunityDialog({
             <Button onClick={handleClose} type="button" variant="outline">
               Cancel
             </Button>
-            <Button disabled={!relayUrl.trim()} type="submit">
+            <Button
+              disabled={
+                !relayUrl.trim() ||
+                Boolean(joinPolicy?.ageAttestationRequired && !ageConfirmed)
+              }
+              type="submit"
+            >
               Add Community
             </Button>
           </div>
diff --git a/desktop/src/features/communities/ui/CommunityEditForm.tsx b/desktop/src/features/communities/ui/CommunityEditForm.tsx
index e0d47b2f78..7365ebf2b0 100644
--- a/desktop/src/features/communities/ui/CommunityEditForm.tsx
+++ b/desktop/src/features/communities/ui/CommunityEditForm.tsx
@@ -1,7 +1,10 @@
 import * as React from "react";
+import { inviteErrorMessage } from "@/shared/api/inviteHelpers";
+import { getJoinPolicy, type JoinPolicy } from "@/shared/api/invites";
 import { Button } from "@/shared/ui/button";
 import { Input } from "@/shared/ui/input";
 import { Spinner } from "@/shared/ui/spinner";
+import { JoinPolicyNotice } from "@/features/onboarding/ui/JoinPolicyNotice";
 import { normalizeRelayUrl, probeRelayReachable } from "../relayProbe";
 
 export type CommunityEditFormProps = {
@@ -9,6 +12,7 @@ export type CommunityEditFormProps = {
   initialName: string;
   initialRelayUrl: string;
   isSubmitting?: boolean;
+  joinPolicyRequired?: boolean;
   onCancel: () => void;
   onSubmit: (name: string, relayUrl: string) => void;
   submitLabel: string;
@@ -19,6 +23,7 @@ export function CommunityEditForm({
   initialName,
   initialRelayUrl,
   isSubmitting = false,
+  joinPolicyRequired = false,
   onCancel,
   onSubmit,
   submitLabel,
@@ -29,6 +34,11 @@ export function CommunityEditForm({
   const [probeWarning, setProbeWarning] = React.useState<string | null>(null);
   const [isProbing, setIsProbing] = React.useState(false);
   const [useAnywayOverride, setUseAnywayOverride] = React.useState(false);
+  const [joinPolicy, setJoinPolicy] = React.useState<JoinPolicy | null>(null);
+  const [policyRelayUrl, setPolicyRelayUrl] = React.useState<string | null>(
+    null,
+  );
+  const [ageConfirmed, setAgeConfirmed] = React.useState(false);
 
   const cancelRef = React.useRef<(() => void) | null>(null);
 
@@ -52,6 +62,33 @@ export function CommunityEditForm({
         return;
       }
 
+      if (joinPolicyRequired) {
+        try {
+          const policy = await getJoinPolicy(normalizedUrl);
+          if (!policy) {
+            onSubmit(trimmedName, normalizedUrl);
+            return;
+          }
+          if (
+            !joinPolicy ||
+            joinPolicy.version !== policy.version ||
+            policyRelayUrl !== normalizedUrl
+          ) {
+            setJoinPolicy(policy);
+            setPolicyRelayUrl(normalizedUrl);
+            setAgeConfirmed(false);
+            return;
+          }
+          if (policy.ageAttestationRequired && !ageConfirmed) {
+            setError("Confirm that you are at least 18 years old.");
+            return;
+          }
+        } catch (policyError) {
+          setError(inviteErrorMessage(policyError));
+          return;
+        }
+      }
+
       if (useAnywayOverride) {
         onSubmit(trimmedName, normalizedUrl);
         return;
@@ -79,7 +116,16 @@ export function CommunityEditForm({
 
       onSubmit(trimmedName, normalizedUrl);
     },
-    [name, onSubmit, relayUrl, useAnywayOverride],
+    [
+      ageConfirmed,
+      joinPolicy,
+      joinPolicyRequired,
+      name,
+      onSubmit,
+      policyRelayUrl,
+      relayUrl,
+      useAnywayOverride,
+    ],
   );
 
   const handleUseAnyway = React.useCallback(() => {
@@ -139,6 +185,9 @@ export function CommunityEditForm({
             setError(null);
             setProbeWarning(null);
             setUseAnywayOverride(false);
+            setJoinPolicy(null);
+            setPolicyRelayUrl(null);
+            setAgeConfirmed(false);
           }}
           placeholder="wss://relay.example.com"
           type="text"
@@ -146,10 +195,26 @@ export function CommunityEditForm({
         />
       </div>
 
+      {joinPolicy ? (
+        <JoinPolicyNotice
+          ageConfirmed={ageConfirmed}
+          onAgeConfirmedChange={(confirmed) => {
+            setAgeConfirmed(confirmed);
+            setError(null);
+          }}
+          policy={joinPolicy}
+        />
+      ) : null}
+
       <div className="flex w-full flex-col gap-3 pt-1">
         <Button
           className="h-10 w-full"
-          disabled={isBusy || !name.trim() || !relayUrl.trim()}
+          disabled={
+            isBusy ||
+            !name.trim() ||
+            !relayUrl.trim() ||
+            Boolean(joinPolicy?.ageAttestationRequired && !ageConfirmed)
+          }
           type="submit"
         >
           {isProbing ? (
diff --git a/desktop/src/features/communities/ui/WelcomeSetup.tsx b/desktop/src/features/communities/ui/WelcomeSetup.tsx
index 6d6b4c13fc..650626c79b 100644
--- a/desktop/src/features/communities/ui/WelcomeSetup.tsx
+++ b/desktop/src/features/communities/ui/WelcomeSetup.tsx
@@ -159,11 +159,11 @@ export function WelcomeSetup({
   );
 
   const handleWelcomeInviteRedeem = React.useCallback(
-    async (relayWsUrl: string, code: string) => {
+    async (relayWsUrl: string, code: string, policyReceipt?: string) => {
       setIsRedeeming(true);
       setInviteError(null);
       try {
-        await claimInvite(relayWsUrl, code);
+        await claimInvite(relayWsUrl, code, policyReceipt);
         await handleConnect(relayWsUrl);
       } catch (err) {
         setInviteError(inviteErrorMessage(err));
@@ -341,6 +341,7 @@ export function WelcomeSetup({
                 initialName=""
                 initialRelayUrl=""
                 isSubmitting={isConnecting}
+                joinPolicyRequired
                 onCancel={showWelcomePage}
                 onSubmit={(name, url) => {
                   void handleConnect(url, name);
diff --git a/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx b/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx
index 5e8f36a423..e6541eafce 100644
--- a/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx
+++ b/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx
@@ -1,9 +1,18 @@
 import * as React from "react";
 
-import { parseInviteInput } from "@/shared/api/inviteHelpers";
+import {
+  inviteErrorMessage,
+  parseInviteInput,
+} from "@/shared/api/inviteHelpers";
+import {
+  acceptJoinPolicy,
+  getJoinPolicy,
+  type JoinPolicy,
+} from "@/shared/api/invites";
 import { Button } from "@/shared/ui/button";
 import { Input } from "@/shared/ui/input";
 import { Spinner } from "@/shared/ui/spinner";
+import { JoinPolicyNotice } from "./JoinPolicyNotice";
 
 type InviteRedeemFormProps = {
   /**
@@ -16,7 +25,7 @@ type InviteRedeemFormProps = {
   error: string | null;
   isRedeeming: boolean;
   onCancel: () => void;
-  onRedeem: (relayWsUrl: string, code: string) => void;
+  onRedeem: (relayWsUrl: string, code: string, policyReceipt?: string) => void;
 };
 
 export function InviteRedeemForm({
@@ -30,6 +39,14 @@ export function InviteRedeemForm({
   const [bareCodeRelayUrl, setBareCodeRelayUrl] = React.useState(
     defaultRelayUrl ?? "",
   );
+  const [joinPolicy, setJoinPolicy] = React.useState<JoinPolicy | null>(null);
+  const [policyInvite, setPolicyInvite] = React.useState<{
+    relayWsUrl: string;
+    code: string;
+  } | null>(null);
+  const [ageConfirmed, setAgeConfirmed] = React.useState(false);
+  const [policyError, setPolicyError] = React.useState<string | null>(null);
+  const [isLoadingPolicy, setIsLoadingPolicy] = React.useState(false);
 
   const parsed = React.useMemo(
     () => parseInviteInput(inviteInput),
@@ -44,17 +61,61 @@ export function InviteRedeemForm({
       (isBareCode && bareCodeRelayUrl.trim().length > 0));
 
   const handleSubmit = React.useCallback(
-    (event: React.FormEvent) => {
+    async (event: React.FormEvent) => {
       event.preventDefault();
       if (!parsed) return;
 
-      if ("relayWsUrl" in parsed) {
-        onRedeem(parsed.relayWsUrl, parsed.code);
-      } else if (bareCodeRelayUrl.trim()) {
-        onRedeem(bareCodeRelayUrl.trim(), parsed.code);
+      const relayWsUrl =
+        "relayWsUrl" in parsed ? parsed.relayWsUrl : bareCodeRelayUrl.trim();
+      if (!relayWsUrl) return;
+
+      setPolicyError(null);
+      setIsLoadingPolicy(true);
+      try {
+        const policy = await getJoinPolicy(relayWsUrl);
+        if (!policy) {
+          onRedeem(relayWsUrl, parsed.code);
+          return;
+        }
+
+        if (
+          !joinPolicy ||
+          joinPolicy.version !== policy.version ||
+          policyInvite?.relayWsUrl !== relayWsUrl ||
+          policyInvite.code !== parsed.code
+        ) {
+          setJoinPolicy(policy);
+          setPolicyInvite({ relayWsUrl, code: parsed.code });
+          setAgeConfirmed(false);
+          return;
+        }
+
+        if (policy.ageAttestationRequired && !ageConfirmed) {
+          setPolicyError("Confirm that you are at least 18 years old.");
+          return;
+        }
+
+        const receipt = await acceptJoinPolicy(
+          relayWsUrl,
+          parsed.code,
+          policy.version,
+          ageConfirmed,
+        );
+        onRedeem(relayWsUrl, parsed.code, receipt);
+      } catch (policyFetchError) {
+        setPolicyError(inviteErrorMessage(policyFetchError));
+      } finally {
+        setIsLoadingPolicy(false);
       }
     },
-    [bareCodeRelayUrl, onRedeem, parsed],
+    [
+      ageConfirmed,
+      bareCodeRelayUrl,
+      joinPolicy,
+      onRedeem,
+      parsed,
+      policyInvite,
+    ],
   );
 
   return (
@@ -74,7 +135,13 @@ export function InviteRedeemForm({
           data-testid="invite-redeem-input"
           disabled={isRedeeming}
           id="invite-input"
-          onChange={(event) => setInviteInput(event.target.value)}
+          onChange={(event) => {
+            setInviteInput(event.target.value);
+            setJoinPolicy(null);
+            setPolicyInvite(null);
+            setAgeConfirmed(false);
+            setPolicyError(null);
+          }}
           placeholder="https://relay.example.com/invite/abc123 or paste a code"
           spellCheck={false}
           type="text"
@@ -94,7 +161,13 @@ export function InviteRedeemForm({
             className="h-10 bg-background"
             disabled={isRedeeming}
             id="invite-relay-url"
-            onChange={(event) => setBareCodeRelayUrl(event.target.value)}
+            onChange={(event) => {
+              setBareCodeRelayUrl(event.target.value);
+              setJoinPolicy(null);
+              setPolicyInvite(null);
+              setAgeConfirmed(false);
+              setPolicyError(null);
+            }}
             placeholder="wss://relay.example.com"
             type="text"
             value={bareCodeRelayUrl}
@@ -102,6 +175,21 @@ export function InviteRedeemForm({
         </div>
       ) : null}
 
+      {joinPolicy ? (
+        <JoinPolicyNotice
+          ageConfirmed={ageConfirmed}
+          onAgeConfirmedChange={(confirmed) => {
+            setAgeConfirmed(confirmed);
+            setPolicyError(null);
+          }}
+          policy={joinPolicy}
+        />
+      ) : null}
+
+      {policyError ? (
+        <p className="text-center text-sm text-destructive">{policyError}</p>
+      ) : null}
+
       {error ? (
         <p className="text-center text-sm text-destructive">{error}</p>
       ) : null}
@@ -109,11 +197,21 @@ export function InviteRedeemForm({
       <Button
         className="h-10 w-full"
         data-testid="invite-redeem-submit"
-        disabled={!canSubmit || isRedeeming}
+        disabled={
+          !canSubmit ||
+          isRedeeming ||
+          isLoadingPolicy ||
+          Boolean(joinPolicy?.ageAttestationRequired && !ageConfirmed)
+        }
         type="submit"
       >
-        {isRedeeming ? (
-          <Spinner aria-label="Redeeming invite" className="h-4 w-4 border-2" />
+        {isRedeeming || isLoadingPolicy ? (
+          <Spinner
+            aria-label={isRedeeming ? "Redeeming invite" : "Loading policy"}
+            className="h-4 w-4 border-2"
+          />
+        ) : joinPolicy ? (
+          "Accept and redeem invite"
         ) : (
           "Redeem invite"
         )}
diff --git a/desktop/src/features/onboarding/ui/JoinPolicyNotice.tsx b/desktop/src/features/onboarding/ui/JoinPolicyNotice.tsx
new file mode 100644
index 0000000000..3cbfb18e73
--- /dev/null
+++ b/desktop/src/features/onboarding/ui/JoinPolicyNotice.tsx
@@ -0,0 +1,89 @@
+import * as React from "react";
+
+import type { JoinPolicy } from "@/shared/api/invites";
+import { Button } from "@/shared/ui/button";
+import {
+  Dialog,
+  DialogContent,
+  DialogHeader,
+  DialogTitle,
+} from "@/shared/ui/dialog";
+import { Markdown } from "@/shared/ui/markdown";
+
+type JoinPolicyNoticeProps = {
+  ageConfirmed: boolean;
+  onAgeConfirmedChange: (confirmed: boolean) => void;
+  policy: JoinPolicy;
+};
+
+export function JoinPolicyNotice({
+  ageConfirmed,
+  onAgeConfirmedChange,
+  policy,
+}: JoinPolicyNoticeProps) {
+  const [openDocument, setOpenDocument] = React.useState<
+    "terms" | "privacy" | null
+  >(null);
+  const markdown =
+    openDocument === "terms" ? policy.termsMarkdown : policy.privacyMarkdown;
+
+  return (
+    <div className="space-y-3 rounded-md border p-3 text-left text-sm">
+      {policy.ageAttestationRequired ? (
+        <label className="flex items-start gap-2">
+          <input
+            checked={ageConfirmed}
+            className="mt-1"
+            onChange={(event) => onAgeConfirmedChange(event.target.checked)}
+            type="checkbox"
+          />
+          <span>I am 18 years of age or older.</span>
+        </label>
+      ) : null}
+
+      {policy.termsMarkdown || policy.privacyMarkdown ? (
+        <p className="text-muted-foreground">
+          By proceeding you agree to the Buzz{" "}
+          {policy.termsMarkdown ? (
+            <Button
+              className="h-auto p-0 align-baseline"
+              onClick={() => setOpenDocument("terms")}
+              type="button"
+              variant="link"
+            >
+              Terms of Service
+            </Button>
+          ) : null}
+          {policy.termsMarkdown && policy.privacyMarkdown ? " and " : null}
+          {policy.privacyMarkdown ? (
+            <Button
+              className="h-auto p-0 align-baseline"
+              onClick={() => setOpenDocument("privacy")}
+              type="button"
+              variant="link"
+            >
+              Privacy Policy
+            </Button>
+          ) : null}
+          .
+        </p>
+      ) : null}
+
+      <Dialog
+        onOpenChange={(open) => {
+          if (!open) setOpenDocument(null);
+        }}
+        open={openDocument !== null}
+      >
+        <DialogContent className="max-h-[80vh] max-w-2xl overflow-y-auto">
+          <DialogHeader>
+            <DialogTitle>
+              {openDocument === "terms" ? "Terms of Service" : "Privacy Policy"}
+            </DialogTitle>
+          </DialogHeader>
+          {markdown ? <Markdown content={markdown} /> : null}
+        </DialogContent>
+      </Dialog>
+    </div>
+  );
+}
diff --git a/desktop/src/features/onboarding/ui/MembershipDenied.tsx b/desktop/src/features/onboarding/ui/MembershipDenied.tsx
index bca564fbd8..3c74dcefb8 100644
--- a/desktop/src/features/onboarding/ui/MembershipDenied.tsx
+++ b/desktop/src/features/onboarding/ui/MembershipDenied.tsx
@@ -89,11 +89,11 @@ export function MembershipDenied({
   }, [onImportKey, previewNpub, trimmedNsec]);
 
   const handleInviteRedeem = React.useCallback(
-    async (relayWsUrl: string, code: string) => {
+    async (relayWsUrl: string, code: string, policyReceipt?: string) => {
       setIsRedeeming(true);
       setInviteError(null);
       try {
-        await claimInvite(relayWsUrl, code);
+        await claimInvite(relayWsUrl, code, policyReceipt);
         onInviteRedeemed(relayWsUrl);
       } catch (error) {
         setInviteError(inviteErrorMessage(error));
diff --git a/desktop/src/shared/api/invites.test.mjs b/desktop/src/shared/api/invites.test.mjs
new file mode 100644
index 0000000000..eb4ca3dad1
--- /dev/null
+++ b/desktop/src/shared/api/invites.test.mjs
@@ -0,0 +1,54 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { getJoinPolicy } from "./invites.ts";
+
+function withFetch(response, run) {
+  const originalFetch = globalThis.fetch;
+  globalThis.fetch = async (url) => {
+    assert.equal(url, "https://relay.example/api/join-policy");
+    return response;
+  };
+  return Promise.resolve(run()).finally(() => {
+    globalThis.fetch = originalFetch;
+  });
+}
+
+test("getJoinPolicy maps relay-hosted Markdown and age requirements", async () => {
+  await withFetch(
+    new Response(
+      JSON.stringify({
+        policy: {
+          terms_markdown: "# Terms",
+          privacy_markdown: "# Privacy",
+          age_attestation_required: true,
+          version: "policy-v1",
+        },
+      }),
+      { status: 200 },
+    ),
+    async () => {
+      assert.deepEqual(await getJoinPolicy("wss://relay.example"), {
+        termsMarkdown: "# Terms",
+        privacyMarkdown: "# Privacy",
+        ageAttestationRequired: true,
+        version: "policy-v1",
+      });
+    },
+  );
+});
+
+test("getJoinPolicy preserves opt-in behavior for unconfigured and older relays", async () => {
+  await withFetch(new Response(JSON.stringify({}), { status: 200 }), async () =>
+    assert.equal(await getJoinPolicy("wss://relay.example"), null),
+  );
+  await withFetch(new Response(null, { status: 404 }), async () =>
+    assert.equal(await getJoinPolicy("wss://relay.example"), null),
+  );
+});
+
+test("getJoinPolicy fails closed on a policy endpoint error", async () => {
+  await withFetch(new Response(null, { status: 503 }), async () =>
+    assert.rejects(getJoinPolicy("wss://relay.example"), /HTTP 503/),
+  );
+});
diff --git a/desktop/src/shared/api/invites.ts b/desktop/src/shared/api/invites.ts
index 46014fa44f..d9e6845cc6 100644
--- a/desktop/src/shared/api/invites.ts
+++ b/desktop/src/shared/api/invites.ts
@@ -18,6 +18,13 @@ export type MintedInvite = {
   url: string;
 };
 
+export type JoinPolicy = {
+  termsMarkdown?: string;
+  privacyMarkdown?: string;
+  ageAttestationRequired: boolean;
+  version: string;
+};
+
 export type ClaimResult = {
   status: "joined" | "already_member";
   communityId: string;
@@ -84,6 +91,57 @@ async function invitePost<T>(
   return json as T;
 }
 
+/** Fetch relay-hosted policy content for any join surface. */
+export async function getJoinPolicy(
+  relayWsUrl: string,
+): Promise<JoinPolicy | null> {
+  const base = relayHttpFromWs(relayWsUrl);
+  const response = await fetch(`${base.replace(/\/+$/, "")}/api/join-policy`);
+  // Relays predating join-policy support have no configured policy.
+  if (response.status === 404) return null;
+  if (!response.ok) throw new Error(`HTTP ${response.status}`);
+  const raw = (await response.json()) as {
+    policy?: {
+      terms_markdown?: string;
+      privacy_markdown?: string;
+      age_attestation_required: boolean;
+      version: string;
+    };
+  };
+  return raw.policy
+    ? {
+        termsMarkdown: raw.policy.terms_markdown,
+        privacyMarkdown: raw.policy.privacy_markdown,
+        ageAttestationRequired: raw.policy.age_attestation_required,
+        version: raw.policy.version,
+      }
+    : null;
+}
+
+/** Accept the current join policy for an invite and receive a bound receipt. */
+export async function acceptJoinPolicy(
+  relayWsUrl: string,
+  code: string,
+  policyVersion: string,
+  ageConfirmed: boolean,
+): Promise<string> {
+  const base = relayHttpFromWs(relayWsUrl);
+  const response = await fetch(
+    `${base.replace(/\/+$/, "")}/api/invites/accept-policy`,
+    {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({
+        code,
+        policy_version: policyVersion,
+        age_confirmed: ageConfirmed,
+      }),
+    },
+  );
+  if (!response.ok) throw new Error(`HTTP ${response.status}`);
+  return ((await response.json()) as { receipt: string }).receipt;
+}
+
 /** Mint an invite code on the active community's relay (owner/admin only). */
 export async function mintInvite(ttlSecs?: number): Promise<MintedInvite> {
   const base = await getRelayHttpUrl();
@@ -103,10 +161,10 @@ export async function mintInvite(ttlSecs?: number): Promise<MintedInvite> {
 export async function claimInvite(
   relayWsUrl: string,
   code: string,
-  termsReceipt?: string,
+  policyReceipt?: string,
 ): Promise<ClaimResult> {
   const base = relayHttpFromWs(relayWsUrl);
-  const body = JSON.stringify({ code, terms_receipt: termsReceipt });
+  const body = JSON.stringify({ code, policy_receipt: policyReceipt });
   const raw = await invitePost<{
     status: "joined" | "already_member";
     community_id: string;
diff --git a/desktop/src/shared/deep-link.ts b/desktop/src/shared/deep-link.ts
index 3f8ab562a1..55d238919b 100644
--- a/desktop/src/shared/deep-link.ts
+++ b/desktop/src/shared/deep-link.ts
@@ -48,7 +48,7 @@ export type NostrBindDeepLinkPayload = {
 export type JoinDeepLinkPayload = {
   relayUrl: string;
   code: string;
-  termsReceipt: string | null;
+  policyReceipt: string | null;
 };
 
 /**
@@ -90,8 +90,8 @@ export function listenForDeepLinks(deps: DeepLinkDeps): Promise<UnlistenFn> {
   });
 
   const joinPromise = listen<JoinDeepLinkPayload>("deep-link-join", (event) => {
-    const { relayUrl, code, termsReceipt } = event.payload;
-    void claimInvite(relayUrl, code, termsReceipt ?? undefined)
+    const { relayUrl, code, policyReceipt } = event.payload;
+    void claimInvite(relayUrl, code, policyReceipt ?? undefined)
       .then((result) => {
         const name = addAndSwitch(relayUrl);
         toast.success(
diff --git a/web/src/features/invite/ui/InvitePage.tsx b/web/src/features/invite/ui/InvitePage.tsx
index 27eea3007d..72a1d11c7e 100644
--- a/web/src/features/invite/ui/InvitePage.tsx
+++ b/web/src/features/invite/ui/InvitePage.tsx
@@ -2,39 +2,51 @@ import buzzAppIcon from "@/assets/app-icon@3x.png";
 import { relayWsUrl } from "@/shared/lib/relay-url";
 import { Button } from "@/shared/ui/button";
 import * as React from "react";
+import Markdown from "react-markdown";
+import remarkGfm from "remark-gfm";
 
 const DOWNLOAD_URL = "https://github.com/block/buzz/releases/latest";
-type Terms = { url: string; privacy_url?: string; version: string };
+type JoinPolicy = {
+  terms_markdown?: string;
+  privacy_markdown?: string;
+  age_attestation_required: boolean;
+  version: string;
+};
+
+type PolicyDocument = { title: string; markdown: string };
 
 /** Landing page for a community invite link (`/invite/<code>`). */
 export function InvitePage({ code }: { code: string }) {
   const relay = relayWsUrl();
   const host = relay.replace(/^wss?:\/\//, "");
-  const [terms, setTerms] = React.useState<Terms | null | undefined>(undefined);
+  const [policy, setPolicy] = React.useState<JoinPolicy | null | undefined>(
+    undefined,
+  );
+  const [document, setDocument] = React.useState<PolicyDocument | null>(null);
   const [ageConfirmed, setAgeConfirmed] = React.useState(false);
   const [opening, setOpening] = React.useState(false);
 
   React.useEffect(() => {
-    fetch("/api/invites/config")
+    fetch("/api/join-policy")
       .then(async (response) => {
         if (!response.ok) throw new Error(`HTTP ${response.status}`);
-        const config = (await response.json()) as { terms?: Terms };
-        setTerms(config.terms ?? null);
+        const config = (await response.json()) as { policy?: JoinPolicy };
+        setPolicy(config.policy ?? null);
       })
-      .catch(() => setTerms(undefined));
+      .catch(() => setPolicy(undefined));
   }, []);
 
   const openInvite = async () => {
     setOpening(true);
     try {
       let receipt: string | undefined;
-      if (terms) {
-        const response = await fetch("/api/invites/accept-terms", {
+      if (policy) {
+        const response = await fetch("/api/invites/accept-policy", {
           method: "POST",
           headers: { "Content-Type": "application/json" },
           body: JSON.stringify({
             code,
-            policy_version: terms.version,
+            policy_version: policy.version,
             age_confirmed: ageConfirmed,
           }),
         });
@@ -42,7 +54,7 @@ export function InvitePage({ code }: { code: string }) {
         receipt = ((await response.json()) as { receipt: string }).receipt;
       }
       const query = new URLSearchParams({ relay, code });
-      if (receipt) query.set("terms_receipt", receipt);
+      if (receipt) query.set("policy_receipt", receipt);
       window.location.href = `buzz://join?${query.toString()}`;
     } finally {
       setOpening(false);
@@ -50,7 +62,12 @@ export function InvitePage({ code }: { code: string }) {
   };
 
   const disabled =
-    terms === undefined || opening || (terms !== null && !ageConfirmed);
+    policy === undefined ||
+    opening ||
+    Boolean(policy?.age_attestation_required && !ageConfirmed);
+  const showDocument = (title: string, markdown: string) =>
+    setDocument({ title, markdown });
+
   return (
     <div
       className="flex flex-1 flex-col items-center justify-center px-4 py-16 text-center"
@@ -71,7 +88,7 @@ export function InvitePage({ code }: { code: string }) {
           </h1>
           <p className="mt-9 font-mono text-lg text-black/70">{host}</p>
 
-          {terms && (
+          {policy?.age_attestation_required && (
             <label className="mt-9 flex max-w-md cursor-pointer items-start gap-3 text-left text-sm text-black/70">
               <input
                 className="mt-0.5 h-4 w-4 accent-black"
@@ -79,11 +96,11 @@ export function InvitePage({ code }: { code: string }) {
                 checked={ageConfirmed}
                 onChange={(event) => setAgeConfirmed(event.target.checked)}
               />
-              <span>I confirm that I am at least 18 years old.</span>
+              <span>I am 18 years of age or older.</span>
             </label>
           )}
-          <div className={terms ? "mt-5" : "mt-9"}>
-            {terms === null ? (
+          <div className={policy?.age_attestation_required ? "mt-5" : "mt-9"}>
+            {policy === null ? (
               <Button
                 asChild
                 className="bg-black text-white hover:bg-black/90 focus-visible:ring-black"
@@ -106,30 +123,37 @@ export function InvitePage({ code }: { code: string }) {
               </Button>
             )}
           </div>
-          {terms && (
+          {policy && (policy.terms_markdown || policy.privacy_markdown) && (
             <p className="mt-4 max-w-md text-xs text-black/60">
-              By accepting this invite, you agree to the{" "}
-              <a
-                className="text-black underline-offset-4 hover:text-black/70 hover:decoration-current hover:underline focus-visible:underline"
-                href={terms.url}
-                target="_blank"
-                rel="noreferrer"
-              >
-                Terms of Service
-              </a>
-              {terms.privacy_url && (
-                <>
-                  {" "}
-                  and{" "}
-                  <a
-                    className="text-black underline-offset-4 hover:text-black/70 hover:decoration-current hover:underline focus-visible:underline"
-                    href={terms.privacy_url}
-                    target="_blank"
-                    rel="noreferrer"
-                  >
-                    Privacy Policy
-                  </a>
-                </>
+              By proceeding you agree to the Buzz{" "}
+              {policy.terms_markdown && (
+                <button
+                  className="text-black underline-offset-4 hover:text-black/70 hover:underline focus-visible:underline"
+                  type="button"
+                  onClick={() =>
+                    showDocument(
+                      "Terms of Service",
+                      policy.terms_markdown ?? "",
+                    )
+                  }
+                >
+                  Terms of Service
+                </button>
+              )}
+              {policy.terms_markdown && policy.privacy_markdown && " and "}
+              {policy.privacy_markdown && (
+                <button
+                  className="text-black underline-offset-4 hover:text-black/70 hover:underline focus-visible:underline"
+                  type="button"
+                  onClick={() =>
+                    showDocument(
+                      "Privacy Policy",
+                      policy.privacy_markdown ?? "",
+                    )
+                  }
+                >
+                  Privacy Policy
+                </button>
               )}
               .
             </p>
@@ -138,7 +162,7 @@ export function InvitePage({ code }: { code: string }) {
         <p className="flex h-[3.125rem] items-center justify-center rounded-2xl bg-white text-sm text-black/60">
           Don&apos;t have the app?{" "}
           <a
-            className="ml-1 font-medium text-black underline-offset-4 hover:text-black/70 hover:decoration-current hover:underline focus-visible:underline"
+            className="ml-1 font-medium text-black underline-offset-4 hover:text-black/70 hover:underline focus-visible:underline"
             href={DOWNLOAD_URL}
             rel="noreferrer"
             target="_blank"
@@ -147,6 +171,37 @@ export function InvitePage({ code }: { code: string }) {
           </a>
         </p>
       </div>
+
+      {document && (
+        <div
+          aria-label={document.title}
+          aria-modal="true"
+          className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 text-left"
+          role="dialog"
+          onMouseDown={(event) => {
+            if (event.currentTarget === event.target) setDocument(null);
+          }}
+        >
+          <div className="max-h-[85vh] w-full max-w-3xl overflow-y-auto rounded-2xl bg-white p-6 text-black shadow-xl sm:p-8">
+            <div className="mb-6 flex items-start justify-between gap-4">
+              <h2 className="text-xl font-semibold">{document.title}</h2>
+              <button
+                aria-label="Close"
+                className="text-2xl leading-none text-black/60 hover:text-black"
+                type="button"
+                onClick={() => setDocument(null)}
+              >
+                ×
+              </button>
+            </div>
+            <div className="prose prose-sm max-w-none">
+              <Markdown remarkPlugins={[remarkGfm]}>
+                {document.markdown}
+              </Markdown>
+            </div>
+          </div>
+        </div>
+      )}
     </div>
   );
 }

From 088418be41340bad84146d217d505af16d026575 Mon Sep 17 00:00:00 2001
From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Date: Wed, 15 Jul 2026 12:18:32 -0400
Subject: [PATCH 05/16] Fix desktop join policy handoffs

Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
---
 .../features/communities/ui/WelcomeSetup.tsx  | 82 ++++++++++++++++++-
 .../onboarding/ui/MembershipDenied.tsx        |  4 +-
 desktop/tests/e2e/onboarding.spec.ts          | 57 +++++++++++++
 3 files changed, 137 insertions(+), 6 deletions(-)

diff --git a/desktop/src/features/communities/ui/WelcomeSetup.tsx b/desktop/src/features/communities/ui/WelcomeSetup.tsx
index 650626c79b..61cbf1a5b3 100644
--- a/desktop/src/features/communities/ui/WelcomeSetup.tsx
+++ b/desktop/src/features/communities/ui/WelcomeSetup.tsx
@@ -5,9 +5,14 @@ import {
   getIdentity,
   importIdentity as tauriImportIdentity,
 } from "@/shared/api/tauriIdentity";
-import { claimInvite } from "@/shared/api/invites";
+import {
+  claimInvite,
+  getJoinPolicy,
+  type JoinPolicy,
+} from "@/shared/api/invites";
 import { inviteErrorMessage } from "@/shared/api/inviteHelpers";
 import { InviteRedeemForm } from "@/features/onboarding/ui/InviteRedeemForm";
+import { JoinPolicyNotice } from "@/features/onboarding/ui/JoinPolicyNotice";
 import { NostrKeyImportForm } from "@/features/onboarding/ui/NostrKeyImportForm";
 import {
   type OnboardingTransitionDirection,
@@ -48,13 +53,19 @@ function wait(ms: number) {
 }
 
 function NostrKeyImportPage({
+  ageConfirmed,
   connectionError,
   disabled,
+  joinPolicy,
+  onAgeConfirmedChange,
   onBack,
   onImport,
 }: {
+  ageConfirmed: boolean;
   connectionError: string | null;
   disabled: boolean;
+  joinPolicy: JoinPolicy | null | undefined;
+  onAgeConfirmedChange: (confirmed: boolean) => void;
   onBack: () => void;
   onImport: (nsec: string) => Promise<void>;
 }) {
@@ -75,8 +86,22 @@ function NostrKeyImportPage({
         </p>
       </div>
 
+      {joinPolicy ? (
+        <div className="mt-6 w-full">
+          <JoinPolicyNotice
+            ageConfirmed={ageConfirmed}
+            onAgeConfirmedChange={onAgeConfirmedChange}
+            policy={joinPolicy}
+          />
+        </div>
+      ) : null}
+
       <NostrKeyImportForm
-        disabled={disabled}
+        disabled={
+          disabled ||
+          joinPolicy === undefined ||
+          Boolean(joinPolicy?.ageAttestationRequired && !ageConfirmed)
+        }
         errorMessage={connectionError}
         onBack={onBack}
         onImport={onImport}
@@ -97,8 +122,36 @@ export function WelcomeSetup({
   const [error, setError] = React.useState<string | null>(null);
   const [isRedeeming, setIsRedeeming] = React.useState(false);
   const [inviteError, setInviteError] = React.useState<string | null>(null);
+  const [defaultJoinPolicy, setDefaultJoinPolicy] = React.useState<
+    JoinPolicy | null | undefined
+  >(undefined);
+  const [defaultAgeConfirmed, setDefaultAgeConfirmed] = React.useState(false);
   const systemColorScheme = useSystemColorScheme();
 
+  React.useEffect(() => {
+    let cancelled = false;
+    setDefaultAgeConfirmed(false);
+    if (isLocalDevRelayUrl(defaultRelayUrl)) {
+      setDefaultJoinPolicy(null);
+      return;
+    }
+    setDefaultJoinPolicy(undefined);
+    void getJoinPolicy(defaultRelayUrl)
+      .then((policy) => {
+        if (!cancelled) {
+          setDefaultJoinPolicy(policy);
+        }
+      })
+      .catch((policyError) => {
+        if (!cancelled) {
+          setError(inviteErrorMessage(policyError));
+        }
+      });
+    return () => {
+      cancelled = true;
+    };
+  }, [defaultRelayUrl]);
+
   const handleConnect = React.useCallback(
     async (relayUrl: string, communityName?: string, pubkey?: string) => {
       const trimmedUrl = relayUrl.trim();
@@ -249,10 +302,24 @@ export function WelcomeSetup({
             </p>
 
             <div className="mt-8 flex w-full flex-col gap-3">
+              {defaultJoinPolicy ? (
+                <JoinPolicyNotice
+                  ageConfirmed={defaultAgeConfirmed}
+                  onAgeConfirmedChange={setDefaultAgeConfirmed}
+                  policy={defaultJoinPolicy}
+                />
+              ) : null}
               {isLocalDevRelayUrl(defaultRelayUrl) ? null : (
                 <Button
                   className="h-10 w-full"
                   aria-disabled={isConnecting}
+                  disabled={
+                    defaultJoinPolicy === undefined ||
+                    Boolean(
+                      defaultJoinPolicy?.ageAttestationRequired &&
+                        !defaultAgeConfirmed,
+                    )
+                  }
                   onClick={() => {
                     if (isConnecting) {
                       return;
@@ -381,16 +448,23 @@ export function WelcomeSetup({
                 error={inviteError}
                 isRedeeming={isRedeeming}
                 onCancel={showWelcomePage}
-                onRedeem={(relayWsUrl, code) => {
-                  void handleWelcomeInviteRedeem(relayWsUrl, code);
+                onRedeem={(relayWsUrl, code, policyReceipt) => {
+                  void handleWelcomeInviteRedeem(
+                    relayWsUrl,
+                    code,
+                    policyReceipt,
+                  );
                 }}
               />
             </div>
           </OnboardingSlideTransition>
         ) : (
           <NostrKeyImportPage
+            ageConfirmed={defaultAgeConfirmed}
             connectionError={error}
             disabled={isConnecting}
+            joinPolicy={defaultJoinPolicy}
+            onAgeConfirmedChange={setDefaultAgeConfirmed}
             onBack={showWelcomePage}
             onImport={handleNostrImport}
           />
diff --git a/desktop/src/features/onboarding/ui/MembershipDenied.tsx b/desktop/src/features/onboarding/ui/MembershipDenied.tsx
index 3c74dcefb8..51745f22a3 100644
--- a/desktop/src/features/onboarding/ui/MembershipDenied.tsx
+++ b/desktop/src/features/onboarding/ui/MembershipDenied.tsx
@@ -170,8 +170,8 @@ export function MembershipDenied({
                 setInviteError(null);
                 setIsInviteFormOpen(false);
               }}
-              onRedeem={(relayWsUrl, code) => {
-                void handleInviteRedeem(relayWsUrl, code);
+              onRedeem={(relayWsUrl, code, policyReceipt) => {
+                void handleInviteRedeem(relayWsUrl, code, policyReceipt);
               }}
             />
           ) : isImportFormOpen ? (
diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts
index 7603afa354..bdf0f06831 100644
--- a/desktop/tests/e2e/onboarding.spec.ts
+++ b/desktop/tests/e2e/onboarding.spec.ts
@@ -568,6 +568,16 @@ test("first-run default community handoff gives immediate stepper feedback", asy
       skipCommunitySeed: true,
     },
   );
+  await page.route(
+    "https://default.example.com/api/join-policy",
+    async (route) => {
+      await route.fulfill({
+        status: 200,
+        contentType: "application/json",
+        body: "{}",
+      });
+    },
+  );
   await page.goto("/");
 
   await expect(page.getByText("Welcome to Buzz")).toBeVisible();
@@ -609,6 +619,16 @@ test("welcome can continue using an existing Nostr key", async ({ page }) => {
     skipOnboardingSeed: true,
     skipCommunitySeed: true,
   });
+  await page.route(
+    "https://default.example.com/api/join-policy",
+    async (route) => {
+      await route.fulfill({
+        status: 200,
+        contentType: "application/json",
+        body: "{}",
+      });
+    },
+  );
   await page.goto("/");
 
   await page.getByTestId("welcome-continue-nostr").click();
@@ -1824,7 +1844,41 @@ test("denied on relay A then paste relay B invite URL switches community to B",
   // Intercept the claimInvite POST to relay B so it succeeds.
   const relayBUrl = "wss://relay-b.example.com";
   const relayBHttpUrl = "https://relay-b.example.com";
+  const policyReceipt = "relay-signed-policy-receipt";
+  await page.route(`${relayBHttpUrl}/api/join-policy`, async (route) => {
+    await route.fulfill({
+      status: 200,
+      contentType: "application/json",
+      body: JSON.stringify({
+        policy: {
+          terms_markdown: "# Terms",
+          privacy_markdown: "# Privacy",
+          age_attestation_required: true,
+          version: "policy-v1",
+        },
+      }),
+    });
+  });
+  await page.route(
+    `${relayBHttpUrl}/api/invites/accept-policy`,
+    async (route) => {
+      expect(route.request().postDataJSON()).toEqual({
+        code: "test-invite-code",
+        policy_version: "policy-v1",
+        age_confirmed: true,
+      });
+      await route.fulfill({
+        status: 200,
+        contentType: "application/json",
+        body: JSON.stringify({ receipt: policyReceipt }),
+      });
+    },
+  );
   await page.route(`${relayBHttpUrl}/api/invites/claim`, async (route) => {
+    expect(route.request().postDataJSON()).toMatchObject({
+      code: "test-invite-code",
+      policy_receipt: policyReceipt,
+    });
     await route.fulfill({
       status: 200,
       contentType: "application/json",
@@ -1843,6 +1897,9 @@ test("denied on relay A then paste relay B invite URL switches community to B",
     .getByTestId("invite-redeem-input")
     .fill(`${relayBHttpUrl}/invite/test-invite-code`);
   await page.getByTestId("invite-redeem-submit").click();
+  await expect(page.getByText("I am 18 years of age or older.")).toBeVisible();
+  await page.getByLabel("I am 18 years of age or older.").check();
+  await page.getByTestId("invite-redeem-submit").click();
 
   // After successful claim, the community should switch to relay B's URL.
   await expect

From 08b82fc44679c7c56eab8c98e284b060c5c9975e Mon Sep 17 00:00:00 2001
From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Date: Wed, 15 Jul 2026 15:27:40 -0400
Subject: [PATCH 06/16] Persist invite policy acceptance

Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
---
 crates/buzz-db/src/lib.rs                   |  24 +++
 crates/buzz-db/src/migration.rs             |   2 +-
 crates/buzz-db/src/relay_members.rs         |  93 ++++++++
 crates/buzz-relay/src/api/invites.rs        | 227 +++++++++++++++++++-
 migrations/0019_join_policy_acceptances.sql |  12 ++
 5 files changed, 354 insertions(+), 4 deletions(-)
 create mode 100644 migrations/0019_join_policy_acceptances.sql

diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs
index c0b6bf78c6..0d2da63bdf 100644
--- a/crates/buzz-db/src/lib.rs
+++ b/crates/buzz-db/src/lib.rs
@@ -2651,6 +2651,30 @@ impl Db {
         relay_members::add_relay_member(&self.pool, community, pubkey, role, added_by).await
     }
 
+    /// Claims relay membership via an invite and atomically persists the
+    /// accepted policy version when a policy is configured.
+    pub async fn claim_relay_membership(
+        &self,
+        community: CommunityId,
+        pubkey: &str,
+        role: &str,
+        policy_version: Option<&str>,
+    ) -> Result<bool> {
+        relay_members::claim_relay_membership(&self.pool, community, pubkey, role, policy_version)
+            .await
+    }
+
+    /// Returns whether a member has persisted acceptance evidence for a policy version.
+    pub async fn has_join_policy_acceptance(
+        &self,
+        community: CommunityId,
+        pubkey: &str,
+        policy_version: &str,
+    ) -> Result<bool> {
+        relay_members::has_join_policy_acceptance(&self.pool, community, pubkey, policy_version)
+            .await
+    }
+
     /// Removes a relay member from `community` atomically, refusing to delete the owner.
     pub async fn remove_relay_member(
         &self,
diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs
index faa0b428e4..d159179b74 100644
--- a/crates/buzz-db/src/migration.rs
+++ b/crates/buzz-db/src/migration.rs
@@ -549,7 +549,7 @@ mod tests {
         let mut migrations: Vec<_> = MIGRATOR.iter().collect();
         migrations.sort_by_key(|migration| migration.version);
 
-        assert_eq!(migrations.len(), 18);
+        assert_eq!(migrations.len(), 19);
         assert_eq!(migrations[0].version, 1);
         assert_eq!(&*migrations[0].description, "initial schema");
         assert!(migrations[0]
diff --git a/crates/buzz-db/src/relay_members.rs b/crates/buzz-db/src/relay_members.rs
index d354b057bd..3bd114fda0 100644
--- a/crates/buzz-db/src/relay_members.rs
+++ b/crates/buzz-db/src/relay_members.rs
@@ -114,6 +114,67 @@ pub async fn add_relay_member(
     Ok(result.rows_affected() > 0)
 }
 
+/// Claims relay membership via an invite and atomically persists policy evidence.
+///
+/// Returns `true` when membership was inserted, or `false` when the pubkey was
+/// already a member. A configured `policy_version` is recorded in the same
+/// transaction, so membership cannot be granted without its acceptance record.
+pub async fn claim_relay_membership(
+    pool: &PgPool,
+    community: CommunityId,
+    pubkey: &str,
+    role: &str,
+    policy_version: Option<&str>,
+) -> Result<bool> {
+    let mut tx = pool.begin().await?;
+    let inserted = sqlx::query(
+        "INSERT INTO relay_members (community_id, pubkey, role, added_by) \
+         VALUES ($1, $2, $3, 'invite') \
+         ON CONFLICT (community_id, pubkey) DO NOTHING",
+    )
+    .bind(community.as_uuid())
+    .bind(pubkey)
+    .bind(role)
+    .execute(&mut *tx)
+    .await?
+    .rows_affected()
+        > 0;
+
+    if let Some(version) = policy_version {
+        sqlx::query(
+            "INSERT INTO join_policy_acceptances (community_id, pubkey, policy_version) \
+             VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
+        )
+        .bind(community.as_uuid())
+        .bind(pubkey)
+        .bind(version)
+        .execute(&mut *tx)
+        .await?;
+    }
+
+    tx.commit().await?;
+    Ok(inserted)
+}
+
+/// Returns whether a member has persisted acceptance evidence for a policy version.
+pub async fn has_join_policy_acceptance(
+    pool: &PgPool,
+    community: CommunityId,
+    pubkey: &str,
+    policy_version: &str,
+) -> Result<bool> {
+    let row = sqlx::query(
+        "SELECT 1 FROM join_policy_acceptances \
+         WHERE community_id = $1 AND pubkey = $2 AND policy_version = $3",
+    )
+    .bind(community.as_uuid())
+    .bind(pubkey)
+    .bind(policy_version)
+    .fetch_optional(pool)
+    .await?;
+    Ok(row.is_some())
+}
+
 /// The result of a relay member removal attempt.
 #[derive(Debug, PartialEq)]
 pub enum RemoveResult {
@@ -544,6 +605,38 @@ mod tests {
         (community, owner)
     }
 
+    #[tokio::test]
+    #[ignore = "requires Postgres"]
+    async fn invite_claim_persists_policy_version_and_legacy_claim_does_not() {
+        let pool = setup_pool().await;
+        let community = make_test_community(&pool).await;
+        let policy_member = test_pubkey();
+        let legacy_member = test_pubkey();
+        let version = "a".repeat(64);
+
+        assert!(
+            claim_relay_membership(&pool, community, &policy_member, "member", Some(&version),)
+                .await
+                .expect("claim membership with policy")
+        );
+        assert!(
+            has_join_policy_acceptance(&pool, community, &policy_member, &version)
+                .await
+                .expect("policy acceptance lookup")
+        );
+
+        assert!(
+            claim_relay_membership(&pool, community, &legacy_member, "member", None)
+                .await
+                .expect("legacy claim membership")
+        );
+        assert!(
+            !has_join_policy_acceptance(&pool, community, &legacy_member, &version)
+                .await
+                .expect("legacy acceptance lookup")
+        );
+    }
+
     /// NIP-43 admission confinement: a pubkey admitted to community A is *not*
     /// admitted to community B. This is the exact mutation #1285 targets — a
     /// `WHERE pubkey = $1` membership check (no community predicate) would let an
diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs
index b841194a8e..7d004c08c9 100644
--- a/crates/buzz-relay/src/api/invites.rs
+++ b/crates/buzz-relay/src/api/invites.rs
@@ -257,7 +257,16 @@ pub async fn claim_invite(
 
     let was_inserted = state
         .db
-        .add_relay_member(tenant.community(), &claimer_hex, &payload.r, Some("invite"))
+        .claim_relay_membership(
+            tenant.community(),
+            &claimer_hex,
+            &payload.r,
+            state
+                .config
+                .join_policy
+                .as_ref()
+                .map(|policy| policy.version.as_str()),
+        )
         .await
         .map_err(|e| internal_error(&format!("invite claim insert: {e}")))?;
 
@@ -430,14 +439,17 @@ mod tests {
     /// a fresh community on `host`; returns `None` when Postgres is unavailable.
     async fn invite_test_state(host: &str) -> Option<Arc<AppState>> {
         let mut config = crate::config::Config::from_env().ok()?;
-        config.database_url = TEST_DB_URL.to_string();
+        let database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
+            .or_else(|_| std::env::var("DATABASE_URL"))
+            .unwrap_or_else(|_| TEST_DB_URL.to_string());
+        config.database_url = database_url.clone();
         config.redis_url = "redis://127.0.0.1:1".to_string();
         config.relay_url = format!("wss://{host}");
         // The claim route must work on relays where membership is enforced —
         // that is the entire point of an invite.
         config.require_relay_membership = true;
 
-        let pool = sqlx::PgPool::connect(TEST_DB_URL).await.ok()?;
+        let pool = sqlx::PgPool::connect(&database_url).await.ok()?;
         let db = buzz_db::Db::from_pool(pool.clone());
         db.ensure_configured_community(host).await.ok()?;
 
@@ -574,6 +586,215 @@ mod tests {
         );
     }
 
+    #[tokio::test]
+    #[ignore = "requires Postgres"]
+    async fn join_policy_gate_end_to_end() {
+        let host = format!("invites-policy-{}.example", Uuid::new_v4().simple());
+        let owner = Keys::generate();
+        let joiner = Keys::generate();
+        let Some(state) = invite_test_state(&host).await else {
+            return;
+        };
+        // Force the join policy on regardless of env.
+        let mut state_inner = (*state).clone();
+        let mut config = state_inner.config.as_ref().clone();
+        config.join_policy = Some(crate::config::JoinPolicyConfig {
+            terms_markdown: Some("# Terms".to_string()),
+            privacy_markdown: Some("# Privacy".to_string()),
+            age_attestation_required: true,
+            version: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
+        });
+        state_inner.config = Arc::new(config);
+        let state = Arc::new(state_inner);
+
+        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 an invite.
+        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")
+            .to_string();
+
+        // 1. Claim WITHOUT receipt -> 403 (checkbox bypass).
+        let response = post_json(
+            state.clone(),
+            &host,
+            "/api/invites/claim",
+            &joiner,
+            serde_json::json!({ "code": code }).to_string(),
+        )
+        .await;
+        assert_eq!(
+            response.status(),
+            StatusCode::FORBIDDEN,
+            "no-receipt claim must fail"
+        );
+
+        // 2. Forged receipt (wrong key) -> 403.
+        let forged = crate::invite_token::mint_policy_acceptance(
+            &[9u8; 32],
+            &code,
+            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+        );
+        let response = post_json(
+            state.clone(),
+            &host,
+            "/api/invites/claim",
+            &joiner,
+            serde_json::json!({ "code": code, "policy_receipt": forged }).to_string(),
+        )
+        .await;
+        assert_eq!(
+            response.status(),
+            StatusCode::FORBIDDEN,
+            "forged receipt must fail"
+        );
+
+        // 3. Receipt bound to a DIFFERENT invite code -> 403.
+        let key = crate::invite_token::derive_invite_key(&state.relay_keypair);
+        let other = crate::invite_token::mint_policy_acceptance(
+            &key,
+            "some-other-code",
+            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+        );
+        let response = post_json(
+            state.clone(),
+            &host,
+            "/api/invites/claim",
+            &joiner,
+            serde_json::json!({ "code": code, "policy_receipt": other }).to_string(),
+        )
+        .await;
+        assert_eq!(
+            response.status(),
+            StatusCode::FORBIDDEN,
+            "cross-invite receipt must fail"
+        );
+
+        // 4. Receipt for a STALE policy version -> 403.
+        let stale = crate::invite_token::mint_policy_acceptance(
+            &key,
+            &code,
+            "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+        );
+        let response = post_json(
+            state.clone(),
+            &host,
+            "/api/invites/claim",
+            &joiner,
+            serde_json::json!({ "code": code, "policy_receipt": stale }).to_string(),
+        )
+        .await;
+        assert_eq!(
+            response.status(),
+            StatusCode::FORBIDDEN,
+            "stale-version receipt must fail"
+        );
+
+        // 5. accept-policy without age confirmation -> 400.
+        let response = post_json(
+            state.clone(),
+            &host,
+            "/api/invites/accept-policy",
+            &joiner,
+            serde_json::json!({ "code": code, "policy_version": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "age_confirmed": false })
+                .to_string(),
+        )
+        .await;
+        assert_eq!(
+            response.status(),
+            StatusCode::BAD_REQUEST,
+            "age not confirmed must be rejected when required"
+        );
+
+        // 5b. accept-policy with stale version -> 400.
+        let response = post_json(
+            state.clone(),
+            &host,
+            "/api/invites/accept-policy",
+            &joiner,
+            serde_json::json!({ "code": code, "policy_version": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "age_confirmed": true })
+                .to_string(),
+        )
+        .await;
+        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
+
+        // 6. Legit flow: accept-policy -> receipt -> claim OK.
+        let response = post_json(
+            state.clone(),
+            &host,
+            "/api/invites/accept-policy",
+            &joiner,
+            serde_json::json!({ "code": code, "policy_version": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "age_confirmed": true })
+                .to_string(),
+        )
+        .await;
+        assert_eq!(response.status(), StatusCode::OK);
+        let receipt = read_json(response)
+            .await
+            .get("receipt")
+            .and_then(Value::as_str)
+            .expect("receipt")
+            .to_string();
+
+        let response = post_json(
+            state.clone(),
+            &host,
+            "/api/invites/claim",
+            &joiner,
+            serde_json::json!({ "code": code, "policy_receipt": receipt }).to_string(),
+        )
+        .await;
+        assert_eq!(
+            response.status(),
+            StatusCode::OK,
+            "legit receipt claim must succeed"
+        );
+        let json = read_json(response).await;
+        assert_eq!(json.get("status").and_then(Value::as_str), Some("joined"));
+
+        let member = state
+            .db
+            .get_relay_member(community.id, &joiner.public_key().to_hex())
+            .await
+            .expect("member lookup")
+            .expect("joiner is now a member");
+        assert_eq!(member.role, "member");
+        assert!(
+            state
+                .db
+                .has_join_policy_acceptance(
+                    community.id,
+                    &joiner.public_key().to_hex(),
+                    "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+                )
+                .await
+                .expect("policy acceptance lookup"),
+            "accepted policy version must be persisted",
+        );
+    }
+
     #[tokio::test]
     #[ignore = "requires Postgres"]
     async fn non_admin_cannot_mint() {
diff --git a/migrations/0019_join_policy_acceptances.sql b/migrations/0019_join_policy_acceptances.sql
new file mode 100644
index 0000000000..061d4670fd
--- /dev/null
+++ b/migrations/0019_join_policy_acceptances.sql
@@ -0,0 +1,12 @@
+-- Durable evidence of the policy version accepted when an invite claim grants
+-- relay membership. Rows are scoped to the same community and member identity
+-- as relay_members and are deleted with that membership.
+CREATE TABLE join_policy_acceptances (
+    community_id UUID NOT NULL,
+    pubkey TEXT NOT NULL,
+    policy_version TEXT NOT NULL CHECK (length(policy_version) = 64),
+    accepted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    PRIMARY KEY (community_id, pubkey, policy_version),
+    FOREIGN KEY (community_id, pubkey)
+        REFERENCES relay_members (community_id, pubkey) ON DELETE CASCADE
+);

From b3610cb33071379c4a9bc110e5f963bcefbb03ac Mon Sep 17 00:00:00 2001
From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Date: Wed, 15 Jul 2026 17:48:29 -0400
Subject: [PATCH 07/16] Gate invite acceptance on join policy

Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
---
 .env.example                                  |   7 +
 crates/buzz-db/src/lib.rs                     |  24 ++
 crates/buzz-db/src/migration.rs               |  14 +-
 crates/buzz-db/src/relay_members.rs           |  93 ++++++
 crates/buzz-relay/src/api/invites.rs          | 296 +++++++++++++++++-
 crates/buzz-relay/src/config.rs               |  91 ++++++
 crates/buzz-relay/src/invite_token.rs         |  76 +++++
 crates/buzz-relay/src/router.rs               |   5 +
 desktop/src-tauri/src/deep_link.rs            |  14 +
 .../communities/ui/AddCommunityDialog.tsx     |  99 +++++-
 .../communities/ui/CommunityEditForm.tsx      |  69 +++-
 .../features/communities/ui/WelcomeSetup.tsx  |  87 ++++-
 .../onboarding/ui/InviteRedeemForm.tsx        | 124 +++++++-
 .../onboarding/ui/JoinPolicyNotice.tsx        |  89 ++++++
 .../onboarding/ui/MembershipDenied.tsx        |   8 +-
 desktop/src/shared/api/invites.test.mjs       |  54 ++++
 desktop/src/shared/api/invites.ts             |  61 +++-
 desktop/src/shared/deep-link.ts               |   5 +-
 desktop/tests/e2e/onboarding.spec.ts          |  57 ++++
 migrations/0020_join_policy_acceptances.sql   |  12 +
 web/src/features/invite/ui/InvitePage.tsx     | 212 ++++++++++---
 21 files changed, 1412 insertions(+), 85 deletions(-)
 create mode 100644 desktop/src/features/onboarding/ui/JoinPolicyNotice.tsx
 create mode 100644 desktop/src/shared/api/invites.test.mjs
 create mode 100644 migrations/0020_join_policy_acceptances.sql

diff --git a/.env.example b/.env.example
index 696d3a0617..16b47d0a91 100644
--- a/.env.example
+++ b/.env.example
@@ -197,3 +197,10 @@ RUST_LOG=buzz_relay=debug,buzz_db=debug,buzz_auth=debug,buzz_pubsub=debug,tower_
 # These are accepted for backward compatibility but the canonical names above
 # are preferred:
 #   BUZZ_ACP_PRIVATE_KEY  → BUZZ_PRIVATE_KEY
+
+# Optional relay join policy. Markdown is served by the relay so every join
+# surface can present the same documents. Each document and the independent age
+# attestation are optional; configuring any one enables policy acceptance.
+# BUZZ_TERMS_OF_SERVICE_MARKDOWN="# Terms of Service\n\nFull terms here."
+# BUZZ_PRIVACY_POLICY_MARKDOWN="# Privacy Policy\n\nFull policy here."
+# BUZZ_AGE_ATTESTATION_REQUIRED=true
diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs
index 07c67a8241..5e20c267ee 100644
--- a/crates/buzz-db/src/lib.rs
+++ b/crates/buzz-db/src/lib.rs
@@ -2689,6 +2689,30 @@ impl Db {
         relay_members::add_relay_member(&self.pool, community, pubkey, role, added_by).await
     }
 
+    /// Claims relay membership via an invite and atomically persists the
+    /// accepted policy version when a policy is configured.
+    pub async fn claim_relay_membership(
+        &self,
+        community: CommunityId,
+        pubkey: &str,
+        role: &str,
+        policy_version: Option<&str>,
+    ) -> Result<bool> {
+        relay_members::claim_relay_membership(&self.pool, community, pubkey, role, policy_version)
+            .await
+    }
+
+    /// Returns whether a member has persisted acceptance evidence for a policy version.
+    pub async fn has_join_policy_acceptance(
+        &self,
+        community: CommunityId,
+        pubkey: &str,
+        policy_version: &str,
+    ) -> Result<bool> {
+        relay_members::has_join_policy_acceptance(&self.pool, community, pubkey, policy_version)
+            .await
+    }
+
     /// Removes a relay member from `community` atomically, refusing to delete the owner.
     pub async fn remove_relay_member(
         &self,
diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs
index 1d8c961a29..eb4086024a 100644
--- a/crates/buzz-db/src/migration.rs
+++ b/crates/buzz-db/src/migration.rs
@@ -549,7 +549,7 @@ mod tests {
         let mut migrations: Vec<_> = MIGRATOR.iter().collect();
         migrations.sort_by_key(|migration| migration.version);
 
-        assert_eq!(migrations.len(), 19);
+        assert_eq!(migrations.len(), 20);
         assert_eq!(migrations[0].version, 1);
         assert_eq!(&*migrations[0].description, "initial schema");
         assert!(migrations[0]
@@ -812,6 +812,18 @@ mod tests {
             .sql
             .as_str()
             .contains("purge_soft_deleted_buzz_mesh_status"));
+
+        // Join policy acceptances landed concurrently with mesh status retention;
+        // keep both additive migrations in a single, unambiguous sequence.
+        assert_eq!(migrations[19].version, 20);
+        assert!(migrations[19]
+            .sql
+            .as_str()
+            .contains("CREATE TABLE join_policy_acceptances"));
+        assert!(!migrations[0]
+            .sql
+            .as_str()
+            .contains("join_policy_acceptances"));
     }
 
     #[test]
diff --git a/crates/buzz-db/src/relay_members.rs b/crates/buzz-db/src/relay_members.rs
index d354b057bd..3bd114fda0 100644
--- a/crates/buzz-db/src/relay_members.rs
+++ b/crates/buzz-db/src/relay_members.rs
@@ -114,6 +114,67 @@ pub async fn add_relay_member(
     Ok(result.rows_affected() > 0)
 }
 
+/// Claims relay membership via an invite and atomically persists policy evidence.
+///
+/// Returns `true` when membership was inserted, or `false` when the pubkey was
+/// already a member. A configured `policy_version` is recorded in the same
+/// transaction, so membership cannot be granted without its acceptance record.
+pub async fn claim_relay_membership(
+    pool: &PgPool,
+    community: CommunityId,
+    pubkey: &str,
+    role: &str,
+    policy_version: Option<&str>,
+) -> Result<bool> {
+    let mut tx = pool.begin().await?;
+    let inserted = sqlx::query(
+        "INSERT INTO relay_members (community_id, pubkey, role, added_by) \
+         VALUES ($1, $2, $3, 'invite') \
+         ON CONFLICT (community_id, pubkey) DO NOTHING",
+    )
+    .bind(community.as_uuid())
+    .bind(pubkey)
+    .bind(role)
+    .execute(&mut *tx)
+    .await?
+    .rows_affected()
+        > 0;
+
+    if let Some(version) = policy_version {
+        sqlx::query(
+            "INSERT INTO join_policy_acceptances (community_id, pubkey, policy_version) \
+             VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
+        )
+        .bind(community.as_uuid())
+        .bind(pubkey)
+        .bind(version)
+        .execute(&mut *tx)
+        .await?;
+    }
+
+    tx.commit().await?;
+    Ok(inserted)
+}
+
+/// Returns whether a member has persisted acceptance evidence for a policy version.
+pub async fn has_join_policy_acceptance(
+    pool: &PgPool,
+    community: CommunityId,
+    pubkey: &str,
+    policy_version: &str,
+) -> Result<bool> {
+    let row = sqlx::query(
+        "SELECT 1 FROM join_policy_acceptances \
+         WHERE community_id = $1 AND pubkey = $2 AND policy_version = $3",
+    )
+    .bind(community.as_uuid())
+    .bind(pubkey)
+    .bind(policy_version)
+    .fetch_optional(pool)
+    .await?;
+    Ok(row.is_some())
+}
+
 /// The result of a relay member removal attempt.
 #[derive(Debug, PartialEq)]
 pub enum RemoveResult {
@@ -544,6 +605,38 @@ mod tests {
         (community, owner)
     }
 
+    #[tokio::test]
+    #[ignore = "requires Postgres"]
+    async fn invite_claim_persists_policy_version_and_legacy_claim_does_not() {
+        let pool = setup_pool().await;
+        let community = make_test_community(&pool).await;
+        let policy_member = test_pubkey();
+        let legacy_member = test_pubkey();
+        let version = "a".repeat(64);
+
+        assert!(
+            claim_relay_membership(&pool, community, &policy_member, "member", Some(&version),)
+                .await
+                .expect("claim membership with policy")
+        );
+        assert!(
+            has_join_policy_acceptance(&pool, community, &policy_member, &version)
+                .await
+                .expect("policy acceptance lookup")
+        );
+
+        assert!(
+            claim_relay_membership(&pool, community, &legacy_member, "member", None)
+                .await
+                .expect("legacy claim membership")
+        );
+        assert!(
+            !has_join_policy_acceptance(&pool, community, &legacy_member, &version)
+                .await
+                .expect("legacy acceptance lookup")
+        );
+    }
+
     /// NIP-43 admission confinement: a pubkey admitted to community A is *not*
     /// admitted to community B. This is the exact mutation #1285 targets — a
     /// `WHERE pubkey = $1` membership check (no community predicate) would let an
diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs
index 3b09e2a388..7d004c08c9 100644
--- a/crates/buzz-relay/src/api/invites.rs
+++ b/crates/buzz-relay/src/api/invites.rs
@@ -54,6 +54,66 @@ pub struct MintInviteRequest {
 pub struct ClaimInviteRequest {
     /// The invite code to redeem.
     pub code: String,
+    /// Relay-issued proof of accepting the configured terms, when required.
+    #[serde(default)]
+    pub policy_receipt: Option<String>,
+}
+
+/// Body for `POST /api/invites/accept-policy`.
+#[derive(Debug, Deserialize)]
+pub struct AcceptPolicyRequest {
+    /// Invite code the acceptance receipt will be bound to.
+    pub code: String,
+    /// Policy revision displayed by the client.
+    pub policy_version: String,
+    /// Minimum-age assertion, required only when configured by the operator.
+    #[serde(default)]
+    pub age_confirmed: bool,
+}
+
+/// Public join policy shared by every client-side join surface.
+pub async fn join_policy(State(state): State<Arc<AppState>>) -> Json<Value> {
+    match &state.config.join_policy {
+        Some(policy) => Json(serde_json::json!({
+            "policy": {
+                "terms_markdown": policy.terms_markdown,
+                "privacy_markdown": policy.privacy_markdown,
+                "age_attestation_required": policy.age_attestation_required,
+                "version": policy.version
+            }
+        })),
+        None => Json(serde_json::json!({})),
+    }
+}
+
+/// Exchange explicit policy acceptance for a short-lived, invite-bound receipt.
+pub async fn accept_policy(
+    State(state): State<Arc<AppState>>,
+    body: axum::body::Bytes,
+) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
+    let Some(policy) = &state.config.join_policy else {
+        return Err(api_error(
+            StatusCode::NOT_FOUND,
+            "join_policy_not_configured",
+        ));
+    };
+    let request: AcceptPolicyRequest = serde_json::from_slice(&body).map_err(|e| {
+        api_error(
+            StatusCode::BAD_REQUEST,
+            &format!("invalid policy acceptance JSON: {e}"),
+        )
+    })?;
+    if request.policy_version != policy.version
+        || (policy.age_attestation_required && !request.age_confirmed)
+    {
+        return Err(api_error(
+            StatusCode::BAD_REQUEST,
+            "join_policy_not_accepted",
+        ));
+    }
+    let key = invite_token::derive_invite_key(&state.relay_keypair);
+    let receipt = invite_token::mint_policy_acceptance(&key, &request.code, &policy.version);
+    Ok(Json(serde_json::json!({ "receipt": receipt })))
 }
 
 /// Shared prelude: bind the tenant from the Host header and verify the NIP-98
@@ -186,9 +246,27 @@ pub async fn claim_invite(
     )?;
 
     let claimer_hex = pubkey.to_hex();
+    if let Some(policy) = &state.config.join_policy {
+        let receipt = request
+            .policy_receipt
+            .as_deref()
+            .ok_or_else(|| api_error(StatusCode::FORBIDDEN, "join_policy_required"))?;
+        invite_token::verify_policy_acceptance(&key, receipt, &request.code, &policy.version)
+            .map_err(|_| api_error(StatusCode::FORBIDDEN, "join_policy_required"))?;
+    }
+
     let was_inserted = state
         .db
-        .add_relay_member(tenant.community(), &claimer_hex, &payload.r, Some("invite"))
+        .claim_relay_membership(
+            tenant.community(),
+            &claimer_hex,
+            &payload.r,
+            state
+                .config
+                .join_policy
+                .as_ref()
+                .map(|policy| policy.version.as_str()),
+        )
         .await
         .map_err(|e| internal_error(&format!("invite claim insert: {e}")))?;
 
@@ -361,14 +439,17 @@ mod tests {
     /// a fresh community on `host`; returns `None` when Postgres is unavailable.
     async fn invite_test_state(host: &str) -> Option<Arc<AppState>> {
         let mut config = crate::config::Config::from_env().ok()?;
-        config.database_url = TEST_DB_URL.to_string();
+        let database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
+            .or_else(|_| std::env::var("DATABASE_URL"))
+            .unwrap_or_else(|_| TEST_DB_URL.to_string());
+        config.database_url = database_url.clone();
         config.redis_url = "redis://127.0.0.1:1".to_string();
         config.relay_url = format!("wss://{host}");
         // The claim route must work on relays where membership is enforced —
         // that is the entire point of an invite.
         config.require_relay_membership = true;
 
-        let pool = sqlx::PgPool::connect(TEST_DB_URL).await.ok()?;
+        let pool = sqlx::PgPool::connect(&database_url).await.ok()?;
         let db = buzz_db::Db::from_pool(pool.clone());
         db.ensure_configured_community(host).await.ok()?;
 
@@ -505,6 +586,215 @@ mod tests {
         );
     }
 
+    #[tokio::test]
+    #[ignore = "requires Postgres"]
+    async fn join_policy_gate_end_to_end() {
+        let host = format!("invites-policy-{}.example", Uuid::new_v4().simple());
+        let owner = Keys::generate();
+        let joiner = Keys::generate();
+        let Some(state) = invite_test_state(&host).await else {
+            return;
+        };
+        // Force the join policy on regardless of env.
+        let mut state_inner = (*state).clone();
+        let mut config = state_inner.config.as_ref().clone();
+        config.join_policy = Some(crate::config::JoinPolicyConfig {
+            terms_markdown: Some("# Terms".to_string()),
+            privacy_markdown: Some("# Privacy".to_string()),
+            age_attestation_required: true,
+            version: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
+        });
+        state_inner.config = Arc::new(config);
+        let state = Arc::new(state_inner);
+
+        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 an invite.
+        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")
+            .to_string();
+
+        // 1. Claim WITHOUT receipt -> 403 (checkbox bypass).
+        let response = post_json(
+            state.clone(),
+            &host,
+            "/api/invites/claim",
+            &joiner,
+            serde_json::json!({ "code": code }).to_string(),
+        )
+        .await;
+        assert_eq!(
+            response.status(),
+            StatusCode::FORBIDDEN,
+            "no-receipt claim must fail"
+        );
+
+        // 2. Forged receipt (wrong key) -> 403.
+        let forged = crate::invite_token::mint_policy_acceptance(
+            &[9u8; 32],
+            &code,
+            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+        );
+        let response = post_json(
+            state.clone(),
+            &host,
+            "/api/invites/claim",
+            &joiner,
+            serde_json::json!({ "code": code, "policy_receipt": forged }).to_string(),
+        )
+        .await;
+        assert_eq!(
+            response.status(),
+            StatusCode::FORBIDDEN,
+            "forged receipt must fail"
+        );
+
+        // 3. Receipt bound to a DIFFERENT invite code -> 403.
+        let key = crate::invite_token::derive_invite_key(&state.relay_keypair);
+        let other = crate::invite_token::mint_policy_acceptance(
+            &key,
+            "some-other-code",
+            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+        );
+        let response = post_json(
+            state.clone(),
+            &host,
+            "/api/invites/claim",
+            &joiner,
+            serde_json::json!({ "code": code, "policy_receipt": other }).to_string(),
+        )
+        .await;
+        assert_eq!(
+            response.status(),
+            StatusCode::FORBIDDEN,
+            "cross-invite receipt must fail"
+        );
+
+        // 4. Receipt for a STALE policy version -> 403.
+        let stale = crate::invite_token::mint_policy_acceptance(
+            &key,
+            &code,
+            "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+        );
+        let response = post_json(
+            state.clone(),
+            &host,
+            "/api/invites/claim",
+            &joiner,
+            serde_json::json!({ "code": code, "policy_receipt": stale }).to_string(),
+        )
+        .await;
+        assert_eq!(
+            response.status(),
+            StatusCode::FORBIDDEN,
+            "stale-version receipt must fail"
+        );
+
+        // 5. accept-policy without age confirmation -> 400.
+        let response = post_json(
+            state.clone(),
+            &host,
+            "/api/invites/accept-policy",
+            &joiner,
+            serde_json::json!({ "code": code, "policy_version": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "age_confirmed": false })
+                .to_string(),
+        )
+        .await;
+        assert_eq!(
+            response.status(),
+            StatusCode::BAD_REQUEST,
+            "age not confirmed must be rejected when required"
+        );
+
+        // 5b. accept-policy with stale version -> 400.
+        let response = post_json(
+            state.clone(),
+            &host,
+            "/api/invites/accept-policy",
+            &joiner,
+            serde_json::json!({ "code": code, "policy_version": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "age_confirmed": true })
+                .to_string(),
+        )
+        .await;
+        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
+
+        // 6. Legit flow: accept-policy -> receipt -> claim OK.
+        let response = post_json(
+            state.clone(),
+            &host,
+            "/api/invites/accept-policy",
+            &joiner,
+            serde_json::json!({ "code": code, "policy_version": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "age_confirmed": true })
+                .to_string(),
+        )
+        .await;
+        assert_eq!(response.status(), StatusCode::OK);
+        let receipt = read_json(response)
+            .await
+            .get("receipt")
+            .and_then(Value::as_str)
+            .expect("receipt")
+            .to_string();
+
+        let response = post_json(
+            state.clone(),
+            &host,
+            "/api/invites/claim",
+            &joiner,
+            serde_json::json!({ "code": code, "policy_receipt": receipt }).to_string(),
+        )
+        .await;
+        assert_eq!(
+            response.status(),
+            StatusCode::OK,
+            "legit receipt claim must succeed"
+        );
+        let json = read_json(response).await;
+        assert_eq!(json.get("status").and_then(Value::as_str), Some("joined"));
+
+        let member = state
+            .db
+            .get_relay_member(community.id, &joiner.public_key().to_hex())
+            .await
+            .expect("member lookup")
+            .expect("joiner is now a member");
+        assert_eq!(member.role, "member");
+        assert!(
+            state
+                .db
+                .has_join_policy_acceptance(
+                    community.id,
+                    &joiner.public_key().to_hex(),
+                    "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+                )
+                .await
+                .expect("policy acceptance lookup"),
+            "accepted policy version must be persisted",
+        );
+    }
+
     #[tokio::test]
     #[ignore = "requires Postgres"]
     async fn non_admin_cannot_mint() {
diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs
index d72654dbfa..b9a876bc0b 100644
--- a/crates/buzz-relay/src/config.rs
+++ b/crates/buzz-relay/src/config.rs
@@ -3,6 +3,7 @@
 use std::net::SocketAddr;
 use std::time::Duration;
 
+use sha2::{Digest, Sha256};
 use thiserror::Error;
 use tracing::warn;
 
@@ -23,6 +24,19 @@ pub enum ConfigError {
     InvalidValue(String),
 }
 
+/// Relay-hosted policy content presented on join surfaces.
+#[derive(Debug, Clone)]
+pub struct JoinPolicyConfig {
+    /// Operator-provided Terms of Service document in Markdown.
+    pub terms_markdown: Option<String>,
+    /// Operator-provided Privacy Policy document in Markdown.
+    pub privacy_markdown: Option<String>,
+    /// Whether join surfaces must collect an 18+ attestation.
+    pub age_attestation_required: bool,
+    /// Content-derived identifier binding receipts to the exact policy revision.
+    pub version: String,
+}
+
 /// Relay runtime configuration, loaded from environment variables.
 #[derive(Debug, Clone)]
 pub struct Config {
@@ -197,6 +211,10 @@ pub struct Config {
     /// Hard timeout for one gateway delivery request.
     pub push_gateway_timeout: Duration,
 
+    /// Optional relay-hosted policy shown on join surfaces. Disabled when no
+    /// documents or age attestation are configured.
+    pub join_policy: Option<JoinPolicyConfig>,
+
     /// Optional path to the web UI `dist/` directory.
     /// When set, the relay serves the invite landing page and its static assets.
     /// When unset, no static file serving happens (relay behaves as before).
@@ -256,6 +274,22 @@ fn parse_push_gateway_delivery_url(raw: &str) -> Result<url::Url, ConfigError> {
     Ok(url)
 }
 
+fn parse_optional_bool(name: &str) -> Result<bool, ConfigError> {
+    match std::env::var(name) {
+        Err(std::env::VarError::NotPresent) => Ok(false),
+        Err(error) => Err(ConfigError::InvalidValue(format!(
+            "{name} must be valid UTF-8: {error}"
+        ))),
+        Ok(value) => match value.trim().to_ascii_lowercase().as_str() {
+            "true" | "1" | "on" => Ok(true),
+            "false" | "0" | "off" | "" => Ok(false),
+            _ => Err(ConfigError::InvalidValue(format!(
+                "{name} must be true or false"
+            ))),
+        },
+    }
+}
+
 fn ensure_git_repo_path(
     raw: impl Into<std::path::PathBuf>,
 ) -> Result<std::path::PathBuf, ConfigError> {
@@ -602,6 +636,44 @@ impl Config {
         };
         let push_gateway_timeout = Duration::from_millis(push_gateway_timeout_millis);
 
+        const MAX_POLICY_MARKDOWN_BYTES: usize = 256 * 1024;
+        let read_policy_markdown = |name: &str| -> Result<Option<String>, ConfigError> {
+            let value = std::env::var(name)
+                .ok()
+                .map(|value| value.trim().to_string())
+                .filter(|value| !value.is_empty());
+            if value
+                .as_ref()
+                .is_some_and(|value| value.len() > MAX_POLICY_MARKDOWN_BYTES)
+            {
+                return Err(ConfigError::InvalidValue(format!(
+                    "{name} must contain at most {MAX_POLICY_MARKDOWN_BYTES} bytes"
+                )));
+            }
+            Ok(value)
+        };
+        let terms_markdown = read_policy_markdown("BUZZ_TERMS_OF_SERVICE_MARKDOWN")?;
+        let privacy_markdown = read_policy_markdown("BUZZ_PRIVACY_POLICY_MARKDOWN")?;
+        let age_attestation_required = parse_optional_bool("BUZZ_AGE_ATTESTATION_REQUIRED")?;
+        let join_policy = if terms_markdown.is_none()
+            && privacy_markdown.is_none()
+            && !age_attestation_required
+        {
+            None
+        } else {
+            let mut hasher = Sha256::new();
+            hasher.update(terms_markdown.as_deref().unwrap_or_default().as_bytes());
+            hasher.update([0]);
+            hasher.update(privacy_markdown.as_deref().unwrap_or_default().as_bytes());
+            hasher.update([0, u8::from(age_attestation_required)]);
+            Some(JoinPolicyConfig {
+                terms_markdown,
+                privacy_markdown,
+                age_attestation_required,
+                version: hex::encode(hasher.finalize()),
+            })
+        };
+
         // Web UI static file serving
         let web_dir = std::env::var("BUZZ_WEB_DIR")
             .ok()
@@ -673,6 +745,7 @@ impl Config {
             push_executor_key_id,
             push_gateway_delivery_url,
             push_gateway_timeout,
+            join_policy,
             web_dir,
             serve_git_web_gui,
         })
@@ -729,6 +802,24 @@ mod tests {
         );
     }
 
+    #[test]
+    fn join_policy_age_attestation_rejects_invalid_boolean() {
+        let _guard = ENV_MUTEX.lock().unwrap();
+        let previous = std::env::var_os("BUZZ_AGE_ATTESTATION_REQUIRED");
+        std::env::set_var("BUZZ_AGE_ATTESTATION_REQUIRED", "sometimes");
+        let result = parse_optional_bool("BUZZ_AGE_ATTESTATION_REQUIRED");
+        if let Some(value) = previous {
+            std::env::set_var("BUZZ_AGE_ATTESTATION_REQUIRED", value);
+        } else {
+            std::env::remove_var("BUZZ_AGE_ATTESTATION_REQUIRED");
+        }
+        assert!(matches!(
+            result,
+            Err(ConfigError::InvalidValue(ref message))
+                if message.contains("BUZZ_AGE_ATTESTATION_REQUIRED")
+        ));
+    }
+
     #[test]
     fn relay_operator_pubkeys_parse_dedupe_and_normalize() {
         let _guard = ENV_MUTEX.lock().unwrap();
diff --git a/crates/buzz-relay/src/invite_token.rs b/crates/buzz-relay/src/invite_token.rs
index dda3c495ee..436040c32a 100644
--- a/crates/buzz-relay/src/invite_token.rs
+++ b/crates/buzz-relay/src/invite_token.rs
@@ -327,3 +327,79 @@ mod tests {
         assert_eq!(verify_invite(&key, c, &code), Err(InviteError::InvalidRole));
     }
 }
+
+/// Short-lived proof that the browser accepted the configured join policy.
+#[derive(Debug, Serialize, Deserialize)]
+pub struct PolicyAcceptancePayload {
+    /// SHA-256 of the invite code this acceptance is bound to.
+    pub c: String,
+    /// Configured policy version.
+    pub v: String,
+    /// Receipt expiry (unix seconds).
+    pub e: u64,
+}
+
+/// Mint a relay-authenticated, invite-bound policy acceptance receipt.
+pub fn mint_policy_acceptance(key: &[u8; 32], code: &str, version: &str) -> String {
+    let payload = PolicyAcceptancePayload {
+        c: hex::encode(Sha256::digest(code.as_bytes())),
+        v: version.to_string(),
+        e: now_unix() + 10 * 60,
+    };
+    let bytes = serde_json::to_vec(&payload).expect("policy acceptance serializes");
+    format!(
+        "{}.{}",
+        URL_SAFE_NO_PAD.encode(&bytes),
+        URL_SAFE_NO_PAD.encode(sign_payload(key, &bytes))
+    )
+}
+
+/// Verify a policy receipt and bind it to the submitted invite and current policy.
+pub fn verify_policy_acceptance(
+    key: &[u8; 32],
+    receipt: &str,
+    code: &str,
+    version: &str,
+) -> Result<PolicyAcceptancePayload, InviteError> {
+    if receipt.len() > 2048 {
+        return Err(InviteError::Malformed);
+    }
+    let (payload, signature) = receipt.split_once('.').ok_or(InviteError::Malformed)?;
+    let bytes = URL_SAFE_NO_PAD
+        .decode(payload)
+        .map_err(|_| InviteError::Malformed)?;
+    let signature = URL_SAFE_NO_PAD
+        .decode(signature)
+        .map_err(|_| InviteError::Malformed)?;
+    let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key size");
+    mac.update(&bytes);
+    mac.verify_slice(&signature)
+        .map_err(|_| InviteError::BadSignature)?;
+    let payload: PolicyAcceptancePayload =
+        serde_json::from_slice(&bytes).map_err(|_| InviteError::Malformed)?;
+    if payload.e < now_unix() {
+        return Err(InviteError::Expired);
+    }
+    let expected_code = hex::encode(Sha256::digest(code.as_bytes()));
+    if payload.c != expected_code || payload.v != version {
+        return Err(InviteError::Malformed);
+    }
+    Ok(payload)
+}
+
+#[cfg(test)]
+mod policy_acceptance_tests {
+    use super::*;
+
+    #[test]
+    fn policy_receipt_is_bound_to_invite_and_version() {
+        let key = [7_u8; 32];
+        let receipt = mint_policy_acceptance(&key, "invite-a", "v1");
+        let payload =
+            verify_policy_acceptance(&key, &receipt, "invite-a", "v1").expect("valid receipt");
+        assert_eq!(payload.v, "v1");
+        assert!(verify_policy_acceptance(&key, &receipt, "invite-b", "v1").is_err());
+        assert!(verify_policy_acceptance(&key, &receipt, "invite-a", "v2").is_err());
+        assert!(verify_policy_acceptance(&[8_u8; 32], &receipt, "invite-a", "v1").is_err());
+    }
+}
diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs
index bb8de55569..0036a47af3 100644
--- a/crates/buzz-relay/src/router.rs
+++ b/crates/buzz-relay/src/router.rs
@@ -83,6 +83,11 @@ pub fn build_router(state: Arc<AppState>) -> Router {
         )
         // Relay invites: mint (owner/admin) + claim (membership-gate exempt)
         .route("/api/invites", post(api::invites::mint_invite))
+        .route("/api/join-policy", get(api::invites::join_policy))
+        .route(
+            "/api/invites/accept-policy",
+            post(api::invites::accept_policy),
+        )
         .route("/api/invites/claim", post(api::invites::claim_invite))
         // Moderation queue reads (NIP-98 auth + mod-authz gate, L6)
         .route("/moderation/reports", get(api::bridge::moderation_reports))
diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs
index f90d2f390a..2a542357d5 100644
--- a/desktop/src-tauri/src/deep_link.rs
+++ b/desktop/src-tauri/src/deep_link.rs
@@ -59,6 +59,7 @@ fn parse_message_deep_link(url: &Url) -> Option<serde_json::Value> {
 fn parse_join_deep_link(url: &Url) -> Option<serde_json::Value> {
     let mut relay: Option<String> = None;
     let mut code: Option<String> = None;
+    let mut policy_receipt: Option<String> = None;
     for (k, v) in url.query_pairs() {
         let v = v.into_owned();
         if v.is_empty() {
@@ -67,6 +68,7 @@ fn parse_join_deep_link(url: &Url) -> Option<serde_json::Value> {
         match k.as_ref() {
             "relay" => relay = Some(v),
             "code" => code = Some(v),
+            "policy_receipt" => policy_receipt = Some(v),
             _ => {}
         }
     }
@@ -78,6 +80,7 @@ fn parse_join_deep_link(url: &Url) -> Option<serde_json::Value> {
     Some(serde_json::json!({
         "relayUrl": relay_url,
         "code": code,
+        "policyReceipt": policy_receipt,
     }))
 }
 
@@ -337,6 +340,17 @@ mod tests {
         let payload = parse_join_deep_link(&url).expect("required params present");
         assert_eq!(payload["relayUrl"], "wss://relay.example");
         assert_eq!(payload["code"], "abc.def");
+        assert!(payload["policyReceipt"].is_null());
+    }
+
+    #[test]
+    fn parse_join_deep_link_extracts_policy_receipt() {
+        let url = Url::parse(
+            "buzz://join?relay=wss%3A%2F%2Frelay.example&code=abc.def&policy_receipt=receipt.value",
+        )
+        .unwrap();
+        let payload = parse_join_deep_link(&url).expect("required params present");
+        assert_eq!(payload["policyReceipt"], "receipt.value");
     }
 
     #[test]
diff --git a/desktop/src/features/communities/ui/AddCommunityDialog.tsx b/desktop/src/features/communities/ui/AddCommunityDialog.tsx
index b3ac2116ec..9c2b8f0913 100644
--- a/desktop/src/features/communities/ui/AddCommunityDialog.tsx
+++ b/desktop/src/features/communities/ui/AddCommunityDialog.tsx
@@ -10,7 +10,12 @@ import {
   inviteErrorMessage,
   isInviteExpiredError,
 } from "@/shared/api/inviteHelpers";
-import { claimInvite } from "@/shared/api/invites";
+import {
+  acceptJoinPolicy,
+  claimInvite,
+  getJoinPolicy,
+  type JoinPolicy,
+} from "@/shared/api/invites";
 import { validateReposDir } from "@/shared/api/tauri";
 import { Button } from "@/shared/ui/button";
 import {
@@ -21,6 +26,7 @@ import {
   DialogTitle,
 } from "@/shared/ui/dialog";
 import { Input } from "@/shared/ui/input";
+import { JoinPolicyNotice } from "@/features/onboarding/ui/JoinPolicyNotice";
 
 type AddCommunityDialogProps = {
   open: boolean;
@@ -38,6 +44,8 @@ export function AddCommunityDialog({
   const [token, setToken] = React.useState("");
   const [inviteCode, setInviteCode] = React.useState("");
   const [inviteError, setInviteError] = React.useState<string | null>(null);
+  const [joinPolicy, setJoinPolicy] = React.useState<JoinPolicy | null>(null);
+  const [ageConfirmed, setAgeConfirmed] = React.useState(false);
   const [reposDir, setReposDir] = React.useState("");
   const [reposDirError, setReposDirError] = React.useState<string | null>(null);
 
@@ -48,6 +56,8 @@ export function AddCommunityDialog({
     setToken("");
     setInviteCode("");
     setInviteError(null);
+    setJoinPolicy(null);
+    setAgeConfirmed(false);
     setReposDir("");
     setReposDirError(null);
   }, [onOpenChange]);
@@ -71,21 +81,45 @@ export function AddCommunityDialog({
         return;
       }
 
-      // If the relay handed out an invite code, claim it before saving the
-      // community — a closed relay would otherwise reject the connection.
       const normalizedRelayUrl = normalizeRelayUrl(relayUrl.trim());
-      if (inviteCode.trim()) {
-        try {
-          await claimInvite(normalizedRelayUrl, inviteCode.trim());
-        } catch (error) {
-          const message = inviteErrorMessage(error);
-          setInviteError(
-            isInviteExpiredError(error)
-              ? "This invite code has expired — ask for a new one."
-              : `Invite rejected: ${message}`,
-          );
+      try {
+        const policy = await getJoinPolicy(normalizedRelayUrl);
+        if (policy && (!joinPolicy || joinPolicy.version !== policy.version)) {
+          setJoinPolicy(policy);
+          setAgeConfirmed(false);
+          setInviteError("Review this relay's join policy below.");
+          return;
+        }
+        if (policy?.ageAttestationRequired && !ageConfirmed) {
+          setInviteError("Confirm that you are at least 18 years old.");
           return;
         }
+
+        // If the relay handed out an invite code, claim it before saving the
+        // community — a closed relay would otherwise reject the connection.
+        if (inviteCode.trim()) {
+          const policyReceipt = policy
+            ? await acceptJoinPolicy(
+                normalizedRelayUrl,
+                inviteCode.trim(),
+                policy.version,
+                ageConfirmed,
+              )
+            : undefined;
+          await claimInvite(
+            normalizedRelayUrl,
+            inviteCode.trim(),
+            policyReceipt,
+          );
+        }
+      } catch (error) {
+        const message = inviteErrorMessage(error);
+        setInviteError(
+          isInviteExpiredError(error)
+            ? "This invite code has expired — ask for a new one."
+            : `Community rejected: ${message}`,
+        );
+        return;
       }
 
       const community: Community = {
@@ -100,7 +134,17 @@ export function AddCommunityDialog({
       onSubmit(community);
       handleClose();
     },
-    [name, relayUrl, token, inviteCode, reposDir, onSubmit, handleClose],
+    [
+      name,
+      relayUrl,
+      token,
+      inviteCode,
+      reposDir,
+      joinPolicy,
+      ageConfirmed,
+      onSubmit,
+      handleClose,
+    ],
   );
 
   return (
@@ -127,7 +171,12 @@ export function AddCommunityDialog({
             <Input
               autoFocus
               id="ws-relay-url"
-              onChange={(e) => setRelayUrl(e.target.value)}
+              onChange={(e) => {
+                setRelayUrl(e.target.value);
+                setInviteError(null);
+                setJoinPolicy(null);
+                setAgeConfirmed(false);
+              }}
               placeholder="wss://relay.example.com"
               type="text"
               value={relayUrl}
@@ -184,6 +233,8 @@ export function AddCommunityDialog({
               onChange={(e) => {
                 setInviteCode(e.target.value);
                 setInviteError(null);
+                setJoinPolicy(null);
+                setAgeConfirmed(false);
               }}
               placeholder="Paste an invite code for a members-only relay"
               type="text"
@@ -192,6 +243,16 @@ export function AddCommunityDialog({
             {inviteError ? (
               <p className="text-xs text-destructive">{inviteError}</p>
             ) : null}
+            {joinPolicy ? (
+              <JoinPolicyNotice
+                ageConfirmed={ageConfirmed}
+                onAgeConfirmedChange={(confirmed) => {
+                  setAgeConfirmed(confirmed);
+                  setInviteError(null);
+                }}
+                policy={joinPolicy}
+              />
+            ) : null}
           </div>
           <div className="flex flex-col gap-1.5">
             <label
@@ -230,7 +291,13 @@ export function AddCommunityDialog({
             <Button onClick={handleClose} type="button" variant="outline">
               Cancel
             </Button>
-            <Button disabled={!relayUrl.trim()} type="submit">
+            <Button
+              disabled={
+                !relayUrl.trim() ||
+                Boolean(joinPolicy?.ageAttestationRequired && !ageConfirmed)
+              }
+              type="submit"
+            >
               Add Community
             </Button>
           </div>
diff --git a/desktop/src/features/communities/ui/CommunityEditForm.tsx b/desktop/src/features/communities/ui/CommunityEditForm.tsx
index e0d47b2f78..7365ebf2b0 100644
--- a/desktop/src/features/communities/ui/CommunityEditForm.tsx
+++ b/desktop/src/features/communities/ui/CommunityEditForm.tsx
@@ -1,7 +1,10 @@
 import * as React from "react";
+import { inviteErrorMessage } from "@/shared/api/inviteHelpers";
+import { getJoinPolicy, type JoinPolicy } from "@/shared/api/invites";
 import { Button } from "@/shared/ui/button";
 import { Input } from "@/shared/ui/input";
 import { Spinner } from "@/shared/ui/spinner";
+import { JoinPolicyNotice } from "@/features/onboarding/ui/JoinPolicyNotice";
 import { normalizeRelayUrl, probeRelayReachable } from "../relayProbe";
 
 export type CommunityEditFormProps = {
@@ -9,6 +12,7 @@ export type CommunityEditFormProps = {
   initialName: string;
   initialRelayUrl: string;
   isSubmitting?: boolean;
+  joinPolicyRequired?: boolean;
   onCancel: () => void;
   onSubmit: (name: string, relayUrl: string) => void;
   submitLabel: string;
@@ -19,6 +23,7 @@ export function CommunityEditForm({
   initialName,
   initialRelayUrl,
   isSubmitting = false,
+  joinPolicyRequired = false,
   onCancel,
   onSubmit,
   submitLabel,
@@ -29,6 +34,11 @@ export function CommunityEditForm({
   const [probeWarning, setProbeWarning] = React.useState<string | null>(null);
   const [isProbing, setIsProbing] = React.useState(false);
   const [useAnywayOverride, setUseAnywayOverride] = React.useState(false);
+  const [joinPolicy, setJoinPolicy] = React.useState<JoinPolicy | null>(null);
+  const [policyRelayUrl, setPolicyRelayUrl] = React.useState<string | null>(
+    null,
+  );
+  const [ageConfirmed, setAgeConfirmed] = React.useState(false);
 
   const cancelRef = React.useRef<(() => void) | null>(null);
 
@@ -52,6 +62,33 @@ export function CommunityEditForm({
         return;
       }
 
+      if (joinPolicyRequired) {
+        try {
+          const policy = await getJoinPolicy(normalizedUrl);
+          if (!policy) {
+            onSubmit(trimmedName, normalizedUrl);
+            return;
+          }
+          if (
+            !joinPolicy ||
+            joinPolicy.version !== policy.version ||
+            policyRelayUrl !== normalizedUrl
+          ) {
+            setJoinPolicy(policy);
+            setPolicyRelayUrl(normalizedUrl);
+            setAgeConfirmed(false);
+            return;
+          }
+          if (policy.ageAttestationRequired && !ageConfirmed) {
+            setError("Confirm that you are at least 18 years old.");
+            return;
+          }
+        } catch (policyError) {
+          setError(inviteErrorMessage(policyError));
+          return;
+        }
+      }
+
       if (useAnywayOverride) {
         onSubmit(trimmedName, normalizedUrl);
         return;
@@ -79,7 +116,16 @@ export function CommunityEditForm({
 
       onSubmit(trimmedName, normalizedUrl);
     },
-    [name, onSubmit, relayUrl, useAnywayOverride],
+    [
+      ageConfirmed,
+      joinPolicy,
+      joinPolicyRequired,
+      name,
+      onSubmit,
+      policyRelayUrl,
+      relayUrl,
+      useAnywayOverride,
+    ],
   );
 
   const handleUseAnyway = React.useCallback(() => {
@@ -139,6 +185,9 @@ export function CommunityEditForm({
             setError(null);
             setProbeWarning(null);
             setUseAnywayOverride(false);
+            setJoinPolicy(null);
+            setPolicyRelayUrl(null);
+            setAgeConfirmed(false);
           }}
           placeholder="wss://relay.example.com"
           type="text"
@@ -146,10 +195,26 @@ export function CommunityEditForm({
         />
       </div>
 
+      {joinPolicy ? (
+        <JoinPolicyNotice
+          ageConfirmed={ageConfirmed}
+          onAgeConfirmedChange={(confirmed) => {
+            setAgeConfirmed(confirmed);
+            setError(null);
+          }}
+          policy={joinPolicy}
+        />
+      ) : null}
+
       <div className="flex w-full flex-col gap-3 pt-1">
         <Button
           className="h-10 w-full"
-          disabled={isBusy || !name.trim() || !relayUrl.trim()}
+          disabled={
+            isBusy ||
+            !name.trim() ||
+            !relayUrl.trim() ||
+            Boolean(joinPolicy?.ageAttestationRequired && !ageConfirmed)
+          }
           type="submit"
         >
           {isProbing ? (
diff --git a/desktop/src/features/communities/ui/WelcomeSetup.tsx b/desktop/src/features/communities/ui/WelcomeSetup.tsx
index 6d6b4c13fc..61cbf1a5b3 100644
--- a/desktop/src/features/communities/ui/WelcomeSetup.tsx
+++ b/desktop/src/features/communities/ui/WelcomeSetup.tsx
@@ -5,9 +5,14 @@ import {
   getIdentity,
   importIdentity as tauriImportIdentity,
 } from "@/shared/api/tauriIdentity";
-import { claimInvite } from "@/shared/api/invites";
+import {
+  claimInvite,
+  getJoinPolicy,
+  type JoinPolicy,
+} from "@/shared/api/invites";
 import { inviteErrorMessage } from "@/shared/api/inviteHelpers";
 import { InviteRedeemForm } from "@/features/onboarding/ui/InviteRedeemForm";
+import { JoinPolicyNotice } from "@/features/onboarding/ui/JoinPolicyNotice";
 import { NostrKeyImportForm } from "@/features/onboarding/ui/NostrKeyImportForm";
 import {
   type OnboardingTransitionDirection,
@@ -48,13 +53,19 @@ function wait(ms: number) {
 }
 
 function NostrKeyImportPage({
+  ageConfirmed,
   connectionError,
   disabled,
+  joinPolicy,
+  onAgeConfirmedChange,
   onBack,
   onImport,
 }: {
+  ageConfirmed: boolean;
   connectionError: string | null;
   disabled: boolean;
+  joinPolicy: JoinPolicy | null | undefined;
+  onAgeConfirmedChange: (confirmed: boolean) => void;
   onBack: () => void;
   onImport: (nsec: string) => Promise<void>;
 }) {
@@ -75,8 +86,22 @@ function NostrKeyImportPage({
         </p>
       </div>
 
+      {joinPolicy ? (
+        <div className="mt-6 w-full">
+          <JoinPolicyNotice
+            ageConfirmed={ageConfirmed}
+            onAgeConfirmedChange={onAgeConfirmedChange}
+            policy={joinPolicy}
+          />
+        </div>
+      ) : null}
+
       <NostrKeyImportForm
-        disabled={disabled}
+        disabled={
+          disabled ||
+          joinPolicy === undefined ||
+          Boolean(joinPolicy?.ageAttestationRequired && !ageConfirmed)
+        }
         errorMessage={connectionError}
         onBack={onBack}
         onImport={onImport}
@@ -97,8 +122,36 @@ export function WelcomeSetup({
   const [error, setError] = React.useState<string | null>(null);
   const [isRedeeming, setIsRedeeming] = React.useState(false);
   const [inviteError, setInviteError] = React.useState<string | null>(null);
+  const [defaultJoinPolicy, setDefaultJoinPolicy] = React.useState<
+    JoinPolicy | null | undefined
+  >(undefined);
+  const [defaultAgeConfirmed, setDefaultAgeConfirmed] = React.useState(false);
   const systemColorScheme = useSystemColorScheme();
 
+  React.useEffect(() => {
+    let cancelled = false;
+    setDefaultAgeConfirmed(false);
+    if (isLocalDevRelayUrl(defaultRelayUrl)) {
+      setDefaultJoinPolicy(null);
+      return;
+    }
+    setDefaultJoinPolicy(undefined);
+    void getJoinPolicy(defaultRelayUrl)
+      .then((policy) => {
+        if (!cancelled) {
+          setDefaultJoinPolicy(policy);
+        }
+      })
+      .catch((policyError) => {
+        if (!cancelled) {
+          setError(inviteErrorMessage(policyError));
+        }
+      });
+    return () => {
+      cancelled = true;
+    };
+  }, [defaultRelayUrl]);
+
   const handleConnect = React.useCallback(
     async (relayUrl: string, communityName?: string, pubkey?: string) => {
       const trimmedUrl = relayUrl.trim();
@@ -159,11 +212,11 @@ export function WelcomeSetup({
   );
 
   const handleWelcomeInviteRedeem = React.useCallback(
-    async (relayWsUrl: string, code: string) => {
+    async (relayWsUrl: string, code: string, policyReceipt?: string) => {
       setIsRedeeming(true);
       setInviteError(null);
       try {
-        await claimInvite(relayWsUrl, code);
+        await claimInvite(relayWsUrl, code, policyReceipt);
         await handleConnect(relayWsUrl);
       } catch (err) {
         setInviteError(inviteErrorMessage(err));
@@ -249,10 +302,24 @@ export function WelcomeSetup({
             </p>
 
             <div className="mt-8 flex w-full flex-col gap-3">
+              {defaultJoinPolicy ? (
+                <JoinPolicyNotice
+                  ageConfirmed={defaultAgeConfirmed}
+                  onAgeConfirmedChange={setDefaultAgeConfirmed}
+                  policy={defaultJoinPolicy}
+                />
+              ) : null}
               {isLocalDevRelayUrl(defaultRelayUrl) ? null : (
                 <Button
                   className="h-10 w-full"
                   aria-disabled={isConnecting}
+                  disabled={
+                    defaultJoinPolicy === undefined ||
+                    Boolean(
+                      defaultJoinPolicy?.ageAttestationRequired &&
+                        !defaultAgeConfirmed,
+                    )
+                  }
                   onClick={() => {
                     if (isConnecting) {
                       return;
@@ -341,6 +408,7 @@ export function WelcomeSetup({
                 initialName=""
                 initialRelayUrl=""
                 isSubmitting={isConnecting}
+                joinPolicyRequired
                 onCancel={showWelcomePage}
                 onSubmit={(name, url) => {
                   void handleConnect(url, name);
@@ -380,16 +448,23 @@ export function WelcomeSetup({
                 error={inviteError}
                 isRedeeming={isRedeeming}
                 onCancel={showWelcomePage}
-                onRedeem={(relayWsUrl, code) => {
-                  void handleWelcomeInviteRedeem(relayWsUrl, code);
+                onRedeem={(relayWsUrl, code, policyReceipt) => {
+                  void handleWelcomeInviteRedeem(
+                    relayWsUrl,
+                    code,
+                    policyReceipt,
+                  );
                 }}
               />
             </div>
           </OnboardingSlideTransition>
         ) : (
           <NostrKeyImportPage
+            ageConfirmed={defaultAgeConfirmed}
             connectionError={error}
             disabled={isConnecting}
+            joinPolicy={defaultJoinPolicy}
+            onAgeConfirmedChange={setDefaultAgeConfirmed}
             onBack={showWelcomePage}
             onImport={handleNostrImport}
           />
diff --git a/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx b/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx
index 5e8f36a423..e6541eafce 100644
--- a/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx
+++ b/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx
@@ -1,9 +1,18 @@
 import * as React from "react";
 
-import { parseInviteInput } from "@/shared/api/inviteHelpers";
+import {
+  inviteErrorMessage,
+  parseInviteInput,
+} from "@/shared/api/inviteHelpers";
+import {
+  acceptJoinPolicy,
+  getJoinPolicy,
+  type JoinPolicy,
+} from "@/shared/api/invites";
 import { Button } from "@/shared/ui/button";
 import { Input } from "@/shared/ui/input";
 import { Spinner } from "@/shared/ui/spinner";
+import { JoinPolicyNotice } from "./JoinPolicyNotice";
 
 type InviteRedeemFormProps = {
   /**
@@ -16,7 +25,7 @@ type InviteRedeemFormProps = {
   error: string | null;
   isRedeeming: boolean;
   onCancel: () => void;
-  onRedeem: (relayWsUrl: string, code: string) => void;
+  onRedeem: (relayWsUrl: string, code: string, policyReceipt?: string) => void;
 };
 
 export function InviteRedeemForm({
@@ -30,6 +39,14 @@ export function InviteRedeemForm({
   const [bareCodeRelayUrl, setBareCodeRelayUrl] = React.useState(
     defaultRelayUrl ?? "",
   );
+  const [joinPolicy, setJoinPolicy] = React.useState<JoinPolicy | null>(null);
+  const [policyInvite, setPolicyInvite] = React.useState<{
+    relayWsUrl: string;
+    code: string;
+  } | null>(null);
+  const [ageConfirmed, setAgeConfirmed] = React.useState(false);
+  const [policyError, setPolicyError] = React.useState<string | null>(null);
+  const [isLoadingPolicy, setIsLoadingPolicy] = React.useState(false);
 
   const parsed = React.useMemo(
     () => parseInviteInput(inviteInput),
@@ -44,17 +61,61 @@ export function InviteRedeemForm({
       (isBareCode && bareCodeRelayUrl.trim().length > 0));
 
   const handleSubmit = React.useCallback(
-    (event: React.FormEvent) => {
+    async (event: React.FormEvent) => {
       event.preventDefault();
       if (!parsed) return;
 
-      if ("relayWsUrl" in parsed) {
-        onRedeem(parsed.relayWsUrl, parsed.code);
-      } else if (bareCodeRelayUrl.trim()) {
-        onRedeem(bareCodeRelayUrl.trim(), parsed.code);
+      const relayWsUrl =
+        "relayWsUrl" in parsed ? parsed.relayWsUrl : bareCodeRelayUrl.trim();
+      if (!relayWsUrl) return;
+
+      setPolicyError(null);
+      setIsLoadingPolicy(true);
+      try {
+        const policy = await getJoinPolicy(relayWsUrl);
+        if (!policy) {
+          onRedeem(relayWsUrl, parsed.code);
+          return;
+        }
+
+        if (
+          !joinPolicy ||
+          joinPolicy.version !== policy.version ||
+          policyInvite?.relayWsUrl !== relayWsUrl ||
+          policyInvite.code !== parsed.code
+        ) {
+          setJoinPolicy(policy);
+          setPolicyInvite({ relayWsUrl, code: parsed.code });
+          setAgeConfirmed(false);
+          return;
+        }
+
+        if (policy.ageAttestationRequired && !ageConfirmed) {
+          setPolicyError("Confirm that you are at least 18 years old.");
+          return;
+        }
+
+        const receipt = await acceptJoinPolicy(
+          relayWsUrl,
+          parsed.code,
+          policy.version,
+          ageConfirmed,
+        );
+        onRedeem(relayWsUrl, parsed.code, receipt);
+      } catch (policyFetchError) {
+        setPolicyError(inviteErrorMessage(policyFetchError));
+      } finally {
+        setIsLoadingPolicy(false);
       }
     },
-    [bareCodeRelayUrl, onRedeem, parsed],
+    [
+      ageConfirmed,
+      bareCodeRelayUrl,
+      joinPolicy,
+      onRedeem,
+      parsed,
+      policyInvite,
+    ],
   );
 
   return (
@@ -74,7 +135,13 @@ export function InviteRedeemForm({
           data-testid="invite-redeem-input"
           disabled={isRedeeming}
           id="invite-input"
-          onChange={(event) => setInviteInput(event.target.value)}
+          onChange={(event) => {
+            setInviteInput(event.target.value);
+            setJoinPolicy(null);
+            setPolicyInvite(null);
+            setAgeConfirmed(false);
+            setPolicyError(null);
+          }}
           placeholder="https://relay.example.com/invite/abc123 or paste a code"
           spellCheck={false}
           type="text"
@@ -94,7 +161,13 @@ export function InviteRedeemForm({
             className="h-10 bg-background"
             disabled={isRedeeming}
             id="invite-relay-url"
-            onChange={(event) => setBareCodeRelayUrl(event.target.value)}
+            onChange={(event) => {
+              setBareCodeRelayUrl(event.target.value);
+              setJoinPolicy(null);
+              setPolicyInvite(null);
+              setAgeConfirmed(false);
+              setPolicyError(null);
+            }}
             placeholder="wss://relay.example.com"
             type="text"
             value={bareCodeRelayUrl}
@@ -102,6 +175,21 @@ export function InviteRedeemForm({
         </div>
       ) : null}
 
+      {joinPolicy ? (
+        <JoinPolicyNotice
+          ageConfirmed={ageConfirmed}
+          onAgeConfirmedChange={(confirmed) => {
+            setAgeConfirmed(confirmed);
+            setPolicyError(null);
+          }}
+          policy={joinPolicy}
+        />
+      ) : null}
+
+      {policyError ? (
+        <p className="text-center text-sm text-destructive">{policyError}</p>
+      ) : null}
+
       {error ? (
         <p className="text-center text-sm text-destructive">{error}</p>
       ) : null}
@@ -109,11 +197,21 @@ export function InviteRedeemForm({
       <Button
         className="h-10 w-full"
         data-testid="invite-redeem-submit"
-        disabled={!canSubmit || isRedeeming}
+        disabled={
+          !canSubmit ||
+          isRedeeming ||
+          isLoadingPolicy ||
+          Boolean(joinPolicy?.ageAttestationRequired && !ageConfirmed)
+        }
         type="submit"
       >
-        {isRedeeming ? (
-          <Spinner aria-label="Redeeming invite" className="h-4 w-4 border-2" />
+        {isRedeeming || isLoadingPolicy ? (
+          <Spinner
+            aria-label={isRedeeming ? "Redeeming invite" : "Loading policy"}
+            className="h-4 w-4 border-2"
+          />
+        ) : joinPolicy ? (
+          "Accept and redeem invite"
         ) : (
           "Redeem invite"
         )}
diff --git a/desktop/src/features/onboarding/ui/JoinPolicyNotice.tsx b/desktop/src/features/onboarding/ui/JoinPolicyNotice.tsx
new file mode 100644
index 0000000000..3cbfb18e73
--- /dev/null
+++ b/desktop/src/features/onboarding/ui/JoinPolicyNotice.tsx
@@ -0,0 +1,89 @@
+import * as React from "react";
+
+import type { JoinPolicy } from "@/shared/api/invites";
+import { Button } from "@/shared/ui/button";
+import {
+  Dialog,
+  DialogContent,
+  DialogHeader,
+  DialogTitle,
+} from "@/shared/ui/dialog";
+import { Markdown } from "@/shared/ui/markdown";
+
+type JoinPolicyNoticeProps = {
+  ageConfirmed: boolean;
+  onAgeConfirmedChange: (confirmed: boolean) => void;
+  policy: JoinPolicy;
+};
+
+export function JoinPolicyNotice({
+  ageConfirmed,
+  onAgeConfirmedChange,
+  policy,
+}: JoinPolicyNoticeProps) {
+  const [openDocument, setOpenDocument] = React.useState<
+    "terms" | "privacy" | null
+  >(null);
+  const markdown =
+    openDocument === "terms" ? policy.termsMarkdown : policy.privacyMarkdown;
+
+  return (
+    <div className="space-y-3 rounded-md border p-3 text-left text-sm">
+      {policy.ageAttestationRequired ? (
+        <label className="flex items-start gap-2">
+          <input
+            checked={ageConfirmed}
+            className="mt-1"
+            onChange={(event) => onAgeConfirmedChange(event.target.checked)}
+            type="checkbox"
+          />
+          <span>I am 18 years of age or older.</span>
+        </label>
+      ) : null}
+
+      {policy.termsMarkdown || policy.privacyMarkdown ? (
+        <p className="text-muted-foreground">
+          By proceeding you agree to the Buzz{" "}
+          {policy.termsMarkdown ? (
+            <Button
+              className="h-auto p-0 align-baseline"
+              onClick={() => setOpenDocument("terms")}
+              type="button"
+              variant="link"
+            >
+              Terms of Service
+            </Button>
+          ) : null}
+          {policy.termsMarkdown && policy.privacyMarkdown ? " and " : null}
+          {policy.privacyMarkdown ? (
+            <Button
+              className="h-auto p-0 align-baseline"
+              onClick={() => setOpenDocument("privacy")}
+              type="button"
+              variant="link"
+            >
+              Privacy Policy
+            </Button>
+          ) : null}
+          .
+        </p>
+      ) : null}
+
+      <Dialog
+        onOpenChange={(open) => {
+          if (!open) setOpenDocument(null);
+        }}
+        open={openDocument !== null}
+      >
+        <DialogContent className="max-h-[80vh] max-w-2xl overflow-y-auto">
+          <DialogHeader>
+            <DialogTitle>
+              {openDocument === "terms" ? "Terms of Service" : "Privacy Policy"}
+            </DialogTitle>
+          </DialogHeader>
+          {markdown ? <Markdown content={markdown} /> : null}
+        </DialogContent>
+      </Dialog>
+    </div>
+  );
+}
diff --git a/desktop/src/features/onboarding/ui/MembershipDenied.tsx b/desktop/src/features/onboarding/ui/MembershipDenied.tsx
index bca564fbd8..51745f22a3 100644
--- a/desktop/src/features/onboarding/ui/MembershipDenied.tsx
+++ b/desktop/src/features/onboarding/ui/MembershipDenied.tsx
@@ -89,11 +89,11 @@ export function MembershipDenied({
   }, [onImportKey, previewNpub, trimmedNsec]);
 
   const handleInviteRedeem = React.useCallback(
-    async (relayWsUrl: string, code: string) => {
+    async (relayWsUrl: string, code: string, policyReceipt?: string) => {
       setIsRedeeming(true);
       setInviteError(null);
       try {
-        await claimInvite(relayWsUrl, code);
+        await claimInvite(relayWsUrl, code, policyReceipt);
         onInviteRedeemed(relayWsUrl);
       } catch (error) {
         setInviteError(inviteErrorMessage(error));
@@ -170,8 +170,8 @@ export function MembershipDenied({
                 setInviteError(null);
                 setIsInviteFormOpen(false);
               }}
-              onRedeem={(relayWsUrl, code) => {
-                void handleInviteRedeem(relayWsUrl, code);
+              onRedeem={(relayWsUrl, code, policyReceipt) => {
+                void handleInviteRedeem(relayWsUrl, code, policyReceipt);
               }}
             />
           ) : isImportFormOpen ? (
diff --git a/desktop/src/shared/api/invites.test.mjs b/desktop/src/shared/api/invites.test.mjs
new file mode 100644
index 0000000000..eb4ca3dad1
--- /dev/null
+++ b/desktop/src/shared/api/invites.test.mjs
@@ -0,0 +1,54 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { getJoinPolicy } from "./invites.ts";
+
+function withFetch(response, run) {
+  const originalFetch = globalThis.fetch;
+  globalThis.fetch = async (url) => {
+    assert.equal(url, "https://relay.example/api/join-policy");
+    return response;
+  };
+  return Promise.resolve(run()).finally(() => {
+    globalThis.fetch = originalFetch;
+  });
+}
+
+test("getJoinPolicy maps relay-hosted Markdown and age requirements", async () => {
+  await withFetch(
+    new Response(
+      JSON.stringify({
+        policy: {
+          terms_markdown: "# Terms",
+          privacy_markdown: "# Privacy",
+          age_attestation_required: true,
+          version: "policy-v1",
+        },
+      }),
+      { status: 200 },
+    ),
+    async () => {
+      assert.deepEqual(await getJoinPolicy("wss://relay.example"), {
+        termsMarkdown: "# Terms",
+        privacyMarkdown: "# Privacy",
+        ageAttestationRequired: true,
+        version: "policy-v1",
+      });
+    },
+  );
+});
+
+test("getJoinPolicy preserves opt-in behavior for unconfigured and older relays", async () => {
+  await withFetch(new Response(JSON.stringify({}), { status: 200 }), async () =>
+    assert.equal(await getJoinPolicy("wss://relay.example"), null),
+  );
+  await withFetch(new Response(null, { status: 404 }), async () =>
+    assert.equal(await getJoinPolicy("wss://relay.example"), null),
+  );
+});
+
+test("getJoinPolicy fails closed on a policy endpoint error", async () => {
+  await withFetch(new Response(null, { status: 503 }), async () =>
+    assert.rejects(getJoinPolicy("wss://relay.example"), /HTTP 503/),
+  );
+});
diff --git a/desktop/src/shared/api/invites.ts b/desktop/src/shared/api/invites.ts
index f0161d6205..d9e6845cc6 100644
--- a/desktop/src/shared/api/invites.ts
+++ b/desktop/src/shared/api/invites.ts
@@ -18,6 +18,13 @@ export type MintedInvite = {
   url: string;
 };
 
+export type JoinPolicy = {
+  termsMarkdown?: string;
+  privacyMarkdown?: string;
+  ageAttestationRequired: boolean;
+  version: string;
+};
+
 export type ClaimResult = {
   status: "joined" | "already_member";
   communityId: string;
@@ -84,6 +91,57 @@ async function invitePost<T>(
   return json as T;
 }
 
+/** Fetch relay-hosted policy content for any join surface. */
+export async function getJoinPolicy(
+  relayWsUrl: string,
+): Promise<JoinPolicy | null> {
+  const base = relayHttpFromWs(relayWsUrl);
+  const response = await fetch(`${base.replace(/\/+$/, "")}/api/join-policy`);
+  // Relays predating join-policy support have no configured policy.
+  if (response.status === 404) return null;
+  if (!response.ok) throw new Error(`HTTP ${response.status}`);
+  const raw = (await response.json()) as {
+    policy?: {
+      terms_markdown?: string;
+      privacy_markdown?: string;
+      age_attestation_required: boolean;
+      version: string;
+    };
+  };
+  return raw.policy
+    ? {
+        termsMarkdown: raw.policy.terms_markdown,
+        privacyMarkdown: raw.policy.privacy_markdown,
+        ageAttestationRequired: raw.policy.age_attestation_required,
+        version: raw.policy.version,
+      }
+    : null;
+}
+
+/** Accept the current join policy for an invite and receive a bound receipt. */
+export async function acceptJoinPolicy(
+  relayWsUrl: string,
+  code: string,
+  policyVersion: string,
+  ageConfirmed: boolean,
+): Promise<string> {
+  const base = relayHttpFromWs(relayWsUrl);
+  const response = await fetch(
+    `${base.replace(/\/+$/, "")}/api/invites/accept-policy`,
+    {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({
+        code,
+        policy_version: policyVersion,
+        age_confirmed: ageConfirmed,
+      }),
+    },
+  );
+  if (!response.ok) throw new Error(`HTTP ${response.status}`);
+  return ((await response.json()) as { receipt: string }).receipt;
+}
+
 /** Mint an invite code on the active community's relay (owner/admin only). */
 export async function mintInvite(ttlSecs?: number): Promise<MintedInvite> {
   const base = await getRelayHttpUrl();
@@ -103,9 +161,10 @@ export async function mintInvite(ttlSecs?: number): Promise<MintedInvite> {
 export async function claimInvite(
   relayWsUrl: string,
   code: string,
+  policyReceipt?: string,
 ): Promise<ClaimResult> {
   const base = relayHttpFromWs(relayWsUrl);
-  const body = JSON.stringify({ code });
+  const body = JSON.stringify({ code, policy_receipt: policyReceipt });
   const raw = await invitePost<{
     status: "joined" | "already_member";
     community_id: string;
diff --git a/desktop/src/shared/deep-link.ts b/desktop/src/shared/deep-link.ts
index 1936338572..96a702f701 100644
--- a/desktop/src/shared/deep-link.ts
+++ b/desktop/src/shared/deep-link.ts
@@ -49,6 +49,7 @@ export type NostrBindDeepLinkPayload = {
 export type JoinDeepLinkPayload = {
   relayUrl: string;
   code: string;
+  policyReceipt: string | null;
 };
 
 /**
@@ -90,8 +91,8 @@ export function listenForDeepLinks(deps: DeepLinkDeps): Promise<UnlistenFn> {
   });
 
   const joinPromise = listen<JoinDeepLinkPayload>("deep-link-join", (event) => {
-    const { relayUrl, code } = event.payload;
-    void claimInvite(relayUrl, code)
+    const { relayUrl, code, policyReceipt } = event.payload;
+    void claimInvite(relayUrl, code, policyReceipt ?? undefined)
       .then((result) => {
         const name = addAndSwitch(relayUrl);
         toast.success(
diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts
index 6ee97dba43..481cba11d9 100644
--- a/desktop/tests/e2e/onboarding.spec.ts
+++ b/desktop/tests/e2e/onboarding.spec.ts
@@ -568,6 +568,16 @@ test("first-run default community handoff gives immediate stepper feedback", asy
       skipCommunitySeed: true,
     },
   );
+  await page.route(
+    "https://default.example.com/api/join-policy",
+    async (route) => {
+      await route.fulfill({
+        status: 200,
+        contentType: "application/json",
+        body: "{}",
+      });
+    },
+  );
   await page.goto("/");
 
   await expect(page.getByText("Welcome to Buzz")).toBeVisible();
@@ -609,6 +619,16 @@ test("welcome can continue using an existing Nostr key", async ({ page }) => {
     skipOnboardingSeed: true,
     skipCommunitySeed: true,
   });
+  await page.route(
+    "https://default.example.com/api/join-policy",
+    async (route) => {
+      await route.fulfill({
+        status: 200,
+        contentType: "application/json",
+        body: "{}",
+      });
+    },
+  );
   await page.goto("/");
 
   await page.getByTestId("welcome-continue-nostr").click();
@@ -1824,7 +1844,41 @@ test("denied on relay A then paste relay B invite URL switches community to B",
   // Intercept the claimInvite POST to relay B so it succeeds.
   const relayBUrl = "wss://relay-b.example.com";
   const relayBHttpUrl = "https://relay-b.example.com";
+  const policyReceipt = "relay-signed-policy-receipt";
+  await page.route(`${relayBHttpUrl}/api/join-policy`, async (route) => {
+    await route.fulfill({
+      status: 200,
+      contentType: "application/json",
+      body: JSON.stringify({
+        policy: {
+          terms_markdown: "# Terms",
+          privacy_markdown: "# Privacy",
+          age_attestation_required: true,
+          version: "policy-v1",
+        },
+      }),
+    });
+  });
+  await page.route(
+    `${relayBHttpUrl}/api/invites/accept-policy`,
+    async (route) => {
+      expect(route.request().postDataJSON()).toEqual({
+        code: "test-invite-code",
+        policy_version: "policy-v1",
+        age_confirmed: true,
+      });
+      await route.fulfill({
+        status: 200,
+        contentType: "application/json",
+        body: JSON.stringify({ receipt: policyReceipt }),
+      });
+    },
+  );
   await page.route(`${relayBHttpUrl}/api/invites/claim`, async (route) => {
+    expect(route.request().postDataJSON()).toMatchObject({
+      code: "test-invite-code",
+      policy_receipt: policyReceipt,
+    });
     await route.fulfill({
       status: 200,
       contentType: "application/json",
@@ -1843,6 +1897,9 @@ test("denied on relay A then paste relay B invite URL switches community to B",
     .getByTestId("invite-redeem-input")
     .fill(`${relayBHttpUrl}/invite/test-invite-code`);
   await page.getByTestId("invite-redeem-submit").click();
+  await expect(page.getByText("I am 18 years of age or older.")).toBeVisible();
+  await page.getByLabel("I am 18 years of age or older.").check();
+  await page.getByTestId("invite-redeem-submit").click();
 
   // After successful claim, the community should switch to relay B's URL.
   await expect
diff --git a/migrations/0020_join_policy_acceptances.sql b/migrations/0020_join_policy_acceptances.sql
new file mode 100644
index 0000000000..061d4670fd
--- /dev/null
+++ b/migrations/0020_join_policy_acceptances.sql
@@ -0,0 +1,12 @@
+-- Durable evidence of the policy version accepted when an invite claim grants
+-- relay membership. Rows are scoped to the same community and member identity
+-- as relay_members and are deleted with that membership.
+CREATE TABLE join_policy_acceptances (
+    community_id UUID NOT NULL,
+    pubkey TEXT NOT NULL,
+    policy_version TEXT NOT NULL CHECK (length(policy_version) = 64),
+    accepted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    PRIMARY KEY (community_id, pubkey, policy_version),
+    FOREIGN KEY (community_id, pubkey)
+        REFERENCES relay_members (community_id, pubkey) ON DELETE CASCADE
+);
diff --git a/web/src/features/invite/ui/InvitePage.tsx b/web/src/features/invite/ui/InvitePage.tsx
index 3a460a1ba8..72a1d11c7e 100644
--- a/web/src/features/invite/ui/InvitePage.tsx
+++ b/web/src/features/invite/ui/InvitePage.tsx
@@ -1,21 +1,72 @@
 import buzzAppIcon from "@/assets/app-icon@3x.png";
 import { relayWsUrl } from "@/shared/lib/relay-url";
 import { Button } from "@/shared/ui/button";
+import * as React from "react";
+import Markdown from "react-markdown";
+import remarkGfm from "remark-gfm";
 
 const DOWNLOAD_URL = "https://github.com/block/buzz/releases/latest";
+type JoinPolicy = {
+  terms_markdown?: string;
+  privacy_markdown?: string;
+  age_attestation_required: boolean;
+  version: string;
+};
 
-/**
- * Landing page for a community invite link (`/invite/<code>`).
- *
- * The code is not validated here — validation happens in the desktop app when
- * the invite is claimed against `POST /api/invites/claim`, signed by the
- * joining key. This page only hands the code off via the `buzz://join` deep
- * link (or tells the visitor where to get the app first).
- */
+type PolicyDocument = { title: string; markdown: string };
+
+/** Landing page for a community invite link (`/invite/<code>`). */
 export function InvitePage({ code }: { code: string }) {
   const relay = relayWsUrl();
   const host = relay.replace(/^wss?:\/\//, "");
-  const deepLink = `buzz://join?relay=${encodeURIComponent(relay)}&code=${encodeURIComponent(code)}`;
+  const [policy, setPolicy] = React.useState<JoinPolicy | null | undefined>(
+    undefined,
+  );
+  const [document, setDocument] = React.useState<PolicyDocument | null>(null);
+  const [ageConfirmed, setAgeConfirmed] = React.useState(false);
+  const [opening, setOpening] = React.useState(false);
+
+  React.useEffect(() => {
+    fetch("/api/join-policy")
+      .then(async (response) => {
+        if (!response.ok) throw new Error(`HTTP ${response.status}`);
+        const config = (await response.json()) as { policy?: JoinPolicy };
+        setPolicy(config.policy ?? null);
+      })
+      .catch(() => setPolicy(undefined));
+  }, []);
+
+  const openInvite = async () => {
+    setOpening(true);
+    try {
+      let receipt: string | undefined;
+      if (policy) {
+        const response = await fetch("/api/invites/accept-policy", {
+          method: "POST",
+          headers: { "Content-Type": "application/json" },
+          body: JSON.stringify({
+            code,
+            policy_version: policy.version,
+            age_confirmed: ageConfirmed,
+          }),
+        });
+        if (!response.ok) throw new Error(`HTTP ${response.status}`);
+        receipt = ((await response.json()) as { receipt: string }).receipt;
+      }
+      const query = new URLSearchParams({ relay, code });
+      if (receipt) query.set("policy_receipt", receipt);
+      window.location.href = `buzz://join?${query.toString()}`;
+    } finally {
+      setOpening(false);
+    }
+  };
+
+  const disabled =
+    policy === undefined ||
+    opening ||
+    Boolean(policy?.age_attestation_required && !ageConfirmed);
+  const showDocument = (title: string, markdown: string) =>
+    setDocument({ title, markdown });
 
   return (
     <div
@@ -24,38 +75,94 @@ export function InvitePage({ code }: { code: string }) {
         backgroundImage: "linear-gradient(180deg, #D7D72E 0%, #D7E7F6 100%)",
       }}
     >
-      <div
-        className="flex w-full max-w-xl flex-col items-center rounded-3xl bg-white px-6 py-10 sm:px-12 sm:py-12"
-        style={{
-          boxShadow:
-            "0 0 0 1px rgba(0, 0, 0, 0.04), 0 8px 24px rgba(0, 0, 0, 0.04)",
-        }}
-      >
-        <div
-          className="h-16 w-16 overflow-hidden bg-black"
-          style={{ borderRadius: "22.37%" }}
-        >
-          <img alt="Buzz" className="h-full w-full" src={buzzAppIcon} />
-        </div>
-        <h1 className="mt-6 text-2xl font-semibold tracking-tight text-black">
-          You&apos;re invited to join
-        </h1>
-        <p className="mt-1 font-mono text-lg text-black/70">{host}</p>
-
-        <div className="mt-8">
-          <Button
-            asChild
-            className="bg-black text-white hover:bg-black/90 focus-visible:ring-black"
-            size="lg"
+      <div className="w-full max-w-xl space-y-4">
+        <div className="flex w-full flex-col items-center rounded-3xl bg-white px-6 py-10 sm:px-12 sm:py-12">
+          <div
+            className="h-12 w-12 overflow-hidden bg-black"
+            style={{ borderRadius: "22.37%" }}
           >
-            <a href={deepLink}>Accept invite in Buzz</a>
-          </Button>
-        </div>
+            <img alt="Buzz" className="h-full w-full" src={buzzAppIcon} />
+          </div>
+          <h1 className="mt-4 text-2xl font-semibold tracking-tight text-black">
+            You&apos;re invited to
+          </h1>
+          <p className="mt-9 font-mono text-lg text-black/70">{host}</p>
 
-        <p className="mt-6 text-sm text-black/60">
+          {policy?.age_attestation_required && (
+            <label className="mt-9 flex max-w-md cursor-pointer items-start gap-3 text-left text-sm text-black/70">
+              <input
+                className="mt-0.5 h-4 w-4 accent-black"
+                type="checkbox"
+                checked={ageConfirmed}
+                onChange={(event) => setAgeConfirmed(event.target.checked)}
+              />
+              <span>I am 18 years of age or older.</span>
+            </label>
+          )}
+          <div className={policy?.age_attestation_required ? "mt-5" : "mt-9"}>
+            {policy === null ? (
+              <Button
+                asChild
+                className="bg-black text-white hover:bg-black/90 focus-visible:ring-black"
+                size="lg"
+              >
+                <a
+                  href={`buzz://join?relay=${encodeURIComponent(relay)}&code=${encodeURIComponent(code)}`}
+                >
+                  Accept invite in Buzz
+                </a>
+              </Button>
+            ) : (
+              <Button
+                className="bg-black text-white hover:bg-black/90 focus-visible:ring-black disabled:cursor-not-allowed disabled:bg-black/30 disabled:text-white/70"
+                size="lg"
+                disabled={disabled}
+                onClick={openInvite}
+              >
+                Accept invite in Buzz
+              </Button>
+            )}
+          </div>
+          {policy && (policy.terms_markdown || policy.privacy_markdown) && (
+            <p className="mt-4 max-w-md text-xs text-black/60">
+              By proceeding you agree to the Buzz{" "}
+              {policy.terms_markdown && (
+                <button
+                  className="text-black underline-offset-4 hover:text-black/70 hover:underline focus-visible:underline"
+                  type="button"
+                  onClick={() =>
+                    showDocument(
+                      "Terms of Service",
+                      policy.terms_markdown ?? "",
+                    )
+                  }
+                >
+                  Terms of Service
+                </button>
+              )}
+              {policy.terms_markdown && policy.privacy_markdown && " and "}
+              {policy.privacy_markdown && (
+                <button
+                  className="text-black underline-offset-4 hover:text-black/70 hover:underline focus-visible:underline"
+                  type="button"
+                  onClick={() =>
+                    showDocument(
+                      "Privacy Policy",
+                      policy.privacy_markdown ?? "",
+                    )
+                  }
+                >
+                  Privacy Policy
+                </button>
+              )}
+              .
+            </p>
+          )}
+        </div>
+        <p className="flex h-[3.125rem] items-center justify-center rounded-2xl bg-white text-sm text-black/60">
           Don&apos;t have the app?{" "}
           <a
-            className="font-medium text-black underline-offset-4 hover:text-black/70 hover:decoration-current hover:underline focus-visible:underline"
+            className="ml-1 font-medium text-black underline-offset-4 hover:text-black/70 hover:underline focus-visible:underline"
             href={DOWNLOAD_URL}
             rel="noreferrer"
             target="_blank"
@@ -64,6 +171,37 @@ export function InvitePage({ code }: { code: string }) {
           </a>
         </p>
       </div>
+
+      {document && (
+        <div
+          aria-label={document.title}
+          aria-modal="true"
+          className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 text-left"
+          role="dialog"
+          onMouseDown={(event) => {
+            if (event.currentTarget === event.target) setDocument(null);
+          }}
+        >
+          <div className="max-h-[85vh] w-full max-w-3xl overflow-y-auto rounded-2xl bg-white p-6 text-black shadow-xl sm:p-8">
+            <div className="mb-6 flex items-start justify-between gap-4">
+              <h2 className="text-xl font-semibold">{document.title}</h2>
+              <button
+                aria-label="Close"
+                className="text-2xl leading-none text-black/60 hover:text-black"
+                type="button"
+                onClick={() => setDocument(null)}
+              >
+                ×
+              </button>
+            </div>
+            <div className="prose prose-sm max-w-none">
+              <Markdown remarkPlugins={[remarkGfm]}>
+                {document.markdown}
+              </Markdown>
+            </div>
+          </div>
+        </div>
+      )}
     </div>
   );
 }

From 39c61fdc0a9ba032c8eb019e4a57839b98dfe13c Mon Sep 17 00:00:00 2001
From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Date: Wed, 15 Jul 2026 18:19:50 -0400
Subject: [PATCH 08/16] Update migration repair test for acceptance migration

Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
---
 crates/buzz-db/src/migration.rs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs
index eb4086024a..2564988464 100644
--- a/crates/buzz-db/src/migration.rs
+++ b/crates/buzz-db/src/migration.rs
@@ -1066,7 +1066,7 @@ mod tests {
         run_migrations(&pool)
             .await
             .expect("retry succeeds after operator repair");
-        assert_eq!(applied_versions(&pool).await.last().copied(), Some(19));
+        assert_eq!(applied_versions(&pool).await.last().copied(), Some(20));
     }
 
     #[tokio::test]

From e5f278ac777f76bae84b94592f2cb7408af6fd4f Mon Sep 17 00:00:00 2001
From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
 <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Date: Wed, 15 Jul 2026 22:06:01 -0400
Subject: [PATCH 09/16] Open join-policy documents in the system browser
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The ToS/Privacy links on join surfaces opened an in-app dialog that
rendered the policy Markdown with the message Markdown component, which
requires the router. During onboarding the router is not mounted, so
clicking either link tore down the whole React tree (blank app).

Fix the class, not the symptom:

- Relay: new public GET /api/join-policy/terms and /privacy routes
  render the operator-configured Markdown as minimal standalone HTML
  pages (pulldown-cmark; raw HTML in the Markdown is escaped to text;
  404 when unconfigured, matching the JSON policy endpoint).
- Desktop: JoinPolicyNotice links now openUrl() those pages in the
  system browser via the Tauri opener; the in-app dialog is deleted.
  Every call site threads relayWsUrl so links resolve against the
  relay that served the policy.

The web invite page keeps its in-page dialog — it already runs in a
browser and does not crash.

Tests: render/escape unit test + Postgres-gated route test on the
relay; joinPolicyDocumentUrl unit test on desktop.

Co-authored-by: Tyler Longwell <tlongwell@squareup.com>
Signed-off-by: Tyler Longwell <tlongwell@squareup.com>
---
 Cargo.lock                                    |  45 +++--
 crates/buzz-relay/Cargo.toml                  |   1 +
 crates/buzz-relay/src/api/invites.rs          | 157 +++++++++++++++++-
 crates/buzz-relay/src/router.rs               |  10 ++
 .../communities/ui/AddCommunityDialog.tsx     |   5 +-
 .../communities/ui/CommunityEditForm.tsx      |   3 +-
 .../features/communities/ui/WelcomeSetup.tsx  |   5 +
 .../onboarding/ui/InviteRedeemForm.tsx        |   3 +-
 .../onboarding/ui/JoinPolicyNotice.tsx        |  54 +++---
 desktop/src/shared/api/invites.test.mjs       |  13 +-
 desktop/src/shared/api/invites.ts             |   9 +
 11 files changed, 254 insertions(+), 51 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 6091f52b63..598142154f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1147,6 +1147,7 @@ dependencies = [
  "opentelemetry-otlp 0.32.0",
  "opentelemetry_sdk 0.32.1",
  "postcard",
+ "pulldown-cmark",
  "rand 0.10.1",
  "redis",
  "reqwest 0.13.4",
@@ -1520,7 +1521,7 @@ version = "3.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
 dependencies = [
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -2158,7 +2159,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090"
 dependencies = [
  "data-encoding",
- "syn 1.0.109",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -2369,7 +2370,7 @@ dependencies = [
  "libc",
  "option-ext",
  "redox_users",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -2607,7 +2608,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
 dependencies = [
  "libc",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -2990,8 +2991,8 @@ dependencies = [
  "libc",
  "log",
  "rustversion",
- "windows-link 0.1.3",
- "windows-result 0.3.4",
+ "windows-link 0.2.1",
+ "windows-result 0.4.1",
 ]
 
 [[package]]
@@ -5549,7 +5550,7 @@ version = "0.50.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
 dependencies = [
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -6733,6 +6734,24 @@ version = "3.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3"
 
+[[package]]
+name = "pulldown-cmark"
+version = "0.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
+dependencies = [
+ "bitflags 2.13.0",
+ "memchr",
+ "pulldown-cmark-escape",
+ "unicase",
+]
+
+[[package]]
+name = "pulldown-cmark-escape"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
+
 [[package]]
 name = "pxfm"
 version = "0.1.29"
@@ -6838,7 +6857,7 @@ dependencies = [
  "once_cell",
  "socket2",
  "tracing",
- "windows-sys 0.59.0",
+ "windows-sys 0.60.2",
 ]
 
 [[package]]
@@ -7485,7 +7504,7 @@ dependencies = [
  "errno",
  "libc",
  "linux-raw-sys 0.12.1",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -7544,7 +7563,7 @@ dependencies = [
  "security-framework 3.7.0",
  "security-framework-sys",
  "webpki-root-certs",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -7807,7 +7826,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521"
 dependencies = [
  "libc",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -8704,7 +8723,7 @@ dependencies = [
  "getrandom 0.4.2",
  "once_cell",
  "rustix 1.1.4",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -10003,7 +10022,7 @@ version = "0.1.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
 dependencies = [
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
diff --git a/crates/buzz-relay/Cargo.toml b/crates/buzz-relay/Cargo.toml
index 36cc71dcb0..31eac113a1 100644
--- a/crates/buzz-relay/Cargo.toml
+++ b/crates/buzz-relay/Cargo.toml
@@ -77,6 +77,7 @@ moka            = { workspace = true }
 metrics         = { workspace = true }
 metrics-exporter-prometheus = { workspace = true }
 metrics-util    = { workspace = true }
+pulldown-cmark = { version = "0.13.4", default-features = false, features = ["html"] }
 
 [features]
 dev = ["buzz-auth/dev"]
diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs
index 7d004c08c9..cf7ee3a6eb 100644
--- a/crates/buzz-relay/src/api/invites.rs
+++ b/crates/buzz-relay/src/api/invites.rs
@@ -19,7 +19,7 @@ use std::time::Duration;
 use axum::{
     extract::State,
     http::{HeaderMap, StatusCode},
-    response::Json,
+    response::{Html, Json},
 };
 use serde::Deserialize;
 use serde_json::Value;
@@ -86,6 +86,78 @@ pub async fn join_policy(State(state): State<Arc<AppState>>) -> Json<Value> {
     }
 }
 
+/// `GET /api/join-policy/terms` — Terms of Service as a standalone HTML page.
+///
+/// Serves the operator-configured Markdown as a real browser page so desktop
+/// clients can hand the link to the system browser instead of rendering the
+/// document inside the webview (which requires app chrome the onboarding
+/// surfaces don't have). 404 when no terms document is configured.
+pub async fn join_policy_terms(
+    State(state): State<Arc<AppState>>,
+) -> Result<Html<String>, (StatusCode, Json<Value>)> {
+    policy_document_page(&state, "Terms of Service", |policy| {
+        policy.terms_markdown.as_deref()
+    })
+}
+
+/// `GET /api/join-policy/privacy` — Privacy Policy as a standalone HTML page.
+pub async fn join_policy_privacy(
+    State(state): State<Arc<AppState>>,
+) -> Result<Html<String>, (StatusCode, Json<Value>)> {
+    policy_document_page(&state, "Privacy Policy", |policy| {
+        policy.privacy_markdown.as_deref()
+    })
+}
+
+fn policy_document_page(
+    state: &AppState,
+    title: &str,
+    select: impl Fn(&crate::config::JoinPolicyConfig) -> Option<&str>,
+) -> Result<Html<String>, (StatusCode, Json<Value>)> {
+    let markdown = state
+        .config
+        .join_policy
+        .as_ref()
+        .and_then(select)
+        .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "join_policy_not_configured"))?;
+    Ok(Html(render_policy_document(title, markdown)))
+}
+
+/// Render operator Markdown into a minimal self-contained HTML page.
+///
+/// Raw HTML embedded in the Markdown is escaped and rendered as text — the
+/// operator authors a policy document, not a web page, and this keeps the
+/// endpoint from serving arbitrary operator-controlled markup.
+fn render_policy_document(title: &str, markdown: &str) -> String {
+    use pulldown_cmark::{html, Event, Parser};
+
+    let mut body = String::new();
+    html::push_html(
+        &mut body,
+        Parser::new(markdown).map(|event| match event {
+            Event::Html(raw) => Event::Text(raw.into_string().into()),
+            Event::InlineHtml(raw) => Event::Text(raw.into_string().into()),
+            other => other,
+        }),
+    );
+
+    // Titles are fixed literals today; escape anyway so a future caller
+    // can't accidentally inject markup through this seam.
+    let escaped_title = title
+        .replace('&', "&amp;")
+        .replace('<', "&lt;")
+        .replace('>', "&gt;");
+
+    format!(
+        "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n\
+         <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\
+         <title>{escaped_title}</title>\n\
+         <style>body{{max-width:42rem;margin:2rem auto;padding:0 1rem;\
+         font-family:system-ui,sans-serif;line-height:1.6}}</style>\n\
+         </head>\n<body>\n{body}</body>\n</html>\n"
+    )
+}
+
 /// Exchange explicit policy acceptance for a short-lived, invite-bound receipt.
 pub async fn accept_policy(
     State(state): State<Arc<AppState>>,
@@ -900,4 +972,87 @@ mod tests {
         let response = post_json(state, &host_b, "/api/invites/claim", &joiner, body).await;
         assert_eq!(response.status(), StatusCode::FORBIDDEN);
     }
+
+    #[test]
+    fn policy_document_renders_markdown_and_escapes_raw_html() {
+        let page = super::render_policy_document(
+            "Terms of Service",
+            "# Terms\n\nBe kind & honest.\n\n<script>alert(1)</script>",
+        );
+        assert!(page.contains("<title>Terms of Service</title>"), "{page}");
+        assert!(page.contains("<h1>Terms</h1>"), "{page}");
+        // `&` inside prose must be entity-encoded by the HTML writer.
+        assert!(page.contains("Be kind &amp; honest."), "{page}");
+        // Raw HTML in operator Markdown renders as escaped text, never markup.
+        assert!(!page.contains("<script>"), "{page}");
+        assert!(
+            page.contains("&lt;script&gt;alert(1)&lt;/script&gt;"),
+            "{page}"
+        );
+    }
+
+    /// The document routes are public (no NIP-98) and 404 until configured,
+    /// exactly like the JSON policy endpoint they sit beside.
+    #[tokio::test]
+    #[ignore = "requires Postgres"]
+    async fn join_policy_document_pages_serve_configured_markdown() {
+        let host = format!("invites-docs-{}.example", Uuid::new_v4().simple());
+        let Some(state) = invite_test_state(&host).await else {
+            return;
+        };
+
+        let get_page = |state: Arc<crate::state::AppState>, path: &'static str| {
+            let host = host.clone();
+            async move {
+                build_router(state)
+                    .oneshot(
+                        Request::builder()
+                            .method("GET")
+                            .uri(path)
+                            .header(header::HOST, host)
+                            .body(Body::empty())
+                            .expect("request"),
+                    )
+                    .await
+                    .expect("response")
+            }
+        };
+
+        // Unconfigured relay: both documents 404.
+        let response = get_page(state.clone(), "/api/join-policy/terms").await;
+        assert_eq!(response.status(), StatusCode::NOT_FOUND);
+        let response = get_page(state.clone(), "/api/join-policy/privacy").await;
+        assert_eq!(response.status(), StatusCode::NOT_FOUND);
+
+        // Configure terms only — terms serves HTML, privacy still 404s.
+        let mut state_inner = (*state).clone();
+        let mut config = state_inner.config.as_ref().clone();
+        config.join_policy = Some(crate::config::JoinPolicyConfig {
+            terms_markdown: Some("# Terms\n\nNo funny business.".to_string()),
+            privacy_markdown: None,
+            age_attestation_required: false,
+            version: "v".repeat(64),
+        });
+        state_inner.config = Arc::new(config);
+        let state = Arc::new(state_inner);
+
+        let response = get_page(state.clone(), "/api/join-policy/terms").await;
+        assert_eq!(response.status(), StatusCode::OK);
+        let content_type = response
+            .headers()
+            .get(header::CONTENT_TYPE)
+            .and_then(|v| v.to_str().ok())
+            .unwrap_or_default()
+            .to_string();
+        assert!(content_type.starts_with("text/html"), "{content_type}");
+        let bytes = to_bytes(response.into_body(), 1024 * 1024)
+            .await
+            .expect("read body");
+        let page = String::from_utf8(bytes.to_vec()).expect("utf8");
+        assert!(page.contains("<h1>Terms</h1>"), "{page}");
+        assert!(page.contains("No funny business."), "{page}");
+
+        let response = get_page(state, "/api/join-policy/privacy").await;
+        assert_eq!(response.status(), StatusCode::NOT_FOUND);
+    }
 }
diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs
index 0036a47af3..ed063398d3 100644
--- a/crates/buzz-relay/src/router.rs
+++ b/crates/buzz-relay/src/router.rs
@@ -84,6 +84,16 @@ pub fn build_router(state: Arc<AppState>) -> Router {
         // Relay invites: mint (owner/admin) + claim (membership-gate exempt)
         .route("/api/invites", post(api::invites::mint_invite))
         .route("/api/join-policy", get(api::invites::join_policy))
+        // Policy documents as standalone pages — desktop opens these in the
+        // system browser instead of rendering the Markdown in-app.
+        .route(
+            "/api/join-policy/terms",
+            get(api::invites::join_policy_terms),
+        )
+        .route(
+            "/api/join-policy/privacy",
+            get(api::invites::join_policy_privacy),
+        )
         .route(
             "/api/invites/accept-policy",
             post(api::invites::accept_policy),
diff --git a/desktop/src/features/communities/ui/AddCommunityDialog.tsx b/desktop/src/features/communities/ui/AddCommunityDialog.tsx
index 9c2b8f0913..4d142e3c6f 100644
--- a/desktop/src/features/communities/ui/AddCommunityDialog.tsx
+++ b/desktop/src/features/communities/ui/AddCommunityDialog.tsx
@@ -243,7 +243,7 @@ export function AddCommunityDialog({
             {inviteError ? (
               <p className="text-xs text-destructive">{inviteError}</p>
             ) : null}
-            {joinPolicy ? (
+            {joinPolicy && relayUrl.trim() ? (
               <JoinPolicyNotice
                 ageConfirmed={ageConfirmed}
                 onAgeConfirmedChange={(confirmed) => {
@@ -251,6 +251,9 @@ export function AddCommunityDialog({
                   setInviteError(null);
                 }}
                 policy={joinPolicy}
+                // Editing the relay URL resets joinPolicy, so a visible
+                // notice always belongs to the URL currently in the field.
+                relayWsUrl={normalizeRelayUrl(relayUrl.trim())}
               />
             ) : null}
           </div>
diff --git a/desktop/src/features/communities/ui/CommunityEditForm.tsx b/desktop/src/features/communities/ui/CommunityEditForm.tsx
index 7365ebf2b0..4c40ce7e75 100644
--- a/desktop/src/features/communities/ui/CommunityEditForm.tsx
+++ b/desktop/src/features/communities/ui/CommunityEditForm.tsx
@@ -195,7 +195,7 @@ export function CommunityEditForm({
         />
       </div>
 
-      {joinPolicy ? (
+      {joinPolicy && policyRelayUrl ? (
         <JoinPolicyNotice
           ageConfirmed={ageConfirmed}
           onAgeConfirmedChange={(confirmed) => {
@@ -203,6 +203,7 @@ export function CommunityEditForm({
             setError(null);
           }}
           policy={joinPolicy}
+          relayWsUrl={policyRelayUrl}
         />
       ) : null}
 
diff --git a/desktop/src/features/communities/ui/WelcomeSetup.tsx b/desktop/src/features/communities/ui/WelcomeSetup.tsx
index 61cbf1a5b3..4bc1ea15fe 100644
--- a/desktop/src/features/communities/ui/WelcomeSetup.tsx
+++ b/desktop/src/features/communities/ui/WelcomeSetup.tsx
@@ -60,6 +60,7 @@ function NostrKeyImportPage({
   onAgeConfirmedChange,
   onBack,
   onImport,
+  relayWsUrl,
 }: {
   ageConfirmed: boolean;
   connectionError: string | null;
@@ -68,6 +69,7 @@ function NostrKeyImportPage({
   onAgeConfirmedChange: (confirmed: boolean) => void;
   onBack: () => void;
   onImport: (nsec: string) => Promise<void>;
+  relayWsUrl: string;
 }) {
   return (
     <OnboardingSlideTransition
@@ -92,6 +94,7 @@ function NostrKeyImportPage({
             ageConfirmed={ageConfirmed}
             onAgeConfirmedChange={onAgeConfirmedChange}
             policy={joinPolicy}
+            relayWsUrl={relayWsUrl}
           />
         </div>
       ) : null}
@@ -307,6 +310,7 @@ export function WelcomeSetup({
                   ageConfirmed={defaultAgeConfirmed}
                   onAgeConfirmedChange={setDefaultAgeConfirmed}
                   policy={defaultJoinPolicy}
+                  relayWsUrl={defaultRelayUrl}
                 />
               ) : null}
               {isLocalDevRelayUrl(defaultRelayUrl) ? null : (
@@ -467,6 +471,7 @@ export function WelcomeSetup({
             onAgeConfirmedChange={setDefaultAgeConfirmed}
             onBack={showWelcomePage}
             onImport={handleNostrImport}
+            relayWsUrl={defaultRelayUrl}
           />
         )}
       </div>
diff --git a/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx b/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx
index e6541eafce..e6663c3fbc 100644
--- a/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx
+++ b/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx
@@ -175,7 +175,7 @@ export function InviteRedeemForm({
         </div>
       ) : null}
 
-      {joinPolicy ? (
+      {joinPolicy && policyInvite ? (
         <JoinPolicyNotice
           ageConfirmed={ageConfirmed}
           onAgeConfirmedChange={(confirmed) => {
@@ -183,6 +183,7 @@ export function InviteRedeemForm({
             setPolicyError(null);
           }}
           policy={joinPolicy}
+          relayWsUrl={policyInvite.relayWsUrl}
         />
       ) : null}
 
diff --git a/desktop/src/features/onboarding/ui/JoinPolicyNotice.tsx b/desktop/src/features/onboarding/ui/JoinPolicyNotice.tsx
index 3cbfb18e73..4b267695c6 100644
--- a/desktop/src/features/onboarding/ui/JoinPolicyNotice.tsx
+++ b/desktop/src/features/onboarding/ui/JoinPolicyNotice.tsx
@@ -1,32 +1,32 @@
-import * as React from "react";
+import { openUrl } from "@tauri-apps/plugin-opener";
 
-import type { JoinPolicy } from "@/shared/api/invites";
+import { joinPolicyDocumentUrl, type JoinPolicy } from "@/shared/api/invites";
 import { Button } from "@/shared/ui/button";
-import {
-  Dialog,
-  DialogContent,
-  DialogHeader,
-  DialogTitle,
-} from "@/shared/ui/dialog";
-import { Markdown } from "@/shared/ui/markdown";
 
 type JoinPolicyNoticeProps = {
   ageConfirmed: boolean;
   onAgeConfirmedChange: (confirmed: boolean) => void;
   policy: JoinPolicy;
+  /** Relay hosting the policy documents the links below point at. */
+  relayWsUrl: string;
 };
 
+/**
+ * Join-policy consent block shown on every join surface.
+ *
+ * The Terms/Privacy links open the relay-hosted document pages
+ * (`/api/join-policy/terms|privacy`) in the system browser via the OS
+ * opener. They must NOT navigate or render in-app: these surfaces exist
+ * before onboarding completes, where the router (required by the message
+ * Markdown component) is not mounted — an in-app render tears down the
+ * whole React tree.
+ */
 export function JoinPolicyNotice({
   ageConfirmed,
   onAgeConfirmedChange,
   policy,
+  relayWsUrl,
 }: JoinPolicyNoticeProps) {
-  const [openDocument, setOpenDocument] = React.useState<
-    "terms" | "privacy" | null
-  >(null);
-  const markdown =
-    openDocument === "terms" ? policy.termsMarkdown : policy.privacyMarkdown;
-
   return (
     <div className="space-y-3 rounded-md border p-3 text-left text-sm">
       {policy.ageAttestationRequired ? (
@@ -47,7 +47,9 @@ export function JoinPolicyNotice({
           {policy.termsMarkdown ? (
             <Button
               className="h-auto p-0 align-baseline"
-              onClick={() => setOpenDocument("terms")}
+              onClick={() =>
+                void openUrl(joinPolicyDocumentUrl(relayWsUrl, "terms"))
+              }
               type="button"
               variant="link"
             >
@@ -58,7 +60,9 @@ export function JoinPolicyNotice({
           {policy.privacyMarkdown ? (
             <Button
               className="h-auto p-0 align-baseline"
-              onClick={() => setOpenDocument("privacy")}
+              onClick={() =>
+                void openUrl(joinPolicyDocumentUrl(relayWsUrl, "privacy"))
+              }
               type="button"
               variant="link"
             >
@@ -68,22 +72,6 @@ export function JoinPolicyNotice({
           .
         </p>
       ) : null}
-
-      <Dialog
-        onOpenChange={(open) => {
-          if (!open) setOpenDocument(null);
-        }}
-        open={openDocument !== null}
-      >
-        <DialogContent className="max-h-[80vh] max-w-2xl overflow-y-auto">
-          <DialogHeader>
-            <DialogTitle>
-              {openDocument === "terms" ? "Terms of Service" : "Privacy Policy"}
-            </DialogTitle>
-          </DialogHeader>
-          {markdown ? <Markdown content={markdown} /> : null}
-        </DialogContent>
-      </Dialog>
     </div>
   );
 }
diff --git a/desktop/src/shared/api/invites.test.mjs b/desktop/src/shared/api/invites.test.mjs
index eb4ca3dad1..53012168b2 100644
--- a/desktop/src/shared/api/invites.test.mjs
+++ b/desktop/src/shared/api/invites.test.mjs
@@ -1,7 +1,7 @@
 import assert from "node:assert/strict";
 import test from "node:test";
 
-import { getJoinPolicy } from "./invites.ts";
+import { getJoinPolicy, joinPolicyDocumentUrl } from "./invites.ts";
 
 function withFetch(response, run) {
   const originalFetch = globalThis.fetch;
@@ -52,3 +52,14 @@ test("getJoinPolicy fails closed on a policy endpoint error", async () => {
     assert.rejects(getJoinPolicy("wss://relay.example"), /HTTP 503/),
   );
 });
+
+test("joinPolicyDocumentUrl builds browser links for both documents", () => {
+  assert.equal(
+    joinPolicyDocumentUrl("wss://relay.example", "terms"),
+    "https://relay.example/api/join-policy/terms",
+  );
+  assert.equal(
+    joinPolicyDocumentUrl("ws://localhost:7000/", "privacy"),
+    "http://localhost:7000/api/join-policy/privacy",
+  );
+});
diff --git a/desktop/src/shared/api/invites.ts b/desktop/src/shared/api/invites.ts
index d9e6845cc6..afb27a5094 100644
--- a/desktop/src/shared/api/invites.ts
+++ b/desktop/src/shared/api/invites.ts
@@ -91,6 +91,15 @@ async function invitePost<T>(
   return json as T;
 }
 
+/** Absolute URL of a relay-hosted policy document page (system-browser target). */
+export function joinPolicyDocumentUrl(
+  relayWsUrl: string,
+  document: "terms" | "privacy",
+): string {
+  const base = relayHttpFromWs(relayWsUrl);
+  return `${base.replace(/\/+$/, "")}/api/join-policy/${document}`;
+}
+
 /** Fetch relay-hosted policy content for any join surface. */
 export async function getJoinPolicy(
   relayWsUrl: string,

From d29219f37d04335e1c4e4ba667757e0a39522bd2 Mon Sep 17 00:00:00 2001
From: 
 "5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co"
 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Date: Wed, 15 Jul 2026 22:30:16 -0400
Subject: [PATCH 10/16] test(desktop): cover external join-policy links

Co-authored-by: Tyler Longwell <tlongwell@squareup.com>
Signed-off-by: Tyler Longwell <tlongwell@squareup.com>
---
 desktop/src/testing/e2eBridge.ts     |  9 ++++
 desktop/tests/e2e/onboarding.spec.ts | 66 ++++++++++++++++++++++++++++
 2 files changed, 75 insertions(+)

diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index 9c5f3d17c3..00b8d92c56 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -2549,6 +2549,7 @@ let mockPersonas: RawPersona[] = [];
 let mockTeams: RawTeam[] = [];
 // Listeners registered via the mock __TAURI_INTERNALS__.listen — keyed by event name.
 const tauriEventListeners = new Map<string, Set<() => void>>();
+const openedExternalUrls: string[] = [];
 const defaultMockRelayAgents: RawRelayAgent[] = [
   {
     pubkey: ALICE_PUBKEY,
@@ -9504,6 +9505,14 @@ export function maybeInstallE2eTauriMocks() {
         return sendToMockSocket(
           payload as Parameters<typeof sendToMockSocket>[0],
         );
+      case "plugin:opener|open_url":
+        openedExternalUrls.push(String((payload as { url: string | URL }).url));
+        return null;
+      case "get_e2e_opened_external_urls":
+        return [...openedExternalUrls];
+      case "clear_e2e_opened_external_urls":
+        openedExternalUrls.length = 0;
+        return null;
       case "plugin:window|show":
       case "plugin:window|unminimize":
       case "plugin:window|set_focus":
diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts
index 481cba11d9..0f98c6d01a 100644
--- a/desktop/tests/e2e/onboarding.spec.ts
+++ b/desktop/tests/e2e/onboarding.spec.ts
@@ -613,6 +613,72 @@ test("first-run default community handoff gives immediate stepper feedback", asy
   await expect(page.getByTestId("onboarding-back")).toBeVisible();
 });
 
+test("welcome join policy documents open externally and keep onboarding mounted", async ({
+  page,
+}) => {
+  await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
+  await installMockBridge(page, undefined, {
+    relayWsUrl: "wss://default.example.com",
+    skipOnboardingSeed: true,
+    skipCommunitySeed: true,
+  });
+  await page.route(
+    "https://default.example.com/api/join-policy",
+    async (route) => {
+      await route.fulfill({
+        status: 200,
+        contentType: "application/json",
+        body: JSON.stringify({
+          policy: {
+            terms_markdown: "# Terms",
+            privacy_markdown: "# Privacy",
+            age_attestation_required: false,
+            version: "policy-v1",
+          },
+        }),
+      });
+    },
+  );
+
+  await page.goto("/");
+  await expect(page.getByText("Welcome to Buzz")).toBeVisible();
+  await expect(
+    page.getByRole("button", { name: "Terms of Service" }),
+  ).toBeVisible();
+
+  await page.evaluate(async () => {
+    const invoke = (
+      window as Window & {
+        __TAURI_INTERNALS__?: {
+          invoke?: (command: string, payload?: unknown) => Promise<unknown>;
+        };
+      }
+    ).__TAURI_INTERNALS__?.invoke;
+    await invoke?.("clear_e2e_opened_external_urls");
+  });
+  await page.getByRole("button", { name: "Terms of Service" }).click();
+  await page.getByRole("button", { name: "Privacy Policy" }).click();
+
+  await expect(page.getByText("Welcome to Buzz")).toBeVisible();
+  await expect(page.locator("#root")).not.toBeEmpty();
+  await expect(page.getByRole("dialog")).toHaveCount(0);
+
+  const openedUrls = await page.evaluate(async () => {
+    const invoke = (
+      window as Window & {
+        __TAURI_INTERNALS__?: {
+          invoke?: (command: string, payload?: unknown) => Promise<unknown>;
+        };
+      }
+    ).__TAURI_INTERNALS__?.invoke;
+    return (await invoke?.("get_e2e_opened_external_urls")) as string[];
+  });
+  expect(openedUrls).toEqual([
+    "https://default.example.com/api/join-policy/terms",
+    "https://default.example.com/api/join-policy/privacy",
+  ]);
+});
+
 test("welcome can continue using an existing Nostr key", async ({ page }) => {
   await installMockBridge(page, undefined, {
     relayWsUrl: "wss://default.example.com",

From 711655d59e9438f4336816b43f4d531508286839 Mon Sep 17 00:00:00 2001
From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Date: Wed, 15 Jul 2026 23:32:04 -0400
Subject: [PATCH 11/16] test(relay): assert join policy defaults off

Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
---
 crates/buzz-relay/src/config.rs | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs
index 5e59f28d98..04c88ee94c 100644
--- a/crates/buzz-relay/src/config.rs
+++ b/crates/buzz-relay/src/config.rs
@@ -814,6 +814,10 @@ mod tests {
             !config.require_media_get_auth,
             "require_media_get_auth should default to false for staged client rollout"
         );
+        assert!(
+            config.join_policy.is_none(),
+            "join_policy should default to None so policy prompts and acceptance receipts are opt-in"
+        );
         assert!(
             config.huddle_audio_available,
             "huddle_audio_available should default to true so single-pod (N=1) keeps today's huddle behavior"

From 4e81548f7b2032555010445abe51c1c413a4d774 Mon Sep 17 00:00:00 2001
From: kenny lopez <klopez4212@gmail.com>
Date: Thu, 16 Jul 2026 09:57:52 +0100
Subject: [PATCH 12/16] Polish join policy consent across join flows

Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
---
 desktop/src/app/App.tsx                       |   6 +-
 .../communities/ui/AddCommunityDialog.tsx     | 126 ++++++++--
 .../communities/ui/CommunityEditForm.tsx      | 125 +++++++++-
 .../features/communities/ui/WelcomeSetup.tsx  | 129 ++--------
 .../onboarding/ui/InviteRedeemForm.tsx        | 122 +++++++++-
 .../onboarding/ui/JoinPolicyNotice.tsx        | 105 +++++---
 .../onboarding/ui/NostrKeyImportForm.tsx      |  24 +-
 desktop/src/shared/api/invites.ts             |  15 ++
 desktop/src/shared/ui/checkbox.tsx            |  57 +++--
 desktop/src/testing/e2eBridge.ts              |   5 +
 desktop/tests/e2e/onboarding.spec.ts          | 226 ++++++++++++------
 desktop/tests/e2e/sidebar.spec.ts             |  54 +++++
 .../invite/ui/InviteJoinPolicyNotice.tsx      | 161 +++++++++++++
 web/src/features/invite/ui/InvitePage.tsx     |  91 +++----
 web/tests/e2e/smoke.spec.ts                   |  64 +++++
 15 files changed, 972 insertions(+), 338 deletions(-)
 create mode 100644 web/src/features/invite/ui/InviteJoinPolicyNotice.tsx

diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx
index 93fb836739..d5cff3c65b 100644
--- a/desktop/src/app/App.tsx
+++ b/desktop/src/app/App.tsx
@@ -220,7 +220,7 @@ function OnboardingLoadingGate() {
               tabIndex={-1}
               type="button"
             >
-              Continue with default community
+              Join a community
             </Button>
 
             <Button
@@ -228,9 +228,9 @@ function OnboardingLoadingGate() {
               className="h-10 w-full"
               tabIndex={-1}
               type="button"
-              variant="secondary"
+              variant="ghost"
             >
-              Join a community
+              Have an invite?
             </Button>
 
             <Button
diff --git a/desktop/src/features/communities/ui/AddCommunityDialog.tsx b/desktop/src/features/communities/ui/AddCommunityDialog.tsx
index 4d142e3c6f..14a78922ee 100644
--- a/desktop/src/features/communities/ui/AddCommunityDialog.tsx
+++ b/desktop/src/features/communities/ui/AddCommunityDialog.tsx
@@ -1,4 +1,5 @@
 import * as React from "react";
+import { AnimatePresence, motion, useReducedMotion } from "motion/react";
 
 import type { Community } from "@/features/communities/types";
 import {
@@ -14,6 +15,7 @@ import {
   acceptJoinPolicy,
   claimInvite,
   getJoinPolicy,
+  isJoinPolicyDiscoveryCandidate,
   type JoinPolicy,
 } from "@/shared/api/invites";
 import { validateReposDir } from "@/shared/api/tauri";
@@ -28,6 +30,9 @@ import {
 import { Input } from "@/shared/ui/input";
 import { JoinPolicyNotice } from "@/features/onboarding/ui/JoinPolicyNotice";
 
+const POLICY_DISCOVERY_DELAY_MS = 250;
+const POLICY_REVEAL_EASE = [0.23, 1, 0.32, 1] as const;
+
 type AddCommunityDialogProps = {
   open: boolean;
   onOpenChange: (open: boolean) => void;
@@ -46,8 +51,38 @@ export function AddCommunityDialog({
   const [inviteError, setInviteError] = React.useState<string | null>(null);
   const [joinPolicy, setJoinPolicy] = React.useState<JoinPolicy | null>(null);
   const [ageConfirmed, setAgeConfirmed] = React.useState(false);
+  const [agreementConfirmed, setAgreementConfirmed] = React.useState(false);
   const [reposDir, setReposDir] = React.useState("");
   const [reposDirError, setReposDirError] = React.useState<string | null>(null);
+  const shouldReduceMotion = useReducedMotion();
+
+  React.useEffect(() => {
+    if (!open || !relayUrl.trim()) return;
+
+    const normalizedUrl = normalizeRelayUrl(relayUrl.trim());
+    if (!isJoinPolicyDiscoveryCandidate(normalizedUrl)) return;
+
+    let cancelled = false;
+    const timeoutId = window.setTimeout(() => {
+      void getJoinPolicy(normalizedUrl)
+        .then((policy) => {
+          if (cancelled || !policy) return;
+          setJoinPolicy(policy);
+          setAgeConfirmed(false);
+          setAgreementConfirmed(false);
+          setInviteError(null);
+        })
+        .catch(() => {
+          // Background discovery is best-effort. A deliberate submit retries
+          // the request and surfaces any relay error to the user.
+        });
+    }, POLICY_DISCOVERY_DELAY_MS);
+
+    return () => {
+      cancelled = true;
+      window.clearTimeout(timeoutId);
+    };
+  }, [open, relayUrl]);
 
   const handleClose = React.useCallback(() => {
     onOpenChange(false);
@@ -58,6 +93,7 @@ export function AddCommunityDialog({
     setInviteError(null);
     setJoinPolicy(null);
     setAgeConfirmed(false);
+    setAgreementConfirmed(false);
     setReposDir("");
     setReposDirError(null);
   }, [onOpenChange]);
@@ -87,13 +123,22 @@ export function AddCommunityDialog({
         if (policy && (!joinPolicy || joinPolicy.version !== policy.version)) {
           setJoinPolicy(policy);
           setAgeConfirmed(false);
-          setInviteError("Review this relay's join policy below.");
+          setAgreementConfirmed(false);
+          setInviteError(null);
           return;
         }
         if (policy?.ageAttestationRequired && !ageConfirmed) {
           setInviteError("Confirm that you are at least 18 years old.");
           return;
         }
+        if (
+          policy &&
+          (policy.termsMarkdown || policy.privacyMarkdown) &&
+          !agreementConfirmed
+        ) {
+          setInviteError("Agree to the Terms of Service and Privacy Policy.");
+          return;
+        }
 
         // If the relay handed out an invite code, claim it before saving the
         // community — a closed relay would otherwise reject the connection.
@@ -142,6 +187,7 @@ export function AddCommunityDialog({
       reposDir,
       joinPolicy,
       ageConfirmed,
+      agreementConfirmed,
       onSubmit,
       handleClose,
     ],
@@ -176,6 +222,7 @@ export function AddCommunityDialog({
                 setInviteError(null);
                 setJoinPolicy(null);
                 setAgeConfirmed(false);
+                setAgreementConfirmed(false);
               }}
               placeholder="wss://relay.example.com"
               type="text"
@@ -235,6 +282,7 @@ export function AddCommunityDialog({
                 setInviteError(null);
                 setJoinPolicy(null);
                 setAgeConfirmed(false);
+                setAgreementConfirmed(false);
               }}
               placeholder="Paste an invite code for a members-only relay"
               type="text"
@@ -243,19 +291,6 @@ export function AddCommunityDialog({
             {inviteError ? (
               <p className="text-xs text-destructive">{inviteError}</p>
             ) : null}
-            {joinPolicy && relayUrl.trim() ? (
-              <JoinPolicyNotice
-                ageConfirmed={ageConfirmed}
-                onAgeConfirmedChange={(confirmed) => {
-                  setAgeConfirmed(confirmed);
-                  setInviteError(null);
-                }}
-                policy={joinPolicy}
-                // Editing the relay URL resets joinPolicy, so a visible
-                // notice always belongs to the URL currently in the field.
-                relayWsUrl={normalizeRelayUrl(relayUrl.trim())}
-              />
-            ) : null}
           </div>
           <div className="flex flex-col gap-1.5">
             <label
@@ -290,6 +325,62 @@ export function AddCommunityDialog({
             Communities share your active identity. To use a different key,
             import it on the profile step (or in settings).
           </p>
+          <AnimatePresence initial={false}>
+            {joinPolicy && relayUrl.trim() ? (
+              <motion.div
+                animate={{
+                  height: "auto",
+                  marginTop: 0,
+                  opacity: 1,
+                  transform: "translateY(0rem)",
+                }}
+                className="overflow-hidden"
+                exit={
+                  shouldReduceMotion
+                    ? { height: 0, marginTop: "-1rem", opacity: 0 }
+                    : {
+                        height: 0,
+                        marginTop: "-1rem",
+                        opacity: 0,
+                        transform: "translateY(-0.25rem)",
+                      }
+                }
+                initial={
+                  shouldReduceMotion
+                    ? false
+                    : {
+                        height: 0,
+                        marginTop: "-1rem",
+                        opacity: 0,
+                        transform: "translateY(-0.25rem)",
+                      }
+                }
+                key={`${normalizeRelayUrl(relayUrl.trim())}:${joinPolicy.version}`}
+                transition={
+                  shouldReduceMotion
+                    ? { duration: 0 }
+                    : { duration: 0.22, ease: POLICY_REVEAL_EASE }
+                }
+              >
+                <JoinPolicyNotice
+                  ageConfirmed={ageConfirmed}
+                  agreementConfirmed={agreementConfirmed}
+                  onAgeConfirmedChange={(confirmed) => {
+                    setAgeConfirmed(confirmed);
+                    setInviteError(null);
+                  }}
+                  onAgreementConfirmedChange={(confirmed) => {
+                    setAgreementConfirmed(confirmed);
+                    setInviteError(null);
+                  }}
+                  policy={joinPolicy}
+                  // Editing the relay URL resets joinPolicy, so a visible
+                  // notice always belongs to the URL currently in the field.
+                  relayWsUrl={normalizeRelayUrl(relayUrl.trim())}
+                />
+              </motion.div>
+            ) : null}
+          </AnimatePresence>
           <div className="flex justify-end gap-2 pt-2">
             <Button onClick={handleClose} type="button" variant="outline">
               Cancel
@@ -297,7 +388,12 @@ export function AddCommunityDialog({
             <Button
               disabled={
                 !relayUrl.trim() ||
-                Boolean(joinPolicy?.ageAttestationRequired && !ageConfirmed)
+                Boolean(joinPolicy?.ageAttestationRequired && !ageConfirmed) ||
+                Boolean(
+                  joinPolicy &&
+                    (joinPolicy.termsMarkdown || joinPolicy.privacyMarkdown) &&
+                    !agreementConfirmed,
+                )
               }
               type="submit"
             >
diff --git a/desktop/src/features/communities/ui/CommunityEditForm.tsx b/desktop/src/features/communities/ui/CommunityEditForm.tsx
index 4c40ce7e75..4c94acf102 100644
--- a/desktop/src/features/communities/ui/CommunityEditForm.tsx
+++ b/desktop/src/features/communities/ui/CommunityEditForm.tsx
@@ -1,12 +1,20 @@
 import * as React from "react";
+import { AnimatePresence, motion, useReducedMotion } from "motion/react";
 import { inviteErrorMessage } from "@/shared/api/inviteHelpers";
-import { getJoinPolicy, type JoinPolicy } from "@/shared/api/invites";
+import {
+  getJoinPolicy,
+  isJoinPolicyDiscoveryCandidate,
+  type JoinPolicy,
+} from "@/shared/api/invites";
 import { Button } from "@/shared/ui/button";
 import { Input } from "@/shared/ui/input";
 import { Spinner } from "@/shared/ui/spinner";
 import { JoinPolicyNotice } from "@/features/onboarding/ui/JoinPolicyNotice";
 import { normalizeRelayUrl, probeRelayReachable } from "../relayProbe";
 
+const POLICY_DISCOVERY_DELAY_MS = 250;
+const POLICY_REVEAL_EASE = [0.23, 1, 0.32, 1] as const;
+
 export type CommunityEditFormProps = {
   cancelLabel?: string;
   initialName: string;
@@ -39,6 +47,8 @@ export function CommunityEditForm({
     null,
   );
   const [ageConfirmed, setAgeConfirmed] = React.useState(false);
+  const [agreementConfirmed, setAgreementConfirmed] = React.useState(false);
+  const shouldReduceMotion = useReducedMotion();
 
   const cancelRef = React.useRef<(() => void) | null>(null);
 
@@ -48,6 +58,37 @@ export function CommunityEditForm({
     };
   }, []);
 
+  React.useEffect(() => {
+    if (!joinPolicyRequired) return;
+
+    const normalizedUrl = normalizeRelayUrl(relayUrl);
+    if (!normalizedUrl || !isJoinPolicyDiscoveryCandidate(normalizedUrl)) {
+      return;
+    }
+
+    let cancelled = false;
+    const timeoutId = window.setTimeout(() => {
+      void getJoinPolicy(normalizedUrl)
+        .then((policy) => {
+          if (cancelled || !policy) return;
+          setJoinPolicy(policy);
+          setPolicyRelayUrl(normalizedUrl);
+          setAgeConfirmed(false);
+          setAgreementConfirmed(false);
+          setError(null);
+        })
+        .catch(() => {
+          // Background discovery is best-effort. A deliberate submit retries
+          // the request and surfaces any relay error to the user.
+        });
+    }, POLICY_DISCOVERY_DELAY_MS);
+
+    return () => {
+      cancelled = true;
+      window.clearTimeout(timeoutId);
+    };
+  }, [joinPolicyRequired, relayUrl]);
+
   const handleSubmit = React.useCallback(
     async (event: React.FormEvent<HTMLFormElement>) => {
       event.preventDefault();
@@ -77,12 +118,20 @@ export function CommunityEditForm({
             setJoinPolicy(policy);
             setPolicyRelayUrl(normalizedUrl);
             setAgeConfirmed(false);
+            setAgreementConfirmed(false);
             return;
           }
           if (policy.ageAttestationRequired && !ageConfirmed) {
             setError("Confirm that you are at least 18 years old.");
             return;
           }
+          if (
+            (policy.termsMarkdown || policy.privacyMarkdown) &&
+            !agreementConfirmed
+          ) {
+            setError("Agree to the Terms of Service and Privacy Policy.");
+            return;
+          }
         } catch (policyError) {
           setError(inviteErrorMessage(policyError));
           return;
@@ -118,6 +167,7 @@ export function CommunityEditForm({
     },
     [
       ageConfirmed,
+      agreementConfirmed,
       joinPolicy,
       joinPolicyRequired,
       name,
@@ -188,6 +238,7 @@ export function CommunityEditForm({
             setJoinPolicy(null);
             setPolicyRelayUrl(null);
             setAgeConfirmed(false);
+            setAgreementConfirmed(false);
           }}
           placeholder="wss://relay.example.com"
           type="text"
@@ -195,17 +246,60 @@ export function CommunityEditForm({
         />
       </div>
 
-      {joinPolicy && policyRelayUrl ? (
-        <JoinPolicyNotice
-          ageConfirmed={ageConfirmed}
-          onAgeConfirmedChange={(confirmed) => {
-            setAgeConfirmed(confirmed);
-            setError(null);
-          }}
-          policy={joinPolicy}
-          relayWsUrl={policyRelayUrl}
-        />
-      ) : null}
+      <AnimatePresence initial={false}>
+        {joinPolicy && policyRelayUrl ? (
+          <motion.div
+            animate={{
+              height: "auto",
+              marginTop: 0,
+              opacity: 1,
+              transform: "translateY(0rem)",
+            }}
+            className="overflow-hidden"
+            exit={
+              shouldReduceMotion
+                ? { height: 0, marginTop: "-1rem", opacity: 0 }
+                : {
+                    height: 0,
+                    marginTop: "-1rem",
+                    opacity: 0,
+                    transform: "translateY(-0.25rem)",
+                  }
+            }
+            initial={
+              shouldReduceMotion
+                ? false
+                : {
+                    height: 0,
+                    marginTop: "-1rem",
+                    opacity: 0,
+                    transform: "translateY(-0.25rem)",
+                  }
+            }
+            key={`${policyRelayUrl}:${joinPolicy.version}`}
+            transition={
+              shouldReduceMotion
+                ? { duration: 0 }
+                : { duration: 0.22, ease: POLICY_REVEAL_EASE }
+            }
+          >
+            <JoinPolicyNotice
+              ageConfirmed={ageConfirmed}
+              agreementConfirmed={agreementConfirmed}
+              onAgeConfirmedChange={(confirmed) => {
+                setAgeConfirmed(confirmed);
+                setError(null);
+              }}
+              onAgreementConfirmedChange={(confirmed) => {
+                setAgreementConfirmed(confirmed);
+                setError(null);
+              }}
+              policy={joinPolicy}
+              relayWsUrl={policyRelayUrl}
+            />
+          </motion.div>
+        ) : null}
+      </AnimatePresence>
 
       <div className="flex w-full flex-col gap-3 pt-1">
         <Button
@@ -214,7 +308,12 @@ export function CommunityEditForm({
             isBusy ||
             !name.trim() ||
             !relayUrl.trim() ||
-            Boolean(joinPolicy?.ageAttestationRequired && !ageConfirmed)
+            Boolean(joinPolicy?.ageAttestationRequired && !ageConfirmed) ||
+            Boolean(
+              joinPolicy &&
+                (joinPolicy.termsMarkdown || joinPolicy.privacyMarkdown) &&
+                !agreementConfirmed,
+            )
           }
           type="submit"
         >
diff --git a/desktop/src/features/communities/ui/WelcomeSetup.tsx b/desktop/src/features/communities/ui/WelcomeSetup.tsx
index 4bc1ea15fe..d50f248f9a 100644
--- a/desktop/src/features/communities/ui/WelcomeSetup.tsx
+++ b/desktop/src/features/communities/ui/WelcomeSetup.tsx
@@ -5,14 +5,9 @@ import {
   getIdentity,
   importIdentity as tauriImportIdentity,
 } from "@/shared/api/tauriIdentity";
-import {
-  claimInvite,
-  getJoinPolicy,
-  type JoinPolicy,
-} from "@/shared/api/invites";
+import { claimInvite } from "@/shared/api/invites";
 import { inviteErrorMessage } from "@/shared/api/inviteHelpers";
 import { InviteRedeemForm } from "@/features/onboarding/ui/InviteRedeemForm";
-import { JoinPolicyNotice } from "@/features/onboarding/ui/JoinPolicyNotice";
 import { NostrKeyImportForm } from "@/features/onboarding/ui/NostrKeyImportForm";
 import {
   type OnboardingTransitionDirection,
@@ -53,23 +48,15 @@ function wait(ms: number) {
 }
 
 function NostrKeyImportPage({
-  ageConfirmed,
   connectionError,
   disabled,
-  joinPolicy,
-  onAgeConfirmedChange,
   onBack,
   onImport,
-  relayWsUrl,
 }: {
-  ageConfirmed: boolean;
   connectionError: string | null;
   disabled: boolean;
-  joinPolicy: JoinPolicy | null | undefined;
-  onAgeConfirmedChange: (confirmed: boolean) => void;
   onBack: () => void;
   onImport: (nsec: string) => Promise<void>;
-  relayWsUrl: string;
 }) {
   return (
     <OnboardingSlideTransition
@@ -82,29 +69,13 @@ function NostrKeyImportPage({
           Use your existing key
         </h1>
         <p className="mt-3 text-sm leading-6 text-muted-foreground">
-          Import your Nostr private key to use that identity with Buzz. If this
-          key already has a profile on the relay, your name and avatar are
-          restored automatically.
+          Import your Nostr private key to use that identity with Buzz. Your
+          profile can be restored after you choose a community.
         </p>
       </div>
 
-      {joinPolicy ? (
-        <div className="mt-6 w-full">
-          <JoinPolicyNotice
-            ageConfirmed={ageConfirmed}
-            onAgeConfirmedChange={onAgeConfirmedChange}
-            policy={joinPolicy}
-            relayWsUrl={relayWsUrl}
-          />
-        </div>
-      ) : null}
-
       <NostrKeyImportForm
-        disabled={
-          disabled ||
-          joinPolicy === undefined ||
-          Boolean(joinPolicy?.ageAttestationRequired && !ageConfirmed)
-        }
+        disabled={disabled}
         errorMessage={connectionError}
         onBack={onBack}
         onImport={onImport}
@@ -125,36 +96,9 @@ export function WelcomeSetup({
   const [error, setError] = React.useState<string | null>(null);
   const [isRedeeming, setIsRedeeming] = React.useState(false);
   const [inviteError, setInviteError] = React.useState<string | null>(null);
-  const [defaultJoinPolicy, setDefaultJoinPolicy] = React.useState<
-    JoinPolicy | null | undefined
-  >(undefined);
-  const [defaultAgeConfirmed, setDefaultAgeConfirmed] = React.useState(false);
+  const [identityImported, setIdentityImported] = React.useState(false);
   const systemColorScheme = useSystemColorScheme();
 
-  React.useEffect(() => {
-    let cancelled = false;
-    setDefaultAgeConfirmed(false);
-    if (isLocalDevRelayUrl(defaultRelayUrl)) {
-      setDefaultJoinPolicy(null);
-      return;
-    }
-    setDefaultJoinPolicy(undefined);
-    void getJoinPolicy(defaultRelayUrl)
-      .then((policy) => {
-        if (!cancelled) {
-          setDefaultJoinPolicy(policy);
-        }
-      })
-      .catch((policyError) => {
-        if (!cancelled) {
-          setError(inviteErrorMessage(policyError));
-        }
-      });
-    return () => {
-      cancelled = true;
-    };
-  }, [defaultRelayUrl]);
-
   const handleConnect = React.useCallback(
     async (relayUrl: string, communityName?: string, pubkey?: string) => {
       const trimmedUrl = relayUrl.trim();
@@ -206,13 +150,13 @@ export function WelcomeSetup({
     [onComplete],
   );
 
-  const handleNostrImport = React.useCallback(
-    async (nsec: string) => {
-      const identity = await tauriImportIdentity(nsec);
-      await handleConnect(defaultRelayUrl, undefined, identity.pubkey);
-    },
-    [defaultRelayUrl, handleConnect],
-  );
+  const handleNostrImport = React.useCallback(async (nsec: string) => {
+    await tauriImportIdentity(nsec);
+    setIdentityImported(true);
+    setError(null);
+    setTransitionMode("backward");
+    setPage("welcome");
+  }, []);
 
   const handleWelcomeInviteRedeem = React.useCallback(
     async (relayWsUrl: string, code: string, policyReceipt?: string) => {
@@ -244,6 +188,7 @@ export function WelcomeSetup({
 
   const showNostrKeyPage = React.useCallback(() => {
     setError(null);
+    setIdentityImported(false);
     setTransitionMode("forward");
     setPage("nostr-key");
   }, []);
@@ -304,39 +249,16 @@ export function WelcomeSetup({
               Choose your first community to get started.
             </p>
 
-            <div className="mt-8 flex w-full flex-col gap-3">
-              {defaultJoinPolicy ? (
-                <JoinPolicyNotice
-                  ageConfirmed={defaultAgeConfirmed}
-                  onAgeConfirmedChange={setDefaultAgeConfirmed}
-                  policy={defaultJoinPolicy}
-                  relayWsUrl={defaultRelayUrl}
-                />
-              ) : null}
-              {isLocalDevRelayUrl(defaultRelayUrl) ? null : (
-                <Button
-                  className="h-10 w-full"
-                  aria-disabled={isConnecting}
-                  disabled={
-                    defaultJoinPolicy === undefined ||
-                    Boolean(
-                      defaultJoinPolicy?.ageAttestationRequired &&
-                        !defaultAgeConfirmed,
-                    )
-                  }
-                  onClick={() => {
-                    if (isConnecting) {
-                      return;
-                    }
-                    setError(null);
-                    void handleConnect(defaultRelayUrl);
-                  }}
-                  type="button"
-                >
-                  Continue with default community
-                </Button>
-              )}
+            {identityImported ? (
+              <p
+                className="mt-4 max-w-[440px] text-sm text-muted-foreground"
+                role="status"
+              >
+                Your key is ready. Choose a community to continue.
+              </p>
+            ) : null}
 
+            <div className="mt-8 flex w-full flex-col gap-3">
               <Button
                 className="h-10 w-full"
                 aria-disabled={isConnecting}
@@ -347,7 +269,6 @@ export function WelcomeSetup({
                   showCreateCommunityPage();
                 }}
                 type="button"
-                variant="secondary"
               >
                 Join a community
               </Button>
@@ -417,7 +338,7 @@ export function WelcomeSetup({
                 onSubmit={(name, url) => {
                   void handleConnect(url, name);
                 }}
-                submitLabel="Join a community"
+                submitLabel="Join community"
               />
               {error ? (
                 <p className="mt-2 text-center text-sm text-destructive">
@@ -464,14 +385,10 @@ export function WelcomeSetup({
           </OnboardingSlideTransition>
         ) : (
           <NostrKeyImportPage
-            ageConfirmed={defaultAgeConfirmed}
             connectionError={error}
             disabled={isConnecting}
-            joinPolicy={defaultJoinPolicy}
-            onAgeConfirmedChange={setDefaultAgeConfirmed}
             onBack={showWelcomePage}
             onImport={handleNostrImport}
-            relayWsUrl={defaultRelayUrl}
           />
         )}
       </div>
diff --git a/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx b/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx
index e6663c3fbc..fc9e9cde7d 100644
--- a/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx
+++ b/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx
@@ -1,4 +1,5 @@
 import * as React from "react";
+import { AnimatePresence, motion, useReducedMotion } from "motion/react";
 
 import {
   inviteErrorMessage,
@@ -7,6 +8,7 @@ import {
 import {
   acceptJoinPolicy,
   getJoinPolicy,
+  isJoinPolicyDiscoveryCandidate,
   type JoinPolicy,
 } from "@/shared/api/invites";
 import { Button } from "@/shared/ui/button";
@@ -14,6 +16,9 @@ import { Input } from "@/shared/ui/input";
 import { Spinner } from "@/shared/ui/spinner";
 import { JoinPolicyNotice } from "./JoinPolicyNotice";
 
+const POLICY_DISCOVERY_DELAY_MS = 250;
+const POLICY_REVEAL_EASE = [0.23, 1, 0.32, 1] as const;
+
 type InviteRedeemFormProps = {
   /**
    * Pre-fill and expose a relay URL field for bare-code inputs.
@@ -45,8 +50,10 @@ export function InviteRedeemForm({
     code: string;
   } | null>(null);
   const [ageConfirmed, setAgeConfirmed] = React.useState(false);
+  const [agreementConfirmed, setAgreementConfirmed] = React.useState(false);
   const [policyError, setPolicyError] = React.useState<string | null>(null);
   const [isLoadingPolicy, setIsLoadingPolicy] = React.useState(false);
+  const shouldReduceMotion = useReducedMotion();
 
   const parsed = React.useMemo(
     () => parseInviteInput(inviteInput),
@@ -55,6 +62,36 @@ export function InviteRedeemForm({
   const isBareCode = parsed !== null && !("relayWsUrl" in parsed);
   const needsRelayField = isBareCode && defaultRelayUrl !== undefined;
 
+  React.useEffect(() => {
+    if (!parsed) return;
+
+    const relayWsUrl =
+      "relayWsUrl" in parsed ? parsed.relayWsUrl : bareCodeRelayUrl.trim();
+    if (!relayWsUrl || !isJoinPolicyDiscoveryCandidate(relayWsUrl)) return;
+
+    let cancelled = false;
+    const timeoutId = window.setTimeout(() => {
+      void getJoinPolicy(relayWsUrl)
+        .then((policy) => {
+          if (cancelled || !policy) return;
+          setJoinPolicy(policy);
+          setPolicyInvite({ relayWsUrl, code: parsed.code });
+          setAgeConfirmed(false);
+          setAgreementConfirmed(false);
+          setPolicyError(null);
+        })
+        .catch(() => {
+          // Background discovery is best-effort. A deliberate submit retries
+          // the request and surfaces any relay error to the user.
+        });
+    }, POLICY_DISCOVERY_DELAY_MS);
+
+    return () => {
+      cancelled = true;
+      window.clearTimeout(timeoutId);
+    };
+  }, [bareCodeRelayUrl, parsed]);
+
   const canSubmit =
     parsed !== null &&
     ("relayWsUrl" in parsed ||
@@ -87,6 +124,7 @@ export function InviteRedeemForm({
           setJoinPolicy(policy);
           setPolicyInvite({ relayWsUrl, code: parsed.code });
           setAgeConfirmed(false);
+          setAgreementConfirmed(false);
           return;
         }
 
@@ -94,6 +132,13 @@ export function InviteRedeemForm({
           setPolicyError("Confirm that you are at least 18 years old.");
           return;
         }
+        if (
+          (policy.termsMarkdown || policy.privacyMarkdown) &&
+          !agreementConfirmed
+        ) {
+          setPolicyError("Agree to the Terms of Service and Privacy Policy.");
+          return;
+        }
 
         const receipt = await acceptJoinPolicy(
           relayWsUrl,
@@ -110,6 +155,7 @@ export function InviteRedeemForm({
     },
     [
       ageConfirmed,
+      agreementConfirmed,
       bareCodeRelayUrl,
       joinPolicy,
       onRedeem,
@@ -140,6 +186,7 @@ export function InviteRedeemForm({
             setJoinPolicy(null);
             setPolicyInvite(null);
             setAgeConfirmed(false);
+            setAgreementConfirmed(false);
             setPolicyError(null);
           }}
           placeholder="https://relay.example.com/invite/abc123 or paste a code"
@@ -166,6 +213,7 @@ export function InviteRedeemForm({
               setJoinPolicy(null);
               setPolicyInvite(null);
               setAgeConfirmed(false);
+              setAgreementConfirmed(false);
               setPolicyError(null);
             }}
             placeholder="wss://relay.example.com"
@@ -175,18 +223,6 @@ export function InviteRedeemForm({
         </div>
       ) : null}
 
-      {joinPolicy && policyInvite ? (
-        <JoinPolicyNotice
-          ageConfirmed={ageConfirmed}
-          onAgeConfirmedChange={(confirmed) => {
-            setAgeConfirmed(confirmed);
-            setPolicyError(null);
-          }}
-          policy={joinPolicy}
-          relayWsUrl={policyInvite.relayWsUrl}
-        />
-      ) : null}
-
       {policyError ? (
         <p className="text-center text-sm text-destructive">{policyError}</p>
       ) : null}
@@ -195,6 +231,61 @@ export function InviteRedeemForm({
         <p className="text-center text-sm text-destructive">{error}</p>
       ) : null}
 
+      <AnimatePresence initial={false}>
+        {joinPolicy && policyInvite ? (
+          <motion.div
+            animate={{
+              height: "auto",
+              marginTop: 0,
+              opacity: 1,
+              transform: "translateY(0rem)",
+            }}
+            className="overflow-hidden"
+            exit={
+              shouldReduceMotion
+                ? { height: 0, marginTop: "-0.75rem", opacity: 0 }
+                : {
+                    height: 0,
+                    marginTop: "-0.75rem",
+                    opacity: 0,
+                    transform: "translateY(-0.25rem)",
+                  }
+            }
+            initial={
+              shouldReduceMotion
+                ? false
+                : {
+                    height: 0,
+                    marginTop: "-0.75rem",
+                    opacity: 0,
+                    transform: "translateY(-0.25rem)",
+                  }
+            }
+            key={`${policyInvite.relayWsUrl}:${joinPolicy.version}`}
+            transition={
+              shouldReduceMotion
+                ? { duration: 0 }
+                : { duration: 0.22, ease: POLICY_REVEAL_EASE }
+            }
+          >
+            <JoinPolicyNotice
+              ageConfirmed={ageConfirmed}
+              agreementConfirmed={agreementConfirmed}
+              onAgeConfirmedChange={(confirmed) => {
+                setAgeConfirmed(confirmed);
+                setPolicyError(null);
+              }}
+              onAgreementConfirmedChange={(confirmed) => {
+                setAgreementConfirmed(confirmed);
+                setPolicyError(null);
+              }}
+              policy={joinPolicy}
+              relayWsUrl={policyInvite.relayWsUrl}
+            />
+          </motion.div>
+        ) : null}
+      </AnimatePresence>
+
       <Button
         className="h-10 w-full"
         data-testid="invite-redeem-submit"
@@ -202,7 +293,12 @@ export function InviteRedeemForm({
           !canSubmit ||
           isRedeeming ||
           isLoadingPolicy ||
-          Boolean(joinPolicy?.ageAttestationRequired && !ageConfirmed)
+          Boolean(joinPolicy?.ageAttestationRequired && !ageConfirmed) ||
+          Boolean(
+            joinPolicy &&
+              (joinPolicy.termsMarkdown || joinPolicy.privacyMarkdown) &&
+              !agreementConfirmed,
+          )
         }
         type="submit"
       >
diff --git a/desktop/src/features/onboarding/ui/JoinPolicyNotice.tsx b/desktop/src/features/onboarding/ui/JoinPolicyNotice.tsx
index 4b267695c6..4d5e6771b6 100644
--- a/desktop/src/features/onboarding/ui/JoinPolicyNotice.tsx
+++ b/desktop/src/features/onboarding/ui/JoinPolicyNotice.tsx
@@ -1,11 +1,15 @@
+import * as React from "react";
 import { openUrl } from "@tauri-apps/plugin-opener";
 
 import { joinPolicyDocumentUrl, type JoinPolicy } from "@/shared/api/invites";
 import { Button } from "@/shared/ui/button";
+import { Checkbox } from "@/shared/ui/checkbox";
 
 type JoinPolicyNoticeProps = {
   ageConfirmed: boolean;
+  agreementConfirmed: boolean;
   onAgeConfirmedChange: (confirmed: boolean) => void;
+  onAgreementConfirmedChange: (confirmed: boolean) => void;
   policy: JoinPolicy;
   /** Relay hosting the policy documents the links below point at. */
   relayWsUrl: string;
@@ -23,54 +27,81 @@ type JoinPolicyNoticeProps = {
  */
 export function JoinPolicyNotice({
   ageConfirmed,
+  agreementConfirmed,
   onAgeConfirmedChange,
+  onAgreementConfirmedChange,
   policy,
   relayWsUrl,
 }: JoinPolicyNoticeProps) {
+  const ageConfirmationId = React.useId();
+  const agreementConfirmationId = React.useId();
+
   return (
-    <div className="space-y-3 rounded-md border p-3 text-left text-sm">
+    <div className="space-y-3 rounded-xl border border-border/70 bg-muted/30 p-4 text-left">
       {policy.ageAttestationRequired ? (
-        <label className="flex items-start gap-2">
-          <input
+        <div className="flex items-start gap-3">
+          <Checkbox
             checked={ageConfirmed}
-            className="mt-1"
-            onChange={(event) => onAgeConfirmedChange(event.target.checked)}
-            type="checkbox"
+            className="mt-0.5"
+            id={ageConfirmationId}
+            onCheckedChange={(checked) =>
+              onAgeConfirmedChange(checked === true)
+            }
           />
-          <span>I am 18 years of age or older.</span>
-        </label>
+          <label
+            className="cursor-pointer text-xs leading-5 text-muted-foreground"
+            htmlFor={ageConfirmationId}
+          >
+            I am 18 years of age or older.
+          </label>
+        </div>
       ) : null}
 
       {policy.termsMarkdown || policy.privacyMarkdown ? (
-        <p className="text-muted-foreground">
-          By proceeding you agree to the Buzz{" "}
-          {policy.termsMarkdown ? (
-            <Button
-              className="h-auto p-0 align-baseline"
-              onClick={() =>
-                void openUrl(joinPolicyDocumentUrl(relayWsUrl, "terms"))
-              }
-              type="button"
-              variant="link"
-            >
-              Terms of Service
-            </Button>
-          ) : null}
-          {policy.termsMarkdown && policy.privacyMarkdown ? " and " : null}
-          {policy.privacyMarkdown ? (
-            <Button
-              className="h-auto p-0 align-baseline"
-              onClick={() =>
-                void openUrl(joinPolicyDocumentUrl(relayWsUrl, "privacy"))
-              }
-              type="button"
-              variant="link"
-            >
-              Privacy Policy
-            </Button>
-          ) : null}
-          .
-        </p>
+        <div className="flex items-start gap-3">
+          <Checkbox
+            checked={agreementConfirmed}
+            className="mt-0.5"
+            id={agreementConfirmationId}
+            onCheckedChange={(checked) =>
+              onAgreementConfirmedChange(checked === true)
+            }
+          />
+          <label
+            className="cursor-pointer text-xs leading-5 text-muted-foreground"
+            htmlFor={agreementConfirmationId}
+          >
+            I agree to the Buzz{" "}
+            {policy.termsMarkdown ? (
+              <Button
+                className="h-auto p-0 align-baseline text-xs no-underline hover:underline focus-visible:no-underline"
+                onClick={(event) => {
+                  event.preventDefault();
+                  void openUrl(joinPolicyDocumentUrl(relayWsUrl, "terms"));
+                }}
+                type="button"
+                variant="link"
+              >
+                Terms of Service
+              </Button>
+            ) : null}
+            {policy.termsMarkdown && policy.privacyMarkdown ? " and " : null}
+            {policy.privacyMarkdown ? (
+              <Button
+                className="h-auto p-0 align-baseline text-xs no-underline hover:underline focus-visible:no-underline"
+                onClick={(event) => {
+                  event.preventDefault();
+                  void openUrl(joinPolicyDocumentUrl(relayWsUrl, "privacy"));
+                }}
+                type="button"
+                variant="link"
+              >
+                Privacy Policy
+              </Button>
+            ) : null}
+            .
+          </label>
+        </div>
       ) : null}
     </div>
   );
diff --git a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx
index afb99a5ff5..2f4d5ade44 100644
--- a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx
+++ b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx
@@ -41,7 +41,7 @@ export function NostrKeyImportForm({
   const trimmedInput = nsecInput.trim();
   const hasInput = trimmedInput.length > 0;
   const isValid = previewNpub !== null;
-  const isBusy = disabled || isImporting;
+  const isInteractionDisabled = disabled || isImporting;
   const showInvalidHint = hasInput && !isValid && trimmedInput.length >= 5;
   const errorMessage = importError ?? externalErrorMessage;
 
@@ -50,12 +50,12 @@ export function NostrKeyImportForm({
   }, []);
 
   const openFilePicker = React.useCallback(() => {
-    if (isBusy) {
+    if (isInteractionDisabled) {
       return;
     }
 
     fileInputRef.current?.click();
-  }, [isBusy]);
+  }, [isInteractionDisabled]);
 
   const handleFiles = React.useCallback(async (files: FileList | null) => {
     const file = files?.[0];
@@ -98,7 +98,7 @@ export function NostrKeyImportForm({
       await onImport(trimmedInput);
     } catch (error) {
       setImportError(
-        error instanceof Error ? error.message : "Failed to import key.",
+        error instanceof Error ? error.message : "Couldn't import this key.",
       );
     } finally {
       setIsImporting(false);
@@ -141,7 +141,7 @@ export function NostrKeyImportForm({
       <input
         accept=".key,text/plain"
         className="sr-only"
-        disabled={isBusy}
+        disabled={isInteractionDisabled}
         onChange={(event) => {
           void handleFiles(event.currentTarget.files);
           event.currentTarget.value = "";
@@ -159,12 +159,12 @@ export function NostrKeyImportForm({
         )}
         data-dragging={isDragging ? "true" : undefined}
         data-testid="nostr-import-drop"
-        disabled={isBusy}
+        disabled={isInteractionDisabled}
         onClick={openFilePicker}
         onDragEnter={(event) => {
           event.preventDefault();
           event.stopPropagation();
-          if (!isBusy) {
+          if (!isInteractionDisabled) {
             setIsDragging(true);
           }
         }}
@@ -181,7 +181,7 @@ export function NostrKeyImportForm({
         onDragOver={(event) => {
           event.preventDefault();
           event.stopPropagation();
-          if (!isBusy) {
+          if (!isInteractionDisabled) {
             event.dataTransfer.dropEffect = "copy";
           }
         }}
@@ -189,7 +189,7 @@ export function NostrKeyImportForm({
           event.preventDefault();
           event.stopPropagation();
           setIsDragging(false);
-          if (isBusy) {
+          if (isInteractionDisabled) {
             return;
           }
           void handleFiles(event.dataTransfer.files);
@@ -250,10 +250,10 @@ export function NostrKeyImportForm({
         <Button
           className="h-10 w-full"
           data-testid="nostr-import-submit"
-          disabled={!isValid || isBusy}
+          disabled={!isValid || isInteractionDisabled}
           type="submit"
         >
-          {isBusy ? (
+          {isImporting ? (
             <Spinner aria-label="Importing key" className="h-4 w-4 border-2" />
           ) : (
             "Continue with this key"
@@ -262,7 +262,7 @@ export function NostrKeyImportForm({
 
         <Button
           className="h-10 w-full text-muted-foreground hover:text-accent-foreground"
-          disabled={isBusy}
+          disabled={isImporting}
           onClick={onBack}
           type="button"
           variant="ghost"
diff --git a/desktop/src/shared/api/invites.ts b/desktop/src/shared/api/invites.ts
index afb27a5094..f2cca02d8c 100644
--- a/desktop/src/shared/api/invites.ts
+++ b/desktop/src/shared/api/invites.ts
@@ -100,6 +100,21 @@ export function joinPolicyDocumentUrl(
   return `${base.replace(/\/+$/, "")}/api/join-policy/${document}`;
 }
 
+/** Whether a normalized relay URL is complete enough for background policy discovery. */
+export function isJoinPolicyDiscoveryCandidate(relayWsUrl: string): boolean {
+  try {
+    const url = new URL(relayWsUrl);
+    if (url.protocol !== "ws:" && url.protocol !== "wss:") return false;
+    return (
+      url.hostname === "localhost" ||
+      url.hostname === "127.0.0.1" ||
+      url.hostname.includes(".")
+    );
+  } catch {
+    return false;
+  }
+}
+
 /** Fetch relay-hosted policy content for any join surface. */
 export async function getJoinPolicy(
   relayWsUrl: string,
diff --git a/desktop/src/shared/ui/checkbox.tsx b/desktop/src/shared/ui/checkbox.tsx
index 41044b2ab0..fbfa2da490 100644
--- a/desktop/src/shared/ui/checkbox.tsx
+++ b/desktop/src/shared/ui/checkbox.tsx
@@ -1,28 +1,53 @@
 import * as React from "react";
 import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
-import { Check } from "lucide-react";
+import { motion, useReducedMotion } from "motion/react";
 
 import { cn } from "@/shared/lib/cn";
 
 const Checkbox = React.forwardRef<
   React.ElementRef<typeof CheckboxPrimitive.Root>,
   React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
->(({ className, ...props }, ref) => (
-  <CheckboxPrimitive.Root
-    ref={ref}
-    className={cn(
-      "peer h-4 w-4 shrink-0 rounded-xs border border-primary ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
-      className,
-    )}
-    {...props}
-  >
-    <CheckboxPrimitive.Indicator
-      className={cn("flex items-center justify-center text-current")}
+>(({ className, ...props }, ref) => {
+  const shouldReduceMotion = useReducedMotion();
+
+  return (
+    <CheckboxPrimitive.Root
+      ref={ref}
+      className={cn(
+        "peer h-4 w-4 shrink-0 rounded-xs border border-primary ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
+        className,
+      )}
+      {...props}
     >
-      <Check className="h-4 w-4" />
-    </CheckboxPrimitive.Indicator>
-  </CheckboxPrimitive.Root>
-));
+      <CheckboxPrimitive.Indicator className="flex items-center justify-center text-current">
+        <svg
+          aria-hidden="true"
+          className="h-4 w-4"
+          fill="none"
+          viewBox="0 0 24 24"
+        >
+          <motion.path
+            animate={{ opacity: 1, pathLength: 1 }}
+            d="m5 12 4 4L19 6"
+            initial={shouldReduceMotion ? false : { opacity: 0, pathLength: 0 }}
+            stroke="currentColor"
+            strokeLinecap="round"
+            strokeLinejoin="round"
+            strokeWidth="2"
+            transition={
+              shouldReduceMotion
+                ? { duration: 0 }
+                : {
+                    duration: 0.18,
+                    ease: [0.23, 1, 0.32, 1],
+                  }
+            }
+          />
+        </svg>
+      </CheckboxPrimitive.Indicator>
+    </CheckboxPrimitive.Root>
+  );
+});
 Checkbox.displayName = CheckboxPrimitive.Root.displayName;
 
 export { Checkbox };
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index 71e2eff5df..a274e15c3d 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -8562,6 +8562,11 @@ export function maybeInstallE2eTauriMocks() {
         return importMockIdentity(
           (payload as { nsec?: string } | null)?.nsec ?? "",
         );
+      case "validate_repos_dir":
+        // The browser harness has no host filesystem to validate. Treat the
+        // seeded empty/default path as valid so Add Community can continue to
+        // relay-policy discovery.
+        return;
       case "apply_workspace": {
         const applyDelayMs = activeConfig?.mock?.applyCommunityDelayMs ?? 0;
         if (applyDelayMs > 0) {
diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts
index 0f98c6d01a..75c0185fe1 100644
--- a/desktop/tests/e2e/onboarding.spec.ts
+++ b/desktop/tests/e2e/onboarding.spec.ts
@@ -550,70 +550,55 @@ test("completed users skip the loading gate while profile is still settling", as
   await expectHomeView(page);
 });
 
-test("first-run default community handoff gives immediate stepper feedback", async ({
-  page,
-}) => {
-  // Use a blank-username identity so the profile has no display name and
-  // the auto-skip logic does not fire — we need to stay in onboarding to
-  // verify the stepper UX.
+test("welcome requires an explicit community destination", async ({ page }) => {
   await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
-  await installMockBridge(
-    page,
-    {
-      profileReadDelayMs: 2_000,
-    },
-    {
-      relayWsUrl: "wss://default.example.com",
-      skipOnboardingSeed: true,
-      skipCommunitySeed: true,
-    },
-  );
+  await installMockBridge(page, undefined, {
+    relayWsUrl: "wss://default.example.com",
+    skipOnboardingSeed: true,
+    skipCommunitySeed: true,
+  });
+  let policyRequestCount = 0;
   await page.route(
     "https://default.example.com/api/join-policy",
     async (route) => {
+      policyRequestCount += 1;
       await route.fulfill({
         status: 200,
         contentType: "application/json",
-        body: "{}",
+        body: JSON.stringify({
+          policy: {
+            terms_markdown: "# Terms",
+            privacy_markdown: "# Privacy",
+            age_attestation_required: true,
+            version: "policy-v1",
+          },
+        }),
       });
     },
   );
   await page.goto("/");
 
   await expect(page.getByText("Welcome to Buzz")).toBeVisible();
-  await page
-    .getByRole("button", { name: "Continue with default community" })
-    .click();
-
-  await page.waitForTimeout(80);
-  await expect(page.getByRole("button", { name: "Connecting..." })).toHaveCount(
+  await expect(
+    page.getByRole("button", { name: "Continue with default community" }),
+  ).toHaveCount(0);
+  await expect(page.getByLabel("I am 18 years of age or older.")).toHaveCount(
     0,
   );
   await expect(
-    page.getByRole("button", { name: "Continue with default community" }),
+    page.getByLabel("I agree to the Buzz Terms of Service and Privacy Policy."),
+  ).toHaveCount(0);
+  await expect(
+    page.getByRole("button", { name: "Join a community" }),
+  ).toBeVisible();
+  await expect(
+    page.getByRole("button", { name: "Have an invite?" }),
   ).toBeVisible();
-  await expect(page.getByRole("progressbar")).toHaveAttribute(
-    "aria-valuenow",
-    "2",
-  );
-  await page.waitForTimeout(240);
   await expect(page.getByTestId("welcome-continue-nostr")).toBeVisible();
-  await expect(page.getByRole("progressbar")).toHaveAttribute(
-    "aria-valuenow",
-    "2",
-  );
-
-  const nameInput = page.getByTestId("onboarding-display-name");
-  await expect(nameInput).toHaveValue("");
-  await expect(page.getByRole("progressbar")).toHaveAttribute(
-    "aria-valuenow",
-    "2",
-  );
-  await expect(nameInput).toHaveAttribute("autocomplete", "off");
-  await expect(page.getByTestId("onboarding-back")).toBeVisible();
+  expect(policyRequestCount).toBe(0);
 });
 
-test("welcome join policy documents open externally and keep onboarding mounted", async ({
+test("destination policy documents open externally and keep onboarding mounted", async ({
   page,
 }) => {
   await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
@@ -623,7 +608,7 @@ test("welcome join policy documents open externally and keep onboarding mounted"
     skipCommunitySeed: true,
   });
   await page.route(
-    "https://default.example.com/api/join-policy",
+    "https://policy.example.com/api/join-policy",
     async (route) => {
       await route.fulfill({
         status: 200,
@@ -641,11 +626,22 @@ test("welcome join policy documents open externally and keep onboarding mounted"
   );
 
   await page.goto("/");
-  await expect(page.getByText("Welcome to Buzz")).toBeVisible();
+  await page.getByRole("button", { name: "Join a community" }).click();
+  await page.locator("#community-edit-url").fill("wss://policy.example.com");
   await expect(
     page.getByRole("button", { name: "Terms of Service" }),
   ).toBeVisible();
 
+  const termsLink = page.getByRole("button", { name: "Terms of Service" });
+  const privacyLink = page.getByRole("button", { name: "Privacy Policy" });
+  await expect(termsLink).toHaveCSS("text-decoration-line", "none");
+  await expect(privacyLink).toHaveCSS("text-decoration-line", "none");
+  await termsLink.hover();
+  await expect(termsLink).toHaveCSS("text-decoration-line", "underline");
+  await page.mouse.move(0, 0);
+  await privacyLink.hover();
+  await expect(privacyLink).toHaveCSS("text-decoration-line", "underline");
+
   await page.evaluate(async () => {
     const invoke = (
       window as Window & {
@@ -659,7 +655,9 @@ test("welcome join policy documents open externally and keep onboarding mounted"
   await page.getByRole("button", { name: "Terms of Service" }).click();
   await page.getByRole("button", { name: "Privacy Policy" }).click();
 
-  await expect(page.getByText("Welcome to Buzz")).toBeVisible();
+  await expect(
+    page.getByRole("heading", { name: "Join a community" }),
+  ).toBeVisible();
   await expect(page.locator("#root")).not.toBeEmpty();
   await expect(page.getByRole("dialog")).toHaveCount(0);
 
@@ -674,12 +672,14 @@ test("welcome join policy documents open externally and keep onboarding mounted"
     return (await invoke?.("get_e2e_opened_external_urls")) as string[];
   });
   expect(openedUrls).toEqual([
-    "https://default.example.com/api/join-policy/terms",
-    "https://default.example.com/api/join-policy/privacy",
+    "https://policy.example.com/api/join-policy/terms",
+    "https://policy.example.com/api/join-policy/privacy",
   ]);
 });
 
-test("welcome can continue using an existing Nostr key", async ({ page }) => {
+test("existing-key import returns to community selection without join consent", async ({
+  page,
+}) => {
   await installMockBridge(page, undefined, {
     relayWsUrl: "wss://default.example.com",
     skipOnboardingSeed: true,
@@ -691,37 +691,66 @@ test("welcome can continue using an existing Nostr key", async ({ page }) => {
       await route.fulfill({
         status: 200,
         contentType: "application/json",
-        body: "{}",
+        body: JSON.stringify({
+          policy: {
+            terms_markdown: "# Terms",
+            privacy_markdown: "# Privacy",
+            age_attestation_required: true,
+            version: "policy-v1",
+          },
+        }),
       });
     },
   );
-  await page.goto("/");
 
+  await page.goto("/");
   await page.getByTestId("welcome-continue-nostr").click();
-  await expect(
-    page.getByRole("heading", { name: "Use your existing key" }),
-  ).toBeVisible();
 
   const importedNsec = nsecEncode(hexToBytes(TEST_IDENTITIES.alice.privateKey));
   await page.getByTestId("nostr-import-nsec-input").fill(importedNsec);
-  await expect(page.getByTestId("nostr-import-npub-preview")).toBeVisible();
-  await page.getByTestId("nostr-import-submit").click();
 
-  // Alice already has a relay profile with a display name, so onboarding
-  // auto-completes after the identity swap.
+  const submit = page.getByTestId("nostr-import-submit");
+  await expect(submit).toBeEnabled();
+  await expect(submit).toHaveText("Continue with this key");
+  await expect(page.getByLabel("Importing key")).toHaveCount(0);
+  await expect(page.getByLabel("I am 18 years of age or older.")).toHaveCount(
+    0,
+  );
+  await expect(
+    page.getByLabel("I agree to the Buzz Terms of Service and Privacy Policy."),
+  ).toHaveCount(0);
+
+  await submit.click();
+
+  await expect(page.getByText("Welcome to Buzz")).toBeVisible();
+  await expect(
+    page.getByText("Your key is ready. Choose a community to continue."),
+  ).toBeVisible();
+  await expect(
+    page.getByRole("button", { name: "Join a community" }),
+  ).toBeVisible();
   await expect
     .poll(() =>
       page.evaluate(() => {
         const rawCommunities = window.localStorage.getItem("buzz-communities");
-        const communities = rawCommunities
-          ? (JSON.parse(rawCommunities) as Array<{ pubkey?: string }>)
-          : [];
-        return communities[0]?.pubkey ?? null;
+        return rawCommunities
+          ? (JSON.parse(rawCommunities) as unknown[]).length
+          : 0;
       }),
     )
-    .toBe(TEST_IDENTITIES.alice.pubkey);
-  await expect(page.getByTestId("onboarding-gate")).toHaveCount(0);
-  await expectHomeView(page);
+    .toBe(0);
+
+  const activeIdentity = await page.evaluate(async () => {
+    const invoke = (
+      window as Window & {
+        __TAURI_INTERNALS__?: {
+          invoke?: (command: string, payload?: unknown) => Promise<unknown>;
+        };
+      }
+    ).__TAURI_INTERNALS__?.invoke;
+    return (await invoke?.("get_identity")) as { pubkey: string };
+  });
+  expect(activeIdentity.pubkey).toBe(TEST_IDENTITIES.alice.pubkey);
 });
 
 test("welcome presents custom community setup as joining a community", async ({
@@ -742,8 +771,52 @@ test("welcome presents custom community setup as joining a community", async ({
     page.getByRole("heading", { name: "Join a community" }),
   ).toBeVisible();
   await expect(
-    page.getByRole("button", { name: "Join a community" }),
+    page.getByRole("button", { name: "Join community" }),
+  ).toBeVisible();
+});
+
+test("join community discovers policy from the pasted relay URL", async ({
+  page,
+}) => {
+  await installMockBridge(page, undefined, {
+    skipOnboardingSeed: true,
+    skipCommunitySeed: true,
+  });
+  await page.route(
+    "https://policy.example.com/api/join-policy",
+    async (route) => {
+      await route.fulfill({
+        status: 200,
+        contentType: "application/json",
+        body: JSON.stringify({
+          policy: {
+            terms_markdown: "# Terms",
+            privacy_markdown: "# Privacy",
+            age_attestation_required: true,
+            version: "policy-v1",
+          },
+        }),
+      });
+    },
+  );
+
+  await page.goto("/");
+  await page.getByRole("button", { name: "Join a community" }).click();
+  await page.locator("#community-edit-name").fill("Block");
+  await page.locator("#community-edit-url").fill("wss://policy.example.com");
+
+  const submit = page.getByRole("button", { name: "Join community" });
+  await expect(page.getByLabel("I am 18 years of age or older.")).toBeVisible();
+  await expect(
+    page.getByLabel("I agree to the Buzz Terms of Service and Privacy Policy."),
   ).toBeVisible();
+  await expect(submit).toBeDisabled();
+
+  const communityConsentBox = await page
+    .getByLabel("I agree to the Buzz Terms of Service and Privacy Policy.")
+    .boundingBox();
+  const communitySubmitBox = await submit.boundingBox();
+  expect(communityConsentBox?.y).toBeLessThan(communitySubmitBox?.y ?? 0);
 });
 
 test("identity fallback text does not count as a real onboarding name", async ({
@@ -1962,10 +2035,21 @@ test("denied on relay A then paste relay B invite URL switches community to B",
   await page
     .getByTestId("invite-redeem-input")
     .fill(`${relayBHttpUrl}/invite/test-invite-code`);
-  await page.getByTestId("invite-redeem-submit").click();
-  await expect(page.getByText("I am 18 years of age or older.")).toBeVisible();
-  await page.getByLabel("I am 18 years of age or older.").check();
-  await page.getByTestId("invite-redeem-submit").click();
+  const inviteAgeConfirmation = page.getByLabel(
+    "I am 18 years of age or older.",
+  );
+  const inviteAgreementConfirmation = page.getByLabel(
+    "I agree to the Buzz Terms of Service and Privacy Policy.",
+  );
+  const redeemSubmit = page.getByTestId("invite-redeem-submit");
+  await expect(inviteAgeConfirmation).toBeVisible();
+  await expect(redeemSubmit).toBeDisabled();
+  const inviteConsentBox = await inviteAgreementConfirmation.boundingBox();
+  const redeemSubmitBox = await redeemSubmit.boundingBox();
+  expect(inviteConsentBox?.y).toBeLessThan(redeemSubmitBox?.y ?? 0);
+  await inviteAgeConfirmation.check();
+  await inviteAgreementConfirmation.check();
+  await redeemSubmit.click();
 
   // After successful claim, the community should switch to relay B's URL.
   await expect
diff --git a/desktop/tests/e2e/sidebar.spec.ts b/desktop/tests/e2e/sidebar.spec.ts
index f5ec16c537..575c237d35 100644
--- a/desktop/tests/e2e/sidebar.spec.ts
+++ b/desktop/tests/e2e/sidebar.spec.ts
@@ -57,6 +57,60 @@ async function dragSidebarRail(page: Page, deltaX: number) {
   await page.mouse.up();
 }
 
+test("automatically shows relay join requirements near the relay URL", async ({
+  page,
+}) => {
+  await page.route(
+    "https://policy.example.com/api/join-policy",
+    async (route) => {
+      await route.fulfill({
+        status: 200,
+        contentType: "application/json",
+        body: JSON.stringify({
+          policy: {
+            terms_markdown: "# Terms",
+            privacy_markdown: "# Privacy",
+            age_attestation_required: true,
+            version: "policy-v1",
+          },
+        }),
+      });
+    },
+  );
+  await page.goto("/");
+
+  await page.getByTestId("sidebar-profile-card").click();
+  await page.getByText("Add Community", { exact: true }).click();
+  await page.getByLabel("Relay URL").fill("wss://policy.example.com");
+
+  const ageConfirmation = page.getByLabel("I am 18 years of age or older.");
+  const agreementConfirmation = page.getByLabel(
+    "I agree to the Buzz Terms of Service and Privacy Policy.",
+  );
+  await expect(ageConfirmation).toBeVisible();
+  await expect(agreementConfirmation).toBeVisible();
+  await expect(
+    page.getByText("Review this relay's join policy below."),
+  ).toHaveCount(0);
+  await expect(page.getByText(/By continuing, you agree/)).toHaveCount(0);
+
+  const addCommunityButton = page.getByRole("button", {
+    name: "Add Community",
+  });
+  await expect(addCommunityButton).toBeDisabled();
+  await ageConfirmation.check();
+  await expect(ageConfirmation.locator("svg path")).toBeVisible();
+  await expect(addCommunityButton).toBeDisabled();
+  await agreementConfirmation.check();
+  await expect(addCommunityButton).toBeEnabled();
+
+  const consentBox = await agreementConfirmation.boundingBox();
+  const reposInput = await page.locator("#ws-repos-dir").boundingBox();
+  const addButtonBox = await addCommunityButton.boundingBox();
+  expect(consentBox?.y).toBeGreaterThan(reposInput?.y ?? Number.MAX_VALUE);
+  expect(consentBox?.y).toBeLessThan(addButtonBox?.y ?? 0);
+});
+
 test("leaving a channel from the context menu never freezes the app", async ({
   page,
 }) => {
diff --git a/web/src/features/invite/ui/InviteJoinPolicyNotice.tsx b/web/src/features/invite/ui/InviteJoinPolicyNotice.tsx
new file mode 100644
index 0000000000..585de0b898
--- /dev/null
+++ b/web/src/features/invite/ui/InviteJoinPolicyNotice.tsx
@@ -0,0 +1,161 @@
+import * as React from "react";
+
+type InviteJoinPolicy = {
+  terms_markdown?: string;
+  privacy_markdown?: string;
+  age_attestation_required: boolean;
+};
+
+type PolicyCheckboxProps = {
+  accessibleLabel: string;
+  checked: boolean;
+  children: React.ReactNode;
+  onCheckedChange: (checked: boolean) => void;
+};
+
+function PolicyCheckbox({
+  accessibleLabel,
+  checked,
+  children,
+  onCheckedChange,
+}: PolicyCheckboxProps) {
+  const id = React.useId();
+
+  return (
+    <div className="relative">
+      <input
+        aria-label={accessibleLabel}
+        checked={checked}
+        className="peer sr-only"
+        id={id}
+        onChange={(event) => onCheckedChange(event.target.checked)}
+        type="checkbox"
+      />
+      <label
+        className="flex cursor-pointer items-start gap-3 rounded-sm text-left text-xs leading-5 text-black/60 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-black"
+        htmlFor={id}
+      >
+        <span
+          aria-hidden="true"
+          className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-[background-color,border-color] duration-150 motion-reduce:transition-none ${
+            checked ? "border-black bg-black" : "border-black/40 bg-white"
+          }`}
+        >
+          <svg
+            aria-hidden="true"
+            className="h-3 w-3 text-white"
+            fill="none"
+            viewBox="0 0 16 16"
+          >
+            <path
+              className={
+                checked
+                  ? "transition-[stroke-dashoffset] duration-[180ms] [transition-timing-function:cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none"
+                  : "transition-none"
+              }
+              d="M3 8.25 6.25 11.5 13 4.75"
+              pathLength="16"
+              stroke="currentColor"
+              strokeDasharray="16"
+              strokeDashoffset={checked ? 0 : 16}
+              strokeLinecap="round"
+              strokeLinejoin="round"
+              strokeWidth="2"
+            />
+          </svg>
+        </span>
+        <span>{children}</span>
+      </label>
+    </div>
+  );
+}
+
+/** The invite page's age and legal confirmations. */
+export function InviteJoinPolicyNotice({
+  ageConfirmed,
+  agreementConfirmed,
+  onAgeConfirmedChange,
+  onAgreementConfirmedChange,
+  onShowDocument,
+  policy,
+}: {
+  ageConfirmed: boolean;
+  agreementConfirmed: boolean;
+  onAgeConfirmedChange: (checked: boolean) => void;
+  onAgreementConfirmedChange: (checked: boolean) => void;
+  onShowDocument: (title: string, markdown: string) => void;
+  policy: InviteJoinPolicy;
+}) {
+  const stopLabelActivation = (
+    event: React.MouseEvent<HTMLButtonElement>,
+    title: string,
+    markdown: string,
+  ) => {
+    event.preventDefault();
+    event.stopPropagation();
+    onShowDocument(title, markdown);
+  };
+
+  return (
+    <div
+      className="w-full space-y-3 rounded-xl border border-black/10 bg-black/[0.03] p-4 text-left"
+      data-testid="invite-join-policy-notice"
+    >
+      {policy.age_attestation_required ? (
+        <PolicyCheckbox
+          accessibleLabel="I am 18 years of age or older."
+          checked={ageConfirmed}
+          onCheckedChange={onAgeConfirmedChange}
+        >
+          I am 18 years of age or older.
+        </PolicyCheckbox>
+      ) : null}
+
+      {policy.terms_markdown || policy.privacy_markdown ? (
+        <PolicyCheckbox
+          accessibleLabel="I agree to the Buzz Terms of Service and Privacy Policy."
+          checked={agreementConfirmed}
+          onCheckedChange={onAgreementConfirmedChange}
+        >
+          I agree to the Buzz{" "}
+          {policy.terms_markdown ? (
+            <button
+              className="text-black no-underline underline-offset-4 hover:text-black/70 hover:underline focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-black"
+              type="button"
+              onClick={(event) =>
+                stopLabelActivation(
+                  event,
+                  "Terms of Service",
+                  policy.terms_markdown ?? "",
+                )
+              }
+            >
+              Terms of Service
+            </button>
+          ) : (
+            "Terms of Service"
+          )}{" "}
+          and{" "}
+          {policy.privacy_markdown ? (
+            <button
+              className="text-black no-underline underline-offset-4 hover:text-black/70 hover:underline focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-black"
+              type="button"
+              onClick={(event) =>
+                stopLabelActivation(
+                  event,
+                  "Privacy Policy",
+                  policy.privacy_markdown ?? "",
+                )
+              }
+            >
+              Privacy Policy
+            </button>
+          ) : (
+            "Privacy Policy"
+          )}
+          .
+        </PolicyCheckbox>
+      ) : null}
+    </div>
+  );
+}
diff --git a/web/src/features/invite/ui/InvitePage.tsx b/web/src/features/invite/ui/InvitePage.tsx
index 72a1d11c7e..7594d851b5 100644
--- a/web/src/features/invite/ui/InvitePage.tsx
+++ b/web/src/features/invite/ui/InvitePage.tsx
@@ -5,6 +5,8 @@ import * as React from "react";
 import Markdown from "react-markdown";
 import remarkGfm from "remark-gfm";
 
+import { InviteJoinPolicyNotice } from "./InviteJoinPolicyNotice";
+
 const DOWNLOAD_URL = "https://github.com/block/buzz/releases/latest";
 type JoinPolicy = {
   terms_markdown?: string;
@@ -24,6 +26,7 @@ export function InvitePage({ code }: { code: string }) {
   );
   const [document, setDocument] = React.useState<PolicyDocument | null>(null);
   const [ageConfirmed, setAgeConfirmed] = React.useState(false);
+  const [agreementConfirmed, setAgreementConfirmed] = React.useState(false);
   const [opening, setOpening] = React.useState(false);
 
   React.useEffect(() => {
@@ -64,7 +67,18 @@ export function InvitePage({ code }: { code: string }) {
   const disabled =
     policy === undefined ||
     opening ||
-    Boolean(policy?.age_attestation_required && !ageConfirmed);
+    Boolean(policy?.age_attestation_required && !ageConfirmed) ||
+    Boolean(
+      policy &&
+        (policy.terms_markdown || policy.privacy_markdown) &&
+        !agreementConfirmed,
+    );
+  const hasPolicyRequirements = Boolean(
+    policy &&
+      (policy.age_attestation_required ||
+        policy.terms_markdown ||
+        policy.privacy_markdown),
+  );
   const showDocument = (title: string, markdown: string) =>
     setDocument({ title, markdown });
 
@@ -88,23 +102,32 @@ export function InvitePage({ code }: { code: string }) {
           </h1>
           <p className="mt-9 font-mono text-lg text-black/70">{host}</p>
 
-          {policy?.age_attestation_required && (
-            <label className="mt-9 flex max-w-md cursor-pointer items-start gap-3 text-left text-sm text-black/70">
-              <input
-                className="mt-0.5 h-4 w-4 accent-black"
-                type="checkbox"
-                checked={ageConfirmed}
-                onChange={(event) => setAgeConfirmed(event.target.checked)}
-              />
-              <span>I am 18 years of age or older.</span>
-            </label>
-          )}
-          <div className={policy?.age_attestation_required ? "mt-5" : "mt-9"}>
+          <div
+            className={`grid w-full max-w-md overflow-hidden transition-[grid-template-rows,margin,opacity,transform] duration-[220ms] [transition-timing-function:cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none ${
+              hasPolicyRequirements
+                ? "mt-9 -mb-4 grid-rows-[1fr] opacity-100 translate-y-0"
+                : "m-0 grid-rows-[0fr] opacity-0 -translate-y-1"
+            }`}
+          >
+            <div className="min-h-0 overflow-hidden">
+              {policy && hasPolicyRequirements ? (
+                <InviteJoinPolicyNotice
+                  ageConfirmed={ageConfirmed}
+                  agreementConfirmed={agreementConfirmed}
+                  onAgeConfirmedChange={setAgeConfirmed}
+                  onAgreementConfirmedChange={setAgreementConfirmed}
+                  onShowDocument={showDocument}
+                  policy={policy}
+                />
+              ) : null}
+            </div>
+          </div>
+
+          <div className="mt-9 w-full max-w-md">
             {policy === null ? (
               <Button
                 asChild
-                className="bg-black text-white hover:bg-black/90 focus-visible:ring-black"
-                size="lg"
+                className="h-10 w-full bg-black text-white hover:bg-black/90 focus-visible:ring-black"
               >
                 <a
                   href={`buzz://join?relay=${encodeURIComponent(relay)}&code=${encodeURIComponent(code)}`}
@@ -114,8 +137,7 @@ export function InvitePage({ code }: { code: string }) {
               </Button>
             ) : (
               <Button
-                className="bg-black text-white hover:bg-black/90 focus-visible:ring-black disabled:cursor-not-allowed disabled:bg-black/30 disabled:text-white/70"
-                size="lg"
+                className="h-10 w-full bg-black text-white hover:bg-black/90 focus-visible:ring-black disabled:cursor-not-allowed disabled:bg-black/30 disabled:text-white/70"
                 disabled={disabled}
                 onClick={openInvite}
               >
@@ -123,41 +145,6 @@ export function InvitePage({ code }: { code: string }) {
               </Button>
             )}
           </div>
-          {policy && (policy.terms_markdown || policy.privacy_markdown) && (
-            <p className="mt-4 max-w-md text-xs text-black/60">
-              By proceeding you agree to the Buzz{" "}
-              {policy.terms_markdown && (
-                <button
-                  className="text-black underline-offset-4 hover:text-black/70 hover:underline focus-visible:underline"
-                  type="button"
-                  onClick={() =>
-                    showDocument(
-                      "Terms of Service",
-                      policy.terms_markdown ?? "",
-                    )
-                  }
-                >
-                  Terms of Service
-                </button>
-              )}
-              {policy.terms_markdown && policy.privacy_markdown && " and "}
-              {policy.privacy_markdown && (
-                <button
-                  className="text-black underline-offset-4 hover:text-black/70 hover:underline focus-visible:underline"
-                  type="button"
-                  onClick={() =>
-                    showDocument(
-                      "Privacy Policy",
-                      policy.privacy_markdown ?? "",
-                    )
-                  }
-                >
-                  Privacy Policy
-                </button>
-              )}
-              .
-            </p>
-          )}
         </div>
         <p className="flex h-[3.125rem] items-center justify-center rounded-2xl bg-white text-sm text-black/60">
           Don&apos;t have the app?{" "}
diff --git a/web/tests/e2e/smoke.spec.ts b/web/tests/e2e/smoke.spec.ts
index 183197dac3..3e897b12f3 100644
--- a/web/tests/e2e/smoke.spec.ts
+++ b/web/tests/e2e/smoke.spec.ts
@@ -9,3 +9,67 @@ test("home page shows repositories section", async ({ page }) => {
   await page.goto("/");
   await expect(page.getByText("Repositories")).toBeVisible();
 });
+
+test("invite requires age and legal consent before opening Buzz", async ({
+  page,
+}) => {
+  await page.route("**/api/join-policy", async (route) => {
+    await route.fulfill({
+      status: 200,
+      contentType: "application/json",
+      body: JSON.stringify({
+        policy: {
+          terms_markdown: "# Terms",
+          privacy_markdown: "# Privacy",
+          age_attestation_required: true,
+          version: "policy-v1",
+        },
+      }),
+    });
+  });
+  await page.goto("/invite/demo-code");
+
+  const ageConfirmation = page.getByLabel("I am 18 years of age or older.");
+  const agreementConfirmation = page.getByLabel(
+    "I agree to the Buzz Terms of Service and Privacy Policy.",
+  );
+  const acceptInvite = page.getByRole("button", {
+    name: "Accept invite in Buzz",
+  });
+
+  await expect(ageConfirmation).toBeVisible();
+  await expect(agreementConfirmation).toBeVisible();
+  await expect(acceptInvite).toBeDisabled();
+
+  const termsLink = page.getByRole("button", { name: "Terms of Service" });
+  const privacyLink = page.getByRole("button", { name: "Privacy Policy" });
+  await expect(termsLink).toHaveCSS("text-decoration-line", "none");
+  await expect(privacyLink).toHaveCSS("text-decoration-line", "none");
+  await termsLink.hover();
+  await expect(termsLink).toHaveCSS("text-decoration-line", "underline");
+  await page.mouse.move(0, 0);
+  await privacyLink.hover();
+  await expect(privacyLink).toHaveCSS("text-decoration-line", "underline");
+
+  await page
+    .locator("label")
+    .filter({ hasText: "I am 18 years of age or older." })
+    .click();
+  await expect(ageConfirmation).toBeChecked();
+  await expect(acceptInvite).toBeDisabled();
+  await page
+    .locator("label")
+    .filter({
+      hasText: "I agree to the Buzz Terms of Service and Privacy Policy.",
+    })
+    .click({ position: { x: 8, y: 8 } });
+  await expect(agreementConfirmation).toBeChecked();
+  await expect(acceptInvite).toBeEnabled();
+
+  const consentBox = await page
+    .getByTestId("invite-join-policy-notice")
+    .boundingBox();
+  const acceptButtonBox = await acceptInvite.boundingBox();
+  expect(consentBox?.y).toBeLessThan(acceptButtonBox?.y ?? 0);
+  expect(consentBox?.width).toBe(acceptButtonBox?.width);
+});

From 2a7d8382182817e62cc37e5115d635bb3d38ba05 Mon Sep 17 00:00:00 2001
From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Date: Thu, 16 Jul 2026 12:46:44 -0400
Subject: [PATCH 13/16] fix(db): avoid duplicate join policy migration version

Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
---
 crates/buzz-db/src/migration.rs                                 | 2 +-
 ..._policy_acceptances.sql => 0020_join_policy_acceptances.sql} | 0
 2 files changed, 1 insertion(+), 1 deletion(-)
 rename migrations/{0019_join_policy_acceptances.sql => 0020_join_policy_acceptances.sql} (100%)

diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs
index 1d8c961a29..0aa9dafbe0 100644
--- a/crates/buzz-db/src/migration.rs
+++ b/crates/buzz-db/src/migration.rs
@@ -549,7 +549,7 @@ mod tests {
         let mut migrations: Vec<_> = MIGRATOR.iter().collect();
         migrations.sort_by_key(|migration| migration.version);
 
-        assert_eq!(migrations.len(), 19);
+        assert_eq!(migrations.len(), 20);
         assert_eq!(migrations[0].version, 1);
         assert_eq!(&*migrations[0].description, "initial schema");
         assert!(migrations[0]
diff --git a/migrations/0019_join_policy_acceptances.sql b/migrations/0020_join_policy_acceptances.sql
similarity index 100%
rename from migrations/0019_join_policy_acceptances.sql
rename to migrations/0020_join_policy_acceptances.sql

From 903a502cbe6e9686e823fbd2497604a33a288e01 Mon Sep 17 00:00:00 2001
From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Date: Thu, 16 Jul 2026 12:48:06 -0400
Subject: [PATCH 14/16] fix(desktop): show add-community policy errors

Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
---
 desktop/src/features/communities/ui/AddCommunityDialog.tsx | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/desktop/src/features/communities/ui/AddCommunityDialog.tsx b/desktop/src/features/communities/ui/AddCommunityDialog.tsx
index de73234587..00986205f2 100644
--- a/desktop/src/features/communities/ui/AddCommunityDialog.tsx
+++ b/desktop/src/features/communities/ui/AddCommunityDialog.tsx
@@ -309,6 +309,9 @@ export function AddCommunityDialog({
             Communities share your active identity. To use a different key,
             import it on the profile step (or in settings).
           </p>
+          {inviteError ? (
+            <p className="text-xs text-destructive">{inviteError}</p>
+          ) : null}
           <AnimatePresence initial={false}>
             {joinPolicy && relayUrl.trim() ? (
               <motion.div

From b7eecaae3427ce8282d3b7d2ec8702b03e0a2f7d Mon Sep 17 00:00:00 2001
From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Date: Thu, 16 Jul 2026 12:49:46 -0400
Subject: [PATCH 15/16] fix(desktop): preserve policy consent through
 onboarding

Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
---
 .../communities/ui/CommunityEditForm.tsx      | 124 +++++++++++++--
 .../onboarding/communityOnboarding.tsx        |   9 ++
 .../onboarding/ui/InviteRedeemForm.tsx        | 121 +++++++++++++--
 .../onboarding/ui/JoinPolicyNotice.tsx        | 143 ++++++++++--------
 .../src/features/onboarding/useClaimInvite.ts |  20 ++-
 desktop/src/shared/api/invites.ts             |  24 +++
 6 files changed, 352 insertions(+), 89 deletions(-)

diff --git a/desktop/src/features/communities/ui/CommunityEditForm.tsx b/desktop/src/features/communities/ui/CommunityEditForm.tsx
index 7365ebf2b0..4c94acf102 100644
--- a/desktop/src/features/communities/ui/CommunityEditForm.tsx
+++ b/desktop/src/features/communities/ui/CommunityEditForm.tsx
@@ -1,12 +1,20 @@
 import * as React from "react";
+import { AnimatePresence, motion, useReducedMotion } from "motion/react";
 import { inviteErrorMessage } from "@/shared/api/inviteHelpers";
-import { getJoinPolicy, type JoinPolicy } from "@/shared/api/invites";
+import {
+  getJoinPolicy,
+  isJoinPolicyDiscoveryCandidate,
+  type JoinPolicy,
+} from "@/shared/api/invites";
 import { Button } from "@/shared/ui/button";
 import { Input } from "@/shared/ui/input";
 import { Spinner } from "@/shared/ui/spinner";
 import { JoinPolicyNotice } from "@/features/onboarding/ui/JoinPolicyNotice";
 import { normalizeRelayUrl, probeRelayReachable } from "../relayProbe";
 
+const POLICY_DISCOVERY_DELAY_MS = 250;
+const POLICY_REVEAL_EASE = [0.23, 1, 0.32, 1] as const;
+
 export type CommunityEditFormProps = {
   cancelLabel?: string;
   initialName: string;
@@ -39,6 +47,8 @@ export function CommunityEditForm({
     null,
   );
   const [ageConfirmed, setAgeConfirmed] = React.useState(false);
+  const [agreementConfirmed, setAgreementConfirmed] = React.useState(false);
+  const shouldReduceMotion = useReducedMotion();
 
   const cancelRef = React.useRef<(() => void) | null>(null);
 
@@ -48,6 +58,37 @@ export function CommunityEditForm({
     };
   }, []);
 
+  React.useEffect(() => {
+    if (!joinPolicyRequired) return;
+
+    const normalizedUrl = normalizeRelayUrl(relayUrl);
+    if (!normalizedUrl || !isJoinPolicyDiscoveryCandidate(normalizedUrl)) {
+      return;
+    }
+
+    let cancelled = false;
+    const timeoutId = window.setTimeout(() => {
+      void getJoinPolicy(normalizedUrl)
+        .then((policy) => {
+          if (cancelled || !policy) return;
+          setJoinPolicy(policy);
+          setPolicyRelayUrl(normalizedUrl);
+          setAgeConfirmed(false);
+          setAgreementConfirmed(false);
+          setError(null);
+        })
+        .catch(() => {
+          // Background discovery is best-effort. A deliberate submit retries
+          // the request and surfaces any relay error to the user.
+        });
+    }, POLICY_DISCOVERY_DELAY_MS);
+
+    return () => {
+      cancelled = true;
+      window.clearTimeout(timeoutId);
+    };
+  }, [joinPolicyRequired, relayUrl]);
+
   const handleSubmit = React.useCallback(
     async (event: React.FormEvent<HTMLFormElement>) => {
       event.preventDefault();
@@ -77,12 +118,20 @@ export function CommunityEditForm({
             setJoinPolicy(policy);
             setPolicyRelayUrl(normalizedUrl);
             setAgeConfirmed(false);
+            setAgreementConfirmed(false);
             return;
           }
           if (policy.ageAttestationRequired && !ageConfirmed) {
             setError("Confirm that you are at least 18 years old.");
             return;
           }
+          if (
+            (policy.termsMarkdown || policy.privacyMarkdown) &&
+            !agreementConfirmed
+          ) {
+            setError("Agree to the Terms of Service and Privacy Policy.");
+            return;
+          }
         } catch (policyError) {
           setError(inviteErrorMessage(policyError));
           return;
@@ -118,6 +167,7 @@ export function CommunityEditForm({
     },
     [
       ageConfirmed,
+      agreementConfirmed,
       joinPolicy,
       joinPolicyRequired,
       name,
@@ -188,6 +238,7 @@ export function CommunityEditForm({
             setJoinPolicy(null);
             setPolicyRelayUrl(null);
             setAgeConfirmed(false);
+            setAgreementConfirmed(false);
           }}
           placeholder="wss://relay.example.com"
           type="text"
@@ -195,16 +246,60 @@ export function CommunityEditForm({
         />
       </div>
 
-      {joinPolicy ? (
-        <JoinPolicyNotice
-          ageConfirmed={ageConfirmed}
-          onAgeConfirmedChange={(confirmed) => {
-            setAgeConfirmed(confirmed);
-            setError(null);
-          }}
-          policy={joinPolicy}
-        />
-      ) : null}
+      <AnimatePresence initial={false}>
+        {joinPolicy && policyRelayUrl ? (
+          <motion.div
+            animate={{
+              height: "auto",
+              marginTop: 0,
+              opacity: 1,
+              transform: "translateY(0rem)",
+            }}
+            className="overflow-hidden"
+            exit={
+              shouldReduceMotion
+                ? { height: 0, marginTop: "-1rem", opacity: 0 }
+                : {
+                    height: 0,
+                    marginTop: "-1rem",
+                    opacity: 0,
+                    transform: "translateY(-0.25rem)",
+                  }
+            }
+            initial={
+              shouldReduceMotion
+                ? false
+                : {
+                    height: 0,
+                    marginTop: "-1rem",
+                    opacity: 0,
+                    transform: "translateY(-0.25rem)",
+                  }
+            }
+            key={`${policyRelayUrl}:${joinPolicy.version}`}
+            transition={
+              shouldReduceMotion
+                ? { duration: 0 }
+                : { duration: 0.22, ease: POLICY_REVEAL_EASE }
+            }
+          >
+            <JoinPolicyNotice
+              ageConfirmed={ageConfirmed}
+              agreementConfirmed={agreementConfirmed}
+              onAgeConfirmedChange={(confirmed) => {
+                setAgeConfirmed(confirmed);
+                setError(null);
+              }}
+              onAgreementConfirmedChange={(confirmed) => {
+                setAgreementConfirmed(confirmed);
+                setError(null);
+              }}
+              policy={joinPolicy}
+              relayWsUrl={policyRelayUrl}
+            />
+          </motion.div>
+        ) : null}
+      </AnimatePresence>
 
       <div className="flex w-full flex-col gap-3 pt-1">
         <Button
@@ -213,7 +308,12 @@ export function CommunityEditForm({
             isBusy ||
             !name.trim() ||
             !relayUrl.trim() ||
-            Boolean(joinPolicy?.ageAttestationRequired && !ageConfirmed)
+            Boolean(joinPolicy?.ageAttestationRequired && !ageConfirmed) ||
+            Boolean(
+              joinPolicy &&
+                (joinPolicy.termsMarkdown || joinPolicy.privacyMarkdown) &&
+                !agreementConfirmed,
+            )
           }
           type="submit"
         >
diff --git a/desktop/src/features/onboarding/communityOnboarding.tsx b/desktop/src/features/onboarding/communityOnboarding.tsx
index 731a2d5161..dbe09cb8f6 100644
--- a/desktop/src/features/onboarding/communityOnboarding.tsx
+++ b/desktop/src/features/onboarding/communityOnboarding.tsx
@@ -29,6 +29,12 @@ export type CommunityOnboardingTransaction = {
   communityName: string;
   token?: string;
   reposDir?: string;
+  /**
+   * Join-policy acceptance receipt minted before the claim (bound to the
+   * invite code). Forwarded to `claimInvite` so relays with a configured
+   * join policy admit the claim.
+   */
+  policyReceipt?: string;
   communityId?: string;
   createdAt: string;
   updatedAt: string;
@@ -52,6 +58,7 @@ export type StartCommunityOnboardingInput = {
   communityName?: string;
   token?: string;
   reposDir?: string;
+  policyReceipt?: string;
 };
 
 function canonicalRelayUrl(rawRelayUrl: string) {
@@ -127,6 +134,7 @@ export function startCommunityOnboarding(
       communityName: input.communityName?.trim() || existing.communityName,
       token: input.token?.trim() || existing.token,
       reposDir: input.reposDir ?? existing.reposDir,
+      policyReceipt: input.policyReceipt ?? existing.policyReceipt,
       updatedAt: now.toISOString(),
       error: undefined,
       // A freshly opened link deserves fresh feedback — re-present the gate
@@ -147,6 +155,7 @@ export function startCommunityOnboarding(
     communityName: input.communityName?.trim() || deriveCommunityName(relayUrl),
     token: input.token?.trim() || undefined,
     reposDir: input.reposDir,
+    policyReceipt: input.policyReceipt,
     createdAt: timestamp,
     updatedAt: timestamp,
   };
diff --git a/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx b/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx
index e6541eafce..fc9e9cde7d 100644
--- a/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx
+++ b/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx
@@ -1,4 +1,5 @@
 import * as React from "react";
+import { AnimatePresence, motion, useReducedMotion } from "motion/react";
 
 import {
   inviteErrorMessage,
@@ -7,6 +8,7 @@ import {
 import {
   acceptJoinPolicy,
   getJoinPolicy,
+  isJoinPolicyDiscoveryCandidate,
   type JoinPolicy,
 } from "@/shared/api/invites";
 import { Button } from "@/shared/ui/button";
@@ -14,6 +16,9 @@ import { Input } from "@/shared/ui/input";
 import { Spinner } from "@/shared/ui/spinner";
 import { JoinPolicyNotice } from "./JoinPolicyNotice";
 
+const POLICY_DISCOVERY_DELAY_MS = 250;
+const POLICY_REVEAL_EASE = [0.23, 1, 0.32, 1] as const;
+
 type InviteRedeemFormProps = {
   /**
    * Pre-fill and expose a relay URL field for bare-code inputs.
@@ -45,8 +50,10 @@ export function InviteRedeemForm({
     code: string;
   } | null>(null);
   const [ageConfirmed, setAgeConfirmed] = React.useState(false);
+  const [agreementConfirmed, setAgreementConfirmed] = React.useState(false);
   const [policyError, setPolicyError] = React.useState<string | null>(null);
   const [isLoadingPolicy, setIsLoadingPolicy] = React.useState(false);
+  const shouldReduceMotion = useReducedMotion();
 
   const parsed = React.useMemo(
     () => parseInviteInput(inviteInput),
@@ -55,6 +62,36 @@ export function InviteRedeemForm({
   const isBareCode = parsed !== null && !("relayWsUrl" in parsed);
   const needsRelayField = isBareCode && defaultRelayUrl !== undefined;
 
+  React.useEffect(() => {
+    if (!parsed) return;
+
+    const relayWsUrl =
+      "relayWsUrl" in parsed ? parsed.relayWsUrl : bareCodeRelayUrl.trim();
+    if (!relayWsUrl || !isJoinPolicyDiscoveryCandidate(relayWsUrl)) return;
+
+    let cancelled = false;
+    const timeoutId = window.setTimeout(() => {
+      void getJoinPolicy(relayWsUrl)
+        .then((policy) => {
+          if (cancelled || !policy) return;
+          setJoinPolicy(policy);
+          setPolicyInvite({ relayWsUrl, code: parsed.code });
+          setAgeConfirmed(false);
+          setAgreementConfirmed(false);
+          setPolicyError(null);
+        })
+        .catch(() => {
+          // Background discovery is best-effort. A deliberate submit retries
+          // the request and surfaces any relay error to the user.
+        });
+    }, POLICY_DISCOVERY_DELAY_MS);
+
+    return () => {
+      cancelled = true;
+      window.clearTimeout(timeoutId);
+    };
+  }, [bareCodeRelayUrl, parsed]);
+
   const canSubmit =
     parsed !== null &&
     ("relayWsUrl" in parsed ||
@@ -87,6 +124,7 @@ export function InviteRedeemForm({
           setJoinPolicy(policy);
           setPolicyInvite({ relayWsUrl, code: parsed.code });
           setAgeConfirmed(false);
+          setAgreementConfirmed(false);
           return;
         }
 
@@ -94,6 +132,13 @@ export function InviteRedeemForm({
           setPolicyError("Confirm that you are at least 18 years old.");
           return;
         }
+        if (
+          (policy.termsMarkdown || policy.privacyMarkdown) &&
+          !agreementConfirmed
+        ) {
+          setPolicyError("Agree to the Terms of Service and Privacy Policy.");
+          return;
+        }
 
         const receipt = await acceptJoinPolicy(
           relayWsUrl,
@@ -110,6 +155,7 @@ export function InviteRedeemForm({
     },
     [
       ageConfirmed,
+      agreementConfirmed,
       bareCodeRelayUrl,
       joinPolicy,
       onRedeem,
@@ -140,6 +186,7 @@ export function InviteRedeemForm({
             setJoinPolicy(null);
             setPolicyInvite(null);
             setAgeConfirmed(false);
+            setAgreementConfirmed(false);
             setPolicyError(null);
           }}
           placeholder="https://relay.example.com/invite/abc123 or paste a code"
@@ -166,6 +213,7 @@ export function InviteRedeemForm({
               setJoinPolicy(null);
               setPolicyInvite(null);
               setAgeConfirmed(false);
+              setAgreementConfirmed(false);
               setPolicyError(null);
             }}
             placeholder="wss://relay.example.com"
@@ -175,17 +223,6 @@ export function InviteRedeemForm({
         </div>
       ) : null}
 
-      {joinPolicy ? (
-        <JoinPolicyNotice
-          ageConfirmed={ageConfirmed}
-          onAgeConfirmedChange={(confirmed) => {
-            setAgeConfirmed(confirmed);
-            setPolicyError(null);
-          }}
-          policy={joinPolicy}
-        />
-      ) : null}
-
       {policyError ? (
         <p className="text-center text-sm text-destructive">{policyError}</p>
       ) : null}
@@ -194,6 +231,61 @@ export function InviteRedeemForm({
         <p className="text-center text-sm text-destructive">{error}</p>
       ) : null}
 
+      <AnimatePresence initial={false}>
+        {joinPolicy && policyInvite ? (
+          <motion.div
+            animate={{
+              height: "auto",
+              marginTop: 0,
+              opacity: 1,
+              transform: "translateY(0rem)",
+            }}
+            className="overflow-hidden"
+            exit={
+              shouldReduceMotion
+                ? { height: 0, marginTop: "-0.75rem", opacity: 0 }
+                : {
+                    height: 0,
+                    marginTop: "-0.75rem",
+                    opacity: 0,
+                    transform: "translateY(-0.25rem)",
+                  }
+            }
+            initial={
+              shouldReduceMotion
+                ? false
+                : {
+                    height: 0,
+                    marginTop: "-0.75rem",
+                    opacity: 0,
+                    transform: "translateY(-0.25rem)",
+                  }
+            }
+            key={`${policyInvite.relayWsUrl}:${joinPolicy.version}`}
+            transition={
+              shouldReduceMotion
+                ? { duration: 0 }
+                : { duration: 0.22, ease: POLICY_REVEAL_EASE }
+            }
+          >
+            <JoinPolicyNotice
+              ageConfirmed={ageConfirmed}
+              agreementConfirmed={agreementConfirmed}
+              onAgeConfirmedChange={(confirmed) => {
+                setAgeConfirmed(confirmed);
+                setPolicyError(null);
+              }}
+              onAgreementConfirmedChange={(confirmed) => {
+                setAgreementConfirmed(confirmed);
+                setPolicyError(null);
+              }}
+              policy={joinPolicy}
+              relayWsUrl={policyInvite.relayWsUrl}
+            />
+          </motion.div>
+        ) : null}
+      </AnimatePresence>
+
       <Button
         className="h-10 w-full"
         data-testid="invite-redeem-submit"
@@ -201,7 +293,12 @@ export function InviteRedeemForm({
           !canSubmit ||
           isRedeeming ||
           isLoadingPolicy ||
-          Boolean(joinPolicy?.ageAttestationRequired && !ageConfirmed)
+          Boolean(joinPolicy?.ageAttestationRequired && !ageConfirmed) ||
+          Boolean(
+            joinPolicy &&
+              (joinPolicy.termsMarkdown || joinPolicy.privacyMarkdown) &&
+              !agreementConfirmed,
+          )
         }
         type="submit"
       >
diff --git a/desktop/src/features/onboarding/ui/JoinPolicyNotice.tsx b/desktop/src/features/onboarding/ui/JoinPolicyNotice.tsx
index 3cbfb18e73..4d5e6771b6 100644
--- a/desktop/src/features/onboarding/ui/JoinPolicyNotice.tsx
+++ b/desktop/src/features/onboarding/ui/JoinPolicyNotice.tsx
@@ -1,89 +1,108 @@
 import * as React from "react";
+import { openUrl } from "@tauri-apps/plugin-opener";
 
-import type { JoinPolicy } from "@/shared/api/invites";
+import { joinPolicyDocumentUrl, type JoinPolicy } from "@/shared/api/invites";
 import { Button } from "@/shared/ui/button";
-import {
-  Dialog,
-  DialogContent,
-  DialogHeader,
-  DialogTitle,
-} from "@/shared/ui/dialog";
-import { Markdown } from "@/shared/ui/markdown";
+import { Checkbox } from "@/shared/ui/checkbox";
 
 type JoinPolicyNoticeProps = {
   ageConfirmed: boolean;
+  agreementConfirmed: boolean;
   onAgeConfirmedChange: (confirmed: boolean) => void;
+  onAgreementConfirmedChange: (confirmed: boolean) => void;
   policy: JoinPolicy;
+  /** Relay hosting the policy documents the links below point at. */
+  relayWsUrl: string;
 };
 
+/**
+ * Join-policy consent block shown on every join surface.
+ *
+ * The Terms/Privacy links open the relay-hosted document pages
+ * (`/api/join-policy/terms|privacy`) in the system browser via the OS
+ * opener. They must NOT navigate or render in-app: these surfaces exist
+ * before onboarding completes, where the router (required by the message
+ * Markdown component) is not mounted — an in-app render tears down the
+ * whole React tree.
+ */
 export function JoinPolicyNotice({
   ageConfirmed,
+  agreementConfirmed,
   onAgeConfirmedChange,
+  onAgreementConfirmedChange,
   policy,
+  relayWsUrl,
 }: JoinPolicyNoticeProps) {
-  const [openDocument, setOpenDocument] = React.useState<
-    "terms" | "privacy" | null
-  >(null);
-  const markdown =
-    openDocument === "terms" ? policy.termsMarkdown : policy.privacyMarkdown;
+  const ageConfirmationId = React.useId();
+  const agreementConfirmationId = React.useId();
 
   return (
-    <div className="space-y-3 rounded-md border p-3 text-left text-sm">
+    <div className="space-y-3 rounded-xl border border-border/70 bg-muted/30 p-4 text-left">
       {policy.ageAttestationRequired ? (
-        <label className="flex items-start gap-2">
-          <input
+        <div className="flex items-start gap-3">
+          <Checkbox
             checked={ageConfirmed}
-            className="mt-1"
-            onChange={(event) => onAgeConfirmedChange(event.target.checked)}
-            type="checkbox"
+            className="mt-0.5"
+            id={ageConfirmationId}
+            onCheckedChange={(checked) =>
+              onAgeConfirmedChange(checked === true)
+            }
           />
-          <span>I am 18 years of age or older.</span>
-        </label>
+          <label
+            className="cursor-pointer text-xs leading-5 text-muted-foreground"
+            htmlFor={ageConfirmationId}
+          >
+            I am 18 years of age or older.
+          </label>
+        </div>
       ) : null}
 
       {policy.termsMarkdown || policy.privacyMarkdown ? (
-        <p className="text-muted-foreground">
-          By proceeding you agree to the Buzz{" "}
-          {policy.termsMarkdown ? (
-            <Button
-              className="h-auto p-0 align-baseline"
-              onClick={() => setOpenDocument("terms")}
-              type="button"
-              variant="link"
-            >
-              Terms of Service
-            </Button>
-          ) : null}
-          {policy.termsMarkdown && policy.privacyMarkdown ? " and " : null}
-          {policy.privacyMarkdown ? (
-            <Button
-              className="h-auto p-0 align-baseline"
-              onClick={() => setOpenDocument("privacy")}
-              type="button"
-              variant="link"
-            >
-              Privacy Policy
-            </Button>
-          ) : null}
-          .
-        </p>
+        <div className="flex items-start gap-3">
+          <Checkbox
+            checked={agreementConfirmed}
+            className="mt-0.5"
+            id={agreementConfirmationId}
+            onCheckedChange={(checked) =>
+              onAgreementConfirmedChange(checked === true)
+            }
+          />
+          <label
+            className="cursor-pointer text-xs leading-5 text-muted-foreground"
+            htmlFor={agreementConfirmationId}
+          >
+            I agree to the Buzz{" "}
+            {policy.termsMarkdown ? (
+              <Button
+                className="h-auto p-0 align-baseline text-xs no-underline hover:underline focus-visible:no-underline"
+                onClick={(event) => {
+                  event.preventDefault();
+                  void openUrl(joinPolicyDocumentUrl(relayWsUrl, "terms"));
+                }}
+                type="button"
+                variant="link"
+              >
+                Terms of Service
+              </Button>
+            ) : null}
+            {policy.termsMarkdown && policy.privacyMarkdown ? " and " : null}
+            {policy.privacyMarkdown ? (
+              <Button
+                className="h-auto p-0 align-baseline text-xs no-underline hover:underline focus-visible:no-underline"
+                onClick={(event) => {
+                  event.preventDefault();
+                  void openUrl(joinPolicyDocumentUrl(relayWsUrl, "privacy"));
+                }}
+                type="button"
+                variant="link"
+              >
+                Privacy Policy
+              </Button>
+            ) : null}
+            .
+          </label>
+        </div>
       ) : null}
-
-      <Dialog
-        onOpenChange={(open) => {
-          if (!open) setOpenDocument(null);
-        }}
-        open={openDocument !== null}
-      >
-        <DialogContent className="max-h-[80vh] max-w-2xl overflow-y-auto">
-          <DialogHeader>
-            <DialogTitle>
-              {openDocument === "terms" ? "Terms of Service" : "Privacy Policy"}
-            </DialogTitle>
-          </DialogHeader>
-          {markdown ? <Markdown content={markdown} /> : null}
-        </DialogContent>
-      </Dialog>
     </div>
   );
 }
diff --git a/desktop/src/features/onboarding/useClaimInvite.ts b/desktop/src/features/onboarding/useClaimInvite.ts
index 830f9412fc..56ca185a26 100644
--- a/desktop/src/features/onboarding/useClaimInvite.ts
+++ b/desktop/src/features/onboarding/useClaimInvite.ts
@@ -1,7 +1,10 @@
 import * as React from "react";
 
 import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding";
-import { inviteErrorMessage } from "@/shared/api/inviteHelpers";
+import {
+  inviteErrorMessage,
+  isInviteExpiredError,
+} from "@/shared/api/inviteHelpers";
 import { claimInvite } from "@/shared/api/invites";
 
 /**
@@ -23,12 +26,23 @@ export function useClaimInvite() {
       return;
     }
     setIsPending(true);
-    void claimInvite(transaction.relayUrl, transaction.inviteCode ?? "")
+    void claimInvite(
+      transaction.relayUrl,
+      transaction.inviteCode ?? "",
+      transaction.policyReceipt,
+    )
       .then(() => {
         update({ stage: "connecting", error: undefined }, transaction.id);
       })
       .catch((error: unknown) =>
-        update({ error: inviteErrorMessage(error) }, transaction.id),
+        update(
+          {
+            error: isInviteExpiredError(error)
+              ? "This invite code has expired — ask for a new one."
+              : inviteErrorMessage(error),
+          },
+          transaction.id,
+        ),
       )
       .finally(() => setIsPending(false));
   }, [isPending, transaction, update]);
diff --git a/desktop/src/shared/api/invites.ts b/desktop/src/shared/api/invites.ts
index f964146260..ae03c71ada 100644
--- a/desktop/src/shared/api/invites.ts
+++ b/desktop/src/shared/api/invites.ts
@@ -97,6 +97,30 @@ async function invitePost<T>(
   return json as T;
 }
 
+/** Absolute URL of a relay-hosted policy document page (system-browser target). */
+export function joinPolicyDocumentUrl(
+  relayWsUrl: string,
+  document: "terms" | "privacy",
+): string {
+  const base = relayHttpFromWs(relayWsUrl);
+  return `${base.replace(/\/+$/, "")}/api/join-policy/${document}`;
+}
+
+/** Whether a normalized relay URL is complete enough for background policy discovery. */
+export function isJoinPolicyDiscoveryCandidate(relayWsUrl: string): boolean {
+  try {
+    const url = new URL(relayWsUrl);
+    if (url.protocol !== "ws:" && url.protocol !== "wss:") return false;
+    return (
+      url.hostname === "localhost" ||
+      url.hostname === "127.0.0.1" ||
+      url.hostname.includes(".")
+    );
+  } catch {
+    return false;
+  }
+}
+
 /** Fetch relay-hosted policy content for any join surface. */
 export async function getJoinPolicy(
   relayWsUrl: string,

From 2b7d26442836c710dac82ee133fd264780affb8c Mon Sep 17 00:00:00 2001
From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Date: Thu, 16 Jul 2026 13:41:55 -0400
Subject: [PATCH 16/16] test(desktop): accept join policy in relay switch flow

Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
---
 desktop/tests/e2e/onboarding.spec.ts | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts
index 605ffd4b0a..feddb43121 100644
--- a/desktop/tests/e2e/onboarding.spec.ts
+++ b/desktop/tests/e2e/onboarding.spec.ts
@@ -1750,6 +1750,9 @@ test("denied on relay A then paste relay B invite URL switches community to B",
   await page.getByTestId("invite-redeem-submit").click();
   await expect(page.getByText("I am 18 years of age or older.")).toBeVisible();
   await page.getByLabel("I am 18 years of age or older.").check();
+  await page
+    .getByLabel("I agree to the Buzz Terms of Service and Privacy Policy.")
+    .check();
   await page.getByTestId("invite-redeem-submit").click();
 
   // After successful claim, relay B is added and becomes active; relay A remains
